最近在做AI应用的小项目时,需要在本地快速拿到多个测试账号的Anthropic API密钥。最开始都是打开控制台,手动创建密钥、复制、再粘到配置文件里,来回折腾十几分钟,账号一多还容易搞混。后来我干脆用Bun写了个小工具,让用户走OAuth授权流程,在浏览器里点一下确认,密钥就自动写入本地文件,整个过程用不了半分钟。
这篇文章把工具的设计思路、OAuth PKCE的核心原理、以及Bun的具体实现完整拆一遍。如果你也想给自己的本地工具或CLI做类似的一次性授权接入,或者单纯想搞明白PKCE在真实场景里怎么落地、有哪些坑,这篇应该能给你省不少时间。
1. 整体设计与技术选型
1.1 需求从哪来
先还原一下我当时面对的痛点。我给一个内部演示项目做多环境支持,需要同时维护开发机、测试服的API凭据。按常规做法,我得定期登录控制台,创建密钥,然后分发给不同机器。但这带来几个问题:
密钥数量一多,根本记不清哪个key对应哪个环境,删错了只能等下一轮全部重新签发。- 密钥会过期,手动续期很容易漏。
- 如果把主账号的密钥直接复制到多台机器,一旦泄露就需要撤销重来,审计上也不可控。
所以核心需求其实是:让每个开发者用自己的账号完成授权,生成的密钥只落在自己本机,同时整个过程能被脚本固化下来,不依赖手工点鼠标。顺着这个需求,我决定把OAuth授权码流程做成一个本地一键工具。
这里有个关键点:工具本身不是为了绕过权限审核,而是把“创建API密钥”这个官方流程自动化。用户仍然需要在授权页面输入自己的账号密码并确认授权,只是省去了控制台里创建、复制、粘贴这些重复劳动。
1.2 为什么选OAuth PKCE而不是其他方式
刚开始我考虑过两种实现:一是直接调用服务商的控制台接口,二是让用户填账号密码由脚本代为登录。这两个方案很快被否决了。
直接调用控制台接口,意味着我需要持有用户的静态凭据,这对本地工具来说是很大的安全隐患,而且控制台往往不支持接口创建密钥。让用户填密码则更糟,脚本会接触到明文密码,即使只在内存里过一遍,也存在被日志、崩溃转储带出去的风险。
最终选了OAuth授权码配合PKCE。这个模式专门解决“不可信客户端”的场景,也就是没有能力保守client secret的纯前端应用或本地CLI工具。它允许客户端先用一个临时凭证(code_verifier)参与授权,再用这个临时凭证去换令牌,即使授权码在中途被窃取,没有原始verifier也换不到令牌。
如果你的授权服务器支持,PKCE比直接使用client secret更安全。本地工具本质上是“公开客户端”,不能像后端服务一样把secret藏在服务器上,PKCE就是为这种场景设计的标准解法。
1.3 为什么用Bun而不是Node
选择Bun最大的理由是开发体验。整个工具需要一个本地HTTP服务器来接收OAuth回调,一个能打开系统浏览器的入口,还要处理crypto和文件写入。这些在Bun里几乎都是开箱即用:
Bun.serve一行就能起一个HTTP服务,不需要额外引入Express。- 原生支持TypeScript,我可以直接写
.ts文件,不需要tsc编译。 - 内置Web标准API,
fetch、crypto.getRandomValues、Bun.write都直接用。 - 启动时间非常短,适合这种执行一次就退出的CLI工具。
用Node写也不是不行,但至少要多装两个依赖、配置tsconfig、还要处理CommonJS和ESM的路径问题。Bun把这些胶水全部去掉了,让我可以把注意力放在OAuth流程本身。
2. OAuth PKCE原理与关键参数
2.1 授权码模式加上了什么
标准的OAuth授权码流程是:客户端引导用户到授权服务器,用户登录并确认授权,服务器带着一个code重定向回客户端,客户端再用这个code去token端点换access_token。
这个流程在没有PKCE时依赖于client_secret的存在来证明“请求token的就是刚才发起授权的客户端”。但本地CLI工具没有安全的secret存储位置,所以PKCE在发起授权时额外生成两个东西:
code_verifier,一个高熵随机字符串,客户端自己保存。code_challenge,由verifier通过SHA-256哈希后再做base64url编码得到。
授权服务器在签发code时记住challenge,并在token交换时要求客户端出示verifier。服务器重新计算双方的匹配关系,匹配才发token。这样即使攻击者截获了code,由于拿不到verifier,也无法完成token交换。
2.2 code_verifier生成和challenge计算细节
这一步是整个流程最容易出错的地方,很多实现跑不通都是因为编码没做对。
code_verifier的规范要求是43到128个字符,只包含A-Z、a-z、0-9、-、.、_、~。我一般生成64字节随机数,然后做base64url编码再截断到要求长度。
base64url编码和普通base64有三点区别:+替换成-,/替换成_,末尾的=全部去掉。很多网上代码漏掉去等号这一步,导致challenge和verifier在服务器端校验失败。
import { createHash } from "node:crypto"; function base64URLEncode(input: string | Buffer): string { return Buffer.from(input) .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); } function generateCodeVerifier(length: number = 64): string { const bytes = new Uint8Array(length); crypto.getRandomValues(bytes); return base64URLEncode(Buffer.from(bytes)).slice(0, length); } function generateCodeChallenge(verifier: string): string { return base64URLEncode(createHash("sha256").update(verifier).digest()); }注意crypto.getRandomValues填的是Uint8Array,不是普通数组,否则拿不到真正的随机字节。Bun里全局有Web Crypto的crypto对象,直接就能用。
提示:
code_verifier只应该保存在发起授权的客户端内存里,不要写入日志,更不要提交到Git仓库。它是换取token的凭证,泄露等于授权泄露。
2.3 授权URL和令牌交换的参数清单
构建授权URL时,除了常见的client_id、redirect_uri、response_type=code之外,PKCE模式下必须带上:
| 参数 | 值 | 说明 |
|---|---|---|
| client_id | 申请到的客户端ID | 由服务商分配 |
| redirect_uri | 本地回调地址 | 必须与服务商注册的完全一致 |
| response_type | code | 授权码模式 |
| scope | 需要的权限范围 | 按服务商文档填写 |
| state | 随机字符串 | 防CSRF,回调时要校验 |
| code_challenge | 见上文计算 | PKCE核心参数之一 |
| code_challenge_method | S256 | 推荐,plain不安全 |
token交换的请求是POST到token端点,Content-Type为application/x-www-form-urlencoded,参数包括grant_type=authorization_code、code、redirect_uri、client_id、code_verifier。有些服务商允许把client_id放到HTTP Basic认证里,但绝大多数字段型授权端点用表单传递就够了。
state参数特别容易被新手忽略。它存在的意义是防止攻击者把伪造的授权码塞进回调URL。发起授权时生成一个随机state并保存在内存,回调时对比是否一致,不一致直接拒绝。
3. 环境准备与项目骨架
3.1 Bun安装与版本确认
我是在macOS上开发的,安装Bun非常简单:
curl -fsSL https://bun.sh/install | bashWindows用户建议用WSL,Windows原生的Bun在文件路径和进程管理上偶尔有边界问题。装完以后确认版本:
bun --version这个工具理论上用Bun 1.x就行,我没有用到特别新的特性。如果你机器上已经跑着旧版本,先升一下再操作,Bun.serve的回调签名在不同小版本之间有过调整。
3.2 目录结构与配置设计
项目不需要复杂的脚手架,一个单文件脚本就能工作。我实际用的目录结构是这样的:
auth-key-tool/ ├── index.ts ├── .env.example └── key_store/.env文件存配置,Bun自动加载环境变量。需要配置的项包括:
CLIENT_ID=你的OAuth客户端ID AUTH_SERVER=https://你的授权服务器地址 REDIRECT_PORT=9123 API_KEY_FILE=./key_store/anthropic_key.json这里有一点值得强调:REDIRECT_PORT选一个不太容易被占用的端口,我平时喜欢用9000以上的随机端口,避免和本地开发服务打架。如果运行时发现端口被占用,改环境变量重跑就行,不用动代码。
key_store目录专门放敏感文件,我会把整个目录加进.gitignore。即使只是个临时工具,密钥文件也不应该出现在版本控制历史里。
4. 核心代码实现
4.1 本地回调服务器
回调服务器是整个工具的心脏。Bun.serve让我们用极少的代码实现一个HTTP服务,并且可以等待它收到回调后再继续执行。
import type { Server } from "bun"; let server: Server; const callbackPromise = new Promise<URL>((resolve, reject) => { const timer = setTimeout(() => { reject(new Error("等待授权回调超时,请重新运行")); }, 120_000); server = Bun.serve({ hostname: "127.0.0.1", port: REDIRECT_PORT, async fetch(req) { const url = new URL(req.url); if (url.pathname === "/callback") { clearTimeout(timer); resolve(url); return new Response("授权完成,本窗口可以关闭", { headers: { "content-type": "text/plain; charset=utf-8" }, }); } return new Response("Not Found", { status: 404 }); }, }); });把hostname设为127.0.0.1是刻意的。如果绑定0.0.0.0,同一局域网内的其他设备也能访问你的回调端口,攻击者可以构造请求抢先把授权码拿走。本地OAuth回调服务永远只绑本机回环地址。
超时时间120秒是合理的,给用户留出足够时间登录和确认授权,又不至于让进程无限挂起。如果用户中途放弃,Promise会reject,就可以把进程干净地退出。
4.2 构建授权链接并拉起系统浏览器
拿到PKCE参数之后,开始拼授权URL。这里要注意各参数的值必须做URL编码,我推荐直接用URLSearchParams,它可以避免手写编码导致的中文或特殊字符问题。
const params = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: "code", scope: SCOPES, state: state, code_challenge: codeChallenge, code_challenge_method: "S256", }); const authorizeUrl = `${AUTH_SERVER}/oauth/authorize?${params.toString()}`;拉起浏览器的方式我按平台区分:
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; await Bun.$`${command} ${authorizeUrl}`;start在Windows上通常需要通过cmd执行,但Bun的shell扩展会自动处理。如果在某些Linux发行版上找不到xdg-open,可以退回打印URL让用户手动复制到浏览器。
4.3 用code换token
回调拿到code之后,第一步先校验state,第二步再去token端点交换。
const callbackParams = callbackUrl.searchParams; if (callbackParams.get("state") !== state) { throw new Error("state校验失败,回调可能被篡改"); } const code = callbackParams.get("code"); const tokenRes = await fetch(`${AUTH_SERVER}/oauth/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, code: code ?? "", code_verifier: codeVerifier, }), }); const tokens = await tokenRes.json(); if (!tokenRes.ok) { throw new Error(`令牌交换失败: ${JSON.stringify(tokens)}`); }不少人在这里犯一个错误:用fetch的默认行为去发这个POST,结果因为服务器要求form格式而拿到415或400。只要显式设置Content-Type并用URLSearchParams做body,就不会踩这个坑。
4.4 密钥保存与文件权限
得到access_token以后,把它按结构化JSON写入指定文件,同时把过期时间一并存下。这样后续再跑工具时可以快速判断磁盘上的密钥是否还在有效期内。
const payload = { access_token: tokens.access_token, refresh_token: tokens.refresh_token ?? null, expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : null, obtained_at: new Date().toISOString(), }; await Bun.write(API_KEY_FILE, JSON.stringify(payload, null, 2)); await Bun.$`chmod 600 ${API_KEY_FILE}`;chmod 600这一步非常关键。默认创建的配置文件如果权限是644,同机其他用户也能读。密钥文件必须只有当前用户可读写。
4.5 一次性跑通全流程
把前面所有部分拼成一个脚本,主流程大概是这样:
const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateCodeVerifier(32); await startCallbackServer(); await openBrowser(authorizeUrl); const callbackUrl = await callbackPromise; await server.stop(); const accessToken = await exchangeToken(callbackUrl, codeVerifier, state); await saveToken(accessToken); console.log(`密钥已写入: ${API_KEY_FILE}`); process.exit(0);整个流程的顺序是:先起回调服务器,再开浏览器。不要反过来,否则用户可能授权完成后回调还没就绪,白白失败。跑一次成功的体验大概就是终端里一行日志,浏览器自动弹出,授权完页面提示可关闭,本地密钥文件已经生成,前后不到三十秒。
5. 常见问题与排查实录
5.1 redirect_uri不匹配
这是我在接入OAuth时遇到最多的错误。服务商会严格校验回调地址是否与注册时一致,一个字符不对都拒绝。
最常见的坑:注册时写的是http://localhost:9123/callback,实际回调用的却是http://127.0.0.1:9123/callback。localhost和127.0.0.1在OAuth服务器眼里是两个完全不同的地址。另一个坑是忘记带端口号,或者路径带不带斜杠不一致。
排查方法:把实际生成的授权URL打印出来,肉眼对比redirect_uri参数与服务商后台的注册值,逐字符核对。
5.2 端口被占用和僵尸进程
杀掉上次测试进程之后,回调端口可能还处于TIME_WAIT状态,导致下一次启动时报端口被占用。可在启动代码里对EADDRINUSE做友好处理,并给出排查命令:
lsof -i :9123如果发现残留进程占用端口,直接kill。我的做法是把端口做成环境变量,测试时换个端口重跑,很少跟残留进程纠缠。工具脚本本身是临时进程,我不会把它跑成daemon,用完即走。
5.3 invalid_grant和一次性code
授权码默认是一次性的,换过一次token后再次使用就会报invalid_grant。如果调试时你手动拿着上一步打印的code去curl重试,自然会失败,别怀疑是代码问题。
另外,code有较短的有效期,一般几分钟到十分钟。如果你在授权页面停留过久,code会过期。这时候重新运行脚本再授权一次。
invalid_grant还有一种可能是code_verifier编码错误。base64url去等号后做ASCII字节哈希,和原始字符串哈希必须完全一致。我踩过一次是在传输环境变量时被shell吞掉了-下划线,最终核对后发现是命令行转义问题,不是算法问题。
5.4 scope权限不足
授权成功但后续调用API时提示权限不足,十有八九是scope没给够。每个服务商的scope名称差异很大,有的叫api.read,有的叫ai:inference,甚至同一个服务商的不同版本也不一样。
建议把需要的scope逐项对照文档,宁可多申请也不要少。很多服务商允许scope叠加,用空格分隔放在一个请求里。
5.5 这里必须强调的安全红线
这个工具虽然简单,安全上却有几个红线,我回顾自己的实现时也专门检查过:
code_verifier绝不写入日志。调试时可以在内存里打印一次,但千万不要把token payload输出到stdout。- 回调服务器只绑
127.0.0.1,不绑0.0.0.0。 access_token和refresh_token不放进Git仓库。.env和key_store目录都要进.gitignore。- 定期检查服务商后台的授权应用列表,撤销不再使用的客户端。
- 本地工具只用于开发环境,不要把这套逻辑直接搬到生产服务器上。
6. 后续扩展与我的实操体会
6.1 换一个服务商怎么适配
这套代码的扩展性比我想象中好,因为OAuth PKCE是标准协议,换服务商时通常只需要改三个地方:授权地址、token地址、scope列表。拿Anthropic API举例,整个流程完全一致,只是因为它们的client_id、端点和scope跟其他服务不同,所以配置上用环境变量隔离后几乎零改动。
遇到非标准服务商时,留意两点:有的服务商要求extra参数,比如audience或organisation;有的token端点在表单参数里要求固定顺序。如果始终报错,用浏览器的开发者工具抓一下服务商官方网页授权时发出的请求,比对字段差异,这是最直接的排错方式。
6.2 refresh_token续期与长期可用
很多服务商的access_token有效期很短,几分钟到几小时。为了工具生成密钥后还能长期使用,我在保存密钥时一并保存了refresh_token和过期时间。之后写一个简单的刷新函数,在access_token过期前用refresh_token换新token。
refresh_token的存储安全要求比access_token更高,因为它生命周期长、权限大。我建议只放在本地且权限设置为0600,切忌同步到云盘。刷新逻辑也不要在崩溃循环里自动执行,过期了由用户手动运营一次脚本更透明。
6.3 我的个人经验总结
做完这个小工具之后,我个人实际感触最深的不是PKCE算法本身,而是状态管理。一个看起来只有几十行的脚本,涉及回调服务器、超时、state校验、token交换四个异步状态,如果没有用Promise把回调事件建模成await callbackPromise,整个流程很快就会变成回调地狱。
另外就是测试习惯。OAuth流程天然有状态,同一个code只能用一次,所以我给自己定了一条规矩:每次调试都重新生成全新PKCE参数,绝不复用上一次的verifier和code。这样虽然多花几秒,但能保证每次测试都是干净状态,排查问题时才不会被遗留状态干扰。
如果你准备在自己的工具里接入类似授权流程,我的建议是先把本章的4.5节完整跑通,再逐步拆开改参数。跑通一次以后,你会对OAuth PKCE里的每个参数都有体感,比纯看文档高效得多。