from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import Annotated from uuid import uuid4 from authlib.jose import jwt # type: ignore from authlib.jose.errors import JoseError # type: ignore from fastapi import Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pwdlib import PasswordHash from pwdlib.hashers.argon2 import Argon2Hasher from app.core.config import settings from app.models.auth import AccessTokenPayload, AuthUserDocument from app.repositories.auth import get_user_by_username, is_token_revoked pwd_context = PasswordHash((Argon2Hasher(),)) bearer_scheme = HTTPBearer(auto_error=False) @dataclass(slots=True) class AuthContext: token: str user: AuthUserDocument payload: AccessTokenPayload class InvalidTokenError(Exception): pass def token_auth_error() -> HTTPException: return HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) def hash_password(password: str) -> str: return pwd_context.hash(password) def verify_password(plain_password: str, password_hash: str) -> bool: return pwd_context.verify(plain_password, password_hash) def create_access_token(*, subject: str) -> str: now = datetime.now(UTC) expires_delta = timedelta(minutes=settings.JWT_EXPIRE_MINUTES) expires_at = int((now + expires_delta).timestamp()) header = { "alg": settings.JWT_ALGORITHM, "typ": "JWT", } payload = { "sub": subject, "jti": str(uuid4()), "exp": expires_at, } token = jwt.encode(header, payload, settings.JWT_SECRET_KEY) if isinstance(token, bytes): return token.decode("utf-8") return token def decode_access_token(token: str) -> AccessTokenPayload: try: claims = jwt.decode(token, settings.JWT_SECRET_KEY) claims.validate() return AccessTokenPayload.model_validate(dict(claims)) except JoseError, ValueError, TypeError: raise InvalidTokenError from None async def authenticate_token(token: str) -> AuthContext: try: payload = decode_access_token(token) except InvalidTokenError: raise InvalidTokenError from None if await is_token_revoked(payload.jti): raise InvalidTokenError user = await get_user_by_username(payload.sub) if user is None: raise InvalidTokenError return AuthContext(token=token, user=user, payload=payload) async def get_auth_context( credentials: Annotated[ HTTPAuthorizationCredentials | None, Depends(bearer_scheme), ], ) -> AuthContext: if credentials is None or credentials.scheme.lower() != "bearer": raise token_auth_error() try: return await authenticate_token(credentials.credentials) except InvalidTokenError: raise token_auth_error() from None async def require_authenticated_user( auth_context: Annotated[AuthContext, Depends(get_auth_context)], ) -> AuthUserDocument: return auth_context.user