Qirava DMS docs
Session tokens: mint, validate, extend
Sessions are how a human signs in to Qirava Studio (the management UI). A user POSTs a username and password to auth.login; the server verifies the password against the PBKDF2 hash stored in _sys_users, mints a 32-byte random session token, and stores only the token's SHA-256 hash (plus an idle-expiry timestamp) in _sys_sessions. The plaintext token is returned exactly once and rides on every later request as a bearer token or the qdms_session cookie. On each request the auth.session before-gate validates the token by hash, rejects it if expired/disabled, and — critically — re-fetches the user's current role from _sys_users so a demotion or disable takes effect immediately. After a successful request the auth.session_extend after-hook bumps the idle window by 30 minutes.
What a session is (and is not)#
A session represents an authenticated human at the management surface (Qirava Studio). It is NOT the mechanism for programmatic/API access — that is API keys (see API keys: minting, storage, scope & rotation and HMAC-signed API keys & replay guard). Sessions are minted only by auth.login and are stored server-side in the _sys_sessions governance catalog.
- _sys_sessions
- The session catalog. Each row is
{ token_hash, user, role, created_us, expires_us }. Only the SHA-256 HASH of the bearer token is stored — never the plaintext. - _sys_users
- The identity catalog. Each row is
{ id, username, pwd_hash, role, db_grants, enabled }.pwd_hashis a PBKDF2-HMAC-SHA256 string;roleis one of custodian|admin|user|guest. - SESSION_IDLE_US
- The idle window: 30 * 60 * 1_000_000 microseconds = 30 minutes. A session expires this long after the last successful authenticated request.
Step 1 — Mint: auth.login#
auth.login (registry key auth.login) is a PUBLIC, ungated function exposed over HTTP as POST /api/login. Studio's own login form (POST /studio/login) calls the exact same function in-process. It accepts {username, password} from the JSON body (or as top-level fields for in-process callers).
Verification path: it reads pwd_hash, role, enabled from _sys_users for the username, then checks enabled && qcrypto::password_verify(password, pwd_hash). The password check is PBKDF2-HMAC-SHA256 at 600,000 iterations with constant-time comparison. To resist user-enumeration timing, a missing user still runs a dummy verify against a throwaway hash (pbkdf2$1$AA$AA) so present-vs-absent users take a similar path. On any failure it returns access_denied with the constant message "invalid credentials".
On success it mints a 32-byte random token (rendered as 64 hex chars), stores sha256_hex(token) plus created_us and expires_us = now + 30min in _sys_sessions, and returns the plaintext token once.
Request
POST /api/login HTTP/1.1
Host: localhost:8080
Content-Type: application/json
{ "username": "alice", "password": "correct horse battery staple" }Response
{
"error": null,
"data": {
"token": "3f9c1a...d2e8", // 64 hex chars (32 random bytes); shown ONCE
"user": "alice",
"role": "admin",
"expires_us": 1750000000000000
},
"root": { "took_us": 0, "request_id": "…" }
}Output
Studio's POST /studio/login wraps the same call: on success it sets an HttpOnly cookie `qdms_session=<token>` with Max-Age = SESSION_IDLE_US/1_000_000 (1800s) and 302-redirects to /studio (Post/Redirect/Get). It never echoes the token in the page body.| Field | Type | Meaning |
|---|---|---|
| token | string | The plaintext session token (64 hex). Send it on later requests. Stored server-side only as its SHA-256 hash. |
| user | string | The authenticated username. |
| role | string | The user's role AT LOGIN TIME (custodian|admin|user|guest). Not trusted by the gate later — the live role is re-fetched per request. |
| expires_us | int | Idle-expiry timestamp in microseconds since epoch (now + 30 min). |
Step 2 — Validate: the auth.session before-gate#
auth.session (key auth.session) is a before security gate attached to authenticated routes via a group's before-chain. It accepts the token from an Authorization: Bearer <token> header OR the qdms_session=<token> cookie. Missing token → access_denied "missing session token".
It hashes the presented token and looks the row up in _sys_sessions by token_hash. No row → "invalid session". If expires_us <= now → "session expired". Then it performs the live role re-fetch (next section). Optionally, if the route declares a required_role, it enforces it by rank: the live role must rank >= the required role, else "role '<req>' required".
Authorization: Bearer <t> OR Cookie: qdms_session=<t>
|
v
sha256_hex(token) ──► SELECT user,role,expires_us
FROM _sys_sessions
WHERE token_hash = :th
|
no row ─┴─► 403 access_denied "invalid session"
|
expires_us <= now? ──► 403 "session expired"
|
re-fetch live user ──► SELECT role,enabled
FROM _sys_users WHERE username = :u
|
no user ─┴─► 403 "user no longer exists"
!enabled ──► 403 "user disabled"
|
route required_role set & live role rank < required? ──► 403
|
v
inject ctx { user, role } ──► handler runsOn success it injects { user, role } into the shared request context (ctx). Downstream governance functions read the caller's role ONLY from ctx.role (never a body field), so a caller cannot spoof its own role by putting role in the request body.
Step 3 — Live role re-fetch (why the session role is not trusted)#
The role captured in the session row at login time is NOT used for the access decision. On every authenticated request, auth.session re-reads role, enabled from the user's CURRENT _sys_users row. This means a demotion, role change, or account disable takes effect IMMEDIATELY rather than after the up-to-30-minute idle window expires.
- If the user row no longer exists → the session is rejected ("user no longer exists").
- If
enabled = false→ rejected ("user disabled"). - Otherwise the LIVE role is what gates the route and what is injected into
ctx.role.
Step 4 — Extend: the auth.session_extend after-hook#
auth.session_extend (key auth.session_extend) is an after hook in the same group's after-chain. After a successful response it bumps the session's expires_us to now + 30min, keeping an active user signed in. It is read-only with respect to the response body and never fails the request.
It extends ONLY on a genuine success. It inspects the rendered response body: an enveloped error ({"error":{...},...}) is a failure (no extend); an enveloped success ("error": null) or a raw non-enveloped body (HTML/Text/Passthrough with no "error" field) is a success (extend). An EMPTY body is treated as a failure (fail closed — no extend). The UPDATE is also guarded with expires_us > now, so an already-expired session is never silently revived.
Request
UPDATE _sys_sessions
SET expires_us = :e -- :e = now_us() + SESSION_IDLE_US
WHERE token_hash = :th
AND expires_us > <now_us> -- guard: never revive an expired sessionResponse
{ "error": null, "data": { "extended": true }, "root": { … } }Output
On a failed/empty response (or a missing token) the hook is a no-op and returns { "extended": false } without throwing.Logout#
Studio exposes POST /studio/logout. It is CSRF-protected (the CSRF token is bound to the current session cookie): if a session cookie is present, a valid CSRF token in the form body is required, else the request bounces to /studio/login?error=Bad+CSRF+token. On success it clears the qdms_session cookie and redirects to the login page with a "logged out" flash. (If no session cookie is present there is nothing to protect, so it simply bounces to login.)