From c358c254da081d4049004ae12067270980e33f40 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Tue, 13 Aug 2024 16:28:55 +0100 Subject: [PATCH] Accept signed OIDC UserInfo --- .../oidc/runtime/OidcIdentityProvider.java | 2 +- .../io/quarkus/oidc/runtime/OidcProvider.java | 37 ++++++++++- .../oidc/runtime/OidcProviderClient.java | 11 ++-- .../src/main/resources/application.properties | 4 +- .../keycloak/CodeFlowAuthorizationTest.java | 61 +++++++++++++++++++ 5 files changed, 107 insertions(+), 8 deletions(-) diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java index d895031606e4e..83aa12b01af3d 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java @@ -477,7 +477,7 @@ private Uni verifyTokenUni(Map requestD resolvedContext.oidcConfig.token.isSubjectRequired(), nonce)); } catch (Throwable t) { if (t.getCause() instanceof UnresolvableKeyException) { - LOG.debug("No matching JWK key is found, refreshing and repeating the verification"); + LOG.debug("No matching JWK key is found, refreshing and repeating the token verification"); return refreshJwksAndVerifyTokenUni(resolvedContext, token, enforceAudienceVerification, resolvedContext.oidcConfig.token.isSubjectRequired(), nonce); } else { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java index c49c8a6d87372..1b42daf190431 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java @@ -41,6 +41,7 @@ import io.quarkus.oidc.UserInfo; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; +import io.quarkus.oidc.runtime.OidcProviderClient.UserInfoResponse; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.credential.TokenCredential; import io.smallrye.jwt.algorithm.SignatureAlgorithm; @@ -65,6 +66,7 @@ public class OidcProvider implements Closeable { AlgorithmConstraints.ConstraintType.PERMIT, ASYMMETRIC_SUPPORTED_ALGORITHMS); private static final AlgorithmConstraints SYMMETRIC_ALGORITHM_CONSTRAINTS = new AlgorithmConstraints( AlgorithmConstraints.ConstraintType.PERMIT, SignatureAlgorithm.HS256.getAlgorithm()); + private static final String APPLICATION_JWT_CONTENT_TYPE = "application/jwt"; static final String ANY_ISSUER = "any"; private final List customValidators; @@ -407,7 +409,40 @@ private static final long now() { } public Uni getUserInfo(String accessToken) { - return client.getUserInfo(accessToken); + return client.getUserInfo(accessToken).onItem() + .transformToUni(new Function>() { + + @Override + public Uni apply(UserInfoResponse response) { + if (APPLICATION_JWT_CONTENT_TYPE.equals(response.contentType())) { + if (oidcConfig.jwks.resolveEarly) { + try { + LOG.debugf("Verifying the signed UserInfo with the local JWK keys: %s", response.data()); + return Uni.createFrom().item( + new UserInfo( + verifyJwtToken(response.data(), true, false, null).localVerificationResult + .encode())); + } catch (Throwable t) { + if (t.getCause() instanceof UnresolvableKeyException) { + LOG.debug( + "No matching JWK key is found, refreshing and repeating the signed UserInfo verification"); + return refreshJwksAndVerifyJwtToken(response.data(), true, false, null) + .onItem().transform(v -> new UserInfo(v.localVerificationResult.encode())); + } else { + LOG.debugf("Signed UserInfo verification has failed: %s", t.getMessage()); + return Uni.createFrom().failure(t); + } + } + } else { + return getKeyResolverAndVerifyJwtToken(new TokenCredential(response.data(), "userinfo"), true, + false, null, true) + .onItem().transform(v -> new UserInfo(v.localVerificationResult.encode())); + } + } else { + return Uni.createFrom().item(new UserInfo(response.data())); + } + } + }); } public Uni getCodeFlowTokens(String code, String redirectUri, String codeVerifier) { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java index 0a01e4b8adf9b..bcd9db16df761 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java @@ -15,7 +15,6 @@ import io.quarkus.oidc.OidcConfigurationMetadata; import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.TokenIntrospection; -import io.quarkus.oidc.UserInfo; import io.quarkus.oidc.common.OidcEndpoint; import io.quarkus.oidc.common.OidcRequestContextProperties; import io.quarkus.oidc.common.OidcRequestFilter; @@ -36,7 +35,6 @@ public class OidcProviderClient implements Closeable { private static final Logger LOG = Logger.getLogger(OidcProviderClient.class); - private static final String TENANT_ID_ATTRIBUTE = "oidc-tenant-id"; private static final String AUTHORIZATION_HEADER = String.valueOf(HttpHeaders.AUTHORIZATION); private static final String CONTENT_TYPE_HEADER = String.valueOf(HttpHeaders.CONTENT_TYPE); private static final String ACCEPT_HEADER = String.valueOf(HttpHeaders.ACCEPT); @@ -93,7 +91,7 @@ public Uni getJsonWebKeySet(OidcRequestContextProperties contextP .transform(resp -> getJsonWebKeySet(resp)); } - public Uni getUserInfo(String token) { + public Uni getUserInfo(String token) { LOG.debugf("Get UserInfo on: %s auth: %s", metadata.getUserInfoUri(), OidcConstants.BEARER_SCHEME + " " + token); return OidcCommonUtils .sendRequest(vertx, @@ -221,8 +219,8 @@ private AuthorizationCodeTokens getAuthorizationCodeTokens(HttpResponse return new AuthorizationCodeTokens(idToken, accessToken, refreshToken, tokenExpiresIn); } - private UserInfo getUserInfo(HttpResponse resp) { - return new UserInfo(getString(metadata.getUserInfoUri(), resp)); + private UserInfoResponse getUserInfo(HttpResponse resp) { + return new UserInfoResponse(resp.getHeader(CONTENT_TYPE_HEADER), getString(metadata.getUserInfoUri(), resp)); } private TokenIntrospection getTokenIntrospection(HttpResponse resp) { @@ -290,4 +288,7 @@ public Vertx getVertx() { public WebClient getWebClient() { return client; } + + static record UserInfoResponse(String contentType, String data) { + }; } diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index f1ec17c5f1949..5495b0fff618d 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -123,7 +123,7 @@ quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.application-type=hybri quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.auth-server-url=${keycloak.url}/realms/quarkus/ quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.authorization-path=/ quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.token-path=access_token_refreshed -quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.user-info-path=protocol/openid-connect/userinfo +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.user-info-path=protocol/openid-connect/signeduserinfo quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.jwks-path=${keycloak.url}/realms/quarkus/protocol/openid-connect/certs quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.code-grant.extra-params.extra-param=extra-param-value quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.code-grant.headers.X-Custom=XCustomHeaderValue @@ -233,6 +233,8 @@ quarkus.log.category."io.quarkus.oidc.runtime.OidcProvider".min-level=TRACE quarkus.log.category."io.quarkus.oidc.runtime.OidcProvider".level=TRACE quarkus.log.category."io.quarkus.oidc.runtime.OidcProviderClient".min-level=TRACE quarkus.log.category."io.quarkus.oidc.runtime.OidcProviderClient".level=TRACE +quarkus.log.file.enable=true +quarkus.log.file.format=%C - %s%n quarkus.http.auth.permission.logout.paths=/code-flow/logout quarkus.http.auth.permission.logout.policy=authenticated diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java index a224ca40d4f7f..66a3931fc3dc7 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java @@ -7,23 +7,33 @@ import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.matching; import static com.github.tomakehurst.wiremock.client.WireMock.notContaining; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static org.awaitility.Awaitility.given; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStreamReader; import java.net.URI; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.security.PrivateKey; import java.util.Date; import java.util.Set; import java.util.StringTokenizer; +import java.util.concurrent.TimeUnit; import javax.crypto.SecretKey; +import org.awaitility.core.ThrowingRunnable; import org.hamcrest.Matchers; import org.htmlunit.SilentCssErrorHandler; import org.htmlunit.TextPage; @@ -33,6 +43,7 @@ import org.htmlunit.html.HtmlForm; import org.htmlunit.html.HtmlPage; import org.htmlunit.util.Cookie; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -49,6 +60,7 @@ import io.restassured.http.ContentType; import io.smallrye.jwt.algorithm.KeyEncryptionAlgorithm; import io.smallrye.jwt.algorithm.SignatureAlgorithm; +import io.smallrye.jwt.build.Jwt; import io.smallrye.jwt.util.KeyUtils; import io.vertx.core.json.JsonObject; @@ -361,6 +373,43 @@ public void testCodeFlowUserInfoCachedInIdToken() throws Exception { .body(Matchers.equalTo("alice:alice:alice-certificate, cache size: 0, TenantConfigResolver: false")); clearCache(); + checkSignedUserInfoRecordInLog(); + } + + private void checkSignedUserInfoRecordInLog() { + final Path logDirectory = Paths.get(".", "target"); + given().await().pollInterval(100, TimeUnit.MILLISECONDS) + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(new ThrowingRunnable() { + @Override + public void run() throws Throwable { + Path accessLogFilePath = logDirectory.resolve("quarkus.log"); + boolean fileExists = Files.exists(accessLogFilePath); + if (!fileExists) { + accessLogFilePath = logDirectory.resolve("target/quarkus.log"); + fileExists = Files.exists(accessLogFilePath); + } + Assertions.assertTrue(Files.exists(accessLogFilePath), + "quarkus log file " + accessLogFilePath + " is missing"); + + boolean signedUserInfoLogDetected = false; + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(new ByteArrayInputStream(Files.readAllBytes(accessLogFilePath)), + StandardCharsets.UTF_8))) { + String line = null; + while ((line = reader.readLine()) != null) { + if (line.contains("Verifying the signed UserInfo with the local JWK keys: ey")) { + signedUserInfoLogDetected = true; + break; + } + } + } + assertTrue(signedUserInfoLogDetected, + "Log file must contain a record confirming that signed UserInfo is returned"); + + } + }); } @Test @@ -564,6 +613,18 @@ private void defineCodeFlowUserInfoCachedInIdTokenStub() { + "\"expires_in\": 305" + "}"))); + wireMockServer.stubFor( + get(urlEqualTo("/auth/realms/quarkus/protocol/openid-connect/signeduserinfo")) + .withHeader("Authorization", containing("Bearer ey")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/jwt") + .withBody( + Jwt.preferredUserName("alice") + .issuer("https://server.example.com") + .audience("quarkus-web-app") + .jws() + .keyId("1").sign("privateKey.jwk")))); + } private void defineCodeFlowTokenIntrospectionStub() {