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:
| Provider | Endpoint | Audience field | Configured value it must match |
|---|---|---|---|
| Twitch | GET id.twitch.tv/oauth2/validate | client_id | auth.twitch_client_id |
GET oauth2.googleapis.com/tokeninfo | aud | auth.google_client_id | |
| Discord | GET discord.com/api/v10/oauth2/@me | application.id | auth.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
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
account_id | UUID | Owning account |
platform | String | Platform identifier (twitch, youtube, etc.) |
client_id | String | Encrypted platform client ID |
client_secret | String | Encrypted platform client secret |
created_at | Timestamp | Creation time |
updated_at | Timestamp | Last 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
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
account_id | UUID | Owning account |
platform | String | Platform identifier |
platform_channel_id | String? | Platform-specific channel/broadcaster ID |
channel_name | String? | Human-readable channel name |
access_token | String | Encrypted OAuth access token |
refresh_token | String? | Encrypted OAuth refresh token |
scopes | String[]? | Granted OAuth scopes |
expires_at | Timestamp? | Token expiration time |
credential_source | String | Pinned OAuth app source: system or account |
created_at | Timestamp | Creation time |
updated_at | Timestamp | Last 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:
primaryisconfig.client_id(orconfig.bot_tokenfor Discord), returned verbatim.- the second element is the client secret decrypted to plaintext: the reader reads
config.client_secret_encryptedand decrypts it withkey, so callers never decrypt themselves and can never forget to. It isNonewhen no secret is stored; a stored-but-undecryptable secret is a hard error, never a silentNone.
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.
| Kind | Default system platforms | Default account platforms |
|---|---|---|
channel | Twitch, Kick, Trovo | YouTube, Spotify |
bot | Twitch, Kick, Trovo, YouTube | none 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_modelooks up[platform_credentials]/[platform_credentials.bot]by platform key; any platform string already resolves. - The call sites.
source_forandsystem_pairtake the platform and kind as parameters, and every token consumer flows through them and through the source-aware refresh inworkers/token_refresh.rs, which keys off the row's pinnedcredential_sourcerather 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}/callbackURL 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
- Add global channel keys to
AuthConfig(apps/api/src/config.rs).google_client_id/google_client_secretare the login app (ID App / NextAuth), not a channel app, and Spotify has no config fields at all. Moving either channel tosystemneeds four newOption<String>fields following the existing*_channel_client_id/*_channel_client_secretconvention (e.g.google_channel_client_id+ secret andspotify_channel_client_id+ secret), each with adefault.tomlcomment. - Add the channel arms to
system_pair(apps/api/src/credentials.rs). It matches onlytwitch,kick, andtrovoand returnsNonefor everything else — thatNoneis what makessource_fordegrade YouTube/Spotify toaccount. Add ayoutubeand aspotifyarm. 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. - List Spotify in
degrade_warned(apps/api/src/credentials.rs). The one-time "selectedsystembut no global keys" warning only fires for platforms in thedegrade_warnedset (twitch,kick,trovo,youtube). Spotify is absent, so a misconfigured Spotifysystemselection degrades silently. The runtime behaviour is already correct — this is only the missing operator warning. - Extend the Spotify
localhost→127.0.0.1dev fix to the unified callback (apps/api/src/routes/oauth_state.rs). Spotify rejectslocalhostredirect URIs and requires127.0.0.1. That swap lives in theaccountauthorize branch (routes/connections.rs,graphql/connections.rs) but not inunified_callback_url, which thesystemflow uses. Without it, Spotify insystemmode fails locally only; production (nolocalhost) 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
systemYouTube 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
- 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 toaccount, and legacy rows without an explicit system pin, must useapp_credentials. - Token refresh follows the pinned
credential_source. Foraccount, the OAuth app pair comes fromapp_credentialsand the token row comes fromchannel_connectionsorbot_connections. Forsystem, the OAuth app pair comes from config and the refresh/access tokens still come fromchannel_connectionsorbot_connections. - ProfileService decrypts on demand. It receives pre-decrypted
ChannelCredentialsfrom the API layer and never stores them. - Secrets are never exposed via API. The GraphQL
appCredentialsquery and its REST twin return only aclient_id_hint(last 4 characters). Tokens are never included inchannelConnectionsresponses. - 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:
| Function | Source row | Credentials |
|---|---|---|
get_fresh_connection_token | channel_connections with a source-aware LEFT JOIN to app_credentials | system rows use CredentialResolver::system_pair(platform, channel); account rows decrypt per-account app_credentials |
get_fresh_oauth_token | login_connections | Returns empty client_id / client_secret — login connections use the config credentials, which the caller supplies |
get_fresh_bot_token | bot_connections with a source-aware LEFT JOIN to app_credentials | system 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:
- Workers check
needs_refresh()before each API call. It istrueonly when arefresh_tokenexists andtoken_needs_refresh(expires_at)holds — a 5-minute margin before expiry. ANoneexpiry is treated as "no refresh needed". - If refresh is needed,
try_refresh()sends a request to the platform's token endpoint viarefresh_oauth_token() - The new tokens are encrypted and persisted (
update_connection_tokens/update_oauth_tokens/update_bot_connection_tokens) - 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_idandclient_secretsent as POST body parameters - BasicHeader (Spotify):
base64(client_id:client_secret)sent asAuthorization: Basicheader
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
| File | Purpose |
|---|---|
apps/api/src/config.rs | AuthConfig with login and system channel/bot config credentials; PlatformCredentialsConfig overrides |
apps/api/src/crypto.rs | AES-256-GCM encrypt/decrypt + key derivation |
apps/api/src/db/connections.rs | CRUD for app_credentials and channel_connections |
apps/api/src/oauth.rs | OAuthTokens struct + token refresh logic |
apps/api/src/graphql/connections.rs | GraphQL resolvers (secrets masked in responses) |
apps/api/src/credentials.rs | CredentialResolver, fail-safe degradation, system/account mode exposure |
apps/api/src/platforms.rs | Platform OAuth configs, credential-source defaults, authorize URLs, token URLs, scopes |
crates/lo-chat/src/profile_service.rs | ChannelCredentials struct consumed by ProfileService |