Source code for litestar_security.providers.iap._iap

"""Authoritative verification of Google IAP signed assertions."""

from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from types import MappingProxyType
from typing import Any, Generic, Literal, TypeVar, cast

from litestar.connection import ASGIConnection
from litestar.exceptions import ImproperlyConfiguredException
from litestar.openapi.spec import SecurityScheme

from litestar_security.authentication import (
    Authenticated,
    AuthenticationMechanism,
    AuthenticationOutcome,
    CredentialExtraction,
    CredentialSlot,
    IdentityResolver,
    InvalidCredentials,
    NoCredentials,
    PresentedCredential,
    VerificationUnavailable,
)
from litestar_security.config import WorkerLimits
from litestar_security.context import AuthenticationEvidence
from litestar_security.providers._internal import DynamicVerifierCache
from litestar_security.providers.jwks import JWKSProvider
from litestar_security.providers.jwt import (
    JWTClaims,
    JWTValidationConfig,
    PyJWTVerifier,
    VerificationKey,
    parse_unverified_jwt_route,
)

__all__ = ("GoogleIAPClaims", "GoogleIAPConfig", "GoogleIAPExternalIdentity")


UserT = TypeVar("UserT")

_IAP_ISSUER = "https://cloud.google.com/iap"
_IAP_JWKS_URI = "https://www.gstatic.com/iap/verify/public_key-jwk"
_IAP_HEADER = "X-Goog-IAP-JWT-Assertion"
_MAXIMUM_ASSERTION_BYTES = 16_384
_MAXIMUM_ASSERTION_LIFETIME = timedelta(minutes=10)
_MAXIMUM_SIGN_IN_ATTRIBUTES = 32
_MAXIMUM_SIGN_IN_ATTRIBUTE_LENGTH = 1_024
_MAXIMUM_ACCESS_LEVELS = 64


[docs] @dataclass(frozen=True, slots=True) class GoogleIAPExternalIdentity: """Validated external Identity Platform identity nested in an IAP assertion.""" subject: str email: str | None = None email_verified: bool | None = None sign_in_provider: str | None = None tenant: str | None = None sign_in_attributes: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
[docs] @dataclass(frozen=True, slots=True) class GoogleIAPClaims: """Verified IAP identity fields offered to the application resolver.""" subject: str email: str | None = None authorized_party: str | None = None hosted_domain: str | None = None access_levels: tuple[str, ...] = () device_id: str | None = None external_identity: GoogleIAPExternalIdentity | None = None
[docs] @dataclass(frozen=True, slots=True) class GoogleIAPConfig(Generic[UserT]): """Pinned trust configuration for Google IAP signed assertions.""" audience: str | frozenset[str] identity_resolver: IdentityResolver[GoogleIAPClaims, UserT] jwks: JWKSProvider issuer: str = _IAP_ISSUER header_name: str = _IAP_HEADER clock_skew: timedelta = timedelta(seconds=30) worker_limits: WorkerLimits = field(default_factory=WorkerLimits, repr=False, compare=False) _validation: JWTValidationConfig = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: """Normalize the audience and reject configurable trust downgrades.""" audiences = frozenset({self.audience}) if isinstance(self.audience, str) else frozenset(self.audience) resolver = cast("object", self.identity_resolver) jwks = cast("object", self.jwks) if ( not audiences or any(not value.strip() for value in audiences) or self.issuer != _IAP_ISSUER or not _valid_header_name(self.header_name) or self.clock_skew.__class__ is not timedelta or self.clock_skew < timedelta(0) or not isinstance(cast("object", self.worker_limits), WorkerLimits) or not callable(getattr(resolver, "resolve", None)) or not isinstance(jwks, JWKSProvider) ): raise ImproperlyConfiguredException(detail="Google IAP configuration is invalid") validation = JWTValidationConfig( issuer=self.issuer, audiences=audiences, algorithms=frozenset({"ES256"}), required_claims=frozenset({"iss", "sub", "aud", "iat", "exp"}), access_token_profile=False, subject_required=True, clock_skew=self.clock_skew, token_types=frozenset({"JWT"}), ) object.__setattr__(self, "audience", audiences) object.__setattr__(self, "_validation", validation)
[docs] def build( self, *, clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc) ) -> "tuple[CredentialSlot[str], AuthenticationMechanism[str, GoogleIAPClaims, UserT]]": """Build the sole IAP assertion slot and authoritative mechanism. Args: clock: Time source used for key freshness and claim validation. Returns: The signed-header slot and paired IAP mechanism. """ if not callable(clock): raise ImproperlyConfiguredException(detail="Google IAP clock must be callable") slot = _GoogleIAPSlot(header_name=self.header_name) authenticator = _GoogleIAPAuthenticator(config=self, validation=self._validation, clock=clock) return slot, AuthenticationMechanism( authenticator=authenticator, resolver=self.identity_resolver, scheme_name="GoogleIAP", security_scheme=SecurityScheme( type="apiKey", name=self.header_name, security_scheme_in="header", description=( "Assertion header injected by Google IAP; normal API clients must not supply this value manually." ), ), )
@dataclass(slots=True) class _GoogleIAPSlot: header_name: str name: str = field(default="google-iap", init=False) def extract(self, connection: ASGIConnection[Any, Any, Any, Any]) -> CredentialExtraction[str]: expected = self.header_name.lower().encode("ascii") values = tuple(value for name, value in connection.scope["headers"] if name.lower() == expected) if not values: return NoCredentials() if len(values) != 1 or not values[0] or len(values[0]) > _MAXIMUM_ASSERTION_BYTES: return InvalidCredentials() try: return PresentedCredential(values[0].decode("ascii")) except (AttributeError, UnicodeDecodeError): return InvalidCredentials() @dataclass(slots=True) class _GoogleIAPAuthenticator(Generic[UserT]): config: GoogleIAPConfig[UserT] validation: JWTValidationConfig clock: Callable[[], datetime] = field(repr=False) name: str = field(default="google-iap", init=False) slot: str = field(default="google-iap", init=False) participates_by_default: bool = True _verifiers: DynamicVerifierCache[PyJWTVerifier] = field( default_factory=DynamicVerifierCache[PyJWTVerifier], init=False, repr=False, compare=False ) async def authenticate( # noqa: PLR0911 - every trust failure retains its structured security outcome self, credential: str, connection: ASGIConnection[Any, Any, Any, Any] ) -> AuthenticationOutcome[GoogleIAPClaims]: del connection now = self.clock() if now.tzinfo is None or now.utcoffset() is None: return InvalidCredentials() now = now.astimezone(timezone.utc) route = parse_unverified_jwt_route(credential, maximum_token_bytes=_MAXIMUM_ASSERTION_BYTES) if isinstance(route, InvalidCredentials): return route algorithm = route.header.get("alg") key_id = route.header.get("kid") if algorithm != "ES256" or not isinstance(key_id, str) or not key_id: return InvalidCredentials() selection = cast( "object", await self.config.jwks.select_key( self.config.issuer, _IAP_JWKS_URI, key_id, algorithm, # pyright: ignore[reportArgumentType] - exact ES256 check narrows this value now=now, ), ) if isinstance(selection, (InvalidCredentials, VerificationUnavailable)): return selection if not isinstance(selection, VerificationKey): return VerificationUnavailable() verifier = self._verifiers.get_or_create( (key_id, "ES256"), selection.key, lambda: PyJWTVerifier( config=self.validation, key=selection.key, mechanism_name=self.name, slot_name=self.slot, maximum_token_bytes=_MAXIMUM_ASSERTION_BYTES, limiter=self.config.worker_limits.crypto_limiter, worker_timeout=self.config.worker_limits.timeout, ), ) outcome = await verifier.verify(credential, now=now) if not isinstance(outcome, Authenticated): return outcome claims = outcome.claims if claims.expires_at - claims.issued_at > _MAXIMUM_ASSERTION_LIFETIME + (self.config.clock_skew * 2): return InvalidCredentials() subject = claims.subject email = _optional_claim(claims, "email") authorized_party = _optional_claim(claims, "azp") hosted_domain = _optional_claim(claims, "hd") google = _google_claims(claims.raw.get("google")) external_identity = _external_identity(claims.raw.get("gcip")) if ( subject is None or email is False or authorized_party is False or hosted_domain is False or google is None or external_identity is False ): return InvalidCredentials() access_levels, device_id = google return Authenticated( claims=GoogleIAPClaims( subject=subject, email=cast("str | None", email), authorized_party=cast("str | None", authorized_party), hosted_domain=cast("str | None", hosted_domain), access_levels=access_levels, device_id=device_id, external_identity=external_identity, ), evidence=AuthenticationEvidence( mechanism=self.name, slot=self.slot, authenticated_at=claims.issued_at, expires_at=claims.expires_at, methods=frozenset({"iap"}), traits=frozenset({"federated"}), ), ) def _google_claims(value: object) -> tuple[tuple[str, ...], str | None] | None: if value is None: return (), None if not isinstance(value, Mapping): return None claims = cast("Mapping[str, object]", value) access_levels = claims.get("access_levels", []) device_id = claims.get("device_id") typed_access_levels = cast("Sequence[object]", access_levels) if ( not isinstance(access_levels, Sequence) or isinstance(access_levels, (str, bytes)) or len(typed_access_levels) > _MAXIMUM_ACCESS_LEVELS or any( not isinstance(item, str) or not item or len(item) > _MAXIMUM_SIGN_IN_ATTRIBUTE_LENGTH for item in typed_access_levels ) or ( device_id is not None and (not isinstance(device_id, str) or not device_id or len(device_id) > _MAXIMUM_SIGN_IN_ATTRIBUTE_LENGTH) ) ): return None return tuple(cast("Sequence[str]", access_levels)), device_id def _external_identity(value: object) -> GoogleIAPExternalIdentity | Literal[False] | None: if value is None: return None if not isinstance(value, Mapping): return False claims = cast("Mapping[str, object]", value) subject = claims.get("sub") email = claims.get("email") email_verified = claims.get("email_verified") provider = claims.get("sign_in_provider") tenant = claims.get("tenant") attributes = claims.get("sign_in_attributes", {}) typed_attributes = cast("Mapping[object, object]", attributes) optional_text = (email, provider, tenant) if ( not isinstance(subject, str) or not subject or len(subject) > _MAXIMUM_SIGN_IN_ATTRIBUTE_LENGTH or any( item is not None and (not isinstance(item, str) or not item or len(item) > _MAXIMUM_SIGN_IN_ATTRIBUTE_LENGTH) for item in optional_text ) or (email_verified is not None and email_verified.__class__ is not bool) or not isinstance(attributes, Mapping) or len(typed_attributes) > _MAXIMUM_SIGN_IN_ATTRIBUTES or any( not isinstance(key, str) or not key or not isinstance(item, str) or len(key) > _MAXIMUM_SIGN_IN_ATTRIBUTE_LENGTH or len(item) > _MAXIMUM_SIGN_IN_ATTRIBUTE_LENGTH for key, item in typed_attributes.items() ) ): return False typed_email_verified = email_verified if isinstance(email_verified, bool) else None return GoogleIAPExternalIdentity( subject=subject, email=cast("str | None", email), email_verified=typed_email_verified, sign_in_provider=cast("str | None", provider), tenant=cast("str | None", tenant), sign_in_attributes=MappingProxyType(dict(cast("Mapping[str, str]", attributes))), ) def _optional_claim(claims: JWTClaims, name: str) -> str | bool | None: value = claims.raw.get(name) if value is None: return None if not isinstance(value, str) or not value: return False return value def _valid_header_name(value: object) -> bool: if not isinstance(value, str) or not value or value != value.strip(): return False return all(character.isascii() and (character.isalnum() or character in "!#$%&'*+-.^_`|~") for character in value)