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.

  • Integrations
  • $oauth-integrations
  • 178 lines
  • 2 files
  • ~2,250 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- oauth-integrations

Installs to ~/.agents/skills/oauth-integrations/ for every project on this machine.

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.

~/.agents/skills/oauth-integrations/SKILL.md

--- name: oauth-integrations description: Connects apps to third-party APIs as an OAuth 2.0 client - authorization code with PKCE and state, exact redirect URIs, minimal and incremental… license: MIT ---

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

  1. Read AGENTS.md; search rg -n "refresh_token|access_token|oauth|redirect_uri" --glob '!node_modules'.
  2. 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.
  3. Find where tokens are stored today and whether they are encrypted at rest.
  4. 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:

  1. Reject if state is missing or does not match the session value; then delete it (single use).
  2. If error is present (access_denied), show a friendly message; do not retry automatically.
  3. Exchange code with grant_type=authorization_code, the same redirect_uri, and code_verifier, authenticating with the client secret (confidential clients) via HTTP Basic or body as the provider requires.
  4. Record the scopes from the token response (scope), which may be fewer than requested.
  5. Fetch the provider’s account identity and upsert the connection on (user_id, provider, external_account); redirect to a validated relative returnTo only.

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_grant as “user must reconnect”.

Provider quirks

ProviderBehaviour to code for
GoogleRefresh 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 platformAsk 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 AppTokens 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.
SlackBot 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.
SalesforceSave instance_url from the token response and call that host. Refresh token lifetime is set by the connected app policy.
XeroAccess 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 state is 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_reauth and the UI shows a reconnect prompt.
  • Logs and error reports contain no tokens or authorization codes.

Gotchas

  • http://localhost:3000/callback and http://127.0.0.1:3000/callback are 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem