"""FastAPI dependencies (db session, storage, publisher, service-token auth).""" from __future__ import annotations from collections.abc import AsyncIterator from typing import Annotated, Any from uuid import UUID from fastapi import Depends, Header, HTTPException, Query, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.contract_check.core.api_keys import hash_api_key from src.contract_check.core.auth import ( AuthError, TokenExpiredError, TokenInvalidError, verify_access_token, ) from src.contract_check.core.auth_refresh import RefreshTokenStore from src.contract_check.core.config import get_settings from src.contract_check.core.db.models import PasskeyCredential, User from src.contract_check.core.db.repositories import ( ApiKeyRepository, DocumentRepository, PasskeyRepository, UserRepository, ) from src.contract_check.core.db.repositories.service_tokens import ServiceTokenRepository from src.contract_check.core.db.session import get_session from src.contract_check.core.logging import get_logger from src.contract_check.core.mq.publisher import Publisher from src.contract_check.core.notifications.publisher import NotificationPublisher from src.contract_check.core.passkeys import PasskeyChallengeStore from src.contract_check.core.rate_limit import RateLimiter, RateLimitResult from src.contract_check.core.s3.port import Storage from src.contract_check.core.security.passwords import hash_password_async from src.contract_check.core.tokens import hash_token log = get_logger(__name__) async def get_db_session() -> AsyncIterator[AsyncSession]: async for session in get_session(): yield session AsyncSessionDep = Annotated[AsyncSession, Depends(get_db_session)] def get_storage(request: Request) -> Storage: storage: Storage = request.app.state.storage return storage StorageDep = Annotated[Storage, Depends(get_storage)] def get_publisher(request: Request) -> Publisher: publisher: Publisher = request.app.state.publisher return publisher PublisherDep = Annotated[Publisher, Depends(get_publisher)] def get_notification_publisher(request: Request) -> NotificationPublisher: publisher: NotificationPublisher = request.app.state.notification_publisher return publisher NotificationPublisherDep = Annotated[NotificationPublisher, Depends(get_notification_publisher)] def get_redis(request: Request) -> Any: """Return the app-state async Redis client. Set on app.state.redis in the api lifespan. Used by the refresh-token store. """ redis: Any = request.app.state.redis return redis def get_refresh_store(request: Request) -> RefreshTokenStore: """Build a RefreshTokenStore from the app-state Redis client. Raises 503 if Redis is unavailable — webUI auth cannot function without it. """ redis: Any = getattr(request.app.state, "redis", None) if redis is None: raise HTTPException(status_code=503, detail="refresh-token store unavailable") settings = get_settings() return RefreshTokenStore(redis, ttl_seconds=settings.jwt_refresh_ttl_days * 24 * 3600) RefreshStoreDep = Annotated[RefreshTokenStore, Depends(get_refresh_store)] def get_passkey_challenge_store(request: Request) -> PasskeyChallengeStore: """Build a PasskeyChallengeStore from the app-state Redis client. Raises 503 if Redis is unavailable — WebAuthn ceremonies cannot function without server-side challenge storage. """ redis: Any = getattr(request.app.state, "redis", None) if redis is None: raise HTTPException(status_code=503, detail="passkey challenge store unavailable") settings = get_settings() return PasskeyChallengeStore(redis, ttl_seconds=settings.passkey_challenge_ttl_seconds) PasskeyChallengeStoreDep = Annotated[PasskeyChallengeStore, Depends(get_passkey_challenge_store)] async def require_service_token( session: AsyncSessionDep, authorization: Annotated[str | None, Header()] = None, ) -> None: """Validate `Authorization: Bearer ` against service_tokens table. Raises 401 on missing/invalid/revoked token. Logs last_used_at on success. """ if not authorization or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=401, detail="Missing or invalid Authorization header") raw = authorization[7:].strip() token_hash = hash_token(raw) repo = ServiceTokenRepository(session) token_id = await repo.get_id_by_hash(token_hash) if token_id is None: raise HTTPException(status_code=401, detail="Invalid or revoked token") await repo.bump_last_used(token_id) await session.commit() AuthDep = Annotated[None, Depends(require_service_token)] async def get_or_create_user_for_telegram( session: AsyncSession, telegram_id: int, *, profile: dict[str, object] | None = None, verified: bool = True, ) -> User: """Fetch or create a user identified by telegram_id. On creation stores the profile snapshot, verification flag, and binding time. On existing row optionally refreshes the profile snapshot. """ repo = UserRepository(session) user = await repo.get_by_telegram_id(telegram_id) if user is not None: if profile: await repo.update_telegram_profile(user.id, profile=profile, verified=verified) await session.commit() user.telegram_verified = verified return user user = await repo.create_telegram_user(telegram_id, profile=profile, verified=verified) await session.commit() return user async def bind_telegram_to_user( session: AsyncSession, user_id: UUID, telegram_id: int, ) -> None: """Link a Telegram id to an existing user (web -> Telegram). Raises HTTPException 409 if the telegram_id is already bound to another user. """ repo = UserRepository(session) if await repo.telegram_id_exists_for_other_user(telegram_id, user_id): raise HTTPException(status_code=409, detail="Telegram id already bound to another account") await repo.bind_telegram(user_id, telegram_id) await session.commit() async def set_user_password(session: AsyncSession, user_id: UUID, password: str) -> None: """Set/rotate a web password for a user (Telegram -> web UI access).""" hashed = await hash_password_async(password) await UserRepository(session).set_password(user_id, hashed) await session.commit() def get_rate_limiter(request: Request) -> RateLimiter: """Return the app-state rate limiter (Redis or in-memory fallback).""" limiter: RateLimiter = request.app.state.rate_limiter return limiter RateLimiterDep = Annotated[RateLimiter, Depends(get_rate_limiter)] def _auth_rate_limit_headers(result: RateLimitResult) -> dict[str, str]: retry = int(result.retry_after_sec or 1) return {"Retry-After": str(retry)} async def require_auth_rate_limit( request: Request, rate_limiter: RateLimiter, *, email: str | None = None, ) -> None: """Throttle public auth endpoints by IP and, optionally, by recipient email. Mail-sending endpoints should pass ``email`` so a single IP cannot bomb an inbox; other endpoints pass only the IP bucket. """ settings = get_settings() ip = request.client.host if request.client else "unknown" ip_key = f"auth:ip:{ip}" ip_result = await rate_limiter.allow(ip_key, settings.auth_rate_limit_ip_rps) if not ip_result.allowed: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="rate limit exceeded", headers=_auth_rate_limit_headers(ip_result), ) if email: email_key = f"auth:email:{email.lower().strip()}" email_result = await rate_limiter.allow(email_key, settings.auth_rate_limit_email_rps) if not email_result.allowed: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="rate limit exceeded", headers=_auth_rate_limit_headers(email_result), ) AuthRateLimiterDep = Annotated[RateLimiter, Depends(get_rate_limiter)] async def fetch_user_by_email(session: AsyncSession, email: str) -> User | None: """Fetch a user by email (case-sensitive — normalize upstream). Returns None if not found.""" return await UserRepository(session).get_by_email(email) async def fetch_passkey_credentials_for_user( session: AsyncSession, user_id: UUID ) -> list[PasskeyCredential]: """All passkey credentials of a user, newest first.""" return await PasskeyRepository(session).list_for_user(user_id) async def fetch_passkey_by_credential_id( session: AsyncSession, credential_id: str ) -> PasskeyCredential | None: """Look up a passkey credential by its base64url credential id.""" result = await session.execute( select(PasskeyCredential).where(PasskeyCredential.credential_id == credential_id) ) return result.scalars().first() async def fetch_user_by_id_full(session: AsyncSession, user_id: UUID) -> User | None: """Fetch a user by UUID including web-auth columns.""" return await UserRepository(session).get_by_id(user_id) async def create_email_user( session: AsyncSession, *, email: str, name: str | None, password_hash: str ) -> User: """Insert a new email/password user with 0 credits and return it.""" return await UserRepository(session).create_email_user( email=email, name=name, password_hash=password_hash ) def get_rate_limiter(request: Request) -> RateLimiter: """Return the app-state rate limiter (Redis in prod, Memory in tests).""" limiter: RateLimiter = request.app.state.rate_limiter return limiter RateLimiterDep = Annotated[RateLimiter, Depends(get_rate_limiter)] class ApiKeyAuth: """Validated B2B API key + its owning user id.""" def __init__(self, api_key_id: UUID, user_id: UUID, rate_limit_rps: int) -> None: self.api_key_id = api_key_id self.user_id = user_id self.rate_limit_rps = rate_limit_rps async def require_api_key( session: AsyncSessionDep, rate_limiter: RateLimiterDep, x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, ) -> ApiKeyAuth: """Validate `X-API-Key` header, rate-limit, and return key metadata. Raises 401 on missing/invalid/revoked key. Raises 429 when rate-limited or monthly quota exhausted. """ if not x_api_key: raise HTTPException(status_code=401, detail="Missing X-API-Key header") key_hash = hash_api_key(x_api_key) repo = ApiKeyRepository(session) key = await repo.get_by_hash(key_hash) if key is None: raise HTTPException(status_code=401, detail="Invalid API key") if key.revoked: raise HTTPException(status_code=401, detail="Revoked API key") await repo.maybe_reset_monthly_quota(key.id) # Check monthly quota (if configured) after potential reset. monthly_quota, monthly_used = await repo.get_monthly_quota_state(key.id) if monthly_quota is not None and int(monthly_used) >= int(monthly_quota): raise HTTPException(status_code=429, detail="Monthly quota exceeded") # Apply token-bucket rate limit per key id. limit = int(key.rate_limit_rps or get_settings().b2b_default_rate_limit_rps) rl_result = await rate_limiter.allow(f"rate_limit:{key.id}", limit) if not rl_result.allowed: retry_after = max(1, int(rl_result.retry_after_sec or 1)) raise HTTPException( status_code=429, detail="Rate limit exceeded", headers={"Retry-After": str(retry_after)}, ) await repo.bump_last_used(key.id) await session.commit() return ApiKeyAuth(api_key_id=key.id, user_id=key.user_id, rate_limit_rps=limit) ApiKeyAuthDep = Annotated[ApiKeyAuth, Depends(require_api_key)] async def fetch_document_status_for_user( session: AsyncSession, document_id: UUID, user_id: UUID ) -> dict[str, Any] | None: """Fetch document + report scoped to a specific user (B2B API).""" status = await DocumentRepository(session).get_with_report_by_id_for_user(document_id, user_id) if status is None: return None return { "id": status.id, "status": status.status, "stage": status.stage, "filename": status.filename, "created_at": status.created_at, "markdown": status.markdown, "content_json": status.content_json, "model_used": status.model_used, "prompt_tokens": status.prompt_tokens, "eval_tokens": status.eval_tokens, "latency_ms": status.latency_ms, "mime": status.mime, "bytes": status.bytes_, "report_created_at": status.report_created_at, } class CurrentUser: """Authenticated user extracted from a Bearer JWT.""" def __init__(self, user_id: UUID, telegram_id: int) -> None: self.user_id = user_id self.telegram_id = telegram_id async def resolve_user_from_token(session: AsyncSession, token: str) -> CurrentUser: """Verify a raw access JWT and return the user (shared auth core).""" try: claims = verify_access_token(token) except TokenExpiredError as exc: raise HTTPException(status_code=401, detail="Token expired") from exc except TokenInvalidError as exc: raise HTTPException(status_code=401, detail="Invalid token") from exc except AuthError as exc: raise HTTPException(status_code=401, detail=str(exc)) from exc # Ensure the user still exists (defense in depth: tokens are stateless, # but a deleted user should not be able to use them). if not await UserRepository(session).exists(claims.sub): raise HTTPException(status_code=401, detail="User not found") return CurrentUser(user_id=claims.sub, telegram_id=claims.telegram_id) async def require_current_user( session: AsyncSessionDep, authorization: Annotated[str | None, Header()] = None, ) -> CurrentUser: """Validate `Authorization: Bearer ` and return the user. This is the common auth gate for bot, web, and Mini App users. """ if not authorization or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=401, detail="Missing or invalid Authorization header") token = authorization[7:].strip() return await resolve_user_from_token(session, token) CurrentUserDep = Annotated[CurrentUser, Depends(require_current_user)] async def require_current_user_header_or_query( session: AsyncSessionDep, authorization: Annotated[str | None, Header()] = None, access_token: Annotated[str | None, Query()] = None, ) -> CurrentUser: """Auth for EventSource-compatible endpoints (SSE). Native `EventSource` cannot set request headers, so besides the usual `Authorization: Bearer` header this also accepts the access JWT via the `access_token` query parameter: new EventSource(`/api/v1/reports/{id}/events?access_token=`) Caveat: query strings may end up in proxy access logs — pass short-lived access tokens only, never refresh tokens. """ token: str | None = None if authorization and authorization.lower().startswith("bearer "): token = authorization[7:].strip() elif access_token: token = access_token.strip() if not token: raise HTTPException( status_code=401, detail="Missing auth: use Authorization header or access_token query param", ) return await resolve_user_from_token(session, token) EventSourceUserDep = Annotated[CurrentUser, Depends(require_current_user_header_or_query)] async def get_credits(session: AsyncSession, user_id: UUID) -> int: credits = await UserRepository(session).get_credits(user_id) return credits if credits is not None else 0