Auth
Overview
Lumio implements a multi-type authentication system with ten auth contexts: nine token-prefixed authenticated contexts plus Anonymous. Authentication is handled by the lo-auth crate (token generation, validation, RBAC) and the API layer (OAuth flows, session management, middleware).
Auth Types
| Type | Prefix | Use Case | Rate Limit |
|---|---|---|---|
| System Key | lm_sys_ | Internal service-to-service communication | Unlimited |
| User API Key | lm_usr_ | Personal, per-member external API access | 1200 req/min |
| Service Key | lm_svc_ | Account-owned key for CI/CD & integrations (survives member departure) | 1200 req/min |
| JWT | lm_ + eyJ... | Session-based auth after OAuth login | 600 req/min |
| Popout Token | lm_pop_ | Browser-source and dashboard popout access with a scoped RBAC permission set | 600 req/min |
| Overlay Token | lm_overlay_ | One-overlay browser-source WebSocket access | 600 req/min |
| Shared Overlay Token | lm_share_ | Time-limited shared overlay WebSocket access | 600 req/min |
| Extension Token | lm_ext_ | Short-lived extension iframe/runtime access | 600 req/min |
| Widget Token | lm_widget_ | One-widget browser-source WebSocket access | 600 req/min |
| Anonymous | (none) | Unauthenticated public access | 120 req/min |
Token type is automatically identified from the prefix via identify_token().
Architecture
Client Request
|
v
Auth Middleware (identify_token -> resolve AuthContext)
|
+-- lm_sys_* --> System Key lookup (config + DB, hash match)
+-- lm_usr_* --> User API Key lookup (DB, hash match)
+-- lm_svc_* --> Service Key lookup (account_service_keys, hash match)
+-- lm_share_* --> Shared overlay token lookup
+-- lm_overlay_* --> Overlay token lookup
+-- lm_ext_* --> Extension token lookup
+-- lm_widget_* --> Widget token lookup
+-- lm_eyJ* --> JWT validation (decode + verify)
+-- ?token=lm_pop_* --> Popout token lookup (DB, hash match)
+-- (none) --> Anonymous
|
v
AuthContext (enum: System | ApiKey | ServiceKey | User | PopoutToken | OverlayToken | SharedOverlayToken | ExtensionToken | WidgetToken | Anonymous)
|
v
Permission checks (PermissionGuard / require_permission)
|
v
Handler / Resolver
OAuth Login Flow
- User initiates login in the ID App (NextAuth-based).
- ID App handles OAuth with the provider (Twitch, YouTube/Google, Discord, Kick, Trovo).
- ID App calls the
exchangeTokenGraphQL mutation with:- Provider name and provider-specific user ID
- OAuth access token
- User profile (display_name, username, avatar_url, email)
- The API finds or creates the user via
find_or_create_user_by_provider. - A session is created in the database with a SHA-256 hash of the refresh token's
jti. - The session is cached in Redis.
- A JWT (short-lived) and refresh token (long-lived) are returned.
Token Refresh Flow
- Client sends expired JWT's refresh token to the
refreshTokenmutation. - Refresh token is validated and 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 (refresh token rotation). The session's
token_hashis updated atomically, invalidating the old refresh token.
Proxy-Level Auto-Refresh
All three Next.js apps (web, admin, ID) run a proxy.ts that intercepts every request and proactively refreshes the JWT when it is within 60 seconds of expiry. The proxy calls the GraphQL refreshToken mutation directly and sets updated cookies on the response. The user experiences no interruption.
If the API is unreachable during a refresh attempt, the proxy passes the request through without logging out — the page renders and shows a service-unavailable error instead.
Client-Side Refresh Timer
The web and admin apps run a useTokenRefresh() hook that proactively refreshes the JWT for long-lived pages (popouts, chat, dashboards left open overnight). After a successful refresh, it schedules the next refresh 5 minutes before the new token expires. The timer only fires when the browser tab is visible.
Logout Flow
- Client sends the refresh token to the
logoutmutation. - Session is deleted from PostgreSQL and Redis.
- Returns success even if the token was already expired.
API
GraphQL Queries
| Query | Args | Returns | Guard |
|---|---|---|---|
me | -- | MeResult! | AuthGuard |
myPermissions | -- | [String!]! | AuthGuard |
MeResult includes:
- User profile (
id,displayName,email,avatarUrl,createdAt) activeAccountId(nullable)accounts: [AccountMembership!]!(role, plan, owner status, owner avatar)permissions: [String!]!-- resolved account-scoped permissionsadminPermissions: [String!]!-- resolved admin-scope permissionsuserPermissions: [String!]!-- resolved user-scoped (cross-account, non-admin) permissions, e.g. Ideas Hubideas:moderate_*. This is the exact setAuthContext::require_user_permissionenforces, so the frontend can gate user-scoped controls on it without drifting from the backend. Account-independent (unchanged by an active-account switch). REST parity:user_permissionsonGET/PATCH /users/me.loginConnections: [LoginConnection!]!enabledFeatures: [String!]!featureStatuses: [FeatureStatusGql!]!-- merged account-scope flags plus the user-scopesystem:account_creationflagtoken: String-- present only whenupdateMeswitched the active accountownedAccountCount: Int!/maxAccounts: Int!-- multi-account ownership countersstreamerMode: Boolean!-- hides sensitive data in the UIisDeveloper: Boolean!-- has adeveloper_profilesentry or an admin overrideextensionDevMode: Boolean!-- load draft extension versions
GraphQL Mutations
| Mutation | Args | Returns | Guard |
|---|---|---|---|
exchangeToken | input: ExchangeTokenInput! | TokenResult! | None (public) |
refreshToken | refreshToken: String! | TokenResult! | None (public) |
logout | refreshToken: String! | LogoutResult! | None (public) |
logoutSession | -- | LogoutResult! | AuthGuard |
disconnectLoginConnection | loginConnectionId: UUID! | LogoutResult! | AuthGuard |
updateMe | input: UpdateMeInput! | MeResult! | AuthGuard |
ExchangeTokenInput
input ExchangeTokenInput {
provider: String! # "twitch", "discord", "google"
providerId: String! # Provider-specific user ID
accessToken: String! # OAuth access token from provider
profile: ProviderProfileInput!
}
input ProviderProfileInput {
displayName: String!
username: String
avatarUrl: String
email: String
}
TokenResult
type TokenResult {
token: String! # Lumio JWT (short-lived, lm_ prefix)
refreshToken: String! # Refresh token (long-lived)
expiresAt: String! # JWT expiration (ISO 8601)
isNewUser: Boolean! # First login ever
hasAccount: Boolean! # User has at least one account
}
REST Endpoints
Auth REST endpoints are public (no permission guard) unless noted. They live under /v1/auth.
| Method | Path | Description |
|---|---|---|
POST | /v1/auth/token | Issue a JWT for a dashboard login (ID App -> API handshake). |
POST | /v1/auth/refresh | Exchange a refresh token for a new JWT. |
POST | /v1/auth/logout | Invalidate the current refresh token/session. |
POST | /v1/auth/authorize | OAuth 2.0 authorize handshake for downstream clients. |
POST | /v1/auth/token/exchange | Exchange a provider OAuth token for a Lumio JWT. |
POST | /v1/auth/link | Link an additional provider identity to the authenticated user (JWT required). |
POST | /v1/auth/ws-token | Mint a short-lived WebSocket handshake token for the current principal. |
POST | /v1/auth/popout/exchange | Exchange a raw lm_pop_* token for the short-lived popout session cookie (see Popout Tokens). |
Bodies are application/json with snake_case fields. Response shapes match the GraphQL TokenResult / LogoutResult types.
User / Session Endpoints
These live under /v1/users/me and are covered in detail in Sessions and the Users resource.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/users/me | Auth | Current user profile + permissions |
PATCH | /v1/users/me | Auth | Update display name / avatar |
GET | /v1/users/me/login-connections | Auth | List provider identities |
GET | /v1/users/me/sessions | Auth | List active sessions |
DELETE | /v1/users/me/sessions/{id} | Auth | Revoke one session (ownership enforced in handler) |
DELETE | /v1/users/me/sessions | Auth | Revoke all other sessions for the current user |
AuthContext
The AuthContext enum is the core of the auth system, resolved by middleware and available in every handler/resolver:
enum AuthContext {
System { name, permissions },
ApiKey { user_id, account_id, label, permissions, rate_tier },
User { user_id, account_id, session_id, admin_permissions, account_permissions, user_permissions },
PopoutToken { account_id, user_id, label, permissions, token_hash },
OverlayToken { token_id, overlay_id, account_id },
SharedOverlayToken { token_id, overlay_id, account_id, expires_at },
ExtensionToken { token_id, extension_id, install_id, account_id },
WidgetToken { token_id, widget_id, account_id },
Anonymous,
}
Permission Resolution
- System -- Checked against the system key's own permission list (bypasses per-account scoping).
- API Key -- Checked against the key's assigned permissions.
- User (JWT) -- Admin-scope and account-scope permission lists are checked in order for the active account.
- Popout Token -- Checked against the token's custom permission list.
- Overlay / Shared Overlay / Extension / Widget Token -- No RBAC permissions; they only grant their own scoped runtime/WebSocket access.
- Anonymous -- Always denied.
Account roles are registry-validated against explicit resource:action strings; wildcard grants are not assignable through roles. Runtime wildcard matching only applies to permission lists that legitimately contain wildcards, such as configured system keys.
JWT Structure
Claims
| Field | Type | Description |
|---|---|---|
sub | UUID | User ID |
account_id | UUID? | Active account (can be switched) |
session_id | UUID? | References sessions table (JWT auth only) |
iat | i64 | Issued at (Unix timestamp) |
exp | i64 | Expiration (Unix timestamp) |
jti | UUID | Unique token identifier (UUID v7) |
All JWTs are prefixed with lm_ for type identification.
API Keys
System Keys (lm_sys_)
- Generated via
generate_system_api_key() - Provisioned through the Admin panel (System → System API Keys), stored in the
api_keystable withis_system = trueand no owning user/account. The auth middleware resolves them by hash against that table, falling back to any static keys loaded from TOML config. - Used by internal services and bots
- 32 random bytes, hex-encoded
- Stored as SHA-256 hash for verification
User API Keys (lm_usr_)
- Generated via
generate_user_api_key() - Created by users from Dashboard → API Keys
- Available when
feature:apikeysis enabled - Gated by
apikeys:readto view keys,apikeys:createto create keys,apikeys:editto rename, andapikeys:deleteto revoke keys - Personal to the creator within the active account: each key is bound to
(user_id, account_id), and users only manage their own keys - Member-bound lifetime: a personal key stops authenticating the moment its owner is no longer a member of the account. Removing the member (or the member leaving) purges their personal keys for that account, and the auth layer additionally refuses any
lm_usr_key whose owner is not a current member (or the owner) of the key's account. This is the deliberate contrast with account-ownedlm_svc_service keys below, which survive member departure. - Permission checkboxes can only select a subset of the creator's current permissions; the backend rejects any requested permission the creator does not hold
- Optional expiry can be set at creation time
- Display prefix shows the first 2 chars of the random part (e.g.,
lm_usr_ab) - Full key shown once on creation, only hash stored in DB
- Revoke hard-deletes the key
Service Keys (lm_svc_)
Account-owned service API keys for CI/CD pipelines and long-lived integrations that must keep working across team changes. Unlike personal lm_usr_ keys, a service key is owned by the account, not a member, and survives the departure of the member who created it.
- Generated via
generate_service_api_key() - Created from Dashboard → API Keys → Service Keys
- Available when
feature:apikeysis enabled (same flag andapikeys:*permission family as personal keys) - Gated by
apikeys:readto view,apikeys:createto create,apikeys:editto rename, andapikeys:deleteto revoke - Stored in the dedicated
account_service_keystable (neverapi_keys), scoped toaccount_id - Carries no member identity: the middleware resolves it to an account-scoped principal (
AuthContext::ServiceKey) with the key's own permission list;user_idisNone, so it keeps authenticating after any member — including its creator — is removed from the account created_byrecords the minting member only as a nullable audit pointer (ON DELETE SET NULL); removing that member sets it tonullbut never deletes the key- Permission checkboxes can only select a subset of the creator's current permissions; the backend rejects any requested permission the creator does not hold (same
ZAF-371subset guard as personal keys) - Optional expiry can be set at creation time
- Display prefix shows the first 2 chars of the random part (e.g.,
lm_svc_ab) - Full key shown once on creation, only hash stored in DB
- Rename changes the label only; permissions, key material and expiry are immutable once minted
- Revoke hard-deletes the key
- Managed on both protocols: GraphQL
accountServiceKeys/createAccountServiceKey/updateAccountServiceKey/deleteAccountServiceKeyand REST/v1/service-keys - Lifecycle emits account-scoped audit events
account:service_key_created/account:service_key_updated/account:service_key_revoked
Popout Tokens (lm_pop_)
- Generated via
generate_popout_token() - Long-lived by default, with optional
expires_atandrevoked_atlifecycle controls - Used for browser sources and dashboard popout windows
- Limited, configurable permissions
- 32 random bytes, hex-encoded, SHA-256 hashed for storage
- The raw token is exchanged once, at page load, for a short-lived (15-minute) popout session cookie (
lumio-popout-token); it never travels in a proxied API/WebSocket call. Proxy auth reads the session cookie only, so a?token=left in a URL can never downgrade an already-logged-in operator to token-scoped rights. See API authentication.
Login Connections
Login connections link OAuth provider identities to Lumio users. Each connection stores:
- Provider name and provider account ID
- Username, display name, avatar URL
- Encrypted OAuth tokens (access_token, refresh_token)
- Scopes and token expiry
Supported providers: Twitch, YouTube (via Google), Discord, Kick, Trovo.
Key Files
| File | Purpose |
|---|---|
crates/lo-auth/src/lib.rs | Public API re-exports |
crates/lo-auth/src/jwt.rs | JWT creation, validation, refresh token generation |
crates/lo-auth/src/context.rs | AuthContext enum with permission checks |
crates/lo-auth/src/api_key.rs | API key generation, hashing, type identification |
crates/lo-auth/src/popout_token.rs | Popout token generation and validation |
crates/lo-auth/src/rbac.rs | Permission constants, default roles |
crates/lo-auth/src/error.rs | Auth error types |
apps/api/src/graphql/auth.rs | GraphQL mutations (exchange, refresh, logout) and me query |
apps/api/src/db/auth.rs | User, session, and login connection DB operations |