Coverage for src/lumen/auth/token.py: 100%
75 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
1"""
2This file validates OIDC/SMART bearer tokens and resolves the associated tenant.
3It decodes JWTs, verifies signatures via JWKS, and extracts tenant identifiers.
4"""
6from __future__ import annotations
8from uuid import UUID
10import jwt
11import structlog
12from fastapi import HTTPException, Request, status
14from ..config import SmartSettings
15from ..core.context import current_claims
16from .jwks import discover_jwks_uri, get_jwks_cache
18log = structlog.get_logger()
20_SUPPORTED_ALGORITHMS = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"]
22# WWW-Authenticate header returned on 401 (RFC 6750 §3).
23_WWW_AUTH = 'Bearer realm="lumen"'
26# TODO: Move errors classes to a separate file, e.g., lumen/core/errors.py
27class AuthError(HTTPException):
28 """401 or 403 with a FHIR OperationOutcome body (built by the exception handler)."""
30 def __init__(self, status_code: int, fhir_code: str, diagnostics: str) -> None:
31 headers = (
32 {"WWW-Authenticate": _WWW_AUTH} if status_code == status.HTTP_401_UNAUTHORIZED else {}
33 )
34 super().__init__(
35 status_code=status_code,
36 detail={"fhir_code": fhir_code, "diagnostics": diagnostics},
37 headers=headers,
38 )
41def _extract_bearer(request: Request) -> str:
42 auth = request.headers.get("Authorization", "")
43 if not auth.startswith("Bearer "):
44 raise AuthError(
45 status.HTTP_401_UNAUTHORIZED,
46 "login",
47 "Missing or malformed Authorization header — expected 'Bearer <token>'.",
48 )
49 return auth[len("Bearer ") :]
52async def validate_token(request: Request, settings: SmartSettings) -> UUID:
53 """Validate the Bearer token and return the resolved tenant_id."""
54 raw = _extract_bearer(request)
56 # Peek at the header to obtain `kid` and `iss` without full verification.
57 try:
58 unverified_header = jwt.get_unverified_header(raw)
59 unverified_claims = jwt.decode(raw, options={"verify_signature": False})
60 except jwt.DecodeError as exc:
61 log.warning("auth.token_invalid", error=f"Token decode error: {exc}")
62 raise AuthError(
63 status.HTTP_401_UNAUTHORIZED, "security", f"Token decode error: {exc}"
64 ) from exc
66 alg = unverified_header.get("alg", "")
67 kid = unverified_header.get("kid", "")
68 iss: str = unverified_claims.get("iss", "")
70 if alg not in _SUPPORTED_ALGORITHMS:
71 log.warning("auth.token_invalid", error=f"Unsupported algorithm: {alg!r}")
72 raise AuthError(
73 status.HTTP_401_UNAUTHORIZED, "security", f"Unsupported algorithm: {alg!r}."
74 )
76 if not iss:
77 log.warning("auth.token_invalid", error="Token missing 'iss' claim.")
78 raise AuthError(status.HTTP_401_UNAUTHORIZED, "security", "Token missing 'iss' claim.")
80 # Resolve JWKS URI from the issuer's OIDC discovery document.
81 # Use internal_issuer_map to translate external → internal base URL when
82 # Lumen runs in Docker and cannot reach the external issuer URL directly.
83 fetch_base = settings.internal_issuer_map.get(iss)
84 cache = get_jwks_cache(ttl=settings.jwks_cache_ttl)
85 jwks_uri: str = ""
86 try:
87 jwks_uri = await discover_jwks_uri(iss, fetch_base=fetch_base)
88 public_key = await cache.get_public_key(jwks_uri, kid)
89 except KeyError as exc:
90 # `kid` not in JWKS — try once after invalidating cache (key rotation).
91 cache.invalidate(jwks_uri)
92 try:
93 public_key = await cache.get_public_key(jwks_uri, kid)
94 except KeyError:
95 log.warning("auth.token_invalid", error=str(exc))
96 raise AuthError(status.HTTP_401_UNAUTHORIZED, "security", str(exc)) from exc
97 except Exception as exc:
98 log.error("auth.jwks_fetch_failed", error=str(exc), iss=iss)
99 raise AuthError(
100 status.HTTP_401_UNAUTHORIZED, "security", f"Failed to fetch JWKS: {exc}"
101 ) from exc
103 # Full verification: signature + iss + aud + exp.
104 try:
105 claims: dict[str, object] = jwt.decode(
106 raw,
107 public_key,
108 algorithms=[alg],
109 audience=settings.audience,
110 issuer=iss,
111 )
112 except jwt.ExpiredSignatureError as exc:
113 log.warning("auth.token_invalid", error="Token has expired")
114 raise AuthError(status.HTTP_401_UNAUTHORIZED, "security", "Token has expired.") from exc
115 except jwt.InvalidAudienceError as exc:
116 log.warning("auth.token_invalid", error=f"Invalid audience: {exc}")
117 raise AuthError(
118 status.HTTP_401_UNAUTHORIZED, "security", f"Invalid audience: {exc}"
119 ) from exc
120 except jwt.InvalidIssuerError as exc:
121 log.warning("auth.token_invalid", error=f"Invalid issuer: {exc}")
122 raise AuthError(status.HTTP_401_UNAUTHORIZED, "security", f"Invalid issuer: {exc}") from exc
123 except jwt.PyJWTError as exc:
124 log.warning("auth.token_invalid", error=f"Token invalid: {exc}")
125 raise AuthError(status.HTTP_401_UNAUTHORIZED, "security", f"Token invalid: {exc}") from exc
127 # Map issuer -> tenant_id.
128 tenant_id = settings.tenant_map.get(iss)
129 if tenant_id is None:
130 log.warning("auth.unknown_issuer", iss=iss)
131 raise AuthError(
132 status.HTTP_403_FORBIDDEN,
133 "forbidden",
134 f"Issuer '{iss}' is not registered with this server.",
135 )
137 log.debug("auth.token_valid", iss=iss, tenant_id=str(tenant_id))
139 # Store full claims so the scope enforcer can read them without re-validating.
140 request.state.smart_claims = claims
141 current_claims.set(claims)
142 return tenant_id