Skip to main content

OAuth & Credentials Architecture

Lumio uses a dual-credential architecture that separates login credentials from channel API credentials. Understanding this separation is critical for anyone working on authentication, connections, or platform integrations.

Two Types of Credentials

1. Config Credentials (Login Only)

Stored in config/local.toml under [auth]:

[auth]
twitch_client_id = "..."
twitch_client_secret = "..."
google_client_id = "..."
google_client_secret = "..."
kick_client_id = "..."
kick_client_secret = "..."
trovo_client_id = "..."
trovo_client_secret = "..."

These credentials are only used by the ID App (NextAuth) for user login. They identify the Lumio application to the platform's OAuth provider during the login flow. They are never used for channel API calls.

Defined in: apps/api/src/config.rs (AuthConfig struct)

2. DB Credentials (Per-Account, Encrypted)

Stored in two PostgreSQL tables, 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: token refresh requests, API client authentication headers.

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
created_atTimestampCreation time
updated_atTimestampLast update time

Used for: all platform API calls (EventSub, chat, profile enrichment, etc.).

Constraint: One connection per platform per account (UNIQUE (account_id, platform)).

Encryption

All DB credentials are encrypted using AES-256-GCM with a random 96-bit nonce per encryption operation.

  • Key source: config.auth.token_encryption_key (TOML config)
  • Key derivation: If the config value is not exactly 32 bytes, it is hashed with SHA-256 to produce a 32-byte key (crypto::derive_key())
  • Ciphertext format: {base64(nonce)}.{base64(ciphertext)}
  • Implementation: apps/api/src/crypto.rs

Encryption happens at the boundary -- values are encrypted before database writes and decrypted after reads. The database never stores plaintext credentials.

Rules

  1. Never use config credentials for channel API calls. Config credentials exist solely for the ID App login flow.
  2. Token refresh requires BOTH DB tables. The client_id and client_secret come from app_credentials, while the refresh_token and access_token come from channel_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 returns only a client_id_hint (last 4 characters). Tokens are never included in channelConnections responses.

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 (5-minute margin before expiry)
  2. If refresh is needed, try_refresh() sends a request to the platform's token endpoint
  3. The new tokens are encrypted and persisted to channel_connections
  4. In-memory state is updated for the worker to continue

Two authentication styles are supported:

  • Body (Twitch, YouTube, Kick, Trovo): 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

Channel Connection Flow (DB Credentials)

flowchart TD
A[User enters client_id + client_secret] --> B[Encrypt with AES-256-GCM]
B --> C[Store in app_credentials]
C --> D[User clicks Connect]
D --> E[Build authorize URL with scopes]
E --> F[Redirect to platform OAuth]
F --> G[User authorizes channel access]
G --> H[Callback with authorization code]
H --> I[Exchange code for access_token + refresh_token]
I --> J[Fetch channel info from platform API]
J --> K[Encrypt tokens]
K --> L[Store in channel_connections]
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["app_credentials.client_id"]
O["app_credentials.client_secret"]
P["channel_connections.access_token"]
end

ProfileService Enrichment Flow

flowchart TD
A[GraphQL: platformUserProfile query] --> B[API resolves account credentials]
B --> C[Decrypt client_id + client_secret from app_credentials]
C --> D[Decrypt access_token from channel_connections]
D --> E[Build ChannelCredentials struct]
E --> F[Call ProfileService.get_profile]
F --> G{Redis cache hit?}
G -->|Yes| H[Return cached profile]
G -->|No| I[Load from database]
I --> J{Enrichment stale?}
J -->|No| K[Cache with 4h TTL]
J -->|Yes| L{Circuit open?}
L -->|Yes| M[Cache with 15min TTL]
L -->|No| N[Call platform API with decrypted credentials]
N -->|Success| O[Merge enrichment + cache 4h]
N -->|Failure| P[Record failure + cache 30min]

style F fill:#3b82f6,color:#fff
style N fill:#f59e0b,color:#fff

Key Files

FilePurpose
apps/api/src/config.rsAuthConfig with config credentials (login only)
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/graphql/connections.rsGraphQL resolvers (secrets masked in responses)
apps/api/src/platforms.rsPlatform OAuth configs (authorize URLs, token URLs, scopes)
crates/lo-chat/src/profile_service.rsChannelCredentials struct consumed by ProfileService