Use Cases (更新: 2026/6/2)

用 Claude Code 实现 OAuth:PKCE、state、nonce 与安全令牌存储

面向初学者的 OAuth 2.1/2.0 授权码+PKCE 实战指南,覆盖 Claude Code、state、nonce、重定向和令牌保存。

用 Claude Code 实现 OAuth:PKCE、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.1RFC 7636 PKCERFC 9700 OAuth 2.0 Security BCPOpenID Connect CoreOWASP OAuth2 Cheat SheetClaude 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 客户端和模拟授权服务器,方便观察 statenonce、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/loginstatenoncecode_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 管理

可商业交付的检查清单

  • 令牌寿命和刷新策略已经写进文档。
  • 团队能用自己的语言解释 statenonce 和 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,校验 statenonce,精确匹配 redirect URI,并把令牌留在服务端。Claude Code 可以加速实现,但前提是不给它真实秘密,并用官方规范审查输出。

#Claude Code #OAuth #authentication #security #TypeScript
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。