KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · 令牌与校验:三种东西,六项检查 — keel 龙骨

02 章末尾换回来三样东西:accesstoken、idtoken、refresh_token。 它们都长得像 JWT、都是三段式、都能被解出 JSON——但它们不是一个东西。 把它们当成同一个东西,会直接产生两类线上问题:拿身份结论去调接口, 或者把一把能调接口的钥匙放进浏览器。

02 章末尾换回来三样东西:access_token、id_token、refresh_token。
它们都长得像 JWT、都是三段式、都能被解出 JSON——但它们不是一个东西。
把它们当成同一个东西,会直接产生两类线上问题:拿身份结论去调接口,
或者把一把能调接口的钥匙放进浏览器。


一、现场:三个「都是 token」的误用

误用一:拿 ID Token 去调 API。 接口返回 401。ID Token 的使命是告诉应用「这个人是谁」,
不是给人去访问资源的凭据。它常常连 aud 都不指向资源服务器。

误用二:把 access token 存进 localStorage。 任何一段注入到页面里的脚本都能读走它,
然后以这个用户的身份调接口——直到它过期为止。存哪里这件事没有「都能过」的选项,
只有暴露面的取舍。

误用三:把角色塞进令牌,然后指望「撤权立刻生效」。 令牌一旦签发就是自包含的:
里面的角色在过期之前都不会变。撤权要么等它过期,要么另建一套撤销机制。

三个误用都来自同一个根:没分清三种 token 的归属方与寿命。

二、三种 token 各自回答什么

flowchart TD
    subgraph IDP["IdP(OP)"]
        T1["令牌端点"]
    end
    subgraph APP["应用(SP / RP,服务端)"]
        A1["读 ID Token:认人"]
        A2["存 refresh token:续期"]
        A3["透传或持有 access token:访问资源"]
    end
    subgraph RES["资源(后端 API / userinfo)"]
        R1["校验 access token"]
    end
    subgraph UA["浏览器"]
        U1["页面上的前端代码"]
    end

    T1 -->|"id_token<br/>aud=client_id"| A1
    T1 -->|"refresh_token<br/>aud=issuer"| A2
    T1 -->|"access_token<br/>aud=account"| A3
    A3 -->|"Bearer"| R1
    A3 -.->|"把 access_token 交给前端:暴露面换一种形式"| U1
    U1 -.->|"localStorage 里的 access token 被脚本读走"| R1
    R1 -.->|"401 invalid_token(过期 / 用途不对)"| A3
    A2 -.->|"invalid_grant(refresh token 过期或已失效)"| A2
    A1 -.->|"invalid_id_token(六项校验任一不符)"| A1

同一个 realm 里三个 token 的真实差别:

ID Token Access Token Refresh Token
回答什么 这个人是谁 这个应用能访问什么 还能不能再换一次
aud sp-spa(client_id) account realm 的 issuer
typ ID Bearer Refresh
谁读它 应用(认证结论) 资源服务器(授权判断) 只有 IdP 认它
典型寿命 与 access token 同寿(本课主案例里都是 300 秒) 300 秒 1800 秒
该不该给浏览器 可以(它只是结论) 要想清楚,见本章第五节 不该

看出来了吗:光凭「都叫 token」这件事,你无法判断哪一个是身份、哪一个是钥匙。
判断依据是 aud 与 typ。

一个顺带值得注意的事实:本课主案例里 refresh token 也是 JWT
(header 里 alg 是 HS512,typ 是 Refresh)。但规范并不要求它是 JWT——
refresh token 完全可以是一段不透明字符串,只有 IdP 自己能解释它。
不要假设 refresh token 能被解出什么来,那是实现细节。

三、JWT 的三段结构与 kid

三种 token 都长成这样:

base64url(header) . base64url(payload) . base64url(signature)

一个真实的 ID Token 头部:

header = {"alg": "RS256", "typ": "JWT", "kid": "6sKOrtKwkpycDAQbwZzpRKJEE04b5-Zy3wCoCUI0ff4"}
签名段长度 = 342 (base64url 字符数)

两件必须一次说清的事:

  1. 中间那段不是加密,只是编码。 任何拿到 token 的人都能把它 base64url 解回 JSON。
    所以绝不要把秘密放进 claim。安全来自第三段(签名)与后面对各项 claim 的校验,
    而不是来自「别人看不出来」。
  2. kid 是「用哪把钥匙验」的索引。 它让 IdP 可以同时公布多把密钥,
    于是换密钥时旧 token 还能验过——这是 07 章「密钥轮换」能不停机的前提。
    忽略了 kid、只用「当前唯一那把密钥」验签的应用,换密钥当天会全体掉线。

两把 key:签名与加密是两件事

同一个 realm 的 JWKS 里有两把密钥:

kid = 6sKOrtKwkpycDAQbwZzpRKJEE04b5-Zy3wCoCUI0ff4 | kty = RSA | alg = RS256    | use = sig
kid = Wm7uV1Y2D6Xz1nADFcVsnGmcQWkOCvw39w-bOeYWhjo | kty = RSA | alg = RSA-OAEP | use = enc

这两把是独立的,因为它们的生命周期与泄露后果不同:签名密钥泄露 = 谁都能伪造身份;
加密密钥泄露 = 历史令牌可被解开。轮换策略因此也不同。
见到 JWKS 里有多把 key 时,先看 use 再看 alg,不要以为它们是一个密钥的两种表示。

四、六项校验:每一项漏了会出什么事

应用拿到 ID Token 之后要做的事,是把 02 章埋下的钉子逐个拔掉。这六项缺一不可:

校验 具体做什么 漏了会出什么事
签名 按 kid 取密钥,验第三段 任何人能手写一个 {"sub": "别人的 id"} 并通过
iss 与配置里的 issuer 字面量比对 换过地址、加过代理后可能把别的签发者当自己人(05 章)
aud 必须包含自己的 client_id 一个签给别的应用的 ID Token 能在你这里当登录成功
exp(含容差) 未过期;容差窗口用来吸收时钟偏移 一个很久以前签发的 token 可以无限期复用
nonce 与本次登录发出的值比对 重放攻击:把一次旧登录的 ID Token 塞进新会话
at_hash 与本次的 access token 摘要比对 攻击者可以把 A 的 access token 配 B 的 ID Token 拼起来用

再加两项实践里同样重要的:

校验 作用
typ 确认你拿到的是 ID 而不是 Bearer——把「身份结论」与「访问凭据」分开
azp 当 aud 有多个值时,指明是谁发起的这次请求

每个 claim 各自防什么

nonce      防「拿一个旧 ID Token 冒充这次登录」
at_hash    防「ID Token 与 access token 被拆开重新拼接」
sid        会话标识:登出、back-channel 登出、会话管理都靠它定位「是哪一次登录」
auth_time  真正的认证发生时刻(刷新时 iat 会变,auth_time 不变)
acr        认证上下文(例如是否多因素),用来表达「这次认证有多强」
jti        这个令牌的唯一 id,用于去重/撤销记录

iat 与 auth_time 的差别值得单独记住:刷新之后 iat 会更新、auth_time 不会。
把 iat 当「登录时间」会在用了刷新令牌之后变成错的;
而很多「超过 N 分钟要求重新进多因素」的策略,判据恰恰是 auth_time。

五、access token:该给前端吗?该塞角色吗?

这两问没有「标准答案」,但有可以摆出来的代价。

给不给前端

做法 好处 代价
只放在服务端(后端持有,前端只在 cookie 里带会话) 前端脚本读不到它 每个请求都要经后端代理;跨域/CSRF 要单独处理
交给前端(内存变量) 前端可以直接调 API 刷新页面就丢;要被 XSS 打就是一个窗口期的钥匙
交给前端并持久化(localStorage) 体验最好 任何脚本都能读走;这是最常见的一类真实事件

判断顺序建议先问一句:这个令牌泄露后,攻击者能做什么、能持续多久?
expires_in = 300 意味着窗口只有 5 分钟;但如果同时给了 refresh token,
攻击者能一直续期——两者的暴露面是叠加的,要一起看。

塞不塞角色

本课主案例的 access token 里带着授权信息:

"realm_access": { "roles": ["offline_access", "default-roles-sso-lab", "uma_authorization"] },
"resource_access": { "account": { "roles": ["manage-account", "manage-account-links", "view-profile"] } }
做法 好处 代价
角色放进令牌(自包含) 资源服务器不用回调 IdP,判断快、依赖少 撤权不即时:令牌过期前旧角色一直有效;令牌越来越长
每次去问 IdP / 查本地位 撤权即时 多一次网络依赖;要处理 IdP 短暂不可用时的降级策略

注意这是一个取舍,不是「哪一种更先进」。真正要写进设计文档的是这两句话:
「这个系统的撤权时限是多少」 与 「IdP 不可用时,接口是拒绝还是降级」。

动手:可观察结果

cd courses/identity/sso-engineering/course/project
python examples/01_authorization_code_flow.py
python examples/04_bad_signature.py

要观察、并且能用本节术语解释的四件事:

  1. id_token aud = sp-spa 而 access_token aud = account——同一个用户、同一时刻,
    两种 aud
    。这就是「先看 aud 再判断手里是什么」的依据。
  2. ID Token 的 at_hash 与 access token 绑定:把 access token 换掉但留着原来的 ID Token,
    校验就会失败(示例 04 的丙段)。
  3. 改 payload、签名段不动 → signature mismatch;把 kid 换成一个没登记过的 →
    取密钥这一步就被拒。两种改法断在不同位置。
  4. verify_id_token 里六项校验的顺序与失败信息——这就是本节那张表的代码版本。

故障注入

破坏 改法 预期现象 对应哪一项校验
改 payload 的 sub python examples/04_bad_signature.py signature mismatch 签名
换 kid 同上,乙段 unknown kid: lab-key-v9 签名(选钥匙那一步)
改 access token 示例 04 丙段 / 测试 test_tampered_access_token_fails_at_hash at_hash does not match access_token at_hash
换个 client_id 配置去验同一个 ID Token 把 RP 的 client_id 改成 sp-portal 后验 sp-spa 的 token aud does not include this client aud
把时钟往前推过 exp 测试里 op.clock.advance(400) exp passed exp

最后一条要注意别用「把容差开大」来绕过:容差是为时钟偏移留的,
开成几分钟就等于把「令牌有效期」偷偷延长了几分钟(本课实验台把它做成
MiniRP(..., leeway=60) 这样显式的参数,正是为了让这个决定写在明面上)。

生产边界

教学里这样 真实部署要换成 不换会出什么事
HS256 对称签名(标准库没有现成 RSA) RS256 / ES256 非对称 + 公布 JWKS 校验密钥 = 签发密钥:任何持有它的组件都能伪造身份
密钥写死在代码里 密钥托管 + 定期轮换,旧密钥按 kid 保留一段时间 换密钥当天所有在途 token 全部验不过
JWKS 每次现取 缓存 JWKS,并按 kid 未命中时限流刷新 缓存太久则轮换后验不过;完全没缓存则被 JWKS 端点拖住、还可能被打
expires_in = 300、refresh_expires_in = 1800 当定值 按业务与暴露面校准,并明确「刷新令牌存哪」 寿命过长 = 泄露窗口过长;过短 = 刷新风暴
令牌里塞角色 明确撤权时限;必要时改为资源服务器侧查询或引入撤销机制 撤权要等到令牌过期才生效,安全事件里这段时间最难受
ID Token 明文(JWS) 按需启用 JWE 加密(JWKS 里 use = enc 的那把) 令牌会经过浏览器,明文 claim 全部暴露给端上脚本
只验签名 六项校验齐全 + typ / azp 签名只证明「是 IdP 签的」,不证明「这次登录合法、这个 token 是给你的」

自测题

  1. 手上有两个 token,aud 一个是 sp-spa、一个是 account。哪个是 ID Token?为什么?
  2. nonce 与 at_hash 都在 ID Token 里,它们防的攻击有什么不同?只做 nonce、不做 at_hash,
    攻击者能构造出什么?
  3. 为什么 auth_time 与 iat 会在刷新之后分叉?一个「登录 30 分钟后要求重新做多因素」的策略
    应该用哪一个?
  4. JWKS 里有 use = sig 与 use = enc 两把 key。什么情况下 ID Token 会用到第二把?
  5. 一个系统把角色放进令牌、有效 1 小时。有人被撤权之后,最坏情况下还能用多久?
    说出两个能让这个时间变短的做法,以及各自的代价。

现在能解释什么

进入 keel 阅读