| from typing import Optional, List |
| from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, Body |
| from fastapi.security import OAuth2PasswordBearer, HTTPBearer, HTTPAuthorizationCredentials |
| from jose import JWTError, jwt |
| from sqlalchemy.orm import Session |
| from core.config import settings |
| from core.database import get_db |
| from models.schemas import UserCreate, Token, TokenData, UserResponse, UserLogin, ForgotPasswordRequest, ChangePasswordRequest, VerifyOTPRequest, ResetPasswordRequest |
| from models import db_models |
| from supabase import create_client, Client |
| import logging |
| import httpx |
| from datetime import datetime, timedelta |
|
|
| logger = logging.getLogger(__name__) |
|
|
| router = APIRouter(prefix="/api/auth", tags=["auth"]) |
|
|
| |
| supabase: Client = create_client(settings.SUPABASE_URL, settings.SUPABASE_ANON_KEY) |
|
|
| |
| JWKS_CACHE = None |
|
|
| async def get_jwks(): |
| """Fetches the JWKS public keys from Supabase.""" |
| global JWKS_CACHE |
| if JWKS_CACHE is None: |
| try: |
| jwks_url = f"{settings.SUPABASE_URL}/auth/v1/.well-known/jwks.json" |
| async with httpx.AsyncClient() as client: |
| response = await client.get(jwks_url) |
| response.raise_for_status() |
| JWKS_CACHE = response.json() |
| logger.info("Successfully fetched Supabase JWKS") |
| except Exception as e: |
| logger.error(f"Failed to fetch JWKS: {str(e)}") |
| return None |
| return JWKS_CACHE |
|
|
| |
| oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False) |
| bearer_scheme = HTTPBearer(auto_error=False) |
|
|
| async def get_token( |
| request: Request, |
| oauth_token: Optional[str] = Depends(oauth2_scheme), |
| bearer_token: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme) |
| ) -> str: |
| token = oauth_token or (bearer_token.credentials if bearer_token else None) |
| if not token: |
| auth_header = request.headers.get("Authorization") |
| if auth_header and auth_header.startswith("Bearer "): |
| token = auth_header.split(" ")[1] |
| |
| if not token: |
| raise HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="Not authenticated. Please Authorize with a Bearer token.", |
| headers={"WWW-Authenticate": "Bearer"}, |
| ) |
| return token |
|
|
| async def get_current_user(token: str = Depends(get_token), db: Session = Depends(get_db)): |
| """ |
| Verifies Supabase JWT using JWKS and returns the local user from Azure SQL. |
| Supports both ES256 (asymmetric) and HS256 (symmetric) automatically. |
| """ |
| credentials_exception = HTTPException( |
| status_code=status.HTTP_401_UNAUTHORIZED, |
| detail="Could not validate credentials", |
| headers={"WWW-Authenticate": "Bearer"}, |
| ) |
| |
| try: |
| |
| header = jwt.get_unverified_header(token) |
| alg = header.get("alg") |
| |
| |
| if alg == "ES256": |
| |
| jwks = await get_jwks() |
| if not jwks: |
| raise credentials_exception |
| |
| payload = jwt.decode( |
| token, |
| jwks, |
| algorithms=["ES256"], |
| options={"verify_aud": False} |
| ) |
| else: |
| |
| payload = jwt.decode( |
| token, |
| settings.SUPABASE_JWT_SECRET, |
| algorithms=["HS256", "HS384", "HS512"], |
| options={"verify_aud": False} |
| ) |
| |
| supabase_id: str = payload.get("sub") |
| email: str = payload.get("email") |
| |
| if supabase_id is None: |
| raise credentials_exception |
| |
| except JWTError as e: |
| logger.error(f"JWT Verification Error: {str(e)}") |
| raise credentials_exception |
| |
| |
| user = db.query(db_models.User).filter(db_models.User.supabase_id == supabase_id).first() |
| if user is None: |
| user = db.query(db_models.User).filter(db_models.User.email == email).first() |
| if user: |
| user.supabase_id = supabase_id |
| db.commit() |
| db.refresh(user) |
| else: |
| user = db_models.User(email=email, supabase_id=supabase_id, is_active=True) |
| db.add(user) |
| db.commit() |
| db.refresh(user) |
| |
| return user |
|
|
| async def get_current_user_ws(token: str, db: Session): |
| """WS Auth helper (Internal only).""" |
| try: |
| header = jwt.get_unverified_header(token) |
| alg = header.get("alg") |
| if alg == "ES256": |
| |
| jwks = JWKS_CACHE or {} |
| payload = jwt.decode(token, jwks, algorithms=["ES256"], options={"verify_aud": False}) |
| else: |
| payload = jwt.decode(token, settings.SUPABASE_JWT_SECRET, algorithms=["HS256"], options={"verify_aud": False}) |
| |
| supabase_id = payload.get("sub") |
| return db.query(db_models.User).filter(db_models.User.supabase_id == supabase_id).first() |
| except: |
| return None |
|
|
| @router.post("/register", response_model=UserResponse) |
| async def register(user_in: UserCreate, db: Session = Depends(get_db)): |
| try: |
| auth_response = supabase.auth.sign_up({"email": user_in.email, "password": user_in.password}) |
| if not auth_response.user: raise HTTPException(status_code=400, detail="Registration failed") |
| |
| supabase_id = auth_response.user.id |
| db_user = db.query(db_models.User).filter(db_models.User.email == user_in.email).first() |
| if db_user: db_user.supabase_id = supabase_id |
| else: |
| db_user = db_models.User(email=user_in.email, supabase_id=supabase_id, is_active=True) |
| db.add(db_user) |
| db.commit() |
| db.refresh(db_user) |
| return db_user |
| except Exception as e: |
| raise HTTPException(status_code=400, detail=str(e)) |
|
|
| @router.post("/login", response_model=Token) |
| async def login( |
| request: Request, |
| email: Optional[str] = Body(None), |
| password: Optional[str] = Body(None), |
| username: Optional[str] = Form(None), |
| password_form: Optional[str] = Form(None, alias="password"), |
| db: Session = Depends(get_db) |
| ): |
| final_email = email or username |
| final_password = password or password_form |
| if not final_email or not final_password: |
| raise HTTPException(status_code=422, detail="Email and password required") |
|
|
| |
| db_user = db.query(db_models.User).filter(db_models.User.email == final_email).first() |
| if not db_user: |
| raise HTTPException(status_code=404, detail="User does not exist") |
|
|
| try: |
| response = supabase.auth.sign_in_with_password({"email": final_email, "password": final_password}) |
| if not response.session: |
| raise HTTPException(status_code=401, detail="Invalid login credentials") |
|
|
| |
| if not db_user.supabase_id or db_user.supabase_id != response.user.id: |
| db_user.supabase_id = response.user.id |
| db.commit() |
|
|
| return {"access_token": response.session.access_token, "token_type": "bearer"} |
| except Exception as e: |
| error_str = str(e).lower() |
| if "invalid login credentials" in error_str or "invalid_credentials" in error_str: |
| raise HTTPException(status_code=401, detail="Invalid login credentials") |
| |
| logger.error(f"Login failed unexpectedly: {str(e)}") |
| raise HTTPException(status_code=401, detail=f"Login failed: {str(e)}") |
|
|
| @router.post("/forgot-password") |
| async def forgot_password(request: ForgotPasswordRequest): |
| try: |
| supabase.auth.reset_password_for_email(request.email) |
| return {"message": "Verification code has been sent."} |
| except Exception as e: |
| raise HTTPException(status_code=400, detail=str(e)) |
|
|
| @router.post("/verify-otp") |
| async def verify_otp(request: VerifyOTPRequest): |
| try: |
| verify_response = supabase.auth.verify_otp({ |
| "email": request.email, |
| "token": request.otp, |
| "type": "recovery" |
| }) |
| if not verify_response.session: raise HTTPException(status_code=400, detail="Invalid code") |
| return { |
| "access_token": verify_response.session.access_token, |
| "token_type": "bearer", |
| "message": "OTP verified." |
| } |
| except Exception as e: |
| raise HTTPException(status_code=400, detail=str(e)) |
|
|
| @router.post("/reset-password") |
| async def reset_password(request: ResetPasswordRequest, token: str = Depends(get_token)): |
| try: |
| temp_supabase = create_client(settings.SUPABASE_URL, settings.SUPABASE_ANON_KEY) |
| temp_supabase.auth.set_session(token, "") |
| temp_supabase.auth.update_user({"password": request.new_password}) |
| return {"message": "Password successfully reset."} |
| except Exception as e: |
| raise HTTPException(status_code=400, detail=str(e)) |
|
|
| @router.post("/change-password") |
| async def change_password(request: ChangePasswordRequest, current_user: db_models.User = Depends(get_current_user)): |
| try: |
| supabase.auth.update_user({"password": request.new_password}) |
| return {"message": "Password updated successfully"} |
| except Exception as e: |
| raise HTTPException(status_code=400, detail=str(e)) |