Sessions
Overview
The sessions module provides user session management, allowing users to view their active sessions and revoke individual sessions or all other sessions. Sessions are created during OAuth login and are tied to refresh tokens via a SHA-256 token hash.
On Security → Sessions (/dashboard/settings/security/sessions) each session shows its device, sign-in method, whether 2FA was verified (including a passkey sign-in, which completes 2FA), approximate location, IP address, and last activity. A value Lumio cannot resolve is shown as Unknown. The current session is marked from Session.isCurrent and cannot be revoked from its own row. Other sessions can be revoked individually or selected together. The page can export session details as CSV or JSON. Popout tokens are separate credentials and do not appear in this table.
Architecture
ID App (NextAuth) ──> OAuth Login
|
v
exchangeToken mutation
|
+--> create_session (DB)
+--> cache_session (Redis)
+--> Issue JWT with session_id claim
|
v
Dashboard UI
|
v
GraphQL (SessionQuery / SessionMutation)
|
v
db::auth (PostgreSQL sessions table)
Session Lifecycle
- Creation -- During
exchangeToken, a session row is created with the user ID, active account ID, SHA-256 hash of the refresh token'sjti, client IP, user agent, and expiry timestamp. The session is also cached in Redis for fast lookup. The client IP and user agent are the end user's, not the web server's: the login almost always reaches the API through a Next.js service (apps/id, web, admin), so both are resolved from what that first-party hop forwards --X-Lumio-Client-IP(lo_api::client_ip, ZAF-1208) andX-Lumio-Client-UA(lo_api::client_ua, ZAF-1414), each honoured only for a caller holding theX-Internal-Tokensecret. Node'sfetchsendsUser-Agent: node, so without the forwarded UA the device would readnodefor every web login. Both channels depend onINTERNAL_TOKENbeing provisioned on the API and the matching secret on web/admin/id; if it is unset the hop is not trusted and the IP and UA fall back to the edge/server values. See Audit events → How the client User-Agent is resolved. - JWT Embedding -- The session ID is embedded in the JWT via
create_token_with_session, allowing the API to identify which session corresponds to the current request. - Refresh -- During
refreshToken, the session is looked up by token hash. A new JWT is issued with the same session ID. A new refresh token is issued with the remaining session lifetime, and the session'stoken_hashis rotated to invalidate the old refresh token. - Account switch / create / dissolve -- These paths (
updateMewithactiveAccountId/clearActiveAccount,createAccount,dissolveAccount, and their REST twins) rewriteactive_account_idand hand back a fresh JWT so the cookie reflects the new account (GraphQLdissolveAccountreturns no token; its REST twin does). A JWT is only minted for a caller that already holds an interactive session (ZAF-1922): the switch / clear itself is session-only, andcreateAccountreturnstoken: nullto a caller without one (DELETE /v1/accounts/\{id\}refuses such a caller: dissolving needs a step-up, which only a session can hold — ZAF-2052). The switch and create writes are scoped to the caller's own session row only (WHERE id = <session_id>, viadb::auth::set_session_active_account): account selection is per-device, so switching on one device never re-points the user's other sessions (ZAF-1416). A session-less caller ofcreateAccount(API-key auth) persists nothing, rather than falling back to a user-wide write. Dissolve is the deliberate exception — it clearsactive_account_idaccount-wide so no other device is left holding a deleted account. The replacement JWT is minted withlo_auth::reissue_session_token(acreate_token_with_sessionwrapper that returnsNonefor a caller without asession_id) and carries the samesession_idas the incoming cookie - the session itself does not change, only which account it is acting on. Preserving thesession_idhere is what keeps the post-switch cookie (and any WebSocket token derived from it) subject to the revocation gate below (ZAF-908). - Logout --
logout(refreshToken: String!)deletes the session from both PostgreSQL and Redis by token hash.logoutSessiondoes the same for the current session, identified from thesession_idJWT claim, so no refresh token has to be handed back. REST twin:POST /v1/auth/logout. - Revocation -- Users can delete specific sessions or all sessions except the current one.
Revocation gate
A signature-valid JWT is not sufficient to authorize a request: the auth middleware (crates/lo-api/src/middleware/auth.rs) additionally checks that the token's session_id still points at a live sessions row (SELECT EXISTS(... WHERE id = $1 AND expires_at > now()), a positive result cached for 60 s in Redis under lumio:session_valid:{session_id}). When the row is gone - logout, "revoke all other sessions", or expiry - the token stops working before its exp, and the request resolves to Anonymous. Every revoke path (REST and GraphQL logout, single-session revoke, revoke all other sessions, sign-out everywhere, user deletion) also deletes the cache entry, so a revoked session is rejected on its next request; the 60 s TTL only bounds a failed delete. Expiry is not evicted - a cached entry can outlive expires_at by up to 60 s.
This gate only inspects tokens that carry a session_id. That is why every session-cookie mint - login, refresh, and the account switch/create/dissolve re-mints - must go through create_token_with_session (the re-mints via reissue_session_token): a token minted without a session_id is structurally exempt from revocation and would survive a "log out everywhere" until its 24 h exp. For the same reason a user JWT without a session_id never counts as an interactive session: AuthContext::session_user_id() returns nothing for it, so it fails every session-only gate. (Refresh tokens, popout-session JWTs, and the reserved WS-scoped token legitimately carry no session_id; they are gated by other means - the /auth/refresh-only typ claim, the origin popout-token revoked_at check, etc.)
Session Filtering
Only non-expired sessions are returned by list_sessions_for_user (filtered by expires_at > now()).
API
GraphQL Queries
| Query | Args | Returns | Permission |
|---|---|---|---|
sessions | -- | [Session] | Auth (first-party) |
Returns all active (non-expired) sessions for the current user, ordered by created_at DESC.
GraphQL Mutations
| Mutation | Args | Returns | Permission |
|---|---|---|---|
deleteSession | id: UUID | Boolean | Auth (first-party) |
deleteAllOtherSessions | -- | Boolean | Auth (first-party) |
deleteSessionverifies the session belongs to the current user before deletion.deleteAllOtherSessionsuses thesession_idfrom the JWT to identify the current session and deletes all others for the user.
GraphQL Types
The response also carries authMethod, mfaVerifiedAt, lastUsedAt, country and city (auth_method, mfa_verified_at, last_used_at, country, city in REST). The location is optional GeoIP enrichment, and the last-activity timestamp advances on session refresh. The dashboard does not label popout tokens as sessions.
type Session {
id: UUID!
userId: UUID!
activeAccountId: UUID
activeAccountName: String
ipAddress: String
userAgent: String
expiresAt: String!
createdAt: String!
isCurrent: Boolean!
}
isCurrent is true when the row's id equals the session_id JWT claim of the request - parity with the REST SessionResponse.is_current (ZAF-1412). It is computed in the sessions resolver from the auth context, not stored; a non-session principal (e.g. a user API key) leaves every row false.
activeAccountName is the display name of the account the session currently has selected (activeAccountId), resolved by a LEFT JOIN accounts in list_sessions_for_user (ZAF-1418). It is null whenever activeAccountId is null - API-key or fresh sessions carry no selection, and account dissolution clears the column - and the LEFT JOIN keeps such rows in the list rather than dropping them. Parity with the REST SessionResponse.active_account_name.
Note: The token_hash field is present in the database row but is not exposed via GraphQL.
REST Endpoints
Sessions are user-scoped; the REST handlers enforce ownership in code rather than via an RBAC permission string - identically to GraphQL. All paths live under /v1/users/me/sessions.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/users/me/sessions | Auth | List active (non-expired) sessions for the current user |
DELETE | /v1/users/me/sessions/{id} | Auth | Revoke one session (must belong to the current user) |
DELETE | /v1/users/me/sessions | Auth | Revoke all other sessions (keeps the current one) |
Response bodies are snake_case. The REST SessionResponse carries the same
account-selection fields as the GraphQL Session type; the only remaining
difference is user_id, which REST leaves implicit (the endpoint is already
scoped to /users/me):
| Field | GraphQL Session | REST SessionResponse |
|---|---|---|
id | yes | yes |
userId / user_id | yes | no |
activeAccountId / active_account_id | yes | yes (ZAF-1418) |
activeAccountName / active_account_name | yes (ZAF-1418) | yes (ZAF-1418) |
ipAddress / ip_address | yes | yes |
userAgent / user_agent | yes | yes |
expiresAt / expires_at | yes | yes |
createdAt / created_at | yes | yes |
isCurrent / is_current | yes (ZAF-1412) | yes - true when the row's id equals the session_id JWT claim |
The token_hash column is exposed by neither protocol. Both protocols apply the
same first-party gate (require_first_party_user) and the same own-scoped row
selection, so authorization is at parity, and active_account_id /
active_account_name now carry identical values on both (ZAF-1418).
WebSocket
There is no session channel: crates/lo-websocket/src/gate.rs maps no sessions channel type, so channel_gate_for("sessions") returns ChannelGate::Unknown and any subscription attempt is rejected. Session changes are polled through the query, not pushed.
Permissions
Session read and revoke are own-scoped, not account-scoped: every endpoint and
resolver selects rows by the acting user's own id, so no RBAC grant gates them on
either protocol. Both surfaces require a first-party principal - a logged-in
session or the user's own API key; a popout / overlay / shared-overlay / widget /
extension token is rejected with FORBIDDEN (ZAF-469), even when bound to the
account owner.
This is why the session list works with no active account selected (the personal profile in the account switcher): an account-scoped guard would see an empty permission set there and hide the user's own sessions.
There is no account-role permission for session management. Session reads and
revokes are own-scoped: they select strictly by the acting user's id and gate only
on AuthGuard plus the first-party principal check. The former sessions:read /
sessions:delete account permissions were removed in ZAF-1094 - they were
assignable in the role editor but no handler ever enforced them, so granting or
withholding them changed nothing about who can see or revoke their own sessions.
Database
Table: sessions
| Column | Type | Description |
|---|---|---|
id | UUID (PK) | Session ID |
user_id | UUID (FK) | Session owner |
active_account_id | UUID (FK) | Currently selected account |
token_hash | TEXT | SHA-256 hash of the refresh token's jti |
ip_address | INET | Client IP address (stored as inet, returned via host()) |
user_agent | TEXT | Client user agent string |
expires_at | TIMESTAMPTZ | Session expiry |
created_at | TIMESTAMPTZ | Creation timestamp |
DB Functions
| Function | Description |
|---|---|
create_session | Insert a new session with token hash, IP, user agent, and expiry |
list_sessions_for_user | List non-expired sessions ordered by created_at DESC, each enriched with the selected account's display name via LEFT JOIN accounts (returns SessionListRow, ZAF-1418) |
find_session_by_token_hash | Lookup by token hash (used during refresh) |
rotate_session_token_hash | Update token hash during refresh token rotation |
delete_session_by_id | Delete a specific session with user ownership check |
set_session_active_account | Set/clear active_account_id on the caller's own session row only (per-device selection, ZAF-1416); skips the write when there is no session |
delete_other_sessions | Delete all sessions except the specified one |
delete_session | Delete by token hash (used during logout) |
delete_all_sessions_for_user | Delete all sessions (used for account dissolution) |
Key Files
| File | Purpose |
|---|---|
apps/api/src/graphql/sessions.rs | GraphQL queries and mutations |
apps/api/src/graphql/auth.rs | Token exchange and refresh (session creation) |
apps/api/src/db/auth.rs | Session CRUD and token hash operations |
crates/lo-auth/src/jwt.rs | JWT creation with embedded session ID |
crates/lo-auth/src/api_key.rs | hash_token function (SHA-256 hashing) |