Spring Security OAuth2 ID Token 生成机制深度解析

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 安全考虑

  1. 签名算法:优先使用 RS256 或 ES256,避免使用 HS256(共享密钥)
  2. 过期时间:ID Token 过期时间不宜过长,默认 30 分钟是合理的
  3. nonce 验证:客户端必须验证 nonce 以防止重放攻击
  4. 敏感信息:不要在 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 生成机制具有以下特点:

  1. 条件生成 :只有当授权请求包含 openid scope 时才生成 ID Token

  2. 统一生成器JwtGenerator 同时负责生成 Access Token 和 ID Token,通过 tokenType 区分

  3. 标准化 Claims:自动添加 OIDC 规范要求的标准 claims(iss, sub, aud, exp, iat, azp, nonce 等)

  4. 可扩展性 :通过 OAuth2TokenCustomizer<JwtEncodingContext> 灵活扩展 claims

  5. Session 支持:自动处理 Session 信息,支持 OIDC 前端/后端登出

  6. 安全设计:支持配置签名算法,Session ID 自动哈希处理

理解 ID Token 的生成机制,有助于正确实现 OIDC 身份认证,并根据业务需求进行合理扩展。

参考资料

相关推荐
老毛肚2 小时前
Spring源码探究1.0
java·后端·spring
韩立学长2 小时前
【开题答辩实录分享】以《以体验为中心的小学古诗互动学习App的设计及实现》为例进行选题答辩实录分享
java·spring·安卓
小李独爱秋2 小时前
计算机网络经典问题透视:流式存储、流式实况与交互式音视频的深度解析
服务器·网络协议·计算机网络·安全·音视频
stillaliveQEJ2 小时前
【JavaEE】Spring AOP(二)
java·spring·java-ee
济6172 小时前
linux(第十五期)--蜂鸣器实验-- Ubuntu20.04
linux·运维·服务器
岁岁种桃花儿2 小时前
Spring Boot项目核心配置:parent父项目详解(附实操指南)
java·spring boot·spring
JANGHIGH2 小时前
ipcs命令行工具
运维·服务器
Run_Teenage2 小时前
Linux:硬链接与软链接
linux·运维·服务器
每日出拳老爷子2 小时前
【浏览器方案】只用浏览器访问的内网会议系统设计思路(无客户端)
运维·服务器·webrtc·实时音视频·流媒体