用 Claude Code 实现 OAuth:PKCE、state、nonce 与安全令牌存储
面向初学者的 OAuth 2.1/2.0 授权码+PKCE 实战指南,覆盖 Claude Code、state、nonce、重定向和令牌保存。
OAuth 不是放一个“使用 Google 登录”按钮就结束了。实现细节稍有问题,就可能出现账号绑定错误、授权码被重复使用、重定向地址被替换、令牌被前端脚本读取等事故。本文说明如何让 Claude Code 搭建 OAuth 代码,同时不泄露真实密钥,并用安全审查的方式验收结果。
实际项目里,优先选择 OAuth 2.0 Authorization Code + PKCE,这也符合 OAuth 2.1 的方向。PKCE 可以理解为:登录开始时生成一个只有客户端知道的原始值,授权服务器只看到它的哈希挑战,换取令牌时客户端必须证明自己还持有原始值。相关基础可以继续阅读 Claude Code 入门、Claude Code 认证实现 和 Claude Code API 开发。
推荐架构
Claude Code 适合生成路由、会话、测试和审查清单,但不要把真实 client secret、refresh token、生产 .env 或控制台截图贴给它。只提供变量名、行为要求和失败用例。
| 项目 | 推荐做法 | 原因 |
|---|---|---|
| 流程 | Authorization Code + PKCE | 避免旧式隐式流程暴露令牌 |
| CSRF | 保存并校验 state | 防止伪造回调完成登录 |
| OIDC 重放 | 保存并校验 nonce | 检测旧身份结果被重放 |
| redirect URI | 精确白名单匹配 | 防止跳转地址被替换 |
| 令牌 | 服务端会话或加密存储 | 不把长期令牌放进 localStorage |
| Claude Code 输入 | 只给占位值和规格 | 避免秘密进入提示词或日志 |
主要参考:OAuth 2.1、RFC 7636 PKCE、RFC 9700 OAuth 2.0 Security BCP、OpenID Connect Core、OWASP OAuth2 Cheat Sheet、Claude Code Security。
三个真实场景
第一是 B2B SaaS 管理后台。用户通过 Google Workspace 或 Microsoft Entra ID 登录,但应用仍要映射到内部用户、组织和角色。OAuth 只证明“这个人是谁”,应用授权层决定“他能做什么”。
第二是外部 API 授权。日历、邮件、云盘和 CRM 集成都需要用户同意和刷新令牌管理。scope 要尽量小,refresh token 要加密保存,并提供断开连接和撤销流程。
第三是内部工具或 MCP 风格服务。很多团队一开始用固定 API key,后来会在撤销、审计和权限分离上遇到困难。OAuth/OIDC 能让登录、同意、过期和身份边界更清楚。
移动应用和 SPA 也很典型,因为它们无法安全隐藏 client secret,所以必须使用 PKCE。即使是传统服务端 Web,也建议默认加入 PKCE,审查会更简单。
可直接运行的本地演示
下面的演示不需要 Google、Microsoft 或 Auth0 账号。一个 Express 进程同时扮演 OAuth 客户端和模拟授权服务器,方便观察 state、nonce、PKCE S256、精确 redirect URI、一次性授权码和服务端令牌保存。
在空目录创建两个文件,运行 npm install && npm start,然后打开 http://localhost:3000。
{
"scripts": { "start": "node server.mjs" },
"dependencies": { "express": "^4.19.2", "express-session": "^1.18.0" },
"engines": { "node": ">=20" }
}
// server.mjs
import crypto from "node:crypto";
import express from "express";
import session from "express-session";
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(session({
name: "oauth_demo_sid",
secret: "dev-only-change-this-32-byte-secret",
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: "lax", secure: false, maxAge: 10 * 60 * 1000 },
}));
const client = {
clientId: "claude-code-demo",
redirectUri: "http://localhost:3000/callback",
scope: "openid profile email",
};
const authorizationEndpoint = "http://localhost:3000/mock/authorize";
const tokenEndpoint = "http://localhost:3000/mock/token";
const registeredRedirectUris = new Set([client.redirectUri]);
const pendingCodes = new Map();
function randomUrlSafe(bytes = 32) {
return crypto.randomBytes(bytes).toString("base64url");
}
function sha256Base64Url(value) {
return crypto.createHash("sha256").update(value).digest("base64url");
}
function fail(res, status, message) {
return res.status(status).type("text/plain").send(message);
}
app.get("/", (_req, res) => {
res.type("html").send(`<h1>OAuth PKCE local demo</h1><p><a href="/auth/login">Start login</a></p>`);
});
app.get("/auth/login", (req, res) => {
const state = randomUrlSafe();
const nonce = randomUrlSafe();
const codeVerifier = randomUrlSafe(48);
const codeChallenge = sha256Base64Url(codeVerifier);
req.session.oauth = { state, nonce, codeVerifier, createdAt: Date.now() };
const params = new URLSearchParams({
response_type: "code",
client_id: client.clientId,
redirect_uri: client.redirectUri,
scope: client.scope,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
res.redirect(`${authorizationEndpoint}?${params}`);
});
app.get("/mock/authorize", (req, res) => {
const p = req.query;
const redirectUri = String(p.redirect_uri || "");
if (p.response_type !== "code") return fail(res, 400, "response_type must be code");
if (p.client_id !== client.clientId) return fail(res, 400, "unknown client_id");
if (!registeredRedirectUris.has(redirectUri)) return fail(res, 400, "redirect_uri is not registered exactly");
if (p.code_challenge_method !== "S256") return fail(res, 400, "PKCE S256 is required");
if (!p.code_challenge || !p.state || !p.nonce) return fail(res, 400, "missing state, nonce, or PKCE challenge");
const code = randomUrlSafe(24);
pendingCodes.set(code, {
clientId: client.clientId,
redirectUri,
codeChallenge: String(p.code_challenge),
nonce: String(p.nonce),
expiresAt: Date.now() + 60_000,
used: false,
});
const redirect = new URL(redirectUri);
redirect.searchParams.set("code", code);
redirect.searchParams.set("state", String(p.state));
res.redirect(redirect.toString());
});
app.get("/callback", async (req, res) => {
const oauth = req.session.oauth;
const code = String(req.query.code || "");
const returnedState = String(req.query.state || "");
if (!oauth) return fail(res, 400, "missing OAuth session");
if (returnedState !== oauth.state) return fail(res, 403, "state mismatch: possible CSRF or mixed login attempt");
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: client.redirectUri,
client_id: client.clientId,
code_verifier: oauth.codeVerifier,
}),
});
const tokens = await response.json();
if (!response.ok) return fail(res, response.status, JSON.stringify(tokens, null, 2));
if (tokens.nonce !== oauth.nonce) return fail(res, 403, "nonce mismatch: possible replay");
req.session.oauth = undefined;
req.session.tokenSet = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
};
res.redirect("/dashboard");
});
app.post("/mock/token", (req, res) => {
const body = req.body;
const record = pendingCodes.get(body.code);
if (body.grant_type !== "authorization_code") return res.status(400).json({ error: "unsupported_grant_type" });
if (!record || record.used || record.expiresAt < Date.now()) return res.status(400).json({ error: "invalid_grant" });
if (body.client_id !== record.clientId) return res.status(400).json({ error: "invalid_client" });
if (body.redirect_uri !== record.redirectUri) return res.status(400).json({ error: "invalid_redirect_uri" });
if (sha256Base64Url(body.code_verifier || "") !== record.codeChallenge) return res.status(400).json({ error: "invalid_code_verifier" });
record.used = true;
res.json({ token_type: "Bearer", access_token: randomUrlSafe(32), refresh_token: randomUrlSafe(32), expires_in: 300, nonce: record.nonce });
});
app.get("/dashboard", (req, res) => {
const tokenSet = req.session.tokenSet;
if (!tokenSet) return res.redirect("/auth/login");
const secondsLeft = Math.max(0, Math.floor((tokenSet.expiresAt - Date.now()) / 1000));
res.type("html").send(`<h1>Logged in</h1><p>Access token is stored server-side, not in localStorage.</p><p>Expires in ${secondsLeft} seconds.</p>`);
});
app.listen(3000, () => console.log("Open http://localhost:3000"));
重点看五处:/auth/login 把 state、nonce、code_verifier 放进服务端会话;授权请求只发送 code_challenge;/callback 校验 state;/mock/token 重新计算 S256;/dashboard 从服务端会话读取令牌,而不是从 localStorage 读取。
给 Claude Code 的提示词
请用 Express + TypeScript 实现 OAuth 2.0 Authorization Code + PKCE。
要求:
- 只定义环境变量名,不写真实密钥。
- state、nonce、code_verifier 存在服务端会话。
- redirect_uri 必须与配置值精确匹配。
- 只允许 code_challenge_method=S256。
- access token 和 refresh token 不得保存到 localStorage。
- refresh token 只能加密入库或保存在服务端会话。
- 添加成功、state 不一致、PKCE 不一致、授权码过期、授权码重用测试。
- 最后输出安全审查清单。
审查时再要求:
请按照 RFC 9700、RFC 7636、OWASP OAuth2 Cheat Sheet 审查这段 OAuth 实现。
检查秘密是否出现在日志、测试快照、前端 bundle 或 Git diff 中。
按 High/Medium/Low 标注风险,并给出修复补丁。
常见失败点
不要用前缀匹配验证 redirect URI。https://app.example.com.evil.test/callback 不是你的应用。应使用注册 URI 的精确匹配。
不要只生成 state 却不校验。登录开始时保存,回调时比较,使用后删除。如果要支持多标签页,可以用 state 作为键保存短生命周期事务。
不要随便允许 PKCE plain。默认只接受 S256,不记录 verifier,授权码要短期有效且只能使用一次。
如果使用 OIDC ID Token,nonce 只是其中一项。还要验证 JWT 签名、issuer、audience、expiry,以及 nonce。ID Token 表示身份,Access Token 表示访问 API 的授权,两者不能混用。
长期令牌不要放在 localStorage。普通 Web 应用更适合服务端会话、加密令牌表、短 Cookie 生命周期和撤销流程。Cookie 细节可参考 Claude Code Cookie 管理。
可商业交付的检查清单
- 令牌寿命和刷新策略已经写进文档。
- 团队能用自己的语言解释
state、nonce和 PKCE。 - redirect URI、scope、provider 控制台设置都有记录。
- 日志不会输出 code、verifier、token 或 cookie 值。
- 失败信息不会暴露内部 provider 细节。
- 测试覆盖成功、不一致、过期和重用。
- Claude Code 生成的差分已对照官方规范审查。
ClaudeCodeLab 的培训和咨询可以把这套流程变成团队工作坊:带上现有 OAuth 代码,梳理风险,补测试,并留下可重复使用的审查清单。可查看 ClaudeCodeLab 培训。
动手验证结果
这个演示用于完整跑通本地流程:开始登录、模拟授权、回调、换取令牌、显示 dashboard。如果篡改返回的 state,会得到 state mismatch;如果 verifier 错误,令牌端点会返回 invalid_code_verifier。我以前做 OAuth 原型时,最容易漏掉的不是正常流程,而是多标签页、后退按钮、过期授权码和日志泄露。把这些失败场景写进 Claude Code 的第一条提示词,质量会明显提高。
总结
OAuth 的重点不是按钮,而是保护从登录开始到令牌保存的整条事务。默认采用 Authorization Code + PKCE,校验 state 和 nonce,精确匹配 redirect URI,并把令牌留在服务端。Claude Code 可以加速实现,但前提是不给它真实秘密,并用官方规范审查输出。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。