Add user accounts: registration, login, and JWT auth
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s

The first authenticated-user concept in Aurora Core -- everything else in
this codebase is public, read-only catalog data. Foundation for ratings
and a watchlist (not yet built): POST /auth/register, POST /auth/login,
GET /auth/me.

Security specifics worth knowing:
- Passwords hashed with argon2 (via argon2-cffi), never stored plain or
  logged.
- Login returns an identical 401 for a nonexistent email and a wrong
  password on a real one, including matching response timing
  (burn_time_like_a_password_check) -- otherwise either the status code
  or the timing alone lets a client enumerate registered emails.
- AURORA_SECRET_KEY now actually signs JWTs (previously defined but
  unused anywhere) -- .env.example's existing token_urlsafe(48) guidance
  already produces a long enough key, just flagged its new stakes.
- Email matched case-insensitively via explicit normalization in the
  service layer, not relied on from Pydantic's EmailStr (which does not
  lowercase the local part).

12 new integration tests (register/login/me over real HTTP via the
`client` fixture) plus 10 unit tests for the hashing/JWT logic itself,
following the same-day lesson that only unit tests actually execute
locally without Postgres -- the auth core is tested for real here rather
than only through integration tests that might not run before a deploy.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
NixReaper
2026-09-06 06:15:39 -05:00
co-authored by Claude Sonnet 5
parent fde5e76bd7
commit 7bee596a0e
16 changed files with 628 additions and 2 deletions
+3 -1
View File
@@ -21,7 +21,9 @@ REDIS_URL=redis://localhost:6379/0
REDIS_REQUIRED=false
# --- Security ---
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))"
# Signs user-account JWT access tokens (app/core/auth.py) -- a weak or
# leaked value lets anyone forge a valid login for any user id. Generate
# with: python -c "import secrets; print(secrets.token_urlsafe(48))"
AURORA_SECRET_KEY=changeme-generate-a-real-secret
# Comma-separated API keys allowed to call /api/v1/admin/* endpoints.
AURORA_ADMIN_API_KEYS=changeme-admin-key
+33 -1
View File
@@ -4,12 +4,44 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from fastapi import HTTPException, status
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import InvalidTokenError, decode_access_token
from app.core.database import get_db
from app.models.user import User
from app.repositories.user_repository import get_user_by_id
from app.services.cache import cache_get_json, cache_set_json
_bearer_scheme = HTTPBearer(auto_error=False)
def _unauthorized() -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "not_authenticated", "message": "Missing or invalid access token."},
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
session: AsyncSession = Depends(get_db),
) -> User:
if credentials is None:
raise _unauthorized()
try:
user_id = decode_access_token(credentials.credentials)
except InvalidTokenError as exc:
raise _unauthorized() from exc
user = await get_user_by_id(session, user_id)
if user is None or not user.is_active:
raise _unauthorized()
return user
def not_found(entity_type: str, aurora_id_str: str) -> HTTPException:
return HTTPException(
+68
View File
@@ -0,0 +1,68 @@
"""Registration and login (section 17, additive). Public, but rate-limited
like every other unauthenticated endpoint -- see app/core/security.py.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.auth import create_access_token
from app.core.database import get_db
from app.core.ids import aurora_id
from app.core.security import enforce_rate_limit
from app.models.user import User
from app.schemas.auth import LoginRequest, RegisterRequest, TokenOut, UserOut
from app.services.user_service import (
EmailAlreadyRegisteredError,
InvalidCredentialsError,
authenticate_user,
register_user,
)
router = APIRouter(prefix="/auth", tags=["auth"], dependencies=[Depends(enforce_rate_limit)])
def _to_user_out(user: User) -> UserOut:
return UserOut(
id=aurora_id("user", str(user.id)),
email=user.email,
display_name=user.display_name,
created_at=user.created_at,
)
@router.post("/register", response_model=TokenOut, status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest, session: AsyncSession = Depends(get_db)) -> TokenOut:
try:
user = await register_user(
session, email=body.email, password=body.password, display_name=body.display_name
)
except EmailAlreadyRegisteredError as exc:
raise HTTPException(
status.HTTP_409_CONFLICT,
{"code": "email_already_registered", "message": "An account with this email already exists."},
) from exc
await session.commit()
return TokenOut(access_token=create_access_token(user.id))
@router.post("/login", response_model=TokenOut)
async def login(body: LoginRequest, session: AsyncSession = Depends(get_db)) -> TokenOut:
try:
user = await authenticate_user(session, email=body.email, password=body.password)
except InvalidCredentialsError as exc:
# Deliberately identical whether the email doesn't exist or the
# password is wrong (see authenticate_user) -- distinguishing them
# here would let a client enumerate registered emails.
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
{"code": "invalid_credentials", "message": "Incorrect email or password."},
) from exc
return TokenOut(access_token=create_access_token(user.id))
@router.get("/me", response_model=UserOut)
async def get_me(user: User = Depends(get_current_user)) -> UserOut:
return _to_user_out(user)
+75
View File
@@ -0,0 +1,75 @@
"""Password hashing and JWT access tokens for registered users.
Separate from app/core/security.py, which handles admin-API-key auth and
rate limiting -- unrelated concerns (a machine-to-machine secret vs. a
human user's own credentials).
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from app.core.config import get_settings
_hasher = PasswordHasher()
_JWT_ALGORITHM = "HS256"
ACCESS_TOKEN_TTL = timedelta(hours=24)
class InvalidTokenError(Exception):
"""Raised for any access token that's missing, malformed, expired, or
signed with a different key -- callers treat all of these identically
(401), so this collapses PyJWT's several exception types into one."""
def normalize_email(email: str) -> str:
"""Emails are matched and stored case-insensitively (RFC 5321 technically
allows a case-sensitive local part, but no mainstream provider honors
that, and forcing lowercase avoids needing a functional unique index)."""
return email.strip().lower()
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, hashed_password: str) -> bool:
try:
return _hasher.verify(hashed_password, password)
except VerifyMismatchError:
return False
# Computed once at import time, verified against on a login attempt whose
# email doesn't exist at all -- so rejecting a nonexistent account takes
# roughly the same time as rejecting a wrong password on a real one.
# Without this, a login endpoint's response time itself reveals which
# emails are registered, letting an attacker enumerate accounts without
# ever guessing a password.
_DUMMY_HASH = _hasher.hash("not-a-real-password-used-only-for-timing")
def burn_time_like_a_password_check() -> None:
verify_password("irrelevant", _DUMMY_HASH)
def create_access_token(user_id: uuid.UUID) -> str:
settings = get_settings()
now = datetime.now(UTC)
payload = {"sub": str(user_id), "iat": now, "exp": now + ACCESS_TOKEN_TTL}
return jwt.encode(payload, settings.AURORA_SECRET_KEY, algorithm=_JWT_ALGORITHM)
def decode_access_token(token: str) -> uuid.UUID:
settings = get_settings()
try:
payload = jwt.decode(token, settings.AURORA_SECRET_KEY, algorithms=[_JWT_ALGORITHM])
return uuid.UUID(payload["sub"])
except (jwt.InvalidTokenError, KeyError, ValueError) as exc:
raise InvalidTokenError(str(exc)) from exc
+1
View File
@@ -51,6 +51,7 @@ ENTITY_PREFIXES: dict[str, str] = {
"alternate_title": "altt",
"release": "rel",
"watch_provider": "watch_provider",
"user": "user",
}
+2
View File
@@ -23,6 +23,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from app.api.v1 import (
admin,
auth,
collections,
companies,
episodes,
@@ -181,6 +182,7 @@ app.include_router(health.router)
API_V1_PREFIX = "/api/v1"
app.include_router(health.router, prefix=API_V1_PREFIX)
app.include_router(search.router, prefix=API_V1_PREFIX)
app.include_router(auth.router, prefix=API_V1_PREFIX)
app.include_router(movies.router, prefix=API_V1_PREFIX)
app.include_router(series.router, prefix=API_V1_PREFIX)
app.include_router(seasons.router, prefix=API_V1_PREFIX)
+2
View File
@@ -33,6 +33,7 @@ from app.models.tv import (
SeriesProductionCountry,
SeriesSpokenLanguage,
)
from app.models.user import User
from app.models.video import Video
from app.models.watch_provider import WatchProvider, WatchProviderOffering
@@ -77,4 +78,5 @@ __all__ = [
"WatchProvider",
"WatchProviderOffering",
"TrendingEntry",
"User",
]
+24
View File
@@ -0,0 +1,24 @@
"""Registered AMDBS users -- section 17, additive (accounts/ratings/
watchlist). The first authenticated-user concept in Aurora Core; every
other entity in this codebase is public, read-only catalog data."""
from __future__ import annotations
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class User(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "users"
# Stored lowercased (see app.core.auth._normalize_email) so lookups and
# the unique constraint are case-insensitive without a functional index.
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
__all__ = ["User"]
+29
View File
@@ -0,0 +1,29 @@
"""Data access for User."""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
async def get_user_by_id(session: AsyncSession, user_id: uuid.UUID) -> User | None:
result = await session.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def get_user_by_email(session: AsyncSession, email: str) -> User | None:
result = await session.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def create_user(
session: AsyncSession, *, email: str, hashed_password: str, display_name: str | None = None
) -> User:
user = User(email=email, hashed_password=hashed_password, display_name=display_name)
session.add(user)
await session.flush()
return user
+30
View File
@@ -0,0 +1,30 @@
"""Registration/login request and response schemas."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: str | None = Field(default=None, max_length=100)
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=128)
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
class UserOut(BaseModel):
id: str
email: str
display_name: str | None = None
created_at: datetime
+53
View File
@@ -0,0 +1,53 @@
"""Registration and login. See app/core/auth.py for hashing/token
mechanics -- this layer owns the business rules (duplicate email, wrong
credentials)."""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import (
burn_time_like_a_password_check,
hash_password,
normalize_email,
verify_password,
)
from app.models.user import User
from app.repositories.user_repository import create_user, get_user_by_email
class EmailAlreadyRegisteredError(Exception):
pass
class InvalidCredentialsError(Exception):
pass
async def register_user(
session: AsyncSession, *, email: str, password: str, display_name: str | None = None
) -> User:
normalized = normalize_email(email)
if await get_user_by_email(session, normalized) is not None:
raise EmailAlreadyRegisteredError(normalized)
return await create_user(
session,
email=normalized,
hashed_password=hash_password(password),
display_name=display_name,
)
async def authenticate_user(session: AsyncSession, *, email: str, password: str) -> User:
normalized = normalize_email(email)
user = await get_user_by_email(session, normalized)
if user is None:
# See burn_time_like_a_password_check's docstring: an email that
# doesn't exist must fail no faster than a real one with a wrong
# password, or response timing alone reveals which emails are
# registered.
burn_time_like_a_password_check()
raise InvalidCredentialsError()
if not user.is_active or not verify_password(password, user.hashed_password):
raise InvalidCredentialsError()
return user
+13
View File
@@ -20,6 +20,19 @@ a consistent envelope:
| GET | `/health` and `/api/v1/health` | Liveness only. |
| GET | `/ready` and `/api/v1/ready` | Checks PostgreSQL (always) and Redis (if `REDIS_REQUIRED=true`). |
## Accounts
| Method | Path | Body | Notes |
|---|---|---|---|
| POST | `/api/v1/auth/register` | `{"email", "password", "display_name"?}` | Creates an account and logs it in — returns an access token immediately, same as `/login`. `password` is 8-128 characters (length only; NIST 800-63B recommends against complexity rules). 409 if the email is already registered (matched case-insensitively). |
| POST | `/api/v1/auth/login` | `{"email", "password"}` | Returns `{"access_token", "token_type": "bearer"}`. A nonexistent email and a wrong password both return the identical 401 `invalid_credentials` — distinguishing them would let a client enumerate registered emails, including via response timing (see `app/core/auth.py:burn_time_like_a_password_check`). |
| GET | `/api/v1/auth/me` | — | Requires `Authorization: Bearer <access_token>`. Returns the caller's own `{"id", "email", "display_name", "created_at"}`. |
Access tokens are JWTs signed with `AURORA_SECRET_KEY`, valid for 24 hours,
with no refresh-token flow yet — a client re-authenticates (logs in again)
once a token expires. This is the foundation ratings and a watchlist will
build on next; those aren't live yet.
## Search
| Method | Path | Notes |
+50
View File
@@ -0,0 +1,50 @@
"""users
Revision ID: 0004_users
Revises: 0003_trending
Create Date: 2026-09-06
Adds the users table (section 17, additive: accounts/ratings/watchlist)
-- the first authenticated-user concept in Aurora Core.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0004_users"
down_revision: str | None = "0003_trending"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("email", sa.String(320), nullable=False, unique=True),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("display_name", sa.String(100), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.create_index("ix_users_email", "users", ["email"])
def downgrade() -> None:
op.drop_index("ix_users_email", table_name="users")
op.drop_table("users")
+3
View File
@@ -25,6 +25,9 @@ dependencies = [
"python-dotenv>=1.0.1",
"tenacity>=9.0.0",
"prometheus-client>=0.20.0",
"argon2-cffi>=23.1.0",
"pyjwt>=2.9.0",
"email-validator>=2.2.0",
]
[project.optional-dependencies]
+146
View File
@@ -0,0 +1,146 @@
"""Integration tests for registration/login (section 17, additive) --
the first authenticated-user flow in Aurora Core. Exercised at the HTTP
layer (register -> login -> /me with the returned bearer token) rather
than just the service layer, since that's the actual contract a frontend
integrates against."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
@pytest.mark.asyncio
async def test_register_returns_a_usable_access_token(client: AsyncClient, db_session: AsyncSession):
response = await client.post(
"/api/v1/auth/register",
json={"email": "[email protected]", "password": "wake-up-neo", "display_name": "Neo"},
)
assert response.status_code == 201
token = response.json()["access_token"]
assert response.json()["token_type"] == "bearer"
me = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
assert me.status_code == 200
body = me.json()
assert body["email"] == "[email protected]"
assert body["display_name"] == "Neo"
assert body["id"].startswith("aur_user_")
@pytest.mark.asyncio
async def test_register_normalizes_email_case(client: AsyncClient, db_session: AsyncSession):
response = await client.post(
"/api/v1/auth/register",
json={"email": " [email protected] ", "password": "follow-the-white-rabbit"},
)
assert response.status_code == 201
token = response.json()["access_token"]
me = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
assert me.json()["email"] == "[email protected]"
@pytest.mark.asyncio
async def test_register_rejects_duplicate_email(client: AsyncClient, db_session: AsyncSession):
await client.post(
"/api/v1/auth/register", json={"email": "[email protected]", "password": "red-pill-please"}
)
response = await client.post(
"/api/v1/auth/register",
json={"email": "[email protected]", "password": "a-different-password"},
)
assert response.status_code == 409
assert response.json()["error"]["code"] == "email_already_registered"
@pytest.mark.asyncio
async def test_register_rejects_duplicate_email_case_insensitively(
client: AsyncClient, db_session: AsyncSession
):
await client.post(
"/api/v1/auth/register", json={"email": "[email protected]", "password": "i-want-steak"}
)
response = await client.post(
"/api/v1/auth/register", json={"email": "[email protected]", "password": "something-else"}
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_register_rejects_short_password(client: AsyncClient, db_session: AsyncSession):
response = await client.post(
"/api/v1/auth/register", json={"email": "[email protected]", "password": "short"}
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_register_rejects_malformed_email(client: AsyncClient, db_session: AsyncSession):
response = await client.post(
"/api/v1/auth/register", json={"email": "not-an-email", "password": "whatever-password"}
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_login_succeeds_with_correct_credentials(client: AsyncClient, db_session: AsyncSession):
await client.post(
"/api/v1/auth/register", json={"email": "[email protected]", "password": "operator-standing-by"}
)
response = await client.post(
"/api/v1/auth/login", json={"email": "[email protected]", "password": "operator-standing-by"}
)
assert response.status_code == 200
assert "access_token" in response.json()
@pytest.mark.asyncio
async def test_login_rejects_wrong_password(client: AsyncClient, db_session: AsyncSession):
await client.post(
"/api/v1/auth/register", json={"email": "[email protected]", "password": "the-real-password"}
)
response = await client.post(
"/api/v1/auth/login", json={"email": "[email protected]", "password": "wrong-password-here"}
)
assert response.status_code == 401
assert response.json()["error"]["code"] == "invalid_credentials"
@pytest.mark.asyncio
async def test_login_rejects_unknown_email_with_the_same_error_as_wrong_password(
client: AsyncClient, db_session: AsyncSession
):
# Same status code and error code as a wrong password -- see
# app/services/user_service.py:authenticate_user. Distinguishing "no
# such account" from "wrong password" would let a client enumerate
# registered emails.
response = await client.post(
"/api/v1/auth/login", json={"email": "[email protected]", "password": "does-not-matter"}
)
assert response.status_code == 401
assert response.json()["error"]["code"] == "invalid_credentials"
@pytest.mark.asyncio
async def test_me_rejects_missing_token(client: AsyncClient):
response = await client.get("/api/v1/auth/me")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_me_rejects_garbage_token(client: AsyncClient):
response = await client.get(
"/api/v1/auth/me", headers={"Authorization": "Bearer not-a-real-token"}
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_me_rejects_a_token_for_a_different_secret(client: AsyncClient):
import jwt
forged = jwt.encode({"sub": "00000000-0000-0000-0000-000000000000"}, "wrong-secret", algorithm="HS256")
response = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {forged}"})
assert response.status_code == 401
+96
View File
@@ -0,0 +1,96 @@
"""Unit tests for app/core/auth.py -- pure functions, no DB needed. Written
after a real bug (watch_provider never registered as an Aurora ID entity
type) shipped unnoticed because every test touching that path skips
locally without Postgres -- this module's logic is exercised for real
here rather than only through integration tests that might not run."""
from __future__ import annotations
import uuid
import pytest
from app.core.auth import (
InvalidTokenError,
burn_time_like_a_password_check,
create_access_token,
decode_access_token,
hash_password,
normalize_email,
verify_password,
)
def test_hash_password_round_trips():
hashed = hash_password("correct horse battery staple")
assert verify_password("correct horse battery staple", hashed) is True
def test_verify_password_rejects_wrong_password():
hashed = hash_password("correct horse battery staple")
assert verify_password("wrong password", hashed) is False
def test_hash_password_is_salted():
# Same input, different output each time -- otherwise two users with
# the same password would have identical hashed_password rows,
# trivially revealing the match to anyone with DB access.
assert hash_password("same password") != hash_password("same password")
def test_normalize_email_lowercases_and_strips():
assert normalize_email(" [email protected] ") == "[email protected]"
def test_access_token_round_trips():
user_id = uuid.uuid4()
token = create_access_token(user_id)
assert decode_access_token(token) == user_id
def test_decode_rejects_garbage_token():
with pytest.raises(InvalidTokenError):
decode_access_token("not.a.valid.token")
def test_decode_rejects_token_signed_with_a_different_key(monkeypatch):
import jwt
from app.core import auth as auth_module
forged = jwt.encode({"sub": str(uuid.uuid4())}, "a-different-secret", algorithm="HS256")
with pytest.raises(InvalidTokenError):
auth_module.decode_access_token(forged)
def test_decode_rejects_expired_token():
from datetime import UTC, datetime, timedelta
import jwt
from app.core import auth as auth_module
from app.core.config import get_settings
expired_payload = {
"sub": str(uuid.uuid4()),
"iat": datetime.now(UTC) - timedelta(hours=48),
"exp": datetime.now(UTC) - timedelta(hours=24),
}
token = jwt.encode(expired_payload, get_settings().AURORA_SECRET_KEY, algorithm="HS256")
with pytest.raises(InvalidTokenError):
auth_module.decode_access_token(token)
def test_decode_rejects_token_missing_subject():
import jwt
from app.core import auth as auth_module
from app.core.config import get_settings
token = jwt.encode({"nope": "no sub claim"}, get_settings().AURORA_SECRET_KEY, algorithm="HS256")
with pytest.raises(InvalidTokenError):
auth_module.decode_access_token(token)
def test_burn_time_like_a_password_check_does_not_raise():
burn_time_like_a_password_check()