aboutsummaryrefslogtreecommitdiff
path: root/jb/views/auth.py
diff options
context:
space:
mode:
Diffstat (limited to 'jb/views/auth.py')
-rw-r--r--jb/views/auth.py203
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="/")