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_PASSWORDis 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
401with codeINVALID_CREDENTIALSand 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
200with a JWT inaccessToken,expiresAt15 minutes ahead, and aSet-Cookieheader carrying the refresh token
Scenario: Malformed body
- WHEN the client posts
{ "user": "admin" } - THEN the response is
400with codeVALIDATION_ERRORlisting["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
429with codeRATE_LIMITEDand a numericRetry-Afterheader
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_PROXYisfalse, a hop count such as2, or a comma-separated list of addresses, CIDRs or the namesloopback,linklocal,uniquelocal - THEN the server starts and applies it; with
trueit 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: 1791893700andedition: "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
401with codeUNAUTHORIZEDanddetails.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 OpenAPI docs routes;
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/roomswithout anAuthorizationheader - THEN the response is
401with codeUNAUTHORIZED,details.reason: "missing"andWWW-Authenticate: Bearer
Scenario: Expired token
- GIVEN an access token issued 16 minutes ago
- WHEN the client requests
GET /api/v1/infowith it - THEN the response is
401withdetails.reason: "expired"
Scenario: Public routes
- WHEN the client requests
GET /api/v1/health,GET /api/v1/readyorGET /api/healthcheckwithout 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-Cookieheader isov_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=43200and 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_PATHis not set and the SPA was built withBASE_HREF=/dashboard/ - WHEN a login succeeds
- THEN the cookie
Pathis/dashboard/api/v1/auth
Scenario: Explicit base path
- GIVEN
DASHBOARD_BASE_PATH=/admin/ - WHEN a login succeeds
- THEN the cookie
Pathis/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
autoand a request received over plain HTTP without a forwarded protocol - WHEN a login succeeds
- THEN the cookie is set without
Secureand the subsequent refresh works
Scenario: HTTPS terminated at the proxy
- GIVEN the default
autoand a trusted proxy sendingX-Forwarded-Proto: https - WHEN a login succeeds
- THEN the cookie is set with
Secure
Scenario: Forced on
- GIVEN
DASHBOARD_COOKIE_SECURE=trueand 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
200with a new access token and a different refresh cookie, and the presented token is marked as rotated
Scenario: No cookie
- WHEN the client posts to refresh without the cookie
- THEN the response is
401with codeUNAUTHORIZEDanddetails.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
401withdetails.reason: "expired"and aSet-Cookiethat 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 a refresh with the successor token issued 5 minutes ago answers401
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
204with a clearingSet-Cookie, and a refresh with the old cookie answers401
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/mewith a valid token - THEN the response is
200with 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,createdAtandexpiresAt
Scenario: Automatic expiry
- GIVEN a session document whose
expiresAtpassed - 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-keydocument exists indashboard_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_SECRETis 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/loginorPOST api/v1/auth/refreshanswers401 - 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,sessionStorageor 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
/overviewinstead of the external URL