Connections
Overview
The Connections module manages platform OAuth connections for Lumio accounts. It handles app credentials when a platform needs the account's own OAuth app, channel connections that store the channel access/refresh tokens, and credential modes that tell the dashboard whether a new connection uses Lumio's global OAuth app for that (platform, kind) pair or account-owned credentials. The module supports CSRF protection via Redis-stored state handles, automatic token refresh, and encrypted credential storage. All secrets are encrypted at rest using AES-256 derived from a master key.
Public stats for non-connected channels are gathered without any OAuth connection by the Public Stats Crawler (anonymous Twitch IRC read + app-token Helix polling). A connection is only ever created for a channel a Lumio account explicitly links here.
Login Connection Assignments
Login connections (Twitch, YouTube, etc.) are managed at the user level. Each user can have multiple login connections per platform. When a user is a member of multiple Lumio accounts, they can assign which login connection to use on each account.
This allows scenarios like:
- A creator with separate streaming channels assigns different Twitch accounts to different Lumio accounts
- A moderator assigns their personal Twitch login to the accounts they moderate on
Assignments are managed from the Profile page (user-centric view) or the Account Management page (account-centric view).
Database
Assignments are stored in the account_login_assignments table, which maps a login connection to a specific account for a specific user:
| Column | Type | Description |
|---|---|---|
account_id | UUID (FK) | Account the connection is assigned to |
user_id | UUID (FK) | Owner of the login connection |
login_connection_id | UUID (FK) | The login connection being assigned |
provider | TEXT | Platform provider (e.g. twitch, google) |
Permissions
| Permission | Description |
|---|---|
login-assignments:read | View login connection assignments for all users on the account. Own assignments are always visible without this permission. |
login-assignments:create | Assign a login connection on behalf of another user. Own assignments are always allowed. |
login-assignments:delete | Remove a login assignment on behalf of another user. Own assignments are always removable. |
API
| Protocol | Operation | Permission |
|---|---|---|
| REST | GET /v1/accounts/login-assignments | login-assignments:read (own always visible) |
| REST | POST /v1/accounts/login-assignments | login-assignments:create (own always allowed) |
| REST | DELETE /v1/accounts/login-assignments/{provider} | login-assignments:delete (own always allowed) |
| REST | PUT /v1/accounts/primary-connection | Owner only |
| REST | GET /v1/users/me/login-connections | Authenticated |
| REST | DELETE /v1/login-connections/{id} | Authenticated (own connection) |
| GraphQL | setPrimaryLoginConnection(loginConnectionId: UUID, accountId: UUID!) | Owner only |
| GraphQL | disconnectLoginConnection(loginConnectionId: UUID!) | AuthGuard |
Login assignments are also broadcast on the WebSocket channel
login-assignments:{key}, which is gated Public — it carries no credential
material, only the fact that an assignment changed, so a connected dashboard
can refetch.
Restrictions
A login connection that is set as the primary connection for an account cannot be disconnected or unassigned. The user must first change or clear the primary connection before removing the assignment.
Reconnect Flags
All three connection tables (channel_connections, login_connections, and bot_connections) have a reconnect_required boolean column (default false).
When reconnect_required is set to true on a connection:
- The Token Refresh Worker skips the connection entirely during its refresh cycle (it no longer retries a dead grant every hour).
- All token retrieval functions (
get_fresh_connection_token,get_fresh_bot_token,get_fresh_oauth_token) return aRowNotFounderror for the flagged connection, causing platform workers that depend on it to stop gracefully. - Users see a "Reconnect Required" banner in the dashboard with a direct reconnect button.
- The
reconnect_requiredfield is exposed on the connection APIs (GraphQLChannelConnection.reconnectRequired, RESTreconnect_requiredon/v1/connections/channel), so the dashboard shows a "Reconnect required" state rather than treating a lapsed 1-hour access token as an error. - The connection-status overview that feature surfaces read (Music control, Events, Multichat — including their popouts) also carries the health signal:
ConnectionStatus(GraphQLconnectionStatuses, RESTGET /v1/connections/status) exposesreconnectRequiredandexpiresAt, and itsisConnectedis health-aware — a flagged connection reportsisConnected: falseso a dead channel never renders a healthy-looking control that then silently fails. To tell "no connection" from "flagged connection", readreconnectRequired(a connection exists iffisConnected || reconnectRequired). Popout tokens carryingconnections:readcan read this overview; the first-party-onlyflaggedConnectionsquery is unaffected.
Login-grant health
The Multichat sends with the acting user's personal login grant (twitch→twitch, youtube→google, kick→kick, trovo→trovo), not the account's channel connection, so connectionStatuses (GraphQL) / GET /v1/connections/status (REST) additionally expose that grant's health per platform, separately from the channel signal:
loginConnected(login_connected) —truewhen the user's login grant for this platform (viaaccount_login_assignments) exists and is healthy.loginReconnectRequired(login_reconnect_required) —truewhen that login grant exists but is flaggedreconnect_required; the send will fail until the user reconnects it.
Both are false when there is no login grant, no user context (fail-closed — an unknown grant is never reported connected), or the platform is not sent-to via a login grant (e.g. Spotify). The channel signals (isConnected / reconnectRequired) are unchanged — the login fields are additive, so a dead login grant never mutates the channel's connected state. This is the data source the Multichat reads to warn before the first send attempt, and it rides the same popout-readable overview (no new guard).
Where the reconnect state is shown
The dashboard makes the reconnect-required state visible everywhere a dead connection would otherwise fail silently — for all platforms (Twitch, YouTube, Kick, Trovo, Spotify) and for login connections alike:
- Account → Profile · Login connections — a flagged login shows a "Reconnect required" badge (instead of the default green Connected badge), an inline error on the affected connection card with a highlighted Reconnect button, and a page-level reconnect banner. A healthy login is unchanged.
- Music control (dashboard and popout) — a flagged Spotify grant shows a distinct "Reconnect Spotify" state with a reconnect button (the dashboard) or read-only notice (the popout), separate from the "never connected" hint. This is unrelated to the transient "Connection lost — reconnecting…" WebSocket notice.
- Events (dashboard and popout) — the per-platform stats row (viewers / likes / views) shows a reconnect-required error pill for a flagged platform, and keeps showing it even while that channel is offline (a dead OAuth grant is exactly when nothing is online). The pill is the platform logo plus a red warning triangle, with the platform name and the expiry in its tooltip.
- Multichat (dashboard and popout) — the flagged platform keeps its tab and shows a red warning triangle in place of the live counts, with a short tooltip naming the platform. This is distinct from, and not merged with, the WebSocket
connectionLoststate.
Both markers are deliberately icon-only: they mark a platform as degraded, while the wording and the repair action live in the sticky alert described next.
Sticky expired-connection alert (Multichat and Events)
Because Multichat and Events are fullscreen surfaces, they render no page-level dashboard banner. Instead, whenever at least one channel connection is flagged, both show an error alert pinned directly under their topbar:
- It sits in the normal document flow, so it shortens the feed rather than covering messages or events, and it is not dismissible — the connection is genuinely broken until it is repaired.
- Like the other header chrome it is hidden when a popout runs with its header turned off, so a browser source used in a stream never composites a dashboard alert into the broadcast.
- It carries one row per flagged platform: the platform logo, an explanation, and a Reconnect button that starts the OAuth flow for that platform in place (
authorizeChannel→ provider redirect), rather than merely linking to the connections page. From a first-party popout window the flow opens in a new tab so the docked window is not navigated away. - Visibility is
connections:create— the permission theauthorizeChannelmutation itself is guarded on, so the button can never 403. Among the default roles that is Owner and Administrator; a Moderator (who holds onlyconnections:read) still sees the per-tab warning marker but is not shown a repair action they cannot perform. - When the alert is visible, Multichat's send-path reconnect banner drops its channel rows to avoid warning about the same connection twice; login-grant rows are unaffected and always shown, because the sticky alert does not cover the personal login grant.
The feature-surface indicators read the popout-token-readable connectionStatuses overview, so the state is visible in browser-source popouts without loosening the first-party flaggedConnections guard. A degraded feature indicator is not dismissible — only the global dashboard banner can be temporarily hidden.
Automatic detection
The Token Refresh Worker classifies every failed refresh as terminal or transient:
- Terminal — the provider rejected the grant itself: an OAuth2 error of
invalid_grant/unauthorized_client, or HTTP 400 / 401 (arefresh_tokengrant only 400/401s when the refresh token or client authorization is no longer valid). The worker setsreconnect_required = trueon the affected row (channel / bot / login). - Transient — a retryable failure (provider 5xx, 429 rate-limit, timeout, network error, or an internal encrypt/persist hiccup). The worker leaves the flag untouched and retries on the existing 1-hour backoff.
The worker also treats two conditions the refresh HTTP call never even reaches as terminal — previously both were skipped silently, leaving the dead grant invisible until the first failed send:
- Undecryptable stored token — ciphertext that no longer authenticates under the encryption key (a key mismatch / corruption,
aead::Error), not an expired token. It can never be refreshed, so the row is flagged. - Expired login grant with no refresh token — e.g. a Google grant that only returned a refresh token on the first consent. Once its access token has expired it can never be renewed, so a dedicated worker pass flags it.
The Multichat send path acquires the sender's login token through the same terminal-aware chokepoint: an undecryptable, already-flagged, or expired-without-refresh grant is flagged reconnect_required, fires the same audit + email, and returns a typed login_reconnect_required result instead of a raw error string (see Chat).
On the terminal false → true transition (and only then, so it fires once) the worker:
- Emits an audit event —
account:connection_reconnect_required(channel/bot, account scope) oruser:login_reconnect_required(login, user scope), withplatform+connection_typein the metadata. - Sends the reconnect email (
lo_emailReconnectRequiredtemplate) to the account owner.
The flag is automatically cleared when the user reconnects through any of the standard flows:
- Login callback (re-authentication clears the flag on the login connection)
- Channel OAuth upsert (reconnecting the channel clears the flag)
- Bot OAuth upsert (reconnecting the bot clears the flag)
Admins can also set or clear the flag manually via the admin panel for any connection type. This is useful for forcing a user to re-authorize after a token leak, scope change, or platform-side revocation.
Google / YouTube: the 7-day "Testing" rule
The most common "my connection keeps expiring every few days" support case is not a bug in our refresh path. Channel connections use per-account BYO Google credentials (the customer's own Google Cloud OAuth client), so Google-side settings decide token lifetime. If the customer's OAuth consent screen is External with publishing status "Testing", Google issues refresh tokens that expire after 7 days (the exception is name/email/profile-only scopes; we request youtube.readonly + youtube.force-ssl, so the rule always applies). When that refresh token dies, our worker classifies it as terminal invalid_grant, flags reconnect_required, and emails the owner.
Fix (customer-side): set the OAuth client to "In production" (verification may be required for youtube.force-ssl), then reconnect once. Before debugging the refresh path for a Google/YouTube complaint, check the OAuth client's publishing status first.
Admin API
| Method | Path | Description |
|---|---|---|
PUT | /v1/admin/channel-connections/\{id\}/reconnect-flag | Set/clear reconnect flag on a channel connection |
PUT | /v1/admin/bot-connections/\{id\}/reconnect-flag | Set/clear reconnect flag on a bot connection |
PUT | /v1/admin/login-connections/\{id\}/reconnect-flag | Set/clear reconnect flag on a login connection |
Request body: { "reconnect_required": true } or { "reconnect_required": false }.
Unified OAuth callback (single redirect URI)
Kick and Trovo let an OAuth app register only one redirect URI. Once
Twitch/Kick/Trovo move to a global Lumio OAuth app (one app per platform), the
three non-login flows — channel connection, account bot, and admin global bot —
can no longer each register their own .../callback. They collapse onto one
web-host redirect URI per platform:
{web.public_url}/api/connections/oauth/{platform}/callback
Which flow a callback belongs to is not in the URL. It lives in a
server-side state handle minted at the authorize step:
OAuthStateHandle { kind ∈ {channel, bot, global_bot}, account_id, platform, return_to, actor_user_id },
stored in Redis under an unguessable single-use nonce with a 10-minute TTL. The
nonce is what travels through the provider as the state parameter.
The browser callback (Next.js) forwards code + state to the unified exchange
POST /v1/connections/oauth/{platform}/exchange, which:
- Redeems the handle (single-use: deleted on read).
- Validates
return_tofail-closed against the[web.public_url, admin.public_url]allowlist by comparing URL origin (scheme + host + port) — a freereturn_towould be an open redirect. - Dispatches on
kindinto the shared channel / account-bot / global-bot completion logic (the same functions the legacy per-flow exchanges call), and - Returns the absolute, marker-appended redirect URL (
data.redirect_url) for the browser to follow (?connected=for channel/bot,?bot_connected=for the admin global bot).
The unified exchange is deliberately unauthenticated: the one-time,
account-bound handle minted by the authenticated authorize step is the
authorization. Requiring a caller JWT would break the admin global-bot flow,
whose browser session on the web host is never an admin session — the global bot
starts in the admin app, returns through the web host, and return_to sends the
browser back into the admin providers UI.
Fail-safe / prod-gated. The unified flow only activates for a system-sourced
connect, which requires global OAuth keys. Without them (local, dev, staging,
operator-managed installs) every connect degrades to account and keeps using the legacy
per-flow callbacks and exchanges, which remain as aliases for account/BYO
setups and in-flight authorizations.
Credential modes
The backend exposes the effective platform policy through platformCredentialModes and GET /v1/connections/credential-modes. The dashboard reads this surface instead of hardcoding which platforms need a customer OAuth app.
| Platform | Channel mode | Dashboard effect |
|---|---|---|
| Twitch | system by default | Connect can use Lumio's global app when keys are configured; otherwise it falls back to account credentials. |
| Kick | system by default | Connect can use Lumio's global app when keys are configured; otherwise it falls back to account credentials. |
| Trovo | system by default | Connect can use Lumio's global app when keys are configured; otherwise it falls back to account credentials. |
| YouTube | account | The account supplies its own Google Cloud OAuth app. |
| Spotify | account | The account supplies its own Spotify app. |
Each mode row includes systemConfigured. mode: "system" with system_configured: false means the installation is configured to use Lumio-managed credentials, but the required dedicated global keys for that (platform, kind) pair are not present, so new connects may degrade to the account-credential path. The fallback is intentional: a local or operator-managed install with no global keys behaves like the historical BYO flow.
systemConfigured is kind-specific. Channel rows report the channel app pair (Twitch may count its login-key fallback); Bot rows report only the dedicated bot app pair. A bot connect can still run through the channel fallback when bot keys are missing, but the Bot row remains systemConfigured: false so the dashboard does not claim a dedicated bot app exists.
The App Credentials page (/dashboard/connections/credentials) lists only the platforms an account can actually set up. A platform's card is shown when it is enabled and either it is not system-managed (a BYO platform such as YouTube or Spotify) or the account already holds its own legacy credential. System-managed platforms with no account credential (Twitch/Kick/Trovo when Lumio's global keys are configured) are hidden — a one-line notice explains that those run through the Lumio app and need no setup. An account still holding its own legacy Twitch/Kick/Trovo credential keeps its card with full update and delete, so it is never locked out of removing a live account-pinned connection. When an empty enabledProviders list comes back (e.g. the query timed out), no filtering is applied and every platform is shown, so a transient failure never blanks the page. If no card remains after filtering, the page shows a short "nothing to set up" explanation instead of an empty grid.
When operators change [platform_credentials], existing channel connections are not rewritten. The row's credential_source remains pinned because a refresh token minted by OAuth app A cannot be refreshed with OAuth app B. Users reconnect when they want a connection to move to the newly selected source.
Architecture
Backend
- GraphQL (
apps/api/src/graphql/connections.rs) -- Queries for listing credentials, channel connections, and connection status. Mutations for saving/deleting credentials, initiating OAuth authorization, and disconnecting channels. - REST (
apps/api/src/routes/connections.rs) -- REST endpoints for credential CRUD, channel connection listing, OAuth authorization initiation, OAuth callback handling, and code exchange. REST routes provide the full OAuth flow with redirect handling. - Crypto (
apps/api/src/crypto.rs) -- AES-256 encryption/decryption for client_id, client_secret, access_token, and refresh_token. Key derived fromconfig.auth.token_encryption_key. - Platforms (
apps/api/src/platforms.rs) -- Platform registry with OAuth configuration (authorize URL, token URL, scopes, extra params) per platform.SUPPORTED_PLATFORMSconstant andis_valid_platform()validation.
Twitch IRC-tags listener (connected channels only)
Two Twitch chat-metadata signals are exposed only over IRC and are absent
from EventSub, so a dedicated worker
(apps/api/src/workers/twitch_irc_tags.rs) supplements the EventSub worker for
connected channels:
- First-time chatter — the
first-msg=1tag on a chatPRIVMSG. Emitted as atwitch:first_messageevent. - Watch streak — a
USERNOTICEwithmsg-id=viewermilestone,msg-param-category=watch-streak,msg-param-value=<count>. Emitted as atwitch:watch_streakevent carryingstreak_count.
The worker opens a single anonymous (justinfan) read-only IRC connection —
no per-channel OAuth token, no credential surface — and JOINs only the channels
that have an active Twitch channel_connections row (join on connect, part on
removal via a 60s roster reload). It is scoped to connected channels only and
is entirely separate from the global-scope
Public Stats Crawler, which it does
not touch. Both signals flow through the normal lo-events insert + broadcast
pipeline, so they appear on the same GraphQL / REST / WebSocket surfaces as every
other twitch:* event (see Events). This is a
deliberately reversible IRC dependency: if Twitch later exposes these via
EventSub, the source is swapped and the listener dropped.
Frontend
- Connection settings page with cards per platform showing connection status.
- App credential form for entering client_id/client_secret.
- "Connect" button initiates OAuth flow, redirects to platform, callback saves tokens.
API
GraphQL Queries
| Query | Permission | Description |
|---|---|---|
appCredentials | connections:read | List app credentials for the account. Secrets are masked; only last 4 chars of client_id shown as hint. |
channelConnections | connections:read | List channel connections for the account. Tokens are never exposed. Shows platform, channel ID, channel name, scopes, expiry, and reconnectRequired. |
connectionStatuses | connections:read | Connection status overview for all supported platforms. Fields: hasCredentials, isConnected (health-aware — false when the connection is flagged reconnectRequired), reconnectRequired, channelName, expiresAt, enabled. Readable with a popout token that carries connections:read. |
platformCredentialModes | connections:read | Credential mode per (platform, kind) — the backend truth the frontend reads instead of hardcoding which platforms Lumio operates globally. One PlatformCredentialMode per (platform, kind) that has an OAuth mode: platform, kind (CHANNEL | BOT), mode (SYSTEM | ACCOUNT), and systemConfigured. systemConfigured is not derivable from mode — it says whether global keys are actually configured, distinguishing "Lumio manages this" from "should, but is not set up". |
enabledProviders(connectionType) | Public (no auth) | List enabled provider slugs for a given connection type (login, channel, or bot) |
enabledPlatforms | Public (no auth) | List platform slugs enabled for channel connections |
GraphQL Mutations
| Mutation | Permission | Description |
|---|---|---|
saveAppCredentials(input: SaveCredentialsInput) | connections:create | Save (upsert) app credentials for a platform. Encrypts client_id and client_secret before storage. |
deleteAppCredentials(platform) | connections:delete | Delete app credentials, source-aware cascade. An account-pinned connection depends on the credentials (its token refresh reads app_credentials), so it is torn down together with them — leaving it alive would lock the customer out of an unrefreshable connection. A system-pinned connection uses the global Lumio app and does not depend on the account credentials, so it survives: only the (stray) credentials are removed. Runs in one transaction so the connection and its credentials never diverge. Use disconnectChannel to drop only the connection and keep the credentials. |
authorizeChannel(platform) | connections:create | Start OAuth authorization flow. Generates a state parameter (stored in Redis with 10-min TTL), builds the authorization URL with scopes, and returns the redirect URL. |
disconnectChannel(platform) | connections:delete | Disconnect a channel connection (keeps app credentials) |
REST Endpoints
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/connections/credentials | connections:read | List all app credentials |
PUT | /v1/connections/credentials/{platform} | connections:create | Save app credentials |
DELETE | /v1/connections/credentials/{platform} | connections:delete | Delete app credentials (source-aware, twin of deleteAppCredentials). Stops the platform worker only when an account-pinned connection is actually disconnected; a surviving system-pinned connection keeps running. |
GET | /v1/connections/channel | connections:read | List all channel connections |
GET | /v1/connections/status | connections:read | Per-platform connection status overview (REST counterpart of connectionStatuses; health-aware is_connected + reconnect_required + expires_at) |
GET | /v1/connections/credential-modes | connections:read | Credential mode per (platform, kind) (REST twin of platformCredentialModes). Each item: platform, kind ("channel" | "bot"), mode ("system" | "account"), system_configured. Same connections:read + feature:connections guard as the GraphQL query. |
DELETE | /v1/connections/channel/{platform} | connections:delete | Disconnect a channel |
GET | /v1/connections/channel/{platform}/authorize | connections:create | Initiate OAuth flow (redirect URL). A system-sourced connect mints a structured state handle and targets the unified callback; an account/BYO connect keeps the legacy per-flow callback. |
GET | /v1/connections/channel/{platform}/callback | -- | Legacy per-flow OAuth callback handler (account/BYO alias) |
POST | /v1/connections/channel/{platform}/exchange | -- | Exchange OAuth code for tokens (legacy per-flow, account/BYO alias) |
POST | /v1/connections/oauth/{platform}/exchange | -- | Unified OAuth code exchange behind the single web-host callback. Redeems the structured state handle, validates return_to fail-closed against [web, admin], dispatches on kind (channel / bot / global_bot) into the shared completion logic, and returns the absolute browser redirect URL (data.redirect_url). Deliberately unauthenticated — the one-time, account-bound handle is the authorization. |
Permissions
| Permission | Description |
|---|---|
connections:read | View credentials (masked) and channel connections. Enforced on appCredentials, channelConnections, connectionStatuses, and the matching REST reads. |
connections:create | Save app credentials, initiate OAuth flows. Enforced on saveAppCredentials, authorizeChannel, and the matching REST writes. |
connections:edit | Gates the connection-settings controls in the dashboard (<Gate permission="connections:edit">). No backend query or mutation checks it — credential writes go through connections:create and removals through connections:delete. |
connections:delete | Delete credentials, disconnect channels. Enforced on deleteAppCredentials, disconnectChannel, and the matching REST deletes. |
Feature Gating
The channel-connections management surface is gated on the feature:connections
flag, in addition to the connections:* permissions. Every credential/channel
query and mutation (appCredentials, channelConnections, connectionStatuses,
saveAppCredentials, deleteAppCredentials, authorizeChannel,
disconnectChannel on GraphQL; the /v1/connections/credentials,
/v1/connections/channel, /v1/channel-info reads/writes, the authorize step
and the authenticated OAuth exchange on REST) enforces it on both protocols —
GraphQL via FeatureGuard::new("feature:connections") chained before the
permission guard, REST via require_feature(..., "feature:connections", ...).
With the flag disabled the API rejects the call directly (Feature 'feature:connections' is not available, HTTP 403); the webapp route gate is
UX only. The public enabledProviders / enabledPlatforms queries (used by
unauthenticated pricing pages) and the deprecated unauthenticated OAuth redirect
callback are intentionally not feature-gated. Per-platform availability is
additionally gated by the platform:{platform}:channel sub-flags on the
credential-save and authorize steps.
Database
| Table | Database | Description |
|---|---|---|
app_credentials | PostgreSQL | id, account_id, platform, client_id (encrypted), client_secret (encrypted), created_at, updated_at. Unique on (account_id, platform). |
channel_connections | PostgreSQL | id, account_id, platform, platform_channel_id, channel_name, access_token (encrypted), refresh_token (encrypted), scopes (text array), credential_source (account/system), reconnect_required (bool, default false), expires_at, created_at, updated_at. Unique on (account_id, platform). |
Data Flow
OAuth Connection Flow
- User clicks Connect, or first saves app credentials when the platform's current channel mode is
account. authorizeChannel(platform)resolves the source for(platform, channel).- For
system, the API reads the global Lumio OAuth app from config and mints a structured state handle for the unified callback. Foraccount, it decrypts the account'sapp_credentialsand mints the legacy account state. - Server builds the OAuth authorization URL with the platform's authorize endpoint, scopes, redirect URI, and state.
- Client redirects to the platform's authorization page.
- User authorizes on the platform. The web callback receives
codeandstate. - The callback exchange validates/redeems state, exchanges the code for tokens with the same OAuth app source, and fetches channel details.
- Access token, refresh token, granted scopes, channel details, and
credential_sourceare encrypted/stored inchannel_connections. - Platform workers start or continue for the connected platform.
Security
- Encryption at rest: All client_id, client_secret, access_token, and refresh_token values are encrypted using AES-256 with a key derived from
config.auth.token_encryption_key. - CSRF protection: OAuth state is stored in Redis with 10-minute TTL; unified system flows use single-use structured state handles.
- Secret masking: Client secrets and tokens are never exposed in API responses. Only a 4-character hint of the client_id is shown.
- Spotify localhost workaround: For development, Spotify requires
127.0.0.1instead oflocalhostin redirect URIs. - Spotify Development Mode allowlist: A BYO Spotify app that Spotify has not approved stays in Development Mode and can only be authorized by accounts the customer allows manually. The credentials setup wizard tells the customer to open User Management in their Spotify app and add the email address of the account they will connect; without it, the OAuth authorization is rejected.
- Audit trail: adding a channel connection emits an
account:connection_addedaudit event whose metadata records thecredential_source("system"/"account") — so the log shows which OAuth app minted the token. Never a secret. Identical on both protocols (the add path is REST-side; the GraphQL disconnect twin emitsaccount:connection_removed).
Key Files
| Path | Description |
|---|---|
apps/api/src/graphql/connections.rs | GraphQL queries and mutations |
apps/api/src/routes/connections.rs | REST endpoints including OAuth callback/exchange |
apps/api/src/crypto.rs | AES-256 encryption/decryption utilities |
apps/api/src/platforms.rs | Platform OAuth configuration registry |
apps/api/src/db/connections.rs | Database operations for credentials and connections |