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 /dashboard/tokens page — covers 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 whole /dashboard/tokens segment is wrapped in a FeatureRouteGate feature="feature:tokens" in its layout.tsx, so every page beneath it — including deep links — is closed when the plan lacks the feature.
  • 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, 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), never the account-owner fallback (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. Uses double-option semantics: absent = don't change, null = clear, value = set.
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 and the revoked_at / expires_at lifecycle, 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). This is because an unassigned popout token's acting-user identity falls back to the account owner, so the omitted-userId (NULL) binding now keys off the creator, never that owner fallback — a member can no longer mint an owner-bound popout via the NULL path. Passing an explicit userId for another account member stays a legitimate admin action (validated as an account member). 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
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 a raw lm_pop_* token 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.
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
labelOption<Option<String>>Double-option: absent = no change, null = clear, value = set
permissionsOption<[String]>Replace permissions list (absent = no change)
userIdOption<Option<UUID>>Double-option: absent = no change, null = unassign, value = assign. An assigned user must be a member or the owner of the account (same account-scoping check as create).
expiresAtOption<Option<String>>Double-option (RFC3339): absent = no change, null = clear expiry, value = set expiry
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), 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)

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 or revoked (revoked_at) tokens are rejected by the resolver 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).
  • 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 and the account's primary connection, 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 user_id is bound at create time and 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. Account-scoped surfaces a popout is meant to drive (extension secrets and billing, both keyed on account_id, which never falls back to the owner) stay open.

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).
    • If usable, authenticates the request with the token's account_id, user_id, and permissions.
  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 check is re-run per request against the origin token, so revoking or expiring a popout token also kills its 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