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
+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()