aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorstuppie2026-08-31 16:52:15 -0600
committerstuppie2026-08-31 16:52:15 -0600
commit3f8d178d38686c1a5d71d94ebccdb61701469e95 (patch)
tree435707789cc0d1eb3c8670e4ab4bbae11d773285
parent12f6fee851b68e86af658dfa17e4a0daed457dd1 (diff)
downloadamt-jb-3f8d178d38686c1a5d71d94ebccdb61701469e95.tar.gz
amt-jb-3f8d178d38686c1a5d71d94ebccdb61701469e95.zip
working on magic token and session token auth
-rw-r--r--amt-jb.conf4
-rw-r--r--jb-ui/src/JBApp.tsx4
-rw-r--r--jb-ui/src/pages/MagicLink.tsx44
-rw-r--r--jb/api/__init__.py0
-rw-r--r--jb/api/auth.py92
-rw-r--r--jb/api/magic_token.py41
-rw-r--r--jb/main.py2
-rw-r--r--jb/models/auth.py65
-rw-r--r--jb/settings.py7
-rw-r--r--jb/views/auth.py77
-rw-r--r--requirements.txt1
11 files changed, 334 insertions, 3 deletions
diff --git a/amt-jb.conf b/amt-jb.conf
index c6edf5f..8bd87c6 100644
--- a/amt-jb.conf
+++ b/amt-jb.conf
@@ -9,4 +9,6 @@ autorestart=true
numprocs=2
numprocs_start=1
process_name=uvicorn-%(process_num)d
-environment=LD_LIBRARY_PATH="/usr/local/lib:" \ No newline at end of file
+environment=LD_LIBRARY_PATH="/usr/local/lib:"
+stopasgroup=true
+killasgroup=true \ No newline at end of file
diff --git a/jb-ui/src/JBApp.tsx b/jb-ui/src/JBApp.tsx
index 76e3f29..ef23b3b 100644
--- a/jb-ui/src/JBApp.tsx
+++ b/jb-ui/src/JBApp.tsx
@@ -19,6 +19,7 @@ import { setAssignmentID, setProductUserID, setTurkSubmitTo } from "@/models/app
import { addEvent } from "@/models/grlEventsSlice";
import { addStatsData } from "@/models/grlStatsSlice";
import Home from "@/pages/Home";
+import MagicLink from "@/pages/MagicLink";
import Preview from "@/pages/Preview";
import Result from "@/pages/Result";
import Work from "@/pages/Work";
@@ -193,6 +194,7 @@ function JBApp() {
return (
<BrowserRouter basename={routeBasename}>
<Routes>
+ <Route path="/auth/magic-link/" element={<MagicLink />} />
<Route element={<QueryParamProcessor />}>
<Route element={<Layout />}>
<Route path="/" element={<Home />} />
@@ -206,4 +208,4 @@ function JBApp() {
)
}
-export default JBApp; \ No newline at end of file
+export default JBApp;
diff --git a/jb-ui/src/pages/MagicLink.tsx b/jb-ui/src/pages/MagicLink.tsx
new file mode 100644
index 0000000..cf7f87b
--- /dev/null
+++ b/jb-ui/src/pages/MagicLink.tsx
@@ -0,0 +1,44 @@
+import { useEffect, useRef, useState } from "react";
+
+type ExchangeState = "working" | "failed" | "missing";
+
+const MagicLink = function () {
+ const started = useRef(false);
+ const [state, setState] = useState<ExchangeState>("working");
+
+ useEffect(() => {
+ // React Strict Mode runs effects twice in development. Never redeem twice.
+ if (started.current) return;
+ started.current = true;
+
+ const params = new URLSearchParams(window.location.search);
+ const token = params.get("token");
+ if (!token) {
+ setState("missing");
+ return;
+ }
+
+ // Keep the credential out of browser history and subsequent Referer headers.
+ window.history.replaceState({}, "", window.location.pathname);
+
+ void fetch("/auth/magic-link/exchange", {
+ method: "POST",
+ credentials: "include",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({token}),
+ }).then((response) => {
+ if (!response.ok) throw new Error("Magic-link exchange failed");
+ window.location.replace("/");
+ }).catch(() => setState("failed"));
+ }, []);
+
+ if (state === "missing") {
+ return <p>This login link is missing its token.</p>;
+ }
+ if (state === "failed") {
+ return <p>This login link is invalid or has expired.</p>;
+ }
+ return <p>Signing you in…</p>;
+};
+
+export default MagicLink;
diff --git a/jb/api/__init__.py b/jb/api/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/jb/api/__init__.py
diff --git a/jb/api/auth.py b/jb/api/auth.py
new file mode 100644
index 0000000..7d6c352
--- /dev/null
+++ b/jb/api/auth.py
@@ -0,0 +1,92 @@
+import logging
+from datetime import datetime, timedelta, timezone
+from typing import Annotated
+from uuid import uuid4
+
+import jwt
+from fastapi import Depends, HTTPException, Request, Response, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+from jb.config import settings
+from jb.models.auth import AuthenticatedUser
+
+bearer = HTTPBearer(auto_error=False)
+logger = logging.getLogger(__name__)
+
+AUTH_CACHE_TTL_SECONDS = 60
+
+SESSION_COOKIE_NAME = "jb_session"
+JWT_ISSUER = "jamesbillings67"
+JWT_AUDIENCE = "jamesbillings67"
+
+
+def get_authenticated_user(
+ request: Request,
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
+) -> AuthenticatedUser:
+ """FastAPI dependency for endpoints requiring a valid session."""
+ if settings.session_jwt_secret is None:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Account session signing key is not configured",
+ )
+
+ token = request.cookies.get(SESSION_COOKIE_NAME)
+ if credentials is not None and credentials.scheme.lower() == "bearer":
+ token = credentials.credentials
+
+ if token is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Missing session token",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ try:
+ claims = jwt.decode(
+ token,
+ settings.session_jwt_secret.get_secret_value(),
+ algorithms=["HS256"],
+ issuer=JWT_ISSUER,
+ audience=JWT_AUDIENCE,
+ options={"require": ["sub", "iat", "exp", "jti", "iss", "aud", "type"]},
+ )
+ except jwt.PyJWTError:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired session token",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ if claims["type"] != "session" or not isinstance(claims["sub"], str):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired session token",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ product_user_id = claims.get("sub")
+
+ # todo: in here, hit THL by the user's bpuid (email hash),
+ # in order to 1) be sure user exists & 2) pull the display_name
+ user_email = ... # lookup in thl by product_user_id
+
+ return AuthenticatedUser(email=user_email)
+
+
+def create_session(product_user_id: str) -> str:
+ now = datetime.now(timezone.utc)
+ return jwt.encode(
+ {
+ "sub": product_user_id,
+ "iat": now,
+ "exp": now + timedelta(seconds=settings.session_token_ttl_seconds),
+ "jti": uuid4().hex,
+ "iss": JWT_ISSUER,
+ "aud": JWT_AUDIENCE,
+ "type": "session",
+ },
+ settings.session_jwt_secret.get_secret_value(),
+ algorithm="HS256",
+ )
+
+
diff --git a/jb/api/magic_token.py b/jb/api/magic_token.py
new file mode 100644
index 0000000..136e1b4
--- /dev/null
+++ b/jb/api/magic_token.py
@@ -0,0 +1,41 @@
+import hashlib
+import secrets
+
+from fastapi import HTTPException, status
+
+from jb.decorators import REDIS
+
+MAGIC_TOKEN_PREFIX = "auth:magic:"
+MAGIC_TOKEN_TTL: int = 5 * 60 # 5 minutes, in seconds
+
+
+def redis_token_key(token: str) -> str:
+ # Redis never contains a usable credential, even if its keys are exposed.
+ digest = hashlib.sha256(token.encode("utf-8")).hexdigest()
+ return f"{MAGIC_TOKEN_PREFIX}{digest}"
+
+
+def create_magic_token(user_email: str) -> str:
+ """Create a short-lived, single-use token for a user.
+ The raw token can then be sent by email.
+ """
+ if not user_email or not user_email.strip():
+ raise ValueError("user_email must not be empty")
+
+ token = secrets.token_urlsafe(32)
+ REDIS.set(
+ redis_token_key(token),
+ user_email,
+ ex=MAGIC_TOKEN_TTL,
+ )
+ return token
+
+
+def consume_magic_token(token: str) -> str:
+ user_email = REDIS.getdel(redis_token_key(token))
+ if user_email is None:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired magic token",
+ )
+ return user_email
diff --git a/jb/main.py b/jb/main.py
index fa59167..2b85eb7 100644
--- a/jb/main.py
+++ b/jb/main.py
@@ -7,6 +7,7 @@ from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from jb.views.common import common_router
+from jb.views.auth import auth_router
from jb.settings import BASE_HTML
from jb.config import settings
@@ -31,6 +32,7 @@ app.add_middleware(
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["*"])
app.include_router(router=common_router)
+app.include_router(router=auth_router)
@app.get("/robots.txt")
diff --git a/jb/models/auth.py b/jb/models/auth.py
new file mode 100644
index 0000000..5f31bd3
--- /dev/null
+++ b/jb/models/auth.py
@@ -0,0 +1,65 @@
+import hashlib
+import hmac
+
+from pydantic import (
+ BaseModel,
+ ConfigDict,
+ EmailStr,
+ Field,
+ TypeAdapter,
+ computed_field,
+)
+
+from jb.config import settings
+
+
+def email_to_product_user_id(email: str) -> str:
+ """Return a deterministic, non-reversible product user ID for an email.
+
+ The same normalized email and secret salt always produce the same ID. Keep
+ the salt private and stable; changing it changes every generated ID.
+ """
+ salt_bytes = settings.magic_token_salt.get_secret_value().encode("utf-8")
+ if len(salt_bytes) < 32:
+ raise ValueError("salt must be at least 32 bytes")
+
+ if not email.isascii():
+ raise ValueError("email must contain ASCII characters only")
+
+ normalized_email = str(TypeAdapter(EmailStr).validate_python(email)).lower()
+ return hmac.new(
+ key=salt_bytes,
+ msg=normalized_email.encode("utf-8"),
+ digestmod=hashlib.sha256,
+ ).hexdigest()
+
+
+class AuthenticatedUser(BaseModel):
+ """A user that has been authenticated and exists in THL"""
+
+ email: EmailStr = Field()
+
+ @computed_field
+ def product_user_id(self) -> str:
+ return email_to_product_user_id(self.email)
+
+
+class AccountCreate(BaseModel):
+ email: EmailStr = Field()
+
+
+class AccountLogin(BaseModel):
+ email: EmailStr = Field()
+
+
+
+class MagicLinkExchangeRequest(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+ token: str = Field(min_length=1)
+
+
+class SessionResponse(BaseModel):
+ session_token: str
+ token_type: str = "bearer"
+ expires_in: int
diff --git a/jb/settings.py b/jb/settings.py
index d529591..cc1e870 100644
--- a/jb/settings.py
+++ b/jb/settings.py
@@ -4,7 +4,7 @@ from pathlib import Path
from typing import Optional
from generalresearchutils.models.custom_types import InfluxDsn
-from pydantic import Field, PostgresDsn, HttpUrl, RedisDsn
+from pydantic import Field, PostgresDsn, HttpUrl, RedisDsn, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
from jb.models.custom_types import UUIDStr
@@ -55,6 +55,11 @@ class Settings(AmtJbBaseSettings):
sns_path: str = Field()
+ session_token_ttl_seconds: int = Field(default=30 * 24 * 60 * 60, gt=0)
+ session_jwt_secret: SecretStr = Field(min_length=32)
+
+ magic_token_salt: SecretStr = Field(min_length=32)
+
class TestSettings(Settings):
model_config = SettingsConfigDict(
diff --git a/jb/views/auth.py b/jb/views/auth.py
new file mode 100644
index 0000000..0591294
--- /dev/null
+++ b/jb/views/auth.py
@@ -0,0 +1,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="/")
diff --git a/requirements.txt b/requirements.txt
index 8976073..c23b668 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -66,6 +66,7 @@ Pygments==2.19.2
pylibmc==1.6.3
pymemcache==4.0.0
PyMySQL==1.1.2
+PyJWT==2.13.0
pytest==8.4.2
python-dateutil==2.9.0.post0
python-dotenv==1.1.1