项目概述
本项目实现带 PKCE 的 OAuth2 授权码服务器:公共客户端无需密钥即可获取受作用域约束的访问令牌,并轮换刷新令牌而不暴露密钥。授权码流程是最容易实现得微妙错误的环节,这里的每一处校验都有明确理由。
架构与关键决策
授权端点按注册信息校验客户端、作用域与重定向 URI,记录同意并签发绑定客户端、重定向 URI、作用域与 PKCE 挑战的一次性授权码;令牌端点一次性兑换该码,校验 verifier 与存挑战匹配,返回短时效访问令牌与轮换刷新令牌。授权码的绑定是安全模型:任一绑定被改动即拒绝,因此令牌端点自行复核而不信任授权端点的早前判断。签名密钥经稳定 URL 的 JWKS 发布,使资源服务器无需每次回调即可校验。
实施与测试
以真实重定向走完整流程:取码、兑换、访问受保护资源、轮换刷新,再重放旧刷新断言失败;断言未注册重定向 URI 被拒、同码二次兑换失败、返回作用域不超同意范围、且授权码绑定到特定客户端。
限制与后续
- 仅实现授权码流程与刷新轮换,未含客户端凭证与隐式流程。
- 无动态客户端注册。
- 不含 OpenID Connect 身份层,签发访问令牌而非 id 令牌。
代码示例
pnpm init
pnpm add jose express pg
pnpm add -D typescript tsx vitest
oauth2-authz-server/
├── src/authorize.ts
├── src/token.ts
├── src/pkce.ts
├── src/keys.ts
├── src/store.ts
├── tests/flow.test.ts
└── package.json
await store.saveCode({ code, clientId, redirectUri, challenge, ttl: 60 });
const { base64url } = await import('jose');
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
assert(base64url.encode(digest) === storedChallenge);
const jwt = await new SignJWT({ scope: granted.join(' ') })
.setSubject(userId).setExpirationTime('5m')
.setProtectedHeader({ alg: 'RS256', kid }).sign(privateKey);
const ok = await store.rotateRefresh(oldToken, newToken);
if (!ok) throw new Error('refresh token reuse detected');
await store.revokeFamily(refreshToken.familyId);
pnpm vitest run