Search code examples
spring-bootspring-securityoauth-2.0keycloakspring-oauth2

How to bypass keycloak login page and provide client suggested idp with spring security


I have configured spring security with keycloak using the Oauth2 client feature in the BFF, keycloak will play the role of an idp broker and it is configured successfully with Microsoft (we will be add support later for google, facebook, etc...) every thing work as expected!

we have a requirement in our app that the users should not land on the keycloak login page, instead we will add custom "login with Micorosoft" (or with his/her preferred login provider) button in our ui app and user will be redirected to the Microsoft login page to connect.

My OAuth2 setup (and it is working):

  • I am using BFF pattern with java/spring using spring security to handle authN/authZ.
  • BFF is configured as confidential client in keycloak
  • Authentication is configured to use cookies and server side sessions
  • keycloak is used as idp broker by integrating and configuring external idps.
  • UI app that runs on the browser and talks to BFF (served as static files with nginx)

The question is: how to bypass keycloak login page and redirect user directly to his choice of provider that we already configured and support? How we can do that from srping security so we can tell keycloak to redirect to a specific idp login page?

What I have found so far:

  • In keycloak we can provide extrat query param "kc_idp_hint" in the authorization_endpoint I tested it by adding the param manually and it works but I don't know how to do that from spring security to handle the user requested idp.

Any help please ?


Solution

  • How to bypass Keycloak login page and provide client-suggested IDP?

    You already found the answer to this question: provide a kc_idp_hint parameter with the authorization request.

    How to add a non-standard or optional parameter to the authorization request built by a Spring OAuth2 client (like kc_idp_hint for Keycloak, audience for Auth0, etc.)?

    This is done with a custom (Server)OAuth2AuthorizationRequestResolver.

    I'll expose solutions relying on an OAuth2 client registration per login option, with a sample for:

    • the "normal" Keycloak login screen as you have now (this is the registraion with default-registration-id as ID)
    • a direct access to the Google login screen (google-registration-id with the assumption that you named google the IDP in Keycloak)
    • a direct access to the Facebook login screen (facebook-registration-id with the assumption that you named facebook the IDP in Keycloak)

    This implies configuration properties looking something like that:

    oauth2-issuer: change-me
    oauth2-client-id: change-me
    oauth2-client-secret: change-me
    scope: "openid profile email offline_access"
    
    spring:
      security:
        oauth2:
          client:
            provider:
              keycloak:
                issuer-uri: ${oauth2-issuer}
                user-name-attribute: preferred_username
            registration:
              # registration for direct access to Google login page
              google-registration-id:
                provider: keycloak
                client-id: ${oauth2-client-id}
                client-secret: ${oauth2-client-secret}
                authorization-grant-type: authorization_code
                scope: $scope
              # registration for direct access to Facebook login page
              facebook-registration-id:
                provider: keycloak
                client-id: ${oauth2-client-id}
                client-secret: ${oauth2-client-secret}
                authorization-grant-type: authorization_code
                scope: $scope
              # registration for accessing Keycloak login page
              default-registration-id:
                provider: keycloak
                client-id: ${oauth2-client-id}
                client-secret: ${oauth2-client-secret}
                authorization-grant-type: authorization_code
                scope: $scope
    

    Depending on which OAuth2 client registration the frontend activates (by redirecting the user to the BFF /oauth2/authorization/{registration-id} endpoint), the user will see a different login screen.

    As I guess that your Spring BFF configured as a confidential OAuth2 client is a reactive Spring Cloud Gateway instance, I'll focus on the reactive authorization-request resolver: ServerOAuth2AuthorizationRequestResolver.

    Two options depending you are using just spring-boot-starter-oauth2-client, or spring-addons-starter-oidc (a starter of mine) in addition to it.

    1. With spring-addons-starter-oidc

    Let's start simple. All you need in that case is adding the kc_idp_hint for each registration ID in application properties:

    com:
      c4-soft:
        springaddons:
          oidc:
            client:
              authorization-params:
                google-registration-id:
                  kc_idp_hint: google
                facebook-registration-id:
                  kc_idp_hint: facebook
    

    2. With just spring-boot-starter-oauth2-client

    In that case, you'll have to write a custom authorization-request resolver yourself and then configure the security filter-chain with it.

    Here is a possible impl:

    @Component
    public class KcIdpHintServerOAuth2AuthorizationRequestResolver implements ServerOAuth2AuthorizationRequestResolver {
    
        private final DefaultServerOAuth2AuthorizationRequestResolver defaultDelegate;
        private final Map<String, ServerOAuth2AuthorizationRequestResolver> authorizationRequestResolversByRegistrationId;
    
        public KcIdpHintServerOAuth2AuthorizationRequestResolver(ReactiveClientRegistrationRepository clientRegistrationRepository) {
            this.defaultDelegate = new DefaultServerOAuth2AuthorizationRequestResolver(clientRegistrationRepository);
    
            // Rather than a static conf like that, you might parse a map from custom configuration properties
            this.authorizationRequestResolversByRegistrationId = Map.of(
                    "google-registration-id", authorizationRequestResolverWithHintFor(clientRegistrationRepository, "google"),
                    "facebook-registration-id", authorizationRequestResolverWithHintFor(clientRegistrationRepository, "facebook"));
        }
    
        @Override
        public Mono<OAuth2AuthorizationRequest> resolve(ServerWebExchange exchange) {
            return defaultDelegate.resolve(exchange);
        }
    
        @Override
        public Mono<OAuth2AuthorizationRequest> resolve(ServerWebExchange exchange, String clientRegistrationId) {
            final var delegate = authorizationRequestResolversByRegistrationId.getOrDefault(clientRegistrationId, defaultDelegate);
            return delegate.resolve(exchange, clientRegistrationId);
        }
    
        private
                ServerOAuth2AuthorizationRequestResolver
                authorizationRequestResolverWithHintFor(ReactiveClientRegistrationRepository clientRegistrationRepository, String hint) {
            final var authorizationRequestResolver = new DefaultServerOAuth2AuthorizationRequestResolver(clientRegistrationRepository);
            authorizationRequestResolver.setAuthorizationRequestCustomizer(
                    oauth2AuthorizationRequestBuilder -> oauth2AuthorizationRequestBuilder.additionalParameters(Map.of("kc_idp_hint", hint)));
            return authorizationRequestResolver;
        }
    
    }
    
    @Bean
    SecurityWebFilterChain clientSecurityFilterChain(
            ServerHttpSecurity http,
            ServerOAuth2AuthorizationRequestResolver authorizationRequestResolver) {
        http.oauth2Login(oauth2 -> {
            oauth2.authorizationRequestResolver(authorizationRequestResolver);
        });
        ...
        return http.build();
    }