Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,28 @@
|
||||
"""OIDC identity, browser BFF, and machine-principal boundaries."""
|
||||
|
||||
from .bff import BFFService
|
||||
from .config import OIDCSettings
|
||||
from .machine import (
|
||||
MachineClientBinding,
|
||||
MachinePrincipalAuthenticator,
|
||||
PrincipalMapper,
|
||||
RevocationCache,
|
||||
)
|
||||
from .models import AuthenticatedPrincipal, ManagementRole, PrincipalKind, SessionMetadata
|
||||
from .provider import OIDCProviderClient
|
||||
from .router import build_auth_router
|
||||
|
||||
__all__ = [
|
||||
"AuthenticatedPrincipal",
|
||||
"BFFService",
|
||||
"MachineClientBinding",
|
||||
"MachinePrincipalAuthenticator",
|
||||
"PrincipalMapper",
|
||||
"ManagementRole",
|
||||
"OIDCProviderClient",
|
||||
"OIDCSettings",
|
||||
"PrincipalKind",
|
||||
"RevocationCache",
|
||||
"build_auth_router",
|
||||
"SessionMetadata",
|
||||
]
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Same-origin browser BFF with server-side opaque sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http.cookies import SimpleCookie
|
||||
|
||||
from .config import OIDCSettings
|
||||
from .errors import CSRFError, InvalidState, SessionExpired
|
||||
from .models import (
|
||||
AuthenticatedPrincipal,
|
||||
PrincipalKind,
|
||||
SessionCookie,
|
||||
SessionMetadata,
|
||||
TokenSet,
|
||||
ValidatedClaims,
|
||||
)
|
||||
from .provider import OIDCProviderClient
|
||||
|
||||
|
||||
def _hash(value: str, key: bytes | None = None) -> str:
|
||||
payload = value.encode("ascii")
|
||||
return (
|
||||
hmac.new(key, payload, hashlib.sha256).hexdigest()
|
||||
if key
|
||||
else hashlib.sha256(payload).hexdigest()
|
||||
)
|
||||
|
||||
|
||||
def _pkce_challenge(verifier: str) -> str:
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PendingLogin:
|
||||
nonce: str
|
||||
code_verifier: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SessionRecord:
|
||||
principal: AuthenticatedPrincipal
|
||||
access_token: str
|
||||
refresh_token: str | None
|
||||
csrf_token: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""Thread-safe in-memory session/CSRF store -- this IS the production
|
||||
implementation, not a placeholder. It is a deliberate, documented,
|
||||
single-instance-only design (docs/02-security/SESSION_STORE_DECISION.md):
|
||||
control-api must run as exactly one process/replica, every session and
|
||||
every CSRF token is lost on restart (users simply re-authenticate), and
|
||||
horizontal scaling requires a real shared store first. The Compose
|
||||
single-instance constraint this depends on is enforced statically by
|
||||
scripts/compose_release_check.py, not just documented."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ttl_seconds: int,
|
||||
cookie_name: str,
|
||||
same_site: str,
|
||||
secure: bool,
|
||||
hash_key: bytes | None = None,
|
||||
) -> None:
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.cookie_name = cookie_name
|
||||
self.same_site = same_site
|
||||
self.secure = secure
|
||||
self._hash_key = hash_key
|
||||
self._records: dict[str, _SessionRecord] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def create(
|
||||
self,
|
||||
principal: AuthenticatedPrincipal,
|
||||
token_set: TokenSet,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> SessionCookie:
|
||||
reference = now or datetime.now(UTC)
|
||||
expires_at = min(
|
||||
principal.expires_at,
|
||||
reference + timedelta(seconds=self.ttl_seconds),
|
||||
)
|
||||
if expires_at <= reference:
|
||||
raise SessionExpired()
|
||||
session_value = secrets.token_urlsafe(32)
|
||||
csrf_token = secrets.token_urlsafe(24)
|
||||
record = _SessionRecord(
|
||||
principal=principal,
|
||||
access_token=token_set.access_token,
|
||||
refresh_token=token_set.refresh_token,
|
||||
csrf_token=csrf_token,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
with self._lock:
|
||||
self._records[_hash(session_value, self._hash_key)] = record
|
||||
metadata = SessionMetadata(
|
||||
subject=principal.subject,
|
||||
client_id=principal.client_id,
|
||||
kind=principal.kind,
|
||||
roles=principal.roles,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
return SessionCookie(
|
||||
value=session_value,
|
||||
csrf_token=csrf_token,
|
||||
metadata=metadata,
|
||||
set_cookie=self._cookie(session_value, expires_at),
|
||||
)
|
||||
|
||||
def get(self, session_value: str | None, *, now: datetime | None = None) -> _SessionRecord:
|
||||
if not session_value or len(session_value) > 256:
|
||||
raise SessionExpired()
|
||||
reference = now or datetime.now(UTC)
|
||||
key = _hash(session_value, self._hash_key)
|
||||
with self._lock:
|
||||
record = self._records.get(key)
|
||||
if record is None:
|
||||
raise SessionExpired()
|
||||
if record.expires_at <= reference:
|
||||
self._records.pop(key, None)
|
||||
raise SessionExpired()
|
||||
return record
|
||||
|
||||
def logout(self, session_value: str | None) -> None:
|
||||
if session_value:
|
||||
with self._lock:
|
||||
self._records.pop(_hash(session_value, self._hash_key), None)
|
||||
|
||||
def _cookie(self, value: str, expires_at: datetime) -> str:
|
||||
cookie = SimpleCookie()
|
||||
cookie[self.cookie_name] = value
|
||||
morsel = cookie[self.cookie_name]
|
||||
morsel["httponly"] = True
|
||||
morsel["secure"] = self.secure
|
||||
morsel["samesite"] = self.same_site
|
||||
morsel["path"] = "/"
|
||||
morsel["expires"] = expires_at.astimezone(UTC).strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||
return morsel.OutputString()
|
||||
|
||||
def clear_cookie(self) -> str:
|
||||
cookie = SimpleCookie()
|
||||
cookie[self.cookie_name] = ""
|
||||
morsel = cookie[self.cookie_name]
|
||||
morsel["httponly"] = True
|
||||
morsel["secure"] = self.secure
|
||||
morsel["samesite"] = self.same_site
|
||||
morsel["path"] = "/"
|
||||
morsel["max-age"] = 0
|
||||
return morsel.OutputString()
|
||||
|
||||
|
||||
class BFFService:
|
||||
"""Coordinates PKCE login, opaque sessions, CSRF, logout, and safe metadata."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: OIDCSettings,
|
||||
provider: OIDCProviderClient,
|
||||
*,
|
||||
principal_mapper: Callable[[ValidatedClaims], AuthenticatedPrincipal],
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.provider = provider
|
||||
self.principal_mapper = principal_mapper
|
||||
self.sessions = SessionStore(
|
||||
ttl_seconds=settings.session_ttl_seconds,
|
||||
cookie_name=settings.cookie_name,
|
||||
same_site=settings.same_site,
|
||||
secure=settings.secure_cookie,
|
||||
hash_key=settings.session_signing_key(),
|
||||
)
|
||||
self._pending: dict[str, _PendingLogin] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def begin_login(self, *, now: datetime | None = None) -> str:
|
||||
reference = now or datetime.now(UTC)
|
||||
state = secrets.token_urlsafe(32)
|
||||
pending = _PendingLogin(
|
||||
nonce=secrets.token_urlsafe(24),
|
||||
code_verifier=secrets.token_urlsafe(48),
|
||||
expires_at=reference + timedelta(minutes=10),
|
||||
)
|
||||
with self._lock:
|
||||
self._pending[_hash(state)] = pending
|
||||
return self.provider.build_authorization_url(
|
||||
state=state,
|
||||
nonce=pending.nonce,
|
||||
code_challenge=_pkce_challenge(pending.code_verifier),
|
||||
)
|
||||
|
||||
def complete_login(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
state: str,
|
||||
now: datetime | None = None,
|
||||
) -> SessionCookie:
|
||||
reference = now or datetime.now(UTC)
|
||||
with self._lock:
|
||||
pending = self._pending.pop(_hash(state), None)
|
||||
if pending is None or pending.expires_at <= reference:
|
||||
raise InvalidState()
|
||||
token_set = self.provider.exchange_code(code=code, code_verifier=pending.code_verifier)
|
||||
raw_token = token_set.id_token or token_set.access_token
|
||||
claims = self.provider.validate_token(raw_token, now=reference)
|
||||
if token_set.id_token:
|
||||
if claims.nonce != pending.nonce:
|
||||
raise InvalidState()
|
||||
principal = self.principal_mapper(claims)
|
||||
if principal.kind is not PrincipalKind.MANAGEMENT_USER:
|
||||
raise InvalidState()
|
||||
return self.sessions.create(principal, token_set, now=reference)
|
||||
|
||||
def session_metadata(
|
||||
self, session_value: str | None, *, now: datetime | None = None
|
||||
) -> SessionMetadata:
|
||||
record = self.sessions.get(session_value, now=now)
|
||||
return SessionMetadata(
|
||||
subject=record.principal.subject,
|
||||
client_id=record.principal.client_id,
|
||||
kind=record.principal.kind,
|
||||
roles=record.principal.roles,
|
||||
expires_at=record.expires_at,
|
||||
)
|
||||
|
||||
def require_session(
|
||||
self, session_value: str | None, *, now: datetime | None = None
|
||||
) -> AuthenticatedPrincipal:
|
||||
return self.sessions.get(session_value, now=now).principal
|
||||
|
||||
def verify_csrf(self, session_value: str | None, csrf_token: str | None) -> None:
|
||||
"""Require the session-bound token for every unsafe browser request."""
|
||||
|
||||
record = self.sessions.get(session_value)
|
||||
if not csrf_token or not secrets.compare_digest(record.csrf_token, csrf_token):
|
||||
raise CSRFError()
|
||||
|
||||
def logout(self, session_value: str | None, csrf_token: str | None) -> str:
|
||||
self.verify_csrf(session_value, csrf_token)
|
||||
self.sessions.logout(session_value)
|
||||
return self.sessions.clear_cookie()
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Environment-only OIDC configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Literal, cast
|
||||
|
||||
from pydantic import (
|
||||
AnyHttpUrl,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
TypeAdapter,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from packages.hub_runtime import SecretFileError, read_secret_file
|
||||
|
||||
from .errors import ConfigurationError
|
||||
from .models import ManagementRole
|
||||
|
||||
|
||||
class OIDCSettings(BaseModel):
|
||||
"""Validated configuration; secret values are never persisted in this model."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
issuer: str = Field(min_length=1, max_length=2048)
|
||||
audience: str = Field(min_length=1, max_length=256)
|
||||
client_id: str = Field(min_length=1, max_length=256)
|
||||
redirect_uri: AnyHttpUrl
|
||||
client_secret_env: str | None = Field(default=None, min_length=1, max_length=256)
|
||||
client_secret_file: Path | None = None
|
||||
session_signing_key_file: Path | None = None
|
||||
session_ttl_seconds: int = Field(default=28800, ge=60, le=28800)
|
||||
clock_skew_seconds: int = Field(default=60, ge=0, le=120)
|
||||
revocation_ttl_seconds: int = Field(default=60, ge=1, le=60)
|
||||
cookie_name: str = Field(default="hub_session", pattern=r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
|
||||
same_site: Literal["strict", "lax"] = "strict"
|
||||
secure_cookie: bool = True
|
||||
allowed_algorithms: tuple[str, ...] = ("RS256",)
|
||||
role_group_map: dict[str, ManagementRole] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("issuer")
|
||||
@classmethod
|
||||
def preserve_exact_https_issuer(cls, value: str) -> str:
|
||||
parsed = TypeAdapter(AnyHttpUrl).validate_python(value)
|
||||
if parsed.scheme != "https" or parsed.username is not None or parsed.password is not None:
|
||||
raise ValueError("OIDC issuer must be an HTTPS URL without userinfo")
|
||||
return value
|
||||
|
||||
@field_validator("secure_cookie")
|
||||
@classmethod
|
||||
def require_secure_cookie(cls, value: bool) -> bool:
|
||||
if not value:
|
||||
raise ValueError("session cookies must be Secure")
|
||||
return value
|
||||
|
||||
@field_validator("allowed_algorithms")
|
||||
@classmethod
|
||||
def validate_algorithms(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
if not value or any(
|
||||
algorithm in {"none", "HS256", "HS384", "HS512"} for algorithm in value
|
||||
):
|
||||
raise ValueError("only asymmetric JWT algorithms are allowed")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def one_client_secret_source(self) -> OIDCSettings:
|
||||
if self.client_secret_env and self.client_secret_file:
|
||||
raise ValueError("configure only one OIDC client secret source")
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def from_env(cls, environ: Mapping[str, str] | None = None) -> OIDCSettings:
|
||||
values = environ if environ is not None else os.environ
|
||||
required = {
|
||||
"issuer": values.get("HUB_OIDC_ISSUER"),
|
||||
"audience": values.get("HUB_OIDC_AUDIENCE"),
|
||||
"client_id": values.get("HUB_OIDC_CLIENT_ID"),
|
||||
"redirect_uri": values.get("HUB_OIDC_REDIRECT_URI"),
|
||||
}
|
||||
missing = [key for key, value in required.items() if not value]
|
||||
if missing:
|
||||
raise ConfigurationError("missing required OIDC configuration")
|
||||
role_map: dict[str, ManagementRole] = {}
|
||||
raw_role_map = values.get("HUB_OIDC_ROLE_GROUP_MAP")
|
||||
if raw_role_map:
|
||||
try:
|
||||
decoded = json.loads(raw_role_map)
|
||||
if not isinstance(decoded, dict):
|
||||
raise ValueError
|
||||
role_map = {
|
||||
str(group): ManagementRole(str(role)) for group, role in decoded.items()
|
||||
}
|
||||
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise ConfigurationError("invalid OIDC role mapping") from exc
|
||||
try:
|
||||
return cls(
|
||||
issuer=cast(str, required["issuer"]),
|
||||
audience=cast(str, required["audience"]),
|
||||
client_id=cast(str, required["client_id"]),
|
||||
redirect_uri=cast(AnyHttpUrl, required["redirect_uri"]),
|
||||
client_secret_env=values.get("HUB_OIDC_CLIENT_SECRET_ENV"),
|
||||
client_secret_file=(
|
||||
Path(values["HUB_OIDC_CLIENT_SECRET_FILE"])
|
||||
if values.get("HUB_OIDC_CLIENT_SECRET_FILE")
|
||||
else None
|
||||
),
|
||||
session_signing_key_file=(
|
||||
Path(values["HUB_SESSION_SIGNING_KEY_FILE"])
|
||||
if values.get("HUB_SESSION_SIGNING_KEY_FILE")
|
||||
else None
|
||||
),
|
||||
session_ttl_seconds=int(values.get("HUB_SESSION_TTL_SECONDS", "28800")),
|
||||
clock_skew_seconds=int(values.get("HUB_OIDC_CLOCK_SKEW_SECONDS", "60")),
|
||||
revocation_ttl_seconds=int(values.get("HUB_REVOCATION_TTL_SECONDS", "60")),
|
||||
cookie_name=values.get("HUB_SESSION_COOKIE_NAME", "hub_session"),
|
||||
same_site=cast(
|
||||
Literal["strict", "lax"], values.get("HUB_SESSION_SAME_SITE", "strict")
|
||||
),
|
||||
secure_cookie=values.get("HUB_SESSION_SECURE", "true").lower() == "true",
|
||||
allowed_algorithms=tuple(
|
||||
algorithm.strip()
|
||||
for algorithm in values.get("HUB_OIDC_ALLOWED_ALGORITHMS", "RS256").split(",")
|
||||
if algorithm.strip()
|
||||
),
|
||||
role_group_map=role_map,
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ConfigurationError("invalid OIDC configuration") from exc
|
||||
|
||||
def client_secret(self, environ: Mapping[str, str] | None = None) -> str | None:
|
||||
"""Read a secret only at the outbound token-exchange boundary."""
|
||||
|
||||
if self.client_secret_file:
|
||||
try:
|
||||
return read_secret_file(self.client_secret_file)
|
||||
except SecretFileError as exc:
|
||||
raise ConfigurationError("configured OIDC client secret is unavailable") from exc
|
||||
if not self.client_secret_env:
|
||||
return None
|
||||
value = (environ or os.environ).get(self.client_secret_env)
|
||||
if not value:
|
||||
raise ConfigurationError("configured OIDC client secret is unavailable")
|
||||
return value
|
||||
|
||||
def session_signing_key(self) -> bytes | None:
|
||||
"""Read the server-side session key only while constructing the session store."""
|
||||
|
||||
if not self.session_signing_key_file:
|
||||
return None
|
||||
try:
|
||||
return read_secret_file(self.session_signing_key_file).encode("utf-8")
|
||||
except SecretFileError as exc:
|
||||
raise ConfigurationError("configured session key is unavailable") from exc
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Safe, stable authentication errors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""An error whose public code/message is safe to expose to a caller."""
|
||||
|
||||
code = "authentication_failed"
|
||||
public_message = "Authentication failed."
|
||||
|
||||
def __init__(self, message: str | None = None) -> None:
|
||||
super().__init__(message or self.public_message)
|
||||
|
||||
|
||||
class ConfigurationError(AuthError):
|
||||
code = "authentication_configuration_error"
|
||||
public_message = "Authentication is not configured."
|
||||
|
||||
|
||||
class ProviderUnavailable(AuthError):
|
||||
code = "identity_provider_unavailable"
|
||||
public_message = "The identity provider is temporarily unavailable."
|
||||
|
||||
|
||||
class InvalidToken(AuthError):
|
||||
code = "invalid_token"
|
||||
public_message = "The supplied credential is invalid."
|
||||
|
||||
|
||||
class TokenExchangeError(AuthError):
|
||||
code = "token_exchange_failed"
|
||||
public_message = "Authentication could not be completed."
|
||||
|
||||
|
||||
class InvalidState(AuthError):
|
||||
code = "invalid_auth_state"
|
||||
public_message = "Authentication could not be completed."
|
||||
|
||||
|
||||
class CSRFError(AuthError):
|
||||
code = "csrf_failed"
|
||||
public_message = "The request could not be verified."
|
||||
|
||||
|
||||
class SessionExpired(AuthError):
|
||||
code = "session_expired"
|
||||
public_message = "The session has expired."
|
||||
|
||||
|
||||
class UnknownMachinePrincipal(AuthError):
|
||||
code = "unknown_machine_principal"
|
||||
public_message = "The machine client is not registered."
|
||||
|
||||
|
||||
class RevokedMachinePrincipal(AuthError):
|
||||
code = "revoked_machine_principal"
|
||||
public_message = "The machine client is disabled."
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Deterministic offline OIDC provider used by integration and E2E tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, cast
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeKey:
|
||||
kid: str
|
||||
private_key: Any
|
||||
|
||||
|
||||
class FakeOIDCProvider:
|
||||
"""A local MockTransport provider; it never uses real credentials or network I/O."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
issuer: str = "https://fake.oidc.test",
|
||||
audience: str = "itworx-mcp-hub",
|
||||
user_subject: str = "user-management-1",
|
||||
user_groups: tuple[str, ...] = ("hub-viewer",),
|
||||
persona_groups: dict[str, tuple[str, ...]] | None = None,
|
||||
token_ttl_seconds: int = 300,
|
||||
) -> None:
|
||||
self.issuer = issuer
|
||||
self._base_url = issuer.rstrip("/")
|
||||
self.audience = audience
|
||||
self.user_subject = user_subject
|
||||
self.user_groups = user_groups
|
||||
self._token_ttl_seconds = token_ttl_seconds
|
||||
# Test-only: lets a real browser flow (GET /authorize, unlike
|
||||
# authorize() which is called directly from Python) select which
|
||||
# group claim to issue via a "persona" query param, so E2E tests can
|
||||
# exercise multiple roles against one running fake-provider process
|
||||
# without touching the real BFF login route to add a hint parameter.
|
||||
self._persona_groups = persona_groups or {}
|
||||
self._key = self._new_key("fake-key-1")
|
||||
self._codes: dict[str, tuple[str, str, tuple[str, ...]]] = {}
|
||||
|
||||
@staticmethod
|
||||
def _new_key(kid: str) -> _FakeKey:
|
||||
return _FakeKey(
|
||||
kid=kid,
|
||||
private_key=rsa.generate_private_key(public_exponent=65537, key_size=2048),
|
||||
)
|
||||
|
||||
def rotate_keys(self, kid: str = "fake-key-rotated") -> None:
|
||||
self._key = self._new_key(kid)
|
||||
|
||||
def transport(self) -> httpx.MockTransport:
|
||||
return httpx.MockTransport(self._handle)
|
||||
|
||||
def asgi_app(self) -> Any:
|
||||
"""A real ASGI app serving the same endpoints as transport(), for
|
||||
E2E harnesses where an actual browser must navigate to /authorize
|
||||
(a MockTransport only intercepts server-to-server httpx calls)."""
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
from starlette.routing import Route
|
||||
|
||||
async def endpoint(request: StarletteRequest) -> StarletteResponse:
|
||||
body = await request.body()
|
||||
httpx_request = httpx.Request(
|
||||
request.method,
|
||||
str(request.url),
|
||||
headers=list(request.headers.items()),
|
||||
content=body,
|
||||
)
|
||||
httpx_response = self._handle(httpx_request)
|
||||
headers = {
|
||||
key: value
|
||||
for key, value in httpx_response.headers.items()
|
||||
if key.lower() not in {"content-length", "transfer-encoding"}
|
||||
}
|
||||
return StarletteResponse(
|
||||
content=httpx_response.content,
|
||||
status_code=httpx_response.status_code,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
return Starlette(
|
||||
routes=[Route("/{path:path}", endpoint, methods=["GET", "POST"])],
|
||||
)
|
||||
|
||||
def _issue_code(self, challenge: str, nonce: str, groups: tuple[str, ...]) -> str:
|
||||
code = f"fake-code-{uuid4().hex}"
|
||||
self._codes[code] = (challenge, nonce, groups)
|
||||
return code
|
||||
|
||||
def authorize(self, authorization_url: str, *, now: datetime | None = None) -> str:
|
||||
query = parse_qs(urlparse(authorization_url).query)
|
||||
state = query.get("state", [""])[0]
|
||||
challenge = query.get("code_challenge", [""])[0]
|
||||
if not state or not challenge:
|
||||
raise ValueError("invalid fake authorization request")
|
||||
return self._issue_code(challenge, query.get("nonce", [""])[0], self.user_groups)
|
||||
|
||||
def issue_machine_token(
|
||||
self,
|
||||
subject: str,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
issuer: str | None = None,
|
||||
audience: str | None = None,
|
||||
) -> str:
|
||||
reference = now or datetime.now(UTC)
|
||||
return self._encode(
|
||||
{
|
||||
"sub": subject,
|
||||
"client_id": subject,
|
||||
"iss": issuer or self.issuer,
|
||||
"aud": audience or self.audience,
|
||||
"iat": int(reference.timestamp()),
|
||||
"exp": int((reference + timedelta(minutes=5)).timestamp()),
|
||||
}
|
||||
)
|
||||
|
||||
def _encode(self, claims: dict[str, Any]) -> str:
|
||||
return jwt.encode(
|
||||
claims, self._key.private_key, algorithm="RS256", headers={"kid": self._key.kid}
|
||||
)
|
||||
|
||||
def _public_jwk(self) -> dict[str, Any]:
|
||||
raw = cast(
|
||||
dict[str, Any],
|
||||
json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(self._key.private_key.public_key())),
|
||||
)
|
||||
return raw | {
|
||||
"kid": self._key.kid,
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
}
|
||||
|
||||
def _handle(self, request: httpx.Request) -> httpx.Response:
|
||||
path = urlparse(str(request.url)).path
|
||||
base_path = urlparse(self._base_url).path.rstrip("/")
|
||||
if request.method == "GET" and path == f"{base_path}/.well-known/openid-configuration":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"issuer": self.issuer,
|
||||
"authorization_endpoint": f"{self._base_url}/authorize",
|
||||
"token_endpoint": f"{self._base_url}/token",
|
||||
"jwks_uri": f"{self._base_url}/jwks",
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
if request.method == "GET" and path == f"{base_path}/jwks":
|
||||
return httpx.Response(200, json={"keys": [self._public_jwk()]}, request=request)
|
||||
if request.method == "GET" and path == f"{base_path}/authorize":
|
||||
query = parse_qs(urlparse(str(request.url)).query)
|
||||
redirect_uri = query.get("redirect_uri", [""])[0]
|
||||
state = query.get("state", [""])[0]
|
||||
challenge = query.get("code_challenge", [""])[0]
|
||||
nonce = query.get("nonce", [""])[0]
|
||||
persona = query.get("persona", [""])[0]
|
||||
if not redirect_uri or not state or not challenge:
|
||||
return httpx.Response(
|
||||
400, text="invalid fake authorization request", request=request
|
||||
)
|
||||
groups = self._persona_groups.get(persona, self.user_groups)
|
||||
code = self._issue_code(challenge, nonce, groups)
|
||||
separator = "&" if "?" in redirect_uri else "?"
|
||||
location = f"{redirect_uri}{separator}code={code}&state={state}"
|
||||
return httpx.Response(302, headers={"location": location}, request=request)
|
||||
if request.method == "POST" and path == f"{base_path}/token":
|
||||
form = parse_qs(request.content.decode("utf-8"))
|
||||
code = form.get("code", [""])[0]
|
||||
verifier = form.get("code_verifier", [""])[0]
|
||||
expected = self._codes.pop(code, None)
|
||||
if expected is None:
|
||||
return httpx.Response(400, json={"error": "invalid_grant"}, request=request)
|
||||
challenge = jwt.utils.base64url_encode(
|
||||
hashlib.sha256(verifier.encode()).digest()
|
||||
).decode()
|
||||
if challenge != expected[0]:
|
||||
return httpx.Response(400, json={"error": "invalid_grant"}, request=request)
|
||||
now = datetime.now(UTC)
|
||||
id_token = self._encode(
|
||||
{
|
||||
"sub": self.user_subject,
|
||||
"iss": self.issuer,
|
||||
"aud": self.audience,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(seconds=self._token_ttl_seconds)).timestamp()),
|
||||
"nonce": expected[1],
|
||||
"groups": list(expected[2]),
|
||||
}
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"access_token": id_token,
|
||||
"id_token": id_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": self._token_ttl_seconds,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
return httpx.Response(404, request=request)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Audience-bound machine principal mapping and bounded revocation cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from .errors import InvalidToken, RevokedMachinePrincipal, UnknownMachinePrincipal
|
||||
from .models import AuthenticatedPrincipal, ManagementRole, PrincipalKind, ValidatedClaims
|
||||
from .provider import OIDCProviderClient
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MachineClientBinding:
|
||||
subject: str
|
||||
client_id: UUID
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class PrincipalMapper:
|
||||
"""Maps trusted identity claims to roles, never to project/environment bindings."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
role_group_map: Mapping[str, ManagementRole] | None = None,
|
||||
machine_subjects: Mapping[str, MachineClientBinding] | None = None,
|
||||
) -> None:
|
||||
self.role_group_map = dict(role_group_map or {})
|
||||
self.machine_subjects = dict(machine_subjects or {})
|
||||
|
||||
def map_claims(self, claims: ValidatedClaims) -> AuthenticatedPrincipal:
|
||||
machine = self.machine_subjects.get(claims.subject)
|
||||
if machine is not None:
|
||||
if not machine.enabled:
|
||||
raise UnknownMachinePrincipal()
|
||||
return AuthenticatedPrincipal(
|
||||
subject=claims.subject,
|
||||
issuer=claims.issuer,
|
||||
audience=claims.audience,
|
||||
kind=PrincipalKind.MACHINE_CLIENT,
|
||||
client_id=machine.client_id,
|
||||
machine_subject=claims.subject,
|
||||
expires_at=claims.expires_at,
|
||||
issued_at=claims.issued_at,
|
||||
)
|
||||
roles = frozenset(
|
||||
role
|
||||
for group in claims.groups
|
||||
for role in [self.role_group_map.get(group)]
|
||||
if role is not None
|
||||
)
|
||||
return AuthenticatedPrincipal(
|
||||
subject=claims.subject,
|
||||
issuer=claims.issuer,
|
||||
audience=claims.audience,
|
||||
kind=PrincipalKind.MANAGEMENT_USER,
|
||||
groups=claims.groups,
|
||||
roles=roles,
|
||||
expires_at=claims.expires_at,
|
||||
issued_at=claims.issued_at,
|
||||
)
|
||||
|
||||
|
||||
class RevocationCache:
|
||||
"""Caches revocation truth for at most 60 seconds; invalidation is immediate."""
|
||||
|
||||
def __init__(self, lookup: Callable[[UUID], bool], *, ttl_seconds: int = 60) -> None:
|
||||
self.lookup = lookup
|
||||
self.ttl_seconds = max(1, min(ttl_seconds, 60))
|
||||
self._values: dict[UUID, tuple[bool, float]] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def is_revoked(self, client_id: UUID) -> bool:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
cached = self._values.get(client_id)
|
||||
if cached is not None and cached[1] > now:
|
||||
return cached[0]
|
||||
value = bool(self.lookup(client_id))
|
||||
with self._lock:
|
||||
self._values[client_id] = (value, now + self.ttl_seconds)
|
||||
return value
|
||||
|
||||
def invalidate(self, client_id: UUID) -> None:
|
||||
with self._lock:
|
||||
self._values.pop(client_id, None)
|
||||
|
||||
|
||||
class MachinePrincipalAuthenticator:
|
||||
"""Validates a Hub-audience bearer and enforces client registration/revocation."""
|
||||
|
||||
_bearer = re.compile(r"^Bearer ([A-Za-z0-9._~+/=-]{1,65536})$")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: OIDCProviderClient,
|
||||
mapper: PrincipalMapper,
|
||||
revocations: RevocationCache,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.mapper = mapper
|
||||
self.revocations = revocations
|
||||
|
||||
def authenticate(
|
||||
self,
|
||||
authorization_header: str | None,
|
||||
) -> AuthenticatedPrincipal:
|
||||
if not authorization_header:
|
||||
raise InvalidToken()
|
||||
match = self._bearer.fullmatch(authorization_header.strip())
|
||||
if match is None:
|
||||
raise InvalidToken()
|
||||
principal = self.mapper.map_claims(self.provider.validate_token(match.group(1)))
|
||||
if principal.kind is not PrincipalKind.MACHINE_CLIENT or principal.client_id is None:
|
||||
raise InvalidToken()
|
||||
if self.revocations.is_revoked(principal.client_id):
|
||||
raise RevokedMachinePrincipal()
|
||||
return principal
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Typed identity models with no raw token or project-binding claims."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class ManagementRole(StrEnum):
|
||||
ADMIN = "admin"
|
||||
OPERATOR = "operator"
|
||||
AUDITOR = "auditor"
|
||||
VIEWER = "viewer"
|
||||
|
||||
|
||||
class PrincipalKind(StrEnum):
|
||||
MANAGEMENT_USER = "management_user"
|
||||
MACHINE_CLIENT = "machine_client"
|
||||
|
||||
|
||||
class ValidatedClaims(BaseModel):
|
||||
"""The small validated projection used by authorization mapping."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
subject: str = Field(min_length=1, max_length=512, alias="sub")
|
||||
issuer: str = Field(min_length=1, max_length=2048, alias="iss")
|
||||
audience: tuple[str, ...] = Field(alias="aud")
|
||||
expires_at: datetime = Field(alias="exp")
|
||||
issued_at: datetime = Field(alias="iat")
|
||||
not_before: datetime | None = Field(default=None, alias="nbf")
|
||||
client_subject: str | None = Field(default=None, alias="azp")
|
||||
client_id: str | None = None
|
||||
nonce: str | None = None
|
||||
groups: tuple[str, ...] = ()
|
||||
roles: tuple[str, ...] = ()
|
||||
|
||||
@field_validator("expires_at", "issued_at", "not_before", mode="before")
|
||||
@classmethod
|
||||
def timezone_aware(cls, value: Any) -> Any:
|
||||
if isinstance(value, int | float):
|
||||
return datetime.fromtimestamp(value, tz=UTC)
|
||||
if isinstance(value, datetime) and value.tzinfo is None:
|
||||
raise ValueError("JWT timestamps must be timezone-aware")
|
||||
return value
|
||||
|
||||
@field_validator("audience", mode="before")
|
||||
@classmethod
|
||||
def normalize_audience(cls, value: Any) -> tuple[str, ...]:
|
||||
if isinstance(value, str):
|
||||
return (value,)
|
||||
if isinstance(value, list | tuple) and all(isinstance(item, str) for item in value):
|
||||
return tuple(value)
|
||||
raise ValueError("invalid JWT audience")
|
||||
|
||||
@field_validator("groups", "roles", mode="before")
|
||||
@classmethod
|
||||
def normalize_strings(cls, value: Any) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if isinstance(value, str):
|
||||
return (value,)
|
||||
if isinstance(value, list | tuple) and all(isinstance(item, str) for item in value):
|
||||
return tuple(value)
|
||||
raise ValueError("invalid identity group or role claim")
|
||||
|
||||
|
||||
class AuthenticatedPrincipal(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
subject: str = Field(min_length=1, max_length=512)
|
||||
issuer: str = Field(min_length=1, max_length=2048)
|
||||
audience: tuple[str, ...]
|
||||
kind: PrincipalKind
|
||||
client_id: UUID | None = None
|
||||
machine_subject: str | None = None
|
||||
groups: tuple[str, ...] = ()
|
||||
roles: frozenset[ManagementRole] = frozenset()
|
||||
expires_at: datetime
|
||||
issued_at: datetime
|
||||
|
||||
@field_validator("expires_at", "issued_at")
|
||||
@classmethod
|
||||
def require_aware_time(cls, value: datetime) -> datetime:
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError("principal timestamps must be timezone-aware")
|
||||
return value
|
||||
|
||||
|
||||
class SessionMetadata(BaseModel):
|
||||
"""Safe browser-visible session data; never contains bearer tokens."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
subject: str
|
||||
client_id: UUID | None
|
||||
kind: PrincipalKind
|
||||
roles: frozenset[ManagementRole]
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class TokenSet(BaseModel):
|
||||
"""Internal token-exchange result. Never serialize this model to a browser."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
access_token: str = Field(min_length=1, max_length=65536)
|
||||
token_type: Literal["Bearer", "bearer"] = "Bearer" # noqa: S105
|
||||
expires_in: int | None = Field(default=None, ge=1, le=86400)
|
||||
refresh_token: str | None = Field(default=None, max_length=65536)
|
||||
id_token: str | None = Field(default=None, max_length=65536)
|
||||
|
||||
|
||||
class SessionCookie(BaseModel):
|
||||
"""Internal result used to set an opaque session cookie and CSRF token."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
value: str
|
||||
csrf_token: str
|
||||
metadata: SessionMetadata
|
||||
set_cookie: str
|
||||
@@ -0,0 +1,219 @@
|
||||
"""OIDC discovery, key rotation, and asymmetric JWT validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, TypeAdapter, field_validator
|
||||
|
||||
from .config import OIDCSettings
|
||||
from .errors import InvalidToken, ProviderUnavailable, TokenExchangeError
|
||||
from .models import TokenSet, ValidatedClaims
|
||||
|
||||
|
||||
class DiscoveryDocument(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", frozen=True)
|
||||
|
||||
issuer: str
|
||||
authorization_endpoint: AnyHttpUrl
|
||||
token_endpoint: AnyHttpUrl
|
||||
jwks_uri: AnyHttpUrl
|
||||
id_token_signing_alg_values_supported: tuple[str, ...] = ("RS256",)
|
||||
|
||||
@field_validator("id_token_signing_alg_values_supported", mode="before")
|
||||
@classmethod
|
||||
def normalize_algorithms(cls, value: Any) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ("RS256",)
|
||||
return tuple(value)
|
||||
|
||||
@field_validator("issuer")
|
||||
@classmethod
|
||||
def validate_issuer_url(cls, value: str) -> str:
|
||||
parsed = TypeAdapter(AnyHttpUrl).validate_python(value)
|
||||
if parsed.scheme != "https" or parsed.username is not None or parsed.password is not None:
|
||||
raise ValueError("OIDC issuer must be an HTTPS URL without userinfo")
|
||||
return value
|
||||
|
||||
|
||||
class OIDCProviderClient:
|
||||
"""Provider client with bounded discovery/JWKS caches and one rotation retry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: OIDCSettings,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
timeout_seconds: float = 5.0,
|
||||
cache_ttl_seconds: int = 300,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self._client = httpx.Client(transport=transport, timeout=timeout_seconds)
|
||||
self._cache_ttl_seconds = max(30, min(cache_ttl_seconds, 900))
|
||||
self._discovery: DiscoveryDocument | None = None
|
||||
self._discovery_loaded = 0.0
|
||||
self._jwks: dict[str, dict[str, Any]] = {}
|
||||
self._jwks_loaded = 0.0
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _request_json(self, method: str, url: str, **kwargs: Any) -> dict[str, Any]:
|
||||
try:
|
||||
response = self._client.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
value = response.json()
|
||||
except (httpx.HTTPError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise ProviderUnavailable() from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ProviderUnavailable()
|
||||
return value
|
||||
|
||||
def discover(self, *, force: bool = False) -> DiscoveryDocument:
|
||||
now = time.monotonic()
|
||||
if (
|
||||
self._discovery is not None
|
||||
and not force
|
||||
and now - self._discovery_loaded < self._cache_ttl_seconds
|
||||
):
|
||||
return self._discovery
|
||||
configured_issuer = str(self.settings.issuer)
|
||||
discovery_base = configured_issuer.rstrip("/") + "/"
|
||||
raw = self._request_json("GET", urljoin(discovery_base, ".well-known/openid-configuration"))
|
||||
try:
|
||||
document = DiscoveryDocument.model_validate(raw)
|
||||
except ValueError as exc:
|
||||
raise ProviderUnavailable() from exc
|
||||
if document.issuer != configured_issuer:
|
||||
raise ProviderUnavailable()
|
||||
issuer_origin = discovery_base.split("/", 3)[:3]
|
||||
for endpoint in (
|
||||
document.authorization_endpoint,
|
||||
document.token_endpoint,
|
||||
document.jwks_uri,
|
||||
):
|
||||
if str(endpoint).split("/", 3)[:3] != issuer_origin:
|
||||
raise ProviderUnavailable()
|
||||
self._discovery = document
|
||||
self._discovery_loaded = now
|
||||
return document
|
||||
|
||||
def _load_jwks(self, *, force: bool = False) -> dict[str, dict[str, Any]]:
|
||||
now = time.monotonic()
|
||||
if self._jwks and not force and now - self._jwks_loaded < self._cache_ttl_seconds:
|
||||
return self._jwks
|
||||
document = self.discover()
|
||||
raw = self._request_json("GET", str(document.jwks_uri))
|
||||
keys = raw.get("keys")
|
||||
if not isinstance(keys, list):
|
||||
raise ProviderUnavailable()
|
||||
parsed: dict[str, dict[str, Any]] = {}
|
||||
for key in keys:
|
||||
if (
|
||||
isinstance(key, dict)
|
||||
and isinstance(key.get("kid"), str)
|
||||
and key.get("kty") in {"RSA", "EC", "OKP"}
|
||||
):
|
||||
parsed[key["kid"]] = key
|
||||
if not parsed:
|
||||
raise ProviderUnavailable()
|
||||
self._jwks = parsed
|
||||
self._jwks_loaded = now
|
||||
return parsed
|
||||
|
||||
def build_authorization_url(self, *, state: str, nonce: str, code_challenge: str) -> str:
|
||||
document = self.discover()
|
||||
query = urlencode(
|
||||
{
|
||||
"response_type": "code",
|
||||
"client_id": self.settings.client_id,
|
||||
"redirect_uri": str(self.settings.redirect_uri),
|
||||
"scope": "openid profile email groups",
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
)
|
||||
return f"{document.authorization_endpoint}?{query}"
|
||||
|
||||
def exchange_code(self, *, code: str, code_verifier: str) -> TokenSet:
|
||||
document = self.discover()
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": str(self.settings.redirect_uri),
|
||||
"client_id": self.settings.client_id,
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
secret = self.settings.client_secret()
|
||||
if secret is not None:
|
||||
data["client_secret"] = secret
|
||||
try:
|
||||
response = self._client.post(str(document.token_endpoint), data=data)
|
||||
response.raise_for_status()
|
||||
return TokenSet.model_validate(response.json())
|
||||
except (httpx.HTTPError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise TokenExchangeError() from exc
|
||||
|
||||
def validate_token(self, token: str, *, now: datetime | None = None) -> ValidatedClaims:
|
||||
if not token or len(token) > 65536:
|
||||
raise InvalidToken()
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise InvalidToken() from exc
|
||||
algorithm = header.get("alg")
|
||||
document = self.discover()
|
||||
kid = header.get("kid")
|
||||
if not isinstance(algorithm, str) or algorithm not in self.settings.allowed_algorithms:
|
||||
raise InvalidToken()
|
||||
if (
|
||||
not isinstance(kid, str)
|
||||
or not kid
|
||||
or algorithm not in document.id_token_signing_alg_values_supported
|
||||
):
|
||||
raise InvalidToken()
|
||||
keys = self._load_jwks()
|
||||
key = keys.get(kid)
|
||||
if key is None:
|
||||
keys = self._load_jwks(force=True)
|
||||
key = keys.get(kid)
|
||||
if key is None:
|
||||
raise InvalidToken()
|
||||
try:
|
||||
signing_key = jwt.PyJWK.from_dict(key).key
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
signing_key,
|
||||
algorithms=list(self.settings.allowed_algorithms),
|
||||
audience=self.settings.audience,
|
||||
issuer=str(document.issuer),
|
||||
leeway=self.settings.clock_skew_seconds,
|
||||
options={
|
||||
"require": ["sub", "iss", "aud", "exp", "iat"],
|
||||
"verify_exp": False,
|
||||
"verify_iat": False,
|
||||
"verify_nbf": False,
|
||||
},
|
||||
)
|
||||
validated = ValidatedClaims.model_validate(claims)
|
||||
except (jwt.PyJWTError, ValueError, TypeError) as exc:
|
||||
raise InvalidToken() from exc
|
||||
reference = now or datetime.now(UTC)
|
||||
if reference.tzinfo is None or reference.utcoffset() is None:
|
||||
raise InvalidToken()
|
||||
skew = timedelta(seconds=self.settings.clock_skew_seconds)
|
||||
if validated.expires_at + skew <= reference:
|
||||
raise InvalidToken()
|
||||
if validated.issued_at - skew > reference:
|
||||
raise InvalidToken()
|
||||
if validated.not_before is not None and validated.not_before - skew > reference:
|
||||
raise InvalidToken()
|
||||
return validated
|
||||
@@ -0,0 +1,70 @@
|
||||
"""FastAPI routes for the same-origin management BFF."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from .bff import BFFService
|
||||
from .errors import AuthError
|
||||
|
||||
|
||||
def _safe_error(exc: AuthError) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"code": exc.code, "message": exc.public_message},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
def build_auth_router(bff: BFFService) -> APIRouter:
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@router.get("/login", include_in_schema=False)
|
||||
def login() -> RedirectResponse:
|
||||
try:
|
||||
return RedirectResponse(
|
||||
bff.begin_login(), status_code=status.HTTP_307_TEMPORARY_REDIRECT
|
||||
)
|
||||
except AuthError as exc:
|
||||
raise _safe_error(exc) from exc
|
||||
|
||||
@router.get("/callback", include_in_schema=False)
|
||||
def callback(code: str, state: str) -> RedirectResponse:
|
||||
try:
|
||||
session = bff.complete_login(code=code, state=state)
|
||||
except AuthError as exc:
|
||||
raise _safe_error(exc) from exc
|
||||
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.headers["Set-Cookie"] = session.set_cookie
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
@router.get("/session")
|
||||
def session(request: Request, response: Response) -> dict[str, object]:
|
||||
cookie = request.cookies.get(bff.settings.cookie_name)
|
||||
if cookie is None:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return {"authenticated": False}
|
||||
try:
|
||||
metadata = bff.session_metadata(cookie)
|
||||
record = bff.sessions.get(cookie)
|
||||
except AuthError as exc:
|
||||
raise _safe_error(exc) from exc
|
||||
return {
|
||||
"authenticated": True,
|
||||
"csrf_token": record.csrf_token,
|
||||
"session": metadata.model_dump(mode="json"),
|
||||
}
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
def logout(request: Request, response: Response) -> None:
|
||||
try:
|
||||
cookie = request.cookies.get(bff.settings.cookie_name)
|
||||
clear_cookie = bff.logout(cookie, request.headers.get("X-CSRF-Token"))
|
||||
except AuthError as exc:
|
||||
raise _safe_error(exc) from exc
|
||||
response.headers["Set-Cookie"] = clear_cookie
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
return router
|
||||
Reference in new issue
Block a user