Skip to main content

Sessions

Overview

The sessions module provides user session management, allowing users to view their active sessions (with IP address, user agent, and expiry information) 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.

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.
  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 the session row's active_account_id and hand back a fresh JWT so the cookie reflects the new account. That replacement JWT is minted with create_token_with_session 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()), cached briefly in Redis, fail-open so a cache/DB blip never mass-logs-out valid users). 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.

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: a token minted without a session_id is structurally exempt from revocation and would survive a "log out everywhere" until its 24 h exp. (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

type Session {
id: UUID!
userId: UUID!
activeAccountId: UUID
ipAddress: String
userAgent: String
expiresAt: String!
createdAt: String!
}

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 shape differs from the GraphQL Session type — the two are not field-identical today:

FieldGraphQL SessionREST SessionResponse
idyesyes
userId / user_idyesno
activeAccountId / active_account_idyesno
ipAddress / ip_addressyesyes
userAgent / user_agentyesyes
expiresAt / expires_atyesyes
createdAt / created_atyesyes
is_currentnoyes — 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 even where the payload shape is not.

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.

Included in: Owner, Administrator, Moderator, Viewer roles.

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