fastapi-oidc-guard
Strict OIDC bearer-token authentication for FastAPI resource servers.
The library validates externally issued JWT access tokens using OIDC discovery, cached JWKS, and PyJWT. Its default user-identity profile additionally requires and verifies UserInfo; an explicit JWT-only profile supports workload identities such as RFC 9068 client-credentials tokens. Authentication is opt-in through a typed FastAPI dependency. The library does not implement browser login, token issuance, sessions, opaque-token introspection, or OAuth grant processing.
Installation
pip install fastapi-oidc-guard
Python 3.12 or newer is required.
Usage
import contextlib
import dataclasses
from fastapi import FastAPI
from fastapi_oidc_guard import Authenticated, OidcConfig, VerifiedIdentity, authentication_context
@dataclasses.dataclass(frozen=True, slots=True)
class User:
id: str
name: str
async def resolve_user(identity: VerifiedIdentity) -> User | None:
userinfo = identity.userinfo
if userinfo.extra.get('role') != 'myrole':
return None
return User(id=identity.sub, name=userinfo.name or '')
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
async with authentication_context(
app, OidcConfig(issuer='https://issuer.example', audience='project-id'), resolve_user
):
yield
app = FastAPI(lifespan=lifespan)
@app.get('/public')
async def public_endpoint():
return {'message': 'This could be anyone'}
@app.get('/authenticated')
async def authenticated_endpoint(user: Authenticated[User]):
return {'message': f'Welcome back {user.name}!'}
authentication_context uses the default profile and eagerly downloads and validates discovery
metadata and JWKS during application startup. By default, discovery must advertise jwks_uri,
userinfo_endpoint, authorization_endpoint, and token_endpoint, and it must support the
authorization-code flow. The resulting values seed the same TTL caches used while requests are
served. Startup fails when an enabled provider capability is unavailable or invalid.
The user resolver must be async. It receives a fully verified VerifiedIdentity and returns the
application's concrete user object. Returning None denies access with HTTP 403.
JWT-only and Workload Identities
Use OidcAuthenticator.jwt_only() for JWT access tokens that must not call UserInfo. The mapper
receives a VerifiedToken containing only signature- and claim-verified JWT data:
import dataclasses
from fastapi import FastAPI
from fastapi_oidc_guard import OidcAuthenticator, OidcConfig, VerifiedToken
@dataclasses.dataclass(frozen=True, slots=True)
class Client:
client_id: str
async def resolve_client(token: VerifiedToken) -> Client | None:
client_id = token.claims.get('client_id')
if not isinstance(client_id, str):
return None
return Client(client_id)
config = OidcConfig(
issuer='https://issuer.example',
audience='project-id',
expected_token_type='at+jwt',
openapi_authorization_code=False,
)
authenticator = OidcAuthenticator.jwt_only(config, resolve_client)
app = FastAPI(lifespan=authenticator.lifespan)
With openapi_authorization_code=False, JWT-only startup requires only issuer and jwks_uri from
discovery. It does not require or call UserInfo and does not inspect authorization-code metadata.
The JWT validator remains unchanged: iss, aud, sub, iat, and exp are still mandatory, and
all signature, algorithm, key, lifetime, and claim checks still apply. The mapper is responsible for
application authorization based on client_id, scopes, roles, or other verified claims.
This profile validates a JWT access token, not the OAuth grant that produced it. Opaque tokens and provider-specific client tokens without the required JWT claims remain unsupported. There is no fallback between profiles: the default profile always requires UserInfo, while JWT-only never calls it.
Manual and Composed Authentication
Applications that use middleware, custom dependencies, or direct token authentication can create
one OidcAuthenticator. Its lifespan performs the same initialization as authentication_context,
and Authenticated[...] automatically uses that instance:
import typing as t
from fastapi import Depends, FastAPI, Request
from fastapi_oidc_guard import Authenticated, OidcAuthenticator, OidcConfig
authenticator = OidcAuthenticator(
OidcConfig(issuer='https://issuer.example', audience='project-id'), resolve_user
)
app = FastAPI(lifespan=authenticator.lifespan)
async def current_user(request: Request) -> User:
return await authenticator.authenticate_connection(request)
@app.get('/composed')
async def composed_endpoint(
request: Request,
injected: Authenticated[User],
manual: t.Annotated[User, Depends(current_user)],
):
repeated = await authenticator.authenticate_connection(request)
assert injected is manual is repeated
return {'message': f'Welcome back {injected.name}!'}
authenticate_connection() accepts an HTTP Request or a WebSocket. It extracts the bearer
token and stores an in-flight task in the shared ASGI connection state. Middleware, custom
dependencies, and Authenticated[...] therefore share the complete authentication operation.
Whichever runs first performs JWT validation, the configured identity profile, and identity mapping;
later calls return the exact same mapped user object. Failed operations are not retained.
Middleware can catch public library exceptions to implement an application-specific error envelope:
from fastapi.responses import JSONResponse
from fastapi_oidc_guard import CredentialsMissingError, InvalidTokenError
@app.middleware('http')
async def authenticate_early(request: Request, call_next):
try:
request.state.user = await authenticator.authenticate_connection(request)
except (CredentialsMissingError, InvalidTokenError):
return JSONResponse({'error': 'invalid_credentials'}, status_code=401)
return await call_next(request)
For a raw JWT that is not associated with a request or WebSocket, use:
user = await authenticator.authenticate_token(token)
Raw-token calls use the same validator, configured identity profile, and mapper, but do not
participate in connection-scoped result caching. Discovery, JWKS, and enabled UserInfo verification
retain their configured caches. Both public authentication methods must be called while
authenticator.lifespan is active.
The configured bearer-input limit applies to both methods. For direct calls it is checked before PyJWT parses the token.
authentication_context() remains available as a convenience for composed application lifespans
and now yields the same public service:
async with authentication_context(app, config, resolve_user) as authenticator:
yield
Public authentication failures derive from OidcGuardError. Applications may handle
CredentialsMissingError, InvalidTokenError, InsufficientPermissionsError,
IdentityProviderUnavailableError, InvalidProviderResponseError, and ConfigurationError.
These methods never translate failures into FastAPI HTTPException; that translation remains the
responsibility of Authenticated[...].
Verified Tokens and Identities
VerifiedToken retains information needed by downstream authorization policies:
sub,issued_at,expires_at,issuer, andaudiences- the verified signing
kidandalgorithm - all verified JWT claims as a recursively immutable mapping
VerifiedIdentity extends VerifiedToken with typed, non-optional userinfo. Known UserInfo fields
are available as attributes. Additional non-null JSON claims are retained in userinfo.extra.
The mapper type reflects the selected profile, so JWT-only code does not receive an optional UserInfo value and default-profile code can rely on UserInfo being present.
Configuration
from datetime import timedelta
from fastapi_oidc_guard import OidcConfig
config = OidcConfig(
issuer='https://issuer.example',
audience='project-id',
allowed_algorithms=(
'RS256',
'RS384',
'RS512',
'PS256',
'PS384',
'PS512',
'ES256',
'ES384',
'ES512',
'EdDSA',
),
discovery_cache_ttl=timedelta(minutes=15),
jwks_cache_ttl=timedelta(minutes=15),
userinfo_cache_ttl=timedelta(minutes=5),
userinfo_cache_max_entries=1024,
leeway=timedelta(seconds=30),
max_token_lifetime=None,
max_bearer_length=16 * 1024,
expected_token_type=None,
openapi_authorization_code=True,
openapi_scopes=None,
swagger_ui_client_id=None,
http_timeout=timedelta(seconds=5),
allow_insecure_http=False,
)
Supported algorithms are RS256, RS384, RS512, PS256, PS384, PS512, ES256,
ES384, ES512, and EdDSA. All are enabled by default. Only configured algorithms are
accepted; provider metadata cannot expand this allowlist. Applications can narrow it to the exact
algorithm or algorithms used by their provider.
Set expected_token_type='at+jwt' when the provider emits RFC 9068 access tokens. It remains
optional because many providers omit typ or use a provider-specific value.
HTTP is rejected by default. allow_insecure_http=True exists for explicit local-development
setups and should not be enabled in production.
Bearer Input Limit
max_bearer_length defaults to 16 KiB and is inclusive. For HTTP requests and WebSockets, the
limit applies to the complete raw Authorization field value in bytes, including the Bearer
scheme and any whitespace. The raw ASGI headers are checked before the value is decoded, split, or
passed to FastAPI's authentication machinery. Multiple Authorization fields are rejected.
For direct authenticate_token() calls, the same numeric limit applies to the Python token
string's character length and is checked before PyJWT parses its header. No framing allowance is
added or subtracted in either path. Consequently, a token sent through an Authorization field has
slightly less available space than a token passed directly.
Providers with larger legitimate credentials can raise the limit explicitly:
config = OidcConfig(
issuer='https://issuer.example', audience='project-id', max_bearer_length=32 * 1024
)
Oversized credentials raise InvalidTokenError and become the same sanitized HTTP 401 response as
other invalid tokens. They do not trigger token parsing, UserInfo, or identity mapping. Discovery
and JWKS are still fetched eagerly during application startup.
This application-level check cannot prevent the ASGI server or a reverse proxy from initially receiving and allocating an oversized field. Configure complementary per-header, total-header, and header-count limits at those layers.
OpenAPI
Routes using Authenticated[...] reference an OidcBearer OAuth2 authorization-code security
scheme. Its authorization URL, token URL, and scopes come from the cached OIDC discovery response,
so generating OpenAPI does not make another provider request. When discovery omits the optional
scopes_supported field, the scheme advertises only openid.
This behavior remains enabled by default. Set openapi_authorization_code=False when the resource
server does not use interactive authorization-code documentation:
config = OidcConfig(
issuer='https://issuer.example', audience='project-id', openapi_authorization_code=False
)
FastAPI then advertises OidcBearer as a standard HTTP bearer scheme, and Swagger UI still allows a
token to be entered manually. The provider's authorization_endpoint, token_endpoint,
response_types_supported, grant_types_supported, and scopes_supported values are not required
or validated because they are not used. openapi_scopes and swagger_ui_client_id cannot be set
while authorization-code integration is disabled.
Set openapi_scopes to replace the discovered scope list:
config = OidcConfig(
issuer='https://issuer.example',
audience='project-id',
openapi_scopes=('openid', 'email', 'profile', 'urn:example:custom'),
)
The override must contain unique OAuth2 scope names and include openid. It affects only the
generated OpenAPI authorization flow; it does not validate a token's scope claim or grant
permissions. Enforce application authorization in the async identity mapper.
Set swagger_ui_client_id for a public documentation client:
config = OidcConfig(
issuer='https://issuer.example', audience='project-id', swagger_ui_client_id='swagger-ui'
)
The configured client ID replaces any clientId already present in FastAPI's
swagger_ui_init_oauth. Other application-owned Swagger settings are preserved, and PKCE is
enabled unless the application explicitly configures usePkceWithAuthorizationCodeGrant. Client
secrets are intentionally unsupported because Swagger UI is a browser-based public client.
Validation Policy
The hardened policy is not configurable down to unsafe compatibility behavior:
iss,aud,sub,iat, andexpare required.iat,exp, and optionalnbfmust be JSON integers; booleans, floats, and strings fail.- Discovery's issuer must exactly match the configured issuer.
- JWT header
alg, the local allowlist, and an explicit JWKalgmust agree. - Symmetric, private, weak RSA, incompatible EC/OKP, and non-verification JWKs are rejected.
- Duplicate JWK IDs are rejected.
- An unknown
kidis rejected without causing an outbound request. - Bearer inputs exceeding
max_bearer_lengthare rejected before JWT header parsing. - Multiple Authorization fields are rejected as invalid credentials.
- In the default profile, UserInfo
submust exactly match the verified tokensub. - Raw bearer tokens and Authorization headers are never included in library errors.
UserInfo is cached by a SHA-256 token fingerprint, not by subject. The bounded five-minute cache is capped by token expiration and deduplicates concurrent fetches.
Readiness and Provider Refresh
OidcAuthenticator.status() returns an immutable operational snapshot without performing network
I/O:
status = authenticator.status()
if status.ready:
print(f'provider generation {status.generation} is ready')
The snapshot reports whether the lifespan is active, whether a refresh is running, discovery and
JWKS freshness, the number of usable configured signing keys, generation, and sanitized UTC refresh
timestamps. expires_in values are derived from monotonic cache deadlines and decrease between
snapshots. Status never contains provider URLs, key identifiers, JWK values, tokens, UserInfo,
cached fingerprints, headers, or exception messages.
ready requires an active lifespan, fresh discovery and JWKS material, and at least one usable
signing key. The default profile also validates that discovery contains an acceptable UserInfo URL,
but readiness does not call UserInfo or assert that it is currently reachable. Authorization-code
OpenAPI metadata is validated at startup but is not part of runtime authentication readiness.
Readiness probes should remain passive:
@app.get('/ready')
async def ready():
return Response(status_code=200 if authenticator.status().ready else 503)
Use refresh_provider() from a separately authenticated and rate-limited administrative operation
when an immediate provider refresh is required:
@app.post('/operations/oidc/refresh')
async def refresh_oidc():
status = await authenticator.refresh_provider()
return {'generation': status.generation}
The refresh fetches candidate discovery first, follows its candidate jwks_uri, validates all
profile requirements and signing-key usability, and then publishes one provider generation.
Concurrent refresh calls share the same operation. A failed refresh raises a sanitized provider
exception and leaves existing material unchanged; that material remains ready only while its TTLs
remain fresh. Refresh does not call UserInfo, clear UserInfo results, or modify the application's
startup OpenAPI and Swagger configuration.
Status is available outside the lifespan with active=False and ready=False. Calling
refresh_provider() outside the active lifespan raises ConfigurationError. The library does not
mount operational routes or authorize refresh callers on the application's behalf.
Key Rotation
JWKS is fetched at application startup, on normal TTL refresh, or through an explicit
refresh_provider() call. Token-controlled values, including an unknown kid, never force a
refresh or bypass the configured TTL.
For rotation without rejected tokens, the identity provider must:
- Publish the next public key at least one complete JWKS TTL before using it to sign tokens.
- Keep an old public key published until all tokens signed by it have expired, including leeway.
- Use
refresh_provider()when an emergency rotation also changesjwks_uri.
Emergency or unannounced rotations can cause authentication failures until the cache expires or the application explicitly refreshes the provider. This is intentional: provider key management and application-controlled operations, rather than attacker-controlled token headers, determine when network refreshes occur.
Identity Profiles
The default OidcAuthenticator(...) and authentication_context(...) profile accepts user access
tokens that the discovered userinfo_endpoint accepts. Appropriate provider scopes, commonly
openid, profile, and email, must be granted when the corresponding claims are required.
UserInfo is fetched successfully and its sub is matched against the JWT before the application
mapper runs. Therefore VerifiedIdentity.userinfo is never None. UserInfo failure rejects the
request instead of producing a partial identity.
OidcAuthenticator.jwt_only(...) accepts locally verifiable JWT access tokens without UserInfo and
passes a VerifiedToken to its mapper. Selecting JWT-only is explicit and fixed for the
authenticator's lifetime; UserInfo failure can never cause the default profile to downgrade to it.
Configuration Parsing
OidcConfig rejects undeclared fields. This makes configuration typos fail during startup instead
of silently retaining a default authentication or cache policy. Validation errors identify the
unknown field and retain its supplied value for debugging.
The strict policy applies only to the OidcConfig boundary. A containing application model keeps
its own extra-field policy:
from pydantic import BaseModel
class ApplicationConfig(BaseModel):
oidc: OidcConfig
storage_url: str
Applications that previously passed a complete application mapping directly to OidcConfig must
select the OIDC section:
oidc_config = OidcConfig.model_validate(application_config['oidc'])
Subclasses may add declared application-specific fields and inherit strict unknown-field handling. An application that temporarily requires the previous behavior can opt into it explicitly as a migration escape hatch:
class LenientOidcConfig(OidcConfig, extra='ignore', frozen=True):
pass
Errors
| Condition | Response |
|---|---|
| Missing credentials | 401, WWW-Authenticate: Bearer |
| Malformed or invalid token | 401, WWW-Authenticate: Bearer error="invalid_token" |
| Oversized or duplicate bearer credentials | 401, WWW-Authenticate: Bearer error="invalid_token" |
Resolver returns None |
403 |
| Malformed provider response | 502 |
| Provider timeout, rate limit, or 5xx | 503 |
| Missing lifespan or mapped-user type mismatch | 500 |
Bodies use FastAPI's stable {'detail': '...'} format without PyJWT, provider, token, claim, or
key details.
Typing
For Pyright, Authenticated[User] is the same static type as User. At runtime it expands to a
normal Annotated[User, Depends(...)] dependency and validates the returned value with
isinstance.
The type argument must therefore be a concrete runtime-checkable class. Unions, parameterized
generics, Any, TypedDict, and non-runtime-checkable protocols are not supported.
Development
uv sync
uv run pyright
uv run ruff format --check .
uv run ruff check .
uv run pytest
uv run behave
uv build
Pyright runs in strict mode using its Node.js extra. Ruff checks all rules except return
annotations, docstrings, security, and lazy-import rules; formatting uses spaces, LF line endings,
a 100-character line length, single quotes, and no magic trailing comma. Pytest covers technical
components and API-level behavior, while Behave covers business-level authentication outcomes.
GitLab CI runs the same quality checks and builds the package from .gitlab-ci.yml.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fastapi_oidc_guard-0.2.0.tar.gz.
File metadata
- Download URL: fastapi_oidc_guard-0.2.0.tar.gz
- Upload date:
- Size: 34.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e1254436e14dba716452e34dbebbd5485902c97fe515d3c6d528e88488a238a9
|
|
| MD5 |
69332f2c03102057cf7d0d5835d3d645
|
|
| BLAKE2b-256 |
74278478bc32163ccd0b8dc956ce7169ef95f9ff02c5b9c755037026bcf65acd
|
File details
Details for the file fastapi_oidc_guard-0.2.0-py3-none-any.whl.
File metadata
- Download URL: fastapi_oidc_guard-0.2.0-py3-none-any.whl
- Upload date:
- Size: 32.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
764cf910b776233f6d8c4fb48ab4dc4325ccfd6ded0e675701fa8e4f525d1b1b
|
|
| MD5 |
cfa1f3ef74420a2ffd786a12696a92cf
|
|
| BLAKE2b-256 |
e632ce3791134cc9c3f692e552085ea705700cdd718365f89fc7c597bc451b9a
|