# Phase 07.3: Security — ES256 Algorithm Upgrade - Pattern Map **Mapped:** 2026-06-05 **Files analyzed:** 7 new/modified files **Analogs found:** 7 / 7 --- ## File Classification | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |-------------------|------|-----------|----------------|---------------| | `backend/services/auth.py` | service | request-response | self (4 signing sites + TTL param) | exact — modify in-place | | `backend/config.py` | config | — | self (add 3 fields) | exact — modify in-place | | `backend/main.py` | config/startup | event-driven | `backend/services/ai_config.py` (`seed_system_settings_from_env`) | exact — same startup-hook + upsert pattern | | `backend/api/auth.py` | controller | request-response | self (`LoginRequest` + `_set_refresh_cookie`) | exact — modify in-place | | `frontend/src/views/auth/LoginView.vue` | component | request-response | self (step-based login form) | exact — modify in-place | | `frontend/src/stores/auth.js` | store | request-response | self (`login()` action) | exact — modify in-place | | `backend/tests/test_auth_es256.py` | test | — | `backend/tests/test_task2_auth_service.py` + `backend/tests/test_auth_api.py` | role-match — same unit + integration pattern | --- ## Pattern Assignments ### `backend/services/auth.py` — ES256 signing sites + TTL param **Analog:** self — 4 sites to change in-place **Current imports block** (lines 18–38): ```python from __future__ import annotations import hashlib import hmac import logging import re import secrets import uuid from datetime import datetime, timezone, timedelta from typing import Optional import httpx import jwt import pyotp from pwdlib import PasswordHash from pwdlib.hashers.argon2 import Argon2Hasher from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from config import settings from db.models import BackupCode, Quota, RefreshToken, User ``` Add `import base64` to this block (no new packages). **Site 1 — `create_access_token`** (lines 86–99, current HS256): ```python def create_access_token(user_id: str, role: str) -> str: now = datetime.now(timezone.utc) payload = { "sub": str(user_id), "role": role, "typ": "access", "iat": now, "exp": now + timedelta(minutes=settings.access_token_expire_minutes), } return jwt.encode(payload, settings.secret_key, algorithm="HS256") ``` Change last line to: ```python private_pem = base64.b64decode(settings.jwt_private_key).decode() return jwt.encode(payload, private_pem, algorithm="ES256") ``` **Site 2 — `decode_access_token`** (lines 102–117, current HS256): ```python def decode_access_token(token: str) -> dict: try: payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) except jwt.ExpiredSignatureError as exc: raise ValueError("Token has expired") from exc except jwt.PyJWTError as exc: raise ValueError(f"Invalid token: {exc}") from exc if payload.get("typ") != "access": raise ValueError("Token type mismatch: expected 'access'") return payload ``` Change the `jwt.decode` call to: ```python public_pem = base64.b64decode(settings.jwt_public_key).decode() payload = jwt.decode(token, public_pem, algorithms=["ES256"]) ``` **Site 3 — `create_password_reset_token`** (lines 120–132): ```python return jwt.encode(payload, settings.secret_key, algorithm="HS256") ``` Change to: ```python private_pem = base64.b64decode(settings.jwt_private_key).decode() return jwt.encode(payload, private_pem, algorithm="ES256") ``` **Site 4 — `decode_password_reset_token`** (lines 135–149): ```python payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) ``` Change to: ```python public_pem = base64.b64decode(settings.jwt_public_key).decode() payload = jwt.decode(token, public_pem, algorithms=["ES256"]) ``` **`create_refresh_token` TTL param** (lines 154–172 — current signature): ```python async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str: raw = secrets.token_urlsafe(32) token_hash = hashlib.sha256(raw.encode()).hexdigest() now = datetime.now(timezone.utc) row = RefreshToken( id=uuid.uuid4(), user_id=user_id, token_hash=token_hash, expires_at=now + timedelta(days=settings.refresh_token_expire_days), revoked=False, ) session.add(row) await session.commit() return raw ``` Add `remember_me: bool = False` param; change TTL line to: ```python ttl = ( timedelta(days=settings.refresh_token_expire_days) if remember_me else timedelta(hours=settings.refresh_token_expire_hours) ) ... expires_at=now + ttl, ``` --- ### `backend/config.py` — add 3 new settings **Analog:** self — add after `refresh_token_expire_days` at line 35 **Current JWT block** (lines 33–35): ```python # Auth / JWT (Phase 2) access_token_expire_minutes: int = 15 refresh_token_expire_days: int = 30 ``` Extend to: ```python # Auth / JWT (Phase 2) access_token_expire_minutes: int = 15 refresh_token_expire_days: int = 30 refresh_token_expire_hours: int = 16 # Phase 7.3: default short session # JWT key pair (Phase 7.3 — ES256; both required in production) # Values are base64-encoded PEM strings (single-line, shell-safe). jwt_private_key: str = "" # JWT_PRIVATE_KEY env var — required jwt_public_key: str = "" # JWT_PUBLIC_KEY env var — required ``` No methods on Settings. Callers decode inline with `base64.b64decode(settings.jwt_private_key).decode()` — consistent with how other base64 secrets are handled in this codebase (e.g., HKDF in `ai_config.py` decodes `cloud_creds_key` inline). --- ### `backend/main.py` — lifespan ES256 rotation check **Primary analog:** `backend/services/ai_config.py:seed_system_settings_from_env` (lines 201–244) — exact pattern for reading and writing a `SystemSettings` key-value row at startup. **Secondary analog:** existing lifespan try/except block (lines 172–180) — exact wrapping pattern for skipping when the table doesn't exist yet. **Existing startup try/except block to copy** (lines 172–180): ```python try: async with AsyncSessionLocal() as session: await seed_system_settings_from_env(session) await session.commit() except Exception as _seed_exc: import logging as _logging _logging.getLogger(__name__).warning( "AI provider seed skipped (table may not exist yet): %s", _seed_exc ) ``` **Pattern to follow for the new rotation block** — slot in after the AI seed block (after line 180, before `yield`): ```python # ES256 startup rotation (Phase 7.3 — D-04): # If jwt_algorithm in system_settings differs from "ES256" (or is absent), # bulk-revoke all refresh tokens and record the new algorithm. # Wrapped in try/except so a missing table before migrations doesn't crash. try: async with AsyncSessionLocal() as session: await _rotate_tokens_on_algorithm_change(session) except Exception as _es256_exc: import logging as _logging _logging.getLogger(__name__).warning( "ES256 rotation check skipped (table may not exist yet): %s", _es256_exc ) ``` **`_rotate_tokens_on_algorithm_change` helper** — define as a module-level async function above `lifespan`, using the `seed_system_settings_from_env` upsert pattern as the direct template: ```python # seed_system_settings_from_env upsert pattern (ai_config.py lines 219–244): stmt = select(SystemSettings).where(SystemSettings.provider_id == provider_id) result = await session.execute(stmt) existing = result.scalar_one_or_none() if existing is not None: return row = SystemSettings( provider_id=provider_id, model_name=model_name, context_chars=context_chars, is_active=True, api_key_enc=None, base_url=None, ) session.add(row) ``` For the rotation helper, use `provider_id="jwt_algorithm"`, `model_name="ES256"`, `context_chars=0`, `is_active=False` (D-03 anti-pattern: never `is_active=True` for metadata rows). Add `text()` bulk UPDATE before the upsert when the algorithm differs: ```python from sqlalchemy import text, select from db.models import SystemSettings, RefreshToken async def _rotate_tokens_on_algorithm_change(session) -> None: """Idempotent ES256 migration. Bulk-revokes tokens if jwt_algorithm changed.""" result = await session.execute( select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm") ) row = result.scalar_one_or_none() if row is not None and row.model_name == "ES256": return # Already migrated # Bulk-revoke all active refresh tokens (single SQL statement, no Python iteration) await session.execute( text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false") ) if row is None: import uuid as _uuid session.add(SystemSettings( id=_uuid.uuid4(), provider_id="jwt_algorithm", model_name="ES256", context_chars=0, is_active=False, api_key_enc=None, base_url=None, )) else: row.model_name = "ES256" await session.commit() ``` **Required import addition at top of `main.py`** (alongside existing `from sqlalchemy import text`): ```python from sqlalchemy import text, select # text already imported; add select ``` --- ### `backend/api/auth.py` — LoginRequest + `_set_refresh_cookie` **Analog:** self — modify `LoginRequest` at lines 56–60 and `_set_refresh_cookie` at lines 70–80; thread `remember_me` through the login handler at line 278–279. **Current `LoginRequest`** (lines 56–60): ```python class LoginRequest(BaseModel): email: EmailStr password: str totp_code: Optional[str] = None backup_code: Optional[str] = None ``` Add one field: ```python remember_me: bool = False ``` **Current `_set_refresh_cookie`** (lines 70–80): ```python def _set_refresh_cookie(response: Response, raw_token: str) -> None: """Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint).""" response.set_cookie( key="refresh_token", value=raw_token, httponly=True, secure=True, samesite="strict", path="/api/auth/refresh", max_age=settings.refresh_token_expire_days * 86400, ) ``` Add `remember_me: bool = False` param; change `max_age` to: ```python max_age=( settings.refresh_token_expire_days * 86400 if remember_me else settings.refresh_token_expire_hours * 3600 ), ``` **Login handler token-issue block** (lines 277–279): ```python 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) ``` Pass `remember_me` through: ```python access_token = auth_service.create_access_token(str(user.id), user.role) 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) ``` --- ### `frontend/src/views/auth/LoginView.vue` — "Stay signed in" checkbox **Analog:** self — the password step form (lines 1–59 of template); the `submitPassword` / `submitTotp` / `submitBackupCode` functions (lines 224–265). **Current reactive state** (lines 188–193): ```javascript const email = ref('') const password = ref('') const totpInput = ref('') const backupCodeInput = ref('') const loading = ref(false) const error = ref(null) ``` Add one ref: ```javascript const rememberMe = ref(false) ``` **Checkbox markup** — insert inside the password step form (`