OAuth/OIDC integration

Use standard authorization code and OIDC discovery against your IdP so Agents or backends access user-consented APIs in a rotatable, auditable way.

Prefer authorization code + PKCE for public clients; keep client_secret on the server for confidential clients. Separate access tokens (resource calls) from ID tokens (identity claims); validate issuer, audience, and allowed signing algorithms.

A SKILL can require listing redirect URIs, scopes, and offline refresh policy; generated code must keep tokens out of model context and repos, with refresh tokens only in server secret storage.

When integrating with an IdP, cover error paths: consent denied, token expiry, revocation; document well-known URLs and client id placeholders per environment.

  • Use state/nonce against CSRF and replay; never pass long-lived secrets in URLs.
  • Least privilege scopes, explainable in UI and logs.
  • Multi-tenant: tenant-specific authorization endpoints and metadata.

Public client: authorization code + PKCE full implementation

  [ User agent: browser / native app ]
                    │
                    ▼
  [ Generate code_verifier; code_challenge = BASE64URL(SHA256(verifier)); method=S256 ]
                    │
                    ▼
  [ Redirect to authorize: response_type=code + code_challenge* + state + nonce (OIDC) ]
                    │
                    ▼
  [ User signs in at IdP and approves scopes ]
                    │
                    ▼
  [ Callback redirect_uri: ?code=…&state=… ]
                    │
                    ▼
  [ Validate state; POST token endpoint: code + code_verifier (often no client_secret) ]
                    │
                    ▼
  [ access_token / id_token / refresh_token (if granted); validate iss, aud, signature, alg allowlist ]
// Full Authorization Code + PKCE flow implementation (browser-side)
async function startAuthFlow() {
  // Generate code_verifier (32 random bytes)
  const verifier = btoa(String.fromCharCode(
    ...crypto.getRandomValues(new Uint8Array(32))
  )).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

  // Compute code_challenge = BASE64URL(SHA256(verifier))
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
  const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

  // state for CSRF protection: random value stored in sessionStorage, verified on callback
  const state = crypto.randomUUID();
  sessionStorage.setItem('pkce_verifier', verifier);
  sessionStorage.setItem('oauth_state', state);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'YOUR_CLIENT_ID',
    redirect_uri: 'https://app.example.com/callback',
    scope: 'openid profile email',
    state,                        // CSRF protection
    code_challenge: challenge,
    code_challenge_method: 'S256',
    nonce: crypto.randomUUID(),   // OIDC replay protection
  });
  window.location.href = `https://auth.example.com/authorize?${params}`;
}

// Callback handler: validate state → exchange code + verifier for tokens
async function handleCallback(searchParams: URLSearchParams) {
  const returnedState = searchParams.get('state');
  const savedState = sessionStorage.getItem('oauth_state');
  if (returnedState !== savedState) throw new Error('State mismatch - CSRF attack');

  const code = searchParams.get('code')!;
  const verifier = sessionStorage.getItem('pkce_verifier')!;
  sessionStorage.removeItem('pkce_verifier');
  sessionStorage.removeItem('oauth_state');

  const response = await fetch('https://auth.example.com/token', {
    method: 'POST',
    body: new URLSearchParams({ grant_type: 'authorization_code', code, code_verifier: verifier,
      client_id: 'YOUR_CLIENT_ID', redirect_uri: 'https://app.example.com/callback' }),
  });
  return response.json(); // { access_token, id_token, refresh_token }
}

Keep code_verifier in client secure storage until token exchange; the authorize request only sends code_challenge. Do not put the verifier in shareable links or logs.

Token storage strategies and security comparison

// Token storage — three strategies compared

// ❌ Strategy 1: localStorage — readable by XSS, do NOT store refresh_token here
localStorage.setItem('access_token', token);   // any JS can read it; XSS = direct leak

// ⚠️ Strategy 2: Memory (variable) — lost on page refresh, but XSS limited
let accessToken: string | null = null;
function setAccessToken(t: string) { accessToken = t; }  // lost after page reload

// ✅ Strategy 3: HttpOnly Cookie — JS cannot read it, recommended for refresh_token
// Server-side setting (Node.js/Express):
res.cookie('refresh_token', refreshToken, {
  httpOnly: true,    // JS inaccessible, prevents XSS
  secure: true,      // HTTPS only
  sameSite: 'strict', // prevents CSRF
  path: '/auth/refresh', // restrict Cookie send path
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
// access_token in Memory (short-lived, 15min), refresh_token in HttpOnly Cookie

// Refresh Token rotation: each refresh returns a new refresh_token
app.post('/auth/refresh', async (req, res) => {
  const oldRefreshToken = req.cookies.refresh_token;
  if (!oldRefreshToken) return res.status(401).json({ error: 'No refresh token' });

  try {
    // Exchange for new token pair at IdP (rotation: old refresh_token is simultaneously invalidated)
    const { access_token, refresh_token: newRefresh } = await exchangeRefreshToken(oldRefreshToken);

    // Immediately rotate: old token is already invalidated at IdP (detects replay)
    res.cookie('refresh_token', newRefresh, { httpOnly: true, secure: true, sameSite: 'strict' });
    res.json({ access_token }); // return only short-lived access_token
  } catch {
    res.clearCookie('refresh_token');
    res.status(401).json({ error: 'Refresh token revoked' }); // possible replay attack
  }
});

ID Token validation and Refresh Token rotation

// ID Token full validation steps (OIDC spec requirements)
async function verifyIdToken(idToken: string, nonce: string) {
  // 1. Fetch JWKS from discovery document
  const discovery = await fetch('https://auth.example.com/.well-known/openid-configuration').then(r => r.json());
  const jwks = await fetch(discovery.jwks_uri).then(r => r.json());

  // 2. Verify signature (same flow as access token)
  const payload = await verifySignature(idToken, jwks);

  // 3. Verify iss: must exactly match IdP URL
  if (payload.iss !== 'https://auth.example.com')
    throw new Error('Invalid issuer');

  // 4. Verify aud: must include this app's client_id
  const audList = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
  if (!audList.includes('YOUR_CLIENT_ID'))
    throw new Error('Invalid audience');

  // 5. Verify exp: token not expired
  if (payload.exp < Math.floor(Date.now() / 1000))
    throw new Error('Token expired');

  // 6. Verify nonce: prevent replay (must match what was sent in the authorize request)
  if (payload.nonce !== nonce)
    throw new Error('Nonce mismatch');

  return payload; // { sub, email, name, ... } — identity claims
}
  • Access token: for the resource server; validate audience, expiry, and scope; do not store in plaintext long-term on the client.
  • Discovery: fetch jwks_uri, end_session, etc. from /.well-known/openid-configuration; do not implicitly trust self-signed metadata in production.
  • Errors and revocation: handle consent denial, invalid_grant, clock skew; document revocation entry points for client and user when supported.

PKCE demo (S256, local only)

Uses crypto.subtle to generate RFC 7636 code_verifier (32 random bytes → Base64URL) and code_challenge (SHA-256 then Base64URL). Computation stays in the browser; nothing is uploaded.

Authorize request: code_challenge_method=S256 and code_challenge from below; token request POSTs code_verifier. Do not use demo values against production accounts unless you fully control the IdP and test redirects.

---
name: oauth-oidc-integration
description: Draft or review OAuth2/OIDC integration
---
# Client type and flow
1. Public client (SPA/native app): auth code + PKCE (S256), no client_secret
2. Confidential client (backend service): auth code + PKCE + client_secret, server-side exchange
3. Service-to-service: Client Credentials Flow, minimize scope

# PKCE implementation key points
4. code_verifier: 32 random bytes, BASE64URL encoded
5. code_challenge: BASE64URL(SHA256(verifier)), method=S256
6. state: random value stored in sessionStorage, exactly verified on callback, prevents CSRF
7. nonce (OIDC): random value bound to ID Token, prevents replay

# Token storage strategy
8. access_token: Memory storage, 15min expiry, do NOT store in localStorage
9. refresh_token: HttpOnly Secure SameSite Cookie, restrict path
10. Rotate refresh_token on each refresh, revoke session if replay detected

# ID Token validation (in order)
11. Verify signature: fetch public key from JWKS endpoint, algorithm allowlist RS256/ES256
12. Verify iss: exact match to IdP URL
13. Verify aud: must include this app's client_id
14. Verify exp: not expired (allow 30-second clock skew)
15. Verify nonce: matches what was sent in authorize request, prevents replay

Back to skills More skills