import logging from typing import Annotated from urllib.parse import urlencode import httpx from fastapi import APIRouter, Depends, Query from fastapi.responses import RedirectResponse from src.api.dependencies import CurrentUserDep, DbSessionDep from src.models.auth import ( CurrentUserResponse, LoginRequest, LogoutRequest, RefreshTokenRequest, RegisterRequest, TokenResponse, ) from src.services.auth import AuthService, AuthServiceError from src.services.security import ( SecurityError, build_google_authorization_url, create_oauth_state, exchange_google_code, verify_google_id_token, verify_oauth_state, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["auth"]) def get_auth_service(db: DbSessionDep) -> AuthService: return AuthService(db) AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] def build_frontend_auth_redirect(callback_url: str, token_response: TokenResponse) -> str: fragment = urlencode( { "access_token": token_response.access_token, "refresh_token": token_response.refresh_token, "token_type": token_response.token_type, "access_token_expires_in": token_response.access_token_expires_in, } ) separator = "&" if "#" in callback_url else "#" return f"{callback_url}{separator}{fragment}" @router.post("/register") def register(payload: RegisterRequest, auth_service: AuthServiceDep) -> TokenResponse: return auth_service.register( email=payload.email, password=payload.password, full_name=payload.full_name, ) @router.post("/login") def login(payload: LoginRequest, auth_service: AuthServiceDep) -> TokenResponse: return auth_service.login(email=payload.email, password=payload.password) @router.post("/refresh") def refresh(payload: RefreshTokenRequest, auth_service: AuthServiceDep) -> TokenResponse: return auth_service.refresh(payload.refresh_token) @router.post("/logout") def logout(payload: LogoutRequest, auth_service: AuthServiceDep) -> dict[str, str]: auth_service.logout(payload.refresh_token) return {"status": "ok"} @router.get("/me") def me(current_user: CurrentUserDep, auth_service: AuthServiceDep) -> CurrentUserResponse: return auth_service.current_user_response(current_user) @router.get("/google/login") def google_login(auth_service: AuthServiceDep) -> RedirectResponse: settings = auth_service.settings if not settings.google_client_id or not settings.google_client_secret: raise AuthServiceError(503, "auth_unavailable", "Google OAuth is not configured.") state = create_oauth_state(settings) return RedirectResponse(build_google_authorization_url(settings, state)) @router.get("/google/callback") async def google_callback( code: Annotated[str, Query(min_length=1)], state: Annotated[str, Query(min_length=1)], auth_service: AuthServiceDep, ) -> RedirectResponse: settings = auth_service.settings try: verify_oauth_state(state, settings) token_payload = await exchange_google_code(code, settings) claims = verify_google_id_token(str(token_payload.get("id_token") or ""), settings) except httpx.HTTPStatusError as exc: try: google_error = exc.response.json() except ValueError: google_error = {"body": exc.response.text[:500]} logger.exception( "Google OAuth token exchange failed: status=%s error=%s redirect_uri=%s client_id_suffix=%s", exc.response.status_code, google_error, settings.google_redirect_uri, settings.google_client_id[-12:], ) raise AuthServiceError(401, "unauthorized", "Google OAuth failed.") from exc except (SecurityError, ValueError, httpx.HTTPError) as exc: logger.exception( "Google OAuth failed after callback: error_type=%s message=%s redirect_uri=%s client_id_suffix=%s", type(exc).__name__, str(exc), settings.google_redirect_uri, settings.google_client_id[-12:], ) raise AuthServiceError(401, "unauthorized", "Google OAuth failed.") from exc token_response = auth_service.upsert_google_user(claims) # MVP/dev handoff: URL fragments are not sent to the backend in follow-up # requests. A future production flow should prefer HttpOnly cookies. return RedirectResponse( build_frontend_auth_redirect( settings.frontend_auth_callback_url, token_response, ) )