You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于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);
    }
}

当前配置无法正常运行,正确的实现方式是什么?


解决方案

核心问题分析

  1. RegisteredClientRepository冲突:定义了两个独立的内存仓库且共用同一个clientId,Spring无法区分不同客户端的配置;
  2. AuthenticationProvider匹配错误:两个Provider都绑定到自定义的OAuth2ThirdPartyClientCredentialsAuthenticationToken,但授权码流程需要对应OAuth2AuthorizationCodeAuthenticationToken,客户端凭证流程对应OAuth2ClientCredentialsAuthenticationToken;
  3. 授权码流程逻辑缺失:UsersAuthenticationProvider未处理授权码的交换逻辑,反而使用客户端凭证的Token类型;
  4. 冗余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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 15:08:08