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
- 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. - 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) rewrite the session row'sactive_account_idand hand back a fresh JWT so the cookie reflects the new account. That replacement JWT is minted withcreate_token_with_sessionand 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()), 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
| 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
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.
| 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 shape differs from the
GraphQL Session type — the two are not field-identical today:
| Field | GraphQL Session | REST SessionResponse |
|---|---|---|
id | yes | yes |
userId / user_id | yes | no |
activeAccountId / active_account_id | yes | no |
ipAddress / ip_address | yes | yes |
userAgent / user_agent | yes | yes |
expiresAt / expires_at | yes | yes |
createdAt / created_at | yes | yes |
is_current | no | 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 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
| 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 |
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 |
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) |