OAuth/OIDC 集成
本页提供授权码 + PKCE 完整实现(含 state 防 CSRF)、Token 存储策略安全对比、Refresh Token 轮换代码、ID Token 验证步骤,帮助 Agent 与后端以可审计方式对接第三方身份提供方。
优先授权码 + PKCE 于公共客户端;机密客户端保管 client_secret 于服务端。区分 access token(调用资源)与 id token(身份声明),校验 issuer、audience 与签名算法白名单。
SKILL 可引导列出 redirect URI、scope 与离线刷新策略;生成代码时强调 token 不落模型上下文、不写入仓库,刷新令牌仅服务端存储。
与 IdP 联调时覆盖错误路径:consent 拒绝、token 过期、revocation;文档中写明各环境的 well-known URL 与客户端 ID 占位符。
- state/nonce 防 CSRF 与重放;严禁在 URL 中传递长期 secret。
- 权限收敛:按最小 scope 申请,并在 UI 与日志中可解释。
- 多租户时注意 tenant 专属的授权端点与元数据。
公共客户端:授权码 + PKCE 完整实现
[ 用户代理:浏览器 / 原生 App ]
│
▼
[ 生成 code_verifier;code_challenge = BASE64URL(SHA256(verifier));method=S256 ]
│
▼
[ 重定向授权端点:response_type=code + code_challenge* + state + nonce(OIDC)]
│
▼
[ 用户在 IdP 登录并同意 scope ]
│
▼
[ 回调 redirect_uri:?code=…&state=… ]
│
▼
[ 校验 state;POST 令牌端点:code + code_verifier(通常无 client_secret)]
│
▼
[ access_token / id_token / refresh_token(若授权);校验 iss、aud、签名与 alg 白名单 ]
// 完整授权码 + PKCE 流程实现(浏览器端)
async function startAuthFlow() {
// 生成 code_verifier(32字节随机)
const verifier = btoa(String.fromCharCode(
...crypto.getRandomValues(new Uint8Array(32))
)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
// 计算 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 防 CSRF:随机值存入 sessionStorage,回调时校验
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 防护
code_challenge: challenge,
code_challenge_method: 'S256',
nonce: crypto.randomUUID(), // OIDC 重放防护
});
window.location.href = `https://auth.example.com/authorize?${params}`;
}
// 回调处理:校验 state → 用 code + verifier 换 token
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 }
}
code_verifier 仅在换票前保存在客户端侧安全存储;授权请求只带 code_challenge。禁止把 verifier 写进可分享链接或日志。
Token 存储策略与安全对比
// Token 存储三种策略安全对比
// ❌ 策略1: localStorage — XSS 可读,不推荐存 refresh_token
localStorage.setItem('access_token', token); // 任何 JS 可读,XSS 直接泄露
// ⚠️ 策略2: Memory(变量)— 刷新页面丢失,但 XSS 受限
let accessToken: string | null = null;
function setAccessToken(t: string) { accessToken = t; } // 页面刷新后丢失
// ✅ 策略3: HttpOnly Cookie — JS 不可读,推荐存 refresh_token
// 服务端设置(Node.js/Express):
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // JS 不可访问,防 XSS
secure: true, // 仅 HTTPS
sameSite: 'strict', // 防 CSRF
path: '/auth/refresh', // 限制 Cookie 发送路径
maxAge: 7 * 24 * 60 * 60 * 1000, // 7天
});
// access_token 存 Memory(短期,15min),refresh_token 存 HttpOnly Cookie
// Refresh Token 轮换实现:每次刷新返回新的 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 {
// 从 IdP 换取新 token 对(rotation: 旧 refresh_token 同时失效)
const { access_token, refresh_token: newRefresh } = await exchangeRefreshToken(oldRefreshToken);
// 立即轮换:旧 token 已在 IdP 侧失效(检测重放)
res.cookie('refresh_token', newRefresh, { httpOnly: true, secure: true, sameSite: 'strict' });
res.json({ access_token }); // 仅返回短期 access_token
} catch {
res.clearCookie('refresh_token');
res.status(401).json({ error: 'Refresh token revoked' }); // 可能是重放攻击
}
});
ID Token 验证与 Refresh Token 轮换
// ID Token 完整验证步骤(OIDC 规范要求)
async function verifyIdToken(idToken: string, nonce: string) {
// 1. 从发现文档获取 JWKS
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. 验证签名(与 access token 相同流程)
const payload = await verifySignature(idToken, jwks);
// 3. 验证 iss:必须精确匹配 IdP URL
if (payload.iss !== 'https://auth.example.com')
throw new Error('Invalid issuer');
// 4. 验证 aud:必须包含本应用的 client_id
const audList = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!audList.includes('YOUR_CLIENT_ID'))
throw new Error('Invalid audience');
// 5. 验证 exp:token 未过期
if (payload.exp < Math.floor(Date.now() / 1000))
throw new Error('Token expired');
// 6. 验证 nonce:防重放(必须与授权请求时一致)
if (payload.nonce !== nonce)
throw new Error('Nonce mismatch');
return payload; // { sub, email, name, ... } — 身份声明
}
- Access token:面向资源服务器;校验 audience、过期与 scope,不在前端长期明文存放。
-
发现文档:从
/.well-known/openid-configuration拉取 jwks_uri、end_session 等;生产禁用隐式信任自签名元数据。 -
错误与吊销:处理 consent 拒绝、
invalid_grant、时钟偏移;支持 revocation 时文档化客户端与用户的撤销入口。
PKCE 演示器(S256,仅本地)
使用浏览器 crypto.subtle 生成符合 RFC 7636 的随机 code_verifier(32 字节 → Base64URL)及 code_challenge(SHA-256 后再 Base64URL)。计算仅在本地完成,不会上传到服务器。
授权请求参数:code_challenge_method=S256,code_challenge 填下框;换票时 POST code_verifier。勿将演示值用于生产账户,除非你完全控制 IdP 与 redirect 的测试环境。
---
name: oauth-oidc-integration
description: 起草或审查 OAuth2/OIDC 集成的安全实现
---
# 客户端类型与流程
1. 公共客户端(SPA/原生App):授权码 + PKCE(S256),无 client_secret
2. 机密客户端(后端服务):授权码 + PKCE + client_secret,服务端换票
3. 服务间调用:Client Credentials Flow,scope 最小化
# PKCE 实现要点
4. code_verifier:32字节随机,BASE64URL 编码
5. code_challenge:BASE64URL(SHA256(verifier)),method=S256
6. state:随机值存 sessionStorage,回调时精确校验,防 CSRF
7. nonce(OIDC):随机值绑定 ID Token,防重放
# Token 存储策略
8. access_token:Memory 存储,15min 有效期,不落 localStorage
9. refresh_token:HttpOnly Secure SameSite Cookie,限制 path
10. 每次刷新轮换 refresh_token,检测重放即吊销会话
# ID Token 验证(按顺序)
11. 验证签名:从 JWKS 端点获取公钥,算法白名单 RS256/ES256
12. 验证 iss:精确匹配 IdP URL
13. 验证 aud:必须包含本应用 client_id
14. 验证 exp:未过期(容忍30秒时钟偏移)
15. 验证 nonce:与授权请求时一致,防重放