Token Refresh
Overview
The Token Refresh Worker is the single, authoritative component responsible for refreshing OAuth access tokens for all platform connections. It runs as a background task inside the API server, continuously monitoring token expiry across channel_connections, login_connections, and bot_connections (excluding Discord, which uses non-expiring bot tokens), and proactively refreshing tokens before they expire.
All workers and services that need a fresh token read it from the database using the utility functions in apps/api/src/oauth.rs. They never hold tokens in-memory across operations or call refresh logic inline.
Token Lifecycle
OAuth Provider (Twitch, YouTube, etc.)
|
| (user connects channel / logs in)
v
DB: channel_connections / login_connections
- access_token (AES-256-GCM encrypted)
- refresh_token (AES-256-GCM encrypted)
- expires_at
|
| (background, always running)
v
Token Refresh Worker (apps/api/src/workers/token_refresh.rs)
- Wakes 10 min before next expiry
- Calls platform token endpoint
- Re-encrypts new tokens
- Writes back to DB
|
| (on each API call)
v
get_fresh_connection_token() / get_fresh_oauth_token()
- Reads encrypted tokens from DB
- Decrypts with config.auth.token_encryption_key
- Returns FreshToken to caller
Which Tables Are Refreshed
| Table | Purpose | Function to read |
|---|---|---|
channel_connections | Platform API tokens (Twitch, YouTube, Kick, Trovo, Spotify) | get_fresh_connection_token() |
login_connections | OAuth login session tokens (used to authenticate users on sign-in) | get_fresh_oauth_token() |
bot_connections | Custom bot identity tokens (refreshed for all platforms except Discord) | (read by bot apps) |
Supported Platforms
| Platform | Token Endpoint | Auth Style |
|---|---|---|
| Twitch | https://id.twitch.tv/oauth2/token | Body params |
| YouTube / Google | https://oauth2.googleapis.com/token | Body params |
| Kick | https://id.kick.com/oauth/token | Body params |
| Trovo | https://open-api.trovo.live/openplatform/exchangetoken | Body params |
| Spotify | https://accounts.spotify.com/api/token | Authorization: Basic header |
| Discord | https://discord.com/api/v10/oauth2/token | Body params |
When Tokens Are Refreshed
The worker uses a smart sleep strategy to minimize wasted wakeups:
- On startup: immediately runs a full pass to catch any already-expired tokens.
- After each pass: queries the minimum
expires_atacross both tables and sleeps until 10 minutes before that expiry. - The sleep is capped at 5 minutes so the worker never goes dormant too long.
- If a token is already within the 10-minute window (or already expired), the worker wakes in 5 seconds.
A token is considered in need of refresh when:
expires_at < now() + interval '10 minutes'
Only tokens with a refresh_token present are eligible. Tokens without a refresh token (e.g., non-expiring API keys) are skipped silently.
Encryption
All tokens in the database are encrypted at rest using AES-256-GCM. The encryption key comes from:
# apps/api/config/local.toml
[auth]
token_encryption_key = "<base64-encoded 32-byte key>"
Generate a key with:
openssl rand -base64 32
The encryption key can also be set via the LUMIO__AUTH__TOKEN_ENCRYPTION_KEY environment variable (the LUMIO__SECTION__KEY convention used by lo_config).
The worker decrypts tokens before sending them to the platform endpoint and re-encrypts the new tokens before writing them back to the database.
App Credentials
For channel_connections, the refresh requires both:
refresh_tokenfromchannel_connectionsclient_id+client_secretfromapp_credentials(matched byaccount_idandplatform)
Both are stored encrypted. The worker decrypts them at refresh time.
For login_connections, the client_id and client_secret are the config-level credentials set in [auth] (e.g., twitch_client_id, google_client_id). These are NOT stored in app_credentials.
Utility Functions
Both functions live in apps/api/src/oauth.rs and are the correct way for any worker or feature to obtain a decrypted, ready-to-use token.
get_fresh_connection_token
Reads and decrypts a channel connection's access token, refresh token, and app credentials in a single JOIN query.
use crate::oauth::get_fresh_connection_token;
let token = get_fresh_connection_token(&db, connection_id, &encryption_key).await?;
// token.access_token -- decrypted, ready for API use
// token.client_id -- decrypted app credential
// token.client_secret -- decrypted app credential
// token.expires_at -- optional expiry timestamp
Use this for: Twitch chat workers, YouTube polling, Spotify, Kick, Trovo, Discord — any worker that calls a platform API on behalf of a channel.
get_fresh_oauth_token
Reads and decrypts a login connection's access token from login_connections. client_id and client_secret are returned as empty strings — the caller must supply config credentials separately.
use crate::oauth::get_fresh_oauth_token;
let token = get_fresh_oauth_token(&db, login_connection_id, &encryption_key).await?;
// token.access_token -- decrypted login token
// token.client_id -- always "" (use config credentials)
Use this for: features that need the user's login token (e.g., reading scoped login-level platform data).
How to Use in a New Worker
When writing a new worker or feature that calls a platform API:
Do this:
// In your worker's run loop or per-request handler:
let token = crate::oauth::get_fresh_connection_token(
&db,
connection_id,
&encryption_key,
).await?;
// Use token.access_token directly
let response = platform_api_client
.some_request(&token.access_token)
.await?;
Never do this:
// DO NOT hold OAuthTokens in-memory across loop iterations
// DO NOT call tokens.try_refresh() inline in a worker
// DO NOT implement your own refresh logic
let mut tokens = OAuthTokens { ... };
tokens.try_refresh(&http).await?; // WRONG — Token Refresh Worker handles this
The Token Refresh Worker guarantees that channel_connections.access_token is always fresh by the time your worker reads it. You can safely read the token from the DB on each API call without worrying about expiry.
Architecture
apps/api/src/workers/token_refresh.rs -- Background worker (runs_token_refresh_worker)
apps/api/src/oauth.rs -- Utility functions + OAuthTokens struct
Worker Internals
| Function | Purpose |
|---|---|
run_token_refresh_worker() | Entry point, starts the loop, handles cancellation |
next_expiry_in_secs() | Queries MIN(expires_at) to determine smart sleep duration |
refresh_expiring_tokens() | Finds all expiring tokens and refreshes each one |
oauth.rs Internals
| Function | Purpose |
|---|---|
get_fresh_connection_token() | Read + decrypt channel connection token (JOIN with app_credentials) |
get_fresh_oauth_token() | Read + decrypt login connection token |
refresh_oauth_token() | Execute the HTTP token refresh request |
update_connection_tokens() | Write refreshed tokens back to channel_connections |
update_oauth_tokens() | Write refreshed tokens back to login_connections |
token_needs_refresh() | Returns true if token expires within 5-minute margin |
OAuthTokens::try_refresh() | Used internally by the worker for channel connections only |
Key Files
| File | Purpose |
|---|---|
apps/api/src/workers/token_refresh.rs | Token Refresh Worker implementation |
apps/api/src/oauth.rs | Utility functions: get_fresh_connection_token, get_fresh_oauth_token, refresh_oauth_token |
apps/api/src/crypto.rs | AES-256-GCM encrypt/decrypt used by all token operations |
apps/api/config/default.toml | [auth] token_encryption_key config key |