Metadata-Version: 2.4
Name: fastapi-oidc-guard
Version: 0.1.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 user access tokens using OIDC discovery, cached JWKS,
PyJWT, and a mandatory UserInfo request. Authentication is opt-in through a typed FastAPI
dependency. It does not implement browser login, authorization-code flows, sessions, PKCE,
machine-to-machine tokens, or providers whose JWT access tokens cannot call UserInfo.

## 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` eagerly downloads and validates discovery metadata and JWKS during
application startup. Discovery must advertise `authorization_endpoint`, `token_endpoint`,
`jwks_uri`, and `userinfo_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 the provider is
unavailable or its metadata is 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.

## Verified Identity

`VerifiedIdentity` 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
- typed, non-optional `userinfo`

Known UserInfo fields are available as attributes. Additional non-null JSON claims are retained
in `userinfo.extra`.

## 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,
    expected_token_type=None,
    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.

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

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

## Key Rotation

JWKS is fetched only at application startup and when `jwks_cache_ttl` expires. Token-controlled
values, including an unknown `kid`, never trigger a network refresh.

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. Avoid changing `jwks_uri` without allowing both discovery and JWKS caches to refresh first.

Emergency or unannounced rotations can cause authentication failures until the cache expires.
This is intentional: provider key management, rather than attacker-controlled token headers,
determines when network refreshes occur.

## UserInfo Contract

Every accepted token must be a user access token that the discovered `userinfo_endpoint` accepts.
The library does not support providers without that endpoint, client-credentials tokens, or JWTs
issued for an API audience that cannot also call UserInfo. 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.

## Configuration Parsing

`OidcConfig` uses Pydantic's default extra-field behavior, which ignores unknown fields. It does
not impose an extra-field policy on an application's root configuration. Pydantic treats nested
models as separate configuration boundaries, so applications that need another policy can
subclass it:

```python
class StrictOidcConfig(OidcConfig, extra='forbid', frozen=True):
    pass
```

## Errors

| Condition | Response |
|---|---|
| Missing credentials | 401, `WWW-Authenticate: Bearer` |
| Malformed or invalid token | 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

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