shreerangss's picture
Deploy production authentication, authorization, and CSP
30c6f13
Raw History Blame Contribute Delete
19.2 kB
from __future__ import annotations
import uuid
import importlib
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
import jwt
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, func, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
import backend.app.auth.router as auth_router_module
from backend.app.auth.config import ACCESS_JWT_AUDIENCE, ACCESS_JWT_ISSUER
from backend.app.auth.refresh_sessions import create_refresh_session, hash_refresh_token
from backend.app.auth.http_errors import install_auth_http_error_handlers
from backend.app.auth.router import router
from backend.app.auth.services import register_user
from backend.app.auth.tokens import create_access_token
from persistence.db import Base, get_session
from persistence.models import RefreshSession, User
TEST_SECRET = "phase-4b-test-secret-that-is-never-used-in-production"
SAME_ORIGIN = "http://testserver"
ORIGIN_HEADERS = {"Origin": SAME_ORIGIN}
PASSWORD = "fifteen chars ok!"
@pytest.fixture(autouse=True)
def auth_environment(monkeypatch):
monkeypatch.setenv("ACCESS_JWT_SECRET", TEST_SECRET)
monkeypatch.delenv("ACCESS_TOKEN_TTL_SECONDS", raising=False)
monkeypatch.setenv("REFRESH_COOKIE_SECURE", "false")
monkeypatch.delenv("AUTH_ALLOWED_ORIGINS", raising=False)
@pytest.fixture
def sqlite_engine():
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
future=True,
)
Base.metadata.create_all(engine)
try:
yield engine
finally:
Base.metadata.drop_all(engine)
engine.dispose()
@pytest.fixture
def app(sqlite_engine) -> FastAPI:
test_app = FastAPI()
install_auth_http_error_handlers(test_app)
def override_get_session():
with Session(sqlite_engine) as session:
yield session
test_app.dependency_overrides[get_session] = override_get_session
test_app.include_router(router)
return test_app
@pytest.fixture
def client(app):
with TestClient(app, base_url=SAME_ORIGIN) as test_client:
yield test_client
def _register(client: TestClient, *, email: str = "person@EXAMPLE.COM"):
return client.post(
"/auth/register",
headers=ORIGIN_HEADERS,
json={"email": email, "password": PASSWORD, "display_name": "Person"},
)
def _seed_user(engine, *, email: str = "person@example.com", password: str = PASSWORD) -> str:
with Session(engine) as session:
return register_user(session, email=email, password=password).id
def _auth_claims(token: str) -> dict:
return jwt.decode(
token,
TEST_SECRET,
algorithms=["HS256"],
issuer=ACCESS_JWT_ISSUER,
audience=ACCESS_JWT_AUDIENCE,
)
def test_register_returns_201_normalized_public_user_and_credentials(client, sqlite_engine) -> None:
response = _register(client)
assert response.status_code == 201
assert response.headers["cache-control"] == "no-store"
body = response.json()
assert body["token_type"] == "bearer"
assert body["expires_in"] == 600
assert _auth_claims(body["access_token"])["sub"] == body["user"]["id"]
assert body["user"]["email"] == "person@example.com"
assert set(body["user"]) == {"id", "email", "display_name", "auth_provider", "created_at"}
assert "password_hash" not in response.text
assert "refresh" not in body
with Session(sqlite_engine) as session:
user = session.get(User, body["user"]["id"])
assert user is not None
assert user.email == "person@example.com"
assert user.password_hash is not None
@pytest.mark.parametrize(
"password",
["x" * 14, "x" * 129],
)
def test_register_enforces_password_length_policy(client, password: str) -> None:
response = client.post(
"/auth/register",
headers=ORIGIN_HEADERS,
json={"email": "policy@example.com", "password": password},
)
assert response.status_code == 422
assert password not in response.text
def test_register_accepts_spaces_unicode_and_symbols_at_minimum_length(client) -> None:
response = client.post(
"/auth/register",
headers=ORIGIN_HEADERS,
json={"email": "unicode@example.com", "password": " 密碼 phrase 🔐!!!"},
)
assert response.status_code == 201
def test_register_rejects_invalid_email_and_normalized_duplicate(client) -> None:
invalid = client.post(
"/auth/register",
headers=ORIGIN_HEADERS,
json={"email": "not-an-email", "password": PASSWORD},
)
first = _register(client, email="duplicate@EXAMPLE.COM")
duplicate = _register(client, email="duplicate@example.com")
assert invalid.status_code == 422
assert first.status_code == 201
assert duplicate.status_code == 409
def test_failed_initial_refresh_creation_rolls_back_registered_user(
client,
sqlite_engine,
monkeypatch,
) -> None:
def fail_refresh_creation(*_args, **_kwargs):
raise SQLAlchemyError("forced refresh-session failure")
monkeypatch.setattr(auth_router_module, "add_refresh_session", fail_refresh_creation)
response = _register(client, email="rollback@example.com")
assert response.status_code == 503
assert response.json() == {"detail": "Authentication service is unavailable."}
with Session(sqlite_engine) as session:
assert session.scalar(select(func.count()).select_from(User)) == 0
assert session.scalar(select(func.count()).select_from(RefreshSession)) == 0
def test_login_returns_generic_401_for_wrong_password_and_unknown_email(client, sqlite_engine) -> None:
_seed_user(sqlite_engine)
wrong = client.post(
"/auth/login",
headers=ORIGIN_HEADERS,
json={"email": "person@example.com", "password": "incorrect password"},
)
unknown = client.post(
"/auth/login",
headers=ORIGIN_HEADERS,
json={"email": "unknown@example.com", "password": "incorrect password"},
)
assert wrong.status_code == unknown.status_code == 401
assert wrong.json() == unknown.json() == {"detail": "Invalid email or password."}
assert wrong.headers["www-authenticate"] == unknown.headers["www-authenticate"] == "Bearer"
def test_valid_login_is_public_and_each_login_creates_independent_session(
client,
sqlite_engine,
) -> None:
user_id = _seed_user(sqlite_engine)
first = client.post(
"/auth/login",
headers=ORIGIN_HEADERS,
json={"email": "person@EXAMPLE.COM", "password": PASSWORD},
)
first_cookie = first.cookies["idqe_refresh"]
second = client.post(
"/auth/login",
headers=ORIGIN_HEADERS,
json={"email": "person@example.com", "password": PASSWORD},
)
second_cookie = second.cookies["idqe_refresh"]
assert first.status_code == second.status_code == 200
assert first_cookie != second_cookie
assert _auth_claims(first.json()["access_token"])["sub"] == user_id
assert "password" not in first.json()
assert "password_hash" not in first.json()["user"]
assert first_cookie not in first.text
with Session(sqlite_engine) as session:
sessions = session.scalars(
select(RefreshSession).where(RefreshSession.user_id == user_id)
).all()
assert len(sessions) == 2
assert all(item.revoked_at is None for item in sessions)
def test_refresh_cookie_has_shared_attributes_and_matches_database_expiry(
client,
sqlite_engine,
) -> None:
response = _register(client, email="cookie@example.com")
raw_token = response.cookies["idqe_refresh"]
set_cookie = response.headers["set-cookie"]
assert "HttpOnly" in set_cookie
assert "SameSite=lax" in set_cookie
assert "Path=/auth" in set_cookie
assert "Domain=" not in set_cookie
assert "Secure" not in set_cookie
with Session(sqlite_engine) as session:
stored = session.scalar(
select(RefreshSession).where(
RefreshSession.token_hash == hash_refresh_token(raw_token)
)
)
assert stored is not None
expires_at = stored.expires_at.replace(tzinfo=timezone.utc)
cookie_parts = {
part.split("=", 1)[0].strip().lower(): part.split("=", 1)[1].strip()
for part in set_cookie.split(";")
if "=" in part
}
header_expiry = parsedate_to_datetime(cookie_parts["expires"])
assert abs((header_expiry - expires_at).total_seconds()) < 1
assert abs(int(cookie_parts["max-age"]) - (expires_at - datetime.now(timezone.utc)).total_seconds()) < 3
def test_refresh_cookie_is_secure_by_default_and_can_be_disabled_for_localhost(
app,
monkeypatch,
) -> None:
monkeypatch.delenv("REFRESH_COOKIE_SECURE", raising=False)
with TestClient(app, base_url=SAME_ORIGIN) as production_client:
production = _register(production_client, email="production-cookie@example.com")
assert "Secure" in production.headers["set-cookie"]
monkeypatch.setenv("REFRESH_COOKIE_SECURE", "false")
with TestClient(app, base_url=SAME_ORIGIN) as local_client:
local = _register(local_client, email="local-cookie@example.com")
assert "Secure" not in local.headers["set-cookie"]
def test_refresh_rotates_cookie_digest_and_preserves_absolute_expiry(client, sqlite_engine) -> None:
login = _register(client, email="rotate@example.com")
first_raw = login.cookies["idqe_refresh"]
with Session(sqlite_engine) as session:
first = session.scalar(
select(RefreshSession).where(RefreshSession.token_hash == hash_refresh_token(first_raw))
)
assert first is not None
first_id = first.id
absolute_expiry = first.expires_at
refreshed = client.post("/auth/refresh", headers=ORIGIN_HEADERS)
second_raw = refreshed.cookies["idqe_refresh"]
assert refreshed.status_code == 200
assert refreshed.headers["cache-control"] == "no-store"
assert second_raw != first_raw
assert first_raw not in refreshed.text
assert second_raw not in refreshed.text
refreshed_cookie_parts = {
part.split("=", 1)[0].strip().lower(): part.split("=", 1)[1].strip()
for part in refreshed.headers["set-cookie"].split(";")
if "=" in part
}
inherited_cookie_expiry = parsedate_to_datetime(refreshed_cookie_parts["expires"])
expected_expiry = absolute_expiry.replace(tzinfo=timezone.utc)
assert abs((inherited_cookie_expiry - expected_expiry).total_seconds()) < 1
with Session(sqlite_engine) as session:
original = session.get(RefreshSession, first_id)
replacement = session.scalar(
select(RefreshSession).where(RefreshSession.token_hash == hash_refresh_token(second_raw))
)
assert original is not None and replacement is not None
assert original.revoked_at is not None
assert original.replaced_by_session_id == replacement.id
assert replacement.expires_at == absolute_expiry
assert replacement.token_hash == hash_refresh_token(second_raw)
with TestClient(client.app, base_url=SAME_ORIGIN) as stale_client:
stale_client.cookies.set("idqe_refresh", first_raw, path="/auth")
stale = stale_client.post("/auth/refresh", headers=ORIGIN_HEADERS)
assert stale.status_code == 401
assert stale.json() == {"detail": "Invalid refresh credential."}
assert "set-cookie" not in stale.headers
@pytest.mark.parametrize("credential_kind", ["missing", "random", "expired", "revoked"])
def test_refresh_failures_collapse_to_generic_401(
app,
sqlite_engine,
credential_kind: str,
) -> None:
raw_token = None
if credential_kind in {"expired", "revoked"}:
user_id = _seed_user(sqlite_engine, email=f"{credential_kind}@example.com")
with Session(sqlite_engine) as session:
created = create_refresh_session(session, user_id)
raw_token = created.raw_token
stored = session.scalar(
select(RefreshSession).where(
RefreshSession.token_hash == hash_refresh_token(raw_token)
)
)
assert stored is not None
if credential_kind == "expired":
stored.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
else:
stored.revoked_at = datetime.now(timezone.utc)
session.commit()
elif credential_kind == "random":
raw_token = "random-refresh-credential"
with TestClient(app, base_url=SAME_ORIGIN) as refresh_client:
if raw_token:
refresh_client.cookies.set("idqe_refresh", raw_token, path="/auth")
response = refresh_client.post("/auth/refresh", headers=ORIGIN_HEADERS)
assert response.status_code == 401
assert response.json() == {"detail": "Invalid refresh credential."}
assert response.headers["www-authenticate"] == "Bearer"
if credential_kind in {"random", "revoked"}:
assert "set-cookie" not in response.headers
if credential_kind == "expired":
assert "Max-Age=0" in response.headers["set-cookie"]
def test_logout_revokes_only_current_device_clears_cookie_and_is_idempotent(
app,
sqlite_engine,
) -> None:
user_id = _seed_user(sqlite_engine, email="logout@example.com")
with TestClient(app, base_url=SAME_ORIGIN) as first_client, TestClient(
app, base_url=SAME_ORIGIN
) as second_client:
first_login = first_client.post(
"/auth/login",
headers=ORIGIN_HEADERS,
json={"email": "logout@example.com", "password": PASSWORD},
)
second_login = second_client.post(
"/auth/login",
headers=ORIGIN_HEADERS,
json={"email": "logout@example.com", "password": PASSWORD},
)
first_raw = first_login.cookies["idqe_refresh"]
second_raw = second_login.cookies["idqe_refresh"]
logout = first_client.post("/auth/logout", headers=ORIGIN_HEADERS)
repeated = first_client.post("/auth/logout", headers=ORIGIN_HEADERS)
assert logout.status_code == repeated.status_code == 204
assert logout.content == b""
clear_header = logout.headers["set-cookie"]
assert "idqe_refresh=" in clear_header
assert "Max-Age=0" in clear_header
assert "HttpOnly" in clear_header
assert "SameSite=lax" in clear_header
assert "Path=/auth" in clear_header
assert "Domain=" not in clear_header
with Session(sqlite_engine) as session:
first_session = session.scalar(
select(RefreshSession).where(
RefreshSession.token_hash == hash_refresh_token(first_raw)
)
)
second_session = session.scalar(
select(RefreshSession).where(
RefreshSession.token_hash == hash_refresh_token(second_raw)
)
)
assert first_session is not None and first_session.revoked_at is not None
assert second_session is not None and second_session.revoked_at is None
assert first_session.user_id == second_session.user_id == user_id
def test_me_returns_public_current_user_and_deleted_user_is_unauthorized(
client,
sqlite_engine,
) -> None:
user_id = _seed_user(sqlite_engine, email="me@example.com")
token = create_access_token(user_id)
response = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 200
assert response.json()["email"] == "me@example.com"
assert "password_hash" not in response.text
with Session(sqlite_engine) as session:
user = session.get(User, user_id)
assert user is not None
session.delete(user)
session.commit()
deleted = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
assert deleted.status_code == 401
assert deleted.headers["www-authenticate"] == "Bearer"
@pytest.mark.parametrize("token", ["not-a-jwt", None])
def test_me_rejects_invalid_or_missing_access_token(client, token: str | None) -> None:
headers = {"Authorization": f"Bearer {token}"} if token else {}
response = client.get("/auth/me", headers=headers)
assert response.status_code == 401
assert response.headers["www-authenticate"] == "Bearer"
def test_me_rejects_expired_access_token(client) -> None:
now = int(datetime.now(timezone.utc).timestamp())
token = jwt.encode(
{
"sub": str(uuid.uuid4()),
"iat": now - 700,
"exp": now - 100,
"jti": str(uuid.uuid4()),
"iss": ACCESS_JWT_ISSUER,
"aud": ACCESS_JWT_AUDIENCE,
},
TEST_SECRET,
algorithm="HS256",
)
response = client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 401
assert response.headers["www-authenticate"] == "Bearer"
def test_same_origin_posts_succeed_and_cross_origin_auth_posts_are_rejected(
client,
sqlite_engine,
) -> None:
same_origin_register = _register(client, email="origin@example.com")
assert same_origin_register.status_code == 201
cross_origin = {"Origin": "https://attacker.example"}
refresh = client.post("/auth/refresh", headers=cross_origin)
logout = client.post("/auth/logout", headers=cross_origin)
login = client.post(
"/auth/login",
headers=cross_origin,
json={"email": "origin@example.com", "password": PASSWORD},
)
register = client.post(
"/auth/register",
headers=cross_origin,
json={"email": "attacker@example.com", "password": PASSWORD},
)
assert refresh.status_code == logout.status_code == 403
assert login.status_code == register.status_code == 403
with Session(sqlite_engine) as session:
assert session.scalar(select(func.count()).select_from(User)) == 1
def test_explicit_production_origin_does_not_allow_other_origins(client, monkeypatch) -> None:
monkeypatch.setenv("AUTH_ALLOWED_ORIGINS", "https://app.example.com")
allowed = client.post(
"/auth/register",
headers={"Origin": "https://app.example.com"},
json={"email": "allowed-origin@example.com", "password": PASSWORD},
)
rejected = client.post(
"/auth/register",
headers={"Origin": "https://other.example.com"},
json={"email": "rejected-origin@example.com", "password": PASSWORD},
)
assert allowed.status_code == 201
assert rejected.status_code == 403
def test_root_application_registers_auth_router_before_spa_catch_all() -> None:
application_module = importlib.import_module("main")
paths = [route.path for route in application_module.app.routes]
assert "/auth/register" in paths
assert "/auth/login" in paths
assert "/auth/refresh" in paths
assert "/auth/logout" in paths
assert "/auth/me" in paths
assert paths.index("/auth/register") < paths.index("/{full_path:path}")