OAuth Client — Connecting to Third-Party Providers
This guideline covers an app acting as an OAuth client: it sends a user to a third-party provider (TikTok, Instagram, YouTube, Google, …), receives an access token on their behalf, stores it, and uses it later — including from background jobs where no user is present. It is the inverse of MCP Server with OAuth, where your app is the authorization/resource server that MCP clients authenticate against. Here a remote provider owns the tokens and your app is the one logging in.
| Role | You are… | Covered by |
|---|---|---|
| OAuth client | obtaining/storing tokens from a provider | this guideline |
| OAuth server | issuing tokens for your own app | OAuth Authorization Server |
| MCP application | the MCP-specific use of these primitives | MCP Server with OAuth |
The only runtime dependencies are ttoss packages: @ttoss/oauth-client for the OAuth flow itself (authorization URL, code exchange, refresh), @ttoss/http-server for the endpoints, and @ttoss/auth-core for token encryption and the internal service token. @ttoss/oauth-client ships provider presets — the examples use createTikTokClient; other providers are one preset over the same core, or createOAuthClient for one without a preset yet.
1. Authorization redirect
A connect-url endpoint builds the provider's /authorize URL with client.buildAuthUrl and returns it (or redirects to it). Generate a random state, persist it against the session, and verify it on callback to prevent CSRF — state and its verification stay app-side, since they belong to your session store.
import { createTikTokClient } from '@ttoss/oauth-client';
import { Router } from '@ttoss/http-server';
import crypto from 'node:crypto';
const router = new Router();
const tiktok = createTikTokClient({
clientKey: process.env.TIKTOK_CLIENT_KEY!,
clientSecret: process.env.TIKTOK_CLIENT_SECRET!,
});
router.get('/social/tiktok/connect-url', (ctx) => {
const state = crypto.randomBytes(16).toString('hex');
// Persist `state` against the user's session for later verification.
saveOAuthState(ctx, state);
ctx.body = {
url: tiktok.buildAuthUrl({
redirectUri: `${process.env.APP_URL}/my/settings/social`,
scope: ['user.info.basic', 'video.publish'],
state,
}),
};
});
2. Callback exchange
The provider redirects the user back to the settings page with ?code=. The page posts that code to a connect endpoint, which verifies state and exchanges the code for tokens with client.exchangeCode. Provider-specific fields (TikTok's open_id) come back on raw.
router.post('/social/tiktok/connect', async (ctx) => {
const { code, state } = ctx.request.body as { code: string; state: string };
assertOAuthState(ctx, state); // throws on mismatch
const tokens = await tiktok.exchangeCode({
code,
redirectUri: `${process.env.APP_URL}/my/settings/social`,
});
await saveSocialToken({
userId: ctx.state.userId,
platform: 'tiktok',
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
accessTokenExpiresAt: new Date(Date.now() + tokens.expiresIn * 1000),
openId: tokens.raw.open_id,
});
ctx.body = { connected: true };
});
3. Token storage (encrypted at rest)
Store one record per user, per platform. Access and refresh tokens are credentials — encrypt them at rest with encryptValue / decryptValue from @ttoss/auth-core (AES-256-GCM). Generate the key once with generateEncryptionKey and keep it in your secret manager, never in the codebase.
import { encryptValue, decryptValue } from '@ttoss/auth-core';
const KEY = process.env.TOKEN_ENCRYPTION_KEY!; // 64-char hex, from generateEncryptionKey()
// On write:
const stored = encryptValue({ plaintext: accessToken, key: KEY });
// On read:
const accessToken = decryptValue({ ciphertext: stored, key: KEY });
A minimal SocialToken record:
| Field | Purpose |
|---|---|
userId + platform | unique key — one connection per provider |
accessToken | encrypted access token |
refreshToken | encrypted refresh token |
accessTokenExpiresAt | drives lazy and scheduled refresh |
openId / username | provider account id shown in the settings UI |
The ciphertext is a single base64 string (IV + auth tag + payload), so no extra columns are needed. Decryption throws if the key is wrong or the value was tampered with.
4. Auto-refresh
Access tokens are short-lived; refresh tokens last longer. Use two complementary strategies.
Lazy refresh at call time — client.getValidToken returns a usable access token, refreshing first if it expires within a safety window (default 2 hours) and handing the new tokens to onRefresh so you can re-store them. Every code path that calls the provider goes through this helper, so callers never handle expiry. The client works with plaintext, so decrypt on the way in and encrypt inside onRefresh.
export const getValidTikTokToken = async (userId: string): Promise<string> => {
const token = await loadSocialToken({ userId, platform: 'tiktok' });
return tiktok.getValidToken(
{
accessToken: decryptValue({ ciphertext: token.accessToken, key: KEY }),
refreshToken: decryptValue({ ciphertext: token.refreshToken, key: KEY }),
accessTokenExpiresAt: token.accessTokenExpiresAt,
},
{
onRefresh: (updated) =>
saveSocialToken({
userId,
platform: 'tiktok',
accessToken: encryptValue({
plaintext: updated.accessToken,
key: KEY,
}),
refreshToken: encryptValue({
plaintext: updated.refreshToken,
key: KEY,
}),
accessTokenExpiresAt: new Date(Date.now() + updated.expiresIn * 1000),
}),
}
);
};
Scheduled refresh — a cron job refreshes tokens expiring within a wider window (default 6 hours) so connections stay alive even for users who are inactive. This guards against refresh tokens that themselves expire if never used. findExpiringTokens selects the records due for refresh.
import { findExpiringTokens } from '@ttoss/oauth-client';
// jobs/tiktokTokenRefresher.ts — scheduled (e.g. hourly) by your deploy infra
export const tiktokTokenRefresher = async () => {
const tokens = findExpiringTokens(
await listSocialTokens({ platform: 'tiktok' })
);
for (const token of tokens) {
await getValidTikTokToken(token.userId).catch((error) =>
logRefreshFailure(token, error)
);
}
};
5. Internal token access (no user session)
Background scripts — a publish pipeline, an analytics sync — need a valid access token but run with no user session. Expose a system-authenticated endpoint that returns a fresh token for a given user, guarded by a service credential rather than a login. Sign that credential with signJwt from @ttoss/auth-core and verify it on the way in; never expose this endpoint publicly.
import { verifyJwt } from '@ttoss/auth-core';
router.post('/social/tiktok/internal-token', async (ctx) => {
const auth = ctx.headers.authorization?.replace('Bearer ', '') ?? '';
const claims = verifyJwt({
token: auth,
secret: process.env.SERVICE_JWT_SECRET!,
});
if (!claims || claims.aud !== 'internal') {
ctx.status = 401;
return;
}
const { userId } = ctx.request.body as { userId: string };
ctx.body = { accessToken: await getValidTikTokToken(userId) };
});
The job mints a short-lived service token with the matching secret and calls this endpoint — keeping provider tokens encrypted in one place and out of every background script.
6. Status and disconnect
The settings UI needs two more endpoints. status reports whether a connection exists and surfaces the username / openId for display (never the tokens). disconnect revokes the token with the provider when supported, then deletes the local record.
router.get('/social/tiktok/status', async (ctx) => {
const token = await loadSocialToken({
userId: ctx.state.userId,
platform: 'tiktok',
});
ctx.body = token
? { connected: true, username: token.username }
: { connected: false };
});
router.delete('/social/tiktok/disconnect', async (ctx) => {
await revokeWithProvider(ctx.state.userId); // best-effort call to provider /revoke
await deleteSocialToken({ userId: ctx.state.userId, platform: 'tiktok' });
ctx.body = { connected: false };
});
Summary
Build the provider /authorize URL with state, exchange the callback code for tokens, and store them encrypted per user and platform. Refresh both lazily (within a short window at call time) and on a schedule (within a wider window via cron), so tokens are always valid. Hand tokens to background jobs through a system-authenticated internal-token endpoint, and let the settings UI manage connection state through status and disconnect. Pair this with MCP Server with OAuth when the same app must also be an OAuth server.