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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
|
"""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="/")
|