Skip to main content

Channel Connections & OAuth Scopes

Channel connections are the mechanism by which Lumio accesses platform APIs on behalf of a user's channel. Each connection stores encrypted OAuth tokens and the scopes that were granted during authorization.

One Connection Per Platform​

Each account can have one channel connection per platform. This is enforced by a UNIQUE (account_id, platform) constraint on the channel_connections table. app_credentials and bot_connections carry the same constraint.

Platforms with a channel-connection OAuth config (get_oauth_config()): Twitch, YouTube, Kick, and Spotify. Discord and Shopify are known platforms too, but they have no channel_connections surface -- Discord is login + bot + integration, Shopify is integration only (PLATFORMS in apps/api/src/platforms.rs). Shopify's OAuth install is not a channel connection either: it stores its expiring offline token in integration_configs and requests the Admin API scopes read_orders,read_products,read_legal_policies from [shopify] scopes (see Shopify).

Discord's separate global-bot invite uses get_bot_oauth_config("discord") with scopes bot applications.commands identify, integration_type=0, and permissions bitmask 2147576832 (View Channels, Send Messages, Manage Messages, Embed Links, Read Message History, Use Application Commands). identify makes Discord return a verifiable server in the code exchange; the resulting user token is revoked and never stored. The callback is /api/connections/oauth/discord/callback. Discord has no channel app or account-owned bot app.

Channel Identity Columns: channel_name vs channel_login (ZAF-1461)​

channel_connections carries two name columns, set from the platform's channel-info fetch on connect:

  • channel_name — the display name (Twitch display_name, YouTube channel title, Kick/Spotify display). Shown in the UI. May be localized (こさむん).
  • channel_login — the login / handle (Twitch login, Kick slug). The URL-safe, lowercase spelling used for IRC JOIN, IRC-tag routing, the stream-history freeze, and profile URLs. NULL for platforms without a login concept (YouTube/Spotify).

Do not conflate them. A Twitch display_name can be non-ASCII, so JOIN #<display_name> is never honoured (to_lowercase is a no-op on CJK) — the bot goes silently absent while EventSub (keyed on broadcaster_user_id) keeps the channel looking healthy. Consumers read channel_login and fall back to channel_name only for legacy rows the boot backfill has not filled yet.

Backfill. Rows stored before P6 have a NULL channel_login. A best-effort boot backfill (backfill_missing_channel_logins) fills them from platform_chat_messages.channel_name (where the EventSub ingest already wrote broadcaster_user_login, so no Helix call is needed for any channel with chat history), then via Helix GET /users for the remainder. The upsert COALESCEs channel_login, so a corrected value survives every later reconnect.

Credential Source by Platform​

Channel connections are not all issued by the same OAuth app source. CredentialResolver chooses the source for a new connect, and the resulting value is stored on channel_connections.credential_source:

PlatformChannel defaultNotes
TwitchsystemUses twitch_channel_client_id / twitch_channel_client_secret when set; may fall back to the Twitch login pair because Twitch supports multiple redirect URIs per app.
KicksystemUses kick_channel_client_id / kick_channel_client_secret when set; falls back to the Kick login pair (kick_client_id / kick_client_secret) with a one-time warning when unset (ZAF-1493) — Kick accepts up to 10 redirect URIs per app, so the login app can also register the channel callback.
YouTubeaccountBYO Google Cloud OAuth client; quota, verification, and the 7-day Testing refresh-token rule stay with the customer app.
SpotifyaccountBYO Spotify app; Spotify Development Mode limits and registered-user rules stay with the customer app.

If a system platform has no global key pair configured, new connects fail safe to account and use the legacy per-account app_credentials path. Existing rows keep refreshing with whichever source minted them; changing [platform_credentials] affects only future connects.

OAuth Scopes by Platform​

Scopes are defined statically in apps/api/src/platforms.rs (get_oauth_config()). They are requested during the authorization flow. The Connections group of the settings window can request the current scopes again by reconnecting the same channel account; a disconnect is not required for an ordinary scope grant.

Twitch (33 scopes)​

ScopePurpose
channel:read:subscriptionsRead subscriber list and events
channel:read:redemptionsRead channel point redemptions
channel:manage:redemptionsManage channel point redemptions
channel:read:hype_trainRead hype train events
channel:read:pollsRead poll data
channel:manage:pollsCreate and manage polls
channel:read:predictionsRead prediction data
channel:manage:predictionsCreate and manage predictions
channel:read:goalsRead channel goals
bits:readRead cheer/bits events
moderator:read:followersRead follower list (used by ProfileService)
moderator:read:suspicious_usersRead suspicious user flags
moderator:manage:suspicious_usersUpdate suspicious user treatment
moderator:manage:banned_usersBan and unban users
channel:botSend chat messages as the channel bot
user:read:chatRead chat messages
channel:read:adsRead ad schedule
channel:manage:raidsCancel raids (Lumio does not start raids)
channel:moderateModerate chat (delete, timeout)
moderator:read:blocked_termsRead blocked terms list
moderator:read:chat_settingsRead chat settings
moderator:read:unban_requestsRead unban requests
moderator:read:banned_usersRead banned users list
moderator:read:chat_messagesRead chat messages as moderator
moderator:read:warningsRead user warnings
moderator:read:moderatorsRead moderator list
moderator:read:vipsRead VIP list
moderator:read:chattersRead the connected chatters (Helix GET /chat/chatters) for the chatter list
moderation:readRead the moderator list (Helix Get Moderators) — the chatter list's moderator group
channel:read:vipsRead the VIP list (Helix Get VIPs) — the chatter list's VIP group
moderator:manage:automodReceive held AutoMod messages and decisions through EventSub
moderator:manage:blocked_termsRequested for later blocked-term management; Lumio does not use it yet
moderator:manage:automod_settingsRequested for later AutoMod-level management; Lumio does not use it yet

Extra params: force_verify=true (forces re-authorization even if already granted)

Reconnect an existing Twitch channel once to grant these three scopes. The held-message queue needs moderator:manage:automod; the other two rights are requested in the same consent for future controls. Allow and Deny use the moderator's login grant instead, which also needs moderator:manage:automod: ConnectionStatus.loginMissingScopes / login_missing_scopes reports the login scopes the acting user's stored grant lacks (empty when unknown, nothing is missing or there is no grant; openid, email and profile are never reported), and a decision with a known scope set that lacks it fails with login_scope_missing before any Helix call. The backend's login-scope lists (TWITCH_LOGIN_SCOPES, GOOGLE_LOGIN_SCOPES, KICK_LOGIN_SCOPES in apps/api/src/platforms.rs) must match apps/id/src/lib/providers.ts; apps/api/tests/login_scopes_drift.rs fails on drift.

YouTube (2 required scopes, 1 optional scope)​

ScopePurpose
https://www.googleapis.com/auth/youtube.readonly (required)Read channel, video, and live chat data
https://www.googleapis.com/auth/youtube.force-ssl (required)Full YouTube Data API access (chat, comments)
https://www.googleapis.com/auth/yt-analytics-monetary.readonly (optional)Read estimated revenue for channels in the YouTube Partner Program

Google scopes are requested as full URIs, not short names. The YouTube own-app guide has six steps. It displays the two required Data API scopes and the optional Analytics monetary scope from channelOAuthScopes. Its final step accepts a Google web-client JSON file, validates its callback address in the browser and extracts the client ID and secret. Declining the optional scope keeps the channel connected and does not populate missing_scopes.

Extra params: access_type=offline (enables refresh tokens), prompt=consent (forces consent screen)

The 7-day "Testing" refresh-token cap

Channel connections use per-account BYO Google credentials - the customer's own Google Cloud OAuth client - so the customer's own consent-screen publishing status governs token lifetime. An External consent screen in publishing status "Testing" issues refresh tokens that expire after 7 days (the exception is name/email/profile-only scopes; because we request youtube.readonly + youtube.force-ssl, both sensitive, the rule always applies). When that refresh token dies the Token Refresh Worker sees invalid_grant, classifies it terminal, flags reconnect_required, and emails the owner - so in the product it looks like "YouTube keeps disconnecting every few days."

Fix (customer-side): set the OAuth client to "In production" (verification may be required for youtube.force-ssl), then reconnect once. Already-issued tokens stay 7-day-capped until reconnection. Check the OAuth client's publishing status before debugging the refresh path for a Google/YouTube "expires too fast" complaint. See Connections → Reconnect Flags.

Kick (8 scopes)​

ScopePurpose
user:readRead user profile information
channel:readRead channel information
events:subscribeSubscribe to real-time events
chat:writeSend chat messages
moderation:chat_message:manageDelete chat messages
kicks:readRead KICKs (kicks.gifted events, KICKs leaderboard)
channel:rewards:readRead channel-point rewards and redemptions (channel.reward.redemption.updated)
ads:readRead ad-break status for the stream-history revenue estimate; polled only for a live Kick stream and once at finalization

channel:rewards:write is not requested: Lumio shows channel-point redemptions but does not manage rewards. Kick does not document whether it ties webhook subscriptions to these scopes. A Kick connection missing ads:read must be reconnected once for ad estimates; missing_scopes lists it, and a provider 403 removes it from the stored grant so the reconnect notice returns. The ad-break status is polled, not delivered by a webhook.

Spotify (7 scopes)​

ScopePurpose
user-read-playback-stateRead current playback state
user-modify-playback-stateControl playback (play, pause, skip)
user-read-currently-playingRead currently playing track
playlist-read-privateRead private playlists
playlist-read-collaborativeRead collaborative playlists
playlist-modify-publicModify public playlists
playlist-modify-privateModify private playlists

The Spotify own-app guide has five steps: confirm the intended account has Premium, create the app with Lumio's callback, allow the connecting account in Development Mode, copy the keys, and enter them in Lumio. The guide reads scope names from channelOAuthScopes. Spotify authorization ends 180 days after consent even when access tokens refresh; ChannelConnection.authExpiresAt / REST auth_expires_at exposes that date, and the hub prompts reconnection during the last 14 days. Reconnecting renews the authorization while retaining the app credentials.

OAuth Connection Flow​

The complete flow from source resolution to active worker:

sequenceDiagram
participant User
participant Dashboard as Dashboard (Next.js)
participant API as API Server (Rust)
participant DB as PostgreSQL
participant Platform as Platform OAuth

Note over User,Platform: Step 1: Authorize Channel
User->>Dashboard: Click "Connect"
Dashboard->>API: Get authorize URL
API->>API: Resolve credential_source for (platform, channel)
alt system source
API->>API: Read global app from [auth] via CredentialResolver
API->>API: Mint unified state handle
else account source
API->>DB: Read encrypted app_credentials
API->>API: Mint legacy account state
end
API->>API: Build URL with scopes from get_oauth_config()
opt platform requires PKCE (OAuth 2.1, e.g. Kick)
API->>API: Mint code_verifier, store under oauth:pkce:{state} (600s)
API->>API: Add code_challenge + code_challenge_method=S256 to URL
end
API-->>Dashboard: Redirect URL
Dashboard->>Platform: Redirect to OAuth authorize
Platform->>User: Show consent screen
User->>Platform: Approve
Platform-->>API: Callback with authorization code

Note over API,DB: Step 2: Token Exchange
API->>API: Reuse the pinned source from state
opt platform requires PKCE
API->>API: Take code_verifier from oauth:pkce:{state} (single-use)
end
API->>Platform: Exchange code (+ code_verifier) for access_token + refresh_token
Platform-->>API: Token response
API->>Platform: Fetch channel info (name, ID)
Platform-->>API: Channel details
API->>API: Encrypt tokens with AES-256-GCM
API->>DB: Upsert into channel_connections with credential_source

Note over API: Step 3: Start Worker
API->>API: Start platform worker (EventSub, chat listener, etc.)

PKCE (OAuth 2.1)​

Providers that run OAuth 2.1 require PKCE (RFC 7636) on every authorize/exchange, even for confidential clients that send a client_secret. This is opt-in per platform via PlatformOAuthConfig::requires_pkce in apps/api/src/platforms.rs (currently true only for Kick, channel and bot). When set, the authorize step mints a 43-char base64url code_verifier, stores it in Redis under oauth:pkce:{state} (10-minute TTL, single-use), and puts the S256 code_challenge on the authorize URL (apps/api/src/routes/pkce.rs). The token exchange takes the verifier back and sends code_verifier. All six authorize builders share platforms::build_authorize_url, so the PKCE parameters cannot drift between the channel/bot/global-bot paths on GraphQL and REST. refresh_token grants never carry PKCE.

Scope Behavior​

Static Scopes​

Scopes are defined at compile time in get_oauth_config(). PlatformOAuthConfig.scopes contains the base scopes; optional_scopes contains grants a feature can work without. build_authorize_url requests both lists on every connection. Google may still let the user untick an optional scope on its consent screen. YouTube's monetary Analytics scope is optional, while its two Data API scopes are base scopes.

Changing Scopes​

When the requested scope list grows, existing connections keep their old grant. Their tokens keep working for the scopes they hold, but the features behind the new scopes stay off until the owner reconnects: the normal connect flow (authorizeChannel / GET /v1/connections/channel/{platform}/authorize) requests the full current list, and the OAuth exchange upserts the existing row with the grant the provider returned. A user may decline an optional Google scope. No disconnect is needed.

After a reconnect the platform worker restarts with the new token. For Kick, the worker lists the existing event subscriptions and subscribes to every event not yet subscribed (workers/kick.rs), so events that needed the new scopes start arriving without further action. A reconnect records account:connection_added like a first connect (see Audit Log).

Chatter-list scopes (one reconnect)​

The chatter list needs three Twitch scopes, added together so existing connections get exactly one reconnect prompt: moderator:read:chatters (Get Chatters), moderation:read (Get Moderators) and channel:read:vips (Get VIPs). The older moderator:read:moderators / moderator:read:vips are EventSub channel.moderate scopes and are not accepted by the two list endpoints. Until the owner reconnects, the list reports available = false, reason = "missing_scope" and shows only people who write; without the moderator/VIP scopes the groups fall back to chat badges. The poller uses the channel connection's token (via get_fresh_connection_token), never a login token.

Missing Scopes​

Every channel connection exposes missing_scopes: the base scopes the platform's connect flow requests today that the stored grant lacks, in request order. A non-empty list means "reconnect to grant these". Optional scopes never enter this list, so declining YouTube Analytics does not leave a permanent warning on the connection card or in the connection notices. The revenue area checks the stored grant separately and can show not_granted there.

channelOAuthScopes(platform) / GET /v1/connections/oauth-scopes/\{platform\} returns {platform, required, optional} with full provider names. required is the base list, optional is the optional list, and authorization requests both. Both protocols use feature:connections and connections:read and return Unsupported platform: X for an unsupported platform.

ProtocolFieldGuard
GraphQLchannelConnections { missingScopes } ([String!]!)feature:connections + connections:read (same as scopes)
RESTGET /v1/connections/channel → missing_scopes (string[])feature:connections + connections:read (same as scopes)

Both protocols compute it with the same helper, platforms::missing_scopes(platform, scopes), which returns the base get_oauth_config().scopes minus the stored grant. It is always a list, never null:

  • Nothing missing → [].
  • Grant never recorded (scopes is NULL or empty — legacy rows, or a token response without a scope field) → []. What such a token holds is unknown, so it is not reported as missing; this avoids a false "reconnect" on every old connection.
  • Platform without an OAuth config → [].

This is independent of reconnect_required, which flags a dead grant (terminal refresh failure). A connection with missing_scopes still works for everything its grant covers.

For optional features, platforms::grant_has_scope checks the actual stored grant; a NULL or empty grant does not prove that the optional scope is present. platforms::granted_optional_scopes records the optional scopes granted in the account:connection_added audit event's optional_scopes_granted metadata. See YouTube Analytics setup.

Scope Storage​

Granted scopes are stored in the scopes column of channel_connections as a nullable TEXT[] array. This records what was actually granted (which may differ from what was requested if the user denied specific scopes on platforms that support partial grants). bot_connections carries the same scopes TEXT[] column for the bot-side token. Extension chat moderation checks it before calling the provider: a Twitch bot row without moderator:manage:banned_users (ban, unban, timeout) or moderator:manage:chat_messages (delete), or a YouTube bot row without youtube.force-ssl, fails with bot_scope_missing without touching reconnect_required. An empty scopes value counts as unknown and is not blocked up front; a provider 401 then maps to the same code. The bot scope lists live in get_bot_oauth_config(), separate from the channel lists above.

Credential Requirements​

The required OAuth app source depends on the row's pinned credential_source:

SourceRequired for refreshToken row
accountapp_credentials for (account_id, platform)channel_connections
systemGlobal config pair from [auth] via CredentialResolver::system_pairchannel_connections

If an account-pinned connection loses its app_credentials, refresh cannot succeed. If a system-pinned connection has no global pair at refresh time, refresh fails clearly instead of silently using a different app.

If channel_connections are deleted, no API calls can be made for that platform.

Deleting app_credentials via the API is source-aware. deleteAppCredentials deletes an account-pinned channel connection together with the credentials it depends on. A system-pinned channel connection survives because it refreshes with the global Lumio app; only the stray account credential row is removed. Use disconnectChannel to drop only the channel connection and keep account credentials.

Refresh Failure Handling​

The Token Refresh Worker classifies every failed refresh (apps/api/src/oauth.rs, classify_refresh_response → RefreshError):

  • Terminal (invalid_grant / unauthorized_client, or HTTP 400/401) - the grant is dead. record_refresh_failure sets reconnect_required = true on the row (channel/login/bot), which stops the hourly retry (the candidate query skips flagged rows) and, on the first transition, emits an audit event and sends the reconnect email.
  • Transient (5xx / 429 / timeout / network, or an internal encrypt/persist error) - the refresh is retried on the 1-hour backoff; the flag is left untouched.

A reconnect never nulls a still-valid stored refresh token: upsert_connection uses refresh_token = COALESCE(EXCLUDED.refresh_token, channel_connections.refresh_token), matching bot_connections and the login path. See Connections → Reconnect Flags for the user-facing surface and the Google 7-day "Testing" rule.

Permissions​

OperationPermission
Connection queries (list credentials / connections / status)connections:read
saveAppCredentialsconnections:create
authorizeChannelconnections:create
deleteAppCredentialsconnections:delete
disconnectChannelconnections:delete

Connection permissions are granular (read / create / edit / delete); there is no connections:write.

Key Files​

FilePurpose
apps/api/src/platforms.rsget_oauth_config() -- scopes, URLs, extra params per platform; missing_scopes() -- requested minus granted scopes; PLATFORMS capability table
apps/api/src/db/connections.rsCRUD for app_credentials, channel_connections and bot_connections
apps/api/src/graphql/connections.rsGraphQL mutations for save/delete credentials, authorize, disconnect
apps/api/src/oauth.rsOAuthTokens, get_fresh_connection_token(), get_fresh_oauth_token(), get_fresh_bot_token() -- the only sanctioned refresh entry points
apps/api/src/crypto.rsAES-256-GCM encryption/decryption
apps/api/migrations/20260318000007_create_connections.up.sqlTable definitions