Taiga#458: fixing exception with real JWT from HS Keycloak OIDC (#220)
Co-authored-by: Michael Hoennig <michael@hoennig.de> Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/220 Reviewed-by: Marc Sandlus <hsh-marcsandlus@noreply.dev.hostsharing.net> Co-authored-by: Michael Hoennig <michael.hoennig@hostsharing.net> Co-committed-by: Michael Hoennig <michael.hoennig@hostsharing.net>
This commit is contained in:
committed by
Timotheus Pokorra
parent
28eebbc95a
commit
a1bac0f764
@@ -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.
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ class HsAccountControllerAcceptanceTest extends ContextBasedTestWithCleanup {
|
||||
class GetCurrentUser {
|
||||
|
||||
@Test
|
||||
void shouldFetchCurrentLoginUser() throws Exception {
|
||||
void shouldFetchCurrentLoginUser() {
|
||||
// given
|
||||
context.define("superuser-alex@hostsharing.net");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user