UNPKG

skills-ws

Version:

87 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.

194 lines (164 loc) • 9.14 kB
## Contents - 1. OAuth 2.1 / OIDC Flows - Discover endpoints (don't hardcode) - Authorization Code + PKCE — full server-side flow with state - Authorization Code + PKCE — public client (SPA/mobile) caveat - Client Credentials Flow (Machine-to-Machine) ## 1. OAuth 2.1 / OIDC Flows OAuth 2.1 (the consolidation of 2.0 + best-practice RFCs) makes **PKCE mandatory for all clients**, forbids the implicit and password grants, and requires exact redirect-URI matching. Use the **Authorization Code flow + PKCE everywhere** (yes, even confidential server-side clients). ### Discover endpoints (don't hardcode) Prefer the provider's OIDC discovery document over hardcoded URLs so endpoints and the JWKS URI stay correct: ```javascript // Fetch once at boot, cache in memory (respect Cache-Control) const discovery = await fetch( 'https://accounts.google.com/.well-known/openid-configuration' ).then(r => r.json()); // => { authorization_endpoint, token_endpoint, jwks_uri, issuer, ... } // Google (as of Jun 2026): authorization_endpoint = https://accounts.google.com/o/oauth2/v2/auth // token_endpoint = https://oauth2.googleapis.com/token // Verify: https://accounts.google.com/.well-known/openid-configuration ``` ### Authorization Code + PKCE — full server-side flow with `state` This is the canonical flow. **`state` and the PKCE `code_verifier` are both persisted server-side before redirect and verified on callback** — skipping either reopens CSRF / login-injection / code-injection. Below, secrets live on the server and only `code_challenge` + `state` ever hit the browser. ```javascript import crypto from 'node:crypto'; const b64url = (buf) => buf.toString('base64url'); // Node >=16 supports 'base64url' function pkcePair() { const verifier = b64url(crypto.randomBytes(32)); // 43-128 chars, high entropy const challenge = b64url(crypto.createHash('sha256').update(verifier).digest()); return { verifier, challenge }; } // --- Step 1: begin login (server route) --- app.get('/auth/login', async (req, res) => { const state = b64url(crypto.randomBytes(32)); const nonce = b64url(crypto.randomBytes(32)); // OIDC: binds id_token to this session const { verifier, challenge } = pkcePair(); // PERSIST state + verifier + nonce server-side, keyed to THIS session, BEFORE redirecting. // Short TTL; single use. (Express-session shown; a signed httpOnly cookie also works.) req.session.oauth = { state, nonce, verifier, createdAt: Date.now() }; const url = new URL(discovery.authorization_endpoint); url.searchParams.set('client_id', process.env.OAUTH_CLIENT_ID); url.searchParams.set('redirect_uri', process.env.OAUTH_REDIRECT_URI); // must EXACTLY match registered URI url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', 'openid email profile'); url.searchParams.set('code_challenge', challenge); url.searchParams.set('code_challenge_method', 'S256'); url.searchParams.set('state', state); url.searchParams.set('nonce', nonce); res.redirect(url.toString()); }); // --- Step 2: callback (server route) — VALIDATE everything --- app.get('/auth/callback', async (req, res) => { const { code, state } = req.query; const saved = req.session.oauth; delete req.session.oauth; // consume immediately so it can't be replayed // (a) provider returned an error? if (req.query.error) return res.status(400).send(`OAuth error: ${req.query.error}`); // (b) we actually started a flow, and it hasn't expired if (!saved || Date.now() - saved.createdAt > 10 * 60 * 1000) { return res.status(400).send('No pending OAuth flow / expired'); } // (c) STATE MUST MATCH — constant-time compare to avoid timing oracles const ok = typeof state === 'string' && state.length === saved.state.length && crypto.timingSafeEqual(Buffer.from(state), Buffer.from(saved.state)); if (!ok) return res.status(403).send('Invalid OAuth state'); // CSRF / login-injection blocked here // (d) need a code if (typeof code !== 'string' || !code) return res.status(400).send('Missing authorization code'); // (e) exchange code — token request is form-encoded; include the PKCE verifier we persisted const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: process.env.OAUTH_REDIRECT_URI, client_id: process.env.OAUTH_CLIENT_ID, code_verifier: saved.verifier, // proves we started this exact flow }); // Confidential clients add their secret (Basic auth header preferred over body params): const headers = { 'Content-Type': 'application/x-www-form-urlencoded' }; if (process.env.OAUTH_CLIENT_SECRET) { const basic = Buffer.from( `${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}` ).toString('base64'); headers.Authorization = `Basic ${basic}`; } const tokenRes = await fetch(discovery.token_endpoint, { method: 'POST', headers, body }); if (!tokenRes.ok) return res.status(401).send('Token exchange failed'); const tokens = await tokenRes.json(); // { access_token, refresh_token, id_token, expires_in } // (f) Validate the OIDC id_token signature/iss/aud/exp AND that nonce matches saved.nonce // (see §2 verifyToken; pass audience = OAUTH_CLIENT_ID and check decoded.nonce === saved.nonce) // (g) Establish your OWN session here (don't hand provider tokens to the browser). res.redirect('/'); }); ``` ### Authorization Code + PKCE — public client (SPA/mobile) caveat A SPA cannot keep `state`/`verifier` truly secret from XSS. `sessionStorage` survives a redirect but is JS-readable. **Preferred 2026 pattern: run the code exchange in a BFF** so the browser never holds tokens. If you must do it browser-side, still generate and check `state`, and still send the `code_verifier`: ```javascript // Browser: begin const { verifier, challenge } = await pkcePairWebCrypto(); // Web Crypto version below const state = crypto.randomUUID(); sessionStorage.setItem('pkce_verifier', verifier); sessionStorage.setItem('oauth_state', state); const url = new URL(discovery.authorization_endpoint); url.searchParams.set('client_id', CLIENT_ID); url.searchParams.set('redirect_uri', REDIRECT_URI); url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', 'openid email profile'); url.searchParams.set('code_challenge', challenge); url.searchParams.set('code_challenge_method', 'S256'); url.searchParams.set('state', state); location.href = url.toString(); // Browser: callback — VALIDATE state before exchanging const params = new URLSearchParams(location.search); const code = params.get('code'); const returnedState = params.get('state'); const savedState = sessionStorage.getItem('oauth_state'); const verifier = sessionStorage.getItem('pkce_verifier'); sessionStorage.removeItem('oauth_state'); sessionStorage.removeItem('pkce_verifier'); if (!code || !returnedState || returnedState !== savedState) { throw new Error('Invalid OAuth state or missing code'); // stop — do not exchange } const res = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, code_verifier: verifier, }), }); const tokens = await res.json(); // Web Crypto PKCE (browser) async function pkcePairWebCrypto() { const bytes = crypto.getRandomValues(new Uint8Array(32)); const verifier = btoa(String.fromCharCode(...bytes)) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 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(/=+$/, ''); return { verifier, challenge }; } ``` ### Client Credentials Flow (Machine-to-Machine) For backend services and API-to-API calls. No user. Token requests are **form-encoded** per RFC 6749, the secret stays server-side, and you should **cache the token** until shortly before `expires_in` rather than minting one per call. ```javascript let cached = { token: null, exp: 0 }; async function getServiceToken() { if (cached.token && Date.now() < cached.exp - 60_000) return cached.token; // 60s safety margin const res = await fetch(process.env.OAUTH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: process.env.CLIENT_ID, client_secret: process.env.CLIENT_SECRET, // server-only; never ship to a browser audience: 'https://api.example.com', // provider-specific (Auth0 uses `audience`) scope: 'read:things write:things', }), }); if (!res.ok) throw new Error(`Token request failed: ${res.status}`); const { access_token, expires_in } = await res.json(); cached = { token: access_token, exp: Date.now() + expires_in * 1000 }; return access_token; } ``` ---