from __future__ import annotations import base64 import hashlib import hmac import os from datetime import datetime, timedelta, timezone from typing import Any import jwt from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.database import get_db from app.models.user import User bearer_scheme = HTTPBearer(auto_error=False) MEDIA_AUTH_COOKIE = "docdoe_media_token" def get_current_user_optional( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), db: Session = Depends(get_db), ) -> User | None: settings = get_settings() if not settings.auth_enabled: if not ( settings.environment == "development" and settings.allow_insecure_dev_auth ): raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Authentication is not configured for this deployment.", ) return get_or_create_dev_user(db) token = ( credentials.credentials if credentials is not None and credentials.scheme.lower() == "bearer" else request.cookies.get(MEDIA_AUTH_COOKIE) ) if not token: return None auth_provider = (settings.auth_provider or "jwt").strip().lower() if auth_provider == "supabase": return _user_from_supabase_token(db, token) if auth_provider == "jwt": return _user_from_local_jwt(db, token) return None def get_current_user( user: User | None = Depends(get_current_user_optional), ) -> User | None: return user def require_user(user: User | None = Depends(get_current_user_optional)) -> User: if user is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required", headers={"WWW-Authenticate": "Bearer"}, ) return user def get_or_create_dev_user(db: Session) -> User: user = db.get(User, "usr_demo_student") if user is not None: if user.name != "Asnan" or user.email != "asnan@example.com": user.name = "Asnan" user.email = "asnan@example.com" db.commit() db.refresh(user) return user user = User( id="usr_demo_student", name="Asnan", email="asnan@example.com", role="student", class_level="Plus Two", syllabus="Kerala HSE", preferred_language="English", ) db.add(user) db.commit() db.refresh(user) return user def hash_password(password: str) -> str: salt = os.urandom(16) digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 390_000) return "pbkdf2_sha256$390000${}${}".format( base64.b64encode(salt).decode("ascii"), base64.b64encode(digest).decode("ascii"), ) def verify_password(password: str, password_hash: str | None) -> bool: if not password_hash: return False try: algorithm, iterations_text, salt_text, digest_text = password_hash.split("$", 3) if algorithm != "pbkdf2_sha256": return False salt = base64.b64decode(salt_text.encode("ascii")) expected_digest = base64.b64decode(digest_text.encode("ascii")) actual_digest = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, int(iterations_text), ) return hmac.compare_digest(actual_digest, expected_digest) except Exception: return False def create_access_token(user: User) -> tuple[str, int]: settings = get_settings() expires_in_seconds = settings.access_token_expire_minutes * 60 expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds) payload: dict[str, Any] = { "sub": user.id, "email": user.email, "role": user.role, "ver": user.auth_version, "exp": expires_at, "iat": datetime.now(timezone.utc), } token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) return token, expires_in_seconds def get_verified_auth_subject(token: str) -> str: """Return the verified identity-provider subject for an access token. Most application routes only need the hydrated local ``User``. Destructive identity operations also need the original provider subject so a legacy email-matched local row cannot accidentally be used as a Supabase user ID. """ settings = get_settings() auth_provider = (settings.auth_provider or "jwt").strip().lower() if auth_provider == "supabase": payload = _decode_supabase_jwt(token) elif auth_provider == "jwt": try: payload = jwt.decode( token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm], options={"verify_aud": False}, ) except jwt.PyJWTError as exc: raise _auth_error() from exc else: raise _auth_error() subject = str(payload.get("sub") or "") if not subject: raise _auth_error() return subject def _user_from_local_jwt(db: Session, token: str) -> User | None: settings = get_settings() try: payload = jwt.decode( token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm], options={"verify_aud": False}, ) except jwt.PyJWTError as exc: raise _auth_error() from exc user_id = str(payload.get("sub") or "") if not user_id: raise _auth_error() user = db.get(User, user_id) if user is None: raise _auth_error() try: token_version = int(payload.get("ver", 1)) except (TypeError, ValueError) as exc: raise _auth_error() from exc if token_version != user.auth_version: raise _auth_error() return user def _user_from_supabase_token(db: Session, token: str) -> User | None: payload = _decode_supabase_jwt(token) user_id = str(payload.get("sub") or "") email = str(payload.get("email") or "").strip().lower() if not user_id: raise _auth_error() user = db.get(User, user_id) if user is not None: return user if email: # A verified Supabase email is not proof that this is the same account # as a legacy local-JWT row. Returning that row would merge identities # and can expose its private state to a different provider subject. existing_email_user = db.scalar(select(User).where(User.email == email)) if existing_email_user is not None: raise _auth_error() user = User( id=user_id, name=email.split("@")[0] if email else "Student", email=email or f"{user_id}@supabase.local", role="student", preferred_language="English", ) db.add(user) db.commit() db.refresh(user) return user def _decode_supabase_jwt(token: str) -> dict[str, Any]: """Verify the exact Supabase issuer and audience before trusting ``sub``. DocDoe's current Supabase integration uses the configured legacy HS256 JWT secret. A project using asymmetric signing must add JWKS verification before changing the Supabase signing-key configuration. """ settings = get_settings() if not settings.supabase_jwt_secret or not settings.supabase_url: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Supabase auth is enabled but SUPABASE_URL or SUPABASE_JWT_SECRET is not configured.", ) try: payload = jwt.decode( token, settings.supabase_jwt_secret, algorithms=["HS256"], audience="authenticated", issuer=f"{settings.supabase_url.rstrip('/')}/auth/v1", ) except jwt.PyJWTError as exc: raise _auth_error() from exc return payload def _auth_error() -> HTTPException: return HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired authentication token", headers={"WWW-Authenticate": "Bearer"}, )