From a3a6b41881219ce23fe9e576736429d0c5cf8ee7 Mon Sep 17 00:00:00 2001 From: Kai Helbig Date: Fri, 7 Jun 2024 16:53:46 +0200 Subject: [PATCH] upgrade to Spring Boot 3 since Keycloak does no longer support the Spring Boot Starter we may just as well upgrade and do the integration manually Signed-off-by: Kai Helbig --- example-backend/build.gradle | 6 +- .../ExampleBackendApplication.java | 12 +-- .../config/KeycloakConfiguration.java | 18 +++++ .../config/WebSecurityConfig.java | 74 +++++++++++++------ .../examplebackend/rest/HelloController.java | 6 +- .../src/main/resources/application.yaml | 16 ++-- .../AuthenticationJUnit4Test.java | 16 +++- .../AuthenticationJUnit5Test.java | 18 ++++- .../impl/handler/LogoutRoute.java | 1 + 9 files changed, 119 insertions(+), 48 deletions(-) create mode 100644 example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/config/KeycloakConfiguration.java diff --git a/example-backend/build.gradle b/example-backend/build.gradle index 17f013b..6d4c02d 100644 --- a/example-backend/build.gradle +++ b/example-backend/build.gradle @@ -1,7 +1,6 @@ buildscript { ext { - // spring boot 3 is not (yet) supported by the Keycloak spring boot starter - springBootVersion = '2.7.16' + springBootVersion = '3.3.0' } repositories { mavenCentral() @@ -29,8 +28,9 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-web:$springBootVersion" implementation "org.springframework.boot:spring-boot-starter-security:$springBootVersion" + implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server:$springBootVersion" implementation "org.springframework.boot:spring-boot-configuration-processor:$springBootVersion" - implementation "org.keycloak:keycloak-spring-boot-starter:$keycloak_version" + implementation "org.keycloak:keycloak-policy-enforcer:$keycloak_version" testImplementation project(':mock-junit') testImplementation project(':mock-junit5') testImplementation "org.springframework.boot:spring-boot-starter-test:$springBootVersion" diff --git a/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/ExampleBackendApplication.java b/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/ExampleBackendApplication.java index 454e137..e10a093 100644 --- a/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/ExampleBackendApplication.java +++ b/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/ExampleBackendApplication.java @@ -1,10 +1,10 @@ package com.tngtech.keycloakmock.examplebackend; -import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.Bean; -import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.core.GrantedAuthorityDefaults; import org.springframework.web.servlet.config.annotation.CorsRegistry; @@ -12,18 +12,14 @@ @SpringBootApplication @EnableWebSecurity -@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) +@EnableMethodSecurity(securedEnabled = true) +@ConfigurationPropertiesScan("com.tngtech.keycloakmock.examplebackend.config") public class ExampleBackendApplication { public static void main(String[] args) { SpringApplication.run(ExampleBackendApplication.class, args); } - @Bean - public KeycloakSpringBootConfigResolver keycloakConfigResolver() { - return new KeycloakSpringBootConfigResolver(); - } - @Bean public GrantedAuthorityDefaults grantedAuthorityDefaults() { // Remove the default ROLE_ prefix that spring boot otherwise expects diff --git a/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/config/KeycloakConfiguration.java b/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/config/KeycloakConfiguration.java new file mode 100644 index 0000000..3741729 --- /dev/null +++ b/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/config/KeycloakConfiguration.java @@ -0,0 +1,18 @@ +package com.tngtech.keycloakmock.examplebackend.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; + +@ConfigurationProperties(prefix = "keycloak") +public class KeycloakConfiguration { + private final String clientId; + + @ConstructorBinding + public KeycloakConfiguration(String clientId) { + this.clientId = clientId; + } + + public String getClientId() { + return clientId; + } +} diff --git a/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/config/WebSecurityConfig.java b/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/config/WebSecurityConfig.java index 1d6ae9c..d22b2d3 100644 --- a/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/config/WebSecurityConfig.java +++ b/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/config/WebSecurityConfig.java @@ -1,36 +1,62 @@ package com.tngtech.keycloakmock.examplebackend.config; -import org.keycloak.adapters.springsecurity.KeycloakConfiguration; -import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter; -import org.springframework.beans.factory.annotation.Autowired; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.web.authentication.session.NullAuthenticatedSessionStrategy; -import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.web.SecurityFilterChain; -@KeycloakConfiguration -public class WebSecurityConfig extends KeycloakWebSecurityConfigurerAdapter { +@Configuration +public class WebSecurityConfig { + private final KeycloakConfiguration keycloakConfiguration; - @Override - protected void configure(final HttpSecurity http) throws Exception { - super.configure(http); - http.authorizeRequests() - .antMatchers(HttpMethod.OPTIONS, "/**") - .permitAll() - .antMatchers("/api/**") - .authenticated() - .antMatchers("/**") - .permitAll(); + public WebSecurityConfig(KeycloakConfiguration keycloakConfiguration) { + this.keycloakConfiguration = keycloakConfiguration; } - @Override - protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { - return new NullAuthenticatedSessionStrategy(); + @Bean + public SecurityFilterChain configure(final HttpSecurity http) throws Exception { + http.authorizeHttpRequests( + authorizeRequests -> + authorizeRequests + .requestMatchers(HttpMethod.OPTIONS, "/**") + .permitAll() + .requestMatchers("/api/**") + .authenticated() + .requestMatchers("/**") + .permitAll()) + .oauth2ResourceServer( + server -> server.jwt(jwt -> jwt.jwtAuthenticationConverter(this::convert))); + return http.build(); } - @Autowired - public void configureGlobal(final AuthenticationManagerBuilder auth) { - auth.authenticationProvider(keycloakAuthenticationProvider()); + // as Keycloak no longer supports their Spring Boot Starter module, we need to extract roles + // ourselves here ... + private AbstractAuthenticationToken convert(Jwt jwt) { + Set authorities = + Stream.concat( + jwt + .>>getClaim("realm_access") + .getOrDefault("roles", Collections.emptyList()) + .stream(), + jwt + .>>>getClaim("resource_access") + .getOrDefault(keycloakConfiguration.getClientId(), Collections.emptyMap()) + .getOrDefault("roles", Collections.emptyList()) + .stream()) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toSet()); + return new JwtAuthenticationToken(jwt, authorities); } } diff --git a/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/rest/HelloController.java b/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/rest/HelloController.java index 4c1d8b4..25c5dd4 100644 --- a/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/rest/HelloController.java +++ b/example-backend/src/main/java/com/tngtech/keycloakmock/examplebackend/rest/HelloController.java @@ -1,7 +1,7 @@ package com.tngtech.keycloakmock.examplebackend.rest; -import java.security.Principal; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -10,7 +10,7 @@ @RequestMapping("/api") public class HelloController { @GetMapping("/hello") - public String hello(@AuthenticationPrincipal Principal user) { - return "Hello " + user.getName(); + public String hello(@AuthenticationPrincipal Jwt user) { + return "Hello " + user.getSubject(); } } diff --git a/example-backend/src/main/resources/application.yaml b/example-backend/src/main/resources/application.yaml index a3f8d21..2b7f9b5 100644 --- a/example-backend/src/main/resources/application.yaml +++ b/example-backend/src/main/resources/application.yaml @@ -2,10 +2,12 @@ server: port: 8080 keycloak: - realm: realm - bearer-only: true - auth-server-url: http://localhost:8000/auth - resource: client - principal-attribute: preferred_username - # default has changed to 'external', and within e2e tests the docker IP is not recognized as internal - ssl-required: none + client-id: server + +spring: + security: + oauth2: + resourceserver: + jwt: + audiences: [ "${keycloak.client-id}"] + issuer-uri: "http://localhost:8000/auth/realms/realm" diff --git a/example-backend/src/test/java/com/tngtech/keycloakmock/examplebackend/AuthenticationJUnit4Test.java b/example-backend/src/test/java/com/tngtech/keycloakmock/examplebackend/AuthenticationJUnit4Test.java index 26671b9..895d5b9 100644 --- a/example-backend/src/test/java/com/tngtech/keycloakmock/examplebackend/AuthenticationJUnit4Test.java +++ b/example-backend/src/test/java/com/tngtech/keycloakmock/examplebackend/AuthenticationJUnit4Test.java @@ -11,7 +11,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; @RunWith(SpringJUnit4ClassRunner.class) @@ -76,4 +76,18 @@ public void authentication_with_role_works() { .and() .body(equalTo("you may feel very special here")); } + + @Test + public void authentication_with_resource_role_works() { + RestAssured.given() + .auth() + .preemptive() + .oauth2(mock.getAccessToken(aTokenConfig().withResourceRole("server", "vip").build())) + .when() + .get("/api/vip") + .then() + .statusCode(200) + .and() + .body(equalTo("you may feel very special here")); + } } diff --git a/example-backend/src/test/java/com/tngtech/keycloakmock/examplebackend/AuthenticationJUnit5Test.java b/example-backend/src/test/java/com/tngtech/keycloakmock/examplebackend/AuthenticationJUnit5Test.java index 53611c9..02f9110 100644 --- a/example-backend/src/test/java/com/tngtech/keycloakmock/examplebackend/AuthenticationJUnit5Test.java +++ b/example-backend/src/test/java/com/tngtech/keycloakmock/examplebackend/AuthenticationJUnit5Test.java @@ -10,7 +10,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.boot.test.web.server.LocalServerPort; @SpringBootTest( classes = ExampleBackendApplication.class, @@ -61,7 +61,7 @@ void authentication_without_role_fails() { } @Test - void authentication_with_role_works() { + void authentication_with_realm_role_works() { RestAssured.given() .auth() .preemptive() @@ -73,4 +73,18 @@ void authentication_with_role_works() { .and() .body(equalTo("you may feel very special here")); } + + @Test + void authentication_with_resource_role_works() { + RestAssured.given() + .auth() + .preemptive() + .oauth2(mock.getAccessToken(aTokenConfig().withResourceRole("server", "vip").build())) + .when() + .get("/api/vip") + .then() + .statusCode(200) + .and() + .body(equalTo("you may feel very special here")); + } } diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/LogoutRoute.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/LogoutRoute.java index 474e45e..6fbe99d 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/LogoutRoute.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/handler/LogoutRoute.java @@ -21,6 +21,7 @@ public class LogoutRoute implements Handler { private static final String LEGACY_REDIRECT_URI = "redirect_uri"; + /** * Before Keycloak 18, the logout * endpoint had used the {@value #LEGACY_REDIRECT_URI} query parameter.