OAuth 2.1 与 OIDC:授权流程、令牌类型与单点登录
本文目录
OAuth 是"授权委托"协议,OIDC 是在它之上补出来的"认证层"。很多团队把两者当成一个黑盒:接了个第三方登录,复制示例代码,能跑就上线。结果是 redirect_uri 校验不严、state 不生成、refresh token 永不过期——每一个都对应一类真实攻击。这篇文章按流程走一遍:授权模式选择、PKCE、令牌校验、单点登录与注销,以及撤销策略。
授权模式为什么只剩授权码 + PKCE
OAuth 2.0 定义了四种授权模式,OAuth 2.1 草案只保留一种:
| 模式 | OAuth 2.1 中的状态 | 被淘汰的原因 |
|---|---|---|
| 授权码(Authorization Code) | 保留,且强制 PKCE | 唯一同时适用于 Web 与原生客户端的流程 |
| 隐式(Implicit) | 移除 | 令牌直接经浏览器 URL 分发,暴露给历史记录、日志与 Referer,且无刷新令牌 |
| 密码(Resource Owner Password) | 移除 | 应用拿到用户原始密码,无法证明用户真的来自授权服务器,也绕过了 MFA |
| 客户端凭证(Client Credentials) | 保留 | 但它是服务间授权,不涉及用户,与"登录"无关 |
隐式模式是历史教训最深的:当年浏览器没有跨域 POST 能力,只能把令牌塞进 URL 片段。今天它的两个前提都不成立,安全社区的结论也一致:任何新系统都不该再实现它。授权码流程把"用户在授权服务器登录"和"应用换取令牌"分成两步,令牌只出现在应用后端与授权服务器之间的直连请求里,不经过浏览器:
浏览器 应用后端 授权服务器
|--- GET /login ----------->| |
|<-- 302 /authorize?client_id&redirect_uri&state&code_challenge --|
|--- 在授权服务器登录并同意 ------------------------->|
|<-- 302 redirect_uri?code=Spxq9v...&state=r9Wz2k... ------------|
|--- 302 带 code 到回调路由 -->| |
| |--- POST /token (code + code_verifier + client_secret) -->|
|<-- Set-Cookie 会话 -------|<-- access_token / id_token --|
授权请求(跳转到 /authorize)的参数是协议的固定面,逐个都有安全语义:
| 参数 | 作用 | 缺失或错误的后果 |
|---|---|---|
| response_type=code | 声明走授权码流程 | 流程退化风险,2.1 起基本固定 |
| client_id | 标识接入应用 | 授权方无法匹配 redirect_uri 白名单 |
| redirect_uri | 回信地址,必须与注册值精确一致 | 被前缀匹配或通配放大会被利用 |
| scope | 申请的授权范围 | 超范围申请应被拒绝或按最小化收敛 |
| state | 防 CSRF 的随机串 | 回调可被伪造,登录绑定攻击 |
| code_challenge / method | PKCE 证明 | 授权码被截获后可被交换 |
| nonce(OIDC) | 绑定 id_token 与本次流程 | id_token 重放、令牌替换 |
PKCE:公开客户端存不住密钥
授权码流程原本要求应用持有 client_secret。但 SPA 和移动 App 是"公开客户端"——代码运行在用户设备上,任何打包进前端的密钥都能被提取。PKCE(Proof Key for Code Exchange)的思路是:不存密钥,改为每次授权现场生成一次性证明:
import { randomBytes, createHash } from "node:crypto";
import { base64url } from "node:buffer";
// 1. 浏览器侧生成密钥对:verifier 是随机串,challenge 是它的哈希
function createPkce(): { verifier: string; challenge: string } {
const verifier = base64url.encode(randomBytes(32)); // 至少 43 字符
const challenge = base64url.encode(
createHash("sha256").update(verifier).digest(),
);
return { verifier, challenge };
}
// 2. 发起授权时只带 challenge 与算法标记:
// GET /authorize?...&code_challenge=Spxq9v...&code_challenge_method=S256
// 3. 换令牌时带 verifier,授权服务器重算哈希比对:
// POST /token body: grant_type=authorization_code&code=...&code_verifier=dBjftJe...
PKCE 防的是授权码被截获的场景:攻击者拿到 code 后去换令牌,但他没有 verifier,只能瞎猜——S256 哈希不可逆,攻击等于失败。OAuth 2.1 把 PKCE 定为所有客户端(包括有 secret 的机密客户端)的强制要求,因为机密客户端的 secret 也可能因日志或配置泄露。自建授权服务器时,不接受无 code_challenge 的授权请求应当是默认行为。
redirect_uri:白名单严格匹配
redirect_uri 是授权码流程的"回信地址",也是历史上最多漏洞的参数。校验规则只有一条:精确字符串匹配白名单,不做前缀匹配、不做路径通配。
- 前缀匹配的失效方式:白名单写了
https://app.example.com/callback,攻击者注册https://app.example.com/callback/../../attacker-page或利用开放重定向,把 code 带到自己的页面。 - 每个客户端注册多个 redirect_uri 时,逐个完整匹配;二级域名不同(a.example.com 与 b.example.com)就是不同的 URI。
- 授权服务器在 /token 交换时必须再次校验 redirect_uri,且与 /authorize 时完全一致——这是 OAuth 2.1 明确加固的点。
同理,对客户端开发者:redirect_uri 只用 HTTPS(本机 localhost 例外),不要复用业务路由,回调端点对未知 code 直接报错并记录。
state:防 CSRF 的最低要求
state 是发起授权时生成、回调时校验的随机串。没有它,攻击者可以构造一个"自己账号已登录"的回调链接发给受害者,受害者的浏览器完成绑定后,攻击者就能通过"登录回调 CSRF"间接控制受害者会话。实践标准:
// 发起授权:生成 state 并与回跳地址一起存入临时存储
const state = base64url.encode(randomBytes(16));
await sessionStorage.setItem(`oauth_state:${state}`, JSON.stringify({ from: location.pathname, ts: Date.now() }));
// 回调处理:state 必须存在、一次性消费、带时效(如 10 分钟)
const saved = sessionStorage.getItem(`oauth_state:${state}`);
if (!saved) throw new Error("invalid state");
sessionStorage.removeItem(`oauth_state:${state}`); // 立即删除,防重放
state 校验失败必须终止流程并告警,而不是"容错跳过"。OAuth 2.1 草案将 state 列为强制参数。
令牌三件套:access、refresh、id token
| 令牌 | 有效期 | 用途 | 存放位置 |
|---|---|---|---|
| access token | 短(5–15 分钟) | 调用资源 API 的凭证 | 内存(Web)或安全存储(App) |
| refresh token | 长(天到周级) | 换新 access token,可撤销 | 服务端 httpOnly Cookie 或 App 安全存储 |
| id token(OIDC) | 短 | 证明"用户是谁",只给客户端自己用 | 校验后取 claims,不转发 |
两条容易被违反的边界:access token 不验证、只当"开门卡"用——资源服务器校验签名即可,不要假设它保密;refresh token 是真正的资产,泄露一个长命 refresh token 等于长期账号控制权,所以它的存储、传输与轮换策略都比 access token 严格。
/token 端点的标准响应是三件套同时返回(是否含 refresh token 取决于 scope 与授权方策略):
{
"access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjIwMjZ3NDMifQ...",
"token_type": "Bearer",
"expires_in": 600,
"refresh_token": "def50200e0a3...",
"scope": "openid profile orders:read",
"id_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjIwMjZ3NDMifQ..."
}
处理这份响应的三条纪律:expires_in 只是参考值,真正的过期时间以 JWT 的 exp 为准;refresh_token 一旦出现新的(旋转),旧的立即作废,客户端只保存最新一份;id_token 交给 OIDC 校验流程,不要原样转发给后端当 access token 用。
OIDC 的 id_token:JWT 校验清单
OAuth 只管授权,没回答"这个用户到底是谁"。OIDC 在 /authorize 加 scope=openid,在令牌响应里多给一个 id_token——它是签名的 JWT,claims 表达用户身份。客户端拿到后必须逐项校验,缺一项就是一类漏洞:
| Claim | 校验内容 | 漏掉后果 |
|---|---|---|
| iss | 等于签发者配置的精确 URL | 接受任意来源伪造的令牌 |
| aud | 包含自己的 client_id | 接受发给别人的令牌(令牌替换) |
| exp / nbf | 当前时间在有效窗口内(留 30–60 秒时钟容差) | 使用过期令牌或被未来时间令牌绕过 |
| nonce | 与发起授权时生成的 nonce 一致 | id_token 重放、令牌替换 |
| at_hash | 如响应含 access token,校验其哈希 | access token 与 id_token 被调包 |
// 用成熟库校验,不要手写 JWT 验证逻辑;此处展示"必须核对的字段"
const claims = await validateIdToken(idToken, {
issuer: "https://idp.example.com", // 精确匹配,含末尾斜杠与否要与文档一致
audience: "my_client_id",
clockTolerance: 60,
});
if (claims.nonce !== session.nonce) throw new Error("nonce mismatch");
if (typeof claims.sub !== "string") throw new Error("missing sub"); // sub 是用户唯一标识
sub 是用户在签发方体系内的稳定 ID,是建本地用户表的关联键。不要用 email 当主键——邮箱可变更、可复用,OIDC 明确不保证唯一。email_verified 为 true 才可以信任邮箱做找回凭证。
发现端点:按文档接,不按经验接
OIDC 规定了标准的发现地址 /.well-known/openid-configuration,把端点地址、支持范围、算法列表一次性发布出来。接入方应当从发现文档读取配置,而不是把端点写死:
// 每次发布前拉一次发现文档,配置漂移在构建期就能暴露
async function loadOidcConfig(issuer: string): Promise<OidcConfig> {
const res = await fetch(new URL("/.well-known/openid-configuration", issuer));
const doc = await res.json();
if (doc.issuer !== issuer) throw new Error("issuer mismatch"); // 发现文档自证身份
return {
authorizationEndpoint: doc.authorization_endpoint, // 发起授权
tokenEndpoint: doc.token_endpoint, // 换令牌
jwksUri: doc.jwks_uri, // 校验 id_token 的公钥
endSessionEndpoint: doc.end_session_endpoint, // 注销
};
}
接入新授权方时的检查顺序:issuer 与发现文档一致、token 端点走 HTTPS、JWKS 可取且含有效 kid。这三条任何一条失败都不该硬编码"先上线再说"——它们就是协议配置错误的第一信号。
scope 最小化
scope 是授权范围的载体。规则是按端点能力拆细(profile、email、orders:read、orders:write),授权请求只带当前页面必需的集合,让授权页能向用户展示人话说明。请求超集 scope 时,响应的 granted_scopes 以服务器实际授予为准,资源侧校验"这个 access token 的 scope 覆盖本次操作"而不是信任请求时申请过的列表。不要发明 all 或 admin 这类一揽子 scope——等于回到没有授权粒度。
单点登录:session 与 token 两种思路
接入一个 OIDC 授权方后,多个业务系统共享登录态,有两条实现路径:
| 思路 | 登录态载体 | 适合 | 复杂点 |
|---|---|---|---|
| 服务端 session | 应用各自发会话 Cookie,首个未登录系统跳转授权方 | 传统多页应用、强会话控制需求 | 会话存储与过期策略要统一 |
| token 透传 | 每个系统自己持有 access token 调 API | 前后端分离、服务间调用 | token 存储与刷新在端侧实现 |
流程上的共同点:用户访问系统 A 未登录 → 跳授权方 → 授权方发现自己已有该用户的有效会话(这才是"单点")→ 静默同意,带着 code 回 A → A 建立本地会话。"单点"的本质是授权方自己的会话 Cookie 还活着,所以授权方的会话有效期决定了 SSO 体验,而不是各业务系统的有效期。对 SPA 用 prompt=none 做静默授权探测:成功即免交互续登录,失败(login_required)再走完整登录页。
回调路由的职责边界要收窄:验 state → 换令牌 → 建本地会话 → 重定向到业务页,不放任何业务逻辑:
// 回调路由只做协议收尾,业务处理移交给登录后的首跳
app.get("/auth/callback", async (req: Request, res: Response) => {
const saved = popState(req.query.state); // 校验并一次性消费 state
if (!saved) return res.redirect("/login?error=state");
if (req.query.error) return res.redirect(`/login?error=${req.query.error}`);
const tokens = await exchangeCode(req.query.code, saved.verifier); // 换令牌在后端做
await createLocalSession(res, tokens); // 写会话 Cookie / 签发本域令牌
res.redirect(saved.from); // 回到发起登录前的页面
});
exchangeCode 必须在服务端执行:code_verifier 与 client_secret 都不该出现在浏览器。回调对无效参数的处理是"跳回登录页并带错误原因",不要把错误页做成可被注入提示文案的模板——错误参数回显要转义或只映射白名单枚举。
单点注销:为什么这么难
单点注销没有像单点登录那样统一的协议行为。两个事实要先摆清楚:
- 各业务系统的本地会话是独立 Cookie,注销授权方只删掉了自己的会话,业务系统下次仍会拿着"还有效的 access token"访问 API,直到 token 自然过期。
- OIDC 定义了 RP-Initiated Logout(前端跳转
end_session_endpoint,带 id_token_hint),授权方可以按 backchannel 逐个通知注册了post_logout_redirect_uris的应用。但这要求每个业务系统提前向授权方注册注销回调,跨组织的授权方通常做不到。
工程上的务实组合:access token 短 TTL(把"注销后还能用多久"限制在分钟级)+ 注销时撤销 refresh token(RFC 7009)+ 高敏感操作二次验证会话活性。接受"注销不等于所有旧 token 立即失效"这个边界,把它转化为短 TTL 与撤销的组合控制,比追求一个不存在的全局注销协议更可靠。
令牌撤销与 refresh token 旋转
旋转(rotation)指每次用 refresh token 换新 access token 时,同时发放新的 refresh token 并废弃旧的:
// 伪代码:旋转 + 被盗检测
async function refresh(oldToken: RefreshToken): Promise<TokenSet> {
const record = await store.findRefresh(oldToken.hashed);
if (!record) {
await store.revokeUserSessions(record?.userId ?? undefined); // 旧 token 被重放:可能是被盗
throw new InvalidGrantError();
}
const tokenSet = issueTokens(record.userId, record.scopes);
await store.replaceRefresh(oldToken.hashed, hash(tokenSet.refreshToken), {
rotatedAt: new Date(),
}); // 旧 token 标记 rotated,仅允许极短的重试宽限期
return tokenSet;
}
两个细节决定旋转是否有效:宽限期(grace period)要短(如 30 秒)且只允许同一 token 的一次重试,否则网络重试会误杀正常用户;旋转后检测到旧 token 再次出现,应当撤销该用户整个授权链(token 家族),因为这通常意味着 refresh token 已被盗。撤销端点(RFC 7009)要求客户端先认证,且只通知资源方"尽快失效"——所以撤销之后短 TTL 仍是最后防线。
注意事项与边界
- OAuth 是授权不是认证:把 access token 发到用户信息端点拿到身份,是 OAuth 早期著名的误用(登录混淆问题),认证请走 OIDC 的 id_token 流程。
- 不要自己实现授权服务器,除非业务就是做身份提供方:部署成熟实现(Keycloak、Auth0、Authing 等)后,仍要按本文逐条配置校验策略,默认配置不等于安全配置。
- 所有令牌与授权码都是一次性语义:code 用一次即焚,state/nonce 用一次即焚;授权服务器对重复使用必须报错而非幂等返回。
- 网络边界上,/token 端点要有独立限流与失败延迟(错误时消耗时间一致),否则可以在线爆破 code 与 verifier。
- 日志里不得出现 code、verifier、state、任何 token 的明文;排障需要时只记前 8 位哈希。
总结
OAuth 2.1 收敛到"授权码 + PKCE"不是简化,是把过去十年的真实漏洞修进协议:PKCE 补掉公开客户端存不住密钥的问题,强制 state 补掉 CSRF,redirect_uri 精确匹配补掉开放重定向链。接入方的工作是把校验做全:iss/aud/exp/nonce/at_hash 一项不缺,refresh token 旋转并检测重放,短 TTL 兜住撤销做不到的即时性。单点登录复用的是授权方的会话,单点注销则要靠"短 TTL + 撤销 + 二次验证"的组合拳去逼近效果。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。