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_connectionsBot identity tokens (refreshed for OAuth bot platforms; Discord uses a static token)get_fresh_bot_token()

In all three tables, rows flagged reconnect_required = true are excluded — both from the refresh pass and from the "next expiry" calculation. A connection in that state needs the user to re-authorize; retrying its refresh token would only burn requests.

Cluster Safety

Every API replica starts its own Token Refresh Worker, but only one may refresh a given cycle. Before each pass the worker takes a non-blocking Postgres advisory lock (pg_try_advisory_lock, key 0x746F6B65 = "toke"); a replica that does not get the lock skips the cycle immediately without holding a connection.

This matters because several providers (Google, Discord, Trovo, …) rotate the refresh token on use. Two replicas refreshing the same row would replay an already-consumed refresh token, and the provider may invalidate the whole token family — permanently breaking that connection's auth. The lock is released after the pass, and a crashed replica releases it automatically on disconnect.

Failure Backoff

A refresh that fails bumps the row's updated_at. The refresh query then skips any row matching expires_at < now() AND updated_at > now() - interval '1 hour' — that is, an already-expired token whose refresh was attempted within the last hour. This keeps a permanently broken connection (revoked grant, deleted app credential) from being retried on every 5-minute cycle. Tokens that are not yet expired are retried normally.

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.

OAuth App Credentials

For channel_connections and OAuth bot_connections, refresh follows the row's pinned credential_source.

credential_sourceOAuth app pairToken row
accountclient_id + client_secret from app_credentials matched by (account_id, platform)channel_connections or bot_connections
systemglobal config pair resolved by CredentialResolver::system_pair(platform)channel_connections or bot_connections

The access token and refresh token are always stored encrypted on the connection row. account app credentials are also encrypted in the database; system app credentials come from config. The pin exists because a refresh token minted by one OAuth app cannot be refreshed by another.

If a row is pinned to system but the matching global key pair is absent at refresh time, the refresh fails clearly and the row is handled like any other refresh failure. If a row is pinned to account but the account's app_credentials row is absent, the account must save credentials and reconnect.

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), resolved by services::login_token::get_provider_credentials. These are NOT stored in app_credentials. A provider with no configured credentials is skipped.

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 and refresh token, then resolves the OAuth app pair from the pinned credential_source.

use crate::oauth::get_fresh_connection_token;

let token = get_fresh_connection_token(&db, connection_id, &resolver).await?;
// token.access_token -- decrypted, ready for API use
// token.client_id -- matching OAuth app client ID
// token.client_secret -- matching OAuth app client secret
// 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 the login 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).

get_fresh_bot_token

Reads and decrypts a bot identity token from bot_connections, resolving the OAuth app pair from the row's pinned credential_source. Rows with reconnect_required = true are not returned. For Discord the returned client_id / client_secret are empty strings, because Discord bot tokens are static and are never refreshed.

use crate::oauth::get_fresh_bot_token;

let token = get_fresh_bot_token(&db, bot_connection_id, &resolver).await?;
// token.access_token -- decrypted bot identity token
// token.refresh_token -- decrypted, None when the platform issues none

Use this for: anything acting as a bot identity rather than as the channel.

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,
&resolver,
).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

This is enforced in code where it used to be violated: lo-spotify-api's client-side refresh helper is marked #[deprecated] with the note "Use crate::oauth::get_fresh_connection_token in apps/api instead. Inline refresh races with the Token Refresh Worker." OAuthTokens::try_refresh and refresh_oauth_token have no callers outside workers/token_refresh.rs.

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
refresh_expiring_tokens_if_leader()Takes the advisory lock; runs the pass only on the winning replica
next_expiry_in_secs()Queries MIN(expires_at) across the three tables 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, resolve credentials by pinned credential_source
get_fresh_oauth_token()Read + decrypt login connection token
get_fresh_bot_token()Read + decrypt bot connection token, resolve credentials by pinned credential_source
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
update_bot_connection_tokens()Write refreshed tokens back to bot_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/credentials.rsCredentialResolver, source-aware global/account credential lookup
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