Skip to main content

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:

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 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​

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, 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:

  • 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, 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.

KindDefault system platformsDefault account platforms
channelTwitch, KickYouTube, Spotify
botTwitch, Kick, YouTube, Discordnone 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_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 and kick 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, 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 localhost → 127.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 (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 is SHA-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.
  • 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. a v3. row read by a binary that only knows v1/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 appCredentials query and its REST twin return decryptable: false for a row whose client_id no longer decrypts (the platform App section then shows an "action required" warning). authorizeChannel and the REST authorize twin refuse with the stable code CREDENTIAL_DECRYPTION_FAILED and 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 connection reconnect_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​

  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) plus a non-null decryptable flag (false when the stored client_id no longer decrypts under the current key). 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's static global token is not refreshed

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

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_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

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):

  1. Authorize — when requires_pkce, mint a 43-char base64url code_verifier, store it in Redis under oauth:pkce:{state} (600s TTL), and add code_challenge (base64url_nopad(sha256(verifier))) + code_challenge_method=S256 to the authorize URL. All six builders share platforms::build_authorize_url, so the parameters are identical across paths.
  2. Exchange — pkce::take(state) reads and deletes the verifier (single-use) and the token exchange sends it as code_verifier. A missing entry (non-PKCE platform, or expired/replayed state) yields None and no field is sent.

The verifier is a short-lived secret and is never logged.

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/platforms.rsPer-platform OAuth config (requires_pkce) + build_authorize_url
apps/api/src/routes/pkce.rsPKCE verifier mint/take + S256 challenge (RFC 7636)
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