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

16 KiB

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

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.

  • 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

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
  • 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