Skip to main content

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​

  1. Creation -- During exchangeToken, a session row is created with the user ID, active account ID, SHA-256 hash of the refresh token's jti, 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) and X-Lumio-Client-UA (lo_api::client_ua, ZAF-1414), each honoured only for a caller holding the X-Internal-Token secret. Node's fetch sends User-Agent: node, so without the forwarded UA the device would read node for every web login. Both channels depend on INTERNAL_TOKEN being 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.
  2. 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.
  3. 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's token_hash is rotated to invalidate the old refresh token.
  4. Account switch / create / dissolve -- These paths (updateMe with activeAccountId/clearActiveAccount, createAccount, dissolveAccount, and their REST twins) rewrite active_account_id and hand back a fresh JWT so the cookie reflects the new account (GraphQL dissolveAccount returns 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, and createAccount returns token: null to 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>, via db::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 of createAccount (API-key auth) persists nothing, rather than falling back to a user-wide write. Dissolve is the deliberate exception — it clears active_account_id account-wide so no other device is left holding a deleted account. The replacement JWT is minted with lo_auth::reissue_session_token (a create_token_with_session wrapper that returns None for a caller without a session_id) and carries the same session_id as the incoming cookie - the session itself does not change, only which account it is acting on. Preserving the session_id here is what keeps the post-switch cookie (and any WebSocket token derived from it) subject to the revocation gate below (ZAF-908).
  5. Logout -- logout(refreshToken: String!) deletes the session from both PostgreSQL and Redis by token hash. logoutSession does the same for the current session, identified from the session_id JWT claim, so no refresh token has to be handed back. REST twin: POST /v1/auth/logout.
  6. 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​

QueryArgsReturnsPermission
sessions--[Session]Auth (first-party)

Returns all active (non-expired) sessions for the current user, ordered by created_at DESC.

GraphQL Mutations​

MutationArgsReturnsPermission
deleteSessionid: UUIDBooleanAuth (first-party)
deleteAllOtherSessions--BooleanAuth (first-party)
  • deleteSession verifies the session belongs to the current user before deletion.
  • deleteAllOtherSessions uses the session_id from 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.

MethodPathPermissionDescription
GET/v1/users/me/sessionsAuthList active (non-expired) sessions for the current user
DELETE/v1/users/me/sessions/{id}AuthRevoke one session (must belong to the current user)
DELETE/v1/users/me/sessionsAuthRevoke 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):

FieldGraphQL SessionREST SessionResponse
idyesyes
userId / user_idyesno
activeAccountId / active_account_idyesyes (ZAF-1418)
activeAccountName / active_account_nameyes (ZAF-1418)yes (ZAF-1418)
ipAddress / ip_addressyesyes
userAgent / user_agentyesyes
expiresAt / expires_atyesyes
createdAt / created_atyesyes
isCurrent / is_currentyes (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​

ColumnTypeDescription
idUUID (PK)Session ID
user_idUUID (FK)Session owner
active_account_idUUID (FK)Currently selected account
token_hashTEXTSHA-256 hash of the refresh token's jti
ip_addressINETClient IP address (stored as inet, returned via host())
user_agentTEXTClient user agent string
expires_atTIMESTAMPTZSession expiry
created_atTIMESTAMPTZCreation timestamp

DB Functions​

FunctionDescription
create_sessionInsert a new session with token hash, IP, user agent, and expiry
list_sessions_for_userList 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_hashLookup by token hash (used during refresh)
rotate_session_token_hashUpdate token hash during refresh token rotation
delete_session_by_idDelete a specific session with user ownership check
set_session_active_accountSet/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_sessionsDelete all sessions except the specified one
delete_sessionDelete by token hash (used during logout)
delete_all_sessions_for_userDelete all sessions (used for account dissolution)

Key Files​

FilePurpose
apps/api/src/graphql/sessions.rsGraphQL queries and mutations
apps/api/src/graphql/auth.rsToken exchange and refresh (session creation)
apps/api/src/db/auth.rsSession CRUD and token hash operations
crates/lo-auth/src/jwt.rsJWT creation with embedded session ID
crates/lo-auth/src/api_key.rshash_token function (SHA-256 hashing)