From a1bac0f76421e37a184efa3996e8dbf048fabe86 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Fri, 24 Apr 2026 06:41:02 +0200 Subject: [PATCH] Taiga#458: fixing exception with real JWT from HS Keycloak OIDC (#220) Co-authored-by: Michael Hoennig Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/220 Reviewed-by: Marc Sandlus Co-authored-by: Michael Hoennig Co-committed-by: Michael Hoennig --- README.md | 2 + bin/jwt-curl | 4 +- ...ion-with-real-JWT-from-HS-Keycloak-OIDC.md | 167 ++++++++++++++++++ .../config/BaseWebSecurityConfig.java | 28 ++- .../hsadminng/rbac/context/Context.java | 13 +- .../HsAccountControllerAcceptanceTest.java | 2 +- 6 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 doc/PR/2026-03-29-PR#220-Fix-exception-with-real-JWT-from-HS-Keycloak-OIDC.md diff --git a/README.md b/README.md index e9ffe2b7..cee4e8a2 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,8 @@ If you want a formatted JSON output, you can pipe the result to `jq` or similar. And to see the full, currently implemented, API, open http://localhost:8080/swagger-ui/index.html. +### HOWTO: Run the application with a real (OAuth2) JWT-authentication, e.g. Keycloak OIDC + If you want to run the application with real (OAuth2) JWT-authentication: # set the JWT-issuer URI, e.g. diff --git a/bin/jwt-curl b/bin/jwt-curl index 0489e231..af491b7d 100755 --- a/bin/jwt-curl +++ b/bin/jwt-curl @@ -100,12 +100,12 @@ function jwtLogin() { fi # OAuth2 Resource Owner Password Credentials Grant (public client) - trace "+ curl --fail-with-body --show-error -X POST \ + trace "+ curl --no-progress-meter --fail-with-body --show-error -X POST \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d \"grant_type=password&client_id=$HSADMINNG_JWT_CLIENT_ID&client_secret=$HSADMINNG_JWT_CLIENT_SECRET&username=$HSADMINNG_JWT_USERNAME&password=$HSADMINNG_JWT_PASSWORD_DISPLAY\" \ $HSADMINNG_JWT_TOKEN_URL -o ~/.jwt-token.response" - JWT_RESPONSE=$(curl --fail-with-body --show-error -X POST \ + JWT_RESPONSE=$(curl --no-progress-meter --fail-with-body --show-error -X POST \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d "grant_type=password&client_id=$HSADMINNG_JWT_CLIENT_ID&client_secret=$HSADMINNG_JWT_CLIENT_SECRET&username=$HSADMINNG_JWT_USERNAME&password=$HSADMINNG_JWT_PASSWORD_DISPLAY" \ $HSADMINNG_JWT_TOKEN_URL 2>&1 | tee ~/.jwt-token.response) diff --git a/doc/PR/2026-03-29-PR#220-Fix-exception-with-real-JWT-from-HS-Keycloak-OIDC.md b/doc/PR/2026-03-29-PR#220-Fix-exception-with-real-JWT-from-HS-Keycloak-OIDC.md new file mode 100644 index 00000000..9590e715 --- /dev/null +++ b/doc/PR/2026-03-29-PR#220-Fix-exception-with-real-JWT-from-HS-Keycloak-OIDC.md @@ -0,0 +1,167 @@ +# PR#220: Fix exception with real JWT from HS Keycloak OIDC + +## The Problems + +### Hsadmin-NG Throws an Exception at Startup + +A locally running hsadmin-NG app does not work with the real JWT from an HS Keycloak OIDC: + +```sh +source .unset-environment + +export HSADMINNG_POSTGRES_JDBC_URL=jdbc:postgresql://localhost:5432/postgres +export HSADMINNG_POSTGRES_ADMIN_USERNAME=postgres +export HSADMINNG_POSTGRES_ADMIN_PASSWORD=password +export HSADMINNG_POSTGRES_RESTRICTED_USERNAME=restricted +export HSADMINNG_SUPERUSER=superuser-alex@hostsharing.net +export HSADMINNG_MIGRATION_DATA_PATH=migration +export HSADMINNG_OFFICE_DATA_SQL_FILE= + +export HSADMINNG_ACCOUNT_PASSWORD_HASH_ALGORITHM='{SSHA}' +export HSADMINNG_JWT_ISSUER=https://login.dev.hsadmin.de/realms/testui +export SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI=https://login.dev.hsadmin.de/realms/testui + +export LANG=en_US.UTF-8 + +export ALLOWED_ORIGINS=http://127.0.0.1:8082 + +gw bootRun --args='--spring.profiles.active=dev,complete,test-data' +``` + +This fails with the following error: + +``` +Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled. +2026-03-27T15:12:48.630+01:00 ERROR 11995 --- [ restartedMain] o.s.boot.SpringApplication  : Application run failed + +org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration': Unsatisfied dependency expressed through method 'setFilterChains' parameter 0: Error creating bean with name 'securityFilterChain' defined in class path resource [net/hostsharing/hsadminng/config/WebSecurityConfig.class]: Failed to instantiate [org.springframework.security.web.SecurityFilterChain]: Factory method 'securityFilterChain' threw exception with message: Error creating bean with name 'jwtDecoder' defined in class path resource [net/hostsharing/hsadminng/config/WebSecurityConfig.class]: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoder' threw exception with message: jwkSetUri cannot be empty + at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredMethodElement.resolveMethodArguments(AutowiredAnnotationBeanPostProcessor.java:896) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredMethodElement.inject(AutowiredAnnotationBeanPostProcessor.java:849) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:146) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:509) ~[spring-beans-6.2.10.jar:6.2.10] +``` + +### jwt-curl login does not work + +``` +jwt-curl login +Username: superuser-alex@hostsharing.net +Password: password +ERROR: could not get JWT access token: curl: (22) The requested URL returned error: 401 +{"error":"invalid_client","error_description":"Invalid client or Invalid client credentials"} +``` + +## The Cause + +### Cause of the App-Start Problem + +The environment sets `HSADMINNG_JWT_ISSUER`and `SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI`, which is redundant. +But it does neither set `HSADMINNG_JWT_JWKS_URL` nor `SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWKS_URI`. + +See also the Spring config in the `main/.../application.yml`: + +``` + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${HSADMINNG_JWT_ISSUER:} + jwk-set-uri: ${HSADMINNG_JWT_JWKS_URL:} +``` + +When `issuer-uri` is in the Spring config at all and `HSADMINNG_JWT_JWKS_URL` is unset, it becomes an empty string. +And an empty value is invalid for `NimbusJwtDecoder.withJwkSetUri(...)`, which is used by `BaseWebSecurityConfig`, +as well as for the default-bean if we make it conditional. + + +### Cause of the jwt-curl Login Problem + +jwt-curl needs the envionment variables `HSADMINNG_JWT_TOKEN_URL` and `HSADMINNG_JWT_CLIENT_ID` to be set properly set. + +## The Solution + +### Improving the README.md to Run the App with a Real JWT + +I added a "HOWTO" title above the part in the `README.md` so it can be found easier by running: + +```sh +. .aliases +howto real keycloak +# or e.g. +howot real jwt +``` + +### Fixing the Environment Problem to Run the App with a Real JWT + +Remove the redundant environment variable: + +``` +export SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI=https://login.dev.hsadmin.de/realms/testui +``` + +Set the issuer (required): + +``` +export HSADMINNG_JWT_ISSUER=https://login.dev.hsadmin.de/realms/testui +``` + +Set the JWKS URL only if you want to override autodetection (optional): + +``` +export HSADMINNG_JWT_JWKS_URL=https://login.dev.hsadmin.de/realms/testui/protocol/openid-connect/certs +``` + +Otherwise, make sure it's unset, e.g. via `unset HSADMINNG_JWT_JWKS_URL`. + +Now the app starts. Let's also test auth: + +```sh +CLIENT_ID=... # to be replaced +USERNAME=... # to be replaced +PASSWORD=... # to be replaced +BEARER="$(curl -X POST https://login.dev.hsadmin.de/realms/testui/protocol/openid-connect/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=$CLIENT_ID" \ + -d "scope=openid \ + -d "grant_type=password" \ + -d "username=$USERNAME" \ + -d "password=$PASSWORD" \ + | jq -r '.refresh_token')" +``` + +Now try to fetch the accessible accounts: + +```sh +curl --no-progress-meter -X GET 'http://127.0.0.1:8080/api/hs/accounts/current' \ + -H "Origin: https://testui.hsngdev.hs-example.de" \ + -H 'Accept: application/json' \ + -H "Authorization: Bearer $BEARER" \ + | jq +``` + +### Autodetection of jwk-set-uri + +The environment variable `HSADMINNG_JWT_JWKS_URL` is now optional. + +`BaseWebSecurityConfig` now creates the decoder explicitly: + +- if `spring.security.oauth2.resourceserver.jwt.jwk-set-uri` has text, it uses `NimbusJwtDecoder.withJwkSetUri(...)` +- if it is empty but `spring.security.oauth2.resourceserver.jwt.issuer-uri` has text, it uses `JwtDecoders.fromIssuerLocation(...)` +- if both are empty, startup fails fast with a clear `IllegalStateException` + +This prevents Spring Boot auto-configuration from trying to use an empty `jwk-set-uri`. + + +### Fixing the jwt-curl Login Problem + +Properly set the environment variables required by `jwt-curl`: + +``` +export HSADMINNG_JWT_CLIENT_ID=FIXME +export HSADMINNG_JWT_CLIENT_SECRET=FIXME +export HSADMINNG_JWT_USERNAME=superuser-alex@hostsharing.net +export HSADMINNG_JWT_PASSWORD=password +export HSADMINNG_JWT_TOKEN_URL=FIXME +``` + +FIXME: Find out the proper values. diff --git a/src/main/java/net/hostsharing/hsadminng/config/BaseWebSecurityConfig.java b/src/main/java/net/hostsharing/hsadminng/config/BaseWebSecurityConfig.java index 4471fd7b..98814650 100644 --- a/src/main/java/net/hostsharing/hsadminng/config/BaseWebSecurityConfig.java +++ b/src/main/java/net/hostsharing/hsadminng/config/BaseWebSecurityConfig.java @@ -14,12 +14,18 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtDecoders; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.util.StringUtils; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import jakarta.servlet.http.HttpServletResponse; +import javax.crypto.spec.SecretKeySpec; + +import java.nio.charset.StandardCharsets; import static net.hostsharing.hsadminng.config.JwtFakeBearer.RSA_KEY; @@ -63,13 +69,25 @@ public abstract class BaseWebSecurityConfig { .build(); } - @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:http://localhost:${server.port}/fake-jwt/.well-known/jwks.json}") - private String jwkSetUri; - @Bean @Profile("!fake-jwt") - public JwtDecoder jwtDecoder() { - return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); + public JwtDecoder jwtDecoder( + // FIXME: Maybe move all defaults from the application.yml to here? + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") final String issuerUri, + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}") final String jwkSetUri, + @Value("${spring.security.oauth2.resourceserver.jwt.hmac-secret:${HSADMINNG_JWT_HMAC_SECRET:}}") final String hmacSecret) { + if (StringUtils.hasText(hmacSecret)) { + return NimbusJwtDecoder.withSecretKey(new SecretKeySpec(hmacSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA512")) + .macAlgorithm(MacAlgorithm.HS512) + .build(); + } + if (StringUtils.hasText(jwkSetUri)) { + return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); + } + if (StringUtils.hasText(issuerUri)) { + return JwtDecoders.fromIssuerLocation(issuerUri); + } + throw new IllegalStateException("Either spring.security.oauth2.resourceserver.jwt.hmac-secret (HSADMINNG_JWT_HMAC_SECRET), spring.security.oauth2.resourceserver.jwt.jwk-set-uri (HSADMINNG_JWT_JWKS_URL) or ...issuer-uri (HSADMINNG_JWT_ISSUER) must be configured."); } @Bean diff --git a/src/main/java/net/hostsharing/hsadminng/rbac/context/Context.java b/src/main/java/net/hostsharing/hsadminng/rbac/context/Context.java index 8ee7dc0f..aefde735 100644 --- a/src/main/java/net/hostsharing/hsadminng/rbac/context/Context.java +++ b/src/main/java/net/hostsharing/hsadminng/rbac/context/Context.java @@ -6,6 +6,7 @@ import lombok.val; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.context.request.RequestContextHolder; @@ -47,7 +48,17 @@ public class Context { @Transactional(propagation = MANDATORY) public void define() { - define(SecurityContextHolder.getContext().getAuthentication().getName(), null); + val auth = SecurityContextHolder.getContext().getAuthentication(); + // FIXME: this code works for simplified JWT in tests as well as the real Keycloak, but there should be only one way + // if "preferred_username" is set, use it, otherwise use "sub" + val username = Optional.of(auth) + .filter(JwtAuthenticationToken.class::isInstance) + .map(JwtAuthenticationToken.class::cast) + .map(JwtAuthenticationToken::getToken) + .map(token -> token.getClaimAsString("preferred_username")) + .filter(claim -> !claim.isBlank()) // force to getName ("sub") if blank + .orElseGet(auth::getName); + define(username, null); } @Transactional(propagation = MANDATORY) diff --git a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountControllerAcceptanceTest.java index a1e17ce6..dfaf2672 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/accounts/HsAccountControllerAcceptanceTest.java @@ -72,7 +72,7 @@ class HsAccountControllerAcceptanceTest extends ContextBasedTestWithCleanup { class GetCurrentUser { @Test - void shouldFetchCurrentLoginUser() throws Exception { + void shouldFetchCurrentLoginUser() { // given context.define("superuser-alex@hostsharing.net");