Search code examples
spring-cloud-gatewayspring-authorization-server

Migrate Spring Cloud from OAuth 2.0 to OAuth 2.1


I have a old Spring Cloud gateway working with Keyclock server. I don't have Web UI for login because the project is a Rest API. OAuth 2.0 is used with Grant type password.

I want to migrate to OAuth 2.1 but Grant type password is deprecated.

Can you advise in my case what would be the best way to migrate the project in order again to have user name and password to issue a token in order to authenticate users and make API requests?

Looking at this guide https://connect2id.com/learn/oauth-2-1 I think JWT bearer grant type is a good candidate?

What if I create my own grant type similar to password grant type?


Solution

  • REST APIs secured with OAuth2 are resource-servers. Configure your Spring applications as such.

    The flow clients use to get access-token is not relevant for resource-servers. Do not create your own. Clients use:

    • authorization-code to act on behalf of a user (physical person who logs in)
    • client-credentials if it's a program you allow to issue queries not related to a user (batch process or any other trusted service)

    Configuring a resource-server for Keycloak with the spring-boot starter libs linked above can be as simple as:

    <dependency>
        <groupId>com.c4-soft.springaddons</groupId>
        <artifactId>spring-addons-webmvc-jwt-resource-server</artifactId>
        <version>6.0.4</version>
    </dependency>
    
    @EnableMethodSecurity
    public static class WebSecurityConfig { }
    
    com.c4-soft.springaddons.security.issuers[0].location=https://localhost:8443/realms/master
    com.c4-soft.springaddons.security.issuers[0].authorities.claims=realm_access.roles,ressource_access.some-client.roles
    
    com.c4-soft.springaddons.security.cors[0].path=/some-api
    

    Configuring another OIDC authorization-server than Keycloak is just a mater of editing issuer location and authorities claims.

    You might also use spring-boot-starter-oauth2-resource-server directly as described in first tutorial. Spring-addons starters are just thin wrappers around it which save quite some java conf. Here is what you have to write to achieve the same as above:

    @EnableWebSecurity
    @EnableMethodSecurity
    @Configuration
    public class SecurityConfig {
    
        interface Jwt2AuthoritiesConverter extends Converter<Jwt, Collection<? extends GrantedAuthority>> {
        }
    
        @SuppressWarnings("unchecked")
        @Bean
        Jwt2AuthoritiesConverter authoritiesConverter() {
            // This is a converter for roles as embedded in the JWT by a Keycloak server
            // Roles are taken from both realm_access.roles & resource_access.{client}.roles
            return jwt -> {
                final var realmAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("realm_access", Map.of());
                final var realmRoles = (Collection<String>) realmAccess.getOrDefault("roles", List.of());
    
                final var resourceAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("resource_access", Map.of());
                // We assume here you have "spring-addons-confidential" and
                // "spring-addons-public" clients configured with "client roles" mapper in
                // Keycloak
                final var confidentialClientAccess = (Map<String, Object>) resourceAccess
                        .getOrDefault("spring-addons-confidential", Map.of());
                final var confidentialClientRoles = (Collection<String>) confidentialClientAccess.getOrDefault("roles",
                        List.of());
                final var publicClientAccess = (Map<String, Object>) resourceAccess.getOrDefault("spring-addons-public",
                        Map.of());
                final var publicClientRoles = (Collection<String>) publicClientAccess.getOrDefault("roles", List.of());
    
                return Stream
                        .concat(realmRoles.stream(),
                                Stream.concat(confidentialClientRoles.stream(), publicClientRoles.stream()))
                        .map(SimpleGrantedAuthority::new).toList();
            };
        }
    
        interface Jwt2AuthenticationConverter extends Converter<Jwt, AbstractAuthenticationToken> {
        }
    
        @Bean
        Jwt2AuthenticationConverter authenticationConverter(
                Converter<Jwt, Collection<? extends GrantedAuthority>> authoritiesConverter) {
            return jwt -> new JwtAuthenticationToken(jwt, authoritiesConverter.convert(jwt));
        }
    
        @Bean
        SecurityFilterChain filterChain(
                HttpSecurity http,
                Converter<Jwt, AbstractAuthenticationToken> authenticationConverter,
                ServerProperties serverProperties)
                throws Exception {
    
            // Enable OAuth2 with custom authorities mapping
            http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(authenticationConverter);
    
            // Enable anonymous
            http.anonymous();
    
            // Enable and configure CORS
            http.cors().configurationSource(corsConfigurationSource());
    
            // State-less session (state in access-token only)
            http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    
            // Disable CSRF because of state-less session-management
            http.csrf().disable();
    
            // Return 401 (unauthorized) instead of 403 (redirect to login) when
            // authorization is missing or invalid
            http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
                response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Restricted Content\"");
                response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
            });
    
            // If SSL enabled, disable http (https only)
            if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) {
                http.requiresChannel().anyRequest().requiresSecure();
            } else {
                http.requiresChannel().anyRequest().requiresInsecure();
            }
    
            // Route security: authenticated to all routes but actuator and Swagger-UI
            // @formatter:off
            http.authorizeHttpRequests()
                .requestMatchers("/actuator/health/readiness", "/actuator/health/liveness", "/v3/api-docs", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
                .anyRequest().authenticated();
            // @formatter:on
    
            return http.build();
        }
    
        CorsConfigurationSource corsConfigurationSource() {
            // Very permissive CORS config...
            final var configuration = new CorsConfiguration();
            configuration.setAllowedOrigins(Arrays.asList("*"));
            configuration.setAllowedMethods(Arrays.asList("*"));
            configuration.setAllowedHeaders(Arrays.asList("*"));
            configuration.setExposedHeaders(Arrays.asList("*"));
    
            // Limited to API routes (neither actuator nor Swagger-UI)
            final var source = new UrlBasedCorsConfigurationSource();
            source.registerCorsConfiguration("/greet/**", configuration);
    
            return source;
        }
    }