diff --git a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc index bdc29f38cd162..232a4537fc493 100644 --- a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc @@ -1101,6 +1101,18 @@ quarkus.oidc.tls.trust-store-password=${trust-store-password} #quarkus.oidc.tls.trust-store-alias=certAlias ---- +=== Introspection Endpoint Authentication + +Some OpenID Connect Providers may require authenticating to its introspection endpoint using Basic Authentication with the credentials different to `client_id` and `client_secret` which may have already been configured to support `client_secret_basic` or `client_secret_post` client authentication methods described in the <> section. + +If the tokens have to be introspected and the introspection endpoint specific authentication mechanism is required then you can configure `quarkus-oidc` like this: + +[source,properties] +---- +quarkus.oidc.introspection-credentials.name=introspection-user-name +quarkus.oidc.introspection-credentials.secret=introspection-user-secret +---- + [[integration-testing]] === Testing diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java index c5abaa1879145..8d81feff3077d 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java @@ -373,14 +373,17 @@ public static void verifyConfigurationId(String defaultId, String configKey, Opt public static String initClientSecretBasicAuth(OidcCommonConfig oidcConfig) { if (isClientSecretBasicAuthRequired(oidcConfig.credentials)) { - return OidcConstants.BASIC_SCHEME + " " - + Base64.getEncoder().encodeToString( - (oidcConfig.getClientId().get() + ":" - + clientSecret(oidcConfig.credentials)).getBytes(StandardCharsets.UTF_8)); + return basicSchemeValue(oidcConfig.getClientId().get(), clientSecret(oidcConfig.credentials)); } return null; } + public static String basicSchemeValue(String name, String secret) { + return OidcConstants.BASIC_SCHEME + " " + + Base64.getEncoder().encodeToString((name + ":" + secret).getBytes(StandardCharsets.UTF_8)); + + } + public static Key initClientJwtKey(OidcCommonConfig oidcConfig) { if (isClientJwtAuthRequired(oidcConfig.credentials)) { return clientJwtKey(oidcConfig.credentials); 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 6da1811a24d26..cdeabd872aabe 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 @@ -85,6 +85,49 @@ public class OidcTenantConfig extends OidcCommonConfig { @ConfigItem public Optional publicKey = Optional.empty(); + /** + * Introspection Basic Authentication which must be configured only if the introspection is required + * and OpenId Connect Provider does not support the OIDC client authentication configured with + * {@link OidcCommonConfig#credentials} for its introspection endpoint. + */ + @ConfigItem + public IntrospectionCredentials introspectionCredentials = new IntrospectionCredentials(); + + /** + * Introspection Basic Authentication configuration + */ + @ConfigGroup + public static class IntrospectionCredentials { + /** + * Name + */ + @ConfigItem + public Optional name = Optional.empty(); + + /** + * Secret + */ + @ConfigItem + public Optional secret = Optional.empty(); + + public Optional getName() { + return name; + } + + public void setName(String name) { + this.name = Optional.of(name); + } + + public Optional getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = Optional.of(secret); + } + + } + /** * Configuration to find and parse a custom claim containing the roles information. */ @@ -1203,4 +1246,12 @@ public boolean isCacheUserInfoInIdtoken() { public void setCacheUserInfoInIdtoken(boolean cacheUserInfoInIdtoken) { this.cacheUserInfoInIdtoken = cacheUserInfoInIdtoken; } + + public IntrospectionCredentials getIntrospectionCredentials() { + return introspectionCredentials; + } + + public void setIntrospectionCredentials(IntrospectionCredentials introspectionCredentials) { + this.introspectionCredentials = introspectionCredentials; + } } 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 d75293842154b..6ac9e9baccefc 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 @@ -40,6 +40,7 @@ public class OidcProviderClient implements Closeable { private final OidcConfigurationMetadata metadata; private final OidcTenantConfig oidcConfig; private final String clientSecretBasicAuthScheme; + private final String introspectionBasicAuthScheme; private final Key clientJwtKey; public OidcProviderClient(WebClient client, @@ -50,6 +51,17 @@ public OidcProviderClient(WebClient client, this.oidcConfig = oidcConfig; this.clientSecretBasicAuthScheme = OidcCommonUtils.initClientSecretBasicAuth(oidcConfig); this.clientJwtKey = OidcCommonUtils.initClientJwtKey(oidcConfig); + this.introspectionBasicAuthScheme = initIntrospectionBasicAuthScheme(oidcConfig); + } + + private static String initIntrospectionBasicAuthScheme(OidcTenantConfig oidcConfig) { + if (oidcConfig.getIntrospectionCredentials().name.isPresent() + && oidcConfig.getIntrospectionCredentials().secret.isPresent()) { + return OidcCommonUtils.basicSchemeValue(oidcConfig.getIntrospectionCredentials().name.get(), + oidcConfig.getIntrospectionCredentials().secret.get()); + } else { + return null; + } } public OidcConfigurationMetadata getMetadata() { @@ -72,7 +84,7 @@ public Uni introspectToken(String token) { MultiMap introspectionParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap()); introspectionParams.add(OidcConstants.INTROSPECTION_TOKEN, token); introspectionParams.add(OidcConstants.INTROSPECTION_TOKEN_TYPE_HINT, OidcConstants.ACCESS_TOKEN_VALUE); - return getHttpResponse(metadata.getIntrospectionUri(), introspectionParams) + return getHttpResponse(metadata.getIntrospectionUri(), introspectionParams, true) .transform(resp -> getTokenIntrospection(resp)); } @@ -96,21 +108,28 @@ public Uni getAuthorizationCodeTokens(String code, Stri if (codeVerifier != null) { codeGrantParams.add(OidcConstants.PKCE_CODE_VERIFIER, codeVerifier); } - return getHttpResponse(metadata.getTokenUri(), codeGrantParams).transform(resp -> getAuthorizationCodeTokens(resp)); + return getHttpResponse(metadata.getTokenUri(), codeGrantParams, false) + .transform(resp -> getAuthorizationCodeTokens(resp)); } public Uni refreshAuthorizationCodeTokens(String refreshToken) { MultiMap refreshGrantParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap()); refreshGrantParams.add(OidcConstants.GRANT_TYPE, OidcConstants.REFRESH_TOKEN_GRANT); refreshGrantParams.add(OidcConstants.REFRESH_TOKEN_VALUE, refreshToken); - return getHttpResponse(metadata.getTokenUri(), refreshGrantParams).transform(resp -> getAuthorizationCodeTokens(resp)); + return getHttpResponse(metadata.getTokenUri(), refreshGrantParams, false) + .transform(resp -> getAuthorizationCodeTokens(resp)); } - private UniOnItem> getHttpResponse(String uri, MultiMap formBody) { + private UniOnItem> getHttpResponse(String uri, MultiMap formBody, boolean introspect) { HttpRequest request = client.postAbs(uri); request.putHeader(CONTENT_TYPE_HEADER, APPLICATION_X_WWW_FORM_URLENCODED); request.putHeader(ACCEPT_HEADER, APPLICATION_JSON); - if (clientSecretBasicAuthScheme != null) { + if (introspect && introspectionBasicAuthScheme != null) { + request.putHeader(AUTHORIZATION_HEADER, introspectionBasicAuthScheme); + if (oidcConfig.clientId.isPresent()) { + formBody.set(OidcConstants.CLIENT_ID, oidcConfig.clientId.get()); + } + } else if (clientSecretBasicAuthScheme != null) { request.putHeader(AUTHORIZATION_HEADER, clientSecretBasicAuthScheme); } else if (clientJwtKey != null) { String jwt = OidcCommonUtils.signJwtWithKey(oidcConfig, metadata.getTokenUri(), clientJwtKey); diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java index bf6a609002211..0e06621e8e40d 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java @@ -87,17 +87,21 @@ public OidcTenantConfig get() { config.setAuthServerUrl(authServerUri); config.setDiscoveryEnabled(false); config.authentication.setUserInfoRequired(true); + config.setIntrospectionPath("introspect"); + config.setUserInfoPath("userinfo"); if ("tenant-oidc-introspection-only".equals(tenantId)) { + config.setClientId("client-introspection-only"); config.setAllowTokenIntrospectionCache(false); config.setAllowUserInfoCache(false); + Credentials creds = config.getCredentials(); + creds.clientSecret.setMethod(Credentials.Secret.Method.POST_JWT); + creds.getJwt().setKeyFile("ecPrivateKey.pem"); + creds.getJwt().setSignatureAlgorithm(SignatureAlgorithm.ES256.getAlgorithm()); + } else { + config.setClientId("client-introspection-only-cache"); + config.getIntrospectionCredentials().setName("bob"); + config.getIntrospectionCredentials().setSecret("bob_secret"); } - config.setIntrospectionPath("introspect"); - config.setUserInfoPath("userinfo"); - config.setClientId("client-introspection-only"); - Credentials creds = config.getCredentials(); - creds.clientSecret.setMethod(Credentials.Secret.Method.POST_JWT); - creds.getJwt().setKeyFile("ecPrivateKey.pem"); - creds.getJwt().setSignatureAlgorithm(SignatureAlgorithm.ES256.getAlgorithm()); return config; } else if ("tenant-oidc-no-opaque-token".equals(tenantId)) { OidcTenantConfig config = new OidcTenantConfig(); diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java index 39e67b68cc9a7..36d3e48e65e57 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java @@ -1,10 +1,12 @@ package io.quarkus.it.keycloak; import java.security.PublicKey; +import java.util.Base64; import javax.annotation.PostConstruct; import javax.ws.rs.FormParam; import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.Produces; @@ -106,19 +108,28 @@ public int resetIntrospectionEndpointCallCount() { @POST @Produces("application/json") @Path("introspect") - public String introspect(@FormParam("client_secret") String secret) throws Exception { + public String introspect(@FormParam("client_id") String clientId, @FormParam("client_secret") String clientSecret, + @HeaderParam("Authorization") String authorization) throws Exception { introspectionEndpointCallCount++; - String clientId = "undefined"; - if (secret != null) { + String introspectionClientId = "none"; + String introspectionClientSecret = "none"; + if (clientSecret != null) { // Secret is expected to be a JWT PublicKey verificationKey = KeyUtils.readPublicKey("ecPublicKey.pem", SignatureAlgorithm.ES256); JWTParser parser = new DefaultJWTParser(); // "client-introspection-only" is a client id, set as an issuer by default JWTAuthContextInfo contextInfo = new JWTAuthContextInfo(verificationKey, "client-introspection-only"); contextInfo.setSignatureAlgorithm(SignatureAlgorithm.ES256); - JsonWebToken jwt = parser.parse(secret, contextInfo); + JsonWebToken jwt = parser.parse(clientSecret, contextInfo); clientId = jwt.getIssuer(); + } else if (authorization != null) { + String plainChallenge = new String(Base64.getDecoder().decode(authorization.substring("Basic ".length()))); + int colonPos; + if ((colonPos = plainChallenge.indexOf(":")) > -1) { + introspectionClientId = plainChallenge.substring(0, colonPos); + introspectionClientSecret = plainChallenge.substring(colonPos + 1); + } } return "{" + @@ -126,6 +137,8 @@ public String introspect(@FormParam("client_secret") String secret) throws Excep " \"scope\": \"user\"," + " \"email\": \"user@gmail.com\"," + " \"username\": \"alice\"," + + " \"introspection_client_id\": \"" + introspectionClientId + "\"," + + " \"introspection_client_secret\": \"" + introspectionClientSecret + "\"," + " \"client_id\": \"" + clientId + "\"" + " }"; } diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java index df85470b0da50..1b9866f6aebca 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java @@ -66,6 +66,8 @@ public String userNameService(@PathParam("tenant") String tenant) { if (tenant.startsWith("tenant-oidc-introspection-only")) { TokenIntrospection introspection = securityIdentity.getAttribute("introspection"); response += (",client_id:" + introspection.getString("client_id")); + response += (",introspection_client_id:" + introspection.getString("introspection_client_id")); + response += (",introspection_client_secret:" + introspection.getString("introspection_client_secret")); response += (",active:" + introspection.getBoolean("active")); response += (",cache-size:" + tokenCache.getCacheSize()); } diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index 560717aaafb79..a724be11c1db1 100644 --- a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -396,7 +396,8 @@ public void testJwtTokenIntrospectionOnlyAndUserInfo() { .then() .statusCode(200) .body(equalTo( - "tenant-oidc-introspection-only:alice,client_id:client-introspection-only,active:true,cache-size:0")); + "tenant-oidc-introspection-only:alice,client_id:client-introspection-only," + + "introspection_client_id:none,introspection_client_secret:none,active:true,cache-size:0")); } RestAssured.when().get("/oidc/jwk-endpoint-call-count").then().body(equalTo("0")); @@ -441,7 +442,8 @@ private void verifyTokenIntrospectionAndUserInfoAreCached(String token1, int exp .then() .statusCode(200) .body(equalTo( - "tenant-oidc-introspection-only-cache:alice,client_id:client-introspection-only,active:true,cache-size:" + "tenant-oidc-introspection-only-cache:alice,client_id:client-introspection-only-cache," + + "introspection_client_id:bob,introspection_client_secret:bob_secret,active:true,cache-size:" + expectedCacheSize)); } RestAssured.when().get("/oidc/introspection-endpoint-call-count").then().body(equalTo("1"));