diff options
| author | Max Nanis | 2026-09-13 19:03:55 +0000 |
|---|---|---|
| committer | Max Nanis | 2026-09-13 19:03:55 +0000 |
| commit | 8fbe8d439b418796932aaa88297e006c714e4d13 (patch) | |
| tree | 6c800476edc1e771fc570b559d483df8d59d4324 /jb/views/auth.py | |
| parent | 12f6fee851b68e86af658dfa17e4a0daed457dd1 (diff) | |
| parent | 2c94f248d2438071a918fa9a30bf114ef9aa29b4 (diff) | |
| download | amt-jb-8fbe8d439b418796932aaa88297e006c714e4d13.tar.gz amt-jb-8fbe8d439b418796932aaa88297e006c714e4d13.zip | |
Merges pull request #3
Off of Amazon!!!
Diffstat (limited to 'jb/views/auth.py')
| -rw-r--r-- | jb/views/auth.py | 203 |
1 files changed, 203 insertions, 0 deletions
diff --git a/jb/views/auth.py b/jb/views/auth.py new file mode 100644 index 0000000..ce9c1e0 --- /dev/null +++ b/jb/views/auth.py @@ -0,0 +1,203 @@ +from typing import Annotated +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi.responses import HTMLResponse, RedirectResponse + +from jb.api.auth import ( + SESSION_COOKIE_NAME, + create_session, + get_authenticated_user, +) +from jb.api.magic_token import ( + consume_amt_account_link_token, + consume_magic_token, + create_amt_account_link_token, + create_magic_token, +) +from jb.config import settings +from jb.decorators import LOG +from jb.dependencies import get_gr_api_manager +from jb.managers.email_manager import ( + get_or_create_contact, + send_amt_link_email, + send_login_email, +) +from jb.managers.gr_api import GRApiManager +from jb.models.auth import ( + AccountLogin, + AmtAccountLink, + MagicLinkExchangeRequest, + User, +) +from jb.settings import BASE_HTML + +auth_router = APIRouter(prefix="/auth", tags=["Auth"]) + + +@auth_router.post("/magic-link/request") +def request_magic_link(body: AccountLogin) -> dict[str, str]: + """Create a magic link.""" + email = str(body.email) + token = create_magic_token(user_email=email) + + if settings.debug: + query = urlencode({"token": token}) + return {"magic_link": f"{settings.base_url}auth/magic-link/?{query}"} + + send_login_email(email=email, magic_token=token) + return {"detail": "Link sent. Check your inbox and follow the link to log in."} + + +@auth_router.get("/magic-link/", response_class=HTMLResponse, include_in_schema=False) +def magic_link_landing_page( + gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], + token: str | None = None, +) -> Response: + """Serve the SPA without redeeming the token; email prefetches are harmless.""" + if settings.debug: + if token is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="token is required", + ) + response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) + _exchange_magic_link(token, response, gr_api) + return response + 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, + gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], +) -> None: + """Exchange a magic link only after its landing page makes an explicit POST.""" + _exchange_magic_link(body.token, response, gr_api) + + +def _exchange_magic_link(token: str, response: Response, gr_api: GRApiManager) -> None: + user_email = consume_magic_token(token) + user = gr_api.ensure_user_exists(User.model_validate({"email": user_email})) + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=create_session(user.product_user_id), + max_age=settings.session_token_ttl_seconds, + httponly=True, + secure=not settings.debug, + samesite="lax", + path="/", + ) + + +@auth_router.post("/link-amt/request") +def link_amt_account(body: AmtAccountLink) -> dict[str, str]: + """Link an AMT account and login.""" + email = str(body.email) + amt_worker_id = body.amt_worker_id + + # TODO! Prevent a user that's already transitioned their account, from being + # TODO! able to continuously create this special link token. + # TODO! Max Notes: this seems to be handled within the gr-api, and that + # TODO! can raise, the following line would / should fail if needed. + + token = create_amt_account_link_token(email=email, amt_worker_id=amt_worker_id) + + if settings.debug: + query = urlencode({"token": token}) + return {"magic_link": f"{settings.base_url}auth/link-amt/?{query}"} + + send_amt_link_email(email=email, magic_token=token) + return {} + + +@auth_router.get("/debug/", response_class=HTMLResponse, include_in_schema=False) +def link_amt_account_landing_page( + gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], + token: str | None = None, +) -> HTMLResponse: + """Serve the account-link SPA without consuming the one-time token.""" + + # TODO! Try catch any of this, and if it fails, show the user a + # TODO! failed HTML page. As of now, it shows them a failed JSON response. + + if settings.debug: + if token is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="token is required", + ) + + _response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) + _exchange_amt_account_link(token=token, response=_response, gr_api=gr_api) + + return HTMLResponse( + BASE_HTML, + headers={ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Robots-Tag": "noindex, nofollow", + }, + ) + + +@auth_router.post("/link-amt/exchange", status_code=status.HTTP_204_NO_CONTENT) +def exchange_amt_account_link( + body: MagicLinkExchangeRequest, + response: Response, + gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], +) -> None: + """Validate the email link, then transition the bound AMT account.""" + try: + _exchange_amt_account_link(body.token, response, gr_api) + except ValueError as e: + LOG.error(f"Failed to exchange AMT account link: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + +def _exchange_amt_account_link(token: str, response: Response, gr_api: GRApiManager): + token_data = consume_amt_account_link_token(token) + email = token_data.email + amt_worker_id = token_data.amt_worker_id + + user = User(email=email) + user = gr_api.transition_user_from_amt(user=user, amt_worker_id=amt_worker_id) + + # In Mautic, associate the email with the worker ID (AFTER the user has transitioned) + get_or_create_contact(email=email, amt_worker_id=amt_worker_id) + + 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="/") |
