Third-Party OAuth Integrations skill for Codex
Connect accounts once, refresh tokens without races. Codex implements Connect-your-account flows with state and PKCE, stores granted scopes and encrypted tokens per connection, and refreshes under a lock so rotating refresh tokens never invalidate each other. It codes for provider quirks in Google, Microsoft, Slack, Xero and QuickBooks, and turns invalid_grant into a reconnect prompt instead of repeated 401s.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- oauth-integrations Installs to ~/.agents/skills/oauth-integrations/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install oauth-integrations The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project oauth-integrations Installs to .agents/skills/oauth-integrations/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/oauth-integrations.zipunzip -o oauth-integrations.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) oauth-integrations PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $oauth-integrations, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Connects apps to third-party APIs as an OAuth 2.0 client - authorization code with PKCE and state, exact redirect URIs, minimal and incremental scopes, encrypted token storage, single-flight refresh with rotating refresh tokens, invalid_grant reconnect flows, revocation, and provider quirks for Google, Microsoft, GitHub, Slack, Salesforce, Xero and QuickBooks. Use when adding a "Connect your X account" button, refresh tokens stop working, users get logged out of an integration, or tokens must be stored and refreshed safely across workers.
Use it when
- Calling a third-party API on behalf of your users
- Refresh tokens stop working or users must reconnect repeatedly
- Several processes share and refresh the same tokens
- Adding scopes or a disconnect flow to an existing integration
Use something else when
- You are adding login to your own app or running an identity provider: use oauth2-oidc
- You need a security audit of an OAuth implementation: use oauth-security-review
How to check the result
With the token forced to expire, ten concurrent calls cause exactly one refresh request, and revoking access at the provider produces a needs_reauth status and a reconnect prompt.
The mistake it prevents
Refreshing a rotating refresh token from two workers at once, which invalidates the grant and logs the user out.
Done well means: each connection is a row with encrypted tokens, granted scopes and expiry; the
authorize step uses state and PKCE; refresh happens once per token even under concurrency and
always saves a rotated refresh token; and a revoked or expired grant turns into a clear “reconnect”
prompt instead of a stream of 401s.
This covers calling other companies’ APIs on a user’s behalf. Signing users into your own app is
$oauth2-oidc or $auth-libraries; auditing an existing flow for vulnerabilities is
$oauth-security-review.
Inspect first
- Read
AGENTS.md; searchrg -n "refresh_token|access_token|oauth|redirect_uri" --glob '!node_modules'. - List every provider, its client type (confidential server vs public mobile/SPA), registered redirect URIs per environment, and the scopes actually used by the code.
- Find where tokens are stored today and whether they are encrypted at rest.
- Check how many processes can refresh the same token at once (web servers, workers, cron).
Data model
create table oauth_connections (
id uuid primary key,
user_id uuid not null references users(id),
provider text not null, -- 'google', 'xero', ...
external_account text not null, -- provider's user/tenant id
scopes text[] not null, -- as granted, not as requested
access_token bytea not null, -- encrypted
refresh_token bytea, -- encrypted; null if provider issues none
expires_at timestamptz,
status text not null default 'active', -- active | needs_reauth | revoked
updated_at timestamptz not null default now(),
unique (user_id, provider, external_account)
);
Encrypt with a KMS-managed key or an app key outside the database. Never log tokens, authorization codes or full callback URLs.
Authorize step
import crypto from 'node:crypto';
const b64url = (b: Buffer) => b.toString('base64url');
export function startAuth(req, res, provider: Provider) {
const state = b64url(crypto.randomBytes(32));
const verifier = b64url(crypto.randomBytes(32));
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
req.session.oauth = { state, verifier, provider: provider.id, returnTo: safePath(req.query.returnTo) };
const url = new URL(provider.authorizeUrl);
url.search = new URLSearchParams({
response_type: 'code',
client_id: provider.clientId,
redirect_uri: provider.redirectUri, // byte-for-byte equal to the registered value
scope: provider.scopes.join(' '),
state,
code_challenge: challenge,
code_challenge_method: 'S256',
...provider.extraAuthParams, // see quirks table
}).toString();
res.redirect(url.toString());
}
Callback:
- Reject if
stateis missing or does not match the session value; then delete it (single use). - If
erroris present (access_denied), show a friendly message; do not retry automatically. - Exchange
codewithgrant_type=authorization_code, the sameredirect_uri, andcode_verifier, authenticating with the client secret (confidential clients) via HTTP Basic or body as the provider requires. - Record the scopes from the token response (
scope), which may be fewer than requested. - Fetch the provider’s account identity and upsert the connection on
(user_id, provider, external_account); redirect to a validated relativereturnToonly.
Libraries that handle these steps: openid-client or arctic (Node), Authlib or
requests-oauthlib (Python), golang.org/x/oauth2 (Go). Use one rather than hand-rolling token
parsing when the project allows new dependencies.
Refresh without races
export async function getAccessToken(connId: string): Promise<string> {
const conn = await db.connections.get(connId);
if (conn.expiresAt && conn.expiresAt.getTime() - Date.now() > 120_000) return decrypt(conn.accessToken);
return withLock(`oauth-refresh:${connId}`, async () => { // pg advisory lock or Redis lock
const fresh = await db.connections.get(connId); // another worker may have refreshed
if (fresh.expiresAt && fresh.expiresAt.getTime() - Date.now() > 120_000) return decrypt(fresh.accessToken);
const res = await tokenRequest(fresh.provider, {
grant_type: 'refresh_token', refresh_token: decrypt(fresh.refreshToken),
});
if (res.error === 'invalid_grant') {
await db.connections.update(connId, { status: 'needs_reauth' });
throw new ReconnectRequired(fresh.provider);
}
await db.connections.update(connId, {
accessToken: encrypt(res.access_token),
refreshToken: res.refresh_token ? encrypt(res.refresh_token) : fresh.refreshToken, // rotation
expiresAt: new Date(Date.now() + res.expires_in * 1000),
status: 'active',
});
return res.access_token;
});
}
- Refresh proactively with a margin (1-5 minutes); also refresh once and retry once on a 401.
- With rotating refresh tokens, two concurrent refreshes invalidate each other; the lock is not optional.
- Save the new refresh token in the same write as the access token, before using the access token.
- Treat network errors and 5xx from the token endpoint as retryable; treat
invalid_grantas “user must reconnect”.
Provider quirks
| Provider | Behaviour to code for |
|---|---|
Refresh token only with access_type=offline; usually returned only on first consent, so add prompt=consent when re-linking. Apps in “Testing” publishing status get refresh tokens that expire after 7 days. Sensitive and restricted scopes require verification. | |
| Microsoft identity platform | Ask for offline_access to get a refresh token; refresh tokens rotate; tenant (common, organizations, tenant ID) changes who can sign in; admin consent may be required. |
| GitHub OAuth App | Tokens do not expire and there is no refresh. GitHub App user tokens expire after 8 hours with a 6-month refresh token when expiration is enabled. |
| Slack | Bot tokens do not expire unless token rotation is turned on; with rotation, access tokens last 12 hours and refresh tokens rotate. Store bot and user tokens separately. |
| Salesforce | Save instance_url from the token response and call that host. Refresh token lifetime is set by the connected app policy. |
| Xero | Access tokens last 30 minutes; refresh tokens rotate and expire after 60 days unused; one token can access several tenants (/connections). |
| QuickBooks (Intuit) | Access tokens last 1 hour; refresh tokens last up to 100 days and the value can change on refresh. Keep the realmId from the callback. |
Lifetimes change; read the provider’s current docs when a number matters for scheduling.
Scopes
- Request the minimum for the features enabled now; ask for more later with incremental authorization when the user turns on a feature.
- Check granted scopes before calling an endpoint and show “grant access to X” instead of a 403.
- Broad scopes (full mailbox, full drive, admin) slow down provider review and scare users; justify each one in the PR.
Disconnect and revocation
When the user disconnects, call the provider’s revocation endpoint if it has one, then delete the
tokens and mark the connection revoked. This removes access permanently, so trigger it only from
an explicit user action or confirmed admin request. Also handle provider-side revocation webhooks or
deauthorization callbacks where offered.
Verification
- Full connect flow in a dev environment with the real provider; the stored scopes match the granted ones and tokens are encrypted in the DB.
- Tampered or missing
stateis rejected; reusing a callback URL fails. - Force expiry (
expires_at = now()), fire 10 concurrent API calls: exactly one refresh request hits the provider and all calls succeed. - Revoke access in the provider’s account settings: the next call marks
needs_reauthand the UI shows a reconnect prompt. - Logs and error reports contain no tokens or authorization codes.
Gotchas
http://localhost:3000/callbackandhttp://127.0.0.1:3000/callbackare different redirect URIs; so is a trailing slash.- Implicit flow and tokens in URL fragments are obsolete; use code + PKCE even for SPAs and mobile.
- Storing tokens in browser localStorage exposes them to any XSS; keep third-party tokens server-side.
- Reconnecting a different external account under the same user should create a new connection, not overwrite the old one silently.
- Test clients registered in “development” modes often have user caps and short-lived tokens.
Output
Report: providers and grant types, scopes requested vs granted, token storage and encryption, refresh locking strategy, reconnect UX, revocation handling, and the concurrency test result.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Third-Party OAuth Integrations"
short_description: "Connect accounts once, refresh tokens without races"
default_prompt: "$oauth-integrations Add a Connect Google Calendar button and store the tokens safely" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.