Spring Security OAuth2 ID Token 生成机制深度解析
概述
ID Token 是 OpenID Connect (OIDC) 协议的核心组件,用于向客户端证明用户的身份。本文深入分析 Spring Security 7.x Authorization Server 中 ID Token 的生成流程、核心代码实现以及扩展方式。
一、ID Token 生成流程
1.1 整体架构
┌─────────────────────────────────────────────────────────────────────────┐
│ ID Token 生成流程 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ OAuth2AuthorizationCodeAuthenticationProvider.authenticate() │
│ │ │
│ ▼ │
│ 检查 scope 是否包含 "openid" ───────────────────────────────────────┐ │
│ │ 是 否 │ │
│ ▼ ▼ │ │
│ 构建 tokenContext (tokenType = ID_TOKEN) idToken=null │
│ │ │ │
│ ▼ │ │
│ tokenGenerator.generate(tokenContext) │ │
│ │ │ │
│ ▼ │ │
│ JwtGenerator.generate() │ │
│ │ │ │
│ ├── 1. 构建基础 claims (iss, sub, aud, iat, exp, jti) │ │
│ ├── 2. 添加 ID Token 特有 claims (azp, nonce, sid, auth_time)│
│ ├── 3. 调用 jwtCustomizer.customize() 定制 │ │
│ └── 4. jwtEncoder.encode() 签名生成 JWT │ │
│ │ │ │
│ ▼ │ │
│ new OidcIdToken(tokenValue, issuedAt, expiresAt, claims) │ │
│ │ │ │
│ ▼ │ │
│ authorizationBuilder.token(idToken, metadata) │ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
1.2 关键组件
| 组件 | 职责 |
|---|---|
OAuth2AuthorizationCodeAuthenticationProvider |
授权码认证提供者,协调 Token 生成 |
JwtGenerator |
JWT 生成器,负责构建和编码 JWT |
OAuth2TokenCustomizer |
Token 定制器,允许扩展 claims |
JwtEncoder |
JWT 编码器,负责签名 |
OidcIdToken |
ID Token 实体类 |
二、核心代码分析
2.1 触发条件
文件 : OAuth2AuthorizationCodeAuthenticationProvider.java
java
// ----- ID token -----
OidcIdToken idToken;
if (authorizationRequest.getScopes().contains(OidcScopes.OPENID)) {
// 处理 Session 信息(用于 OIDC 登出)
SessionInformation sessionInformation = getSessionInformation(principal);
if (sessionInformation != null) {
try {
// 计算 Session ID 的哈希值
sessionInformation = new SessionInformation(
sessionInformation.getPrincipal(),
createHash(sessionInformation.getSessionId()),
sessionInformation.getLastRequest()
);
} catch (NoSuchAlgorithmException ex) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
"Failed to compute hash for Session ID.", ERROR_URI);
throw new OAuth2AuthenticationException(error);
}
tokenContextBuilder.put(SessionInformation.class, sessionInformation);
}
// 构建 Token 上下文
tokenContext = tokenContextBuilder
.tokenType(ID_TOKEN_TOKEN_TYPE) // tokenType = "id_token"
.authorization(authorizationBuilder.build())
.build();
// 生成 ID Token
OAuth2Token generatedIdToken = this.tokenGenerator.generate(tokenContext);
if (!(generatedIdToken instanceof Jwt)) {
OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR,
"The token generator failed to generate the ID token.", ERROR_URI);
throw new OAuth2AuthenticationException(error);
}
// 创建 OidcIdToken 实例
idToken = new OidcIdToken(
generatedIdToken.getTokenValue(),
generatedIdToken.getIssuedAt(),
generatedIdToken.getExpiresAt(),
((Jwt) generatedIdToken).getClaims()
);
// 存储到 Authorization
authorizationBuilder.token(idToken,
(metadata) -> metadata.put(
OAuth2Authorization.Token.CLAIMS_METADATA_NAME,
idToken.getClaims()
));
} else {
idToken = null;
}
关键点:
- 只有当请求 scope 包含
openid时才生成 ID Token - Session 信息用于支持 OIDC 前端/后端登出
- ID Token 必须是 JWT 格式
2.2 JwtGenerator 生成逻辑
文件 : JwtGenerator.java
Step 1: 判断 Token 类型
java
@Override
public Jwt generate(OAuth2TokenContext context) {
// 只处理 ACCESS_TOKEN 或 ID_TOKEN
if (context.getTokenType() == null ||
(!OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType()) &&
!OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue()))) {
return null;
}
// Access Token 需要检查格式设置
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType()) &&
!OAuth2TokenFormat.SELF_CONTAINED.equals(
context.getRegisteredClient().getTokenSettings().getAccessTokenFormat())) {
return null;
}
// ...
}
Step 2: 设置过期时间和签名算法
java
Instant issuedAt = this.clock.instant();
Instant expiresAt;
JwsAlgorithm jwsAlgorithm = SignatureAlgorithm.RS256; // 默认 RS256
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
// ID Token 固定 30 分钟过期
expiresAt = issuedAt.plus(30, ChronoUnit.MINUTES);
// 使用客户端配置的签名算法(如果有)
if (registeredClient.getTokenSettings().getIdTokenSignatureAlgorithm() != null) {
jwsAlgorithm = registeredClient.getTokenSettings().getIdTokenSignatureAlgorithm();
}
} else {
// Access Token 使用配置的过期时间
expiresAt = issuedAt.plus(registeredClient.getTokenSettings().getAccessTokenTimeToLive());
}
Step 3: 构建基础 Claims
java
JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder();
// issuer: 签发者 URL
if (StringUtils.hasText(issuer)) {
claimsBuilder.issuer(issuer);
}
claimsBuilder
.subject(context.getPrincipal().getName()) // sub: 用户唯一标识
.audience(Collections.singletonList(clientId)) // aud: 受众(客户端ID)
.issuedAt(issuedAt) // iat: 签发时间
.expiresAt(expiresAt) // exp: 过期时间
.id(UUID.randomUUID().toString()); // jti: Token 唯一ID
Step 4: 添加 ID Token 特有 Claims
java
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
// azp: 授权方(Authorized Party)
claimsBuilder.claim(IdTokenClaimNames.AZP, registeredClient.getClientId());
// 授权码模式
if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(context.getAuthorizationGrantType())) {
OAuth2AuthorizationRequest authorizationRequest = context.getAuthorization()
.getAttribute(OAuth2AuthorizationRequest.class.getName());
// nonce: 防重放攻击的随机数
String nonce = (String) authorizationRequest.getAdditionalParameters()
.get(OidcParameterNames.NONCE);
if (StringUtils.hasText(nonce)) {
claimsBuilder.claim(IdTokenClaimNames.NONCE, nonce);
}
// Session 相关信息(用于 OIDC 登出)
SessionInformation sessionInformation = context.get(SessionInformation.class);
if (sessionInformation != null) {
claimsBuilder.claim("sid", sessionInformation.getSessionId()); // Session ID
claimsBuilder.claim(IdTokenClaimNames.AUTH_TIME,
sessionInformation.getLastRequest()); // 认证时间
}
}
// 刷新令牌模式 - 保留原 ID Token 的 sid 和 auth_time
else if (AuthorizationGrantType.REFRESH_TOKEN.equals(context.getAuthorizationGrantType())) {
OidcIdToken currentIdToken = context.getAuthorization()
.getToken(OidcIdToken.class).getToken();
if (currentIdToken.hasClaim("sid")) {
claimsBuilder.claim("sid", currentIdToken.getClaim("sid"));
}
if (currentIdToken.hasClaim(IdTokenClaimNames.AUTH_TIME)) {
claimsBuilder.claim(IdTokenClaimNames.AUTH_TIME,
currentIdToken.<Date>getClaim(IdTokenClaimNames.AUTH_TIME));
}
}
}
Step 5: 调用自定义器
java
if (this.jwtCustomizer != null) {
JwtEncodingContext.Builder jwtContextBuilder = JwtEncodingContext
.with(jwsHeaderBuilder, claimsBuilder)
.registeredClient(context.getRegisteredClient())
.principal(context.getPrincipal())
.authorizationServerContext(context.getAuthorizationServerContext())
.authorizedScopes(context.getAuthorizedScopes())
.tokenType(context.getTokenType())
.authorizationGrantType(context.getAuthorizationGrantType());
if (context.getAuthorization() != null) {
jwtContextBuilder.authorization(context.getAuthorization());
}
if (context.getAuthorizationGrant() != null) {
jwtContextBuilder.authorizationGrant(context.getAuthorizationGrant());
}
// ID Token 特有:传递 Session 信息
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
SessionInformation sessionInformation = context.get(SessionInformation.class);
if (sessionInformation != null) {
jwtContextBuilder.put(SessionInformation.class, sessionInformation);
}
}
JwtEncodingContext jwtContext = jwtContextBuilder.build();
this.jwtCustomizer.customize(jwtContext); // 允许自定义 claims 和 headers
}
Step 6: 编码生成 JWT
java
JwsHeader jwsHeader = jwsHeaderBuilder.build();
JwtClaimsSet claims = claimsBuilder.build();
// 使用 JwtEncoder 签名生成最终的 JWT
Jwt jwt = this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
return jwt;
三、ID Token 标准 Claims
3.1 OIDC 规范要求的 Claims
| Claim | 说明 | 是否必须 | 来源 |
|---|---|---|---|
iss |
签发者 URL | 必须 | AuthorizationServerContext.getIssuer() |
sub |
用户唯一标识 | 必须 | Principal.getName() |
aud |
受众(客户端ID) | 必须 | RegisteredClient.getClientId() |
exp |
过期时间 | 必须 | issuedAt + 30 分钟 |
iat |
签发时间 | 必须 | Clock.instant() |
auth_time |
用户认证时间 | 条件必须 | SessionInformation.getLastRequest() |
nonce |
防重放随机数 | 条件必须 | 授权请求参数 |
azp |
授权方 | 可选 | RegisteredClient.getClientId() |
3.2 Spring Security 额外添加的 Claims
| Claim | 说明 | 来源 |
|---|---|---|
jti |
Token 唯一标识 | UUID.randomUUID() |
sid |
Session ID(哈希后) | SessionInformation.getSessionId() |
3.3 示例 ID Token Payload
json
{
"iss": "http://localhost:9000",
"sub": "user123",
"aud": ["my-client-id"],
"exp": 1704070800,
"iat": 1704069000,
"jti": "550e8400-e29b-41d4-a716-446655440000",
"azp": "my-client-id",
"nonce": "abc123xyz",
"sid": "hashed-session-id",
"auth_time": 1704068900
}
四、ID Token 与 Access Token 的差异
| 特性 | Access Token | ID Token |
|---|---|---|
| 用途 | 访问受保护资源 | 身份认证证明 |
| 受众 | 资源服务器 | 客户端应用 |
| 过期时间 | 可配置(TokenSettings) | 固定 30 分钟 |
| 生成条件 | 总是生成 | 仅当 scope 含 "openid" |
| 特有 Claims | scope, nbf |
azp, nonce, sid, auth_time |
| 验证方 | 资源服务器 | 客户端应用 |
| 是否可刷新 | 是(通过 refresh_token) | 是(刷新时重新生成) |
五、扩展 ID Token
5.1 基本扩展方式
java
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> idTokenCustomizer() {
return context -> {
// 只处理 ID Token
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
JwtClaimsSet.Builder claims = context.getClaims();
Authentication principal = context.getPrincipal();
if (principal.getPrincipal() instanceof CustomUser user) {
// 添加标准 OIDC 用户信息 claims
claims.claim("email", user.getEmail());
claims.claim("email_verified", user.isEmailVerified());
claims.claim("name", user.getDisplayName());
claims.claim("given_name", user.getFirstName());
claims.claim("family_name", user.getLastName());
claims.claim("picture", user.getAvatarUrl());
claims.claim("phone_number", user.getPhone());
}
}
};
}
5.2 根据 Scope 添加 Claims
java
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> scopeBasedIdTokenCustomizer() {
return context -> {
if (!OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
return;
}
Set<String> scopes = context.getAuthorizedScopes();
JwtClaimsSet.Builder claims = context.getClaims();
Authentication principal = context.getPrincipal();
if (!(principal.getPrincipal() instanceof CustomUser user)) {
return;
}
// profile scope: 基本个人信息
if (scopes.contains(OidcScopes.PROFILE)) {
claims.claim("name", user.getDisplayName());
claims.claim("given_name", user.getFirstName());
claims.claim("family_name", user.getLastName());
claims.claim("nickname", user.getNickname());
claims.claim("picture", user.getAvatarUrl());
claims.claim("gender", user.getGender());
claims.claim("birthdate", user.getBirthdate());
claims.claim("locale", user.getLocale());
claims.claim("zoneinfo", user.getTimezone());
claims.claim("updated_at", user.getUpdatedAt());
}
// email scope: 邮箱信息
if (scopes.contains(OidcScopes.EMAIL)) {
claims.claim("email", user.getEmail());
claims.claim("email_verified", user.isEmailVerified());
}
// phone scope: 电话信息
if (scopes.contains(OidcScopes.PHONE)) {
claims.claim("phone_number", user.getPhone());
claims.claim("phone_number_verified", user.isPhoneVerified());
}
// address scope: 地址信息
if (scopes.contains(OidcScopes.ADDRESS)) {
Map<String, Object> address = new HashMap<>();
address.put("formatted", user.getFormattedAddress());
address.put("street_address", user.getStreetAddress());
address.put("locality", user.getCity());
address.put("region", user.getState());
address.put("postal_code", user.getPostalCode());
address.put("country", user.getCountry());
claims.claim("address", address);
}
};
}
5.3 添加自定义业务 Claims
java
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> businessIdTokenCustomizer() {
return context -> {
if (!OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
return;
}
JwtClaimsSet.Builder claims = context.getClaims();
Authentication principal = context.getPrincipal();
if (principal.getPrincipal() instanceof CustomUser user) {
// 租户信息
claims.claim("tenant_id", user.getTenantId());
claims.claim("tenant_name", user.getTenantName());
// 组织信息
claims.claim("org_id", user.getOrganizationId());
claims.claim("department", user.getDepartment());
// 角色信息
claims.claim("roles", user.getRoles());
// 用户类型
claims.claim("user_type", user.getUserType());
}
// 添加客户端相关信息
RegisteredClient client = context.getRegisteredClient();
claims.claim("client_name", client.getClientName());
};
}
5.4 同时处理 Access Token 和 ID Token
java
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> compositeTokenCustomizer() {
return context -> {
JwtClaimsSet.Builder claims = context.getClaims();
Authentication principal = context.getPrincipal();
// 公共 claims(两种 Token 都添加)
if (principal.getPrincipal() instanceof CustomUser user) {
claims.claim("user_id", user.getId());
claims.claim("tenant_id", user.getTenantId());
}
// Access Token 特有 claims
if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
claims.claim("token_type", "access");
// 添加权限信息
claims.claim("authorities", principal.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList());
}
// ID Token 特有 claims
if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
claims.claim("token_type", "id");
if (principal.getPrincipal() instanceof CustomUser user) {
// 添加用户详细信息
claims.claim("email", user.getEmail());
claims.claim("name", user.getDisplayName());
}
}
};
}
六、ID Token 验证要点
6.1 客户端验证流程
┌─────────────────────────────────────────────────────────────┐
│ ID Token 验证流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 验证签名 │
│ └── 使用授权服务器的公钥验证 JWT 签名 │
│ │
│ 2. 验证 iss (issuer) │
│ └── 必须与授权服务器 URL 完全匹配 │
│ │
│ 3. 验证 aud (audience) │
│ └── 必须包含当前客户端的 client_id │
│ │
│ 4. 验证 exp (expiration) │
│ └── 当前时间必须在过期时间之前 │
│ │
│ 5. 验证 iat (issued at) │
│ └── 签发时间应在合理范围内(可选) │
│ │
│ 6. 验证 nonce │
│ └── 必须与授权请求中发送的 nonce 匹配 │
│ │
│ 7. 验证 azp (authorized party) - 如果存在 │
│ └── 必须是当前客户端的 client_id │
│ │
└─────────────────────────────────────────────────────────────┘
6.2 验证代码示例(客户端)
java
public class IdTokenValidator {
public void validate(String idToken, String expectedNonce, String clientId, String issuer) {
// 解码 JWT(同时验证签名)
Jwt jwt = jwtDecoder.decode(idToken);
// 验证 issuer
if (!issuer.equals(jwt.getIssuer().toString())) {
throw new JwtValidationException("Invalid issuer");
}
// 验证 audience
if (!jwt.getAudience().contains(clientId)) {
throw new JwtValidationException("Invalid audience");
}
// 验证过期时间
if (jwt.getExpiresAt().isBefore(Instant.now())) {
throw new JwtValidationException("Token expired");
}
// 验证 nonce
String nonce = jwt.getClaimAsString("nonce");
if (expectedNonce != null && !expectedNonce.equals(nonce)) {
throw new JwtValidationException("Invalid nonce");
}
// 验证 azp(如果存在)
String azp = jwt.getClaimAsString("azp");
if (azp != null && !clientId.equals(azp)) {
throw new JwtValidationException("Invalid authorized party");
}
}
}
七、最佳实践
7.1 Claims 精简原则
java
@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> minimalIdTokenCustomizer() {
return context -> {
if (!OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
return;
}
// ✅ 只添加必要的身份信息
// ID Token 的主要目的是证明用户身份,不是传递所有用户数据
// ❌ 避免添加敏感信息
// claims.claim("password", user.getPassword());
// claims.claim("ssn", user.getSocialSecurityNumber());
// ❌ 避免添加大量数据
// claims.claim("all_permissions", getAllPermissions());
// claims.claim("full_profile", getFullProfile());
// ✅ 使用 UserInfo 端点获取详细信息
// ID Token 应该精简,详细信息通过 /userinfo 端点获取
};
}
7.2 安全考虑
- 签名算法:优先使用 RS256 或 ES256,避免使用 HS256(共享密钥)
- 过期时间:ID Token 过期时间不宜过长,默认 30 分钟是合理的
- nonce 验证:客户端必须验证 nonce 以防止重放攻击
- 敏感信息:不要在 ID Token 中包含密码、密钥等敏感信息
7.3 与 UserInfo 端点配合
java
// ID Token: 只包含基本身份信息
{
"iss": "http://auth.example.com",
"sub": "user123",
"aud": ["client-app"],
"exp": 1704070800,
"iat": 1704069000,
"name": "John Doe",
"email": "john@example.com"
}
// UserInfo 端点: 返回完整的用户信息
// GET /userinfo
// Authorization: Bearer <access_token>
{
"sub": "user123",
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"email": "john@example.com",
"email_verified": true,
"phone_number": "+1234567890",
"address": {
"formatted": "123 Main St, City, Country"
},
"picture": "https://example.com/photo.jpg"
}
八、总结
Spring Security 7.x 中的 ID Token 生成机制具有以下特点:
-
条件生成 :只有当授权请求包含
openidscope 时才生成 ID Token -
统一生成器 :
JwtGenerator同时负责生成 Access Token 和 ID Token,通过tokenType区分 -
标准化 Claims:自动添加 OIDC 规范要求的标准 claims(iss, sub, aud, exp, iat, azp, nonce 等)
-
可扩展性 :通过
OAuth2TokenCustomizer<JwtEncodingContext>灵活扩展 claims -
Session 支持:自动处理 Session 信息,支持 OIDC 前端/后端登出
-
安全设计:支持配置签名算法,Session ID 自动哈希处理
理解 ID Token 的生成机制,有助于正确实现 OIDC 身份认证,并根据业务需求进行合理扩展。