Skip to main content

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

TypePrefixUse CaseRate Limit
System Keylm_sys_Internal service-to-service communicationUnlimited
User API Keylm_usr_Personal, per-member external API access1200 req/min
Service Keylm_svc_Account-owned key for CI/CD & integrations (survives member departure)1200 req/min
JWTlm_ + eyJ...Session-based auth after OAuth login600 req/min
Popout Tokenlm_pop_Browser-source and dashboard popout access with a scoped RBAC permission set600 req/min
Overlay Tokenlm_overlay_One-overlay browser-source WebSocket access600 req/min
Shared Overlay Tokenlm_share_Time-limited shared overlay WebSocket access600 req/min
Extension Tokenlm_ext_Short-lived extension iframe/runtime access600 req/min
Widget Tokenlm_widget_One-widget browser-source WebSocket access600 req/min
Anonymous(none)Unauthenticated public access120 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

  1. User initiates login in the ID App (NextAuth-based).
  2. ID App handles OAuth with the provider (Twitch, YouTube/Google, Discord, Kick, Trovo).
  3. ID App calls the exchangeToken GraphQL mutation with:
    • Provider name and provider-specific user ID
    • OAuth access token
    • User profile (display_name, username, avatar_url, email)
  4. The API finds or creates the user via find_or_create_user_by_provider.
  5. A session is created in the database with a SHA-256 hash of the refresh token's jti.
  6. The session is cached in Redis.
  7. A JWT (short-lived) and refresh token (long-lived) are returned.

Token Refresh Flow

  1. Client sends expired JWT's refresh token to the refreshToken mutation.
  2. Refresh token is validated and the session is looked up by token hash.
  3. A new JWT is issued with the same session ID.
  4. A new refresh token is issued with the remaining session lifetime (refresh token rotation). The session's token_hash is 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

  1. Client sends the refresh token to the logout mutation.
  2. Session is deleted from PostgreSQL and Redis.
  3. Returns success even if the token was already expired.

API

GraphQL Queries

QueryArgsReturnsGuard
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 permissions
  • adminPermissions: [String!]! -- resolved admin-scope permissions
  • userPermissions: [String!]! -- resolved user-scoped (cross-account, non-admin) permissions, e.g. Ideas Hub ideas:moderate_*. This is the exact set AuthContext::require_user_permission enforces, 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_permissions on GET/PATCH /users/me.
  • loginConnections: [LoginConnection!]!
  • enabledFeatures: [String!]!
  • featureStatuses: [FeatureStatusGql!]! -- merged account-scope flags plus the user-scope system:account_creation flag
  • token: String -- present only when updateMe switched the active account
  • ownedAccountCount: Int! / maxAccounts: Int! -- multi-account ownership counters
  • streamerMode: Boolean! -- hides sensitive data in the UI
  • isDeveloper: Boolean! -- has a developer_profiles entry or an admin override
  • extensionDevMode: Boolean! -- load draft extension versions

GraphQL Mutations

MutationArgsReturnsGuard
exchangeTokeninput: ExchangeTokenInput!TokenResult!None (public)
refreshTokenrefreshToken: String!TokenResult!None (public)
logoutrefreshToken: String!LogoutResult!None (public)
logoutSession--LogoutResult!AuthGuard
disconnectLoginConnectionloginConnectionId: UUID!LogoutResult!AuthGuard
updateMeinput: 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.

MethodPathDescription
POST/v1/auth/tokenIssue a JWT for a dashboard login (ID App -> API handshake).
POST/v1/auth/refreshExchange a refresh token for a new JWT.
POST/v1/auth/logoutInvalidate the current refresh token/session.
POST/v1/auth/authorizeOAuth 2.0 authorize handshake for downstream clients.
POST/v1/auth/token/exchangeExchange a provider OAuth token for a Lumio JWT.
POST/v1/auth/linkLink an additional provider identity to the authenticated user (JWT required).
POST/v1/auth/ws-tokenMint a short-lived WebSocket handshake token for the current principal.
POST/v1/auth/popout/exchangeExchange 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.

MethodPathPermissionDescription
GET/v1/users/meAuthCurrent user profile + permissions
PATCH/v1/users/meAuthUpdate display name / avatar
GET/v1/users/me/login-connectionsAuthList provider identities
GET/v1/users/me/sessionsAuthList active sessions
DELETE/v1/users/me/sessions/{id}AuthRevoke one session (ownership enforced in handler)
DELETE/v1/users/me/sessionsAuthRevoke 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

FieldTypeDescription
subUUIDUser ID
account_idUUID?Active account (can be switched)
session_idUUID?References sessions table (JWT auth only)
iati64Issued at (Unix timestamp)
expi64Expiration (Unix timestamp)
jtiUUIDUnique 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_keys table with is_system = true and 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:apikeys is enabled
  • Gated by apikeys:read to view keys, apikeys:create to create keys, apikeys:edit to rename, and apikeys:delete to 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-owned lm_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:apikeys is enabled (same flag and apikeys:* permission family as personal keys)
  • Gated by apikeys:read to view, apikeys:create to create, apikeys:edit to rename, and apikeys:delete to revoke
  • Stored in the dedicated account_service_keys table (never api_keys), scoped to account_id
  • Carries no member identity: the middleware resolves it to an account-scoped principal (AuthContext::ServiceKey) with the key's own permission list; user_id is None, so it keeps authenticating after any member — including its creator — is removed from the account
  • created_by records the minting member only as a nullable audit pointer (ON DELETE SET NULL); removing that member sets it to null but 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-371 subset 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 / deleteAccountServiceKey and 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_at and revoked_at lifecycle 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

FilePurpose
crates/lo-auth/src/lib.rsPublic API re-exports
crates/lo-auth/src/jwt.rsJWT creation, validation, refresh token generation
crates/lo-auth/src/context.rsAuthContext enum with permission checks
crates/lo-auth/src/api_key.rsAPI key generation, hashing, type identification
crates/lo-auth/src/popout_token.rsPopout token generation and validation
crates/lo-auth/src/rbac.rsPermission constants, default roles
crates/lo-auth/src/error.rsAuth error types
apps/api/src/graphql/auth.rsGraphQL mutations (exchange, refresh, logout) and me query
apps/api/src/db/auth.rsUser, session, and login connection DB operations