OpenID Connect(ID Token 与 UserInfo)

Signet 在 Authorization Code Flow 上支持 OpenID Connect 1.0。当您在 scope 中包含 openid,Signet 会在发出 access token 的同时签发一张 ID token,并开放 /oauth/userinfo 端点。

目前 Device Flow 不签发 ID token。要做 OIDC 请用 Authorization Code Flow。

ID Token vs. Access Token

问题 ID Token Access Token
它在描述 _谁_? 终端用户(身份) 调用 API 的授权
它 _给谁用_? 您的客户端应用(aud=client_id) 它绑定的 resource server(aud=每次请求的 resource 或 JWT_AUDIENCE)
能当 Authorization: Bearer 送 API? 不行 — 永远不行 可以
需要验 aud? 要 — 必须等于您的 client_id 要 — 必须等于 resource server 的标识符(见 JWT 验证 §Audience Binding)
需要验 nonce? 要 — 必须与您送出的一致 不适用
包含个人信息? 有(email、name、picture,视 scope 而定) 无

原则:只有您自己的客户端应用才应该去解析 ID token。把它传给另一个服务,等于把用户身份泄露给非预期对象。

索取 ID token

在 Authorization Code Flow 的授权请求中把 openid 放进 scope,并带上 nonce:

GET /oauth/authorize
  ?client_id=YOUR_CLIENT_ID
  &redirect_uri=https://yourapp.example/callback
  &response_type=code
  &scope=openid profile email
  &state=RANDOM_STATE
  &nonce=RANDOM_NONCE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256

在 /oauth/token 换取 token 的响应中会附上 id_token:

{
  "access_token": "eyJhbG...",
  "refresh_token": "def502...",
  "id_token": "eyJhbG...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile email"
}

ID Token 的 claims

Header:

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

Payload(依授予的 scope 而定):

Claim 必出现 出现时机 含义
iss ✓ Issuer URL — 必须等于发现文档中的 issuer
sub ✓ 稳定的用户标识(UUID)
aud ✓ 您的 client_id — 必须相符 才算有效
exp ✓ 过期时间(Unix 秒)
iat ✓ 签发时间(Unix 秒)
auth_time ✓ 用户认证时间(Unix 秒)
jti ✓ 唯一 token id
nonce — 您在授权请求有带 nonce 时 必须与您送出的值一致 — 防重放
at_hash — 同时签发 access token 时 对 access token 做 SHA-256 的前半段,base64url 编码
name — scope 含 profile 显示用全名
preferred_username — scope 含 profile 显示用账号(例如 alice)
picture — scope 含 profile 且用户有头像 头像 URL
updated_at — scope 含 profile profile 最近更新时间(Unix 秒)
email — scope 含 email 主要 email
email_verified — scope 含 email true 表示 email 已被验证(例如通过 OAuth provider)

验证 ID token

与 access token 同样使用 JWKS(见 JWT 验证),但验证规则 更严格:

  1. 签名 — 以 JWKS 中 kid 对应的密钥验证。
  2. iss — 必须等于发现文档中的 issuer(即 BASE_URL 的规范化形式:scheme 与主机小写、去掉结尾斜杠)。
  3. aud — 必须等于您的 client_id。若 aud 是数组,必须包含您的 client_id 且不得包含可疑值。
  4. exp — 必须在未来(可容许少量时钟偏移,例如 30 秒)。
  5. iat — 应该是近期时间。
  6. nonce — 必须等于您在授权请求送出的 nonce。
  7. auth_time — 若您带了 max_age,需强制检查。
  8. at_hash (可选、建议) — 验证与同时拿到的 access token 相符。

Go(golang-jwt + keyfunc)

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

jwksURL := "https://your-signet/.well-known/jwks.json"
k, _ := keyfunc.NewDefault([]string{jwksURL})

token, err := jwt.Parse(idTokenString, k.Keyfunc,
    jwt.WithIssuer("https://your-signet"),
    jwt.WithAudience(clientID),               // 强制验 aud
    jwt.WithExpirationRequired(),
    jwt.WithValidMethods([]string{"RS256", "ES256"}),
)
if err != nil {
    return fmt.Errorf("invalid id_token: %w", err)
}

claims := token.Claims.(jwt.MapClaims)
nonce, ok := claims["nonce"].(string)
if !ok || nonce != expectedNonce {
    return fmt.Errorf("nonce mismatch")
}

Python(PyJWT)

import jwt
from jwt import PyJWKClient

jwks_client = PyJWKClient(f"{SIGNET_URL}/.well-known/jwks.json")
signing_key = jwks_client.get_signing_key_from_jwt(id_token)

claims = jwt.decode(
    id_token,
    signing_key.key,
    algorithms=["RS256", "ES256"],
    issuer=SIGNET_URL,
    audience=CLIENT_ID,              # 强制验 aud
    options={"require": ["exp", "iss", "sub", "aud"]},
)

if claims.get("nonce") != expected_nonce:
    raise ValueError("nonce mismatch")

Node.js(jose)

import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(new URL(`${SIGNET_URL}/.well-known/jwks.json`));

const { payload } = await jwtVerify(idToken, JWKS, {
  issuer: SIGNET_URL,
  audience: CLIENT_ID, // 强制验 aud
  algorithms: ["RS256", "ES256"],
});

if (payload.nonce !== expectedNonce) throw new Error("nonce mismatch");

UserInfo 端点

要获取 scope 授权的实时用户数据,请用 access token(不是 ID token)调用 /oauth/userinfo:

curl -H "Authorization: Bearer ACCESS_TOKEN" https://your-signet/oauth/userinfo

响应(字段取决于授予的 scope):

{
  "sub": "user-uuid",
  "iss": "https://your-signet",
  "name": "Alice Example",
  "preferred_username": "alice",
  "picture": "https://...",
  "updated_at": 1700000000,
  "email": "alice@example.com",
  "email_verified": true
}
  • 一定包含 sub 与 iss
  • profile scope 控制 name、preferred_username、picture、updated_at
  • email scope 控制 email、email_verified

若 token 无效或过期,UserInfo 响应 401 Unauthorized 并带 WWW-Authenticate: Bearer error="invalid_token", resource_metadata="<issuer>/.well-known/oauth-protected-resource"。除非设置 PROTECTED_RESOURCE_METADATA_ENABLED=false,否则会带上 resource_metadata 指针(RFC 9728 §5.1);客户端可循此指针找到授权服务器。

ID token 的 claim vs. UserInfo,该选哪个? ID token 是登录当下一次性的身份证明。若需要实时 profile 数据(例如用户刚换头像),用当下的 access token 打 UserInfo。

Discovery

OIDC 库应该自动从这里获取配置:

https://your-signet/.well-known/openid-configuration

完整文档格式见 开始使用。

Signet 也在 /.well-known/oauth-authorization-server 发布平行的 RFC 8414 文档,提供给非 OIDC 的 OAuth 2.1 / MCP 客户端。两份文档共享的字段内容保持一致 — 依您库的需求挑一份用即可。当管理员启用 Client ID Metadata Documents(CIMD_ENABLED=true)时,RFC 8414 文档会额外声明 client_id_metadata_document_supported: true — 其作用请见授权码流程指南。

常见陷阱

  • 把 ID token 当 Bearer 送去打 API。不要。用 access token。
  • 不验 aud。缺这一步,别人家客户端的 ID token 可能被您误接受。
  • 不验 nonce。一律送并验 nonce。规范虽然在 Auth Code Flow 标为 OPTIONAL,但省略就等于放弃防重放保护,强烈不建议。
  • 没验签就解析 ID token。千万别这样做 — 没验签前的 JWT 是 未认证 的。
  • 对 access token 验 aud=client_id。Access token 的 aud 是 resource server 的标识符(RFC 8707 resource 或 JWT_AUDIENCE),不是您的 client_id。aud=client_id 的验法只适用于 ID token。

相关文档