Skip to main content

Tokens

Overview​

The Tokens module manages popout tokens for dashboard popout pages and other browser-source contexts where cookie-based authentication is not practical. Popout tokens are non-expiring by default, but each token can optionally be given an expiry time or revoked (see the expires_at / revoked_at lifecycle fields). Each token has configurable permissions, an optional user assignment, and a label for identification. The full token is only shown once at creation time; afterward, only a prefix is stored for identification. Tokens are hashed before storage for security.

Popout tokens are one of Lumio's nine authentication types and carry the raw prefix lm_pop_. This module and the Access → Popout Tokens area (/dashboard/settings/channel/tokens) cover popout tokens (overlay_tokens table) exclusively. Do not confuse them with the other token types: User API Keys (AuthContext::ApiKey, api_keys table; the is_system = true rows are System Keys and are managed from the admin panel), Overlay Token (lm_overlay_*), Shared Overlay Token (lm_share_*), Extension Token (lm_ext_*) and Widget Token (lm_widget_*). Each has its own table, its own lifecycle, and its own feature documentation.

Architecture​

Backend​

  • GraphQL (apps/api/src/graphql/tokens.rs) -- Queries for listing tokens. Mutations for creating, updating, and deleting tokens.
  • Token Generation (crates/lo-auth/src/popout_token.rs) -- generate_popout_token() produces a cryptographically random token, its hash (for storage), and a prefix (for display).
  • Authentication -- Popout tokens are one of Lumio's authenticated token contexts. When a request includes a raw lm_pop_* token, the auth middleware hashes it and looks it up in the overlay_tokens table. The token's permissions are used for authorization.

Frontend​

  • The settings area gate checks tokens:read and feature:tokens in both the window list and GET /api/settings/sections/[id]. A deep link to a disallowed area receives a locked or no-access state; hiding its list entry alone does not authorize a read.
  • Token management page (token-list.tsx) listing all tokens as cards (token-card.tsx) with their labels, permissions, prefixes, assigned member, and an always-visible lifecycle status line derived in token-status.ts.
  • A single full-screen wizard (token-wizard.tsx, on the shared FullscreenWizard primitive) handles both create and edit via an isEdit switch - three steps: Identity (label + assignee), Permissions, Lifetime. Create is linear and ends on a one-time token reveal; edit lets you jump to any step and Save from anywhere.
  • Lifetime is chosen from presets (7 / 30 / 90 days, 1 year, Never, or a custom date) that resolve client-side to an absolute expires_at at end-of-day in the browser timezone (token-lifetime.ts, unit-tested); only the absolute timestamp is persisted.
  • The permission selector only offers permissions the creator holds (mirroring the backend subset rule); copy-to-clipboard for the full token at creation (shown only once).
  • Delete is a separate confirmation dialog, distinct from revoke.
  • Popout URLs use format: /popout/[view]?token=xxx.

API​

GraphQL Queries​

QueryPermissionDescription
popoutTokenstokens:read + feature:tokensList all popout tokens for the account. Returns id, accountId, userId, tokenPrefix, label, permissions, effectivePermissions (the stored scopes the bound member's current role still grants), createdAt, expiresAt, revokedAt, lastUsedAt. Never exposes the token hash.
myPermissionsAuthReturn the permissions of the current auth context - role-derived for a JWT user, the token's own scope for a popout token or API key. Never returns the ["*"] sentinel. REST twin: GET /v1/tokens/me.

GraphQL Mutations​

MutationPermissionDescription
createPopoutToken(input: CreatePopoutTokenInput)tokens:create + feature:tokens + first-party sessionCreate a new popout token. Returns token (the full string, shown only once) and popoutToken (the metadata). If no userId is provided, binds to the creator (the acting first-party user) - a popout token is always bound to an explicit user (see the first-party note below). An optional expiresAt sets the lifetime at creation; null/absent = never expires, and a past value is rejected (see below).
updatePopoutToken(input: UpdatePopoutTokenInput)tokens:edit + feature:tokensUpdate a token's label, permissions, user binding, expiry, or revocation state. A new user binding follows the same rule as create (self, or a member whose role does not exceed yours). Uses double-option semantics: absent = don't change, null = clear, value = set - except userId: null, which binds the token to the editor (see below).
deletePopoutToken(id: UUID)tokens:delete + feature:tokensDelete/revoke a popout token. Verifies account ownership. Returns DeleteTokenResult { success }.
exchangePopoutToken(token: String!)None - possession of a valid popout token is the authorizationExchange a raw lm_pop_* token for a 15-minute popout session JWT and return PopoutSession. Validates the prefix, lifecycle, and continued membership of any assigned user, then sets the session as an httpOnly cookie (lumio-popout-token, Path=/, SameSite=Lax). REST twin: POST /v1/auth/popout/exchange.

Every popout-token query and mutation is gated twice - by the feature:tokens plan flag and by the RBAC permission. GraphQL composes them as FeatureGuard::new("feature:tokens").and(PermissionGuard::new(…)); REST calls auth.require_permission(…) followed by require_feature(…, "feature:tokens", …). Both gates must pass on both protocols; only the order in which the two errors surface differs (GraphQL reports FEATURE_DISABLED first, REST reports the permission failure first).

Minting requires a first-party session (ZAF-1017 / ZAF-469). Creating a popout token - createPopoutToken / POST /v1/tokens - is itself a first-party action: the caller must be a logged-in user session or a user's own API key. A popout/overlay/widget/extension token is rejected with FORBIDDEN (403 on REST). A popout token is always bound to an explicit user (ZAF-1922). The token's bound user_id is its acting-user identity, and there is no implicit fallback to the account owner anywhere: an omitted userId (NULL) on create binds the creator, and an unassign on update ("user_id": null on PATCH /v1/tokens/{id}, userId: null on updatePopoutToken) binds the editor. Both need a first-party editor/creator - a popout/overlay/widget/extension token gets FORBIDDEN "This endpoint requires a logged-in user session" - and the resulting binding goes through the same binding check as an explicit assignment. A token row with no bound user (overlay_tokens.user_id IS NULL) resolves to nothing: the raw-token path, the popout exchange and the WebSocket credential re-check all treat it as invalid. Migration 20261007000001_bind_unassigned_popout_tokens bound every legacy unbound token to its account's owner at migration time (reversible; the touched ids are recorded in zaf1922_popout_token_owner_backfill); a token on an ownerless account stays unbound and fails closed. Binding to another member requires that every account permission on the target's role is in the caller's grantable set. The account owner counts as holding the full account permission registry. The personal API-key routes (/v1/api-keys, userApiKeys / createUserApiKey / updateUserApiKey / deleteUserApiKey) carry the same first-party gate for the same reason.

REST Endpoints​

All paths live under /v1/tokens. Bodies are snake_case and mirror the GraphQL inputs. The full token string is returned only from POST /v1/tokens and cannot be retrieved later.

MethodPathPermissionDescription
GET/v1/tokenstokens:readList popout tokens for the account (hash never exposed)
POST/v1/tokenstokens:createCreate a popout token; returns full token once. Accepts optional expires_at (RFC3339; null/absent = never expires; past value rejected)
PATCH/v1/tokens/{id}tokens:editUpdate label, permissions, user binding, expiry, or revocation state. "user_id": null binds the token to the editor (first-party session or own API key required)
DELETE/v1/tokens/{id}tokens:deleteRevoke a popout token
GET/v1/tokens/meAuthReturn the permissions granted to the current token (self-introspection). GraphQL twin: myPermissions
POST/v1/auth/popout/exchangeNone - the raw token is the credentialExchange an active lm_pop_* token whose assigned member still belongs to the account for a 15-minute popout session JWT (also set as the lumio-popout-token httpOnly cookie). GraphQL twin: exchangePopoutToken

POST /v1/auth/popout/exchange lives under the Auth tag rather than /v1/tokens, because it is the authentication step, not token management.

WebSocket​

There is no tokens:* WebSocket channel. A popout token reaches live data by exchanging itself for a popout session and then subscribing to the account-scoped channels its own permission scope allows (events:{account_id}, chat:{account_id}, …) - the channel gate in crates/lo-websocket/src/gate.rs evaluates the token's permissions exactly like a user's.

Input Types​

CreatePopoutTokenInput:

FieldTypeDescription
labelString?Human-readable label for the token
permissions[String]List of permission strings the token grants
userIdUUID?User to bind the token to (defaults to authenticated user). Must be a member or the owner of the account - a token cannot be bound to a user outside the account (rejected with FORBIDDEN), since the binding becomes the token's authenticated identity. Binding the token to another member additionally requires that every account permission of that member's role is one you hold yourself, and the account owner counts as holding every account permission - so only a caller holding the full permission set can bind a token to the owner. Otherwise both protocols return FORBIDDEN "Cannot bind a token to a member whose role exceeds your own permissions" (ZAF-1740 A1). Binding to yourself is always allowed.
expiresAtString?Optional lifetime (RFC3339). null/absent = never expires (the default). Must not be in the past - a back-dated value is rejected with VALIDATION_ERROR "expires_at cannot be in the past", identically on GraphQL and REST. Optional and additive: existing callers are unaffected.

UpdatePopoutTokenInput:

FieldTypeDescription
idUUIDToken ID to update
labelMaybeUndefined<String>Absent = no change, explicit null = clear, value = set. Explicit null and absent are distinct, as on REST.
permissionsOption<[String]>Replace permissions list (absent = no change)
userIdMaybeUndefined<UUID>Absent = no change, explicit null = unassign, which binds the token to the editor (a first-party user; a token-authenticated editor gets FORBIDDEN), value = assign. Explicit null and absent are distinct. An assigned user must be a member or the owner of the account. Binding to another member also requires the caller to hold every account permission on that member's role; the owner counts as holding the full account registry.
expiresAtMaybeUndefined<String>RFC3339. Absent = no change, explicit null = clear expiry (never expires), value = set expiry. Explicit null and absent are distinct, as on REST.
revokeOption<Boolean>true = revoke now, false = un-revoke, absent = no change

Permissions​

PermissionDescription
tokens:readView popout tokens (prefix, label, permissions)
tokens:createCreate new popout tokens
tokens:editUpdate token label, permissions, user binding
tokens:deleteDelete/revoke popout tokens

In addition to the permission, the account's plan must carry the feature:tokens flag - see the feature-gate note under GraphQL Mutations.

Subset rule. A token can never grant more permissions than its creator holds. services/token_permissions::ensure_within_creator_permissions runs on create and on any permission-replacing update, on both protocols, and returns the same ApiError (routed through to_graphql_error on GraphQL) so the message and code are identical.

Database​

TableDatabaseDescription
overlay_tokensPostgreSQLid, account_id, user_id (nullable column; a NULL row never resolves - every live token is bound), token_hash (sha256 hash), token_prefix (first N chars for display), label, permissions (text array), created_at, expires_at (nullable), revoked_at (nullable), last_used_at (nullable; written at most once a minute per token, so a polling overlay does not rewrite the row on every request)

Security​

  • The full token is generated using generate_popout_token() from lo-auth.
  • Only the hash is stored in the database; the full token cannot be recovered.
  • The full token is returned exactly once at creation time.
  • The token_prefix (first few characters) is stored for identification in the UI.
  • Tokens are non-expiring by default (for browser sources that run unattended), but an optional expires_at can be set. Expired, revoked (revoked_at), or member-bound tokens whose assigned member has left the account are rejected by raw-token resolution and exchange exactly like invalid ones, without disclosing which state applies.
  • A token's user_id becomes its authenticated popout identity, so it is validated against the account on create and update: only a member or the owner of the account may be bound (both protocols return FORBIDDEN otherwise). This prevents a member from minting a token bound to another user (ZAF-450 S1). Binding the token to another member additionally requires that every account permission of that member's role is one you hold yourself, and the account owner counts as holding every account permission - so only a caller holding the full permission set can bind a token to the owner. Otherwise both protocols return FORBIDDEN "Cannot bind a token to a member whose role exceeds your own permissions" (ZAF-1740 A1). Binding to yourself is always allowed.
  • A popout token must never act on the first-party user surface - the endpoints keyed on the acting user's own identity. Reading or editing the profile (/v1/users/me), listing/revoking sessions, listing/deleting login connections and flagged connections, linking/reconnecting an OAuth login provider (POST /v1/auth/link), managing login assignments, notifications (list, mark read, preferences, bulk read/delete, actions), the developer application, creating/dissolving/leaving an account, and minting a session JWT (POST /v1/auth/ws-token / issueWsToken, POST /v1/accounts / createAccount) all require a logged-in user session or a user's own API key. A popout/overlay/widget/extension token - whose bound user_id could be the account owner - is rejected with FORBIDDEN on both REST and GraphQL, so it can never read the owner's data, act as the owner, or obtain an owner-bound JWT (ZAF-450 S1 / ZAF-469).
  • expires_at may be set at creation (createPopoutToken / POST /v1/tokens) and there it must be in the future - a back-dated value is rejected with VALIDATION_ERROR. The update path (updatePopoutToken / PATCH /v1/tokens/{id}) intentionally allows a past expires_at, so back-dating an existing token remains a supported soft-revoke.
  • The same gate covers the extension-developer surface, which keys off the acting user's developer identity (ZAF-471). Developer profile, revenue and payout data; payout-settings changes and payout requests; developer limits and limit-increase requests; creating/submitting/deleting extensions and inviting testers; developer-team reads and management; and extension-access grants/invites all require a logged-in user session or a user's own API key - a popout/overlay/widget/extension token is rejected with FORBIDDEN on both protocols. Extension secrets belong to the extension, not to an account, so they sit behind the same developer-identity gate. The money-moving writes - payout-settings changes, payout requests, Stripe Connect onboarding and the billing checkout / portal / cancel mutations - are stricter still: they are session-only (ZAF-1750) and refuse a user's own API key too (see Authentication → Session-only Surfaces).

Data Flow​

  1. User creates a popout token, selecting which permissions it should grant.
  2. generate_popout_token() creates a random token, computes its hash, and extracts a prefix.
  3. The hash, prefix, permissions, and metadata are stored in overlay_tokens.
  4. The full token is returned to the user (shown once, must be copied).
  5. User adds the token to a popout URL: /popout/events?token=xxx.
  6. When the popout loads, the auth middleware:
    • Extracts the token query parameter.
    • Hashes it and looks up the hash in overlay_tokens.
    • Checks the lifecycle via popout_origin_active (not revoked, expires_at not passed) and verifies that any assigned member still belongs to the account.
    • If usable, authenticates the request with the token's account_id, its bound user_id, and permissions. A token with no bound user is rejected like an invalid one.
  7. Rather than resending the raw token on every request, the popout can exchange it once for a popout session (exchangePopoutToken / POST /v1/auth/popout/exchange). The session is a short-lived JWT stored as the httpOnly lumio-popout-token cookie and resolves back to AuthContext::PopoutToken with the origin token's permission subset. The lifecycle and assigned-member checks are re-run per request against the origin token, so revocation, expiry, or the assigned member leaving the account also ends live sessions.

Key Files​

PathDescription
apps/api/src/graphql/tokens.rsGraphQL queries and mutations
apps/api/src/routes/tokens.rsREST handlers under /v1/tokens
apps/api/src/db/tokens.rsDatabase operations for token CRUD
apps/api/src/services/token_permissions.rsCreator-subset, expiry and account-membership validation shared by both protocols
apps/api/src/services/popout_exchange.rsRaw token → popout session exchange
crates/lo-auth/src/popout_token.rsToken generation (random token, hash, prefix) and the popout_origin_active lifecycle gate
crates/lo-auth/src/jwt.rscreate_popout_session_token - mints the popout-session JWT
crates/lo-auth/src/context.rsAuthContext::PopoutToken - the popout authentication type