OIDC 中 ID Token 的签名:从密码学原理到工程实现

ID Token 是 OIDC(OpenID Connect)区别于纯 OAuth 2.0 的核心产物。OAuth 2.0 关心的是"授权"------给你一把能开门的钥匙;OIDC 关心的是"认证"------告诉 RP(Relying Party,依赖方)"这个人到底是谁"。而这个"告诉"的动作要可信,就必须依赖签名。本文系统地拆解 ID Token 签名的方方面面:它解决什么问题、用 JWS 怎么承载、有哪些算法及其陷阱、密钥如何发布与轮换、验签的精确步骤、有哪些经典攻击,以及在 ASP.NET Core 里如何落地。

一、为什么 ID Token 必须被签名

ID Token 本质是一份身份断言(identity assertion):IdP(Identity Provider,身份提供方)用结构化的方式声明"在某时刻,主体 sub 通过了认证,其受众是 client_id"。RP 拿到这份断言后,会据此创建本地会话、放行受保护资源。

问题在于:这份断言通常经过浏览器、前端、各种中间环节流转。如果它只是一段明文 JSON,任何能接触到传输链路的人都能伪造一个 "sub": "admin" 塞给 RP。RP 凭什么相信这是 IdP 签发的,而不是攻击者捏造的?

签名同时提供三重保证:

保证 含义 对称签名 非对称签名
完整性(integrity) 内容未被篡改
来源认证(origin authentication) 确实来自持有密钥的一方
不可否认性(non-repudiation) 签发方无法抵赖 ❌(双方共享密钥) ✅(私钥唯一持有)

正因为如此,OIDC Core 规范明确要求:ID Token 必须使用 JWS(JSON Web Signature)签名 ,除非它被加密(那也要先签后加)。唯一的例外是授权码流程(Authorization Code Flow)下、客户端在注册时显式声明 id_token_signed_response_alg = none、且 Token 通过 TLS 直连从令牌端点取回------这种场景在生产环境几乎都应禁止。


二、JWS:ID Token 签名的承载格式

ID Token 是一个 JWT(JSON Web Token),而带签名的 JWT 在物理上就是一个 JWS Compact Serialization(紧凑序列化)。它由三段经过 Base64URL 编码的内容用点号连接而成:

复制代码
eyJhbGciOiJSUzI1NiIsImtpZCI6IjJhYiJ9 . eyJpc3MiOiJodHRwczovL2lkcCJ9 . NHVaYe26MbtO...
└──────── Header ────────┘   └─── Payload ───┘   └──── Signature ────┘
        (JOSE Header)            (Claims)            (签名值)

三段各自的职责:

  • Header(JOSE Header) :声明 alg(签名算法)和 kid(Key ID,用于定位验签公钥),常含 typ: JWT
  • Payload(Claims) :isssubaudexpiatnonce,以及 at_hashc_hash 等。
  • Signature:对前两段拼接结果的签名值。

签名输入的精确定义

这是最容易被忽视却最关键的细节。签名不是 对原始 JSON 计算的,而是对已经 Base64URL 编码后的 Header 和 Payload 的拼接串计算的:

复制代码
Signing Input = ASCII( BASE64URL(UTF8(Header)) + "." + BASE64URL(Payload) )

Signature     = Sign( Signing Input, Key )

JWT           = BASE64URL(UTF8(Header)) + "." 
              + BASE64URL(Payload)      + "."
              + BASE64URL(Signature)

之所以这样设计,是为了规避"JSON 规范化"难题:不同库序列化 JSON 时空格、键顺序、转义都可能不同,如果对原始 JSON 签名,验签方几乎无法重现完全相同的字节。改为对编码后的字符串签名,验签方拿到什么字节就验什么字节,无需重新序列化,彻底消除了歧义。

注意 Base64URL 与标准 Base64 的差异:用 - 替换 +、用 _ 替换 /、去掉末尾的 = 填充。这是 JOSE 体系全程使用的编码方式。


三、签名算法详解

算法标识符在 JWA(JSON Web Algorithms,RFC 7518)及补充规范中定义。命名规律是"算法族 + 摘要长度":尾部的 256/384/512 指 SHA-256/384/512。
#mermaid-svg-5aIloI2JS6271XGx{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-5aIloI2JS6271XGx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5aIloI2JS6271XGx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5aIloI2JS6271XGx .error-icon{fill:#552222;}#mermaid-svg-5aIloI2JS6271XGx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5aIloI2JS6271XGx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5aIloI2JS6271XGx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5aIloI2JS6271XGx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5aIloI2JS6271XGx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5aIloI2JS6271XGx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5aIloI2JS6271XGx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5aIloI2JS6271XGx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5aIloI2JS6271XGx .marker.cross{stroke:#333333;}#mermaid-svg-5aIloI2JS6271XGx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5aIloI2JS6271XGx p{margin:0;}#mermaid-svg-5aIloI2JS6271XGx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5aIloI2JS6271XGx .cluster-label text{fill:#333;}#mermaid-svg-5aIloI2JS6271XGx .cluster-label span{color:#333;}#mermaid-svg-5aIloI2JS6271XGx .cluster-label span p{background-color:transparent;}#mermaid-svg-5aIloI2JS6271XGx .label text,#mermaid-svg-5aIloI2JS6271XGx span{fill:#333;color:#333;}#mermaid-svg-5aIloI2JS6271XGx .node rect,#mermaid-svg-5aIloI2JS6271XGx .node circle,#mermaid-svg-5aIloI2JS6271XGx .node ellipse,#mermaid-svg-5aIloI2JS6271XGx .node polygon,#mermaid-svg-5aIloI2JS6271XGx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5aIloI2JS6271XGx .rough-node .label text,#mermaid-svg-5aIloI2JS6271XGx .node .label text,#mermaid-svg-5aIloI2JS6271XGx .image-shape .label,#mermaid-svg-5aIloI2JS6271XGx .icon-shape .label{text-anchor:middle;}#mermaid-svg-5aIloI2JS6271XGx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5aIloI2JS6271XGx .rough-node .label,#mermaid-svg-5aIloI2JS6271XGx .node .label,#mermaid-svg-5aIloI2JS6271XGx .image-shape .label,#mermaid-svg-5aIloI2JS6271XGx .icon-shape .label{text-align:center;}#mermaid-svg-5aIloI2JS6271XGx .node.clickable{cursor:pointer;}#mermaid-svg-5aIloI2JS6271XGx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5aIloI2JS6271XGx .arrowheadPath{fill:#333333;}#mermaid-svg-5aIloI2JS6271XGx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5aIloI2JS6271XGx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5aIloI2JS6271XGx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5aIloI2JS6271XGx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5aIloI2JS6271XGx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5aIloI2JS6271XGx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5aIloI2JS6271XGx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5aIloI2JS6271XGx .cluster text{fill:#333;}#mermaid-svg-5aIloI2JS6271XGx .cluster span{color:#333;}#mermaid-svg-5aIloI2JS6271XGx 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-5aIloI2JS6271XGx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5aIloI2JS6271XGx rect.text{fill:none;stroke-width:0;}#mermaid-svg-5aIloI2JS6271XGx .icon-shape,#mermaid-svg-5aIloI2JS6271XGx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5aIloI2JS6271XGx .icon-shape p,#mermaid-svg-5aIloI2JS6271XGx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5aIloI2JS6271XGx .icon-shape .label rect,#mermaid-svg-5aIloI2JS6271XGx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5aIloI2JS6271XGx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5aIloI2JS6271XGx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5aIloI2JS6271XGx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} JWS 签名算法
对称 / MAC
非对称 / 数字签名
HS256 / HS384 / HS512

HMAC + SHA
RS256 / RS384 / RS512

RSASSA-PKCS1-v1_5
PS256 / PS384 / PS512

RSASSA-PSS
ES256 / ES384 / ES512

ECDSA
EdDSA

Ed25519 / Ed448
none

无签名(危险)

1. HMAC 系列(HS256/384/512)------对称

基于 HMAC + SHA。签发与验签用同一把密钥 。在 OIDC 里,这把密钥就是 client_secret

这带来两个直接后果:其一,只有**机密客户端(confidential client)**才能用------SPA、移动 App 这类公开客户端(public client)没有 secret,无法验签;其二,IdP 必须知道每个客户端的 secret,且针对不同客户端产生不同签名。HMAC 速度快,但密钥分发与保密压力大,因此 OIDC 主流不推荐它作为 ID Token 的签名手段。

2. RSA PKCS#1 v1.5 系列(RS256/384/512)------非对称

RS256 = RSASSA-PKCS1-v1_5 + SHA-256。这是 OIDC 的默认且强制实现 的算法,生态兼容性最好。特点是确定性:相同输入相同密钥,签名值恒定。PKCS#1 v1.5 填充方案历史悠久,密码学界近年更倾向 PSS,但 RS256 因兼容性仍占绝对主流。

3. RSA-PSS 系列(PS256/384/512)------非对称

PS256 = RSASSA-PSS + SHA-256 + MGF1(SHA-256),盐长等于摘要长。PSS 引入随机盐,因此是概率性的:同一份内容每次签名结果都不同。这在可证明安全性上优于 PKCS#1 v1.5,是新系统的推荐选择,但部分老旧 RP 库可能不支持。

4. ECDSA 系列(ES256/384/512)------非对称(有坑)

基于椭圆曲线。ES256 用 P-256 曲线,ES384 用 P-384,ES512 用 P-521(注意是 521 不是 512)。优点是密钥短、签名快。

这里有一个经典实现陷阱 :JWS 规定 ECDSA 签名必须是固定长度的 R ‖ S 原始拼接(ES256 下 R、S 各 32 字节,共 64 字节)。而 OpenSSL、Java、.NET 等很多底层 API 默认输出的是 ASN.1/DER 编码 (带长度前缀、可变长)。如果直接把 DER 字节塞进 JWS,验签必然失败。在 .NET 里,要用 DSASignatureFormat.IeeeP1363FixedFieldConcatenation(成熟的 JsonWebTokenHandler 已内部处理好,但手写时务必注意)。

复制代码
DER 格式(错误):  30 44 02 20 <R...> 02 20 <S...>   ← 不能用于 JWS
P1363 格式(正确):<R 固定32字节> <S 固定32字节>      ← JWS 要求

5. EdDSA(Ed25519 / Ed448)------非对称

RFC 8037 引入。基于 Edwards 曲线,签名快、抗侧信道、无需随机数源(确定性),是现代密码学的明星。但 OIDC 生态采纳度仍不及 RS256/ES256,RP 端支持需要确认。

6. none 算法------无签名

alg: none 表示不签名,签名段为空。OIDC 仅在前述极严格条件下允许。任何生产 RP 的验签逻辑都绝不能接受 none------否则等于敞开大门。

OIDC 的算法约束小结

  • ID Token 必须 用非 none 算法签名(除极特殊例外)。
  • RS256 是强制实现项,可作为兜底默认。
  • IdP 通过 Discovery 文档的 id_token_signing_alg_values_supported 公布支持的算法。
  • 客户端注册时通过 id_token_signed_response_alg 指定期望算法。

四、为什么 OIDC 几乎总是用非对称签名

把对称与非对称放在 OIDC 的真实拓扑里对比,结论就很清晰了:
RP N RP B RP A IdP(签发方) RP N RP B RP A IdP(签发方) #mermaid-svg-3Lw6S27zmICgFKJv{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-3Lw6S27zmICgFKJv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3Lw6S27zmICgFKJv .error-icon{fill:#552222;}#mermaid-svg-3Lw6S27zmICgFKJv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3Lw6S27zmICgFKJv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3Lw6S27zmICgFKJv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3Lw6S27zmICgFKJv .marker.cross{stroke:#333333;}#mermaid-svg-3Lw6S27zmICgFKJv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3Lw6S27zmICgFKJv p{margin:0;}#mermaid-svg-3Lw6S27zmICgFKJv .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3Lw6S27zmICgFKJv text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3Lw6S27zmICgFKJv .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3Lw6S27zmICgFKJv .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3Lw6S27zmICgFKJv .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3Lw6S27zmICgFKJv .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3Lw6S27zmICgFKJv #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3Lw6S27zmICgFKJv .sequenceNumber{fill:white;}#mermaid-svg-3Lw6S27zmICgFKJv #sequencenumber{fill:#333;}#mermaid-svg-3Lw6S27zmICgFKJv #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3Lw6S27zmICgFKJv .messageText{fill:#333;stroke:none;}#mermaid-svg-3Lw6S27zmICgFKJv .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3Lw6S27zmICgFKJv .labelText,#mermaid-svg-3Lw6S27zmICgFKJv .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3Lw6S27zmICgFKJv .loopText,#mermaid-svg-3Lw6S27zmICgFKJv .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3Lw6S27zmICgFKJv .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3Lw6S27zmICgFKJv .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3Lw6S27zmICgFKJv .noteText,#mermaid-svg-3Lw6S27zmICgFKJv .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3Lw6S27zmICgFKJv .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3Lw6S27zmICgFKJv .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3Lw6S27zmICgFKJv .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3Lw6S27zmICgFKJv .actorPopupMenu{position:absolute;}#mermaid-svg-3Lw6S27zmICgFKJv .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-3Lw6S27zmICgFKJv .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3Lw6S27zmICgFKJv .actor-man circle,#mermaid-svg-3Lw6S27zmICgFKJv line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3Lw6S27zmICgFKJv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 非对称:IdP 持有 1 把私钥 任意 RP 用公钥即可独立验签无需保管任何机密 用私钥签所有 ID Token公钥(经 JWKS 公开)同一份公钥同一份公钥

非对称签名的核心优势:

  1. 密钥分发问题消失 :公钥本就是公开的,通过 jwks_uri 自动发布,RP 不需要安全信道去拿密钥。
  2. RP 只需公钥验签:验签方拿不到、也不需要私钥,无法伪造 Token,职责天然隔离。
  3. 统一签名,多方复用:IdP 用同一把私钥服务所有 RP,签名结果一致;而 HMAC 需为每个客户端用其 secret 单独签。
  4. 覆盖所有客户端类型:SPA、移动端这类无 secret 的公开客户端也能验签。

对称签名只在"单一机密客户端、且不想引入公钥基础设施"的小场景里偶有价值。


五、JWK / JWKS:公钥的发布与发现

既然 RP 要用公钥验签,公钥怎么传给它?答案是 JWKS(JSON Web Key Set),通过 OIDC Discovery 自动发现。

发现链路

复制代码
1. RP 读取   https://idp.example.com/.well-known/openid-configuration
2. 文档里有  "jwks_uri": "https://idp.example.com/.well-known/jwks.json"
3. RP 拉取该 jwks_uri,得到一组公钥(JWKS)
4. 验签时按 Token 头里的 kid 在这组键里挑出对应公钥

JWK 结构(以 RSA 公钥为例)

JWK 是公钥的 JSON 表示。一个 JWKS 就是 keys 数组,里面放多把 JWK:

json 复制代码
{
  "keys": [
    {
      "kty": "RSA",            // Key Type:密钥类型
      "use": "sig",            // 用途:sig=签名验证,enc=加密
      "kid": "2ab-2025-q2",    // Key ID:与 Token 头的 kid 对应
      "alg": "RS256",          // 配套算法
      "n": "0vx7agoeb...",     // RSA 模数(modulus)
      "e": "AQAB"              // RSA 公开指数(exponent)
    },
    {
      "kty": "EC",
      "use": "sig",
      "kid": "ec-2025-q2",
      "crv": "P-256",          // Curve:曲线
      "x": "f83OJ3D2...",      // 椭圆曲线点 X 坐标
      "y": "x_FEzRu9..."       // 椭圆曲线点 Y 坐标
    }
  ]
}

不同 kty 暴露不同参数:RSA 是 n/e;EC 是 crv/x/y。注意 JWKS 里只放公钥分量 ,绝不含私钥参数(如 RSA 的 dpq)。

kid:连接 Token 与公钥的索引

kid 是验签的"路由键"。Token 头里写 "kid": "2ab-2025-q2",RP 就在 JWKS 中找 kid 相同的那把键。这个机制让 IdP 可以同时挂多把键------而这正是平滑轮换的基础。

如果 Token 头没有 kid(不推荐),RP 只能用 kty/alg 匹配后逐一尝试,效率与确定性都差。

JWKS 的缓存

RP 不会每次验签都去拉 JWKS------那样会拖垮 IdP。常见做法是按 Cache-Control 或固定 TTL 缓存。但缓存带来一个关键问题:当 IdP 轮换出新 kid 时,RP 缓存里还没有这把新键,验签会找不到 kid 。成熟的 RP 库(包括 .NET 的 ConfigurationManager)会处理这种"遇到未知 kid 时强制刷新一次 JWKS"的逻辑。这个缓存策略与下面的密钥轮换是一体两面。


六、签名验证的完整流程

把签发与验证放在一张时序图里:
jwks_uri IdP RP jwks_uri IdP RP #mermaid-svg-gTTC0tuOjRnaiEo2{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-gTTC0tuOjRnaiEo2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gTTC0tuOjRnaiEo2 .error-icon{fill:#552222;}#mermaid-svg-gTTC0tuOjRnaiEo2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gTTC0tuOjRnaiEo2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gTTC0tuOjRnaiEo2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gTTC0tuOjRnaiEo2 .marker.cross{stroke:#333333;}#mermaid-svg-gTTC0tuOjRnaiEo2 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gTTC0tuOjRnaiEo2 p{margin:0;}#mermaid-svg-gTTC0tuOjRnaiEo2 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gTTC0tuOjRnaiEo2 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-gTTC0tuOjRnaiEo2 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-gTTC0tuOjRnaiEo2 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-gTTC0tuOjRnaiEo2 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-gTTC0tuOjRnaiEo2 .sequenceNumber{fill:white;}#mermaid-svg-gTTC0tuOjRnaiEo2 #sequencenumber{fill:#333;}#mermaid-svg-gTTC0tuOjRnaiEo2 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-gTTC0tuOjRnaiEo2 .messageText{fill:#333;stroke:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gTTC0tuOjRnaiEo2 .labelText,#mermaid-svg-gTTC0tuOjRnaiEo2 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .loopText,#mermaid-svg-gTTC0tuOjRnaiEo2 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-gTTC0tuOjRnaiEo2 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-gTTC0tuOjRnaiEo2 .noteText,#mermaid-svg-gTTC0tuOjRnaiEo2 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-gTTC0tuOjRnaiEo2 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gTTC0tuOjRnaiEo2 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gTTC0tuOjRnaiEo2 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gTTC0tuOjRnaiEo2 .actorPopupMenu{position:absolute;}#mermaid-svg-gTTC0tuOjRnaiEo2 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-gTTC0tuOjRnaiEo2 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gTTC0tuOjRnaiEo2 .actor-man circle,#mermaid-svg-gTTC0tuOjRnaiEo2 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-gTTC0tuOjRnaiEo2 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 签发阶段 验证阶段 构造 Header(alg=RS256, kid=K1)+ PayloadSigningInput = b64(Header).b64(Payload)Signature = Sign(SigningInput, 私钥K1)返回 ID Token(三段拼接)1. 拆分三段,解码 Header2. 校验 alg 在白名单内(拒绝 none / 防混淆)3. 按 kid=K1 取公钥(缓存未命中则刷新)返回公钥 K14. Verify(SigningInput, Signature, 公钥K1)5. 校验 iss / aud / exp / iat / nonce6. 校验 at_hash / c_hash(如适用)

验签步骤的细节(对应 OIDC Core 3.1.3.7):

  1. 若加密则先解密(JWE 场景)。
  2. 校验 alg :必须在 RP 预先约定的白名单内,绝不能信任 Token 自报的算法类别(这是防御算法混淆的关键,详见第八节)。
  3. kid 定位公钥:从缓存的 JWKS 取,缺失则刷新。
  4. 执行签名验证 :对 SigningInput 用公钥验签。只有这一步通过,后续 Claims 才可信------顺序很重要,先验签再读内容。
  5. 校验标准声明 :iss 等于预期 Issuer;aud 包含本 client_id(多受众时还要查 azp);exp 未过期;iat 合理;nonce 与发起时一致(防重放)。
  6. 校验绑定哈希 :at_hash 绑定 access_token、c_hash 绑定授权码,确保它们与本 ID Token 同源未被掉包。

补充说明 at_hash/c_hash 的计算:取对应 Token 字符串,用与 ID Token 的 alg 匹配的摘要算法(RS256→SHA-256)做哈希,取左半截字节,再 Base64URL 编码。它本身写在已签名的 Payload 里,因此其完整性由 ID Token 签名间接保护------这是一种巧妙的"用签名保护其他工件"的设计。


七、密钥轮换(Key Rotation)

私钥用久了泄露风险累积,合规上也常要求定期更换。但直接"换掉私钥"会导致大量尚未过期的旧 Token 集体验签失败。kid + JWKS 多键并存让零停机轮换成为可能。

平滑轮换的标准时间线:

复制代码
阶段 0  仅 K1 在 JWKS,用 K1 签名
         JWKS: [K1]            签名用: K1

阶段 1  生成 K2,把 K2 公钥加入 JWKS;此时仍用 K1 签名
         JWKS: [K1, K2]        签名用: K1     ← 让 RP 缓存提前拿到 K2

阶段 2  切换为用 K2 签名;K1 公钥保留在 JWKS
         JWKS: [K1, K2]        签名用: K2     ← 旧 Token(K1签)仍可验
         └─ 此重叠窗口需 ≥ (最长 Token 有效期 + JWKS 缓存 TTL)

阶段 3  确认无任何 K1 签发的有效 Token 后,移除 K1
         JWKS: [K2]            签名用: K2

几个工程要点:

  • 重叠窗口必须足够长:至少覆盖"最长 ID Token 有效期 + RP 端 JWKS 缓存的最坏 TTL",否则会出现"旧 Token 还没过期、但其公钥已从 JWKS 消失"的验签失败。
  • kid 命名要稳定可辨:如带时间/轮次信息,便于排障和审计。
  • 先发布后启用:阶段 1 提前把新公钥放进 JWKS,给 RP 缓存留出同步时间,避免切换瞬间 RP 集体扑空。
  • RP 侧要支持"未知 kid 触发刷新":这是兜底保障,即便重叠窗口估算偏短也能自愈。

八、签名相关的攻击与防御

签名机制最危险的漏洞,几乎都出在验签方对算法的处理上。

1. alg: none 攻击

攻击者把 Header 改成 {"alg":"none"},删掉签名段,直接伪造任意 Payload。如果 RP 的库"看到 none 就认为无需验签",任何 Token 都会被接受。

防御 :验签端维护算法白名单,显式拒绝 none。在 .NET 中通过 TokenValidationParameters.ValidAlgorithms 限定。

2. 算法混淆攻击(RS256 → HS256)

这是最经典也最隐蔽的一类。IdP 用 RS256(非对称),公钥是公开的。攻击者把 Header 的 alg 改成 HS256,然后用 IdP 的 RSA 公钥当作 HMAC 密钥去签。如果 RP 的验签逻辑是"按 Token 头里的 alg 决定算法,并把配置好的那把'密钥'拿来用",当它对 HS256 用 RSA 公钥做 HMAC 验证时------因为攻击者正是用同一把公钥签的------签名竟然通过了!

根因:让 Token 自报算法类别,且验签方在对称/非对称之间用了同一把"密钥材料"
#mermaid-svg-MxTFljR4nTJSkEBT{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-MxTFljR4nTJSkEBT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MxTFljR4nTJSkEBT .error-icon{fill:#552222;}#mermaid-svg-MxTFljR4nTJSkEBT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MxTFljR4nTJSkEBT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MxTFljR4nTJSkEBT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MxTFljR4nTJSkEBT .marker.cross{stroke:#333333;}#mermaid-svg-MxTFljR4nTJSkEBT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MxTFljR4nTJSkEBT p{margin:0;}#mermaid-svg-MxTFljR4nTJSkEBT .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MxTFljR4nTJSkEBT .cluster-label text{fill:#333;}#mermaid-svg-MxTFljR4nTJSkEBT .cluster-label span{color:#333;}#mermaid-svg-MxTFljR4nTJSkEBT .cluster-label span p{background-color:transparent;}#mermaid-svg-MxTFljR4nTJSkEBT .label text,#mermaid-svg-MxTFljR4nTJSkEBT span{fill:#333;color:#333;}#mermaid-svg-MxTFljR4nTJSkEBT .node rect,#mermaid-svg-MxTFljR4nTJSkEBT .node circle,#mermaid-svg-MxTFljR4nTJSkEBT .node ellipse,#mermaid-svg-MxTFljR4nTJSkEBT .node polygon,#mermaid-svg-MxTFljR4nTJSkEBT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MxTFljR4nTJSkEBT .rough-node .label text,#mermaid-svg-MxTFljR4nTJSkEBT .node .label text,#mermaid-svg-MxTFljR4nTJSkEBT .image-shape .label,#mermaid-svg-MxTFljR4nTJSkEBT .icon-shape .label{text-anchor:middle;}#mermaid-svg-MxTFljR4nTJSkEBT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MxTFljR4nTJSkEBT .rough-node .label,#mermaid-svg-MxTFljR4nTJSkEBT .node .label,#mermaid-svg-MxTFljR4nTJSkEBT .image-shape .label,#mermaid-svg-MxTFljR4nTJSkEBT .icon-shape .label{text-align:center;}#mermaid-svg-MxTFljR4nTJSkEBT .node.clickable{cursor:pointer;}#mermaid-svg-MxTFljR4nTJSkEBT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MxTFljR4nTJSkEBT .arrowheadPath{fill:#333333;}#mermaid-svg-MxTFljR4nTJSkEBT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MxTFljR4nTJSkEBT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MxTFljR4nTJSkEBT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MxTFljR4nTJSkEBT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MxTFljR4nTJSkEBT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MxTFljR4nTJSkEBT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MxTFljR4nTJSkEBT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MxTFljR4nTJSkEBT .cluster text{fill:#333;}#mermaid-svg-MxTFljR4nTJSkEBT .cluster span{color:#333;}#mermaid-svg-MxTFljR4nTJSkEBT 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-MxTFljR4nTJSkEBT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MxTFljR4nTJSkEBT rect.text{fill:none;stroke-width:0;}#mermaid-svg-MxTFljR4nTJSkEBT .icon-shape,#mermaid-svg-MxTFljR4nTJSkEBT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MxTFljR4nTJSkEBT .icon-shape p,#mermaid-svg-MxTFljR4nTJSkEBT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MxTFljR4nTJSkEBT .icon-shape .label rect,#mermaid-svg-MxTFljR4nTJSkEBT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MxTFljR4nTJSkEBT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MxTFljR4nTJSkEBT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MxTFljR4nTJSkEBT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 信任 Token 自报算法

且复用同一密钥材料
算法白名单 + 区分密钥类型
IdP 用 RS256

RSA 私钥签名
公钥公开发布
攻击者改 alg=HS256

用 RSA 公钥作 HMAC 密钥重签
RP 验签逻辑
❌ 验签通过,被攻破
✅ 拒绝

防御 :RP 必须固定预期算法 (只接受 RS256),不让 Token 决定算法族;不同算法族使用类型隔离的密钥对象(RsaSecurityKey 就是 RSA,不会被当成 HMAC 字节)。

3. kid 注入攻击

kid 若被某些实现拿去拼路径(读文件)或拼 SQL(查库),攻击者可注入路径穿越或 SQL 注入,诱导 RP 用一把"攻击者可控的密钥"去验签,甚至读到 /dev/null 之类可预测内容当密钥。

防御 :把 kid 严格当作不可信输入,只用于在受信任的 JWKS 集合里做精确匹配查找,绝不参与文件/数据库的动态拼接。

4. 密钥/JWKS 替换攻击

jwks_uri 走的不是 HTTPS,或 RP 不校验 TLS 证书,攻击者可中间人替换整份 JWKS,塞入自己的公钥。

防御 :jwks_uri 强制 HTTPS 并严格校验证书;iss、Discovery 文档、jwks_uri 三者来源一致性校验。

一句话总结防御原则 :验签方掌握主动权------预先固定 Issuer、固定算法白名单、隔离密钥类型、把 Token 头里的一切(alg/kid)都当不可信输入。


九、ASP.NET Core 中的实现要点

下面把上述概念映射到 Microsoft.IdentityModel.Tokens 体系,分签发(IdP)与验证(RP)两侧。

IdP 侧:配置签名凭据并暴露 JWKS

csharp 复制代码
// 1) 准备签名密钥(生产中私钥应来自密钥库/HSM,而非内存随机)
var rsa = RSA.Create(2048);
var securityKey = new RsaSecurityKey(rsa)
{
    KeyId = "2ab-2025-q2"   // 这个 KeyId 会写进 Token 头的 kid
};

// 2) 用 RS256 构造签名凭据
var signingCredentials = new SigningCredentials(
    securityKey,
    SecurityAlgorithms.RsaSha256);   // "RS256"

// 3) 签发 ID Token(推荐用较新的 JsonWebTokenHandler)
var handler = new JsonWebTokenHandler();
string idToken = handler.CreateToken(new SecurityTokenDescriptor
{
    Issuer   = "https://idp.example.com",
    Audience = "client-app-id",
    Claims   = new Dictionary<string, object>
    {
        ["sub"]   = "248289761001",
        ["nonce"] = "n-0S6_WzA2Mj",
        // at_hash / c_hash 按需计算后加入
    },
    Expires            = DateTime.UtcNow.AddMinutes(10),
    SigningCredentials = signingCredentials
});

JWKS 端点则把公钥分量 导出为 JWK 数组发布到 jwks_uri:

csharp 复制代码
// 把公钥转成 JWK(仅含 n/e 等公开参数,绝不含私钥)
var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(
    new RsaSecurityKey(rsa.ExportParameters(includePrivateParameters: false))
);
jwk.KeyId = "2ab-2025-q2";
jwk.Use   = "sig";
jwk.Alg   = "RS256";

var jwks = new JsonWebKeySet();
jwks.Keys.Add(jwk);
// 序列化后挂到 /.well-known/jwks.json

轮换时,只需在 jwks.Keys同时保留新旧两把公钥 ,并按第七节的时间线切换签名所用的 signingCredentials

RP 侧:验证配置(防御要点都在这里)

csharp 复制代码
builder.Services.AddAuthentication()
    .AddJwtBearer(options =>
    {
        options.Authority = "https://idp.example.com";
        // Authority 会自动驱动 ConfigurationManager:
        // 拉取 discovery → jwks_uri → 缓存公钥,并在遇到未知 kid 时自动刷新

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer   = "https://idp.example.com",
            ValidAudience = "client-app-id",

            // 关键防御:固定算法白名单,直接堵死 none 与算法混淆
            ValidAlgorithms = new[] { "RS256" },

            ValidateIssuer           = true,
            ValidateAudience         = true,
            ValidateLifetime         = true,
            ValidateIssuerSigningKey = true,
            RequireSignedTokens      = true,   // 拒绝无签名 Token
            ClockSkew                = TimeSpan.FromSeconds(30)
        };
    });

落地清单:

  • ValidAlgorithms 显式限定算法------这一行同时挡住了 alg: none 和 RS256→HS256 混淆。
  • RequireSignedTokens = true 杜绝无签名 Token。
  • 依赖 Authority 自动管理 JWKS 缓存与刷新,不要手写脆弱的缓存逻辑。
  • 私钥务必托管在密钥库/HSM,代码里不出现私钥常量。

十、签名之外:签名 + 加密(Nested JWT)

签名解决"防篡改、可溯源",但不解决机密性 ------签名后的 Payload 仍是 Base64URL 明文,任何人都能解码看到 subemail 等声明。如果 ID Token 会经过不可信环节、且 Claims 含敏感信息,就需要在签名之外叠加 JWE(JSON Web Encryption) 加密。

OIDC 规定的顺序是先签后加(sign-then-encrypt),形成嵌套 JWT:

复制代码
明文 Claims
   │ ① JWS 签名(IdP 私钥)
   ▼
已签名 JWT ────────────── 这就是普通 ID Token
   │ ② JWE 加密(RP 公钥)
   ▼
加密的嵌套 JWT ─────────── 只有 RP 用其私钥能解

验证时反向:RP 先用自己的私钥解密 (JWE),拿到内层已签名 JWT,再用 IdP 公钥验签(JWS)。这样既保证了来源可信(IdP 签名),又保证了内容只有目标 RP 可读(RP 加密)。绝大多数场景只需签名;只有当 ID Token 暴露在高风险链路、且含敏感个人信息时,才上签名+加密的组合。


总结

ID Token 的签名是 OIDC 信任模型的基石,一句话串起来:

IdP 用私钥 对"Base64URL(Header).Base64URL(Payload)"这段精确字节做 JWS 签名 (默认 RS256),把对应公钥JWK 形式发布在 jwks_uri 上并用 kid 标识;RP 通过 Discovery 自动获取公钥,验签时先固定算法白名单、按 kid 取公钥、验签通过后再校验 iss/aud/exp/nonce ;借助 kid + JWKS 多键并存,密钥可以零停机轮换

工程上最该刻进肌肉记忆的三条:验签方永远不信任 Token 自报的 alg、永远把 kid 当不可信输入、永远用类型隔离的密钥对象 。这三条做到位,alg: none 和算法混淆这两类最致命的攻击就基本被关在门外了。