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 theoverlay_tokenstable. The token's permissions are used for authorization.
Frontend
- The whole
/dashboard/tokenssegment is wrapped in aFeatureRouteGate feature="feature:tokens"in itslayout.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 intoken-status.ts. - A single full-screen wizard (
token-wizard.tsx, on the sharedFullscreenWizardprimitive) handles both create and edit via anisEditswitch — 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_atat 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
| Query | Permission | Description |
|---|---|---|
popoutTokens | tokens:read + feature:tokens | List all popout tokens for the account. Returns id, accountId, userId, tokenPrefix, label, permissions, createdAt, expiresAt, revokedAt, lastUsedAt. Never exposes the token hash. |
myPermissions | Auth | Return 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
| Mutation | Permission | Description |
|---|---|---|
createPopoutToken(input: CreatePopoutTokenInput) | tokens:create + feature:tokens + first-party session | Create 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:tokens | Update 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:tokens | Delete/revoke a popout token. Verifies account ownership. Returns DeleteTokenResult { success }. |
exchangePopoutToken(token: String!) | None — possession of a valid popout token is the authorization | Exchange 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.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/tokens | tokens:read | List popout tokens for the account (hash never exposed) |
POST | /v1/tokens | tokens:create | Create a popout token; returns full token once. Accepts optional expires_at (RFC3339; null/absent = never expires; past value rejected) |
PATCH | /v1/tokens/{id} | tokens:edit | Update label, permissions, user binding, expiry, or revocation state |
DELETE | /v1/tokens/{id} | tokens:delete | Revoke a popout token |
GET | /v1/tokens/me | Auth | Return the permissions granted to the current token (self-introspection). GraphQL twin: myPermissions |
POST | /v1/auth/popout/exchange | None — the raw token is the credential | Exchange 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:
| Field | Type | Description |
|---|---|---|
label | String? | Human-readable label for the token |
permissions | [String] | List of permission strings the token grants |
userId | UUID? | 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. |
expiresAt | String? | 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:
| Field | Type | Description |
|---|---|---|
id | UUID | Token ID to update |
label | Option<Option<String>> | Double-option: absent = no change, null = clear, value = set |
permissions | Option<[String]> | Replace permissions list (absent = no change) |
userId | Option<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). |
expiresAt | Option<Option<String>> | Double-option (RFC3339): absent = no change, null = clear expiry, value = set expiry |
revoke | Option<Boolean> | true = revoke now, false = un-revoke, absent = no change |
Permissions
| Permission | Description |
|---|---|
tokens:read | View popout tokens (prefix, label, permissions) |
tokens:create | Create new popout tokens |
tokens:edit | Update token label, permissions, user binding |
tokens:delete | Delete/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
| Table | Database | Description |
|---|---|---|
overlay_tokens | PostgreSQL | id, 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()fromlo-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_atcan 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_idbecomes 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 returnFORBIDDENotherwise). 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 — whoseuser_idis bound at create time and could be the account owner — is rejected withFORBIDDENon 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_atmay be set at creation (createPopoutToken/POST /v1/tokens) and there it must be in the future — a back-dated value is rejected withVALIDATION_ERROR. The update path (updatePopoutToken/PATCH /v1/tokens/{id}) intentionally allows a pastexpires_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
FORBIDDENon both protocols. Account-scoped surfaces a popout is meant to drive (extension secrets and billing, both keyed onaccount_id, which never falls back to the owner) stay open.
Data Flow
- User creates a popout token, selecting which permissions it should grant.
generate_popout_token()creates a random token, computes its hash, and extracts a prefix.- The hash, prefix, permissions, and metadata are stored in
overlay_tokens. - The full token is returned to the user (shown once, must be copied).
- User adds the token to a popout URL:
/popout/events?token=xxx. - When the popout loads, the auth middleware:
- Extracts the
tokenquery parameter. - Hashes it and looks up the hash in
overlay_tokens. - Checks the lifecycle via
popout_origin_active(not revoked,expires_atnot passed). - If usable, authenticates the request with the token's
account_id,user_id, and permissions.
- Extracts the
- 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 httpOnlylumio-popout-tokencookie and resolves back toAuthContext::PopoutTokenwith 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
| Path | Description |
|---|---|
apps/api/src/graphql/tokens.rs | GraphQL queries and mutations |
apps/api/src/routes/tokens.rs | REST handlers under /v1/tokens |
apps/api/src/db/tokens.rs | Database operations for token CRUD |
apps/api/src/services/token_permissions.rs | Creator-subset, expiry and account-membership validation shared by both protocols |
apps/api/src/services/popout_exchange.rs | Raw token → popout session exchange |
crates/lo-auth/src/popout_token.rs | Token generation (random token, hash, prefix) and the popout_origin_active lifecycle gate |
crates/lo-auth/src/jwt.rs | create_popout_session_token — mints the popout-session JWT |
crates/lo-auth/src/context.rs | AuthContext::PopoutToken — the popout authentication type |