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.
90 lines
4.6 KiB
Markdown
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
|