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?
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;
}
}