aboutsummaryrefslogtreecommitdiff
path: root/jb/views/auth.py
blob: 059129406b78b582007723f7f5e623f1ebfb1d65 (plain)
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="/")