diff --git a/.env.example b/.env.example index 263306c..7d71cdf 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/api/deps.py b/app/api/deps.py index 93e8024..55be39d 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -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( diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..4669382 --- /dev/null +++ b/app/api/v1/auth.py @@ -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) diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..33d4c3e --- /dev/null +++ b/app/core/auth.py @@ -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 diff --git a/app/core/ids.py b/app/core/ids.py index b10f32a..122187b 100644 --- a/app/core/ids.py +++ b/app/core/ids.py @@ -51,6 +51,7 @@ ENTITY_PREFIXES: dict[str, str] = { "alternate_title": "altt", "release": "rel", "watch_provider": "watch_provider", + "user": "user", } diff --git a/app/main.py b/app/main.py index 30d5a9a..2523f83 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index 99fc6bb..fae8d24 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", ] diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..bd37505 --- /dev/null +++ b/app/models/user.py @@ -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"] diff --git a/app/repositories/user_repository.py b/app/repositories/user_repository.py new file mode 100644 index 0000000..c1be396 --- /dev/null +++ b/app/repositories/user_repository.py @@ -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 diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..89c952e --- /dev/null +++ b/app/schemas/auth.py @@ -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 diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..da6505b --- /dev/null +++ b/app/services/user_service.py @@ -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 diff --git a/docs/api.md b/docs/api.md index 49f2b8e..2554639 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 `. 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 | diff --git a/migrations/versions/0004_users.py b/migrations/versions/0004_users.py new file mode 100644 index 0000000..446e13e --- /dev/null +++ b/migrations/versions/0004_users.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 2566a23..354e042 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py new file mode 100644 index 0000000..a4dedb3 --- /dev/null +++ b/tests/integration/test_auth_api.py @@ -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": "neo@zion.net", "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"] == "neo@zion.net" + 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": " Trinity@Zion.NET ", "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"] == "trinity@zion.net" + + +@pytest.mark.asyncio +async def test_register_rejects_duplicate_email(client: AsyncClient, db_session: AsyncSession): + await client.post( + "/api/v1/auth/register", json={"email": "morpheus@zion.net", "password": "red-pill-please"} + ) + response = await client.post( + "/api/v1/auth/register", + json={"email": "morpheus@zion.net", "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": "cypher@zion.net", "password": "i-want-steak"} + ) + response = await client.post( + "/api/v1/auth/register", json={"email": "CYPHER@ZION.NET", "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": "short@zion.net", "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": "tank@zion.net", "password": "operator-standing-by"} + ) + response = await client.post( + "/api/v1/auth/login", json={"email": "tank@zion.net", "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": "dozer@zion.net", "password": "the-real-password"} + ) + response = await client.post( + "/api/v1/auth/login", json={"email": "dozer@zion.net", "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": "nobody@zion.net", "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 diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000..0c159da --- /dev/null +++ b/tests/unit/test_auth.py @@ -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(" User@Example.COM ") == "user@example.com" + + +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()