Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import hmac | |
| from collections.abc import Callable, Iterable | |
| from dataclasses import dataclass | |
| from datetime import UTC, datetime, timedelta | |
| from uuid import uuid4 | |
| from typing import Any, Annotated | |
| import jwt | |
| from fastapi import Header, HTTPException, status | |
| from jwt import InvalidTokenError | |
| from src.auth.revocation import is_token_revoked | |
| from src.config import settings | |
| def _unauthorized(detail: str) -> HTTPException: | |
| return HTTPException( | |
| status_code=status.HTTP_401_UNAUTHORIZED, | |
| detail=detail, | |
| headers={"WWW-Authenticate": "Bearer"}, | |
| ) | |
| def _extract_bearer_token(authorization: str | None) -> str: | |
| if not authorization: | |
| raise _unauthorized("Missing Authorization header.") | |
| scheme, _, token = authorization.partition(" ") | |
| if not hmac.compare_digest(scheme.lower(), "bearer") or not token.strip(): | |
| raise _unauthorized("Invalid Authorization header.") | |
| return token.strip() | |
| def _audience_matches(expected: str, claim: Any) -> bool: | |
| if isinstance(claim, str): | |
| return hmac.compare_digest(claim, expected) | |
| if isinstance(claim, list): | |
| return any(isinstance(item, str) and hmac.compare_digest(item, expected) for item in claim) | |
| return False | |
| def _extract_scope_set(payload: dict[str, Any]) -> set[str]: | |
| raw_scope = payload.get("scope") | |
| if isinstance(raw_scope, str): | |
| return {part for part in raw_scope.split(" ") if part} | |
| return set() | |
| def _normalize_scopes(scopes: Iterable[str]) -> tuple[str, ...]: | |
| seen: set[str] = set() | |
| normalized: list[str] = [] | |
| for scope in scopes: | |
| value = scope.strip() | |
| if not value or value in seen: | |
| continue | |
| seen.add(value) | |
| normalized.append(value) | |
| return tuple(normalized) | |
| def _ensure_client_subject(payload: dict[str, Any]) -> None: | |
| subject = payload.get("sub") | |
| if not isinstance(subject, str): | |
| raise _unauthorized("Invalid token subject.") | |
| if not subject.startswith("client:") or len(subject) <= len("client:"): | |
| raise _unauthorized("Invalid token subject.") | |
| class IssuedAccessToken: | |
| access_token: str | |
| token_type: str | |
| expires_in: int | |
| scope: str | |
| jti: str | |
| def decode_and_verify_jwt(token: str) -> dict[str, Any]: | |
| try: | |
| payload = jwt.decode( | |
| token, | |
| settings.jwt_secret, | |
| algorithms=["HS256"], | |
| options={ | |
| "require": ["iss", "aud", "sub", "exp", "iat"], | |
| "verify_signature": True, | |
| "verify_exp": True, | |
| "verify_iat": True, | |
| "verify_iss": False, | |
| "verify_aud": False, | |
| }, | |
| leeway=settings.jwt_clock_skew_seconds, | |
| ) | |
| except InvalidTokenError as exc: | |
| raise _unauthorized("Invalid or expired token.") from exc | |
| issuer = payload.get("iss") | |
| if not isinstance(issuer, str) or not hmac.compare_digest(issuer, settings.jwt_issuer): | |
| raise _unauthorized("Invalid token issuer.") | |
| if not _audience_matches(settings.jwt_audience, payload.get("aud")): | |
| raise _unauthorized("Invalid token audience.") | |
| return payload | |
| def issue_client_access_token(client_id: str, scopes: Iterable[str]) -> IssuedAccessToken: | |
| now = datetime.now(UTC) | |
| expires_in = max(1, settings.oauth_token_ttl_seconds) | |
| normalized_scopes = _normalize_scopes(scopes) | |
| jti = uuid4().hex | |
| payload = { | |
| "iss": settings.jwt_issuer, | |
| "aud": settings.jwt_audience, | |
| "sub": f"client:{client_id}", | |
| "iat": int(now.timestamp()), | |
| "exp": int((now + timedelta(seconds=expires_in)).timestamp()), | |
| "scope": " ".join(normalized_scopes), | |
| "jti": jti, | |
| } | |
| token = jwt.encode(payload, settings.jwt_secret, algorithm="HS256") | |
| return IssuedAccessToken( | |
| access_token=token, | |
| token_type="Bearer", | |
| expires_in=expires_in, | |
| scope=payload["scope"], | |
| jti=jti, | |
| ) | |
| def require_jwt(required_scopes: list[str] | None = None) -> Callable[..., dict[str, Any]]: | |
| async def dependency( | |
| authorization: Annotated[str | None, Header(alias="Authorization")] = None, | |
| ) -> dict[str, Any]: | |
| if not settings.jwt_enabled: | |
| return {} | |
| token = _extract_bearer_token(authorization) | |
| payload = decode_and_verify_jwt(token) | |
| _ensure_client_subject(payload) | |
| jti = payload.get("jti") | |
| if isinstance(jti, str) and jti and await is_token_revoked(jti): | |
| raise _unauthorized("Token has been revoked.") | |
| needed = set(required_scopes or []) | |
| if not needed: | |
| return payload | |
| provided_scopes = _extract_scope_set(payload) | |
| if not needed.issubset(provided_scopes): | |
| raise HTTPException( | |
| status_code=status.HTTP_403_FORBIDDEN, | |
| detail="Insufficient scope.", | |
| ) | |
| return payload | |
| return dependency | |