From 3f8d178d38686c1a5d71d94ebccdb61701469e95 Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 31 Aug 2026 16:52:15 -0600 Subject: working on magic token and session token auth --- jb/api/__init__.py | 0 jb/api/auth.py | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++ jb/api/magic_token.py | 41 +++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 jb/api/__init__.py create mode 100644 jb/api/auth.py create mode 100644 jb/api/magic_token.py (limited to 'jb/api') diff --git a/jb/api/__init__.py b/jb/api/__init__.py new file mode 100644 index 0000000..e69de29 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 -- cgit v1.2.3