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
| 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: token refresh requests, API client authentication headers.
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 |
created_at | Timestamp | Creation time |
updated_at | Timestamp | Last 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
- Never use config credentials for channel API calls. Config credentials exist solely for the ID App login flow.
- Token refresh requires BOTH DB tables. The
client_idandclient_secretcome fromapp_credentials, while therefresh_tokenandaccess_tokencome fromchannel_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 returns only aclient_id_hint(last 4 characters). Tokens are never included inchannelConnectionsresponses.
Token Refresh Flow
The OAuthTokens struct in apps/api/src/oauth.rs manages automatic token refresh:
- Workers check
needs_refresh()before each API call (5-minute margin before expiry) - If refresh is needed,
try_refresh()sends a request to the platform's token endpoint - The new tokens are encrypted and persisted to
channel_connections - In-memory state is updated for the worker to continue
Two authentication styles are supported:
- Body (Twitch, YouTube, Kick, Trovo):
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
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
| File | Purpose |
|---|---|
apps/api/src/config.rs | AuthConfig with config credentials (login only) |
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/graphql/connections.rs | GraphQL resolvers (secrets masked in responses) |
apps/api/src/platforms.rs | Platform OAuth configs (authorize URLs, token URLs, scopes) |
crates/lo-chat/src/profile_service.rs | ChannelCredentials struct consumed by ProfileService |