Compare commits

...
6 Commits
Author SHA1 Message Date
curo1305andClaude Sonnet 4.6 169c2e7e29 docs(phase-07.3): mark complete — 3/3 plans, 9/9 tests, remember_me shipped
Plan 03 human checkpoint passed. 07.3-03-SUMMARY.md written.
ROADMAP: 07.3-03 checked, progress table updated to 3/3 Complete 2026-06-06.
STATE: current_phase → 07.4, completed_phases → 12, completed_plans → 63.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 19:19:17 +02:00
curo1305 739d5b3d9a chore: merge executor worktree (worktree-agent-a9c4907ff53a1a111)
# Conflicts:
#	.env.example
#	README.md
#	backend/config.py
#	backend/main.py
#	backend/tests/test_auth_es256.py
2026-06-06 18:07:31 +02:00
curo1305 21e5d27c90 feat(07.3-03): frontend remember_me — checkbox + ref threading + store pass-through
- LoginView.vue: add rememberMe ref(false) + "Stay signed in for 30 days" checkbox in password step; thread rememberMe.value through all 3 submit handlers (submitPassword, submitTotp, submitBackupCode) (D-12)
- stores/auth.js: login() forwards options.rememberMe as remember_me in api.login body (D-12)
- api/client.js: no change needed — already forwards full body verbatim
- Frontend build passes (vite build exits 0)
2026-06-06 17:23:02 +02:00
curo1305 9cc11b5446 feat(07.3-03): backend remember_me — TTL split + cookie Max-Age + 3 promoted tests
- services/auth.py: create_refresh_token gains remember_me=False param; selects 16h or 30d TTL (D-09, D-10, D-11)
- api/auth.py: LoginRequest.remember_me bool field; _set_refresh_cookie remember_me param for conditional max_age; login handler threads remember_me through both calls (D-11, RM-03)
- test_auth_es256.py: promote RM-01, RM-02, RM-03 stubs — all 9 phase tests now PASSED
2026-06-06 17:21:14 +02:00
curo1305 8be792ab4c feat(07.3-02): ES256 JWT algorithm upgrade + startup rotation hook
- config.py: add refresh_token_expire_hours=16, jwt_private_key, jwt_public_key fields (D-01, D-09)
- services/auth.py: swap all 4 JWT sites to ES256 via base64-decoded PEM keys; remove HS256 (D-02, D-03)
- main.py: add _rotate_tokens_on_algorithm_change lifespan hook — bulk-revokes refresh tokens on algorithm change; idempotent on repeat boots (D-04, D-05)
- test_auth_es256.py: promote ES256-01..05 + CFG-01 stubs to 6 passing tests; RM-01..03 remain xfail
- docker-compose.yml: inject JWT_PRIVATE_KEY + JWT_PUBLIC_KEY into backend + celery-worker (D-07)
- README.md: add JWT key env vars + key generation Python one-liner snippet
- .env.example: add JWT_PRIVATE_KEY= and JWT_PUBLIC_KEY= lines
- Version bump to 0.1.2
2026-06-06 17:19:50 +02:00
curo1305 99f55825aa test(07.3-01): Wave 0 Nyquist scaffold — 9 xfail stubs for ES256 + remember_me
- Create test_auth_es256.py with 9 xfail(strict=True) stubs: ES256-01..05, RM-01..03, CFG-01
- Add es256_keys(autouse) fixture using cryptography P-256 keygen + monkeypatch with raising=False
- Extend test_settings_has_jwt_config to assert refresh_token_expire_hours==16 + jwt key fields
2026-06-06 17:13:50 +02:00
8 changed files with 403 additions and 150 deletions
+3 -3
View File
@@ -493,9 +493,9 @@ Before any phase is marked complete, all three gates must pass:
**Wave 2** *(blocked on Wave 1)* — "Remember me" 16h/30d TTL split **Wave 2** *(blocked on Wave 1)* — "Remember me" 16h/30d TTL split
- [ ] 07.3-03-PLAN.md — create_refresh_token remember_me param + LoginRequest.remember_me + _set_refresh_cookie max_age conditional + LoginView.vue "Stay signed in for 30 days" checkbox + stores/auth.js + api/client.js forwarding + human checkpoint - [x] 07.3-03-PLAN.md — create_refresh_token remember_me param + LoginRequest.remember_me + _set_refresh_cookie max_age conditional + LoginView.vue "Stay signed in for 30 days" checkbox + stores/auth.js + api/client.js forwarding + human checkpoint
**Status:** Planned (2026-06-05) **Status:** Complete (2026-06-06)
--- ---
@@ -525,5 +525,5 @@ Before any phase is marked complete, all three gates must pass:
| 7. Redo and optimize LLM integration | 5/5 | Complete | 2026-06-05 | | 7. Redo and optimize LLM integration | 5/5 | Complete | 2026-06-05 |
| 7.1. Security: session revocation on privilege change (CR-01..03) | 0/2 | Planned | — | | 7.1. Security: session revocation on privilege change (CR-01..03) | 0/2 | Planned | — |
| 7.2. Security: JTI claim + Redis access-token revocation | 3/3 | Complete | 2026-06-05 | | 7.2. Security: JTI claim + Redis access-token revocation | 3/3 | Complete | 2026-06-05 |
| 7.3. Security: ES256 algorithm upgrade | 2/3 | In Progress| | | 7.3. Security: ES256 algorithm upgrade | 3/3 | Complete | 2026-06-06 |
| 7.4. Security: token fingerprinting / token binding | 0/? | Not planned (INSERTED) | — | | 7.4. Security: token fingerprinting / token binding | 0/? | Not planned (INSERTED) | — |
+17 -16
View File
@@ -1,24 +1,24 @@
--- ---
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v1.0 milestone: v1.0
milestone_name: Redo and Optimize LLM Integration milestone_name: "audit gaps: SHARE-02/STORE-06/ADMIN-06"
current_phase: 07.3 current_phase: 07.4
status: executing status: planning
last_updated: "2026-06-06T14:46:42.232Z" last_updated: "2026-06-06T17:45:00.000Z"
progress: progress:
total_phases: 7 total_phases: 13
completed_phases: 5 completed_phases: 12
total_plans: 20 total_plans: 63
completed_plans: 17 completed_plans: 63
percent: 71 percent: 92
--- ---
# Project State # Project State
**Project:** DocuVault **Project:** DocuVault
**Status:** Executing Phase 07.3 **Status:** Phase 07.3 Complete — Ready for Phase 07.4
**Current Phase:** 07.3 **Current Phase:** 07.4
**Last Updated:** 2026-06-05 **Last Updated:** 2026-06-06
## Phase Status ## Phase Status
@@ -35,12 +35,12 @@ progress:
| 7 | Redo and Optimize LLM Integration | ✓ Complete (5/5 plans, UAT 11/11 passed, security gate passed) | | 7 | Redo and Optimize LLM Integration | ✓ Complete (5/5 plans, UAT 11/11 passed, security gate passed) |
| 7.1 | Security: session revocation on privilege change (CR-01..03) | ✓ Complete (2/2 plans, 3 new tests, frontend toasts) | | 7.1 | Security: session revocation on privilege change (CR-01..03) | ✓ Complete (2/2 plans, 3 new tests, frontend toasts) |
| 7.2 | Security: JTI claim + Redis access-token revocation | ✓ Complete (3/3 plans, 9/9 verified, 84/84 tests) | | 7.2 | Security: JTI claim + Redis access-token revocation | ✓ Complete (3/3 plans, 9/9 verified, 84/84 tests) |
| 7.3 | Security: ES256 algorithm upgrade | ◆ Planned (3 plans, 3 waves) — ready to execute | | 7.3 | Security: ES256 algorithm upgrade | ✓ Complete (3/3 plans, all 9 tests passing, remember_me shipped) |
| 7.4 | Security: Token fingerprinting / token binding | ◆ Not planned yet |
## Current Position ## Current Position
Phase: 07.3 (security-es256-algorithm-upgrade-inserted) — EXECUTING Phase: 07.3 (security-es256-algorithm-upgrade-inserted) — COMPLETE (3/3 plans, 9/9 tests, 2026-06-06)
Plan: 1 of 3
Phase: 07.2 (security-jti-claim-redis-access-token-revocation-inserted) — COMPLETE (3/3 plans, 9/9 verified) Phase: 07.2 (security-jti-claim-redis-access-token-revocation-inserted) — COMPLETE (3/3 plans, 9/9 verified)
**Progress:** [██████████] 100% (v1.0 base complete; Phase 7.1 inserted as urgent follow-up) **Progress:** [██████████] 100% (v1.0 base complete; Phase 7.1 inserted as urgent follow-up)
@@ -209,6 +209,7 @@ _Updated at each phase transition._
| Last session | 2026-06-05 — Phase 7 complete: all 5 plans executed; UAT 11/11 passed; security gate passed (bandit zero HIGH, npm audit zero high/critical); _doc_to_dict status field fix + regression test committed; v1.0 milestone DONE | | Last session | 2026-06-05 — Phase 7 complete: all 5 plans executed; UAT 11/11 passed; security gate passed (bandit zero HIGH, npm audit zero high/critical); _doc_to_dict status field fix + regression test committed; v1.0 milestone DONE |
| Last session | 2026-06-05 — Phase 7.1 complete: CR-01/CR-02/CR-03 implemented; skip_token_hash added to revoke_all_refresh_tokens; change_password, enable_totp, disable_totp now revoke other sessions and return sessions_revoked; 3 new tests passing; frontend toasts in SettingsAccountTab + TotpEnrollment; 373 passed 0 failed; v0.1.1 | | Last session | 2026-06-05 — Phase 7.1 complete: CR-01/CR-02/CR-03 implemented; skip_token_hash added to revoke_all_refresh_tokens; change_password, enable_totp, disable_totp now revoke other sessions and return sessions_revoked; 3 new tests passing; frontend toasts in SettingsAccountTab + TotpEnrollment; 373 passed 0 failed; v0.1.1 |
| Last session | 2026-06-05 — Phase 7.2 planned: 3 plans (Wave 0 test scaffolding, Wave 1 jti+NBF check, Wave 2 user_nbf writes in 4 handlers); verification passed (0 blockers, 1 warning fixed); ready to execute | | Last session | 2026-06-05 — Phase 7.2 planned: 3 plans (Wave 0 test scaffolding, Wave 1 jti+NBF check, Wave 2 user_nbf writes in 4 handlers); verification passed (0 blockers, 1 warning fixed); ready to execute |
| Next action | Execute Phase 7.2: /gsd:execute-phase 7.2 | | Last session | 2026-06-06 — Phase 7.3 complete: ES256 asymmetric JWT signing live; default session 16h; remember_me opt-in 30d; startup bulk-revocation hook; all 9 ES256/remember-me tests green; v0.1.2 |
| Next action | Plan Phase 7.4: /gsd:plan-phase 7.4 (token fingerprinting) |
| Pending decisions | None | | Pending decisions | None |
| Resume file | None | | Resume file | None |
@@ -0,0 +1,129 @@
---
phase: 07.3-security-es256-algorithm-upgrade-inserted
plan: "03"
subsystem: backend/auth + frontend/auth
tags:
- security
- jwt
- remember-me
- session-lifetime
- frontend
dependency_graph:
requires:
- 07.3-02
provides:
- remember_me param in create_refresh_token (16h default / 30d opt-in)
- Conditional cookie Max-Age in _set_refresh_cookie
- "Stay signed in for 30 days" checkbox in LoginView.vue
- All 9 Phase 7.3 tests PASSED (RM-01, RM-02, RM-03 promoted)
affects:
- backend/services/auth.py
- backend/api/auth.py
- backend/tests/test_auth_es256.py
- frontend/src/views/auth/LoginView.vue
- frontend/src/stores/auth.js
tech_stack:
added: []
patterns:
- "remember_me=False default param threading: LoginView → store → api → LoginRequest → services"
- "Conditional TTL: timedelta(hours=16) default, timedelta(days=30) when remember_me=True"
- "Conditional Max-Age: 57600 default, 2592000 when remember_me=True"
key_files:
created: []
modified:
- path: backend/services/auth.py
note: "create_refresh_token gains remember_me: bool = False; selects 16h or 30d TTL"
- path: backend/api/auth.py
note: "LoginRequest.remember_me bool field; _set_refresh_cookie remember_me param; login handler threads remember_me"
- path: backend/tests/test_auth_es256.py
note: "RM-01, RM-02, RM-03 promoted from xfail to passing — all 9 tests now PASSED"
- path: frontend/src/views/auth/LoginView.vue
note: "rememberMe ref(false) + checkbox 'Stay signed in for 30 days'; threaded through all 3 submit handlers"
- path: frontend/src/stores/auth.js
note: "login() forwards options.rememberMe as remember_me in api.login body"
decisions:
- "rotate_refresh_token intentionally NOT updated — rotated sessions revert to 16h default (per Pitfall 4 in RESEARCH.md; simpler path chosen)"
- "api/client.js unchanged — already forwards full body verbatim, no field stripping"
- "Checkbox unchecked by default — explicit opt-in for extended session, not opt-out"
metrics:
duration: "~20 minutes"
completed: "2026-06-06"
tasks_completed: 2
files_modified: 5
human_checkpoint: PASSED
---
# Phase 07.3 Plan 03: Remember-Me Feature — TTL Split + Cookie Max-Age + Frontend Checkbox Summary
Shipped "Stay signed in for 30 days" opt-in: backend TTL split (16h default / 30d opt-in), conditional cookie Max-Age, and LoginView checkbox with full threading from UI through store, API client, LoginRequest, and service layer. All 9 Phase 7.3 tests now PASSED.
---
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Backend remember_me — TTL split + cookie Max-Age + 3 promoted tests | 9cc11b5 | backend/services/auth.py, backend/api/auth.py, backend/tests/test_auth_es256.py |
| 2 | Frontend remember_me — checkbox + ref threading + store pass-through | 21e5d27 | frontend/src/views/auth/LoginView.vue, frontend/src/stores/auth.js |
---
## Grep Gate Results (Acceptance Criteria Verified)
| Gate | Expected | Actual | Status |
|------|----------|--------|--------|
| `remember_me` in services/auth.py | >= 3 | 4 | PASS |
| `remember_me` in api/auth.py | >= 5 | 7 | PASS |
| `Max-Age` / `max_age` in api/auth.py | >= 2 | 4 | PASS |
| `Stay signed in for 30 days` in LoginView.vue | 1 | 1 | PASS |
| `remember_me` in stores/auth.js | >= 1 | 1 | PASS |
---
## Promoted Tests (xfail → PASSED)
| Test | Requirement | Before | After |
|------|-------------|--------|-------|
| test_default_ttl_16_hours | RM-01 | XFAIL (strict) | PASSED |
| test_remember_me_ttl_30_days | RM-02 | XFAIL (strict) | PASSED |
| test_remember_me_cookie_max_age | RM-03 | XFAIL (strict) | PASSED |
**Net suite delta:** `+3 PASSED`. All 9 Phase 7.3 tests now PASSED. Zero XFAILED remain.
---
## Must-Haves Verified
| Truth | Status |
|-------|--------|
| Login without remember_me → TTL = 16 hours | PASS (RM-01) |
| Login with remember_me=True → TTL = 30 days | PASS (RM-02) |
| Login with remember_me=True → cookie Max-Age = 2592000 | PASS (RM-03) |
| Login without remember_me → cookie Max-Age = 57600 | PASS (RM-03, default branch) |
| LoginView.vue shows "Stay signed in for 30 days" checkbox, unchecked by default | PASS (human checkpoint) |
| State flows: LoginView ref → store → api body → LoginRequest → create_refresh_token | PASS (grep + human) |
---
## Deviations from Plan
None. All tasks completed as specified. Intended non-updates (rotate_refresh_token, api/client.js) confirmed as correct.
---
## Human Checkpoint: PASSED
User confirmed:
- "Stay signed in for 30 days" checkbox visible on login page
- Cookie Max-Age verified in DevTools → Network → POST /api/auth/login → Response Headers → set-cookie
---
## Phase 7.3 Complete
All 3 plans shipped:
- **Plan 01 (Wave 0):** 9 xfail TDD stubs scaffolded
- **Plan 02 (Wave 1):** ES256 JWT signing at all 4 sites + startup rotation hook + operator wiring
- **Plan 03 (Wave 2):** remember_me TTL split + cookie Max-Age + frontend checkbox
Phase outcome: ES256 asymmetric JWT signing live; default session 16h; opt-in 30-day sessions; startup bulk-revocation on algorithm change; all 9 ES256/remember-me tests green.
+17 -5
View File
@@ -59,6 +59,7 @@ class LoginRequest(BaseModel):
password: str password: str
totp_code: Optional[str] = None totp_code: Optional[str] = None
backup_code: Optional[str] = None backup_code: Optional[str] = None
remember_me: bool = False
class ChangePasswordRequest(BaseModel): class ChangePasswordRequest(BaseModel):
@@ -68,8 +69,19 @@ class ChangePasswordRequest(BaseModel):
# ── Helper: set httpOnly refresh cookie ────────────────────────────────────── # ── Helper: set httpOnly refresh cookie ──────────────────────────────────────
def _set_refresh_cookie(response: Response, raw_token: str) -> None: def _set_refresh_cookie(
"""Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint).""" response: Response, raw_token: str, remember_me: bool = False
) -> None:
"""Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint).
remember_me=False (default): Max-Age = refresh_token_expire_hours * 3600 (16h, D-11, RM-03)
remember_me=True: Max-Age = refresh_token_expire_days * 86400 (30d, D-11, RM-03)
"""
max_age = (
settings.refresh_token_expire_days * 86400
if remember_me
else settings.refresh_token_expire_hours * 3600
)
response.set_cookie( response.set_cookie(
key="refresh_token", key="refresh_token",
value=raw_token, value=raw_token,
@@ -77,7 +89,7 @@ def _set_refresh_cookie(response: Response, raw_token: str) -> None:
secure=True, secure=True,
samesite="strict", samesite="strict",
path="/api/auth/refresh", path="/api/auth/refresh",
max_age=settings.refresh_token_expire_days * 86400, max_age=max_age,
) )
@@ -276,8 +288,8 @@ async def login(
# Issue tokens # Issue tokens
access_token = auth_service.create_access_token(str(user.id), user.role) access_token = auth_service.create_access_token(str(user.id), user.role)
raw_refresh = await auth_service.create_refresh_token(session, user.id) raw_refresh = await auth_service.create_refresh_token(session, user.id, remember_me=body.remember_me)
_set_refresh_cookie(response, raw_refresh) _set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me)
# D-13: login success event # D-13: login success event
await write_audit_log( await write_audit_log(
+12 -2
View File
@@ -157,20 +157,30 @@ def decode_password_reset_token(token: str) -> str:
# ── Refresh token lifecycle ───────────────────────────────────────────────────── # ── Refresh token lifecycle ─────────────────────────────────────────────────────
async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str: async def create_refresh_token(
session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False
) -> str:
"""Insert a new RefreshToken row and return the raw (unhashed) token string. """Insert a new RefreshToken row and return the raw (unhashed) token string.
The raw token is returned to the caller and set as an httpOnly cookie. The raw token is returned to the caller and set as an httpOnly cookie.
Only the SHA-256 hash is stored in the database. Only the SHA-256 hash is stored in the database.
remember_me=False (default): TTL = refresh_token_expire_hours (16h short session, D-09, D-10)
remember_me=True: TTL = refresh_token_expire_days (30d extended session, D-11)
""" """
raw = secrets.token_urlsafe(32) raw = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw.encode()).hexdigest() token_hash = hashlib.sha256(raw.encode()).hexdigest()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
ttl = (
timedelta(days=settings.refresh_token_expire_days)
if remember_me
else timedelta(hours=settings.refresh_token_expire_hours)
)
row = RefreshToken( row = RefreshToken(
id=uuid.uuid4(), id=uuid.uuid4(),
user_id=user_id, user_id=user_id,
token_hash=token_hash, token_hash=token_hash,
expires_at=now + timedelta(days=settings.refresh_token_expire_days), expires_at=now + ttl,
revoked=False, revoked=False,
) )
session.add(row) session.add(row)
+207 -121
View File
@@ -2,6 +2,8 @@
TDD scaffold for Phase 7.3: ES256 algorithm upgrade, startup token rotation, TDD scaffold for Phase 7.3: ES256 algorithm upgrade, startup token rotation,
and remember_me session TTL — all stubs xfail strict=True until promoted. and remember_me session TTL — all stubs xfail strict=True until promoted.
""" """
from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import json import json
@@ -11,56 +13,61 @@ from datetime import datetime, timedelta, timezone
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
# ── ES256 key fixture ────────────────────────────────────────────────────────
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def es256_keys(monkeypatch): def es256_keys(monkeypatch):
"""Patch settings with a freshly generated P-256 key pair for each test.""" """Generate a throw-away P-256 keypair and monkeypatch it into settings.
k = ec.generate_private_key(ec.SECP256R1())
priv = base64.b64encode( raising=False: settings fields do not exist until Plan 02 adds them;
k.private_bytes( this fixture must not error before that.
serialization.Encoding.PEM, """
serialization.PrivateFormat.PKCS8, from cryptography.hazmat.primitives.asymmetric import ec
serialization.NoEncryption(), from cryptography.hazmat.primitives import serialization
private_key = ec.generate_private_key(ec.SECP256R1())
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
) )
).decode() public_pem = private_key.public_key().public_bytes(
pub = base64.b64encode( encoding=serialization.Encoding.PEM,
k.public_key().public_bytes( format=serialization.PublicFormat.SubjectPublicKeyInfo,
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
) )
).decode() private_b64 = base64.b64encode(private_pem).decode()
monkeypatch.setattr("config.settings.jwt_private_key", priv, raising=False) public_b64 = base64.b64encode(public_pem).decode()
monkeypatch.setattr("config.settings.jwt_public_key", pub, raising=False)
import config
monkeypatch.setattr(config.settings, "jwt_private_key", private_b64, raising=False)
monkeypatch.setattr(config.settings, "jwt_public_key", public_b64, raising=False)
# ── ES256 algorithm tests ───────────────────────────────────────────────────── # ── ES256-01: access token algorithm ─────────────────────────────────────────
def test_access_token_uses_es256(): def test_access_token_uses_es256():
"""ES256-01: access token header must declare alg=ES256."""
from services.auth import create_access_token from services.auth import create_access_token
token = create_access_token("u1", "user") token = create_access_token("u1", "user")
# Decode the header (first segment of the JWT) # Decode header (first segment of JWT)
header_b64 = token.split(".")[0] segment = token.split(".")[0]
# Add padding to make it valid base64 # Add padding
padding = "=" * (4 - len(header_b64) % 4) segment += "=" * (4 - len(segment) % 4)
header = json.loads(base64.urlsafe_b64decode(header_b64 + padding)) header = json.loads(base64.urlsafe_b64decode(segment))
assert header["alg"] == "ES256" assert header["alg"] == "ES256"
# ── ES256-02: HS256 token rejected ───────────────────────────────────────────
def test_hs256_token_rejected(): def test_hs256_token_rejected():
"""ES256-02: an HS256 token must raise ValueError when decoded."""
import jwt as _jwt import jwt as _jwt
from services.auth import decode_access_token from services.auth import decode_access_token
hs256_token = _jwt.encode( hs256_token = _jwt.encode(
{ {"sub": "u1", "typ": "access", "exp": int(time.time()) + 60, "iat": int(time.time())},
"sub": "u1",
"typ": "access",
"exp": int(time.time()) + 60,
"iat": int(time.time()),
},
"any-hs256-secret", "any-hs256-secret",
algorithm="HS256", algorithm="HS256",
) )
@@ -68,113 +75,111 @@ def test_hs256_token_rejected():
decode_access_token(hs256_token) decode_access_token(hs256_token)
# ── ES256-03: password-reset token algorithm ──────────────────────────────────
def test_reset_token_uses_es256(): def test_reset_token_uses_es256():
"""ES256-03: password-reset token header must declare alg=ES256 and round-trips."""
from services.auth import create_password_reset_token, decode_password_reset_token from services.auth import create_password_reset_token, decode_password_reset_token
token = create_password_reset_token("u1") token = create_password_reset_token("u1")
header_b64 = token.split(".")[0] segment = token.split(".")[0]
padding = "=" * (4 - len(header_b64) % 4) segment += "=" * (4 - len(segment) % 4)
header = json.loads(base64.urlsafe_b64decode(header_b64 + padding)) header = json.loads(base64.urlsafe_b64decode(segment))
assert header["alg"] == "ES256" assert header["alg"] == "ES256"
assert decode_password_reset_token(token) == "u1" assert decode_password_reset_token(token) == "u1"
# ── Startup token rotation tests ────────────────────────────────────────────── # ── ES256-04: startup rotation revokes tokens ─────────────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_startup_rotation_revokes_tokens(db_session): async def test_startup_rotation_revokes_tokens(db_session):
"""ES256-04: on first run, all active refresh tokens are bulk-revoked and jwt_algorithm row is upserted.""" from datetime import datetime, timezone, timedelta
import secrets as _secrets import hashlib
from main import _rotate_tokens_on_algorithm_change import secrets
from db.models import RefreshToken, SystemSettings, User import uuid as _uuid
from sqlalchemy import select from sqlalchemy import select
from db.models import RefreshToken, SystemSettings, User, Quota
from main import _rotate_tokens_on_algorithm_change
from services.auth import hash_password
# Create a minimal user row (required by RefreshToken FK) # Set up: create a user + two RefreshToken rows with revoked=False
user_id = uuid.uuid4() user_id = _uuid.uuid4()
user = User( user = User(
id=user_id, id=user_id,
handle=f"testrotate_{user_id.hex[:8]}", handle=f"rottest_{user_id.hex[:8]}",
email=f"testrotate_{user_id.hex[:8]}@example.com", email=f"rottest_{user_id.hex[:8]}@example.com",
password_hash="fakehash", password_hash=hash_password("Testpassword123!"),
role="user", role="user",
is_active=True, is_active=True,
password_must_change=False, password_must_change=False,
) )
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(user) db_session.add(user)
await db_session.flush() db_session.add(quota)
# Insert two active refresh tokens
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
tok1_id = uuid.uuid4() rt1_id = _uuid.uuid4()
tok2_id = uuid.uuid4() rt2_id = _uuid.uuid4()
tok1 = RefreshToken( rt1 = RefreshToken(
id=tok1_id, id=rt1_id,
user_id=user_id, user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(), token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
expires_at=now + timedelta(days=1), expires_at=now + timedelta(days=1),
revoked=False, revoked=False,
) )
tok2 = RefreshToken( rt2 = RefreshToken(
id=tok2_id, id=rt2_id,
user_id=user_id, user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(), token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
expires_at=now + timedelta(days=1), expires_at=now + timedelta(days=1),
revoked=False, revoked=False,
) )
db_session.add(tok1) db_session.add(rt1)
db_session.add(tok2) db_session.add(rt2)
await db_session.flush() await db_session.flush()
# No jwt_algorithm row in system_settings
# Act: no jwt_algorithm row exists yet # Act
await _rotate_tokens_on_algorithm_change(db_session) await _rotate_tokens_on_algorithm_change(db_session)
# expire_all() required: raw SQL UPDATE bypasses ORM identity map; expire_on_commit=False # After the helper's commit, expire the identity map so we read fresh DB state
# (set in conftest.py) means SQLAlchemy won't auto-reload stale objects after commit.
db_session.expire_all() db_session.expire_all()
# Assert: both tokens are now revoked # Assert: both tokens revoked
result = await db_session.execute( result = await db_session.execute(
select(RefreshToken).where(RefreshToken.id.in_([tok1_id, tok2_id])) select(RefreshToken).where(RefreshToken.id.in_([rt1_id, rt2_id]))
) )
rows = result.scalars().all() rows = result.scalars().all()
assert len(rows) == 2 assert len(rows) == 2
assert all(r.revoked is True for r in rows) assert all(r.revoked for r in rows)
# Assert: jwt_algorithm row was created correctly # Assert: jwt_algorithm marker row created
ss_result = await db_session.execute( result2 = await db_session.execute(
select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm") select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
) )
ss_row = ss_result.scalar_one_or_none() marker = result2.scalar_one_or_none()
assert ss_row is not None assert marker is not None
assert ss_row.model_name == "ES256" assert marker.model_name == "ES256"
assert ss_row.is_active is False assert not marker.is_active
assert ss_row.context_chars == 0 assert marker.context_chars == 0
# ── ES256-05: startup rotation is idempotent ─────────────────────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_startup_rotation_idempotent(db_session): async def test_startup_rotation_idempotent(db_session):
"""ES256-05: when jwt_algorithm row already has model_name=ES256, no tokens are revoked.""" import uuid as _uuid
import secrets as _secrets
from main import _rotate_tokens_on_algorithm_change
from db.models import RefreshToken, SystemSettings, User
from sqlalchemy import select, func from sqlalchemy import select, func
from db.models import RefreshToken, SystemSettings, User, Quota
from main import _rotate_tokens_on_algorithm_change
from services.auth import hash_password
from datetime import datetime, timezone, timedelta
import hashlib
import secrets
# Create a user # Set up: pre-existing jwt_algorithm marker row with model_name='ES256'
user_id = uuid.uuid4() marker_id = _uuid.uuid4()
user = User( marker = SystemSettings(
id=user_id, id=marker_id,
handle=f"testidempotent_{user_id.hex[:8]}",
email=f"testidempotent_{user_id.hex[:8]}@example.com",
password_hash="fakehash",
role="user",
is_active=True,
password_must_change=False,
)
db_session.add(user)
await db_session.flush()
# Seed the jwt_algorithm row as already migrated
existing_marker = SystemSettings(
id=uuid.uuid4(),
provider_id="jwt_algorithm", provider_id="jwt_algorithm",
model_name="ES256", model_name="ES256",
context_chars=0, context_chars=0,
@@ -182,66 +187,147 @@ async def test_startup_rotation_idempotent(db_session):
api_key_enc=None, api_key_enc=None,
base_url=None, base_url=None,
) )
db_session.add(existing_marker) db_session.add(marker)
# Insert one fresh active token # Create a user + one fresh RefreshToken with revoked=False
user_id = _uuid.uuid4()
user = User(
id=user_id,
handle=f"idem_{user_id.hex[:8]}",
email=f"idem_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(user)
db_session.add(quota)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
tok_id = uuid.uuid4() rt_id = _uuid.uuid4()
tok = RefreshToken( rt = RefreshToken(
id=tok_id, id=rt_id,
user_id=user_id, user_id=user_id,
token_hash=hashlib.sha256(_secrets.token_urlsafe(32).encode()).hexdigest(), token_hash=hashlib.sha256(secrets.token_urlsafe(16).encode()).hexdigest(),
expires_at=now + timedelta(days=1), expires_at=now + timedelta(days=1),
revoked=False, revoked=False,
) )
db_session.add(tok) db_session.add(rt)
await db_session.flush() await db_session.flush()
# Act # Act: run rotation — should be no-op because model_name is already 'ES256'
await _rotate_tokens_on_algorithm_change(db_session) await _rotate_tokens_on_algorithm_change(db_session)
# expire_all() to clear identity map cache (same reason as test_startup_rotation_revokes_tokens)
db_session.expire_all()
# Assert: the fresh token was NOT revoked (bulk update did not fire) # Assert: RefreshToken still not revoked
result = await db_session.execute( result = await db_session.execute(
select(RefreshToken).where(RefreshToken.id == tok_id) select(RefreshToken).where(RefreshToken.id == rt_id)
) )
fresh_tok = result.scalar_one_or_none() token_row = result.scalar_one_or_none()
assert fresh_tok is not None assert token_row is not None
assert fresh_tok.revoked is False assert not token_row.revoked
# Assert: still exactly one jwt_algorithm row (no duplicate upsert) # Assert: exactly one jwt_algorithm row (no duplicates)
count_result = await db_session.execute( result2 = await db_session.execute(
select(func.count()).where(SystemSettings.provider_id == "jwt_algorithm") select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
) )
count = count_result.scalar_one() markers = result2.scalars().all()
assert count == 1 assert len(markers) == 1
# ── Remember-me TTL stubs ───────────────────────────────────────────────────── # ── RM helpers ───────────────────────────────────────────────────────────────
async def _do_login(async_client, auth_user, remember_me: bool = False) -> dict:
"""POST /api/auth/login for the auth_user and return the response."""
from tests.test_auth_api import FakeRedis
from main import app
app.state.redis = FakeRedis()
resp = await async_client.post(
"/api/auth/login",
json={
"email": auth_user["user"].email,
"password": "Testpassword123!",
"remember_me": remember_me,
},
)
return resp
# ── RM-01: default TTL is 16 hours ───────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="RM-01: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_default_ttl_16_hours(async_client, db_session, auth_user): async def test_default_ttl_16_hours(async_client, db_session, auth_user):
pytest.xfail("not yet implemented") from datetime import datetime, timezone, timedelta
from sqlalchemy import select
from db.models import RefreshToken
resp = await _do_login(async_client, auth_user, remember_me=False)
assert resp.status_code == 200
uid = auth_user["user"].id
result = await db_session.execute(
select(RefreshToken)
.where(RefreshToken.user_id == uid)
.order_by(RefreshToken.id.desc())
)
row = result.scalars().first()
assert row is not None
now = datetime.now(timezone.utc)
delta = row.expires_at.replace(tzinfo=timezone.utc) - now
assert timedelta(hours=15, minutes=30) < delta < timedelta(hours=16, minutes=30)
# ── RM-02: remember_me TTL is 30 days ────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="RM-02: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remember_me_ttl_30_days(async_client, db_session, auth_user): async def test_remember_me_ttl_30_days(async_client, db_session, auth_user):
pytest.xfail("not yet implemented") from datetime import datetime, timezone, timedelta
from sqlalchemy import select
from db.models import RefreshToken
resp = await _do_login(async_client, auth_user, remember_me=True)
assert resp.status_code == 200
uid = auth_user["user"].id
result = await db_session.execute(
select(RefreshToken)
.where(RefreshToken.user_id == uid)
.order_by(RefreshToken.id.desc())
)
row = result.scalars().first()
assert row is not None
now = datetime.now(timezone.utc)
delta = row.expires_at.replace(tzinfo=timezone.utc) - now
assert timedelta(days=29, hours=23) < delta < timedelta(days=30, hours=1)
# ── RM-03: cookie Max-Age values ─────────────────────────────────────────────
@pytest.mark.xfail(strict=True, reason="RM-03: not yet implemented")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_remember_me_cookie_max_age(async_client, auth_user): async def test_remember_me_cookie_max_age(async_client, auth_user):
pytest.xfail("not yet implemented") # Default (no remember_me): Max-Age = 16 * 3600 = 57600
resp_short = await _do_login(async_client, auth_user, remember_me=False)
assert resp_short.status_code == 200
# Parse raw Set-Cookie header for Max-Age
set_cookie_short = resp_short.headers.get("set-cookie", "")
assert "Max-Age=57600" in set_cookie_short
# With remember_me=True: Max-Age = 30 * 86400 = 2592000
resp_long = await _do_login(async_client, auth_user, remember_me=True)
assert resp_long.status_code == 200
set_cookie_long = resp_long.headers.get("set-cookie", "")
assert "Max-Age=2592000" in set_cookie_long
# ── Config field tests ──────────────────────────────────────────────────────── # ── CFG-01 satellite: settings has jwt key fields ────────────────────────────
def test_settings_has_jwt_keys(): def test_settings_has_jwt_keys():
"""CFG-01: Settings must expose jwt_private_key, jwt_public_key, and refresh_token_expire_hours=16."""
from config import settings from config import settings
assert hasattr(settings, "jwt_private_key") assert hasattr(settings, "jwt_private_key")
assert hasattr(settings, "jwt_public_key") assert hasattr(settings, "jwt_public_key")
+1
View File
@@ -66,6 +66,7 @@ export const useAuthStore = defineStore('auth', () => {
password, password,
totp_code: options.totpCode ?? null, totp_code: options.totpCode ?? null,
backup_code: options.backupCode ?? null, backup_code: options.backupCode ?? null,
remember_me: options.rememberMe ?? false,
}) })
if (data.requires_totp) { if (data.requires_totp) {
+15 -1
View File
@@ -27,6 +27,17 @@
/> />
</div> </div>
<!-- Stay signed in checkbox (D-12) -->
<div class="flex items-center gap-2">
<input
v-model="rememberMe"
id="remember-me"
type="checkbox"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<label for="remember-me" class="text-sm text-gray-600">Stay signed in for 30 days</label>
</div>
<!-- Form-level error --> <!-- Form-level error -->
<div <div
v-if="error" v-if="error"
@@ -191,6 +202,7 @@ const totpInput = ref('')
const backupCodeInput = ref('') const backupCodeInput = ref('')
const loading = ref(false) const loading = ref(false)
const error = ref(null) const error = ref(null)
const rememberMe = ref(false)
// Step: 'password' | 'totp' | 'backup' // Step: 'password' | 'totp' | 'backup'
const step = ref('password') const step = ref('password')
@@ -225,7 +237,7 @@ async function submitPassword() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const result = await authStore.login(email.value, password.value) const result = await authStore.login(email.value, password.value, { rememberMe: rememberMe.value })
await handleLoginResult(result) await handleLoginResult(result)
} catch (e) { } catch (e) {
error.value = e.message error.value = e.message
@@ -240,6 +252,7 @@ async function submitTotp() {
try { try {
const result = await authStore.login(email.value, password.value, { const result = await authStore.login(email.value, password.value, {
totpCode: totpInput.value, totpCode: totpInput.value,
rememberMe: rememberMe.value,
}) })
await handleLoginResult(result) await handleLoginResult(result)
} catch (e) { } catch (e) {
@@ -255,6 +268,7 @@ async function submitBackupCode() {
try { try {
const result = await authStore.login(email.value, password.value, { const result = await authStore.login(email.value, password.value, {
backupCode: backupCodeInput.value, backupCode: backupCodeInput.value,
rememberMe: rememberMe.value,
}) })
await handleLoginResult(result) await handleLoginResult(result)
} catch (e) { } catch (e) {