Files
OmniOilPersonal/openspec/changes/archive/2026-05-07-console-tech-debt-cleanup/specs/auth/spec.md
henryor af5535dfcc docs(openspec): archiva el change console-tech-debt-cleanup
Persiste proposal, specs delta (auth, api-client, access-requests,
platform-shell, testing), design con 8 ADRs, tasks (27 items en 10 fases),
verify-report (28 tests, 0 críticos, 0 warnings tras el cierre) y
archive-report final.

Cierra el ciclo SDD del cleanup de deuda técnica del console.
2026-05-07 21:14:56 -05:00

4.6 KiB

Auth Specification

Delta for: Auth — console-tech-debt-cleanup

ADDED Requirements

Requirement: Single Access Token Field in Auth Store

The auth store MUST expose exactly one access-token field: accessToken. The token field MUST NOT exist in AuthState. setSession MUST write only accessToken. logout MUST clear only accessToken (plus refreshToken and user). isAuthenticated MUST derive its result from accessToken.

All consumers (ProtectedRoute, LoginPage, apiClient) MUST read state.accessToken; they MUST NOT reference state.token.

Scenario: Login success stores token in accessToken only

  • GIVEN a user submits valid credentials
  • WHEN loginApi resolves and login() is called with the response
  • THEN state.accessToken holds the received JWT
  • AND state.token does not exist on the state object

Scenario: Refresh success updates accessToken only

  • GIVEN a valid refresh cookie exists and SessionCoordinator.refresh() succeeds
  • WHEN setSession is called with the new token
  • THEN state.accessToken is updated to the refreshed JWT
  • AND state.token does not exist on the state object

Scenario: Logout clears accessToken and user

  • GIVEN a user is authenticated (state.accessToken is non-null)
  • WHEN logout() is called
  • THEN state.accessToken is null
  • AND state.user is null
  • AND the persisted key omnioil-console-auth is removed from localStorage

Scenario: ProtectedRoute reads accessToken

  • GIVEN the store has accessToken = "eyJ..." and token field is absent
  • WHEN ProtectedRoute evaluates the session
  • THEN the component reads state.accessToken without referencing state.token

Requirement: LoginResponse Matches Backend Runtime Contract

The LoginResponse type MUST declare both access_token: string (primary) and token: string (legacy compatibility field), because the backend always emits both. The ?? fallback across non-equivalent fields MUST be removed; LoginPage MUST read response.access_token directly. The refresh_token field is NOT present in the login JSON body (it is delivered as an HttpOnly cookie only) and MUST NOT be declared on LoginResponse.

Scenario: Login page reads access_token directly

  • GIVEN the backend returns { access_token: "eyJ...", token: "eyJ...", role: "platform_admin", email: "op@x.com", projects: [] }
  • WHEN loginApi resolves and onSubmit processes the response
  • THEN login() is called with accessToken: response.access_token (no ?? response.token fallback)

Scenario: LoginResponse type has no refresh_token field

  • GIVEN the LoginResponse interface is the only type representing a login API response
  • WHEN a developer reads or extends LoginResponse
  • THEN the interface contains access_token, token, role, and email — no refresh_token

Scenario: Refresh endpoint response aligned in RefreshResponse

  • GIVEN SessionCoordinator.performRefresh receives the refresh response
  • WHEN the backend returns { access_token: "eyJ...", token: "eyJ...", role: "...", email: "..." }
  • THEN performRefresh reads data.access_token as the canonical field; the ?? data.token fallback MAY remain as a defensive guard but MUST NOT silently mask a missing access_token

Requirement: ProtectedRoute Uses Persisted User for Refresh Heuristic

ProtectedRoute MUST decide whether to attempt a refresh based on the persisted user field (which survives hydration), NOT on accessToken (which is intentionally not persisted). When user is null, the component MUST navigate to /login immediately without showing "Restaurando sesion...".

Scenario: Cold visit — no persisted user, no flash

  • GIVEN no prior session exists (user is null, accessToken is null)
  • WHEN the user navigates to a protected route
  • THEN the component renders <Navigate to="/login" replace /> immediately
  • AND "Restaurando sesion..." is never rendered

Scenario: Warm visit — persisted user, expired token, refresh succeeds

  • GIVEN user is non-null (persisted) but accessToken is null (in-memory only)
  • WHEN ProtectedRoute mounts
  • THEN it renders the "Restaurando sesion..." indicator while SessionCoordinator.refresh() is pending
  • AND when refresh resolves with a new token, setSession is called and the outlet renders

Scenario: Warm visit — persisted user, refresh fails

  • GIVEN user is non-null (persisted) but accessToken is null
  • WHEN SessionCoordinator.refresh() rejects or returns null
  • THEN ProtectedRoute navigates to /login after the refresh attempt completes
  • AND "Restaurando sesion..." disappears before the redirect