Phase 0 milestone 0.3: auth + JWT + iam.assurance_level claim

Lands local password auth with JWT access + refresh tokens, and starts
carrying YPMS-specific claims (`uid`, `roles`, `iam.assurance_level`,
`mfa_verified`) so downstream authorization can gate high-risk actions
above base RBAC without a DB round-trip. Follows the OWC auth pattern
verbatim except where noted.

Added:

- src/ypms/auth/security.py    hash_password / verify_password
                               (pbkdf2_sha256), create_access_token
                               with YPMS-specific claims,
                               create_refresh_token, decode_token
- src/ypms/auth/rate_limit.py  SlidingWindow (vendored from OWC),
                               login_limiter tuned to NIST AC-7 /
                               source RFP C.5.7.10: 5 failed attempts
                               per IP per 15 minutes → 429 lockout
- src/ypms/auth/schemas.py     LoginResponse, RefreshRequest,
                               RefreshResponse, ChangePasswordRequest,
                               UserRead
- src/ypms/auth/deps.py        get_current_user replaces the milestone
                               0.2 stub; rejects refresh tokens on
                               resource endpoints; refuses inactive users
- src/ypms/auth/router.py      POST /auth/login  (OAuth2 form; audit-logs
                               successful auth; DOES_SYSTEM_ADMIN role
                               upgrades assurance_level to `admin` in
                               the token)
                               POST /auth/refresh (issues fresh access;
                               refuses non-refresh tokens)
                               GET  /auth/me
                               POST /auth/change-password (self-service;
                               ≥12 char floor; audit-logged)
- src/ypms/main.py             Registers auth_router

Tests (43 passing, 93.47% coverage — up from 26 / 93.10%):

- tests/auth/test_security.py  Token claims shape, refresh-vs-access
                               separation, invalid-token handling
                               (5 tests)
- tests/auth/test_router.py    End-to-end via TestClient with a
                               dependency-overridden DB: login happy
                               path + wrong password + inactive user,
                               admin assurance upgrade, /me auth-gate,
                               refresh happy path + reject access token,
                               change-password full flow + length gate,
                               login lockout after 5 attempts (12 tests)

Test-infra fixes:

- tests/conftest.py            Env defaults moved from a session-scoped
                               fixture to module-import time so
                               `ypms.main` (which builds the app at
                               import) can be transitively imported by
                               test modules without ValidationError on
                               Settings.
                               `db` fixture switched to StaticPool +
                               check_same_thread=False so TestClient's
                               worker thread sees the same in-memory
                               SQLite DB the fixture set up.

Notes:

- OWC includes an /auth/register for external users; that's out of scope
  here — Phase 1 lands parental co-consent and youth/host registration,
  which needs its own PIA-driven flow. Keeping /auth minimal in Phase 0.
- MFA is captured in `User.mfa_verified` and JWT claim but not enforced
  yet; enforcement lands as an assurance-level gate in a later phase.
- Refresh flow returns only a new access token; refresh-token rotation
  is a later hardening step.

Advances YPMS-POAM-040 and unblocks milestone 0.4 (RBAC seed) and 0.5
(Registrant vertical slice).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris TonjesandClaude Opus 4.7 committed 2026-09-30 18:30:39 -04:00
1 parent 7379bb4cf1
commit 0501850a9c
10 files changed
+688 -32

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
"""Password + JWT primitives."""
from __future__ import annotations
import time
import pytest
from ypms.auth.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
def test_hash_password_returns_pbkdf2_prefix() -> None:
h = hash_password("hunter2hunter2")
assert h.startswith("$pbkdf2-sha256$")
def test_verify_password_true_on_match_false_on_mismatch() -> None:
h = hash_password("correcthorsebattery")
assert verify_password("correcthorsebattery", h) is True
assert verify_password("wrong", h) is False
def test_access_token_carries_ypms_claims() -> None:
tok = create_access_token(
sub="chris",
uid=1,
roles=["DOES_SYSTEM_ADMIN"],
assurance_level="admin",
mfa_verified=False,
)
claims = decode_token(tok)
assert claims["sub"] == "chris"
assert claims["uid"] == 1
assert claims["roles"] == ["DOES_SYSTEM_ADMIN"]
assert claims["iam.assurance_level"] == "admin"
assert claims["mfa_verified"] is False
assert claims["type"] == "access"
assert claims["exp"] > claims["iat"] >= int(time.time()) - 5
def test_refresh_token_has_type_refresh_and_carries_uid() -> None:
tok = create_refresh_token(sub="chris", uid=42)
claims = decode_token(tok)
assert claims["type"] == "refresh"
assert claims["sub"] == "chris"
assert claims["uid"] == 42
assert "roles" not in claims
assert "iam.assurance_level" not in claims
def test_decode_invalid_token_raises() -> None:
with pytest.raises(ValueError):
decode_token("not-a-real-jwt")