"""Auth API endpoints: register, login, refresh, logout.""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings from app.core.db import get_db from app.core.deps import get_current_user from app.models.user import User from app.schemas.auth import ( LogoutResponse, RegisterResponse, TokenResponse, UserLoginRequest, UserRegisterRequest, ) from app.schemas.user import UserOut from app.services import auth_service router = APIRouter(prefix="/auth", tags=["auth"]) @router.post( "/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED, summary="Bootstrap user registration (only allowed if users table is empty)", ) async def register( payload: UserRegisterRequest, db: AsyncSession = Depends(get_db), ) -> RegisterResponse: """Create the first user and a default organization. Returns 403 after the first user has been registered. """ try: user, token = await auth_service.register_user(db, payload) except auth_service.BootstrapAlreadyCompleted as e: raise HTTPException(status_code=403, detail=str(e)) from e except auth_service.EmailAlreadyExists as e: raise HTTPException(status_code=409, detail=str(e)) from e settings = get_settings() return RegisterResponse( user=UserOut.model_validate(user), access_token=token, token_type="bearer", expires_in=settings.jwt_expiry_seconds, ) @router.post( "/login", response_model=TokenResponse, summary="Login with email + password (form-data or JSON)", ) async def login( form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db), ) -> TokenResponse: """OAuth2-compatible login. `username` field carries the email. Returns 401 on invalid credentials — never leaks whether the email exists. """ result = await auth_service.authenticate_user( db, email=form_data.username, password=form_data.password ) if result is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password", headers={"WWW-Authenticate": "Bearer"}, ) _user, token = result settings = get_settings() return TokenResponse( access_token=token, token_type="bearer", expires_in=settings.jwt_expiry_seconds, ) @router.post( "/login/json", response_model=TokenResponse, summary="Login with JSON body (alternative to form-data)", ) async def login_json( payload: UserLoginRequest, db: AsyncSession = Depends(get_db), ) -> TokenResponse: """JSON-body login variant for clients that prefer JSON over form-data.""" result = await auth_service.authenticate_user( db, email=payload.email, password=payload.password ) if result is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password", headers={"WWW-Authenticate": "Bearer"}, ) _user, token = result settings = get_settings() return TokenResponse( access_token=token, token_type="bearer", expires_in=settings.jwt_expiry_seconds, ) @router.post( "/refresh", response_model=TokenResponse, summary="Issue a new JWT for the current user", ) async def refresh( current_user: User = Depends(get_current_user), ) -> TokenResponse: """Re-issue a fresh token. v1.1 will add refresh-token rotation; v1 re-signs with the same secret.""" settings = get_settings() from app.core.security import create_access_token role_str = ( current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role) ) token = create_access_token(current_user.id, current_user.org_id, role_str) return TokenResponse( access_token=token, token_type="bearer", expires_in=settings.jwt_expiry_seconds, ) @router.post( "/logout", response_model=LogoutResponse, status_code=status.HTTP_200_OK, summary="Logout (client-side token discard)", ) async def logout( _current_user: User = Depends(get_current_user), ) -> LogoutResponse: """Stateless logout. Client deletes the token from localStorage. The endpoint validates the token (so a stolen token can be detected on logout) but does not maintain a server-side blacklist in v1. """ return LogoutResponse()