Skip to main content

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

TablePurposeFunction to read
channel_connectionsPlatform API tokens (Twitch, YouTube, Kick, Trovo, Spotify)get_fresh_connection_token()
login_connectionsOAuth login session tokens (used to authenticate users on sign-in)get_fresh_oauth_token()
bot_connectionsCustom bot identity tokens (refreshed for all platforms except Discord)(read by bot apps)

Supported Platforms

PlatformToken EndpointAuth Style
Twitchhttps://id.twitch.tv/oauth2/tokenBody params
YouTube / Googlehttps://oauth2.googleapis.com/tokenBody params
Kickhttps://id.kick.com/oauth/tokenBody params
Trovohttps://open-api.trovo.live/openplatform/exchangetokenBody params
Spotifyhttps://accounts.spotify.com/api/tokenAuthorization: Basic header
Discordhttps://discord.com/api/v10/oauth2/tokenBody params

When Tokens Are Refreshed

The worker uses a smart sleep strategy to minimize wasted wakeups:

  1. On startup: immediately runs a full pass to catch any already-expired tokens.
  2. After each pass: queries the minimum expires_at across both tables and sleeps until 10 minutes before that expiry.
  3. The sleep is capped at 5 minutes so the worker never goes dormant too long.
  4. 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_token from channel_connections
  • client_id + client_secret from app_credentials (matched by account_id and platform)

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

FunctionPurpose
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

FunctionPurpose
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

FilePurpose
apps/api/src/workers/token_refresh.rsToken Refresh Worker implementation
apps/api/src/oauth.rsUtility functions: get_fresh_connection_token, get_fresh_oauth_token, refresh_oauth_token
apps/api/src/crypto.rsAES-256-GCM encrypt/decrypt used by all token operations
apps/api/config/default.toml[auth] token_encryption_key config key