openvidu/openvidu-dashboard/openspec/specs/auth/spec.md

365 lines
16 KiB
Markdown

# auth Specification
## Purpose
Authenticates the single dashboard administrator and keeps the session alive across page reloads and HA replicas
with a short-lived bearer access token and a rotating refresh-token cookie, without storing credentials or tokens in
the browser and without a Redis dependency.
## Requirements
### Requirement: Single administrator credentials
Edition: CE. The dashboard SHALL have exactly one user, whose credentials come from `ADMIN_USERNAME` and
`ADMIN_PASSWORD`; both variables SHALL be required at startup, and credential checks SHALL compare username and
password in constant time so that response timing does not reveal which one is wrong.
#### Scenario: Missing credentials at startup
- **GIVEN** `ADMIN_PASSWORD` is not set
- **WHEN** the server starts
- **THEN** it logs which variable is missing and exits with a non-zero code without listening
#### Scenario: Wrong username and wrong password take the same time
- **WHEN** one login uses an unknown username and another the right username with a wrong password
- **THEN** both answer `401` with code `INVALID_CREDENTIALS` and the same message, with no measurable timing
difference attributable to the comparison
### Requirement: Login endpoint
Edition: CE. `POST /api/v1/auth/login` SHALL accept `{ username, password }`, SHALL answer `200`
`{ accessToken, expiresAt }` and set the refresh cookie when the credentials match, SHALL answer `401`
`INVALID_CREDENTIALS` otherwise, and SHALL answer `400` `VALIDATION_ERROR` when the body is malformed.
#### Scenario: Successful login
- **WHEN** the client posts the configured username and password
- **THEN** the response is `200` with a JWT in `accessToken`, `expiresAt` 15 minutes ahead, and a `Set-Cookie`
header carrying the refresh token
#### Scenario: Malformed body
- **WHEN** the client posts `{ "user": "admin" }`
- **THEN** the response is `400` with code `VALIDATION_ERROR` listing `["username"]` and `["password"]` as issues
### Requirement: Login rate limit
Edition: CE. Login SHALL be limited per client IP to 10 failed attempts in a fixed 15-minute window opened by the
first login attempt from that IP; once the limit is reached further login requests from that IP SHALL answer `429`
`RATE_LIMITED` with a `Retry-After` header in seconds until the window ends, successful logins SHALL NOT count, and the
counter SHALL be kept per server replica.
#### Scenario: Eleventh failed attempt
- **GIVEN** an IP has failed 10 logins in its current window
- **WHEN** it posts another login, even with the right credentials
- **THEN** the response is `429` with code `RATE_LIMITED` and a numeric `Retry-After` header
#### Scenario: Window expiry
- **GIVEN** an IP was rate limited
- **WHEN** 15 minutes have passed since the attempt that opened its window
- **THEN** a login with the right credentials succeeds
### Requirement: Trusted proxy
Edition: CE. The client IP (rate limiting) and the request protocol (cookie policy) SHALL be taken from
`X-Forwarded-For` / `X-Forwarded-Proto` only when the request comes from a proxy trusted by `DASHBOARD_TRUST_PROXY`,
whose default trusts loopback, link-local and private networks; the value `true` SHALL be rejected at startup
because it allows header spoofing.
#### Scenario: Accepted values
- **WHEN** `DASHBOARD_TRUST_PROXY` is `false`, a hop count such as `2`, or a comma-separated list of addresses, CIDRs
or the names `loopback`, `linklocal`, `uniquelocal`
- **THEN** the server starts and applies it; with `true` it logs the reason and exits with a non-zero code
#### Scenario: Behind the deployment proxy
- **GIVEN** the default setting and a request relayed by Caddy from a private network address
- **WHEN** the request carries `X-Forwarded-For: 203.0.113.9`
- **THEN** the rate limiter counts the attempt against `203.0.113.9`
#### Scenario: Spoofed header from an untrusted address
- **GIVEN** the default setting and a request arriving directly from a public address
- **WHEN** it carries `X-Forwarded-For: 10.0.0.1`
- **THEN** the header is ignored and the attempt counts against the connecting address
### Requirement: Access token
Edition: CE. The access token SHALL be a JWT signed with HS256 and the dashboard signing key, valid for 15 minutes,
with claims `sub` (username), `iat`, `exp` and `edition` (`ce` or `pro`); the server SHALL verify signature,
algorithm and expiry on every protected request and SHALL NOT keep a revocation list for access tokens.
#### Scenario: Claims
- **WHEN** a login succeeds on the CE edition at 2026-10-13T12:00:00Z
- **THEN** the decoded token has `sub: "<username>"`, `iat: 1791892800`, `exp: 1791893700` and `edition: "ce"`
#### Scenario: Token signed with another key
- **WHEN** a request carries a token signed with a different secret or with `alg: none`
- **THEN** the response is `401` with code `UNAUTHORIZED` and `details.reason: "invalid"`
### Requirement: Protected routes
Edition: CE. Every `/api/v1` route SHALL require `Authorization: Bearer <accessToken>` except the auth endpoints
`login`, `refresh` and `logout`, `health`, `ready`, the public subset of `info` and the development-only docs;
`GET /api/healthcheck` and the SPA assets SHALL also be public. A missing, malformed, invalid or expired token SHALL
answer `401` `UNAUTHORIZED` with `details.reason` `missing`, `invalid` or `expired` and `WWW-Authenticate: Bearer`.
#### Scenario: Missing token
- **WHEN** the client requests `GET /api/v1/rooms` without an `Authorization` header
- **THEN** the response is `401` with code `UNAUTHORIZED`, `details.reason: "missing"` and `WWW-Authenticate: Bearer`
#### Scenario: Expired token
- **GIVEN** an access token issued 16 minutes ago
- **WHEN** the client requests `GET /api/v1/info` with it
- **THEN** the response is `401` with `details.reason: "expired"`
#### Scenario: Public routes
- **WHEN** the client requests `GET /api/v1/health`, `GET /api/v1/ready` or `GET /api/healthcheck` without a token
- **THEN** the responses are not `401`
### Requirement: Refresh token cookie
Edition: CE. On login and on refresh the server SHALL set the refresh token in a cookie named
`ov_dashboard_refresh` with `HttpOnly`, `SameSite=Strict`, `Path=<basePath>api/v1/auth`, `Max-Age` equal to
`DASHBOARD_REFRESH_TOKEN_TTL` (positive integer seconds, default `604800`) and the `Secure` flag per the cookie
security policy; the token SHALL be an opaque random value of at least 256 bits, never a JWT, and SHALL never appear
in a response body.
#### Scenario: Cookie attributes behind the deployment proxy
- **GIVEN** the dashboard is published under `/dashboard/` over HTTPS
- **WHEN** a login succeeds
- **THEN** the `Set-Cookie` header is `ov_dashboard_refresh=<token>; Path=/dashboard/api/v1/auth; Max-Age=604800;
HttpOnly; Secure; SameSite=Strict`
#### Scenario: Custom lifetime
- **GIVEN** `DASHBOARD_REFRESH_TOKEN_TTL=43200`
- **WHEN** a login succeeds
- **THEN** the cookie has `Max-Age=43200` and the stored session expires 12 hours later
#### Scenario: Invalid lifetime
- **GIVEN** `DASHBOARD_REFRESH_TOKEN_TTL=soon`
- **WHEN** the server starts
- **THEN** it logs the invalid value and exits with a non-zero code
### Requirement: Public base path
Edition: CE. The cookie path SHALL be built from the dashboard's public base path, taken from `DASHBOARD_BASE_PATH`
when set and otherwise from the `<base href>` of the built SPA (`/dashboard/` in release images, `/` locally), so
that the browser sends the cookie to the auth endpoints and to nothing else on the same origin.
#### Scenario: Default base path from the SPA
- **GIVEN** `DASHBOARD_BASE_PATH` is not set and the SPA was built with `BASE_HREF=/dashboard/`
- **WHEN** a login succeeds
- **THEN** the cookie `Path` is `/dashboard/api/v1/auth`
#### Scenario: Explicit base path
- **GIVEN** `DASHBOARD_BASE_PATH=/admin/`
- **WHEN** a login succeeds
- **THEN** the cookie `Path` is `/admin/api/v1/auth`
### Requirement: Cookie security policy
Edition: CE. The `Secure` flag of the refresh cookie SHALL follow `DASHBOARD_COOKIE_SECURE`: `auto` (default) sets it
when the request was received over HTTPS, directly or through a trusted proxy reporting `X-Forwarded-Proto: https`;
`true` always sets it; `false` never sets it.
#### Scenario: Plain HTTP local deployment
- **GIVEN** the default `auto` and a request received over plain HTTP without a forwarded protocol
- **WHEN** a login succeeds
- **THEN** the cookie is set without `Secure` and the subsequent refresh works
#### Scenario: HTTPS terminated at the proxy
- **GIVEN** the default `auto` and a trusted proxy sending `X-Forwarded-Proto: https`
- **WHEN** a login succeeds
- **THEN** the cookie is set with `Secure`
#### Scenario: Forced on
- **GIVEN** `DASHBOARD_COOKIE_SECURE=true` and a plain HTTP request
- **WHEN** a login succeeds
- **THEN** the cookie is set with `Secure` (the browser will drop it; the setting is for deployments that always
terminate TLS but do not forward the protocol)
### Requirement: Refresh rotation
Edition: CE. `POST /api/v1/auth/refresh` SHALL read the refresh cookie, SHALL answer `200` `{ accessToken, expiresAt }`
with a new refresh cookie of the same family when the token is live (not rotated, revoked or expired), SHALL mark the
presented token as rotated, and SHALL answer `401` `UNAUTHORIZED` and clear the cookie when it is missing, unknown,
expired or belongs to a revoked family.
#### Scenario: Rotation
- **GIVEN** a live refresh token
- **WHEN** the client posts to refresh with it
- **THEN** the response is `200` with a new access token and a different refresh cookie, and the presented token is
no longer live
#### Scenario: No cookie
- **WHEN** the client posts to refresh without the cookie
- **THEN** the response is `401` with code `UNAUTHORIZED` and `details.reason: "missing"`
#### Scenario: Expired token
- **GIVEN** a refresh token whose lifetime has passed
- **WHEN** the client posts to refresh with it
- **THEN** the response is `401` with `details.reason: "expired"` and a `Set-Cookie` that clears the cookie
### Requirement: Reuse detection
Edition: CE. When a refresh token that was already rotated is presented again, the server SHALL revoke its whole
family and answer `401`, unless the token was rotated less than 10 seconds earlier, in which case the request SHALL
be treated as a concurrent refresh and rotated normally; every revocation SHALL be logged at warning level with the
family id.
#### Scenario: Replay after the grace period
- **GIVEN** a refresh token rotated 5 minutes ago
- **WHEN** it is presented again
- **THEN** the response is `401`, the cookie is cleared, every token of the family is revoked and the successor token
issued 5 minutes ago no longer works
#### Scenario: Two tabs refreshing at once
- **GIVEN** two browser tabs sharing the cookie whose access tokens expired together
- **WHEN** both post to refresh within one second with the same token
- **THEN** both receive `200`, both of the issued tokens work and the family stays live
### Requirement: Logout
Edition: CE. `POST /api/v1/auth/logout` SHALL revoke the family of the refresh token in the cookie, clear the cookie
and answer `204`, SHALL answer `204` also when no valid cookie is present, and SHALL NOT invalidate access tokens
already issued, which expire on their own within 15 minutes.
#### Scenario: Logout
- **GIVEN** a logged-in session
- **WHEN** the client posts to logout
- **THEN** the response is `204` with a clearing `Set-Cookie`, and a refresh with the old cookie answers `401`
#### Scenario: Logout without session
- **WHEN** the client posts to logout without a cookie
- **THEN** the response is `204`
### Requirement: Current session
Edition: CE. `GET /api/v1/auth/me` SHALL answer `200` `{ username, edition, expiresAt }` for a valid access token,
where `expiresAt` is the token's expiry.
#### Scenario: Me
- **WHEN** the client requests `GET /api/v1/auth/me` with a valid token
- **THEN** the response is `200` with the configured username, the edition and the token expiry as ISO 8601
### Requirement: Session storage
Edition: CE. Refresh tokens SHALL be stored in the `dashboard_sessions` collection, one document per token with its
family id, a SHA-256 hash of the token (never the token), creation, expiry, rotation and revocation timestamps and
the client address and user agent that created it; expired documents SHALL be removed automatically by a TTL index
on `expiresAt`.
#### Scenario: Only hashes at rest
- **WHEN** the collection is inspected after a login
- **THEN** no document contains the cookie value and each holds `tokenHash`, `familyId`, `createdAt` and `expiresAt`
#### Scenario: Automatic expiry
- **GIVEN** a session document whose `expiresAt` passed
- **WHEN** the MongoDB TTL monitor runs
- **THEN** the document is deleted without dashboard intervention
### Requirement: Signing key provisioning
Edition: CE. At startup the server SHALL use `DASHBOARD_JWT_SECRET` (at least 32 bytes) when set; otherwise it SHALL
read the key from `dashboard_settings` `{ _id: 'signing-key' }`, generating 32 random bytes and inserting them atomically
when the document is absent, so that concurrent replicas converge on one key; a `DASHBOARD_JWT_SECRET` shorter than
32 bytes SHALL abort startup.
#### Scenario: First start
- **GIVEN** no `signing-key` document exists in `dashboard_settings`
- **WHEN** the server starts
- **THEN** it inserts `{ _id: 'signing-key', key, createdAt }` and signs tokens with that key
#### Scenario: Two replicas starting together
- **WHEN** two replicas start concurrently against the same database
- **THEN** exactly one insert succeeds, the other replica reads the stored key, and a token issued by either replica
verifies on the other
#### Scenario: Override
- **GIVEN** `DASHBOARD_JWT_SECRET` is set to a 48-character value
- **WHEN** the server starts
- **THEN** tokens are signed with that secret and the stored key is ignored
#### Scenario: Short override
- **GIVEN** `DASHBOARD_JWT_SECRET=short`
- **WHEN** the server starts
- **THEN** it logs that the secret must be at least 32 bytes and exits with a non-zero code
### Requirement: Frontend session flow
Edition: CE. The SPA SHALL keep the access token in memory only, SHALL attach it as a bearer to every API request,
SHALL restore the session on load by calling refresh, SHALL on a `401` from a protected request run one refresh
(shared by concurrent requests) and replay the request once, SHALL redirect to `/login?redirect=<current path>` when
the refresh fails, and SHALL store no credential or token in `localStorage`, `sessionStorage` or IndexedDB.
#### Scenario: Bearer attached
- **GIVEN** a logged-in session
- **WHEN** the UI requests `api/v1/rooms`
- **THEN** the request carries `Authorization: Bearer <accessToken>`
#### Scenario: Silent refresh and replay
- **GIVEN** three list requests in flight fail with `401`
- **WHEN** the interceptor handles them
- **THEN** exactly one refresh request is sent, and the three requests are replayed once with the new token
#### Scenario: Refresh fails
- **GIVEN** the user is on `/rooms?status=active`
- **WHEN** the silent refresh answers `401`
- **THEN** the in-memory token is cleared and the UI navigates to `/login?redirect=%2Frooms%3Fstatus%3Dactive`
#### Scenario: Page reload
- **GIVEN** a logged-in session and a page reload
- **WHEN** the SPA starts
- **THEN** it calls refresh, obtains a new access token and renders the requested route without showing the login
#### Scenario: Login endpoints are never retried
- **WHEN** `POST api/v1/auth/login` or `POST api/v1/auth/refresh` answers `401`
- **THEN** the interceptor does not trigger another refresh
#### Scenario: Nothing in browser storage
- **WHEN** the browser storage is inspected after login
- **THEN** no key of `localStorage`, `sessionStorage` or IndexedDB holds a token, username or password
#### Scenario: Redirect target is validated
- **GIVEN** a login page opened with `redirect=https://evil.example/`
- **WHEN** the login succeeds
- **THEN** the UI navigates to `/overview` instead of the external URL