Metadata-Version: 2.4
Name: fastapi-oidc-guard
Version: 0.3.0
Summary: Strict OIDC bearer-token authentication for FastAPI
Keywords: fastapi,jwt,oauth2,oidc,security
Author: Florian Daude
Author-email: Florian Daude <floriandaude@hotmail.fr>
License-Expression: Apache-2.0
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Dist: fastapi>=0.115
Requires-Dist: httpx>=0.28,<1
Requires-Dist: pyjwt[crypto]>=2.10.1,<3
Requires-Dist: pydantic>=2.10,<3
Requires-Python: >=3.12
Project-URL: Homepage, https://gitlab.com/daude_f/fastapi-oidc-guard
Project-URL: Repository, https://gitlab.com/daude_f/fastapi-oidc-guard
Project-URL: Issues, https://gitlab.com/daude_f/fastapi-oidc-guard/-/issues
Description-Content-Type: text/markdown

# 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

```bash
pip install fastapi-oidc-guard
```

Python 3.12 or newer is required.

## Usage

```python
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:

```python
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:

```python
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:

```python
from fastapi.responses import JSONResponse

from fastapi_oidc_guard import CredentialsMissingError, InvalidTokenError, InvalidTokenReason


@app.middleware('http')
async def authenticate_early(request: Request, call_next):
    try:
        request.state.user = await authenticator.authenticate_connection(request)
    except InvalidTokenError as error:
        assert isinstance(error.reason, InvalidTokenReason)
        return JSONResponse({'error': error.reason}, status_code=401)
    except CredentialsMissingError:
        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:

```python
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:

```python
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[...]`.

## Authentication Errors

`Authenticated[...]` returns specific, constant authentication details by default. Invalid-token
responses include `WWW-Authenticate: Bearer error="invalid_token"` with the same fixed detail in
`error_description`; missing credentials use `WWW-Authenticate: Bearer`. No response includes the
token, key ID, claim values, configured policy values, or provider URLs.

| Public reason | HTTP detail |
| --- | --- |
| Missing credentials (separate `CredentialsMissingError`) | `Bearer credentials are required.` |
| `MALFORMED_AUTHORIZATION` | `Authorization must contain one Bearer credential.` |
| `CREDENTIALS_TOO_LARGE` | `Bearer credentials exceed the supported size.` |
| `MALFORMED_TOKEN` | `The bearer token is not a valid compact JWT.` |
| `UNSUPPORTED_ALGORITHM` | `The token signing algorithm is not accepted.` |
| `SIGNING_KEY_NOT_FOUND` | `No signing key is available for this token.` |
| `SIGNING_KEY_INCOMPATIBLE` | `The signing key is incompatible with this token.` |
| `INVALID_SIGNATURE` | `The token signature is invalid.` |
| `INVALID_ISSUER` | `The token issuer is not accepted.` |
| `INVALID_AUDIENCE` | `The token audience is not accepted.` |
| `EXPIRED` | `The token has expired.` |
| `NOT_ACTIVE` | `The token is not active yet.` |
| `INVALID_TYPE` | `The token type is not accepted.` |
| `INVALID_CLAIMS` | `The token contains invalid or missing claims.` |
| `USERINFO_REJECTED` | `The identity provider rejected the token for UserInfo.` |
| `USERINFO_SUBJECT_MISMATCH` | `The token and UserInfo identify different subjects.` |
| Mapper denied identity (separate `InsufficientPermissionsError`) | `The authenticated identity is not permitted to use this application.` |

Set `generic_authentication_errors=True` to collapse all 401 details to
`Missing or invalid authorization` and mapper denials to `Not permitted`. RFC challenges remain
distinct, but generic invalid-token challenges omit `error_description`:

```python
config = OidcConfig(
    issuer='https://issuer.example', audience='project-id', generic_authentication_errors=True
)
```

This setting affects only FastAPI responses generated by `Authenticated[...]`. Direct
`authenticate_token()` and `authenticate_connection()` calls continue to raise
`InvalidTokenError` with a stable public `InvalidTokenReason` in `error.reason`, allowing custom
integrations to choose their own disclosure policy. Signature validation takes precedence over
claim diagnostics.

## Verified Tokens and Identities

`VerifiedToken` retains information needed by downstream authorization policies:

- `sub`, `issued_at`, `expires_at`, `issuer`, and `audiences`
- the verified signing `kid` and `algorithm`
- 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

```python
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,
    generic_authentication_errors=False,
    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 library measures the token as a canonical
`Bearer <token>` field value. Compact JWTs are ASCII, so their character and byte lengths are
identical and no encoded copy is needed. Non-ASCII input is rejected as malformed. A canonical HTTP
header and direct authentication therefore have the same inclusive boundary. HTTP-specific
whitespace still counts toward the actual raw field value.

Providers with larger legitimate credentials can raise the limit explicitly:

```python
config = OidcConfig(
    issuer='https://issuer.example', audience='project-id', max_bearer_length=32 * 1024
)
```

Oversized credentials raise `InvalidTokenError` with
`InvalidTokenReason.CREDENTIALS_TOO_LARGE`. 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:

```python
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:

```python
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:

```python
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`, and `exp` are required.
- `iat`, `exp`, and optional `nbf` must 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 JWK `alg` must agree.
- Symmetric, private, weak RSA, incompatible EC/OKP, and non-verification JWKs are rejected.
- Duplicate JWK IDs are rejected.
- An unknown `kid` is rejected without causing an outbound request.
- Bearer inputs exceeding `max_bearer_length` are rejected before JWT header parsing.
- Multiple Authorization fields are rejected as invalid credentials.
- In the default profile, UserInfo `sub` must exactly match the verified token `sub`.
- 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:

```python
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.

For passive telemetry, `status()` intentionally becomes not-ready after either provider cache
expires and does not initiate a refresh. In a traffic-gated deployment, use
`ensure_provider_ready()` in the readiness probe so an idle replica can restore readiness without
waiting for an authentication request:

```python
from fastapi import Response


@app.get('/ready')
async def ready():
    status = await authenticator.ensure_provider_ready()
    return Response(status_code=200 if status.ready else 503)
```

The async check returns immediately without network I/O while discovery and JWKS are fresh. When
material has expired, it uses the normal conditional refresh path: JWKS is refreshed alone when
discovery remains fresh, concurrent checks share one operation, and recent failures observe the
normal retry cooldown. Expected provider failures are represented by a sanitized
`AuthenticationStatus` with `ready=False` rather than being exposed by the readiness endpoint. The
check does not call UserInfo. Cancellation and unexpected local errors still propagate; cancelling
one caller does not cancel a refresh shared with other callers. Liveness probes should remain
independent of the identity provider.

Use `refresh_provider()` from a separately authenticated and rate-limited administrative operation
when an immediate provider refresh is required:

```python
@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
`ensure_provider_ready()` or `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:

1. Publish the next public key at least one complete JWKS TTL before using it to sign tokens.
2. Keep an old public key published until all tokens signed by it have expired, including leeway.
3. Use `refresh_provider()` when an emergency rotation also changes `jwks_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:

```python
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:

```python
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:

```python
class LenientOidcConfig(OidcConfig, extra='ignore', frozen=True):
    pass
```

## Errors

| Condition | Response |
|---|---|
| Missing credentials | 401, `WWW-Authenticate: Bearer` |
| Malformed or invalid token | 401, Bearer `invalid_token` challenge |
| Oversized or duplicate bearer credentials | 401, Bearer `invalid_token` challenge |
| Resolver returns `None` | 403 |
| Malformed provider response | 502 |
| Provider timeout, rate limit, or 5xx | 503 |
| Missing lifespan or mapped-user type mismatch | 500 |

Detailed mode adds the fixed response detail as the challenge's `error_description`; generic mode
omits it. Bodies use FastAPI's stable `{'detail': '...'}` format without dynamic PyJWT, provider,
token, claim, or key values.

## 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

```bash
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`.
