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