3 plans (3 waves): Wave 0 xfail stubs, Wave 1 ES256 core + startup rotation, Wave 2 remember-me TTL split + LoginView checkbox. Verification passed (0 blockers, 1 minor doc warning fixed). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
27 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07.3-security-es256-algorithm-upgrade-inserted | 03 | execute | 2 |
|
|
false |
|
|
- backend/services/auth.py:create_refresh_token gains remember_me: bool = False; selects timedelta(hours=settings.refresh_token_expire_hours) by default and timedelta(days=settings.refresh_token_expire_days) when True. (D-09, D-10, D-11)
- backend/api/auth.py LoginRequest gains remember_me: bool = False. (D-11)
- backend/api/auth.py _set_refresh_cookie gains remember_me: bool = False and computes max_age accordingly. (D-11)
- The login handler passes body.remember_me through to both create_refresh_token and _set_refresh_cookie. (D-11)
- frontend/src/views/auth/LoginView.vue gains a "Stay signed in for 30 days" checkbox; its state flows through submitPassword/submitTotp/submitBackupCode into authStore.login(options.rememberMe). (D-12)
- frontend/src/stores/auth.js login() forwards options.rememberMe as remember_me into the api.login body. (D-12)
- frontend/src/api/client.js api.login accepts a body shape with remember_me. (D-12 — implicit; api.login is a thin wrapper today and simply forwards the body, so verify it does not drop the field.)
- Refresh-token rotation (rotate_refresh_token in services/auth.py) is intentionally NOT updated — rotated sessions revert to the 16-hour default (per Pitfall 4 and Open Question 1 in RESEARCH.md; CONTEXT.md is silent → simpler path chosen).
Tests promoted from Plan 01 to passing: RM-01, RM-02, RM-03.
This plan ends the phase. After this plan completes and the human checkpoint passes, Phase 7.3 is shippable.
Purpose: Bound default session lifetime to a workday; let users explicitly opt into a 30-day session when convenient. Output: Backend + frontend changes + 3 promoted tests + human verification of the checkbox and cookie Max-Age.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/STATE.md @.planning/ROADMAP.md @.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md @.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-RESEARCH.md @.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md @.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-VALIDATION.md @.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-02-PLAN.md @backend/services/auth.py @backend/api/auth.py @backend/tests/test_auth_es256.py @backend/tests/test_auth_api.py @frontend/src/views/auth/LoginView.vue @frontend/src/stores/auth.js @frontend/src/api/client.js From backend/services/auth.py (post Plan 02): - async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str — Plan 03 changes signature to add remember_me: bool = False as the final keyword arg - async def rotate_refresh_token(session: AsyncSession, raw_token: str) -> str — internally calls create_refresh_token(session, row.user_id) with no kwargs; LEFT UNTOUCHED in this plan (rotated tokens default to 16h per Pitfall 4)From backend/api/auth.py:
- class LoginRequest(BaseModel) (lines 56-60) — email, password, totp_code Optional, backup_code Optional; Plan 03 adds remember_me: bool = False
- def _set_refresh_cookie(response, raw_token) -> None (lines 70-80) — sets refresh_token cookie with httponly, secure, samesite=strict, path=/api/auth/refresh, max_age=settings.refresh_token_expire_days * 86400; Plan 03 changes to (response, raw_token, remember_me=False) and computes max_age conditionally
- Login handler call sites (lines 277-279):
- access_token = auth_service.create_access_token(str(user.id), user.role)
- raw_refresh = await auth_service.create_refresh_token(session, user.id)
- _set_refresh_cookie(response, raw_refresh)
- Refresh handler call site (line 349) — _set_refresh_cookie(response, new_raw); LEFT UNTOUCHED (rotation does not preserve remember_me)
From frontend/src/api/client.js:
- export function login(body) { return request('/api/auth/login', { method: 'POST', body: JSON.stringify(body) }) } — pass-through; verify it forwards the body without filtering remember_me out
From frontend/src/stores/auth.js (lines 60-88):
- async function login(email, password, options = {}) → currently passes email, password, totp_code: options.totpCode ?? null, backup_code: options.backupCode ?? null in the api.login body; Plan 03 adds remember_me: options.rememberMe ?? false to the same body
From frontend/src/views/auth/LoginView.vue:
- Existing form structure: step-based (password step → optional TOTP step → optional backup-code step)
- Existing refs (lines 188-193): email, password, totpInput, backupCodeInput, loading, error
- Existing handlers:
- submitPassword (lines 224-235): calls authStore.login(email.value, password.value)
- submitTotp (lines 237-250): calls authStore.login(email.value, password.value, { totpCode: totpInput.value })
- submitBackupCode (lines 252-265): calls authStore.login(email.value, password.value, { backupCode: backupCodeInput.value })
From backend/tests/test_auth_api.py:
- FakeRedis class (lines ~47-97 area) — in-memory Redis stub for integration tests
- _register and _login helpers — used to register and log in real users via httpx.AsyncClient
Inside the function body, replace the line currently computing the TTL/expires_at:
- currently: expires_at=now + timedelta(days=settings.refresh_token_expire_days)
- new: insert a local `ttl = timedelta(days=settings.refresh_token_expire_days) if remember_me else timedelta(hours=settings.refresh_token_expire_hours)` BEFORE constructing the RefreshToken row; use `expires_at=now + ttl` in the row constructor. Per D-09 / D-10.
Do NOT touch rotate_refresh_token (Pitfall 4 acceptance — rotated tokens revert to 16h default). The internal call `await create_refresh_token(session, row.user_id)` continues to omit remember_me → defaults to False → 16h TTL. This is the documented acceptable behavior.
Step 2 — backend/api/auth.py: In the LoginRequest BaseModel definition (lines 56-60 area), add one new field after backup_code: `remember_me: bool = False`. Per D-11. Default False so existing clients that omit the field receive the short-session behavior.
Step 3 — backend/api/auth.py: Update _set_refresh_cookie signature (line 70 area) from `def _set_refresh_cookie(response: Response, raw_token: str) -> None` to `def _set_refresh_cookie(response: Response, raw_token: str, remember_me: bool = False) -> None`. Inside the function body, replace `max_age=settings.refresh_token_expire_days * 86400` with a conditional:
- max_age = settings.refresh_token_expire_days * 86400 if remember_me else settings.refresh_token_expire_hours * 3600
Per D-11 / RM-03. All other cookie attributes (key, value, httponly, secure, samesite, path) remain UNCHANGED.
Step 4 — backend/api/auth.py: In the login handler (line 277-279 area), thread remember_me through both downstream calls:
- raw_refresh = await auth_service.create_refresh_token(session, user.id, remember_me=body.remember_me)
- _set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me)
Per D-11.
Step 5 — backend/api/auth.py: Do NOT change the refresh handler call site at line 349. _set_refresh_cookie(response, new_raw) continues to call with default remember_me=False → rotated sessions get the 16h short cookie. This is the documented acceptable trade-off (Pitfall 4; Open Question 1 in RESEARCH.md).
Step 6 — backend/tests/test_auth_es256.py: Promote the three RM tests by REMOVING the @pytest.mark.xfail decorators and replacing the bodies with real assertions:
For test_default_ttl_16_hours(async_client, db_session, auth_user):
- Lazy imports: from db.models import RefreshToken, from sqlalchemy import select, from datetime import datetime, timezone, timedelta
- Act: POST /api/auth/login via async_client with body {"email": auth_user["email"], "password": auth_user["password"]} (no remember_me) — handle TOTP/backup-code chain if auth_user is TOTP-enrolled by checking the existing test_auth_api login helpers
- Assert response status == 200; fetch the most-recent RefreshToken for auth_user.id via select(RefreshToken).where(RefreshToken.user_id == uid).order_by(RefreshToken.id.desc()) → row.expires_at - datetime.now(tz=timezone.utc) is in (timedelta(hours=15, minutes=30), timedelta(hours=16, minutes=30))
For test_remember_me_ttl_30_days(async_client, db_session, auth_user):
- Same flow; body includes "remember_me": True
- Assert row.expires_at - now is in (timedelta(days=29, hours=23), timedelta(days=30, hours=1))
For test_remember_me_cookie_max_age(async_client, auth_user):
- First call: POST /api/auth/login WITHOUT remember_me → response.headers.get_list("set-cookie") (httpx exposes this via headers.get_list or response.cookies) contains a refresh_token cookie with Max-Age=57600 (16*3600)
- Second call: POST /api/auth/login with remember_me=True → Set-Cookie Max-Age=2592000 (30*86400)
- If httpx ASGITransport response cookies normalize Max-Age, parse the raw "set-cookie" header text via response.headers.raw or response.headers.get_list("set-cookie") and assert the substring "Max-Age=57600" / "Max-Age=2592000" appears in the matching cookie line
Use the existing FakeRedis pattern from test_auth_api.py for any rate-limit/Redis dependencies. If auth_user fixture creates a TOTP-enrolled user, follow the same multi-step login the existing test_auth_api login flow uses (post password → use TOTP code via pyotp.TOTP(secret).now()) — verify by reading the existing test_auth_api login fixtures in the read_first list.
Step 2 — frontend/src/stores/auth.js: In the login(email, password, options = {}) action (lines 60-88), extend the body passed to api.login to include one new field on the SAME object literal alongside totp_code and backup_code:
- remember_me: options.rememberMe ?? false
Place it after backup_code for symmetry with backend LoginRequest field order. Per D-12. Do NOT change the function signature — options is already optional and additive.
Step 3 — frontend/src/views/auth/LoginView.vue: Add a new reactive ref alongside the existing form refs (lines 188-193 area): const rememberMe = ref(false). Per D-12.
Step 4 — frontend/src/views/auth/LoginView.vue: Add a checkbox markup inside the password step form. Insert between the password input block and the error/submit elements. Match existing Tailwind class conventions found elsewhere in this file. Use these exact identifiers:
- input element: v-model="rememberMe", id="remember-me", type="checkbox", class follows existing input styling (Tailwind: "rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" or equivalent matching adjacent inputs)
- label element: for="remember-me", class="text-sm text-gray-600" or whatever the existing label scheme is, text content: "Stay signed in for 30 days" (per CONTEXT.md Specifics, clearer than "Remember me")
- Wrap input + label in a containing div with flex layout: class="flex items-center gap-2"
Step 5 — frontend/src/views/auth/LoginView.vue: Update the three submit handlers to pass rememberMe through the options object:
- submitPassword (line 228 area): change authStore.login(email.value, password.value) → authStore.login(email.value, password.value, { rememberMe: rememberMe.value })
- submitTotp (line 241 area): change authStore.login(email.value, password.value, { totpCode: totpInput.value }) → authStore.login(email.value, password.value, { totpCode: totpInput.value, rememberMe: rememberMe.value })
- submitBackupCode (line 256 area): change authStore.login(email.value, password.value, { backupCode: backupCodeInput.value }) → authStore.login(email.value, password.value, { backupCode: backupCodeInput.value, rememberMe: rememberMe.value })
The single ref persists across TOTP/backup-code steps because it lives at component scope; users who tick the checkbox before submitting password do not need to re-tick on the second step.
Do NOT add the checkbox to the TOTP or backup-code step forms — D-12 specifies the password step only. The state from the first step is carried forward by the ref.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| client form → /api/auth/login | remember_me is a typed Pydantic bool (defaults False) — non-boolean values rejected by FastAPI validation |
| backend → browser cookie store | refresh_token cookie remains httpOnly, Secure, SameSite=Strict regardless of remember_me — only TTL changes |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-07.3-03-01 | Elevation of Privilege | Long-lived remember_me session stolen via cookie theft | mitigate | Cookie remains httpOnly Secure SameSite=Strict; refresh-token family revocation (RFC 9700) on reuse still applies; 30-day TTL is bounded, not infinite |
| T-07.3-03-02 | Spoofing | Client tampers with remember_me to forge a 30-day session despite not opting in | accept | remember_me is operator-visible flag from the same user; no privilege escalation occurs (user always gets their own session); attacker only extending their own session length |
| T-07.3-03-03 | Information Disclosure | Cookie Max-Age leaks the user's "remember me" preference to network observers | accept | TLS protects the cookie in flight; Max-Age is observable only via local DevTools (same trust boundary as the cookie itself) |
| T-07.3-03-04 | Tampering | Frontend ref leaked across components, default value flips to true | mitigate | rememberMe ref is scoped to LoginView.vue setup block; default ref(false) enforced by code; checkbox unchecked-by-default confirmed in human checkpoint |
| T-07.3-03-05 | Denial of Service | Refresh handler downgrade — remember_me=True session silently shortened to 16h after rotation | accept | Documented in CONTEXT.md / RESEARCH.md Open Question 1 / Pitfall 4; behavior is conservative (shorter is more secure); future phase can preserve TTL by adding remember_me column to RefreshToken |
| T-07.3-03-SC | Tampering | npm/pip/cargo installs | accept | No new packages introduced; existing PyJWT and cryptography pins unchanged |
| </threat_model> |
<success_criteria>
- create_refresh_token in backend/services/auth.py accepts remember_me=False keyword and selects between hours and days TTL
- LoginRequest in backend/api/auth.py exposes remember_me: bool = False
- _set_refresh_cookie in backend/api/auth.py honors remember_me and sets max_age accordingly
- rotate_refresh_token and the refresh handler are intentionally NOT updated — rotated sessions revert to 16h default
- LoginView.vue shows the "Stay signed in for 30 days" checkbox; ref threads through all three submit handlers
- stores/auth.js and api/client.js forward remember_me to the backend
- All 9 tests in test_auth_es256.py pass (ES256-01..05, RM-01..03, CFG-01 satellite)
- Human checkpoint approved with documented Max-Age observations </success_criteria>