diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index 77cbdaa69fa9f..4c8cdfc3beb36 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -194,7 +194,7 @@ public static class CertificateChain { * A parameter to specify the password of the truststore file if it is configured with {@link #trustStoreFile}. */ @ConfigItem - public Optional trustStorePassword; + public Optional trustStorePassword = Optional.empty(); /** * A parameter to specify the alias of the truststore certificate. diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DynamicVerificationKeyResolver.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DynamicVerificationKeyResolver.java index 3844bd400cbfd..dbb2adeb2af49 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DynamicVerificationKeyResolver.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DynamicVerificationKeyResolver.java @@ -1,8 +1,10 @@ package io.quarkus.oidc.runtime; import java.security.Key; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import jakarta.enterprise.event.Observes; @@ -24,6 +26,9 @@ public class DynamicVerificationKeyResolver { private static final Logger LOG = Logger.getLogger(DynamicVerificationKeyResolver.class); + private static final Set KEY_HEADERS = Set.of(HeaderParameterNames.KEY_ID, + HeaderParameterNames.X509_CERTIFICATE_SHA256_THUMBPRINT, + HeaderParameterNames.X509_CERTIFICATE_THUMBPRINT); private final OidcProviderClient client; private final MemoryCache cache; @@ -46,6 +51,12 @@ public Uni resolve(TokenCredential tokenCred) { if (key != null) { return Uni.createFrom().item(new SingleKeyVerificationKeyResolver(key)); } + if (chainResolverFallback != null && headers.containsKey(HeaderParameterNames.X509_CERTIFICATE_CHAIN) + && Collections.disjoint(KEY_HEADERS, headers.fieldNames())) { + // If none of the key headers is available which can be used to resolve JWK then do + // not try to get another JWK set but delegate to the chain resolver fallback if it is available + return getChainResolver(); + } return client.getJsonWebKeySet(new OidcRequestContextProperties( Map.of(OidcRequestContextProperties.TOKEN, tokenCred.getToken(), @@ -105,9 +116,7 @@ public Uni apply(JsonWebKeySet jwks) { } if (newKey == null && chainResolverFallback != null) { - LOG.debug("JWK is not available, neither 'kid' nor 'x5t#S256' nor 'x5t' token headers are set," - + " falling back to the certificate chain resolver"); - return Uni.createFrom().item(chainResolverFallback); + return getChainResolver(); } if (newKey == null) { @@ -121,6 +130,12 @@ public Uni apply(JsonWebKeySet jwks) { }); } + private Uni getChainResolver() { + LOG.debug("JWK is not available, neither 'kid' nor 'x5t#S256' nor 'x5t' token headers are set," + + " falling back to the certificate chain resolver"); + return Uni.createFrom().item(chainResolverFallback); + } + private static Key getKeyWithId(JsonWebKeySet jwks, String kid) { if (kid != null) { return jwks.getKeyWithId(kid); 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 5ecd2c072296a..6c93dccc5510e 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 @@ -18,6 +18,7 @@ import io.quarkus.oidc.AccessTokenCredential; import io.quarkus.oidc.IdTokenCredential; +import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.OidcTenantConfig.Roles.Source; import io.quarkus.oidc.TokenIntrospection; @@ -98,14 +99,16 @@ protected Map getRequestData(TokenAuthenticationRequest request) private Uni authenticate(TokenAuthenticationRequest request, Map requestData, TenantConfigContext resolvedContext) { - if (resolvedContext.oidcConfig.publicKey.isPresent()) { - LOG.debug("Performing token verification with a configured public key"); - return validateTokenWithoutOidcServer(request, resolvedContext); + if (resolvedContext.oidcConfig.authServerUrl.isPresent()) { + return validateAllTokensWithOidcServer(requestData, request, resolvedContext); } else if (resolvedContext.oidcConfig.getCertificateChain().trustStoreFile.isPresent()) { LOG.debug("Performing token verification with a public key inlined in the certificate chain"); return validateTokenWithoutOidcServer(request, resolvedContext); + } else if (resolvedContext.oidcConfig.publicKey.isPresent()) { + LOG.debug("Performing token verification with a configured public key"); + return validateTokenWithoutOidcServer(request, resolvedContext); } else { - return validateAllTokensWithOidcServer(requestData, request, resolvedContext); + return Uni.createFrom().failure(new OIDCException("Unexpected authentication request")); } } 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 77f389b5c4bae..ecd207f38ded8 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 @@ -87,8 +87,15 @@ public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, Json this.client = client; this.oidcConfig = oidcConfig; this.tokenCustomizer = tokenCustomizer; - this.asymmetricKeyResolver = jwks == null ? null - : new JsonWebKeyResolver(jwks, oidcConfig.token.forcedJwkRefreshInterval, oidcConfig.certificateChain); + if (jwks != null) { + this.asymmetricKeyResolver = new JsonWebKeyResolver(jwks, oidcConfig.token.forcedJwkRefreshInterval, + oidcConfig.certificateChain); + } else if (oidcConfig != null && oidcConfig.certificateChain.trustStoreFile.isPresent()) { + this.asymmetricKeyResolver = new CertChainPublicKeyResolver(oidcConfig.certificateChain); + } else { + this.asymmetricKeyResolver = null; + } + if (client != null && oidcConfig != null && !oidcConfig.jwks.resolveEarly) { this.keyResolverProvider = new DynamicVerificationKeyResolver(client, oidcConfig); } else { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/AzureAccessTokenCustomizer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/AzureAccessTokenCustomizer.java index 5d764fe560fff..edad83046e7be 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/AzureAccessTokenCustomizer.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/providers/AzureAccessTokenCustomizer.java @@ -10,22 +10,22 @@ import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.TokenCustomizer; +import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.oidc.runtime.OidcUtils; @Named("azure-access-token-customizer") @ApplicationScoped public class AzureAccessTokenCustomizer implements TokenCustomizer { - private static final String NONCE = "nonce"; @Override public JsonObject customizeHeaders(JsonObject headers) { try { - String nonce = headers.getString(NONCE); + String nonce = headers.containsKey(OidcConstants.NONCE) ? headers.getString(OidcConstants.NONCE) : null; if (nonce != null) { byte[] nonceSha256 = OidcUtils.getSha256Digest(nonce.getBytes(StandardCharsets.UTF_8)); byte[] newNonceBytes = Base64.getUrlEncoder().withoutPadding().encode(nonceSha256); return Json.createObjectBuilder(headers) - .add(NONCE, new String(newNonceBytes, StandardCharsets.UTF_8)).build(); + .add(OidcConstants.NONCE, new String(newNonceBytes, StandardCharsets.UTF_8)).build(); } return null; } catch (Exception ex) { diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java index a499a3d7c5f62..3a6e8c4eb2825 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/AdminResource.java @@ -42,7 +42,7 @@ public String adminRequiredAlgorithm() { @Authenticated @Produces(MediaType.APPLICATION_JSON) public String adminAzure() { - return "Issuer:" + ((JsonWebToken) identity.getPrincipal()).getIssuer(); + return "Name:" + identity.getPrincipal().getName() + ",Issuer:" + ((JsonWebToken) identity.getPrincipal()).getIssuer(); } @Path("bearer-no-introspection") diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowTokenIntrospectionResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowTokenIntrospectionResource.java index 86825d36ddaac..3044536588a9e 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowTokenIntrospectionResource.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowTokenIntrospectionResource.java @@ -4,6 +4,8 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; +import org.eclipse.microprofile.jwt.JsonWebToken; + import io.quarkus.oidc.TokenIntrospection; import io.quarkus.security.Authenticated; import io.quarkus.security.identity.SecurityIdentity; @@ -20,6 +22,10 @@ public class CodeFlowTokenIntrospectionResource { @GET public String access() { - return identity.getPrincipal().getName() + ":" + tokenIntrospection.getUsername(); + if (identity.getPrincipal() instanceof JsonWebToken) { + return identity.getPrincipal().getName(); + } else { + return identity.getPrincipal().getName() + ":" + tokenIntrospection.getUsername(); + } } } diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java index ddc772b6febea..47ecbf71538da 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CodeFlowUserInfoResource.java @@ -16,12 +16,15 @@ @Authenticated public class CodeFlowUserInfoResource { + // UserInfo stubs are available for code flow JWT and binary access tokens and bearer JWT tokens @Inject UserInfo userInfo; + // Custom test augmentor changes Principal to use UserInfo which has a preferred `alice` name @Inject SecurityIdentity identity; + // current access token @Inject JsonWebToken accessToken; diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index 7afccf0162bd4..f78e4dc9dd018 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -88,6 +88,7 @@ quarkus.oidc.bearer-user-info-github-service.client-id=quarkus-web-app quarkus.oidc.bearer-user-info-github-service.credentials.secret=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.provider=github +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.application-type=hybrid 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 @@ -101,6 +102,9 @@ quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.authentication.verify- quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.client-id=quarkus-web-app quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.credentials.client-secret.value=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.credentials.client-secret.method=query +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.certificate-chain.trust-store-file=truststore.p12 +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.certificate-chain.trust-store-password=storepassword +quarkus.oidc.code-flow-user-info-github-cached-in-idtoken.certificate-chain.trust-store-cert-alias=fullchain quarkus.oidc.code-flow-token-introspection.provider=github @@ -150,6 +154,9 @@ quarkus.oidc.bearer-azure.jwks-path=${keycloak.url}/azure/jwk quarkus.oidc.bearer-azure.jwks.resolve-early=false quarkus.oidc.bearer-azure.token.lifespan-grace=2147483647 quarkus.oidc.bearer-azure.token.customizer-name=azure-access-token-customizer +quarkus.oidc.bearer-azure.certificate-chain.trust-store-file=truststore.p12 +quarkus.oidc.bearer-azure.certificate-chain.trust-store-password=storepassword +quarkus.oidc.bearer-azure.certificate-chain.trust-store-cert-alias=fullchain quarkus.oidc.bearer-role-claim-path.auth-server-url=${keycloak.url}/realms/quarkus/ quarkus.oidc.bearer-role-claim-path.client-id=quarkus-app diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index 96b0b5786f7e8..ac7ddb516d975 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -65,11 +65,20 @@ public void testAccessResourceAzure() throws Exception { wireMockServer.stubFor(WireMock.get("/auth/azure/jwk") .withHeader("Authorization", matching("Access token: " + azureToken)) .willReturn(WireMock.aResponse().withBody(azureJwk))); - RestAssured.given().auth().oauth2(azureToken) + // RestAssured.given().auth().oauth2(azureToken) + // .when().get("/api/admin/bearer-azure") + // .then() + // .statusCode(200) + // .body(Matchers.equalTo( + // "Name:jondoe@quarkusoidctest.onmicrosoft.com,Issuer:https://sts.windows.net/e7861267-92c5-4a03-bdb2-2d3e491e7831/")); + + String accessTokenWithCert = TestUtils.createTokenWithInlinedCertChain("alice-certificate"); + + RestAssured.given().auth().oauth2(accessTokenWithCert) .when().get("/api/admin/bearer-azure") .then() .statusCode(200) - .body(Matchers.equalTo("Issuer:https://sts.windows.net/e7861267-92c5-4a03-bdb2-2d3e491e7831/")); + .body(Matchers.equalTo("Name:alice-certificate,Issuer:https://server.example.com")); } private String readFile(String filePath) throws Exception { @@ -292,7 +301,7 @@ public void testAccessAdminResourceWithKidOrChain() throws Exception { List.of(subjectCert, intermediateCert, rootCert), subjectPrivateKey); - assertX5cOnlyIsPresent(token); + TestUtils.assertX5cOnlyIsPresent(token); RestAssured.given().auth().oauth2(token) .when().get("/api/admin/bearer-kid-or-chain") @@ -311,7 +320,7 @@ public void testAccessAdminResourceWithKidOrChain() throws Exception { List.of(intermediateCert, subjectCert, rootCert), subjectPrivateKey); - assertX5cOnlyIsPresent(token); + TestUtils.assertX5cOnlyIsPresent(token); RestAssured.given().auth().oauth2(token) .when().get("/api/admin/bearer-kid-or-chain") @@ -364,14 +373,6 @@ private void assertKidOnlyIsPresent(String token, String kid) { assertFalse(headers.containsKey("x5t#S256")); } - private void assertX5cOnlyIsPresent(String token) { - JsonObject headers = OidcUtils.decodeJwtHeaders(token); - assertTrue(headers.containsKey("x5c")); - assertFalse(headers.containsKey("kid")); - assertFalse(headers.containsKey("x5t")); - assertFalse(headers.containsKey("x5t#S256")); - } - @Test public void testAccessAdminResourceWithCustomRolePathForbidden() { RestAssured.given().auth().oauth2(getAccessTokenWithCustomRolePath("admin", Set.of("admin"))) 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 56e7bae3d7d58..dc0a8e857c6db 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 @@ -8,6 +8,7 @@ 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.urlPathMatching; +import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -23,6 +24,7 @@ import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -271,6 +273,16 @@ public void testCodeFlowUserInfoCachedInIdToken() throws Exception { webClient.getCookieManager().clearCookies(); } + + // Now send a bearer access token with the inline chain + String bearerAccessToken = TestUtils.createTokenWithInlinedCertChain("alice-certificate"); + + RestAssured.given().auth().oauth2(bearerAccessToken) + .when().get("/code-flow-user-info-github-cached-in-idtoken") + .then() + .statusCode(200) + .body(Matchers.equalTo("alice:alice:alice-certificate, cache size: 0")); + clearCache(); } @@ -296,6 +308,7 @@ public void testCodeFlowTokenIntrospection() throws Exception { webClient.getCookieManager().clearCookies(); } + clearCache(); } diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/TestUtils.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/TestUtils.java new file mode 100644 index 0000000000000..a7439ceacd048 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/TestUtils.java @@ -0,0 +1,51 @@ +package io.quarkus.it.keycloak; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.List; + +import io.quarkus.oidc.runtime.OidcUtils; +import io.smallrye.jwt.build.Jwt; +import io.smallrye.jwt.util.KeyUtils; +import io.smallrye.jwt.util.ResourceUtils; +import io.vertx.core.json.JsonObject; + +public class TestUtils { + + public static String createTokenWithInlinedCertChain(String preferredUserName) throws Exception { + X509Certificate rootCert = KeyUtils.getCertificate(ResourceUtils.readResource("/ca.cert.pem")); + X509Certificate intermediateCert = KeyUtils.getCertificate(ResourceUtils.readResource("/intermediate.cert.pem")); + X509Certificate subjectCert = KeyUtils.getCertificate(ResourceUtils.readResource("/www.quarkustest.com.cert.pem")); + PrivateKey subjectPrivateKey = KeyUtils.readPrivateKey("/www.quarkustest.com.key.pem"); + + String bearerAccessToken = getAccessTokenWithCertChain( + List.of(subjectCert, intermediateCert, rootCert), + subjectPrivateKey, + preferredUserName); + + assertX5cOnlyIsPresent(bearerAccessToken); + return bearerAccessToken; + } + + public static String getAccessTokenWithCertChain(List chain, + PrivateKey privateKey, String preferredUserName) throws Exception { + return Jwt.preferredUserName(preferredUserName) + .groups("admin") + .issuer("https://server.example.com") + .audience("https://service.example.com") + .jws().chain(chain) + .sign(privateKey); + } + + public static void assertX5cOnlyIsPresent(String token) { + JsonObject headers = OidcUtils.decodeJwtHeaders(token); + assertTrue(headers.containsKey("x5c")); + assertFalse(headers.containsKey("kid")); + assertFalse(headers.containsKey("x5t")); + assertFalse(headers.containsKey("x5t#S256")); + } + +}