xtr-security-oauth2-server
An OAuth 2.0 authorization server and resource-server authenticator for xtr security.
Why?
The security family authenticates a request that already carries a credential, and xtr-security-jwt signs tokens for an application's own users — neither issues a token to a third party. This package is that half: an authorization server an application hosts, which registers clients, runs the OAuth 2.0 grants against them, issues access and refresh tokens, and a firewall authenticator that accepts those tokens back on a protected route.
It is the bundle package over xtr-security-oauth2-server-core, the protocol library, and ships everything around it an application would otherwise write:
- 🔑 The flows — client credentials, authorization code with proof key (required of a client holding no secret), refresh token with rotation, and the device flow; password and implicit ship off.
- 🌐 The endpoints —
/authorize,/token,/device-code,/revoke(RFC 7009),/introspect(RFC 7662), the RFC 8414 description and the published key set, added bysetup(app, kernel)and arranged in a routes file. - 🔥 A firewall key
oauth2—OAuth2AuthenticatorConfig()on a firewall accepts the access tokens (RFC 9068) this server signs; their scopes become roles andFirewall.scoped()checks. - 🗄️ Stores — in memory, in a database through xtr-orm (the mapping ships, the revision is the application's), or the application's own.
- 🗣️ Events — consent, scopes, users, extra claims and the token answer are each a question an application answers in a listener.
- 🛠️ Commands — mint the signing key pair, administer clients, sweep what expired.
Install
uv add xtr-security-oauth2-server # the server, its bundle and its endpoints
uv add xtr-orm # beside it: clients and credentials in a database
uv add xtr-console # beside it: the oauth2-server:* commands
Requires Python 3.11+. It brings the protocol library, the security family, xtr-security-jwt's key layer, the password hasher, the clock, the event dispatcher and the HTTP kernel with it. xtr-orm and xtr-console are not among them: each is used when installed beside it.
Quick start
A server issuing tokens to a service (client credentials) and to a browser application acting for a person (authorization code), and an API accepting them. The person signs in with a self-issued token in a cookie, which is what xtr-security-jwt is for.
1. List the bundles and mint the keys
# app/bundles.py
from xtr_security_jwt.bundle import JwtBundle
from xtr_security_oauth2_server.bundle import OAuth2ServerBundle
BUNDLES = {OAuth2ServerBundle: {"all": True}, JwtBundle: {"all": True}}
$ oauth2-server:generate-keypair # where OAUTH2_SERVER_PRIVATE_KEY_PATH points
$ jwt:generate-keypair --kid jwt --output-dir secrets
$ python -c "import base64, secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip('='))"
The second line writes secrets/jwt.pem and its public secrets/jwt.jwks.json: point
JWT_SECRET_KEY_PATH at secrets/jwt.pem, the key the jwt configuration in step 3 signs the
person's sign-in token with.
The last line is the value of OAUTH2_SERVER_ENCRYPTION_KEY, which authorization codes and refresh
tokens are sealed with; it is a different key from the pair access tokens are signed with.
2. Say who the server is
# app/config/oauth2_server.py
from xtr_dependency_injection import configure, env
from xtr_security_oauth2_server.bundle import (
AuthorizationServerConfig,
InMemoryPersistenceConfig,
OAuth2ServerConfig,
ScopesConfig,
)
@configure
def oauth2_server() -> OAuth2ServerConfig:
return OAuth2ServerConfig(
authorization_server=AuthorizationServerConfig(
issuer="https://authorization.example.com",
private_key=env("resolve:OAUTH2_SERVER_PRIVATE_KEY_PATH"),
encryption_key=env("OAUTH2_SERVER_ENCRYPTION_KEY"),
encryption_key_type="key",
enable_device_code_grant=False,
),
scopes=ScopesConfig(
available=("invoices:read", "invoices:write"), default=("invoices:read",)
),
persistence=InMemoryPersistenceConfig(),
)
3. Write the firewalls
# app/config/security.py
from xtr_dependency_injection import configure, env
from xtr_security.bundle import AccessControlConfig, FirewallConfig, SecurityConfig
from xtr_security_jwt.bundle import (
AuthorizationHeaderExtractorConfig,
CookieExtractorConfig,
JwtAuthenticatorConfig,
JwtConfig,
JwtUserProviderConfig,
TokenExtractorsConfig,
)
from xtr_security_oauth2_server.bundle import OAuth2AuthenticatorConfig
@configure
def jwt() -> JwtConfig:
# The end user's own sign-in: a self-issued token in a cookie.
return JwtConfig(
secret_key=env("resolve:JWT_SECRET_KEY_PATH"),
token_extractors=TokenExtractorsConfig(
authorization_header=AuthorizationHeaderExtractorConfig(enabled=False),
cookie=CookieExtractorConfig(enabled=True, name="session"),
),
)
@configure
def security() -> SecurityConfig:
return SecurityConfig(
providers={"people": JwtUserProviderConfig()},
firewalls={
# 1. The person answering a consent question signs in here.
"login": FirewallConfig(
pattern=r"^/authorize",
provider="people",
authenticators=(JwtAuthenticatorConfig(),),
),
# 2. The API accepts this server's access tokens.
"api": FirewallConfig(
pattern=r"^/api",
provider="people",
authenticators=(OAuth2AuthenticatorConfig(),),
),
# 3. No firewall claims /token, /revoke, /introspect, /device-code or
# /.well-known/*: the server authenticates the client there itself.
},
access_control=(
AccessControlConfig(path=r"^/(authorize|api)", attribute="IS_AUTHENTICATED"),
),
)
4. Answer the consent question, and register the clients
# app/consent.py
from typing import Annotated
import anyio.to_thread
from xtr_dependency_injection import Injected, Target, on_boot
from xtr_event_dispatcher import as_event_listener
from xtr_password_hasher import PasswordHasherInterface
from xtr_security_oauth2_server.event.authorization_request_resolve_event import (
AuthorizationRequestResolveEvent,
)
from xtr_security_oauth2_server.manager.client_manager_interface import ClientManagerInterface
from xtr_security_oauth2_server.model.client import Client
from xtr_security_oauth2_server.oauth2_events import OAuth2Events
from xtr_security_oauth2_server.value_object.grant import Grant
from xtr_security_oauth2_server.value_object.redirect_uri import RedirectUri
from xtr_security_oauth2_server.value_object.scope import Scope
@as_event_listener(OAuth2Events.AUTHORIZATION_REQUEST_RESOLVE)
async def approve_known_clients(event: AuthorizationRequestResolveEvent) -> None:
# A real deployment shows a consent page with event.set_response(...) on the first
# pass, and approves when the form posts the original query back.
if event.get_client().get_identifier() == "dashboard":
_ = event.resolve_authorization(AuthorizationRequestResolveEvent.APPROVED)
@on_boot
async def register_demo_clients(
clients: Injected[ClientManagerInterface],
hasher: Annotated[PasswordHasherInterface, Target("oauth2_server")],
) -> None:
# In memory, clients are registered at every start; a database keeps them.
billing = Client("Billing", "billing", await anyio.to_thread.run_sync(hasher.hash, "s3cret"))
_ = billing.set_grants(Grant("client_credentials")).set_scopes(Scope("invoices:read"))
dashboard = Client("Dashboard", "dashboard", None) # public: it holds no secret
_ = dashboard.set_grants(Grant("authorization_code"), Grant("refresh_token"))
_ = dashboard.set_redirect_uris(RedirectUri("https://dashboard.example.com/callback"))
for client in (billing, dashboard):
_ = client.set_active(True)
await clients.save(client)
A request is denied until a listener approves it. With a database store, register clients with
oauth2-server:create-client instead, which prints the secret once and stores only its hash.
5. Serve it
# main.py — beside the app package, so the kernel's scan does not import it
from typing import Annotated
from fastapi import FastAPI, Response
from xtr_dependency_injection import Injected, Kernel
from xtr_http_kernel import setup
from xtr_security_core.user.in_memory_user import InMemoryUser
from xtr_security_core.user.user_interface import UserInterface
from xtr_security_http import CurrentUser, Firewall
from xtr_security_jwt import JwtTokenManagerInterface
app = FastAPI(dependencies=[Firewall()])
@app.post("/login")
async def login(
identifier: str, tokens: Injected[JwtTokenManagerInterface], response: Response
) -> None:
user = InMemoryUser(identifier, roles=["ROLE_USER"]) # the application checks the password
response.set_cookie("session", await tokens.create(user), httponly=True, secure=True)
@app.get("/api/invoices", dependencies=[Firewall("api").scoped("invoices:read")])
async def invoices(user: Annotated[UserInterface, CurrentUser()]) -> dict[str, str]:
return {"caller": user.get_user_identifier()}
kernel = Kernel("app", concurrent_scoped_access=True)
setup(app, kernel)
The unbound Firewall() on the application is what runs the firewalls in front of the endpoints
setup adds, which carry no dependency of their own: without it /authorize is reached with
nobody signed in and answers access_denied. The description and the key set stay at the fixed
paths RFC 8414 puts under the issuer; the other endpoints can move — see Endpoints.
$ curl -s -u billing:s3cret -d grant_type=client_credentials -d scope=invoices:read \
https://authorization.example.com/token
{"token_type": "Bearer", "expires_in": 3599, "access_token": "eyJ…", "scope": "invoices:read"}
$ curl -s -H "Authorization: Bearer eyJ…" https://authorization.example.com/api/invoices
{"caller": "billing"}
The dashboard sends the browser to /authorize?response_type=code&client_id=dashboard&… with an
S256 code_challenge; signed in, the person is approved by the listener and redirected back with
a code, which the dashboard redeems at /token with its code_verifier for an access token and
a refresh token.
Configure
OAuth2ServerConfig is a frozen dataclass buildable with no arguments, and the bundle builds and
boots on it — the console included, which is how the key pair and the clients a fresh application
lacks are made. Nothing is issued until a deployment supplies what only it can: each of
authorization_server.private_key, .encryption_key, .issuer, scopes.available,
persistence and — while the device flow is on — authorization_server.device_code_verification_uri
is reported by the first service that cannot be built without it, as an
InvalidConfigurationError naming the setting and <app>/config/oauth2_server.py. Nothing is
ever minted on the deployment's behalf.
| Field | Default | What it is |
|---|---|---|
authorization_server |
AuthorizationServerConfig() |
What signs, seals, issues and runs — below |
resource_server |
ResourceServerConfig() |
How an API verifies the tokens |
scopes |
ScopesConfig() |
The scopes this deployment knows |
persistence |
None |
Where clients and credentials live: one of the three below. Required |
client |
ClientConfig() |
Which class registered clients are |
role_prefix |
"ROLE_OAUTH2_" |
What a scope becomes a role under: invoices:read grants ROLE_OAUTH2_INVOICES:READ. Never empty |
AuthorizationServerConfig:
| Field | Default | What it is |
|---|---|---|
issuer |
None |
The origin every token claims as iss and the description names. An origin only — no path, query or fragment |
private_key |
None |
The key access tokens are signed with, as PEM text or a file path |
private_key_passphrase |
"" |
The pass phrase that key is encrypted with |
encryption_key |
None |
What authorization codes and refresh tokens are sealed with |
encryption_key_type |
"plain" |
"plain": a password of at least 32 characters, stretched once when the server is built. "key": the base64url of 32 random bytes, used as is |
audience |
None |
The aud access tokens carry and the resource server requires; None addresses each token to its client and checks none |
signature_algorithm |
"RS256" |
How access tokens are signed |
additional_public_keys |
() |
Files of further public keys tokens verify against, for rotation |
access_token_ttl / refresh_token_ttl |
1 hour / 30 days | timedeltas, positive |
auth_code_ttl / device_code_ttl |
10 minutes / 10 minutes | timedeltas, positive |
enable_client_credentials_grant, enable_auth_code_grant, enable_refresh_token_grant |
True |
The flows on by default |
enable_device_code_grant |
True |
The device flow; it needs device_code_verification_uri, so turn it off or name one |
enable_password_grant, enable_implicit_grant |
False |
The two flows the protocol withdrew |
require_code_challenge_for_public_clients |
True |
A client holding no secret must send a proof key |
persist_access_token |
True |
Whether access tokens are stored. Off, one cannot be revoked before it expires: /revoke still answers 200, and /introspect keeps calling it active |
revoke_refresh_tokens |
True |
Whether redeeming a refresh token spends it |
response_type_class |
None |
The class the token endpoint answers with, built by the container; None for the bearer answer |
device_code_verification_uri |
"" |
The page a person types the user code into — the application's |
enable_device_code_verification_uri_complete_generation |
True |
Also send the URI with the code in it |
device_code_polling_interval |
5 |
Seconds a device waits between polls |
enable_device_code_polling_interval_visibility |
True |
Tell the device that interval |
enable_revocation_endpoint |
True |
/revoke |
enable_introspection_endpoint |
False |
/introspect, off: it answers about tokens issued to other clients |
enable_metadata_endpoint |
True |
/.well-known/oauth-authorization-server |
ResourceServerConfig(public_key=None, jwt_leeway=None): the verifying key — derived from the
signing key when None, which is the common case — and a timedelta widening the expiry window
for clocks that disagree. ScopesConfig(available=(), default=()): every scope a client may ask
for, and the ones a client registered naming none is given; default must be within available
and may be empty. ClientConfig(classname=Client): a subclass of AbstractClient, for a
deployment owning its client table.
persistence is one of three:
| Config | What it registers |
|---|---|
InMemoryPersistenceConfig() |
Stores in this process, forgotten when it ends. Tests and demonstrations; a refresh token's single use holds only within one process |
OrmPersistenceConfig(connection="default", table_prefix="oauth2_") |
The managers on an xtr-orm connection, and the tables joining that connection's definitions: oauth2_authorization_code and oauth2_refresh_token always, oauth2_access_token while access tokens are persisted, oauth2_device_code while the device flow is on, oauth2_client unless the application owns its client class. Needs xtr-orm installed and OrmBundle listed; the application writes the revision with orm:migrations:diff |
CustomPersistenceConfig(access_token_manager=…, authorization_code_manager=…, client_manager=…, refresh_token_manager=…, device_code_manager=…, credentials_revoker=…) |
Nothing of its own: each manager interface is aliased to the application's service named here. All six are required, and each must be a registered service |
An application owning its client class writes it on its own declarative base with
ClientMappingMixin, which maps AbstractClient's registration onto it, and names it under
client.classname; the token tables then point at its table instead of oauth2_client. The
type checker needs two suppressions there — the base brings a constructor the model's own
replaces, and the mixin maps columns onto attributes the model already declares:
from sqlalchemy import String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from xtr_security_oauth2_server.model.abstract_client import AbstractClient
from xtr_security_oauth2_server.persistence.mapping import ClientMappingMixin
class Base(DeclarativeBase): ...
class AppClient( # pyright: ignore[reportUnsafeMultipleInheritance, reportIncompatibleVariableOverride]
ClientMappingMixin,
AbstractClient,
Base,
):
__tablename__ = "app_client"
_identifier: Mapped[str] = mapped_column( # pyright: ignore[reportIncompatibleVariableOverride]
"identifier", String(32), primary_key=True
)
tenant: Mapped[str | None] = mapped_column("tenant", String(32), nullable=True)
On a base whose primary key is its own (UUIDAuditBase, say), _identifier is a unique,
not-null column instead. One client class per process.
Endpoints
setup(app, kernel) adds every endpoint, contributed by two route loaders: resource
oauth2_server (authorize, token, device-code, revoke, introspect) and resource
oauth2_server_well_known (the description and the key set, which RFC 8414 places under the
issuer, so they cannot take a prefix). An endpoint a deployment turned off answers 404. A
routes file in <app>/config/routes/ moves them or leaves some out —
routes.import_("oauth2_server").prefix("/auth").exclude("oauth2_introspect") — and the
description publishes each endpoint wherever it ends up, leaving out the ones excluded:
| Method | Path | Route name | Answers when |
|---|---|---|---|
GET, POST |
/authorize |
oauth2_authorize |
always; behind the application's own sign-in firewall |
POST |
/token |
oauth2_token |
always |
POST |
/device-code |
oauth2_device_code |
enable_device_code_grant |
POST |
/revoke |
oauth2_revoke |
enable_revocation_endpoint |
POST |
/introspect |
oauth2_introspect |
enable_introspection_endpoint |
GET |
/.well-known/oauth-authorization-server |
oauth2_metadata |
enable_metadata_endpoint |
GET |
/.well-known/jwks.json |
oauth2_jwks |
always |
/authorizereads the request, refuses an unhashed proof key unless the client is registered for one, and asksOAuth2Events.AUTHORIZATION_REQUEST_RESOLVEabout the personSecurity.get_user()returns. Consent is stateless: the page a listener answers with posts the original query back to/authorize, and the second pass is the one approved. Binding that form to the person it was shown to (a CSRF token) is the listener's. With nobody signed in the client is told onlyaccess_denied, and the cause is written on theoauth2_serverlog channel./token,/revoke,/introspectauthenticate the client themselves — HTTP Basic orclient_id/client_secretin the form — so no firewall claims them. A revoked refresh token takes its access token with it; a token that is unknown or another client's is answered as one just revoked./device-codeissues the pair a device polls with; the page the person types the user code into is the application's, and callsAuthorizationServer.complete_device_authorization_request(see the skill). Limit how often that page may be guessed at — a user code is 8 characters of 20 — with xtr-http-kernel'sRateLimited.- The description names, under the configured issuer, the endpoints served, the grants the
server runs — one the application or an add-on registered among them — the scopes and the
proof-key methods; the key set publishes the signing key's public half and every additional
public key, each under the
kida token's header names.
Services
OAuth2ServerBundle registers, under the interfaces an application injects:
| Service | Interface / key | What it does |
|---|---|---|
AuthorizationServer |
itself | Runs the enabled grants: /authorize, /token, /device-code |
TokenServer |
itself | Answers /revoke and /introspect |
ResourceServer |
itself | Reads a bearer token off a request — what the oauth2 authenticator uses |
BearerTokenValidator |
AuthorizationValidatorInterface |
Judges an access token: signature, typ, window, iss, aud, revocation |
BearerTokenResponse |
ResponseTypeInterface |
The token answer, or response_type_class |
ClientManagerInterface, AccessTokenManagerInterface, RefreshTokenManagerInterface, AuthorizationCodeManagerInterface, DeviceCodeManagerInterface |
the interfaces | The stores of the chosen persistence |
CredentialsRevokerInterface |
the interface | Withdraws everything issued to a client or a user — database and application stores only |
ScopeManagerInterface |
the interface | The configured vocabulary of scopes, always in memory |
ClientRepository, AccessTokenRepository, AuthCodeRepository, RefreshTokenRepository, DeviceCodeRepository, ScopeRepository, UserRepository |
the core's *RepositoryInterfaces |
The bridges between the protocol and the managers |
RawKeyLoader, JoserfcJwsProvider, PasswordHasherInterface |
qualifier "oauth2_server" |
This server's own key material, and the hasher client secrets are kept under |
The key loader and JWS provider are never registered under the bare interfaces: JwtBundle owns
those, and a deployment commonly runs both, each with its own key. Ask for this server's with
Annotated[KeyLoaderInterface, Target("oauth2_server")].
Events
Every listener registers through xtr-event-dispatcher under the same
name the code dispatches under — @as_event_listener(OAuth2Events.X):
| Name | Event | A listener |
|---|---|---|
OAuth2Events.AUTHORIZATION_REQUEST_RESOLVE |
AuthorizationRequestResolveEvent |
Resolves the request (resolve_authorization(APPROVED)), or answers the browser (set_response) |
OAuth2Events.SCOPE_RESOLVE |
ScopeResolveEvent |
Has the last word on the scopes a token carries (set_scopes) |
OAuth2Events.USER_RESOLVE |
UserResolveEvent |
Checks a password-flow username and password and set_users the owner |
OAuth2Events.ACCESS_TOKEN_EXTRA_CLAIMS_RESOLVE |
AccessTokenExtraClaimsResolveEvent |
Adds claims to an access token before it is signed; the token's own names are refused |
OAuth2Events.TOKEN_REQUEST_RESOLVE |
TokenRequestResolveEvent |
Replaces the token answer, refusals included |
OAuth2Events.PRE_SAVE_CLIENT |
PreSaveClientEvent |
Changes a client before it is stored; the shipped listener hands a scope-less client scopes.default |
The protocol library announces five more, under RequestEvent's names:
ACCESS_TOKEN_ISSUED and REFRESH_TOKEN_ISSUED (with RequestAccessTokenEvent and
RequestRefreshTokenEvent), CLIENT_AUTHENTICATION_FAILED, USER_AUTHENTICATION_FAILED and
REFRESH_TOKEN_CLIENT_FAILED.
Commands
With xtr-console installed beside this package, which makes the console bundle active:
| Command | Does |
|---|---|
oauth2-server:generate-keypair [ALGORITHM] [--dry-run] [--skip-if-exists] [--overwrite] |
Mints the signing pair for the configured algorithm (or RS*, PS*, ES* 256–512, EdDSA) and writes it where private_key — and resource_server.public_key, when set — point: 0600 private, 0644 public, never through a link |
oauth2-server:create-client NAME [IDENTIFIER] [SECRET] [--redirect-uri …] [--grant-type …] [--scope …] [--public] [--allow-plain-text-pkce] |
Registers a client, minting what is left out, and prints the plaintext secret once; only its hash is stored |
oauth2-server:update-client IDENTIFIER [--add-/--remove-redirect-uri …] [--add-/--remove-grant-type …] [--add-/--remove-scope …] [--activate|--deactivate] |
Amends a registration one change at a time |
oauth2-server:delete-client IDENTIFIER |
Withdraws a client |
oauth2-server:list-clients [--columns …] [--redirect-uri …] [--grant-type …] [--scope …] |
Lists the clients; a secret is never shown |
oauth2-server:clear-expired-tokens [-a] [-r] [-c] [-dc] |
Sweeps the expired access tokens, refresh tokens, codes and device codes — all of them when none is named. The one to schedule |
A client deactivated with --deactivate (or set_active(False) and a save) is refused as
invalid_client wherever it names itself — /authorize, /token, /device-code, /revoke,
/introspect — whether or not it holds a secret, so a refresh token it holds can no longer be
spent; an access token already issued to it stays good until it expires.
No command mints the encryption key; the one-liner in the Quick start does. With the in-memory store, a client registered by a command is gone when the command exits.
Use in an application
Everything adding this package to an application on xtr-dependency-injection takes — and, read backwards, what removing it undoes.
- Install —
uv add xtr-security-oauth2-server; install xtr-orm beside it to keep clients and credentials in a database, xtr-console for the commands. - Recipe —
uv run xtr-recipes recipes:syncdoes the Activate, Configure, Environment and Ignore steps below, and no other: it listsOAuth2ServerBundle, writes a starting<app>/config/oauth2_server.py,OAUTH2_SERVER_PRIVATE_KEY_PATH=%kernel.project_dir%/secrets/oauth2.pemand a commented-outOAUTH2_SERVER_ENCRYPTION_KEYin.env, and adds/secrets/*.pemto.gitignore, and writes<app>/config/routes/oauth2_server.pyserving every endpoint at its default path. It prints what a recipe cannot make for you: minting the key pair and the encryption key, naming this deployment's issuer, callingsetup, the three firewalls, the listener that answers the consent question, registering a client, scheduling the sweep of what has expired, and the revision a database store takes. - Activate —
OAuth2ServerBundle: {"all": True}inBUNDLESin<app>/bundles.py, imported fromxtr_security_oauth2_server.bundle— andJwtBundlebeside it when the end user signs in with a self-issued token. Then build the kernel withconcurrent_scoped_access=Trueand callsetup(app, kernel), as the security family requires: it adds this server's endpoints. Put an unboundFirewall()on the application itself: the endpoints carry no dependency of their own, so that is what establishes the resource owner on/authorize. - Brings along — the security bundle, the clock, the
event dispatcher and the HTTP kernel always,
because this bundle requires them; the logging bundle when xtr-logging is
installed, which is where an authorization request arriving with no resource owner is reported,
on the
oauth2_serverchannel; the console bundle when xtr-console is installed, for the commands. - Configure — with no configuration the bundle builds and boots, and issues nothing: a
<app>/config/oauth2_server.py@configurefunction returning anOAuth2ServerConfigsays who this server is (authorization_server.issuer), what it signs and seals with (private_key,encryption_key), which scopes it knows (scopes.available) and where its clients and credentials live (persistence). Each of those is reported, by name and with the file to write it in, by the first service that cannot be built without it — never by the build, which would take the console down with it. Three firewalls in<app>/config/security.py: one claiming^/authorizethat signs the end user in (aJwtAuthenticatorConfig()reading a cookie, say); none claiming/token,/revoke,/introspect,/device-codeor the.well-knownpaths, because authenticating the client there is this server's own job; andOAuth2AuthenticatorConfig()under theauthenticatorsof the one in front of the API. A listener onOAuth2Events.AUTHORIZATION_REQUEST_RESOLVEanswers the consent question — see Configure and Kernel / bundle.<app>/config/routes/oauth2_server.pyimports both route resources; move them or leave endpoints out there — see Endpoints. - Environment —
OAUTH2_SERVER_PRIVATE_KEY_PATH, the file the writtenconfig/oauth2_server.pyreads the signing key from withenv("resolve:OAUTH2_SERVER_PRIVATE_KEY_PATH"); andOAUTH2_SERVER_ENCRYPTION_KEY, the base64url of 32 random bytes that authorization codes and refresh tokens are sealed with. Neither is read while the application builds, so it runs before they are set; a request then reports the one that is missing. - Ignore — the key files the key pair is written to, when they are written under the project:
/secrets/*.pem, which covers the private key and the public halfoauth2-server:generate-keypairwrites beside it when a deployment names a file for one. - Remove —
uv run xtr-recipes recipes:remove xtr-security-oauth2-server, then undo the steps it printed: the firewalls, the consent listener and the client registrations are the application's own. - Check —
debug:bundleslistsoauth2_serveras active;debug:config oauth2_servershows the resolved issuer, lifetimes, flows and store; andGET /.well-known/oauth-authorization-serveranswers with this server's description. With xtr-console installed,list oauth2-servernames the six commands this package ships —oauth2-server:generate-keypairmints the signing key pair whereOAUTH2_SERVER_PRIVATE_KEY_PATHpoints,oauth2-server:create-client,update-client,delete-clientandlist-clientsadminister the clients, andoauth2-server:clear-expired-tokenssweeps what has expired, which is the one to run on a schedule.
Kernel / bundle
# app/bundles.py
from xtr_security_oauth2_server.bundle import OAuth2ServerBundle
BUNDLES = {OAuth2ServerBundle: {"all": True}}
OAuth2ServerBundle (name oauth2_server, config OAuth2ServerConfig) requires the
security, clock, event dispatcher
and HTTP kernel bundles, and the logging and
console bundles when they are installed. No other bundle requires it, so it only
arrives by being listed. It:
- prepends onto the security configuration an authenticator factory keyed
oauth2, which a firewall'sOAuth2AuthenticatorConfig()builds — one authenticator per firewall, over that firewall's user provider; - registers the stores of the chosen persistence, the protocol engine and one controller per endpoint, and the commands when the console bundle is active;
- tags every class subclassing the core's
GrantTypeInterfacewithGRANT_TAG("oauth2_server.authorization_server.grant", attributeaccess_token_ttl), and itsGrantTypePassenables each tagged grant on the server and has the description name it — the built-in ones are registered already tagged. No grant at all, or two answering the same grant type, is refused; - collects services tagged
METADATA_TAG("oauth2_server.metadata", attributepriority) implementingMetadataContributorInterfaceinto the description, under this server's own fields; - hands its tables to xtr-orm's
OrmMappingsPasswith the database persistence, and refuses that persistence whenOrmBundleis not active.
It reads no key, issuer or store while building, so it builds and boots with no configuration.
An add-on — an identity layer, say — plugs in from its own bundle: it prepends
authorization_server.response_type_class and enable_auth_code_grant=False, registers an
AuthCodeGrant subclass tagged GRANT_TAG, and tags a metadata contributor; this bundle knows
nothing of it. The skill's extending reference
walks through one.
Errors
Everything this package and the protocol library raise derives from OAuth2Error
(xtr_security_oauth2_server_core.exception), so one except OAuth2Error catches it all:
| Error | Base | Raised when |
|---|---|---|
OAuthServerError |
OAuth2Error |
A request the protocol has a refusal for — invalid_client, invalid_grant, access_denied, slow_down, … — which the endpoints turn into the answer |
InvalidConfigurationError |
OAuth2Error, ValueError |
A setting that cannot be used, or one missing where a service needs it, naming it and <app>/config/oauth2_server.py |
ClientAlreadyRegisteredError |
OAuth2Error |
oauth2-server:create-client, or a store, met an identifier already registered |
OAuth2AuthenticationError |
AuthenticationError, OAuth2Error |
The resource-server side refused a token; answered 401 with a bearer invalid_token challenge |
OAuth2AuthenticationFailedError |
OAuth2AuthenticationError |
The one such refusal raised — the same for every unusable token, the cause kept |
InvalidArgumentError |
OAuth2Error, ValueError |
One of the library's objects used wrongly — a reserved claim, an unreadable key |
UniqueTokenIdentifierConstraintViolationError |
OAuthServerError |
A store met a token identifier already taken; the grant retries, and only a run of ten answers 500 |
The bundle's two live in xtr_security_oauth2_server.exception, the authentication ones in
xtr_security_oauth2_server.security.exception. A scope a route requires and the token lacks is
the family's AccessDeniedError, answered 403.
Layout
xtr_security_oauth2_server/
├── routing/ the two route loaders setup() mounts the endpoints from
├── oauth2_events.py OAuth2Events, the six names
├── authorization_server_metadata.py, metadata_contributor_interface.py
├── controller/ one controller per endpoint
├── model/ the stored client, tokens and codes, and their interfaces
├── value_object/ Grant, RedirectUri, Scope
├── entity/ what the protocol is handed, built from the models
├── manager/ the store interfaces, ClientFilter; in_memory/, orm/
│ └── null/ an access-token manager storing nothing, for CustomPersistenceConfig
├── repository/ the bridges the protocol library reads through
├── converter/ scope and user conversions
├── persistence/ the ORM mapping (Driver, ClientMappingMixin) and column types
├── service/ CredentialsRevokerInterface and the ORM revoker
├── event/, event_listener/ the events, and the default-scopes listener
├── security/ OAuth2Authenticator, OAuth2Token, ClientCredentialsUser, errors
├── factory/ the oauth2 authenticator factory
├── command/ the oauth2-server:* commands
├── exception/ InvalidConfigurationError, ClientAlreadyRegisteredError
├── recipe/ what recipes:sync applies
└── bundle/ OAuth2ServerBundle, its configuration and its passes
Development
Developed in the python-xtr monorepo, under
packages/xtr-security-oauth2-server; run the commands below from there. The conformance suite,
one module per RFC, is under tests/conformance/.
uv sync --all-packages --all-extras
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest --cov
License
MIT — see LICENSE.
Metadata
Release files for xtr-security-oauth2-server 4.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| xtr_security_oauth2_server-4.1.0.tar.gz | 173.0 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| xtr_security_oauth2_server-4.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 436.9 kB
Release files / xtr_security_oauth2_server-4.1.0.tar.gz
| Download URL | xtr_security_oauth2_server-4.1.0.tar.gz |
|---|---|
| Size | 173.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4f73c208adf65ca83fa693364429b8df4c5e8fe3df50a11d8ba6472fb10794ff
|
|
BLAKE2b-256 checksum How to use checksums |
b19f25ef0d14c01f7017b0426df60f62b42b8df26707bb9fc10c19a23e46d3e4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logRelease files / xtr_security_oauth2_server-4.1.0-py3-none-any.whl
| Download URL | xtr_security_oauth2_server-4.1.0-py3-none-any.whl |
|---|---|
| Size | 263.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6ec9dd651958e0358652d4a78cfba9f6e7623346318221e7528c059a1b4ff44e
|
|
BLAKE2b-256 checksum How to use checksums |
3c2a00c9b23dbd8fe6975b681f879529c4158bb0c75f1564e36735b19d07f108
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logSecurity for xtr-security-oauth2-server 4.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for xtr_security_oauth2_server-4.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.