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

90 lines
4.6 KiB
Markdown

# 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