<!-- skill: OAuth Login | version: 1.0.0 | description: OAuth2 PKCE, sessions vs JWT, social login and refresh | category: Web -->
## Instructions
- Flow choice: Authorization Code + PKCE for web/SPA/native (never Implicit); `code_challenge=S256(code_verifier)`, `state` CSRF nonce + `nonce` OIDC replay guard; validate `iss/aud/exp` on id_token.
- Provider setup: Google/GitHub `client_id` + redirect `https://app.example.com/auth/callback` allowlisted exact-match; scopes minimal (`openid email profile`); separate dev/prod OAuth apps + secrets.
- Session cookies vs JWT: server-rendered → httpOnly `SameSite=Lax Secure` session cookie (opaque id, Redis/DB store); SPA/API → short JWT access (5-15m) + rotating refresh (httpOnly); never localStorage tokens for sessions.
- Login handler: `/auth/login` builds `authorizationUrl` with PKCE/state in cookie, `/auth/callback` exchanges `code+verifier` → `id_token/access`, `upsertUser(sub,email)`, creates session, `302 /app`; errors `?error=denied` friendly.
- Token verify: RS256 `jwks_uri` cached (`getKey(kid)`), check `exp/iss/aud`, `azp` for multi-client; backend `requireAuth` middleware `401` JSON vs `302` page; clock skew 60s leeway.
- Refresh/rotation: `POST /auth/refresh` reads httpOnly refresh, verifies + rotates (new refresh, invalidate old, reuse detection → revoke family); sliding 7d idle, absolute 30d; `revoke` on logout/password change.
- Social mapping: link `provider:sub` → user row (`accounts` table multi-provider), pre-fill `email_verified` only from provider, block login if email exists with different provider unless `link_token` proof.
- Logout: `/auth/logout` clears cookies + revokes refresh + `end_session_endpoint?id_hint+h post_logout_redirect`; front-channel `<iframe>` for SSO apps; show "logged out everywhere" confirm.
- MFA/step-up: `amr` claim check, `acr: mfa` required for `/billing`, `max_age=0` force re-auth before sensitive; TOTP/WebAuthn second factor via provider or `otplib`; recovery codes hashed.
- Security: `state` one-time 10m, PKCE `verifier` httpOnly, `redirect_uri` strict compare (no open redirect), rate-limit `/auth/*` 20/min/IP, log `login success/fail` with IP/UA; Secrets in vault, never frontend.
- Frontend: `useSession()` hook (`/api/me` 200/401), guard routes (middleware 302, React `<RequireAuth>`), silent refresh `setInterval 4m` + on 401 retry once; CSRF `SameSite` + double-submit for cookie POSTs.
- Testing: stub IdP (`mock-oauth2-server` Docker) in CI, Playwright login with `test-user` code exchange, expiry/refresh/reuse-detection cases, logout clears cookies asserted.
## Examples
- SaaS Google login: PKCE cookie flow, session cookie + Postgres store, `hooks/middleware` gate `/app`, org invite auto-accept by email domain, logout revokes + end-session.
- SPA + API JWT: PKCE in SPA, 10m access in memory + rotating refresh httpOnly, Axios 401→refresh→retry once, reuse detection revokes family + email alert.
- Multi-provider: Google + GitHub linked to one user via `accounts` table, `email_verified` gating, step-up MFA for billing, admin force-revoke sessions.
## Workflows
1. Register: dev/prod OAuth apps, exact redirect URIs, minimal scopes, store `CLIENT_ID/SECRET` in vault, JWKS URL noted.
2. Build flow: login URL (PKCE+state) → callback exchange → verify id_token → upsert user → session/JWT issuance.
3. Session: cookie vs JWT per arch, refresh rotation + reuse detection, `/me` + guards, silent refresh client.
4. Social/MFA: multi-provider link table, `email_verified` checks, step-up `amr/acr` for sensitive routes, logout + end-session.
5. Harden: strict redirect, rate limits, audit logs, mock-IdP Playwright tests, secret rotation runbook.
## Templates
- PKCE login + callback (Node):
```ts
import { generators, Issuer } from 'openid-client';
const iss = await Issuer.discover('https://accounts.google.com');
const client = new iss.Client({ client_id: process.env.GOOGLE_ID!, client_secret: process.env.GOOGLE_SECRET!,
  redirect_uris: ['https://app.example.com/auth/callback'], response_types: ['code'] });
// /auth/login
const verifier = generators.codeVerifier(), challenge = generators.codeChallenge(verifier);
const state = crypto.randomUUID();
res.cookie('pkce', JSON.stringify({ verifier, state }), { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 600000 });
res.redirect(client.authorizationUrl({ scope: 'openid email profile', code_challenge: challenge, code_challenge_method: 'S256', state }));
// /auth/callback
const { code, state: back } = req.query as any;
const saved = JSON.parse(req.cookies.pkce); if (back !== saved.state) throw new Error('csrf');
const tokens = await client.callback('https://app.example.com/auth/callback', { code, state: back }, { code_verifier: saved.verifier });
const user = await upsertUser(tokens.claims()); // sub, email, email_verified
req.session.userId = user.id; res.redirect('/app');
```
- Refresh rotation + middleware:
```ts
app.post('/auth/refresh', async (req, res) => {
  const old = req.cookies.refresh; const rec = await getRefresh(hash(old));
  if (!rec) return res.sendStatus(401);
  if (rec.used) { await revokeFamily(rec.family); return res.sendStatus(401); } // reuse = theft
  await markUsed(rec.id);
  const next = newRefresh(rec.userId, rec.family); // new token, same family
  res.cookie('refresh', next.token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 864e5 });
  res.json({ access: signAccess(rec.userId) });
});
function requireAuth(req, res, next) {
  const u = verifyAccess(req); if (!u) return res.status(401).json({ error: 'unauthorized' });
  req.user = u; next();
}
```
- React guard + silent refresh:
```tsx
export function useSession() {
  const [user, setUser] = useState<any>(null);
  useEffect(() => { fetch('/api/me').then((r) => (r.ok ? r.json() : null)).then(setUser);
    const t = setInterval(async () => {
      const r = await fetch('/auth/refresh', { method: 'POST' });
      if (!r.ok) setUser(null); }, 4 * 60e3); return () => clearInterval(t); }, []);
  return user;
}
export function RequireAuth({ children }: any) {
  const u = useSession(); if (u === null) return <p>Loading…</p>;
  if (!u) { location.href = '/auth/login'; return null; } return children;
}
```
## Tools
- SARAH shell_run for `mock-oauth2-server` CI, `curl` code/token exchange, Playwright login/logout specs.
- SARAH file_write for auth-routes/middleware/session-hooks; web_fetch for OAuth2/OIDC + provider docs.
## Advanced
- BFF pattern: SPA talks same-origin BFF (`/api/*` with session cookie), BFF holds tokens + calls downstream with `client_credentials`; tokens never touch browser, WAF + CSRF at BFF edge.
- Org SSO: `organization` prompt + `domain_hint`, SAML→OIDC bridge (WorkOS/Auth0), SCIM provision/deprovision webhook, `jit` role mapping `groups→roles`, audit `sso login` per org.
- Key rotation: dual JWKS `kid` support, `client_secret` versioned with 24h overlap, refresh-family anomaly alert (>2 reuses/hr), quarterly `rotate` drill + revoke-all runbook.
## Documentation
- https://oauth.net/2/pkce/
- https://openid.net/connect/
- https://developers.google.com/identity/openid-connect
- https://docs.github.com/en/apps/oauth-apps
- https://www.rfc-editor.org/rfc/rfc6749
