---
phase: 07.3-security-es256-algorithm-upgrade-inserted
plan: 03
type: execute
wave: 2
depends_on:
  - 07.3-02
files_modified:
  - backend/services/auth.py
  - backend/api/auth.py
  - backend/tests/test_auth_es256.py
  - frontend/src/api/client.js
  - frontend/src/stores/auth.js
  - frontend/src/views/auth/LoginView.vue
autonomous: false
requirements:
  - RM-01
  - RM-02
  - RM-03

must_haves:
  truths:
    - "Login without remember_me issues a refresh token with TTL = 16 hours (DB expires_at within ~16h)"
    - "Login with remember_me=True issues a refresh token with TTL = 30 days (DB expires_at within ~30d)"
    - "Login with remember_me=True sets cookie Max-Age = 30 * 86400 = 2592000"
    - "Login without remember_me sets cookie Max-Age = 16 * 3600 = 57600"
    - "LoginView.vue shows a 'Stay signed in for 30 days' checkbox below the password field; unchecked by default"
    - "Checkbox state flows: LoginView ref → authStore.login(options.rememberMe) → api.login body.remember_me → backend LoginRequest.remember_me → create_refresh_token(remember_me=...)"
  artifacts:
    - path: "backend/services/auth.py"
      provides: "create_refresh_token gains remember_me param selecting between hours and days TTL"
      contains: "remember_me"
    - path: "backend/api/auth.py"
      provides: "LoginRequest.remember_me, _set_refresh_cookie remember_me param, login handler threading"
      contains: "remember_me"
    - path: "frontend/src/views/auth/LoginView.vue"
      provides: "Stay-signed-in checkbox and pass-through to authStore.login"
      contains: "Stay signed in for 30 days"
    - path: "frontend/src/stores/auth.js"
      provides: "login() forwards options.rememberMe to api.login body.remember_me"
      contains: "remember_me"
    - path: "frontend/src/api/client.js"
      provides: "api.login accepts and forwards remember_me field"
      contains: "remember_me"
  key_links:
    - from: "frontend/src/views/auth/LoginView.vue"
      to: "frontend/src/stores/auth.js"
      via: "authStore.login(email, password, { rememberMe: rememberMe.value })"
      pattern: "rememberMe: rememberMe.value"
    - from: "frontend/src/stores/auth.js"
      to: "backend/api/auth.py LoginRequest"
      via: "api.login body.remember_me = options.rememberMe ?? false"
      pattern: "remember_me: options.rememberMe"
    - from: "backend/api/auth.py login handler"
      to: "backend/services/auth.py create_refresh_token"
      via: "create_refresh_token(session, user.id, remember_me=body.remember_me) + _set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me)"
      pattern: "remember_me=body.remember_me"
---

<objective>
Ship the "Stay signed in for 30 days" opt-in. After this plan:

1. 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)
2. backend/api/auth.py LoginRequest gains remember_me: bool = False. (D-11)
3. backend/api/auth.py _set_refresh_cookie gains remember_me: bool = False and computes max_age accordingly. (D-11)
4. The login handler passes body.remember_me through to both create_refresh_token and _set_refresh_cookie. (D-11)
5. 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)
6. frontend/src/stores/auth.js login() forwards options.rememberMe as remember_me into the api.login body. (D-12)
7. 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.)
8. 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.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<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

<interfaces>
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
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Backend remember_me — create_refresh_token TTL param + LoginRequest + cookie max_age + handler threading + promote 3 tests</name>
  <files>backend/services/auth.py, backend/api/auth.py, backend/tests/test_auth_es256.py</files>
  <read_first>
    backend/services/auth.py
    backend/api/auth.py
    backend/tests/test_auth_es256.py
    backend/tests/test_auth_api.py
    backend/tests/conftest.py
    .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
    .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
  </read_first>
  <behavior>
    - test_default_ttl_16_hours (promote): POST /api/auth/login with no remember_me field (or remember_me=False) → look up the new RefreshToken row → expires_at - now is in (15.5h, 16.5h) window (allowing test runtime jitter)
    - test_remember_me_ttl_30_days (promote): POST /api/auth/login with remember_me=True → look up the new RefreshToken row → expires_at - now is in (29.5d, 30.5d) window
    - test_remember_me_cookie_max_age (promote): POST /api/auth/login with remember_me=True → response Set-Cookie header for refresh_token contains Max-Age=2592000 (30 * 86400); default login contains Max-Age=57600 (16 * 3600)
  </behavior>
  <action>
    Step 1 — backend/services/auth.py: Update create_refresh_token signature from `async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str` to `async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False) -> str`. Per D-11.

    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.
  </action>
  <verify>
    <automated>cd backend && pytest tests/test_auth_es256.py::test_default_ttl_16_hours tests/test_auth_es256.py::test_remember_me_ttl_30_days tests/test_auth_es256.py::test_remember_me_cookie_max_age -x -v</automated>
  </verify>
  <acceptance_criteria>
    - grep -c 'remember_me: bool = False' backend/services/auth.py returns at least 1 (create_refresh_token signature)
    - grep -c 'timedelta(hours=settings.refresh_token_expire_hours)' backend/services/auth.py returns 1 (default TTL branch)
    - grep -c 'timedelta(days=settings.refresh_token_expire_days)' backend/services/auth.py returns 1 (remember_me TTL branch)
    - grep -c 'remember_me: bool = False' backend/api/auth.py returns at least 2 (LoginRequest field + _set_refresh_cookie param)
    - grep -c 'remember_me=body.remember_me' backend/api/auth.py returns 2 (login handler passes through to both create_refresh_token and _set_refresh_cookie)
    - grep -c 'refresh_token_expire_hours \* 3600' backend/api/auth.py returns 1 (default cookie max_age)
    - grep -c 'refresh_token_expire_days \* 86400' backend/api/auth.py returns 1 (remember_me cookie max_age)
    - rotate_refresh_token in backend/services/auth.py is UNCHANGED (no remember_me kwarg threading): grep -A20 'async def rotate_refresh_token' backend/services/auth.py | grep -c 'remember_me' returns 0
    - Refresh handler call site at backend/api/auth.py line ~349 is UNCHANGED: the _set_refresh_cookie call there does NOT include a remember_me kwarg (verify by reading surrounding lines)
    - pytest tests/test_auth_es256.py::test_default_ttl_16_hours -x exits 0 (PASSED)
    - pytest tests/test_auth_es256.py::test_remember_me_ttl_30_days -x exits 0 (PASSED)
    - pytest tests/test_auth_es256.py::test_remember_me_cookie_max_age -x exits 0 (PASSED)
    - The three promoted tests no longer carry @pytest.mark.xfail decorators
    - pytest tests/test_auth_es256.py -v reports 9 PASSED + 0 XFAILED + 0 XPASS + 0 FAILED
  </acceptance_criteria>
  <done>Backend honors remember_me end-to-end through create_refresh_token TTL and cookie Max-Age; all 9 phase tests pass; rotated sessions intentionally default to 16h.</done>
</task>

<task type="auto">
  <name>Task 2: Frontend remember_me — checkbox in LoginView, ref threading, store + api.login pass-through</name>
  <files>frontend/src/views/auth/LoginView.vue, frontend/src/stores/auth.js, frontend/src/api/client.js</files>
  <read_first>
    frontend/src/views/auth/LoginView.vue
    frontend/src/stores/auth.js
    frontend/src/api/client.js
    .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-PATTERNS.md
    .planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md
  </read_first>
  <action>
    Step 1 — frontend/src/api/client.js: Locate the existing export function login(body) (line ~154). Verify it forwards the body verbatim to request('/api/auth/login', { method: 'POST', body: JSON.stringify(body) }) and does NOT filter or remap fields. If the current implementation already passes the full body object through, no change is needed. If it picks specific fields, extend the picked set to include remember_me. Document the file in the SUMMARY even if zero-line-change.

    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.
  </action>
  <verify>
    <automated>grep -c 'remember_me: options.rememberMe' frontend/src/stores/auth.js; grep -c 'Stay signed in for 30 days' frontend/src/views/auth/LoginView.vue; grep -c "v-model=\"rememberMe\"" frontend/src/views/auth/LoginView.vue; grep -c 'rememberMe: rememberMe.value' frontend/src/views/auth/LoginView.vue</automated>
  </verify>
  <acceptance_criteria>
    - grep -c 'remember_me: options.rememberMe ?? false' frontend/src/stores/auth.js returns 1
    - grep -c 'const rememberMe = ref(false)' frontend/src/views/auth/LoginView.vue returns 1
    - grep -c 'Stay signed in for 30 days' frontend/src/views/auth/LoginView.vue returns 1
    - grep -c 'v-model="rememberMe"' frontend/src/views/auth/LoginView.vue returns 1
    - grep -c 'id="remember-me"' frontend/src/views/auth/LoginView.vue returns at least 1 (input + label for)
    - grep -c 'rememberMe: rememberMe.value' frontend/src/views/auth/LoginView.vue returns 3 (submitPassword, submitTotp, submitBackupCode)
    - frontend/src/api/client.js login(body) function still exists and forwards body fields without filtering (manual verification recorded in SUMMARY): grep -c 'export function login' frontend/src/api/client.js returns 1
    - cd frontend && npm run build exits 0 (Vite build succeeds)
    - cd frontend && npm test 2>/dev/null || true — pre-existing test suite still passes (no new test required for plain markup; behavior verified by backend integration tests in Task 1)
  </acceptance_criteria>
  <done>Login form shows the checkbox; ref threads through all three submit paths; store forwards remember_me to backend; build succeeds.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: Human checkpoint — verify "Stay signed in" UX and cookie Max-Age in browser</name>
  <what-built>
    - Backend ES256 JWT signing (Plan 02) + remember_me TTL split (Task 1) + frontend checkbox (Task 2)
    - "Stay signed in for 30 days" checkbox on LoginView under the password field, unchecked by default
    - Login without checkbox issues a refresh cookie with Max-Age ≈ 57600 (16h)
    - Login with checkbox issues a refresh cookie with Max-Age = 2592000 (30d)
  </what-built>
  <how-to-verify>
    1. Start the stack: docker compose up (ensure .env contains freshly generated JWT_PRIVATE_KEY and JWT_PUBLIC_KEY from the README snippet)
    2. Open http://localhost:5173/login in a private/incognito window
    3. CONFIRM: the password step shows the "Stay signed in for 30 days" checkbox below the password input; the checkbox is unchecked by default; the label text matches exactly
    4. Open DevTools → Network → preserve log
    5. Log in with valid credentials WITHOUT ticking the checkbox; complete TOTP / backup code if prompted
    6. Open DevTools → Application → Cookies → http://localhost:5173 → find the refresh_token cookie; CONFIRM Max-Age is in the range 57000-58000 (≈ 16h, allowing for round-trip timing)
    7. Log out (via the existing logout control); clear cookies
    8. Log in again, this time TICKING the checkbox; CONFIRM the refresh_token cookie Max-Age is 2592000 (30 days)
    9. While still logged in, in DevTools → Network tab, find the POST /api/auth/login response → CONFIRM Set-Cookie header contains "Max-Age=2592000" and the request body JSON contains "remember_me": true
    10. CONFIRM no console errors in the browser DevTools console during the flow
    11. CONFIRM that the existing TOTP / backup-code flow still works after checking the box (the ref persists across steps; the second-step submit also includes remember_me)
  </how-to-verify>
  <resume-signal>Type "approved" or describe issues observed (checkbox missing, wrong Max-Age, console errors, broken TOTP flow, etc.)</resume-signal>
</task>

</tasks>

<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>

<verification>
- pytest tests/test_auth_es256.py -v reports 9 PASSED + 0 XFAILED (all phase tests green)
- Full suite pytest -v: zero new failures vs Plan 02 baseline; 3 net new PASSED
- bandit -r backend/ — zero HIGH severity findings
- npm audit --audit-level=high — zero high/critical (existing baseline preserved)
- Human checkpoint approved: checkbox visible, default unchecked, two distinct Max-Age values observed in DevTools, TOTP flow still works
</verification>

<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>

<output>
Create `.planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-03-SUMMARY.md` documenting:
- Files modified with line ranges
- Promoted tests (RM-01, RM-02, RM-03) and final phase test totals (9/9 phase tests green; full suite delta)
- Human-checkpoint observations (default Max-Age, remember_me Max-Age, console clean, TOTP flow OK)
- Note on rotated-session TTL behavior (Pitfall 4 documented, deferred for future preservation work)
- Phase 7.3 ready for /gsd:verify-work
</output>
