基于Spring Authorization Server实现双自定义身份提供商的问题排查
问题描述
我希望基于Spring Authorization Server实现两个自定义联邦身份提供商:
- 第一个提供商采用
AuthorizationGrantType.AUTHORIZATION_CODE授权类型,为带登录页的React Web客户端提供认证服务,用于从Keycloak获取用户信息; - 第二个提供商仅面向设备,采用
AuthorizationGrantType.CLIENT_CREDENTIALS授权类型提供API访问认证,通过API调用从Cognito获取用户凭证。
以下是我当前的配置代码:
@Bean @Order(Ordered.HIGHEST_PRECEDENCE) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .tokenEndpoint(tokenEndpoint -> { tokenEndpoint.accessTokenRequestConverter(new ThirdPartyPreAuthenticationConverter()); tokenEndpoint.authenticationProvider( new ApikeyAuthenticationProvider( jwtGenerator(jwkSource()), oAuth2BasicTokenClaimsProvider, apikeyRegisteredClientRepository(passwordEncoder()), passwordEncoder(), tokenGenerator)); tokenEndpoint.authenticationProvider( new UsersAuthenticationProvider( jwtGenerator(jwkSource()), pegasusClient, usersRegisteredClientRepository(passwordEncoder()), passwordEncoder(), tokenGenerator)); tokenEndpoint.accessTokenResponseHandler(new DispatchGeneratedTokenHandler()); }); http.oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer.jwt(Customizer.withDefaults()) ); return http.build(); } @Bean public InMemoryRegisteredClientRepository apikeyRegisteredClientRepository(PasswordEncoder passwordEncoder) { RegisteredClient tokenExchangeClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(clientId) .clientSecret(passwordEncoder.encode(clientSecret)) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .scope("internal.read") .scope("internal.write") .build(); InMemoryRegisteredClientRepository registeredClientRepository = new InMemoryRegisteredClientRepository(tokenExchangeClient); registeredClientRepository.save(tokenExchangeClient); return registeredClientRepository; } @Bean @Primary public InMemoryRegisteredClientRepository usersRegisteredClientRepository(PasswordEncoder passwordEncoder) { RegisteredClient tokenExchangeClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId(clientId) .clientSecret(passwordEncoder.encode(clientSecret)) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .scope("internal.read") .scope("internal.write") .redirectUri("http://localhost:8080/login/oauth2/code/my-client") .redirectUri("http://localhost:8080/authorized") .build(); InMemoryRegisteredClientRepository registeredClientRepository = new InMemoryRegisteredClientRepository(tokenExchangeClient); registeredClientRepository.save(tokenExchangeClient); return registeredClientRepository; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @SneakyThrows @Bean public JWKSource<SecurityContext> jwkSource() { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); KeyPair keyPair = keyPairGenerator.generateKeyPair(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); JWKSet jwkSet = new JWKSet(rsaKey); return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); } @Bean public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } @Bean public JwtEncoder jwtGenerator(JWKSource<SecurityContext> jwkSource) { return new NimbusJwtEncoder(jwkSource); } @Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder().build(); } @Bean public AuthenticationProvider apikeyAuthenticationProvider() { return new ApikeyAuthenticationProvider( jwtGenerator(jwkSource()), oAuth2BasicTokenClaimsProvider, apikeyRegisteredClientRepository(passwordEncoder()), passwordEncoder(), tokenGenerator); } @Bean @Primary public AuthenticationProvider usersAuthenticationProvider() { return new UsersAuthenticationProvider( jwtGenerator(jwkSource()), usersRegisteredClientRepository(passwordEncoder()), passwordEncoder(), tokenGenerator); }
UsersAuthenticationProvider实现:
public class UsersAuthenticationProvider implements AuthenticationProvider { .... public UsersAuthenticationProvider(...) { ......... } @Override public OAuth2AccessTokenAuthenticationToken authenticate(Authentication authentication) throws AuthenticationException { OAuth2ThirdPartyClientCredentialsAuthenticationToken token = (OAuth2ThirdPartyClientCredentialsAuthenticationToken) authentication; RegisteredClient registeredClient = registeredClientRepository.findByClientId(token.getClientId()); if(registeredClient == null){ throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); } if(!encoder.matches(token.getClientSecret(), registeredClient.getClientSecret())) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT); } Map<String, Object> claims = null; // !!! TEMPORARY disabled this.jwtEncoder.encode(JwtEncoderParameters.from(jwsHeaderBuilder.build(), claimsBuilder.build())); OAuth2AccessToken jwt = tokenGenerator.generateToken(claims); return new OAuth2AccessTokenAuthenticationToken( registeredClient, authentication, jwt); } @Override public boolean supports(Class<?> authentication) { return OAuth2ThirdPartyClientCredentialsAuthenticationToken.class.isAssignableFrom(authentication); } }
ApikeyAuthenticationProvider实现:
@Primary public class ApikeyAuthenticationProvider implements AuthenticationProvider { ........... public ApikeyAuthenticationProvider(........) { .......... } @Override public OAuth2AccessTokenAuthenticationToken authenticate(Authentication authentication) throws AuthenticationException { OAuth2ThirdPartyClientCredentialsAuthenticationToken token = (OAuth2ThirdPartyClientCredentialsAuthenticationToken) authentication; RegisteredClient registeredClient = registeredClientRepository.findByClientId(token.getClientId()); if(registeredClient == null){ throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); } if(!encoder.matches(token.getClientSecret(), registeredClient.getClientSecret())) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT); } Map<String, Object> claims = apikeyAuthenticationProviderClient.getClaims(......); OAuth2AccessToken jwt = tokenGenerator.generateToken(claims); return new OAuth2AccessTokenAuthenticationToken( registeredClient, authentication, jwt); } @Override public boolean supports(Class<?> authentication) { return OAuth2ThirdPartyClientCredentialsAuthenticationToken.class.isAssignableFrom(authentication); } }
当前配置无法正常运行,正确的实现方式是什么?
解决方案
核心问题分析
- RegisteredClientRepository冲突:定义了两个独立的内存仓库且共用同一个clientId,Spring无法区分不同客户端的配置;
- AuthenticationProvider匹配错误:两个Provider都绑定到自定义的
OAuth2ThirdPartyClientCredentialsAuthenticationToken,但授权码流程需要对应OAuth2AuthorizationCodeAuthenticationToken,客户端凭证流程对应OAuth2ClientCredentialsAuthenticationToken; - 授权码流程逻辑缺失:UsersAuthenticationProvider未处理授权码的交换逻辑,反而使用客户端凭证的Token类型;
- 冗余Bean定义:SecurityFilterChain中手动实例化Provider的同时又单独定义了Provider Bean,造成依赖混乱。
修正后的实现代码
1. 合并RegisteredClientRepository
将两个客户端配置合并到同一个仓库,用不同clientId区分:
@Bean public InMemoryRegisteredClientRepository registeredClientRepository(PasswordEncoder passwordEncoder) { // React Web客户端(授权码流程,对接Keycloak) RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("web-react-client") .clientSecret(passwordEncoder.encode("web-client-secret-xxx")) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .scope("internal.read") .scope("internal.write") .redirectUri("http://localhost:3000/login/oauth2/code/web-client") // React回调地址 .build(); // 设备客户端(客户端凭证流程,对接Cognito) RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("device-api-client") .clientSecret(passwordEncoder.encode("device-client-secret-xxx")) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .scope("internal.read") .scope("internal.write") .build(); return new InMemoryRegisteredClientRepository(webClient, deviceClient); }
2. 重构SecurityFilterChain配置
移除冗余Bean,按授权类型分别配置Provider:
@Bean @Order(Ordered.HIGHEST_PRECEDENCE) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, RegisteredClientRepository registeredClientRepository, JwtEncoder jwtEncoder, PasswordEncoder passwordEncoder, TokenGenerator tokenGenerator, PegasusClient pegasusClient, OAuth2BasicTokenClaimsProvider claimsProvider) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); OAuth2AuthorizationServerConfigurer authServerConfig = http.getConfigurer(OAuth2AuthorizationServerConfigurer.class); // 授权码流程:对接Keycloak的认证逻辑 authServerConfig.authorizationEndpoint(endpoint -> endpoint.authenticationProvider(new KeycloakAuthCodeProvider(jwtEncoder, registeredClientRepository, passwordEncoder, tokenGenerator, pegasusClient)) ); // 客户端凭证流程:对接Cognito的认证逻辑 authServerConfig.tokenEndpoint(endpoint -> endpoint.authenticationProvider(new CognitoClientCredentialsProvider(jwtEncoder, claimsProvider, registeredClientRepository, passwordEncoder, tokenGenerator)) ); http.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); return http.build(); }
3. 实现授权码流程Provider
处理Keycloak授权码交换逻辑:
public class KeycloakAuthCodeProvider implements AuthenticationProvider { private final JwtEncoder jwtEncoder; private final RegisteredClientRepository clientRepo; private final PasswordEncoder passwordEncoder; private final TokenGenerator tokenGenerator; private final PegasusClient pegasusClient; public KeycloakAuthCodeProvider(JwtEncoder jwtEncoder, RegisteredClientRepository clientRepo, PasswordEncoder passwordEncoder, TokenGenerator tokenGenerator, PegasusClient pegasusClient) { this.jwtEncoder = jwtEncoder; this.clientRepo = clientRepo; this.passwordEncoder = passwordEncoder; this.tokenGenerator = tokenGenerator; this.pegasusClient = pegasusClient; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { OAuth2AuthorizationCodeAuthenticationToken authToken = (OAuth2AuthorizationCodeAuthenticationToken) authentication; // 验证客户端合法性 RegisteredClient client = clientRepo.findByClientId(authToken.getClientId()); if (client == null || !passwordEncoder.matches(authToken.getClientSecret(), client.getClientSecret())) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); } // 调用Keycloak接口,用授权码交换用户信息 Map<String, Object> userClaims = pegasusClient.fetchKeycloakUserInfo(authToken.getCode(), client.getRedirectUris().iterator().next()); if (userClaims == null || userClaims.isEmpty()) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); } // 生成自定义JWT令牌 OAuth2AccessToken jwt = tokenGenerator.generateToken(userClaims); return new OAuth2AccessTokenAuthenticationToken(client, authentication, jwt); } @Override public boolean supports(Class<?> authentication) { return OAuth2AuthorizationCodeAuthenticationToken.class.isAssignableFrom(authentication); } }
4. 修正客户端凭证流程Provider
对接Cognito的API调用逻辑:
public class CognitoClientCredentialsProvider implements AuthenticationProvider { private final JwtEncoder jwtEncoder; private final OAuth2BasicTokenClaimsProvider claimsProvider; private final RegisteredClientRepository clientRepo; private final PasswordEncoder passwordEncoder; private final TokenGenerator tokenGenerator; public CognitoClientCredentialsProvider(JwtEncoder jwtEncoder, OAuth2BasicTokenClaimsProvider claimsProvider, RegisteredClientRepository clientRepo, PasswordEncoder passwordEncoder, TokenGenerator tokenGenerator) { this.jwtEncoder = jwtEncoder; this.claimsProvider = claimsProvider; this.clientRepo = clientRepo; this.passwordEncoder = passwordEncoder; this.tokenGenerator = tokenGenerator; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { OAuth2ClientCredentialsAuthenticationToken authToken = (OAuth2ClientCredentialsAuthenticationToken) authentication; RegisteredClient client = clientRepo.findByClientId(authToken.getClientId()); if (client == null || !passwordEncoder.matches(authToken.getClientSecret(), client.getClientSecret())) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT); } // 调用Cognito获取凭证信息 Map<String, Object> claims = claimsProvider.getClaims(authToken.getClientId(), authToken.getClientSecret()); if (claims == null || claims.isEmpty()) { throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); } OAuth2AccessToken jwt = tokenGenerator.generateToken(claims); return new OAuth2AccessTokenAuthenticationToken(client, authentication, jwt); } @Override public boolean supports(Class<?> authentication) { return OAuth2ClientCredentialsAuthenticationToken.class.isAssignableFrom(authentication); } }
5. 清理冗余Bean
删除原来的apikeyRegisteredClientRepository、usersRegisteredClientRepository、apikeyAuthenticationProvider、usersAuthenticationProvider四个Bean定义,避免依赖冲突。
关键注意事项
- 客户端区分:必须为两个流程分配不同的clientId,否则Spring无法匹配对应的授权逻辑;
- Token类型匹配:每个Provider的
supports方法要绑定对应授权类型的标准Token,不能混用自定义Token; - 联邦认证流程:授权码流程需要提前配置Keycloak的客户端回调地址,确保用户登录后能正确跳转回React应用;
- 错误规范:所有认证异常必须抛出
OAuth2AuthenticationException并携带标准错误码,符合OAuth2协议规范。
内容的提问来源于stack exchange,提问作者Peter Penzov
相关产品推荐
相关产品推荐

