ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Bun实现OAuth PKCE本地授权:自动化获取API密钥的完整指南

用Bun实现OAuth PKCE本地授权:自动化获取API密钥的完整指南

最近在做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_typecode授权码模式
scope需要的权限范围按服务商文档填写
state随机字符串防CSRF,回调时要校验
code_challenge见上文计算PKCE核心参数之一
code_challenge_methodS256推荐,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 | bash

Windows用户建议用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里的每个参数都有体感,比纯看文档高效得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进