From 3f8d178d38686c1a5d71d94ebccdb61701469e95 Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 31 Aug 2026 16:52:15 -0600 Subject: working on magic token and session token auth --- jb/views/auth.py | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 jb/views/auth.py (limited to 'jb/views/auth.py') diff --git a/jb/views/auth.py b/jb/views/auth.py new file mode 100644 index 0000000..0591294 --- /dev/null +++ b/jb/views/auth.py @@ -0,0 +1,77 @@ +"""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 fastapi import APIRouter, Depends, 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 +from jb.config import settings +from jb.models.auth import ( + MagicLinkExchangeRequest, + AuthenticatedUser, + email_to_product_user_id, +) +from jb.settings import BASE_HTML + +auth_router = APIRouter(prefix="/auth", tags=["Auth"]) + + +@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) + product_user_id = email_to_product_user_id(user_email) + + # todo: hit thl to make sure this user exists + + session_token = create_session(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=AuthenticatedUser) +def get_session( + user: Annotated[AuthenticatedUser, Depends(get_authenticated_user)], +) -> AuthenticatedUser: + 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="/") -- cgit v1.2.3