aboutsummaryrefslogtreecommitdiff
path: root/jb/api
diff options
context:
space:
mode:
authorstuppie2026-08-31 16:52:15 -0600
committerstuppie2026-08-31 16:52:15 -0600
commit3f8d178d38686c1a5d71d94ebccdb61701469e95 (patch)
tree435707789cc0d1eb3c8670e4ab4bbae11d773285 /jb/api
parent12f6fee851b68e86af658dfa17e4a0daed457dd1 (diff)
downloadamt-jb-3f8d178d38686c1a5d71d94ebccdb61701469e95.tar.gz
amt-jb-3f8d178d38686c1a5d71d94ebccdb61701469e95.zip
working on magic token and session token auth
Diffstat (limited to 'jb/api')
-rw-r--r--jb/api/__init__.py0
-rw-r--r--jb/api/auth.py92
-rw-r--r--jb/api/magic_token.py41
3 files changed, 133 insertions, 0 deletions
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