1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
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="/")
|