from typing import Annotated from urllib.parse import urlencode from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi.responses import HTMLResponse from jb.api.auth import ( SESSION_COOKIE_NAME, create_session, get_authenticated_user, ) from jb.api.magic_token import ( consume_amt_account_link_token, consume_magic_token, create_amt_account_link_token, create_magic_token, ) from jb.config import settings from jb.dependencies import get_gr_api_manager from jb.managers.gr_api import GRApiManager from jb.models.auth import ( AccountLogin, AmtAccountLink, MagicLinkExchangeRequest, User, ) from jb.settings import BASE_HTML auth_router = APIRouter(prefix="/auth", tags=["Auth"]) @auth_router.post("/magic-link/request") def request_mock_magic_link(body: AccountLogin) -> dict[str, str]: """Create a magic link without sending email in development.""" if not settings.debug: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) # todo: send email here user = User(email=body.email) token = create_magic_token(str(user.email)) query = urlencode({"token": token}) return {"magic_link": f"/auth/magic-link/?{query}"} @auth_router.post("/link-amt/request") def link_amt_account(body: AmtAccountLink) -> dict[str, str]: """Create a mock AMT account-link email in development.""" if not settings.debug: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) # TODO: Derive amt_worker_id from a server-validated AMT assignment and # send this link by email instead of returning it. user = User(email=body.email) token = create_amt_account_link_token(user, body.amt_worker_id) query = urlencode({"token": token}) return {"magic_link": f"/auth/link-amt/?{query}"} @auth_router.get("/magic-link/", response_class=HTMLResponse, include_in_schema=False) def magic_link_landing_page() -> HTMLResponse: """Serve the SPA without redeeming the token; email prefetches are harmless.""" return HTMLResponse( BASE_HTML, headers={ "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Robots-Tag": "noindex, nofollow", }, ) @auth_router.get("/link-amt/", response_class=HTMLResponse, include_in_schema=False) def link_amt_account_landing_page() -> HTMLResponse: """Serve the account-link SPA without consuming the one-time token.""" return magic_link_landing_page() @auth_router.post("/magic-link/exchange", status_code=status.HTTP_204_NO_CONTENT) def exchange_magic_link( body: MagicLinkExchangeRequest, response: Response, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], ) -> None: """Exchange a magic link only after its landing page makes an explicit POST.""" user_email = consume_magic_token(body.token) user = User.model_validate({"email": user_email}) # hit thl to make sure this user exists user = gr_api.ensure_user_exists(user) session_token = create_session(user.product_user_id) response.set_cookie( key=SESSION_COOKIE_NAME, value=session_token, max_age=settings.session_token_ttl_seconds, httponly=True, secure=not settings.debug, samesite="lax", path="/", ) @auth_router.post("/link-amt/exchange", status_code=status.HTTP_204_NO_CONTENT) def exchange_amt_account_link( body: MagicLinkExchangeRequest, response: Response, gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], ) -> None: """Validate the email link, then transition the bound AMT account.""" token_data = consume_amt_account_link_token(body.token) user = User(email=token_data.email) user = gr_api.transition_user_from_amt(user, token_data.amt_worker_id) session_token = create_session(user.product_user_id) response.set_cookie( key=SESSION_COOKIE_NAME, value=session_token, max_age=settings.session_token_ttl_seconds, httponly=True, secure=not settings.debug, samesite="lax", path="/", ) @auth_router.get("/session", response_model=User) def get_session( user: Annotated[User, Depends(get_authenticated_user)], ) -> User: return user @auth_router.delete("/session", status_code=status.HTTP_204_NO_CONTENT) def delete_session( response: Response, ) -> None: # Logout is idempotent so clients can always discard their local token. # JWT sessions are stateless, so logout discards the browser cookie. A token # copied elsewhere remains valid until its short, configured expiration. response.delete_cookie(key=SESSION_COOKIE_NAME, path="/")