Skip to main content

OAuth & Credentials Architecture

Lumio uses separate credential stores for login, channel, bot, and operator-managed integrations. Understanding which store minted a token is critical for anyone working on authentication, connections, or platform integrations: an OAuth refresh token can only be refreshed by the same OAuth app that issued it.

Credential Stores

1. Config credentials

Stored in config/local.toml under [auth] and overridable with LUMIO__AUTH__* environment variables:

[auth]
twitch_client_id = "..."
twitch_client_secret = "..."
google_client_id = "..."
google_client_secret = "..."
discord_client_id = "..." # id only — no secret; used for audience validation
kick_client_id = "..."
kick_client_secret = "..."
trovo_client_id = "..."
trovo_client_secret = "..."
twitch_channel_client_id = "..."
twitch_channel_client_secret = "..."
kick_channel_client_id = "..."
kick_channel_client_secret = "..."
trovo_channel_client_id = "..."
trovo_channel_client_secret = "..."
twitch_bot_client_id = "..."
twitch_bot_client_secret = "..."
kick_bot_client_id = "..."
kick_bot_client_secret = "..."
trovo_bot_client_id = "..."
trovo_bot_client_secret = "..."
youtube_bot_client_id = "..."
youtube_bot_client_secret = "..."

The plain provider pairs (twitch_client_id, google_client_id, kick_client_id, trovo_client_id, and their secrets) are login credentials for the ID App (NextAuth) and for login-token refresh. The channel-specific pairs (twitch_channel_client_id, kick_channel_client_id, trovo_channel_client_id, and their secrets) are the global Lumio OAuth apps used by system channel connections. The bot-specific pairs (twitch_bot_client_id, kick_bot_client_id, trovo_bot_client_id, youtube_bot_client_id, and their secrets) are the global Lumio OAuth apps used by system bot connections.

CredentialResolver resolves credentials per (platform, kind), where kind is channel or bot. system_pair(platform, kind) returns the app pair actually used to mint or refresh a token; system_configured(platform, kind) reports whether a dedicated app for that exact kind is configured. For bot rows, system_configured is false when only the channel keys exist, even though the runtime can still use the channel fallback.

Twitch channel credentials may fall back to the login pair when the channel pair is unset because Twitch allows multiple redirect URIs on one app. Kick and Trovo channel credentials cannot fall back to login credentials because each provider app accepts only one registered redirect URI.

Bot credentials prefer the dedicated bot pair. If a bot pair is unset, the bot flow falls back to the platform's channel pair and logs a one-time warning. If neither bot nor channel keys are available, source_for(platform, bot) degrades to account, matching the local/operator-managed path. YouTube bot has no channel fallback because YouTube channel connections are BYO/account; it needs youtube_bot_client_id / youtube_bot_client_secret to stay on system.

The split exists because provider limits attach to the OAuth client_id: Twitch app-access tokens allow 800 requests per minute per client_id; Kick EventSub subscription caps are 10,000 subscriptions per event type per app; Trovo allows 1,200 requests per minute shared by all clients using the same client_id. Keeping bot and channel apps separate gives chat bots their own quota and suspension surface.

YouTube and Spotify channel connections remain account / BYO, so there is no global channel system pair for them. YouTube bot has a bot OAuth mode and reads youtube_bot_client_id / youtube_bot_client_secret when configured; without those keys it degrades to the account path because there is no YouTube channel pair to fall back to.

Every provider field is Option<String> and defaulted, so a deployment can configure only the login providers it actually offers. The login scopes requested with them live in apps/id/src/auth.ts and are user-level — they are not the channel scopes and must not be trimmed on the assumption that they belong to the channel side.

Audience validation (ZAF-1015). When the API exchanges a provider access token for a Lumio session (POST /v1/auth/token, POST /v1/auth/authorize, GraphQL exchangeToken, POST /v1/auth/link), services::provider_validation::validate_provider_token checks two things, not one: the token's subject (the claimed provider user id) and its audience — the OAuth client the token was minted for. The audience is read straight from the provider's own response, so it needs no extra request:

ProviderEndpointAudience fieldConfigured value it must match
TwitchGET id.twitch.tv/oauth2/validateclient_idauth.twitch_client_id
GoogleGET oauth2.googleapis.com/tokeninfoaudauth.google_client_id
DiscordGET discord.com/api/v10/oauth2/@meapplication.idauth.discord_client_id

The check is fail-closed: if a provider's client id is unset/empty, that provider's token exchange returns an error (mapped to INTERNAL_SERVER_ERROR) instead of silently skipping the audience check. A foreign-audience token (an app other than ours) is rejected as an invalid credential (Unauthorized, audited user:login_failed), identically to a subject mismatch. This closes an OAuth confused-deputy / audience-confusion path to account impersonation. discord_client_id exists only for this check — Discord login tokens are not refreshed by the API, so there is no discord_client_secret.

Defined in: apps/api/src/config.rs (AuthConfig struct — which also holds jwt_secret, jwt_expiration, refresh_token_expiration, ws_token_expiration (WebSocket-token TTL, default 900s / 15 min; env LUMIO__AUTH__WS_TOKEN_EXPIRATION), and token_encryption_key)

2. Account app credentials

Stored in PostgreSQL and encrypted at rest with AES-256-GCM:

app_credentials table

ColumnTypeDescription
idUUIDPrimary key
account_idUUIDOwning account
platformStringPlatform identifier (twitch, youtube, etc.)
client_idStringEncrypted platform client ID
client_secretStringEncrypted platform client secret
created_atTimestampCreation time
updated_atTimestampLast update time

Used for: account-pinned channel connections, legacy/fail-safe account bot connections, and provider API calls whose client ID must match a per-account OAuth app.

channel_connections table

ColumnTypeDescription
idUUIDPrimary key
account_idUUIDOwning account
platformStringPlatform identifier
platform_channel_idString?Platform-specific channel/broadcaster ID
channel_nameString?Human-readable channel name
access_tokenStringEncrypted OAuth access token
refresh_tokenString?Encrypted OAuth refresh token
scopesString[]?Granted OAuth scopes
expires_atTimestamp?Token expiration time
credential_sourceStringPinned OAuth app source: system or account
created_atTimestampCreation time
updated_atTimestampLast update time

Used for: platform API calls made on behalf of the connected channel (EventSub, chat, profile enrichment, Spotify playback, etc.). The credential_source value is pinned at connect time and wins during refresh even if [platform_credentials] later changes.

Constraint: One connection per platform per account (UNIQUE (account_id, platform)) — the same constraint holds on app_credentials.

bot_connections table

The bot identity is a third row set, not a reuse of the channel connection. It has the same UNIQUE (account_id, platform) constraint and holds bot_type (global by default), bot_username, its own encrypted access_token / refresh_token / scopes / expires_at, and a pinned credential_source.

Global bot rows use the nil UUID account and are pinned to system for OAuth platforms. Account bot rows also carry their own pin. The compiled bot default is system for Twitch, Kick, Trovo, and YouTube; Discord uses a static admin bot token instead of OAuth, and Spotify has no bot connection. system bot rows use the dedicated bot app for their platform when configured; otherwise they use the channel fallback described above.

System-level credentials (integration_configs)

integration_configs is the operator-managed store for system connection records and integration configuration. It is not the source for global bot OAuth client credentials; those come from [auth] through CredentialResolver. The admin System Connections upsert — REST PUT /v1/admin/system-connections/{platform} and its GraphQL twin adminUpsertSystemConnection — stores config.client_id plus the AES-256-GCM-encrypted secret under config.client_secret_encrypted (the raw secret is never persisted or logged).

db::system_connections::get_system_credential(db, platform, key) is the single reader. It returns Some((primary, Option<client_secret>)) where:

  • primary is config.client_id (or config.bot_token for Discord), returned verbatim.
  • the second element is the client secret decrypted to plaintext: the reader reads config.client_secret_encrypted and decrypts it with key, so callers never decrypt themselves and can never forget to. It is None when no secret is stored; a stored-but-undecryptable secret is a hard error, never a silent None.

Consumers: the admin System Connections page and server-side native emote fetches (Twitch Helix via get_twitch_emote_auth, Trovo via its client id) in apps/api/src/services/emotes.rs. Global-bot OAuth authorize/exchange reads the config system pairs from CredentialResolver::system_pair(platform, bot), not integration_configs; a global bot token must be minted by the same configured bot app that refreshes it.

Credential Modes and Pinning

CredentialResolver resolves where a new connection gets its OAuth app for each (platform, kind) pair, where kind is channel or bot.

KindDefault system platformsDefault account platforms
channelTwitch, Kick, TrovoYouTube, Spotify
botTwitch, Kick, Trovo, YouTubenone for OAuth-capable bots

The defaults can be overridden in config:

[platform_credentials]
twitch = "system"
kick = "system"
trovo = "system"
youtube = "account"
spotify = "account"

[platform_credentials.bot]
twitch = "system"
kick = "system"
trovo = "system"
youtube = "system"

The flat [platform_credentials] keys are channel overrides. The nested [platform_credentials.bot] table is the bot override table.

If the configured/default mode is system but the required global keys are missing, CredentialResolver::source_for degrades the new connect to account and logs once. For channel, the required keys are the dedicated channel pair (with Twitch's login fallback). For bot, the required keys are the dedicated bot pair or, as a runtime safety net, the platform's channel pair. That fail-safe keeps local and operator-managed installs behaving like the historical BYO model. Once a connection is created, the chosen source is stored on channel_connections.credential_source or bot_connections.credential_source; changing the default later affects only future connects.

Moving a platform to system later

Today only Twitch, Kick, and Trovo channels use the global Lumio OAuth app; YouTube and Spotify channels stay account/BYO (see Config credentials and Credential Modes and Pinning above). The credential mode is pure config — [platform_credentials] plus the compiled defaults — so selecting system for another platform needs no code. But the resolver's global-app lookup and one dev-only callback path are still hard-coded to the three launch platforms, so a system selection for YouTube or Spotify would silently degrade back to account until they are filled in. This section records exactly what is already generic and what still needs a code change, so nobody has to re-derive it.

Already generic — no code needed

  • The config override. CredentialResolver::configured_mode looks up [platform_credentials] / [platform_credentials.bot] by platform key; any platform string already resolves.
  • The call sites. source_for and system_pair take the platform and kind as parameters, and every token consumer flows through them and through the source-aware refresh in workers/token_refresh.rs, which keys off the row's pinned credential_source rather than a hard-coded platform list.
  • The redirect URI. unified_callback_url(web_public_url, platform) (routes/oauth_state.rs) is built from the platform variable — the shared …/api/connections/oauth/{platform}/callback URL works for any platform.
  • The frontend. The connections UI renders from platformCredentialModes (GraphQL/REST), one row per (platform, kind); it is data-driven and needs no per-platform code.

Needs a code change

  1. Add global channel keys to AuthConfig (apps/api/src/config.rs). google_client_id / google_client_secret are the login app (ID App / NextAuth), not a channel app, and Spotify has no config fields at all. Moving either channel to system needs four new Option<String> fields following the existing *_channel_client_id / *_channel_client_secret convention (e.g. google_channel_client_id + secret and spotify_channel_client_id + secret), each with a default.toml comment.
  2. Add the channel arms to system_pair (apps/api/src/credentials.rs). It matches only twitch, kick, and trovo and returns None for everything else — that None is what makes source_for degrade YouTube/Spotify to account. Add a youtube and a spotify arm. YouTube may fall back to the login keys the way Twitch does (Google permits multiple redirect URIs per app); Spotify needs its own dedicated keys.
  3. List Spotify in degrade_warned (apps/api/src/credentials.rs). The one-time "selected system but no global keys" warning only fires for platforms in the degrade_warned set (twitch, kick, trovo, youtube). Spotify is absent, so a misconfigured Spotify system selection degrades silently. The runtime behaviour is already correct — this is only the missing operator warning.
  4. Extend the Spotify localhost127.0.0.1 dev fix to the unified callback (apps/api/src/routes/oauth_state.rs). Spotify rejects localhost redirect URIs and requires 127.0.0.1. That swap lives in the account authorize branch (routes/connections.rs, graphql/connections.rs) but not in unified_callback_url, which the system flow uses. Without it, Spotify in system mode fails locally only; production (no localhost) is unaffected.

Precondition that no code can satisfy

  • YouTube: the Data API quota is billed to the OAuth app's Google Cloud project, so a single global Lumio app means one daily quota shared across every customer — which is the whole reason YouTube stays BYO. A system YouTube channel is gated on a Google quota increase, not on the code above (ZAF-978 §11 step 4b).
  • Spotify: an app in Development Mode is capped at 25 users; serving all customers from one app needs Spotify's Extended Quota Mode, which requires an application to Spotify.

Existing connections do not migrate

Flipping a platform to system governs new connects only. Existing rows keep the credential_source pinned at connect time and refresh with whichever app issued them — a refresh token minted by app A cannot be refreshed by app B. There is no migration step; a full move to system means users reconnect once.

Encryption

All DB credentials are encrypted using AES-256-GCM with a random 96-bit nonce per encryption operation.

  • Key source: config.auth.token_encryption_key (TOML config)
  • Key derivation: If the config value is not exactly 32 bytes, it is hashed with SHA-256 to produce a 32-byte key (crypto::derive_key())
  • Ciphertext format: {base64(nonce)}.{base64(ciphertext)}
  • Implementation: apps/api/src/crypto.rs

Encryption happens at the boundary -- values are encrypted before database writes and decrypted after reads. The database never stores plaintext credentials.

Rules

  1. Config credentials are valid only for their source. Login config credentials are used for login and login-token refresh. System channel and bot config credentials are used for connections pinned to system. Connections pinned to account, and legacy rows without an explicit system pin, must use app_credentials.
  2. Token refresh follows the pinned credential_source. For account, the OAuth app pair comes from app_credentials and the token row comes from channel_connections or bot_connections. For system, the OAuth app pair comes from config and the refresh/access tokens still come from channel_connections or bot_connections.
  3. ProfileService decrypts on demand. It receives pre-decrypted ChannelCredentials from the API layer and never stores them.
  4. Secrets are never exposed via API. The GraphQL appCredentials query and its REST twin return only a client_id_hint (last 4 characters). Tokens are never included in channelConnections responses.
  5. Never refresh inline. Read a fresh token only through the three accessors in apps/api/src/oauth.rs; a worker must not run its own refresh against a platform token endpoint.

Reading a fresh token

All three accessors return the same FreshToken (access_token, refresh_token, client_id, client_secret, expires_at) and all three skip rows flagged reconnect_required = true, so a connection the user must re-authorize never yields a token:

FunctionSource rowCredentials
get_fresh_connection_tokenchannel_connections with a source-aware LEFT JOIN to app_credentialssystem rows use CredentialResolver::system_pair(platform, channel); account rows decrypt per-account app_credentials
get_fresh_oauth_tokenlogin_connectionsReturns empty client_id / client_secret — login connections use the config credentials, which the caller supplies
get_fresh_bot_tokenbot_connections with a source-aware LEFT JOIN to app_credentialssystem rows use CredentialResolver::system_pair(platform, bot); account rows decrypt per-account app_credentials; Discord returns empty client credentials

Token Refresh Flow

The OAuthTokens struct in apps/api/src/oauth.rs manages automatic token refresh:

  1. Workers check needs_refresh() before each API call. It is true only when a refresh_token exists and token_needs_refresh(expires_at) holds — a 5-minute margin before expiry. A None expiry is treated as "no refresh needed".
  2. If refresh is needed, try_refresh() sends a request to the platform's token endpoint via refresh_oauth_token()
  3. The new tokens are encrypted and persisted (update_connection_tokens / update_oauth_tokens / update_bot_connection_tokens)
  4. In-memory state is updated for the worker to continue

Two authentication styles are supported (AuthStyle in the same module):

  • Body (Twitch, YouTube, Kick, Trovo): client_id and client_secret sent as POST body parameters
  • BasicHeader (Spotify): base64(client_id:client_secret) sent as Authorization: Basic header

Flowcharts

User Login Flow (Config Credentials)

flowchart TD
A[User visits /login] --> B[ID App / NextAuth]
B --> C{Select platform}
C --> D[Redirect to platform OAuth]
D --> E[User authorizes]
E --> F[Platform callback with code]
F --> G[ID App exchanges code for token]
G --> H[Create/update user + login_connections]
H --> I[Issue JWT session]

style B fill:#3b82f6,color:#fff
style G fill:#f59e0b,color:#fff

subgraph credentials_used ["Config Credentials Used"]
direction LR
J["auth.twitch_client_id"]
K["auth.twitch_client_secret"]
end

Channel Connection Flow (Pinned Credentials)

flowchart TD
A[User clicks Connect] --> B{Resolve source for platform + channel}
B -->|system| C[Read global OAuth app from config]
B -->|account| D[Read encrypted app_credentials]
C --> E[Build authorize URL with scopes]
D --> E[Build authorize URL with scopes]
E --> F[Redirect to platform OAuth]
F --> G[User authorizes channel access]
G --> H[Callback with authorization code]
H --> I[Exchange code for access_token + refresh_token]
I --> J[Fetch channel info from platform API]
J --> K[Encrypt tokens]
K --> L[Store in channel_connections with credential_source]
L --> M[Start platform worker]

style C fill:#10b981,color:#fff
style L fill:#10b981,color:#fff
style M fill:#8b5cf6,color:#fff

subgraph db_credentials ["DB Credentials Used"]
direction LR
N["config system pair OR app_credentials"]
O["channel_connections.credential_source"]
P["channel_connections.access_token"]
end

ProfileService Enrichment Flow

flowchart TD
A[GraphQL: platformUserProfile query] --> B[API resolves account credentials]
B --> C[Decrypt client_id + client_secret from app_credentials]
C --> D[Decrypt access_token from channel_connections]
D --> E[Build ChannelCredentials struct]
E --> F[Call ProfileService.get_profile]
F --> G{Redis cache hit?}
G -->|Yes| H[Return cached profile]
G -->|No| I[Load from database]
I --> J{Enrichment stale?}
J -->|No| K[Cache with 4h TTL]
J -->|Yes| L{Circuit open?}
L -->|Yes| M[Cache with 15min TTL]
L -->|No| N[Call platform API with decrypted credentials]
N -->|Success| O[Merge enrichment + cache 4h]
N -->|Failure| P[Record failure + cache 30min]

style F fill:#3b82f6,color:#fff
style N fill:#f59e0b,color:#fff

Key Files

FilePurpose
apps/api/src/config.rsAuthConfig with login and system channel/bot config credentials; PlatformCredentialsConfig overrides
apps/api/src/crypto.rsAES-256-GCM encrypt/decrypt + key derivation
apps/api/src/db/connections.rsCRUD for app_credentials and channel_connections
apps/api/src/oauth.rsOAuthTokens struct + token refresh logic
apps/api/src/graphql/connections.rsGraphQL resolvers (secrets masked in responses)
apps/api/src/credentials.rsCredentialResolver, fail-safe degradation, system/account mode exposure
apps/api/src/platforms.rsPlatform OAuth configs, credential-source defaults, authorize URLs, token URLs, scopes
crates/lo-chat/src/profile_service.rsChannelCredentials struct consumed by ProfileService