欢迎光临
我们一直在努力

OIDC id_token 的签发与校验:从规范到工程实践

一、id_token 是什么,以及它解决的问题

OIDC(OpenID Connect)在 OAuth 2.0 之上构建了一层身份认证能力。OAuth 2.0 本身只解决授权(authorization)——它告诉资源服务器"这个 access_token 持有者被允许访问某些资源",但它从不保证"这个 token 的持有者是谁"。把 access_token 当成身份凭证使用,是一个经典且危险的误用。

id_token 正是为填补这个空缺而生:它是一个由 OP(OpenID Provider,身份提供方)签发、面向 RP(Relying Party,依赖方/客户端) 的、携带用户身份断言的 JWT(JSON Web Token)。

核心区别可以用一句话概括:

Token受众(audience)用途
access_token Resource Server(资源服务器) 授权——“能访问什么”
id_token Client / RP(依赖方) 认证——“用户是谁”

id_token 的格式必须是 JWT,且必须签名(JWS,JSON Web Signature)。这是 OIDC 与 OAuth 2.0 的硬性差异之一。


二、id_token 的结构

id_token 是一个标准 JWT,三段式:Header.Payload.Signature,各段用 Base64URL 编码后以 . 连接。

2.1 Header(头部)

{
"alg": "RS256",
"typ": "JWT",
"kid": "2024-key-01"
}

  • alg:签名算法。OIDC 默认且最常见为 RS256(RSA + SHA-256)。
  • kid(Key ID):标识本次签名使用的密钥,是 RP 在校验时从 JWKS(JSON Web Key Set)中精确定位公钥的关键。

2.2 Payload(声明集,Claims)

OIDC 规定了一组标准声明,下面是签发与校验中最关键的几个:

Claim全称/含义必需说明
iss Issuer(签发者) OP 的标识 URL,校验时必须精确字符串匹配
sub Subject(主体) 用户在该 OP 下的唯一且不可变标识符
aud Audience(受众) 接收方,通常为 RP 的 client_id
exp Expiration Time(过期时间) Unix 时间戳,过期后必须拒绝
iat Issued At(签发时间) 签发时刻
nonce 随机串 条件 防重放,绑定到一次认证请求
auth_time 认证发生时间 条件 配合 max_age 使用
azp Authorized Party(被授权方) 条件 当 aud 含多个值时标识实际使用方

一个典型的 payload:

{
"iss": "https://op.example.com",
"sub": "248289761001",
"aud": "s6BhdRkqt3",
"exp": 1735689600,
"iat": 1735686000,
"nonce": "n-0S6_WzA2Mj",
"auth_time": 1735685990,
"email": "alice@example.com",
"email_verified": true
}

关于 sub:它在同一个 issuer 内唯一标识一个用户。RP 判断"是否同一用户"的正确依据是 (iss, sub) 组合,而非 email、phone 等可变信息。把 email 当主键是一个常见的安全/正确性漏洞。

2.3 Signature(签名)

签名覆盖 Base64URL(Header) + "." + Base64URL(Payload),使用 OP 私钥生成,RP 用对应公钥校验。


三、签发流程(OP 视角)

3.1 何时签发

id_token 在 OIDC 各种 flow 的 Token Endpoint 响应中签发(Authorization Code Flow),或在 Authorization Endpoint 直接返回(Implicit / Hybrid Flow)。当前最佳实践是 Authorization Code Flow + PKCE(Proof Key for Code Exchange),Implicit Flow 已被 OAuth 2.1 弃用。

Authorization Code Flow 下,token 响应形如:

{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 3600,
"id_token": "eyJhbGciOiJSUzI1Ni␣…",
"refresh_token": "…"
}

3.2 签名算法选择

算法族代表说明
RSA RS256 最通用,互操作性最好,默认首选
ECDSA ES256 密钥更短、性能更好,逐渐普及
HMAC HS256 对称密钥,RP 与 OP 共享密钥,仅适合机密客户端的特定场景

非对称算法(RS256/ES256)是主流:OP 持有私钥签名,公钥通过 JWKS 公开分发,RP 无需持有任何密钥即可校验。

绝对不要接受 alg: none。这是 JWT 历史上最著名的漏洞类别——攻击者把算法改成 none 并去掉签名,若校验方盲信 header 中的 alg,即可伪造任意身份。

3.3 密钥管理与轮换

  • OP 通过 JWKS 端点(通常是 /.well-known/jwks.json)发布公钥集合,每个密钥带唯一 kid。
  • 密钥轮换时,OP 应先发布新公钥(与旧公钥并存于 JWKS 中),再切换为用新私钥签名。这给了 RP 缓存刷新的时间窗口,避免轮换瞬间校验失败。
  • 签名时把 kid 写入 header,RP 据此选用正确公钥。

3.4 nonce 的回填

如果授权请求中 RP 传了 nonce,OP 必须把它原样写入 id_token 的 nonce 声明。这是防重放链条的 OP 端职责。


四、校验流程(RP 视角)——核心中的核心

校验是整个机制安全性的落脚点。签发由 OP 负责,但绝大多数攻击面在 RP 的校验实现里。 以下步骤缺一不可。

4.1 完整校验清单

#mermaid-svg-55jusKJ201JMwZS2{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-55jusKJ201JMwZS2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-55jusKJ201JMwZS2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-55jusKJ201JMwZS2 .error-icon{fill:#552222;}#mermaid-svg-55jusKJ201JMwZS2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-55jusKJ201JMwZS2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-55jusKJ201JMwZS2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-55jusKJ201JMwZS2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-55jusKJ201JMwZS2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-55jusKJ201JMwZS2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-55jusKJ201JMwZS2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-55jusKJ201JMwZS2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-55jusKJ201JMwZS2 .marker.cross{stroke:#333333;}#mermaid-svg-55jusKJ201JMwZS2 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-55jusKJ201JMwZS2 p{margin:0;}#mermaid-svg-55jusKJ201JMwZS2 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-55jusKJ201JMwZS2 .cluster-label text{fill:#333;}#mermaid-svg-55jusKJ201JMwZS2 .cluster-label span{color:#333;}#mermaid-svg-55jusKJ201JMwZS2 .cluster-label span p{background-color:transparent;}#mermaid-svg-55jusKJ201JMwZS2 .label text,#mermaid-svg-55jusKJ201JMwZS2 span{fill:#333;color:#333;}#mermaid-svg-55jusKJ201JMwZS2 .node rect,#mermaid-svg-55jusKJ201JMwZS2 .node circle,#mermaid-svg-55jusKJ201JMwZS2 .node ellipse,#mermaid-svg-55jusKJ201JMwZS2 .node polygon,#mermaid-svg-55jusKJ201JMwZS2 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-55jusKJ201JMwZS2 .rough-node .label text,#mermaid-svg-55jusKJ201JMwZS2 .node .label text,#mermaid-svg-55jusKJ201JMwZS2 .image-shape .label,#mermaid-svg-55jusKJ201JMwZS2 .icon-shape .label{text-anchor:middle;}#mermaid-svg-55jusKJ201JMwZS2 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-55jusKJ201JMwZS2 .rough-node .label,#mermaid-svg-55jusKJ201JMwZS2 .node .label,#mermaid-svg-55jusKJ201JMwZS2 .image-shape .label,#mermaid-svg-55jusKJ201JMwZS2 .icon-shape .label{text-align:center;}#mermaid-svg-55jusKJ201JMwZS2 .node.clickable{cursor:pointer;}#mermaid-svg-55jusKJ201JMwZS2 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-55jusKJ201JMwZS2 .arrowheadPath{fill:#333333;}#mermaid-svg-55jusKJ201JMwZS2 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-55jusKJ201JMwZS2 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-55jusKJ201JMwZS2 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-55jusKJ201JMwZS2 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-55jusKJ201JMwZS2 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-55jusKJ201JMwZS2 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-55jusKJ201JMwZS2 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-55jusKJ201JMwZS2 .cluster text{fill:#333;}#mermaid-svg-55jusKJ201JMwZS2 .cluster span{color:#333;}#mermaid-svg-55jusKJ201JMwZS2 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-55jusKJ201JMwZS2 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-55jusKJ201JMwZS2 rect.text{fill:none;stroke-width:0;}#mermaid-svg-55jusKJ201JMwZS2 .icon-shape,#mermaid-svg-55jusKJ201JMwZS2 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-55jusKJ201JMwZS2 .icon-shape p,#mermaid-svg-55jusKJ201JMwZS2 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-55jusKJ201JMwZS2 .icon-shape .label rect,#mermaid-svg-55jusKJ201JMwZS2 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-55jusKJ201JMwZS2 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-55jusKJ201JMwZS2 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-55jusKJ201JMwZS2 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

失败

成功

收到 id_token

解析三段,读取 Header 的 alg / kid

alg 是否在白名单内?(拒绝 none、拒绝意外的 HS256)

拒绝

按 kid 从缓存的 JWKS 取公钥

缓存命中?

刷新 JWKS 再取

验证签名

校验 iss 精确匹配

校验 aud 含本 client_id

校验 exp 未过期 (含时钟偏移容忍)

校验 iat 合理

本次发过 nonce?

校验 nonce 与会话中存的一致

跳过

用了 max_age?

校验 auth_time + max_age >= now

通过:建立用户会话

认证失败

4.2 逐项详解

(1) 算法校验——先于一切

不要信任 token header 里的 alg 来决定校验逻辑。RP 应基于自己的配置维护一个算法白名单(例如只接受 RS256),并拒绝一切不在白名单内的算法。尤其要防范:

  • alg: none
  • RS256 → HS256 混淆攻击:攻击者把算法从 RS256 改成 HS256,用 OP 的公钥(公开可得)当作 HMAC 密钥重新签名。若 RP 校验代码"根据 header 的 alg 自动选择算法"且把公钥当对称密钥喂进去,签名就会通过。根治办法是按配置固定算法,而非读 header。

(2) 签名验证

  • 根据 kid 从 JWKS 取对应公钥。
  • JWKS 应带缓存(按 Cache-Control 或固定 TTL),但遇到未知 kid 时应触发一次刷新,以适配密钥轮换。
  • 注意防 JWKS 刷新被滥用为 DoS(限频)。

(3) iss 校验

与 RP 配置的 issuer 做精确字符串比较,不做模糊/子串匹配。多租户场景尤其要锁定到具体租户的 issuer。

(4) aud 校验

aud 可能是字符串或数组。RP 必须确认其中包含自己的 client_id。若 aud 含多个值,且存在 azp,还应校验 azp 等于本 client_id。这一步防止"为别的客户端签发的 token 被拿来冒用"。

(5) exp / iat 时间校验

  • exp:当前时间必须早于 exp。
  • 允许一个小的时钟偏移容忍(clock skew,通常 ≤ 60 秒),避免分布式系统轻微时间差导致误判。容忍值不宜过大。
  • iat:可选地校验签发时间不在未来、不过分陈旧。

(6) nonce 校验——防重放

完整链条:RP 在发起授权请求时生成随机 nonce,存入用户会话(如 server-side session 或 HttpOnly Cookie 绑定的状态);OP 回填到 id_token;RP 收到后比对二者是否一致。这能防止旧的 id_token 被重放,并把 token 绑定到本次具体的认证会话。

注意区分 nonce 与 state:state 防 CSRF(Cross-Site Request Forgery)、维持请求上下文,作用在授权请求/回调环节;nonce 防 id_token 重放,作用在 token 本身。两者职责不同,都应使用。

(7) auth_time / max_age

若 RP 在授权请求中带了 max_age,OP 必须返回 auth_time。RP 校验 auth_time + max_age 是否仍在有效期内,用于对敏感操作强制"近期重新认证"。

4.3 一段示意性校验伪代码

// 概念示意,省略异常处理细节
public ClaimsPrincipal ValidateIdToken(string idToken)
{
var parameters = new TokenValidationParameters
{
// 1) 固定算法白名单,绝不读 header 的 alg 自动决定
ValidAlgorithms = new[] { "RS256" },

// 2) 签名公钥来自带缓存、自动刷新的 JWKS
IssuerSigningKeyResolver = (token, securityToken, kid, p)
=> _jwksCache.ResolveByKid(kid),
ValidateIssuerSigningKey = true,

// 3) iss 精确匹配
ValidateIssuer = true,
ValidIssuer = _config.Issuer,

// 4) aud 必须包含本 client_id
ValidateAudience = true,
ValidAudience = _config.ClientId,

// 5) 过期校验 + 有限时钟偏移
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(60),
};

var principal = _handler.ValidateToken(idToken, parameters, out _);

// 6) nonce 单独比对(库一般不自动做)
var nonce = principal.FindFirst("nonce")?.Value;
if (nonce != _session.ExpectedNonce)
throw new SecurityException("nonce mismatch");

return principal;
}


五、工程最佳实践小结

  • 优先用成熟库:JWT/OIDC 校验细节极多,自己手写极易出错。用经过审计的库(如 .NET 的 Microsoft.IdentityModel.Tokens、各语言的官方 OIDC 中间件),只在配置层做正确决策。
  • 算法按配置固定,建立白名单,杜绝 none 与 RS256/HS256 混淆。
  • iss 精确匹配、aud 必含 client_id,这两步拦截绝大多数"借用 token"攻击。
  • nonce + state 都要用,分别防重放与 CSRF。
  • JWKS 带缓存且能按未知 kid 触发刷新,平滑支持密钥轮换。
  • id_token 不是 access_token:它用于在 RP 建立本地会话/确认身份,不应被当作调用资源服务器的凭证;反之 access_token 也不应被 RP 解析当身份。
  • id_token 设短有效期:它的使命是"认证完成那一刻确认身份",建立会话后就不再需要长期存活。
  • 时钟偏移容忍要小(建议 ≤ 60s),过大会变相延长过期 token 的可用窗口。
  • 赞(0)
    未经允许不得转载:171主机测评 » OIDC id_token 的签发与校验:从规范到工程实践
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址