JWT 验证

在您的 resource server 以公钥验证 Signet 签发的 access token,不用回调 Signet。

本页只涵盖 access token。 ID token(scope 含 openid 时签发)同样通过 JWKS 验证,但 claim 集合与验证规则更严格 — 见 OpenID Connect。

重要取舍:本地 JWT 验证无法检测服务器端的撤销或状态变更(撤销 / 停用的 token 在密码学上仍然是有效的,直到过期为止)。若您需要实时撤销生效,请在本地验证之外抽样或带缓存地搭配 /oauth/introspect(RFC 7662)或 /oauth/tokeninfo — 绝不要每个请求都调它们来验 JWT。两个端点都有速率限制(tokeninfo 每 IP 600 req/min;introspect 每客户端应用 600 次,再加每 IP 1200 次上限),整个 fleet 逐请求调的话会先收到 429。唯一真的需要往返的凭证是不透明的 sgk_ 个人 API 密钥 — 请短暂缓存其验证结果。见 Token 与撤销 与 API 密钥。

适用情境

下列情况适合搭配 JWKS 做 JWT 验证:

  • 有 多个微服务 各自独立验证 token
  • 想 降低 Signet 负载,不用每次都回打验证
  • 需要 离线验证,不想依赖对 Signet 的网络
  • 零信任架构 中,服务不应共享 secret

前置条件:Signet 必须配置为 RS256 或 ES256 签名。若为 HS256(对称),JWKS 端点存在但会回空集合,OIDC discovery 文档也会省略 jwks_uri。

算法比较

算法 类型 密钥材料 Token 大小 适用情境
HS256 对称 JWT_SECRET(共享 secret) ~300 bytes 单一服务的简单部署
RS256 非对称 RSA 2048-bit 私钥 ~600 bytes 广泛生态支持,以 JWKS 分发
ES256 非对称 ECDSA P-256 私钥 ~400 bytes 体积小,适合现代部署

建议:最大兼容性选 RS256;想要小体积 token 选 ES256。多服务架构请避免 HS256。

运作方式

sequenceDiagram participant Client as Client App participant Signet as Signet participant RS as Resource Server Client->>Signet: POST /oauth/token(认证) Signet-->>Client: access_token(以 RS256/ES256 签的 JWT) Client->>RS: GET /api/resource(Authorization Bearer JWT) note over RS: 首次请求或缓存过期 RS->>Signet: GET /.well-known/jwks.json Signet-->>RS: JWKS 文档(以 kid 为键的公钥) note over RS: 缓存 JWKS(max-age=3600) note over RS: 本地验证 JWT 签名 note over RS: 验证 claim(exp、iss、scope) RS-->>Client: 200 OK(资源数据)

初次抓 JWKS 后,后续所有 token 验证都在本地进行,不再调用 Signet。

确认您的 Signet 使用非对称签名

基于 JWKS 的验证只在 Signet 配置为 RS256 或 ES256 时可行。从 OIDC Discovery 确认 — 若 jwks_uri 字段不存在,或 /.well-known/jwks.json 返回空 keys 数组,表示该部署是 HS256,无法用本方式验证。

遇到这情况,请管理员将部署切到 RS256/ES256。对称 secret 绝对不会通过 JWKS 暴露出来。

OIDC Discovery

Resource server 从 OIDC Discovery 发现 JWKS 网址:

curl https://your-signet/.well-known/openid-configuration
{
  "issuer": "https://your-signet",
  "jwks_uri": "https://your-signet/.well-known/jwks.json",
  "id_token_signing_alg_values_supported": ["RS256"]
}

jwks_uri 只在 RS256 / ES256 启用时出现。出现时,id_token_signing_alg_values_supported 会反映配置的 JWT_SIGNING_ALGORITHM(例如 ES256 时是 ["ES256"]),但若不支持 ID token 则可能完全省略此字段。

JWKS 端点

curl https://your-signet/.well-known/jwks.json

RS256 响应:

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "abc123...",
      "alg": "RS256",
      "n": "0vx7agoebGc...",
      "e": "AQAB"
    }
  ]
}

ES256 响应:

{
  "keys": [
    {
      "kty": "EC",
      "use": "sig",
      "kid": "def456...",
      "alg": "ES256",
      "crv": "P-256",
      "x": "f83OJ3D2xF1B...",
      "y": "x_FEzRu9m36H..."
    }
  ]
}

响应带 Cache-Control: public, max-age=3600 — 可缓存最多 1 小时。

HS256:JWKS 端点返回 {"keys": []}。对称 secret 绝不会通过 JWKS 暴露。

JWT 结构

Header:

{
  "alg": "RS256",
  "kid": "abc123...",
  "typ": "JWT"
}

Payload:

{
  "user_id": "user-uuid",
  "client_id": "client-uuid",
  "scope": "openid profile email",
  "type": "access",
  "exp": 1700000000,
  "iat": 1699996400,
  "iss": "https://your-signet",
  "sub": "user-uuid",
  "jti": "unique-token-id"
}
Claim 说明
user_id 与 sub 相同
client_id 索取此 token 的 OAuth 客户端
scope 空格分隔的授予 scope
type 您接受为 Bearer 的 token 一定是 access;refresh token 带 type=refresh,resource server 绝对不可 接受。一律只接受 access
aud Audience — 这张 token 适用的 resource server。当客户端传入 resource 参数(RFC 8707)时设为该值;否则回退为部署层级的 JWT_AUDIENCE。旧部署若两者皆未设置可能无此 claim。可能是单一字符串或 JSON 数组。详见下方 Audience Binding
exp 过期时间(Unix 秒)
iat 签发时间(Unix 秒)
iss Issuer URL(发现文档中的 issuer)
sub 用户 UUID;Client Credentials token 则为 client:<client_id>
jti 唯一 token id(UUID)

M2M token:当 sub 以 client: 开头,代表这张 token 来自 Client Credentials 流程,没有终端用户。若您的 API 要区别处理服务调用,可以依此分流。

Audience Binding (RFC 8707)

Signet 支持 Resource Indicators (RFC 8707)。当 OAuth 客户端在 /oauth/authorize、/oauth/device/code 或 /oauth/token 带入一个以上的 resource=<URL> 参数时,签发当下 就把访问 token 的 aud 绑到这些值。若没带 resource,aud 会回退到部署层级的 JWT_AUDIENCE 配置(若有),或完全省略。

客户端只能把 aud 绑定到管理员已加进该 客户端 allowed-resources 白名单(默认全部拒绝)的 resource。这表示您收到的 aud 是同时经过客户端 以及 运营方精选白名单两道授权的 — 但您 仍必须对自己的标识符验证 aud;白名单是签发侧的控制,不能取代 RS 侧的检查。

Resource server 必须验 aud:

  • 在 JWT 库设置要求 aud 等于 您自己的 resource 标识符 (例如 https://api.example.com)。这是阻止「为 A 服务签的 token 被 B 服务悄悄接受」的关键。
  • aud 可能是单一字符串或 JSON 数组 — 多数 JWT 库(Go 的 jwt.WithAudience、PyJWT 的 audience=...、jose 的 audience:)两种形状都会处理。
  • Refresh token 一律以静态 JWT_AUDIENCE 签,不会带每次请求的 resource。配合 type=access 检查,才能阻止 refresh token 被误当成 access token 拿到 resource server 用。请一定 两个 claim 都检查。
  • audience binding 部署前签发的 token 可能不含 aud。若您的部署保证会带 resource 或配置了非空 JWT_AUDIENCE,请严格要求 aud;否则容许缺失但记录起来。

ID token 不受影响 — 依 OIDC Core 1.0,其 aud 仍为 OAuth client_id。见 OpenID Connect。

验证步骤

  1. 解码 JWT header 取出 kid 与 alg
  2. 抓取 JWKS /.well-known/jwks.json(有缓存就用缓存)
  3. 找密钥 对应 JWT header 的 kid
  4. 验签 用对应公钥
  5. 验 claim:
    • exp — 未过期
    • iss — 对应 Signet URL
    • type — 必须是 access(拒绝 refresh)
    • aud — 必须包含您 resource server 的标识符 — 见 Audience Binding
  6. 授权检查:验 scope 是否满足需求;若您的 API 只允许特定客户端,也验 client_id

程序示例

Go

使用 keyfunc 自动抓取并缓存 JWKS:

package main

import (
  "fmt"
  "log"
  "net/http"
  "slices"
  "strings"

  "github.com/MicahParks/keyfunc/v3"
  "github.com/golang-jwt/jwt/v5"
)

func main() {
  jwksURL := "https://your-signet/.well-known/jwks.json"

  // 创建会自动重新抓取 JWKS 的 keyfunc
  k, err := keyfunc.NewDefault([]string{jwksURL})
  if err != nil {
    log.Fatalf("Failed to create JWKS keyfunc: %v", err)
  }

  http.HandleFunc("/api/resource", func(w http.ResponseWriter, r *http.Request) {
    auth := r.Header.Get("Authorization")
    if !strings.HasPrefix(auth, "Bearer ") {
      http.Error(w, "Missing Bearer token", http.StatusUnauthorized)
      return
    }
    tokenString := strings.TrimPrefix(auth, "Bearer ")

    // 以 JWKS 解析并验证 JWT。
    // WithAudience 要求 JWT 的 `aud` 必须包含此 resource server 的标识符 —
    // Signet 在客户端带 RFC 8707 `resource` 时用该值;否则回退到静态 JWT_AUDIENCE。
    // 不论哪种来源,RS 端验法都一样。
    token, err := jwt.Parse(tokenString, k.Keyfunc,
      jwt.WithIssuer("https://your-signet"),
      jwt.WithAudience("https://api.example.com"), // 您 resource server 的标识符
      jwt.WithExpirationRequired(),
      jwt.WithValidMethods([]string{"RS256", "ES256"}),
    )
    if err != nil {
      http.Error(w, fmt.Sprintf("Invalid token: %v", err), http.StatusUnauthorized)
      return
    }

    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok {
      http.Error(w, "Invalid token claims", http.StatusUnauthorized)
      return
    }

    tokenType, ok := claims["type"].(string)
    if !ok || tokenType != "access" {
      http.Error(w, "Invalid token type", http.StatusUnauthorized)
      return
    }

    // 检查 scope — 视您的 API 所需替换 "profile"。
    scopeStr, _ := claims["scope"].(string)
    scopes := strings.Fields(scopeStr)
    if !slices.Contains(scopes, "profile") {
      http.Error(w, "Insufficient scope", http.StatusForbidden)
      return
    }

    // 用户 token 的 sub 是 UUID;client_credentials 则为 "client:<client_id>"
    subject, ok := claims["sub"].(string)
    if !ok || subject == "" {
      http.Error(w, "Invalid token claims", http.StatusUnauthorized)
      return
    }
    fmt.Fprintf(w, "Hello, %s!", subject)
  })

  log.Fatal(http.ListenAndServe(":8081", nil))
}

Python

使用 PyJWT 内置的 JWKS client:

import jwt
from jwt import PyJWKClient
from flask import Flask, request, jsonify

app = Flask(__name__)

SIGNET_URL = "https://your-signet"
JWKS_URL = f"{SIGNET_URL}/.well-known/jwks.json"
MY_RESOURCE_ID = "https://api.example.com"   # 本 resource server 的标识符

# PyJWKClient 会自动缓存 JWKS
jwks_client = PyJWKClient(JWKS_URL, cache_keys=True, lifespan=3600)

@app.route("/api/resource")
def protected_resource():
    auth = request.headers.get("Authorization", "")
    if not auth.startswith("Bearer "):
        return jsonify({"error": "Missing Bearer token"}), 401

    token = auth.removeprefix("Bearer ")

    try:
        signing_key = jwks_client.get_signing_key_from_jwt(token)
        payload = jwt.decode(
            token,
            signing_key.key,
            algorithms=["RS256", "ES256"],
            issuer=SIGNET_URL,
            audience=MY_RESOURCE_ID,                       # 强制 RFC 8707 audience binding
            options={"require": ["exp", "iss", "sub", "aud"]},
        )
    except jwt.InvalidTokenError as e:
        return jsonify({"error": f"Invalid token: {e}"}), 401

    if payload.get("type") != "access":
        return jsonify({"error": "Invalid token type"}), 401

    # 检查 scope — 视您的 API 所需替换 "profile"。
    scopes = payload.get("scope", "").split()
    if "profile" not in scopes:
        return jsonify({"error": "Insufficient scope"}), 403

    return jsonify({"message": f"Hello, user {payload['user_id']}!"})

Node.js

使用 jose(零依赖):

import { createRemoteJWKSet, jwtVerify } from "jose";
import { createServer } from "node:http";

const SIGNET_URL = "https://your-signet";
const MY_RESOURCE_ID = "https://api.example.com"; // 本 resource server 的标识符
const JWKS = createRemoteJWKSet(new URL(`${SIGNET_URL}/.well-known/jwks.json`));

const server = createServer(async (req, res) => {
  const auth = req.headers.authorization || "";
  if (!auth.startsWith("Bearer ")) {
    res.writeHead(401);
    res.end(JSON.stringify({ error: "Missing Bearer token" }));
    return;
  }

  try {
    const { payload } = await jwtVerify(auth.slice(7), JWKS, {
      issuer: SIGNET_URL,
      audience: MY_RESOURCE_ID, // 强制 RFC 8707 audience binding
      algorithms: ["RS256", "ES256"],
      requiredClaims: ["exp", "sub", "aud", "scope"],
    });

    if (payload.type !== "access") {
      res.writeHead(401);
      res.end(JSON.stringify({ error: "Invalid token type" }));
      return;
    }

    // 检查 scope — 视您的 API 所需替换 "profile"。
    const scopes = (payload.scope || "").trim().split(/\s+/).filter(Boolean);
    if (!scopes.includes("profile")) {
      res.writeHead(403);
      res.end(JSON.stringify({ error: "Insufficient scope" }));
      return;
    }

    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ message: `Hello, user ${payload.user_id}!` }));
  } catch (err) {
    res.writeHead(401);
    res.end(JSON.stringify({ error: `Invalid token: ${err.message}` }));
  }
});

server.listen(8081, () => console.log("Resource server on :8081"));

缓存最佳实践

做法 细节
尊重 Cache-Control Signet 回 max-age=3600(1 小时)。不要抓得更频繁。
使用 JWKS 库 keyfunc(Go)、PyJWKClient(Python)、jose(Node.js)都会自动缓存。
以 kid 当键 以 kid 做 O(1) 查表。
遇到未知的 kid 重抓 JWKS 一次;仍找不到才拒绝此 token。
预热缓存 服务启动时先抓一次 JWKS,避免首个请求卡延迟。

密钥轮换

  1. 生成新密钥对并更新 Signet 的 JWT_PRIVATE_KEY_PATH
  2. 重启 Signet — 新 token 会以新密钥签
  3. Resource server 看到未知的 kid 会自动重抓 JWKS

时间表

时间 事件
T+0 Signet 带新密钥重启;JWKS 端点开始提供新公钥
T+0~1h 还在旧 JWKS 缓存的 resource server 遇到未知 kid 后重抓(JWKS max-age=3600)
T+token-TTL 旧 access token 按各自 profile 过期(short ≈ 15 分钟 · standard ≈ 10 小时 · long ≈ 24 小时;Client Credentials 用自己的 CLIENT_CREDENTIALS_TOKEN_EXPIRATION);使用中寿命最长者过期后,就不再有旧密钥签的 access token 会到达 resource server

限制:Signet 在 JWKS 响应中只公开一把有效公钥。轮换期间,没有正确处理未知 kid 的 resource server 可能拒绝新 token,直到 JWKS 缓存过期(最长 1 小时)。一旦某台 resource server 更新到新 JWKS,它就无法验证仍未过期、由旧密钥签的 token。为减少中断,请使用短效 access token 或在低峰时段轮换。

Refresh token 会立即失效,而非等到 access token TTL。 Refresh token 是用同一把密钥签的 JWT、寿命长得多(数天),但 Signet 只保留这一把有效密钥 — 所以轮换会一次让所有未过期的 refresh token 失效(下次刷新返回 invalid_grant),全部用户都得重新登录。Refresh token 没有双密钥缓冲期;上面的 access token 时间轴只适用于 resource server 验证。

常见陷阱

  • 不检查 kid header — 一律以 JWT 的 kid 对应 JWKS key,才能支持密钥轮换
  • 遇到未知 kid 不重抓 JWKS — 拒绝前先重抓一次,才能无缝轮换
  • HS256 时 JWKS 是空的 — 要改用 RS256 / ES256 才能以 JWKS 验证
  • 不验 iss — 一律验 issuer 与 Signet URL 相符
  • 不验 aud — 为其他 resource server 签的 token 不该在您这里被接受。把 WithAudience(Go)/ audience=(PyJWT)/ audience:(jose)设成 您自己的 resource 标识符 — 这同时涵盖 RFC 8707 每次请求绑定与静态 JWT_AUDIENCE 回退
  • resource server 接受 refresh token — 一律检查 type=access。Refresh token 用同一把密钥签,且 aud 为静态 JWT_AUDIENCE;没检查 type 的话,偷到的 refresh token 就能在任何只验签名/iss/exp/aud 的 RS 重放
  • 把公钥写死 — 改用 JWKS,才能自动支持密钥轮换
  • 时钟偏移 — 服务器保持 NTP 同步;在 JWT 库设 30–60 秒容许误差
  • 信任调用方送来的 extra_claims — token 可能携带客户端通过 extra_claims 参数注入的额外 claim(Token 与撤销 §Caller-Supplied Extra Claims)。这些是 调用方自行声称的,不是 Signet 背书的。绝不要把某个未识别的 claim 当成授权方已为其担保那样用来做授权决策;签名只证明 token 是真的被签发出来的,并不证明客户端声称的值为真

相关文档