"""Redis-backed magic-link authentication. The user service is deliberately not coupled to this module. Once that service has resolved an email address to its stable user identifier, call ``create_magic_token`` and put the returned token in the emailed login URL. """ 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_magic_token, create_magic_token from jb.config import settings from jb.decorators import gr_api_manager from jb.models.auth import ( MagicLinkExchangeRequest, AccountLogin, User, email_to_product_user_id, ) 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.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.post("/magic-link/exchange", status_code=status.HTTP_204_NO_CONTENT) def exchange_magic_link(body: MagicLinkExchangeRequest, response: Response) -> 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_manager.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.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="/")