spring-bootspring-securitykeycloakimpersonationtoken-exchange

How can I authenticate using the token exchange grant type for impersonation with spring boot and keycloak?


To put a bit of context: I have successfully been able to do this using curl so yes, I probably can come up with a home made solution that works but the point of my question here it to not have to maintain auth-related classes and rather leverage what spring boot has to give.

What I'm trying to do is Impersonation through token exchange. To ease things up I'm considering Direct Naked Impersonation

I have a private service (let's call it S) triggered by an event (in an event sourced environment). The event carries an original JWT that comes from the original user. As there is no guarantee in terms of time-to-process we can consider that this JWT has expired. S is working as an anti corruption layer and is interacting with a legacy public GraphQL API (let's call it A). To do so, S needs to be authenticated as a user being authorised to access the specific entities it's going to work with in A.

Until now S only had to interact on behalf of a single user and so this was working fine:

@Bean
fun legacyServerGraphQLClient(
    legacyServerProperties: LegacyServerProperties,
    clientRegistrationRepository: InMemoryReactiveClientRegistrationRepository,
    clientService: ReactiveOAuth2AuthorizedClientService
): WebClientGraphQLClient = MonoGraphQLClient.createWithWebClient(WebClient.builder()
    .baseUrl(legacyServerProperties.httpUrl)
    .filter(
        ServerOAuth2AuthorizedClientExchangeFilterFunction(
            AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(
                clientRegistrationRepository,
                clientService
            )
        ).also {
            // We know we use only one provider, so it's easier and less coupled to the actual value to do things like this
            it.setDefaultClientRegistrationId(clientRegistrationRepository.first().registrationId)
        }
    )
    .build()

The app config looking like this:

spring:
  security.oauth2.client:
    registration:
      keycloak:
        client-id: '${SPRING_OAUTH_CLIENT_ID:service-s}'
        client-secret: '${SPRING_OAUTH_CLIENT_SECRET}'
        authorization-grant-type: client_credentials
    provider:
      keycloak:
        issuer-uri: '${SPRING_OAUTH_ISSUER_URI}'

So now: how can I adapt this to work with TOKEN EXCHANGE for a specific audience and a specific requested_subject ?

I tried going down this road while changing the authorization-grant-type to urn:ietf:params:oauth:grant-type:token-exchange:

ServerOAuth2AuthorizedClientExchangeFilterFunction(
    AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(
        clientRegistrationRepository,
        clientService
    ).apply {
        setAuthorizedClientProvider(TokenExchangeReactiveOAuth2AuthorizedClientProvider().apply {
            setAccessTokenResponseClient(WebClientReactiveTokenExchangeTokenResponseClient().apply {
                addParametersConverter {
                    LinkedMultiValueMap<String, String>().apply {
                        add("audience", "legacy-api-A")
                        add("requested_subject", "myspecificuser")
                    }
                }
            })
        })
    }
)

This fails because I have no subject_token at this point in the code: enter image description here

At this point thoughts are as such: Spring just doesn't support Direct Naked Impersonation which I guess there are multiple good reasons why.

So then I'm thinking I could just chain my client_credentials auth, use the provided token as the subject_token and voilĂ ! token_exchange here you come! But then I hit this issue where I have no idea how to chain these two. I tried something like:

@Bean
fun legacyServerGraphQLClient(
    legacyServerProperties: LegacyServerProperties,
    clientRegistrationRepository: InMemoryReactiveClientRegistrationRepository,
    clientService: ReactiveOAuth2AuthorizedClientService
): WebClientGraphQLClient = MonoGraphQLClient.createWithWebClient(WebClient.builder()
    .baseUrl(legacyServerProperties.httpUrl)
    .filter(
        ServerOAuth2AuthorizedClientExchangeFilterFunction(
            AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(
                clientRegistrationRepository,
                clientService
            )
        ).also {
            // We know we use only one provider, so it's easier and less coupled to the actual value to do things like this
            it.setDefaultClientRegistrationId("reg-cc")
        }
    )
    .filter(
        ServerOAuth2AuthorizedClientExchangeFilterFunction(
            AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(
                clientRegistrationRepository,
                clientService
            ).apply {
                setAuthorizedClientProvider(TokenExchangeReactiveOAuth2AuthorizedClientProvider().apply {
                    setAccessTokenResponseClient(WebClientReactiveTokenExchangeTokenResponseClient().apply {
                        addParametersConverter {
                            LinkedMultiValueMap<String, String>().apply {
                                add("audience", "legacy-api-A")
                                add("requested_subject", "myspecificuser")
                            }
                        }
                    })
                })
            }
        ).also {
            // We know we use only one provider, so it's easier and less coupled to the actual value to do things like this
            it.setDefaultClientRegistrationId("reg-te")
        }
    )
    build()

But the second filter doesn't pick up the authorization header from the first one so again: no subject token.

Before going further I'd be happy if anyone could give me pointers cause I' don't really like my next solutions which are:

  1. Rewrite a TokenExchangeReactiveOAuth2AuthorizedClientProvider to allow for Direct Naked Impersonation
  2. Add a custom filter that just does an http request to the token endpoint

I'm pretty sure it is possible to do token exchange as the code is there but I feel like i'm just not using it in the nominal case and as such I can't find how to chain things right.

As a final note please understand that everything I'm trying to do programatically has already been tested through curl requests. So, from a Keycloak point of view everything works. I just need to get the code going.

I'm using Java 21, spring boot 3.3.0 and Keycloak 24.0.5.

Thank you


Solution

  • You are correct that the default support for Token Exchange does not intend Direct Naked Impersonation, which as far as I can tell is largely a keycloak concept (that I wouldn't recommend).

    However, the support is flexible and most everything can be customized. The TokenExchangeReactiveOAuth2AuthorizedClientProvider allows for customizing the subjectTokenResolver, which is a Function<OAuth2AuthorizationContext, Mono<OAuth2Token>> (see docs). Additionally, you can customize the parameters of the token request.

    You can provide your own implementation of OAuth2Token to assist with adding the requested_subject parameter like this:

    @Configuration
    public class TokenExchangeConfig {
    
        @Bean
        public ReactiveOAuth2AuthorizedClientProvider tokenExchange(
                ReactiveClientRegistrationRepository clientRegistrationRepository,
                ReactiveOAuth2AuthorizedClientService authorizedClientService) {
    
            var authorizedClientManager = createAuthorizedClientManager(
                clientRegistrationRepository, authorizedClientService);
    
            var authorizedClientProvider = new TokenExchangeReactiveOAuth2AuthorizedClientProvider();
            var subjectTokenResolver = createSubjectTokenResolver(authorizedClientManager, "keycloak");
            authorizedClientProvider.setSubjectTokenResolver(subjectTokenResolver);
    
            var tokenResponseClient = new WebClientReactiveTokenExchangeTokenResponseClient();
            tokenResponseClient.addParametersConverter(subjectParametersConverter());
            authorizedClientProvider.setAccessTokenResponseClient(tokenResponseClient)
    
            return authorizedClientProvider;
        }
    
        /**
         * Create a standalone {@link ReactiveOAuth2AuthorizedClientManager} for resolving
         * the subject token using {@code client_credentials}.
         */
        private static ReactiveOAuth2AuthorizedClientManager createAuthorizedClientManager(
                ReactiveClientRegistrationRepository clientRegistrationRepository,
                ReactiveOAuth2AuthorizedClientService authorizedClientService) {
    
            var authorizedClientProvider =
                ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
                    .clientCredentials()
                    .build();
    
            var authorizedClientManager =
                new AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager(
                    clientRegistrationRepository, authorizedClientService);
            authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
    
            return authorizedClientManager;
        }
    
        /**
         * Create a {@code Function} to resolve a token from the current principal.
         */
        private static Function<OAuth2AuthorizationContext, Mono<OAuth2Token>> createSubjectTokenResolver(
            ReactiveOAuth2AuthorizedClientManager authorizedClientManager, String clientRegistrationId) {
    
            var anonymousUser = new AnonymousAuthenticationToken("anonymous", "anonymousUser",
                AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
    
            return (context) -> {
                var authorizeRequest =
                    OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId)
                        .principal(anonymousUser)
                        .build();
    
                return authorizedClientManager.authorize(authorizeRequest)
                    .map(OAuth2AuthorizedClient::getAccessToken)
                    .map((accessToken) -> new DirectImpersonationOAuth2Token(context.getPrincipal(), accessToken));
            };
        }
    
        private static Converter<TokenExchangeGrantRequest, MultiValueMap<String, String>> subjectParametersConverter() {
            return (grantRequest) -> {
                var subjectToken = (DirectImpersonationOAuth2Token) grantRequest.getSubjectToken();
                var parameters = new LinkedMultiValueMap<String, String>();
                parameters.set("audience", "legacy-api-A");
                parameters.set("requested_subject", subjectToken.getRequestedSubject().getName());
                return parameters;
            };
        }
    
        private static class DirectImpersonationOAuth2Token extends AbstractOAuth2Token {
    
            private final Authentication requestedSubject;
    
            protected DirectImpersonationOAuth2Token(Authentication requestedSubject, OAuth2Token accessToken) {
                super(accessToken.getTokenValue(), accessToken.getIssuedAt(), accessToken.getExpiresAt());
                this.requestedSubject = requestedSubject;
            }
    
            public Authentication getRequestedSubject() {
                return this.requestedSubject;
            }
        }
    
    }
    

    If you need to use AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager instead of DefaultReactiveOAuth2AuthorizedClientManager you won't use this bean-based config (again see docs), but you should be able to adapt it easily.