Source code for litestar_security.providers.oidc._provider

"""OIDC provider lifecycle layered over OAuth and a distinct JWT verifier."""

from collections.abc import Mapping
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from hashlib import sha256
from hmac import compare_digest
from typing import NoReturn, cast

from litestar.exceptions import ImproperlyConfiguredException

from litestar_security.authentication import (
    Authenticated,
    AuthenticationOutcome,
    InvalidCredentials,
    VerificationUnavailable,
)
from litestar_security.config import WorkerLimits
from litestar_security.providers._internal import DynamicVerifierCache
from litestar_security.providers.jwks import JWKSProvider
from litestar_security.providers.jwt import (
    JWTClaims,
    JWTValidationConfig,
    JWTVerifier,
    PyJWTVerifier,
    VerificationKey,
    parse_unverified_jwt_route,
)
from litestar_security.providers.oauth import (
    OAuthClientAuth,
    OAuthEndpointConfig,
    OAuthHTTPPolicy,
    OAuthProviderClient,
    OAuthProviderError,
    OAuthTransaction,
    OAuthTransactionStart,
    ProviderIdentity,
    ProviderTokenSet,
    SecretStr,
)
from litestar_security.providers.oidc._discovery import OIDCDiscoveryClient, OIDCMetadata

__all__ = (
    "OIDCProvider",
    "discover_google_oidc_provider",
    "discover_oidc_provider",
    "google_oidc_provider",
    "keycloak_oidc_provider",
    "oidc_provider",
)


_GOOGLE_ISSUER = "https://accounts.google.com"
_DEFAULT_SCOPES = frozenset({"openid", "email", "profile"})
_ID_TOKEN_TYPES = frozenset({"jwt"})
_MAXIMUM_REAUTHENTICATION_AGE = 600
_MAXIMUM_TIMESTAMP = 253_402_300_799
_MAXIMUM_TOKEN_BYTES = 16_384


[docs] @dataclass(frozen=True, slots=True) class OIDCProvider: """OIDC code-flow provider with an independently configured ID-token verifier.""" oauth: OAuthProviderClient metadata: OIDCMetadata verifier: JWTVerifier[JWTClaims] retain_tokens_by_default: bool = True def __post_init__(self) -> None: """Pin discovery, OAuth endpoints, and JWT trust to one issuer/client.""" verifier_value = cast("object", self.verifier) if not isinstance(verifier_value, JWTVerifier): _raise_config("OIDC provider requires a JWT verifier") config = verifier_value.config if ( self.oauth.__class__ is not OAuthProviderClient or self.metadata.__class__ is not OIDCMetadata or self.metadata.authorization_endpoint is None or self.metadata.token_endpoint is None or self.oauth.config.authorization_endpoint != self.metadata.authorization_endpoint or self.oauth.config.token_endpoint != self.metadata.token_endpoint or config.issuer != self.metadata.issuer or config.audiences != frozenset({self.oauth.config.client_id}) or config.access_token_profile or config.token_types != _ID_TOKEN_TYPES or not config.algorithms or not config.algorithms.issubset(self.metadata.algorithms) or "openid" not in self.oauth.config.required_scopes or self.retain_tokens_by_default.__class__ is not bool ): _raise_config("OIDC provider trust configuration is inconsistent") @property def name(self) -> str: """Return the configured provider name.""" return self.oauth.name @property def issuer(self) -> str: """Return the exact discovered issuer.""" return self.metadata.issuer @property def end_session_endpoint(self) -> str | None: """Return the validated provider logout endpoint when advertised.""" return self.metadata.end_session_endpoint
[docs] def build_authorization_url(self, start: OAuthTransactionStart) -> str: """Build the OAuth authorization URL. Args: start: The bound transaction start values. Returns: The fixed provider authorization URL. """ return self.oauth.build_authorization_url(start)
[docs] def build_reauthentication_url(self, start: OAuthTransactionStart, *, max_age: int) -> str: """Build a forced-authentication request using OIDC ``max_age``. Args: start: The bound authorization transaction. max_age: Maximum accepted signed authentication age, including zero. Returns: The authorization URL with the reserved ``max_age`` parameter. Raises: OAuthProviderError: If the age is invalid or provider state is inconsistent. """ if max_age.__class__ is not int or not 0 <= max_age <= _MAXIMUM_REAUTHENTICATION_AGE: _raise_provider() return f"{self.oauth.build_authorization_url(start)}&max_age={max_age}"
[docs] async def exchange_code( self, *, code: SecretStr, transaction: OAuthTransaction, now: datetime | None = None ) -> ProviderTokenSet: """Exchange a code through the composed OAuth client. Args: code: The provider authorization code. transaction: The consumed transaction. now: The authoritative response time. Returns: The validated provider token set. """ return await self.oauth.exchange_code(code=code, transaction=transaction, now=now)
[docs] async def resolve_identity( self, tokens: ProviderTokenSet, *, transaction: OAuthTransaction, now: datetime | None = None ) -> ProviderIdentity: """Verify the ID token and bind its claims to the consumed transaction. Args: tokens: The provider token response containing an ID token. transaction: The consumed transaction containing issuer and nonce bindings. now: The authoritative verification time. Returns: An immutable normalized provider identity. Raises: OAuthProviderError: If verification or any OIDC binding fails. """ if ( tokens.__class__ is not ProviderTokenSet or tokens.id_token is None or transaction.__class__ is not OAuthTransaction or transaction.provider != self.name or transaction.expected_issuer != self.issuer or transaction.nonce is None ): _raise_provider() verification_time = _verification_time(now) outcome = await self.verifier.verify(tokens.id_token.get_secret_value(), now=verification_time) if not isinstance(outcome, Authenticated): _raise_provider() claims = outcome.claims raw = cast("Mapping[str, object]", claims.raw) if ( claims.issuer != self.issuer or claims.subject is None or self.oauth.config.client_id not in claims.audiences ): _raise_provider() _validate_authorized_party(raw.get("azp"), audiences=claims.audiences, client_id=self.oauth.config.client_id) _validate_nonce(raw.get("nonce"), transaction.nonce) display_name = _optional_text(raw.get("name")) if display_name is None: display_name = _optional_text(raw.get("preferred_username")) email = _optional_text(raw.get("email")) email_verified = _boolean(raw.get("email_verified", False)) if email_verified and email is None: _raise_provider() acr = _optional_text(raw.get("acr")) amr = _authentication_methods(raw.get("amr")) authenticated_at = _authentication_time(raw.get("auth_time")) return ProviderIdentity( provider=self.name, issuer=claims.issuer, subject=claims.subject, display_name=display_name, email=email, email_verified=email_verified, raw_claims=raw, acr=acr, amr=amr, authenticated_at=authenticated_at, )
[docs] async def refresh( self, refresh_token: SecretStr, *, current_scopes: frozenset[str] | None = None, now: datetime | None = None ) -> ProviderTokenSet: """Refresh provider credentials. Args: refresh_token: The protected refresh credential. current_scopes: Existing granted scopes when the provider omits them. now: The authoritative response time. Returns: The rotated token set. """ return await self.oauth.refresh(refresh_token, current_scopes=current_scopes, now=now)
[docs] async def revoke(self, token: SecretStr, *, token_type_hint: str | None) -> None: """Revoke a provider credential. Args: token: The credential to revoke. token_type_hint: Its optional standardized kind. """ await self.oauth.revoke(token, token_type_hint=token_type_hint)
[docs] async def aclose(self) -> None: """Close the owned OAuth transport.""" await self.oauth.aclose()
@dataclass(frozen=True, slots=True) class _DiscoveredOIDCVerifier: config: JWTValidationConfig jwks: JWKSProvider jwks_uri: str worker_limits: WorkerLimits = field(repr=False, compare=False) _verifiers: DynamicVerifierCache[PyJWTVerifier] = field( default_factory=DynamicVerifierCache[PyJWTVerifier], init=False, repr=False, compare=False ) async def verify(self, token: str, *, now: datetime) -> AuthenticationOutcome[JWTClaims]: route = parse_unverified_jwt_route(token, maximum_token_bytes=_MAXIMUM_TOKEN_BYTES) if isinstance(route, InvalidCredentials): return route algorithm = route.header.get("alg") key_id = route.header.get("kid") if not isinstance(algorithm, str) or algorithm not in self.config.algorithms: return InvalidCredentials() if not isinstance(key_id, str) or not key_id: return InvalidCredentials() selection = cast( "object", await self.jwks.select_key(self.config.issuer, self.jwks_uri, key_id, algorithm, now=now) ) if isinstance(selection, (InvalidCredentials, VerificationUnavailable)): return selection if not isinstance(selection, VerificationKey): return VerificationUnavailable() verifier = self._verifiers.get_or_create( (key_id, algorithm), selection.key, lambda: PyJWTVerifier( config=replace(self.config, algorithms=frozenset({algorithm})), key=selection.key, mechanism_name="oidc", slot_name="oauth.id-token", maximum_token_bytes=_MAXIMUM_TOKEN_BYTES, limiter=self.worker_limits.crypto_limiter, worker_timeout=self.worker_limits.timeout, ), ) return await verifier.verify(token, now=now)
[docs] async def discover_oidc_provider( # noqa: PLR0913 - discovery trust inputs remain explicit *, name: str, issuer: str, client_id: str, client_secret: SecretStr | None, discovery: OIDCDiscoveryClient, jwks: JWKSProvider, scopes: frozenset[str] = _DEFAULT_SCOPES, client_auth: OAuthClientAuth = OAuthClientAuth.CLIENT_SECRET_BASIC, worker_limits: WorkerLimits | None = None, http_policy: OAuthHTTPPolicy | None = None, ) -> OIDCProvider: """Discover and construct OIDC over application-owned shared resources. Args: name: Stable local provider name. issuer: Exact issuer allowed by the discovery client. client_id: Registered OIDC client identifier. client_secret: Protected client secret when required. discovery: Shared application-owned discovery client. jwks: Shared application-owned JWKS provider. scopes: Allowlisted provider scopes. client_auth: Token endpoint client authentication method. worker_limits: Shared bounded crypto-worker budget. http_policy: Bounded OAuth transport policy. Returns: A discovered provider whose OAuth client is owned by the result. Raises: ImproperlyConfiguredException: If shared resources or trust inputs are invalid. OIDCDiscoveryError: If discovery fails. """ if not isinstance(cast("object", discovery), OIDCDiscoveryClient) or not isinstance( cast("object", jwks), JWKSProvider ): _raise_config("OIDC discovery factory requires shared discovery and JWKS resources") metadata = await discovery.discover(issuer) workers = WorkerLimits() if worker_limits is None else worker_limits config = JWTValidationConfig( issuer=metadata.issuer, audiences=frozenset({client_id}), algorithms=metadata.algorithms, required_claims=frozenset({"iss", "sub", "aud", "exp", "iat"}), access_token_profile=False, token_types=_ID_TOKEN_TYPES, ) verifier = _DiscoveredOIDCVerifier(config=config, jwks=jwks, jwks_uri=metadata.jwks_uri, worker_limits=workers) return oidc_provider( name=name, client_id=client_id, client_secret=client_secret, metadata=metadata, verifier=verifier, scopes=scopes, client_auth=client_auth, http_policy=http_policy, )
[docs] async def discover_google_oidc_provider( # noqa: PLR0913 - discovery trust inputs remain explicit *, client_id: str, client_secret: SecretStr, discovery: OIDCDiscoveryClient, jwks: JWKSProvider, scopes: frozenset[str] = _DEFAULT_SCOPES, offline_access: bool = False, worker_limits: WorkerLimits | None = None, http_policy: OAuthHTTPPolicy | None = None, ) -> OIDCProvider: """Discover Google's exact issuer using caller-owned shared resources.""" if not isinstance(cast("object", discovery), OIDCDiscoveryClient) or not isinstance( cast("object", jwks), JWKSProvider ): _raise_config("OIDC discovery factory requires shared discovery and JWKS resources") metadata = await discovery.discover(_GOOGLE_ISSUER) workers = WorkerLimits() if worker_limits is None else worker_limits verifier = _DiscoveredOIDCVerifier( config=JWTValidationConfig( issuer=metadata.issuer, audiences=frozenset({client_id}), algorithms=metadata.algorithms, required_claims=frozenset({"iss", "sub", "aud", "exp", "iat"}), access_token_profile=False, token_types=_ID_TOKEN_TYPES, ), jwks=jwks, jwks_uri=metadata.jwks_uri, worker_limits=workers, ) return google_oidc_provider( client_id=client_id, client_secret=client_secret, metadata=metadata, verifier=verifier, scopes=scopes, offline_access=offline_access, http_policy=http_policy, )
[docs] def oidc_provider( # noqa: PLR0913 - constructor keeps every trust and transport input explicit *, name: str, client_id: str, client_secret: SecretStr | None, metadata: OIDCMetadata, verifier: JWTVerifier[JWTClaims], scopes: frozenset[str] = _DEFAULT_SCOPES, client_auth: OAuthClientAuth = OAuthClientAuth.CLIENT_SECRET_BASIC, revocation_endpoint: str | None = None, extra_authorization_parameters: Mapping[str, str] | None = None, http_policy: OAuthHTTPPolicy | None = None, ) -> OIDCProvider: """Construct a generic OIDC provider from validated discovery metadata. Args: name: Stable local provider name. client_id: Registered OIDC client identifier. client_secret: Protected client secret, if required. metadata: Validated discovery result. verifier: Distinct verifier pinned to the issuer and client. scopes: Allowed request scopes; ``openid`` is always required. client_auth: Token-endpoint client authentication method. revocation_endpoint: Optional fixed revocation endpoint. extra_authorization_parameters: Optional fixed authorization parameters. http_policy: Bounded OAuth transport policy. Returns: A configured OIDC lifecycle provider. Raises: ImproperlyConfiguredException: If required discovery endpoints are absent. """ if metadata.authorization_endpoint is None or metadata.token_endpoint is None: _raise_config("OIDC provider requires authorization and token endpoints") allowed_scopes = frozenset(scopes) if "openid" not in allowed_scopes: _raise_config("OIDC provider requires the openid scope") oauth = OAuthProviderClient( OAuthEndpointConfig( name=name, client_id=client_id, client_secret=client_secret, client_auth=client_auth, authorization_endpoint=metadata.authorization_endpoint, token_endpoint=metadata.token_endpoint, revocation_endpoint=revocation_endpoint, allowed_scopes=allowed_scopes, required_scopes=frozenset({"openid"}), extra_authorization_parameters=extra_authorization_parameters or {}, ), policy=http_policy, ) return OIDCProvider(oauth=oauth, metadata=metadata, verifier=verifier)
[docs] def google_oidc_provider( # noqa: PLR0913 - constructor keeps the provider trust inputs explicit *, client_id: str, client_secret: SecretStr, metadata: OIDCMetadata, verifier: JWTVerifier[JWTClaims], scopes: frozenset[str] = _DEFAULT_SCOPES, offline_access: bool = False, http_policy: OAuthHTTPPolicy | None = None, ) -> OIDCProvider: """Construct Google's pinned OIDC profile. Args: client_id: Registered Google client identifier. client_secret: Protected Google client secret. metadata: Validated Google discovery result. verifier: ID-token verifier pinned to Google and the client. scopes: Allowed Google scopes. offline_access: Request refresh access while the user is absent. http_policy: Bounded OAuth transport policy. Returns: The Google OIDC provider. Raises: ImproperlyConfiguredException: If discovery does not name Google's exact issuer. """ if metadata.issuer != _GOOGLE_ISSUER: _raise_config("Google OIDC issuer is invalid") configured = oidc_provider( name="google", client_id=client_id, client_secret=client_secret, metadata=metadata, verifier=verifier, scopes=scopes, revocation_endpoint=metadata.revocation_endpoint, extra_authorization_parameters={"access_type": "offline"} if offline_access else None, http_policy=http_policy, ) return OIDCProvider( oauth=configured.oauth, metadata=configured.metadata, verifier=configured.verifier, retain_tokens_by_default=offline_access, )
[docs] def keycloak_oidc_provider( # noqa: PLR0913 - constructor keeps realm trust and client inputs explicit *, base_url: str, realm: str, client_id: str, client_secret: SecretStr | None, metadata: OIDCMetadata, verifier: JWTVerifier[JWTClaims], scopes: frozenset[str] = _DEFAULT_SCOPES, client_auth: OAuthClientAuth = OAuthClientAuth.CLIENT_SECRET_BASIC, http_policy: OAuthHTTPPolicy | None = None, ) -> OIDCProvider: """Construct a Keycloak provider pinned to one exact realm issuer. Args: base_url: Exact HTTPS Keycloak base URL. realm: Exact realm path segment. client_id: Registered Keycloak client identifier. client_secret: Protected client secret, if required. metadata: Validated realm discovery result. verifier: ID-token verifier pinned to the realm and client. scopes: Allowed Keycloak scopes. client_auth: Token-endpoint client authentication method. http_policy: Bounded OAuth transport policy. Returns: The realm-pinned Keycloak OIDC provider. Raises: ImproperlyConfiguredException: If the realm issuer is not exact. """ if ( not _strict_text(base_url) or base_url.endswith("/") or not _strict_text(realm) or "/" in realm or metadata.issuer != f"{base_url}/realms/{realm}" ): _raise_config("Keycloak OIDC realm issuer is invalid") return oidc_provider( name="keycloak", client_id=client_id, client_secret=client_secret, metadata=metadata, verifier=verifier, scopes=scopes, client_auth=client_auth, http_policy=http_policy, )
def _validate_authorized_party(value: object, *, audiences: frozenset[str], client_id: str) -> None: if (len(audiences) > 1 and value != client_id) or (value is not None and value != client_id): _raise_provider() def _validate_nonce(value: object, expected: SecretStr) -> None: if not _strict_text(value): _raise_provider() actual_digest = sha256(cast("str", value).encode()).digest() expected_digest = sha256(expected.get_secret_value().encode()).digest() if not compare_digest(actual_digest, expected_digest): _raise_provider() def _authentication_methods(value: object) -> tuple[str, ...]: if value is None: return () methods = cast("list[object] | tuple[object, ...]", value) if ( not isinstance(value, (list, tuple)) or any(not _strict_text(method) for method in methods) or len(methods) != len(set(cast("list[str] | tuple[str, ...]", methods))) ): _raise_provider() return tuple(cast("list[str] | tuple[str, ...]", methods)) def _authentication_time(value: object) -> datetime | None: if value is None: return None if not isinstance(value, int) or isinstance(value, bool): _raise_provider() timestamp = value if not 0 <= timestamp <= _MAXIMUM_TIMESTAMP: _raise_provider() try: return datetime.fromtimestamp(timestamp, timezone.utc) except (OverflowError, OSError, ValueError): _raise_provider() def _boolean(value: object) -> bool: if not isinstance(value, bool) or value.__class__ is not bool: _raise_provider() return value def _optional_text(value: object) -> str | None: if value is None: return None if not _strict_text(value): _raise_provider() return cast("str", value) def _verification_time(value: datetime | None) -> datetime: now = value or datetime.now(timezone.utc) if now.tzinfo is None or now.utcoffset() is None: _raise_provider() return now.astimezone(timezone.utc) def _strict_text(value: object) -> bool: return isinstance(value, str) and value.__class__ is str and bool(value.strip()) and value == value.strip() def _raise_provider() -> NoReturn: raise OAuthProviderError def _raise_config(message: str) -> NoReturn: raise ImproperlyConfiguredException(detail=message)