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 验证),但验证规则 更严格:
- 签名 — 以 JWKS 中
kid对应的密钥验证。
iss— 必须等于发现文档中的issuer(即BASE_URL的规范化形式:scheme 与主机小写、去掉结尾斜杠)。
aud— 必须等于您的client_id。若aud是数组,必须包含您的client_id且不得包含可疑值。
exp— 必须在未来(可容许少量时钟偏移,例如 30 秒)。
iat— 应该是近期时间。
nonce— 必须等于您在授权请求送出的nonce。
auth_time— 若您带了max_age,需强制检查。
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
profilescope 控制name、preferred_username、picture、updated_at
emailscope 控制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 8707resource或JWT_AUDIENCE),不是您的client_id。aud=client_id的验法只适用于 ID token。