OAuth & Credentials Architecture
Platform email claims do not establish ownership of a Lumio email sign-in credential. A platform can populate an empty profile email, but it does not overwrite an existing address and cannot auto-link a profile by email equality. Email sign-in uses its own confirmed credential record. The ID app completes any required 2FA challenge with an authenticator app, passkey or recovery code before creating the session or returning a PKCE authorization code. A passkey sign-in is already 2FA-verified.
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 = "..."
discord_client_secret = "..." # same login app as AUTH_DISCORD_SECRET in apps/id
kick_client_id = "..."
kick_client_secret = "..."
twitch_channel_client_id = "..."
twitch_channel_client_secret = "..."
kick_channel_client_id = "..."
kick_channel_client_secret = "..."
twitch_bot_client_id = "..."
twitch_bot_client_secret = "..."
kick_bot_client_id = "..."
kick_bot_client_secret = "..."
youtube_bot_client_id = "..."
youtube_bot_client_secret = "..."
discord_bot_client_id = "..."
discord_bot_client_secret = "..."
The plain provider pairs (twitch_client_id, google_client_id, discord_client_id, kick_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, 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, youtube_bot_client_id, discord_bot_client_id, and their secrets) are the global Lumio OAuth apps used by system bot connections or, for Discord, the verified server invite.
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.
Both Twitch and Kick channel credentials fall back to their login pair when the dedicated channel pair is unset. For Kick this fallback (ZAF-1493) logs a one-time warning so the operator registers a dedicated channel app — quota and suspension are scoped to the client_id. It is not a redirect-URI limit: Kick accepts up to 10 redirect URIs per OAuth app (verified in the Kick developer dashboard, 2026-09-23; Kick's public documentation does not state the limit), so the login app can also register the channel callback {web.public_url}/api/connections/oauth/kick/callback.
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, and Kick EventSub subscription caps are 10,000 subscriptions per event type per app. 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/lib/providers.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 is also paired with discord_client_secret for rotating Discord login-token refresh through services::login_token::get_provider_credentials.
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, YouTube, and Discord; Spotify has no bot connection. Discord's global bot token is static, but server invites use the separate global [auth].discord_bot_client_* app pair and store server-to-account mappings without per-account bot tokens. system OAuth 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, playlist sync, and server-side native Twitch emote fetches via get_twitch_emote_auth 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.
Shopify embedded app credentials
Shopify uses a separate public app configured by [shopify] (client_id, secret, scopes and App Store URL) and the matching per-environment apps/shopify/shopify.app.<env>.toml. It has no Lumio login provider, channel connection or authorization-code callback. The embedded page sends a short-lived App Bridge ID token to the REST-only /v1/shopify/embedded/* routes. Those routes verify the token as a Shopify staff-and-store principal, separate from AuthContext; the token cannot authorize Lumio account endpoints. After a link is confirmed in Lumio and completed in Shopify, the API exchanges a fresh ID token for the store's expiring offline access and refresh tokens. It encrypts those tokens in the store's integration_configs.config and refreshes them through services::shopify::fresh_access_token. See Shopify.
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 | YouTube, Spotify |
bot | Twitch, Kick, YouTube, Discord | none for supported bots |
The defaults can be overridden in config:
[platform_credentials]
twitch = "system"
kick = "system"
youtube = "account"
spotify = "account"
[platform_credentials.bot]
twitch = "system"
kick = "system"
youtube = "system"
discord = "system"
The flat [platform_credentials] keys are channel overrides. The nested [platform_credentials.bot] table is the bot override table.
Discord accepts only "system" in the bot table. An "account" override is ignored with a warning. platformCredentialModes includes its Bot row and reports systemConfigured only when both discord_bot_client_* keys are set. Without them the generic resolver degrades to account, but Discord's invite path rejects that fallback: there is no per-account Discord bot app.
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 and Kick 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 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 onlytwitchandkickand 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,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 (versioned registry): the configured secret derives a small key registry (
crypto::EncryptionKeys::from_secret), one AES-256 key per ciphertext version:- v1 (
crypto::derive_key) - legacy derivation: a config value of exactly 32 bytes is used raw, anything else isSHA-256-hashed. Reads every row written before versioning. Retained only for backward compatibility - because the 32-byte branch is used raw, changing the secret's length (31 vs 32 vs 33 bytes) silently changes v1 derivation, which is precisely why new writes use v2. - v2 (
crypto::derive_key_v2) -HMAC-SHA256(domain-salt, secret), a uniform KDF over the raw secret bytes. Length-invariant (no raw/hashed mode flip). Used for all new writes.
- v1 (
- Ciphertext format: new writes are version-tagged
v2.{base64(nonce)}.{base64(ciphertext)}; legacy rows are untagged{base64(nonce)}.{base64(ciphertext)}and read as v1. Standard base64 never contains., so part-count disambiguates cleanly (3 dot-parts ⇒ versioned, 2 ⇒ legacy v1). - Implementation:
apps/api/src/crypto.rs(encrypt_versioned/decrypt_versioned)
Encryption happens at the boundary -- values are encrypted before database writes and decrypted after reads. The database never stores plaintext credentials.
Typed decrypt failures (diagnosability)
Because ciphertext is version-tagged, a decrypt failure is typed rather than a bare AES-GCM error - this is what makes the ZAF-1196 confusion class ("undiagnosable aead::Error") diagnosable:
UnsupportedVersion- the row's tag is not in this build's registry (e.g. av3.row read by a binary that only knowsv1/v2).KeyMismatch- the row is well-formed (valid framing, base64, 12-byte nonce) but AEAD authentication failed: it was written under a different or older key than the one currently configured. This is "wrong/rotated key", diagnosably distinct from "corrupt data".InvalidFormat- malformed framing, bad base64, or wrong nonce length.
Rotating the key invalidates every stored secret
Versioning does not add old-key retention. Changing auth.token_encryption_key (or deploying a build that derives a different key from it) re-derives both the v1 and v2 keys, so every value ever encrypted under the old secret becomes unreadable at once - all app_credentials client_id/client_secret rows, and every access_token/refresh_token on channel_connections and bot_connections. The failure now surfaces as a typed KeyMismatch instead of a bare authentication error, but the data is equally unreadable.
Consequences and remediation:
- App credentials surface the broken state directly: the
appCredentialsquery and its REST twin returndecryptable: falsefor a row whoseclient_idno longer decrypts (the platform App section then shows an "action required" warning).authorizeChanneland the RESTauthorizetwin refuse with the stable codeCREDENTIAL_DECRYPTION_FAILEDand the message "Stored OAuth app credentials for {platform} can no longer be decrypted. Re-enter them in the platform tab’s App section." Remediation: re-enter the client_id/secret under Connections → App Credentials, which re-encrypts them under the current key. - Channel/bot tokens cannot be refreshed once their ciphertext (or their account's
app_credentials) fails to decrypt; the token-refresh worker treats it as terminal and flags the connectionreconnect_required(best-effort audit + email). Remediation: reconnect the affected channels.
So key rotation is only safe with a coordinated re-encrypt of every stored secret; there is no automatic migration path today.
Forward-only format - binary rollback hazard
The version tag is a one-way format upgrade, not a reversible flag. Once a build that writes v2. ciphertext has run in an environment, rolling the API binary back across this change makes those rows unreadable - the pre-versioning decrypt cannot strip the version tag and fails with a bare aead::Error. Treat a rollback past this change like a data migration: it requires re-encrypting the affected rows, not just redeploying the old binary. This is a deliberate BC/DR constraint of the versioned scheme (ZAF-1204). New writes are v2 and legacy untagged rows are never bulk-migrated - they keep reading via the v1 key indefinitely.
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) plus a non-nulldecryptableflag (falsewhen the storedclient_idno longer decrypts under the current key). 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's static global token is not refreshed |
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
The time-based Token Refresh Worker cannot see a token that is dead while its expires_at still lies in the future (revoked, or minted by an app other than the pinned one). For that case oauth::refresh_rejected_connection_token is the on-demand path a platform worker calls after a provider HTTP 401 (Twitch EventSub, Kick subscriptions). It takes the same per-connection lock as the worker, re-reads the token through get_fresh_connection_token (so the pinned credential_source decides the client), and refreshes only when the stored token is still the rejected one. A terminal refresh failure flags reconnect_required; a second 401 with the new token is flagged through oauth::flag_rejected_connection_token. Workers still never refresh inline.
Two authentication styles are supported (AuthStyle in the same module), resolved per platform together with the token endpoint by oauth::token_endpoint:
- Body (Twitch, YouTube, Kick):
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
Display name vs. login. The ID App resolves a distinct display name and login before the exchange: Twitch's OIDC claims expose only the lowercase login, so its profile() calls Helix GET /users (YouTube/Google does the analogous youtube/v3/channels lookup). find_or_create_user_by_provider then persists both — the login connection keeps display_name (visible) and username (login), and the users row carries both display_name and a dedicated login column (the login is COALESCE-protected on update, so a login flow that sends no username, e.g. the PKCE-native path, never wipes it). Render surfaces (sidebar, account switcher, profile) fall back to display_name when login is null. On a returning user the login update refreshes login from the platform, while the Lumio-visible display_name and avatar_url are mirrored from the user's name-source login connection. users.name_source_connection_id pins that source; when it is null, Lumio uses the first-ever-connected login connection. Users switch or clear the source through GraphQL setNameSourceConnection or REST PUT /v1/users/me/name-source-connection; PATCH /users/me and GraphQL updateMe do not accept a free-text display-name override.
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 --> HB{Caller is the session that started the connect?}
HB -->|no| HX[Reject - no row written]
HB -->|yes| 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
Starting a connect (authorize) requires an interactive sign-in session, and every exchange - the unified one for the system flow and the account/BYO alias exchanges (POST /v1/connections/channel/{platform}/exchange, POST /v1/bot-connections/{platform}/exchange, whose state values carry the same structured handle) - completes the channel / account-bot handle only for that same user's session (ZAF-1922 for the alias path), re-checking account access and connections:create / bot-connections:create before pkce::take and before any token is stored. An API key (lm_usr_) can neither start nor finish a connect. The admin global-bot handle is exempt (its return leg runs on the web host without an admin session; only an admin can mint it). See Authentication → Session-only Surfaces.
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
PKCE (OAuth 2.1)
OAuth-2.1 providers require PKCE (RFC 7636) on the authorize/token exchange even
for confidential clients that also send a client_secret. PKCE is opt-in per
platform via PlatformOAuthConfig::requires_pkce (apps/api/src/platforms.rs),
currently true only for Kick (channel and bot). It applies to every
outgoing authorize flow — channel, account bot, and admin global bot — on both
GraphQL and REST, and to the ID App login provider (next-auth's default
checks: ["pkce", "state"], pinned explicitly on the Kick provider).
The refresh_token grant never carries PKCE, so token refresh is unaffected.
Mechanics (apps/api/src/routes/pkce.rs):
- Authorize — when
requires_pkce, mint a 43-char base64urlcode_verifier, store it in Redis underoauth:pkce:{state}(600s TTL), and addcode_challenge(base64url_nopad(sha256(verifier))) +code_challenge_method=S256to the authorize URL. All six builders shareplatforms::build_authorize_url, so the parameters are identical across paths. - Exchange —
pkce::take(state)reads and deletes the verifier (single-use) and the token exchange sends it ascode_verifier. A missing entry (non-PKCE platform, or expired/replayed state) yieldsNoneand no field is sent.
The verifier is a short-lived secret and is never logged.
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/platforms.rs | Per-platform OAuth config (requires_pkce) + build_authorize_url |
apps/api/src/routes/pkce.rs | PKCE verifier mint/take + S256 challenge (RFC 7636) |
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 |