From 849bbb2535ce6703bc359bba1d8d60a3f0f172e9 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Fri, 6 Dec 2024 19:46:00 +0000 Subject: [PATCH] Support OidcProviderClient injection and token revocation --- ...ecurity-oidc-code-flow-authentication.adoc | 88 ++++++++++++++ .../oidc/common/runtime/OidcConstants.java | 1 + .../oidc/deployment/OidcBuildStep.java | 4 +- .../oidc/OidcConfigurationMetadata.java | 10 ++ .../io/quarkus/oidc/OidcProviderClient.java | 44 +++++++ .../DynamicVerificationKeyResolver.java | 4 +- .../oidc/runtime/LazyTenantConfigContext.java | 2 +- .../OidcConfigurationAndProviderProducer.java | 55 +++++++++ .../OidcConfigurationMetadataProducer.java | 31 ----- .../oidc/runtime/OidcIdentityProvider.java | 1 + .../io/quarkus/oidc/runtime/OidcProvider.java | 68 ++--------- ...lient.java => OidcProviderClientImpl.java} | 114 +++++++++++++++--- .../io/quarkus/oidc/runtime/OidcRecorder.java | 19 +-- .../io/quarkus/oidc/runtime/OidcUtils.java | 23 ++++ .../oidc/runtime/TenantConfigContext.java | 2 +- .../oidc/runtime/TenantConfigContextImpl.java | 2 +- .../oidc/runtime/OidcProviderTest.java | 11 -- .../quarkus/oidc/runtime/OidcUtilsTest.java | 10 ++ .../it/keycloak/ProtectedResource.java | 12 +- .../io/quarkus/it/keycloak/CodeFlowTest.java | 4 +- .../oidc-wiremock-logout/pom.xml | 5 + .../it/keycloak/SecurityEventListener.java | 24 ++++ .../src/main/resources/application.properties | 3 +- .../keycloak/CodeFlowAuthorizationTest.java | 55 ++++++++- .../src/main/resources/application.properties | 4 +- 25 files changed, 455 insertions(+), 141 deletions(-) create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcProviderClient.java create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfigurationAndProviderProducer.java delete mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfigurationMetadataProducer.java rename extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/{OidcProviderClient.java => OidcProviderClientImpl.java} (78%) create mode 100644 integration-tests/oidc-wiremock-logout/src/main/java/io/quarkus/it/keycloak/SecurityEventListener.java diff --git a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc index 7c656133d0329..55a8e8d437a92 100644 --- a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc @@ -1586,6 +1586,94 @@ public class SecurityEventListener { TIP: You can listen to other security events as described in the xref:security-customization.adoc#observe-security-events[Observe security events] section of the Security Tips and Tricks guide. + +[[oidc-token-revocation]] +=== Token revocation + +Sometimes, you may want to revoke the current authorization code flow access and/or refresh tokens. +For example, if the user has logged out, your logout event listener can revoke access refresh tokens associated with the current session. + +To support such cases, you can use `quarkus.oidc.OidcProviderClient` which provides access to the OIDC provider's UserInfo, token introspection and revocation endpoints. + +For example: + +[source, java] +---- +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.ObservesAsync; + +import io.quarkus.oidc.AccessTokenCredential; +import io.quarkus.oidc.OidcProviderClient; +import io.quarkus.oidc.RefreshToken; +import io.quarkus.oidc.SecurityEvent; + +@ApplicationScoped +public class SecurityEventListener { + + public void event(@ObservesAsync SecurityEvent event) { + if (SecurityEvent.Type.OIDC_BACKCHANNEL_LOGOUT_COMPLETED == event.getEventType()) { <1> + OidcProviderClient oidcProvider = event.getSecurityIdentity().getAttribute(OidcProviderClient.class.getName()); <2> + String accessToken = event.getSecurityIdentity().getCredential(AccessTokenCredential.class).getToken(); <3> + oidcProvider.revokeAccessToken(accessToken).await().indefinitely(); <3> + String refreshToken = event.getSecurityIdentity().getCredential(RefreshToken.class).getToken(); <4> + oidcProvider.revokeRefreshToken(refreshToken).await().indefinitely(); <4> + } + } + +} +---- +<1> Observe OIDC back channel logout completion events +<2> Get `OidcProviderClient` as a security identity attribute. +<3> Get the current authorization code flow access token and revoke it. +<4> Get the current authorization code flow refresh token and revoke it. + +Alternatively, when you do a local logout with <>, you can inject `OidcProviderClient`, `AccessTokenCredential` and `RefreshToken`. +For example: + +[source,java] +---- +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.quarkus.oidc.AccessTokenCredential; +import io.quarkus.oidc.OidcProviderClient; +import io.quarkus.oidc.OidcSession; +import io.quarkus.oidc.RefreshToken; + +@Path("/service") +public class ServiceResource { + + @Inject + OidcSession oidcSession; + + @Inject + OidcProviderClient oidcProviderClient; + + @Inject + AccessTokenCredential accessToken; + + @Inject + RefreshToken refreshToken; + + @GET + @Path("logout") + public String logout() { + oidcSession.logout().await().indefinitely(); <1> + + oidcProvider.revokeAccessToken(accessToken.getToken()).await().indefinitely(); <2> + oidcProvider.revokeRefreshToken(refreshToken.getToken()).await().indefinitely(); <3> + + return "You are logged out"; + } +} +---- +<1> Do the local logout by clearing the session cookie. +<2> Revoke the authorization code flow access token. +<3> Revoke the authorization code flow refresh token. + === Propagating tokens to downstream services For information about Authorization Code Flow access token propagation to downstream services, see the xref:security-openid-connect-client-reference.adoc#token-propagation-rest[Token Propagation] section. diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java index 5fb2e9f7f40c9..73b6dde2e7225 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java @@ -34,6 +34,7 @@ public final class OidcConstants { public static final String INTROSPECTION_TOKEN_ISS = "iss"; public static final String REVOCATION_TOKEN = "token"; + public static final String REVOCATION_TOKEN_TYPE_HINT = "token_type_hint"; public static final String PASSWORD_GRANT_USERNAME = "username"; public static final String PASSWORD_GRANT_PASSWORD = "password"; diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java index 04515540e12d3..92cb146319724 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java @@ -74,7 +74,7 @@ import io.quarkus.oidc.runtime.Jose4jRecorder; import io.quarkus.oidc.runtime.OidcAuthenticationMechanism; import io.quarkus.oidc.runtime.OidcConfig; -import io.quarkus.oidc.runtime.OidcConfigurationMetadataProducer; +import io.quarkus.oidc.runtime.OidcConfigurationAndProviderProducer; import io.quarkus.oidc.runtime.OidcIdentityProvider; import io.quarkus.oidc.runtime.OidcJsonWebTokenProducer; import io.quarkus.oidc.runtime.OidcRecorder; @@ -174,7 +174,7 @@ public void additionalBeans(BuildProducer additionalBea builder.addBeanClass(OidcAuthenticationMechanism.class) .addBeanClass(OidcJsonWebTokenProducer.class) .addBeanClass(OidcTokenCredentialProducer.class) - .addBeanClass(OidcConfigurationMetadataProducer.class) + .addBeanClass(OidcConfigurationAndProviderProducer.class) .addBeanClass(OidcIdentityProvider.class) .addBeanClass(DefaultTenantConfigResolver.class) .addBeanClass(DefaultTokenStateManager.class) diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfigurationMetadata.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfigurationMetadata.java index bcdde90ad1f74..450b72da2cfa4 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfigurationMetadata.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcConfigurationMetadata.java @@ -16,6 +16,7 @@ public class OidcConfigurationMetadata { public static final String USERINFO_ENDPOINT = "userinfo_endpoint"; public static final String END_SESSION_ENDPOINT = "end_session_endpoint"; private static final String REGISTRATION_ENDPOINT = "registration_endpoint"; + private static final String REVOCATION_ENDPOINT = "revocation_endpoint"; public static final String SCOPES_SUPPORTED = "scopes_supported"; private final String discoveryUri; @@ -26,6 +27,7 @@ public class OidcConfigurationMetadata { private final String userInfoUri; private final String endSessionUri; private final String registrationUri; + private final String revocationUri; private final String issuer; private final JsonObject json; @@ -36,6 +38,7 @@ public OidcConfigurationMetadata(String tokenUri, String userInfoUri, String endSessionUri, String registrationUri, + String revocationUri, String issuer) { this.discoveryUri = null; this.tokenUri = tokenUri; @@ -45,6 +48,7 @@ public OidcConfigurationMetadata(String tokenUri, this.userInfoUri = userInfoUri; this.endSessionUri = endSessionUri; this.registrationUri = registrationUri; + this.revocationUri = revocationUri; this.issuer = issuer; this.json = null; } @@ -70,6 +74,8 @@ public OidcConfigurationMetadata(JsonObject wellKnownConfig, OidcConfigurationMe localMetadataConfig == null ? null : localMetadataConfig.endSessionUri); this.registrationUri = getMetadataValue(wellKnownConfig, REGISTRATION_ENDPOINT, localMetadataConfig == null ? null : localMetadataConfig.registrationUri); + this.revocationUri = getMetadataValue(wellKnownConfig, REVOCATION_ENDPOINT, + localMetadataConfig == null ? null : localMetadataConfig.revocationUri); this.issuer = getMetadataValue(wellKnownConfig, ISSUER, localMetadataConfig == null ? null : localMetadataConfig.issuer); this.json = wellKnownConfig; @@ -87,6 +93,10 @@ public String getTokenUri() { return tokenUri; } + public String getRevocationUri() { + return revocationUri; + } + public String getIntrospectionUri() { return introspectionUri; } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcProviderClient.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcProviderClient.java new file mode 100644 index 0000000000000..207f3c41d985d --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcProviderClient.java @@ -0,0 +1,44 @@ +package io.quarkus.oidc; + +import io.smallrye.mutiny.Uni; + +/** + * Provides access to OIDC UserInfo, token introspection and revocation endpoints. + */ +public interface OidcProviderClient { + + /** + * Get UserInfo. + * + * @param accessToken access token which is required to access a UserInfo endpoint. + * @return Uni {@link UserInfo} + */ + Uni getUserInfo(String accessToken); + + /** + * Introspect the access token. + * + * @param accessToken access oken which must be introspected. + * @return Uni {@link TokenIntrospection} + */ + Uni introspectAccessToken(String accessToken); + + /** + * Revoke the access token. + * + * @param accessToken access token which needs to be revoked. + * @return Uni true if the access token has been revoked or found already being invalidated, + * false if the access token can not be currently revoked in which case a revocation request might be retried. + */ + Uni revokeAccessToken(String accessToken); + + /** + * Revoke the refresh token. + * + * @param refreshToken refresh token which needs to be revoked. + * @return Uni true if the refresh token has been revoked or found already being invalidated, + * false if the refresh token can not be currently revoked in which case a revocation request might be retried. + */ + Uni revokeRefreshToken(String refreshToken); + +} 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 624e1dbae82f0..3f49f63ad0b22 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 @@ -30,11 +30,11 @@ public class DynamicVerificationKeyResolver { HeaderParameterNames.X509_CERTIFICATE_SHA256_THUMBPRINT, HeaderParameterNames.X509_CERTIFICATE_THUMBPRINT); - private final OidcProviderClient client; + private final OidcProviderClientImpl client; private final MemoryCache cache; final CertChainPublicKeyResolver chainResolverFallback; - public DynamicVerificationKeyResolver(OidcProviderClient client, OidcTenantConfig config) { + public DynamicVerificationKeyResolver(OidcProviderClientImpl client, OidcTenantConfig config) { this.client = client; this.cache = new MemoryCache(client.getVertx(), config.jwks().cleanUpTimerInterval(), config.jwks().cacheTimeToLive(), config.jwks().cacheSize()); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/LazyTenantConfigContext.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/LazyTenantConfigContext.java index b736555b7f9dd..91ea3f8590722 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/LazyTenantConfigContext.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/LazyTenantConfigContext.java @@ -61,7 +61,7 @@ public OidcConfigurationMetadata getOidcMetadata() { } @Override - public OidcProviderClient getOidcProviderClient() { + public OidcProviderClientImpl getOidcProviderClient() { return delegate.getOidcProviderClient(); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfigurationAndProviderProducer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfigurationAndProviderProducer.java new file mode 100644 index 0000000000000..dda51e68bc960 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfigurationAndProviderProducer.java @@ -0,0 +1,55 @@ +package io.quarkus.oidc.runtime; + +import jakarta.enterprise.context.RequestScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; + +import io.quarkus.oidc.OIDCException; +import io.quarkus.oidc.OidcConfigurationMetadata; +import io.quarkus.oidc.OidcProviderClient; +import io.quarkus.security.identity.SecurityIdentity; + +@RequestScoped +public class OidcConfigurationAndProviderProducer { + @Inject + TenantConfigBean tenantConfig; + @Inject + SecurityIdentity identity; + + @Produces + @RequestScoped + OidcConfigurationMetadata produceMetadata() { + OidcConfigurationMetadata configMetadata = OidcUtils.getAttribute(identity, OidcUtils.CONFIG_METADATA_ATTRIBUTE); + + if (configMetadata == null && tenantConfig.getDefaultTenant().oidcConfig().tenantEnabled()) { + configMetadata = tenantConfig.getDefaultTenant().provider().getMetadata(); + } + if (configMetadata == null) { + throw new OIDCException("OidcConfigurationMetadata can not be injected"); + } + return configMetadata; + } + + @Produces + @RequestScoped + OidcProviderClient produceProviderClient() { + OidcProviderClient client = null; + String tenantId = OidcUtils.getAttribute(identity, OidcUtils.TENANT_ID_ATTRIBUTE); + if (tenantId != null) { + if (OidcUtils.DEFAULT_TENANT_ID.equals(tenantId)) { + return tenantConfig.getDefaultTenant().getOidcProviderClient(); + } + TenantConfigContext context = tenantConfig.getStaticTenant(tenantId); + if (context == null) { + context = tenantConfig.getDynamicTenant(tenantId); + } + if (context != null) { + client = context.getOidcProviderClient(); + } + } + if (client == null) { + throw new OIDCException("OidcProviderClient can not be injected"); + } + return client; + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfigurationMetadataProducer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfigurationMetadataProducer.java deleted file mode 100644 index e5f9eeab9ddf7..0000000000000 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfigurationMetadataProducer.java +++ /dev/null @@ -1,31 +0,0 @@ -package io.quarkus.oidc.runtime; - -import jakarta.enterprise.context.RequestScoped; -import jakarta.enterprise.inject.Produces; -import jakarta.inject.Inject; - -import io.quarkus.oidc.OIDCException; -import io.quarkus.oidc.OidcConfigurationMetadata; -import io.quarkus.security.identity.SecurityIdentity; - -@RequestScoped -public class OidcConfigurationMetadataProducer { - @Inject - TenantConfigBean tenantConfig; - @Inject - SecurityIdentity identity; - - @Produces - @RequestScoped - OidcConfigurationMetadata produce() { - OidcConfigurationMetadata configMetadata = OidcUtils.getAttribute(identity, OidcUtils.CONFIG_METADATA_ATTRIBUTE); - - if (configMetadata == null && tenantConfig.getDefaultTenant().oidcConfig().tenantEnabled()) { - configMetadata = tenantConfig.getDefaultTenant().provider().getMetadata(); - } - if (configMetadata == null) { - throw new OIDCException("OidcConfigurationMetadata can not be injected"); - } - return configMetadata; - } -} 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 a850d77373dfc..35a1fb1e2c1e1 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 @@ -408,6 +408,7 @@ public String getName() { var vertxContext = getRoutingContextAttribute(request); OidcUtils.setBlockingApiAttribute(builder, vertxContext); OidcUtils.setRoutingContextAttribute(builder, vertxContext); + OidcUtils.setOidcProviderClientAttribute(builder, resolvedContext.getOidcProviderClient()); SecurityIdentity identity = builder.build(); // If the primary token is a bearer access token then there's no point of checking if // it should be refreshed as RT is only available for the code flow tokens 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 c50748cf8e936..e6a53a60e3069 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 @@ -42,7 +42,6 @@ import io.quarkus.oidc.common.runtime.AbstractJsonObject; 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; @@ -67,11 +66,10 @@ 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; - final OidcProviderClient client; + final OidcProviderClientImpl client; final RefreshableVerificationKeyResolver asymmetricKeyResolver; final DynamicVerificationKeyResolver keyResolverProvider; final OidcTenantConfig oidcConfig; @@ -82,12 +80,13 @@ public class OidcProvider implements Closeable { final Key tokenDecryptionKey; final AlgorithmConstraints requiredAlgorithmConstraints; - public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, Key tokenDecryptionKey) { + public OidcProvider(OidcProviderClientImpl client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, + Key tokenDecryptionKey) { this(client, oidcConfig, jwks, TenantFeatureFinder.find(oidcConfig), tokenDecryptionKey, TenantFeatureFinder.find(oidcConfig, Validator.class)); } - public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, + public OidcProvider(OidcProviderClientImpl client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, TokenCustomizer tokenCustomizer, Key tokenDecryptionKey, List customValidators) { this.client = client; this.oidcConfig = oidcConfig; @@ -111,6 +110,9 @@ public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, Json this.tokenDecryptionKey = tokenDecryptionKey; this.requiredAlgorithmConstraints = checkSignatureAlgorithm(); this.customValidators = customValidators == null ? List.of() : customValidators; + if (client != null) { + this.client.setOidcProvider(this); + } } public OidcProvider(String publicKeyEnc, OidcTenantConfig oidcConfig, Key tokenDecryptionKey) { @@ -358,7 +360,7 @@ public Uni introspectToken(String token, boolean fallbackFro throw new AuthenticationFailedException(errorMessage); } - return client.introspectToken(token).onItemOrFailure() + return client.introspectAccessToken(token).onItemOrFailure() .transform(new BiFunction() { @Override @@ -421,8 +423,8 @@ private boolean isTokenExpired(Long exp) { } private int getLifespanGrace() { - return client.getOidcConfig().token().lifespanGrace().isPresent() - ? client.getOidcConfig().token().lifespanGrace().getAsInt() + return oidcConfig.token().lifespanGrace().isPresent() + ? oidcConfig.token().lifespanGrace().getAsInt() : 0; } @@ -431,55 +433,7 @@ private static final long now() { } public Uni getUserInfo(String accessToken) { - return client.getUserInfo(accessToken).onItem() - .transformToUni(new Function>() { - - @Override - public Uni apply(UserInfoResponse response) { - if (isApplicationJwtContentType(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())); - } - } - }); - } - - static boolean isApplicationJwtContentType(String ct) { - if (ct == null) { - return false; - } - ct = ct.trim(); - if (!ct.startsWith(APPLICATION_JWT_CONTENT_TYPE)) { - return false; - } - if (ct.length() == APPLICATION_JWT_CONTENT_TYPE.length()) { - return true; - } - String remainder = ct.substring(APPLICATION_JWT_CONTENT_TYPE.length()).trim(); - return remainder.indexOf(';') == 0; + return client.getUserInfo(accessToken); } 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/OidcProviderClientImpl.java similarity index 78% rename from extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java rename to extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClientImpl.java index 39a696bf8d27e..56d53f00498ea 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/OidcProviderClientImpl.java @@ -9,11 +9,15 @@ import java.util.function.Function; import org.jboss.logging.Logger; +import org.jose4j.lang.UnresolvableKeyException; import io.quarkus.oidc.AuthorizationCodeTokens; import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcConfigurationMetadata; +import io.quarkus.oidc.OidcProviderClient; +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; @@ -25,6 +29,7 @@ import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.oidc.common.runtime.config.OidcClientCommonConfig; import io.quarkus.oidc.common.runtime.config.OidcClientCommonConfig.Credentials.Secret.Method; +import io.quarkus.security.credential.TokenCredential; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.groups.UniOnItem; import io.vertx.core.Vertx; @@ -36,8 +41,8 @@ import io.vertx.mutiny.ext.web.client.HttpResponse; import io.vertx.mutiny.ext.web.client.WebClient; -public class OidcProviderClient implements Closeable { - private static final Logger LOG = Logger.getLogger(OidcProviderClient.class); +public class OidcProviderClientImpl implements OidcProviderClient, Closeable { + private static final Logger LOG = Logger.getLogger(OidcProviderClientImpl.class); private static final String AUTHORIZATION_HEADER = String.valueOf(HttpHeaders.AUTHORIZATION); private static final String CONTENT_TYPE_HEADER = String.valueOf(HttpHeaders.CONTENT_TYPE); @@ -58,7 +63,9 @@ public class OidcProviderClient implements Closeable { private final Map> responseFilters; private final boolean clientSecretQueryAuthentication; - public OidcProviderClient(WebClient client, + private OidcProvider oidcProvider; + + public OidcProviderClientImpl(WebClient client, Vertx vertx, OidcConfigurationMetadata metadata, OidcTenantConfig oidcConfig, @@ -89,6 +96,10 @@ private static ClientAssertionProvider createClientAssertionProvider(Vertx vertx return clientAssertionProvider; } + void setOidcProvider(OidcProvider oidcProvider) { + this.oidcProvider = oidcProvider; + } + private static String initIntrospectionBasicAuthScheme(OidcTenantConfig oidcConfig) { if (oidcConfig.introspectionCredentials().name().isPresent() && oidcConfig.introspectionCredentials().secret().isPresent()) { @@ -99,11 +110,11 @@ private static String initIntrospectionBasicAuthScheme(OidcTenantConfig oidcConf } } - public OidcConfigurationMetadata getMetadata() { + OidcConfigurationMetadata getMetadata() { return metadata; } - public Uni getJsonWebKeySet(OidcRequestContextProperties contextProperties) { + Uni getJsonWebKeySet(OidcRequestContextProperties contextProperties) { final OidcRequestContextProperties requestProps = getRequestProps(contextProperties); return doGetJsonWebKeySet(requestProps, List.of()) .onFailure(OidcCommonUtils.validOidcClientRedirect(metadata.getJsonWebKeySetUri())) @@ -131,20 +142,56 @@ private Uni doGetJsonWebKeySet(OidcRequestContextProperties reque .transform(resp -> getJsonWebKeySet(requestProps, resp)); } - public Uni getUserInfo(final String token) { + public Uni getUserInfo(final String accessToken) { final OidcRequestContextProperties requestProps = getRequestProps(null, null); - return doGetUserInfo(requestProps, token, List.of()) + Uni response = doGetUserInfo(requestProps, accessToken, List.of()) .onFailure(OidcCommonUtils.validOidcClientRedirect(metadata.getUserInfoUri())) .recoverWithUni( new Function>() { @Override public Uni apply(Throwable t) { OidcClientRedirectException ex = (OidcClientRedirectException) t; - return doGetUserInfo(requestProps, token, ex.getCookies()); + return doGetUserInfo(requestProps, accessToken, ex.getCookies()); } }); + return response.onItem() + .transformToUni(new Function>() { + + @Override + public Uni apply(UserInfoResponse response) { + if (OidcUtils.isApplicationJwtContentType(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( + oidcProvider.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 oidcProvider.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 oidcProvider + .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())); + } + } + }); } private Uni doGetUserInfo(OidcRequestContextProperties requestProps, String token, List cookies) { @@ -162,7 +209,7 @@ private Uni doGetUserInfo(OidcRequestContextProperties request .onItem().transform(resp -> getUserInfo(requestProps, resp)); } - public Uni introspectToken(final String token) { + public Uni introspectAccessToken(final String token) { final 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); @@ -175,11 +222,7 @@ private JsonWebKeySet getJsonWebKeySet(OidcRequestContextProperties requestProps return new JsonWebKeySet(getString(requestProps, metadata.getJsonWebKeySetUri(), resp, OidcEndpoint.Type.JWKS)); } - public OidcTenantConfig getOidcConfig() { - return oidcConfig; - } - - public Uni getAuthorizationCodeTokens(String code, String redirectUri, String codeVerifier) { + Uni getAuthorizationCodeTokens(String code, String redirectUri, String codeVerifier) { final MultiMap codeGrantParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap()); codeGrantParams.add(OidcConstants.GRANT_TYPE, OidcConstants.AUTHORIZATION_CODE); codeGrantParams.add(OidcConstants.CODE_FLOW_CODE, code); @@ -195,7 +238,7 @@ public Uni getAuthorizationCodeTokens(String code, Stri .transform(resp -> getAuthorizationCodeTokens(requestProps, resp)); } - public Uni refreshAuthorizationCodeTokens(String refreshToken) { + Uni refreshAuthorizationCodeTokens(String refreshToken) { final 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); @@ -204,6 +247,41 @@ public Uni refreshAuthorizationCodeTokens(String refres .transform(resp -> getAuthorizationCodeTokens(requestProps, resp)); } + public Uni revokeAccessToken(String accessToken) { + return revokeToken(accessToken, OidcConstants.ACCESS_TOKEN_VALUE); + } + + public Uni revokeRefreshToken(String refreshToken) { + return revokeToken(refreshToken, OidcConstants.REFRESH_TOKEN_VALUE); + } + + private Uni revokeToken(String token, String tokenTypeHint) { + + if (metadata.getRevocationUri() != null) { + OidcRequestContextProperties requestProps = getRequestProps(null, null); + MultiMap tokenRevokeParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap()); + tokenRevokeParams.set(OidcConstants.REVOCATION_TOKEN, token); + tokenRevokeParams.set(OidcConstants.REVOCATION_TOKEN_TYPE_HINT, tokenTypeHint); + + return getHttpResponse(requestProps, metadata.getRevocationUri(), tokenRevokeParams, false) + .transform(resp -> toRevokeResponse(requestProps, resp)); + } else { + LOG.debugf("The %s token can not be revoked because the revocation endpoint URL is not set", tokenTypeHint); + return Uni.createFrom().item(false); + } + + } + + private Boolean toRevokeResponse(OidcRequestContextProperties requestProps, HttpResponse resp) { + // Per RFC7009, 200 is returned if a token has been revoked successfully or if the client submitted an + // invalid token, https://datatracker.ietf.org/doc/html/rfc7009#section-2.2. + // 503 is at least theoretically possible if the OIDC server declines and suggests to Retry-After some period of time. + // However this period of time can be set to unpredictable value. + Buffer buffer = resp.body(); + OidcCommonUtils.filterHttpResponse(requestProps, resp, buffer, responseFilters, OidcEndpoint.Type.TOKEN_REVOCATION); + return resp.statusCode() == 503 ? false : true; + } + private UniOnItem> getHttpResponse(OidcRequestContextProperties requestProps, String uri, MultiMap formBody, boolean introspect) { HttpRequest request = client.postAbs(uri); @@ -347,7 +425,7 @@ public void close() { } } - public Key getClientJwtKey() { + Key getClientJwtKey() { return clientJwtKey; } @@ -384,11 +462,11 @@ private OidcRequestContextProperties getRequestProps(OidcRequestContextPropertie return new OidcRequestContextProperties(newProperties); } - public Vertx getVertx() { + Vertx getVertx() { return vertx; } - public WebClient getWebClient() { + WebClient getWebClient() { return client; } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java index a2803969505bf..b91ee629fda83 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java @@ -440,9 +440,9 @@ protected static OIDCException toOidcException(Throwable cause, String authServe protected static Uni createOidcProvider(OidcTenantConfig oidcConfig, Vertx vertx, OidcTlsSupport tlsSupport) { return createOidcClientUni(oidcConfig, vertx, tlsSupport) - .flatMap(new Function>() { + .flatMap(new Function>() { @Override - public Uni apply(OidcProviderClient client) { + public Uni apply(OidcProviderClientImpl client) { if (oidcConfig.jwks().resolveEarly() && client.getMetadata().getJsonWebKeySetUri() != null && !oidcConfig.token().requireJwtIntrospectionOnly()) { @@ -492,7 +492,7 @@ private static Key readTokenDecryptionKey(OidcTenantConfig oidcConfig) { } } - protected static Uni getJsonWebSetUni(OidcProviderClient client, OidcTenantConfig oidcConfig) { + protected static Uni getJsonWebSetUni(OidcProviderClientImpl client, OidcTenantConfig oidcConfig) { if (!oidcConfig.discoveryEnabled().orElse(true)) { String tenantId = oidcConfig.tenantId().orElse(DEFAULT_TENANT_ID); if (shouldFireOidcServerAvailableEvent(tenantId)) { @@ -510,7 +510,7 @@ public void run() { } } - private static Uni getJsonWebSetUniWhenDiscoveryDisabled(OidcProviderClient client, + private static Uni getJsonWebSetUniWhenDiscoveryDisabled(OidcProviderClientImpl client, OidcTenantConfig oidcConfig) { final long connectionDelayInMillisecs = OidcCommonUtils.getConnectionDelayInMillis(oidcConfig); return client.getJsonWebKeySet(null).onFailure(OidcCommonUtils.oidcEndpointNotAvailable()) @@ -529,7 +529,7 @@ public Throwable apply(Throwable t) { .invoke(client::close); } - protected static Uni createOidcClientUni(OidcTenantConfig oidcConfig, Vertx vertx, + protected static Uni createOidcClientUni(OidcTenantConfig oidcConfig, Vertx vertx, OidcTlsSupport tlsSupport) { String authServerUriString = OidcCommonUtils.getAuthServerUrl(oidcConfig); @@ -565,10 +565,10 @@ public OidcConfigurationMetadata apply(JsonObject json) { }); } return metadataUni.onItemOrFailure() - .transformToUni(new BiFunction>() { + .transformToUni(new BiFunction>() { @Override - public Uni apply(OidcConfigurationMetadata metadata, Throwable t) { + public Uni apply(OidcConfigurationMetadata metadata, Throwable t) { String tenantId = oidcConfig.tenantId().orElse(DEFAULT_TENANT_ID); if (t != null) { client.close(); @@ -599,7 +599,7 @@ public Uni apply(OidcConfigurationMetadata metadata, Throwab + " Use 'quarkus.oidc.user-info-path' if the discovery is disabled.")); } return Uni.createFrom() - .item(new OidcProviderClient(client, vertx, metadata, oidcConfig, oidcRequestFilters, + .item(new OidcProviderClientImpl(client, vertx, metadata, oidcConfig, oidcRequestFilters, oidcResponseFilters)); } @@ -616,8 +616,9 @@ private static OidcConfigurationMetadata createLocalMetadata(OidcTenantConfig oi String userInfoUri = OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.userInfoPath()); String endSessionUri = OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.endSessionPath()); String registrationUri = OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.registrationPath()); + String revocationUri = OidcCommonUtils.getOidcEndpointUrl(authServerUriString, oidcConfig.revokePath()); return new OidcConfigurationMetadata(tokenUri, - introspectionUri, authorizationUri, jwksUri, userInfoUri, endSessionUri, registrationUri, + introspectionUri, authorizationUri, jwksUri, userInfoUri, endSessionUri, registrationUri, revocationUri, oidcConfig.token().issuer().orElse(null)); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java index d7e4498ff9f9e..878f0295a76bf 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java @@ -38,6 +38,7 @@ import io.quarkus.oidc.AccessTokenCredential; import io.quarkus.oidc.AuthorizationCodeTokens; import io.quarkus.oidc.OIDCException; +import io.quarkus.oidc.OidcProviderClient; import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.RefreshToken; import io.quarkus.oidc.TokenIntrospection; @@ -96,6 +97,7 @@ public final class OidcUtils { public static final String STATE_COOKIE_NAME = "q_auth"; public static final String JWT_THUMBPRINT = "jwt_thumbprint"; public static final String INTROSPECTION_THUMBPRINT = "introspection_thumbprint"; + private static final String APPLICATION_JWT = "application/jwt"; // Browsers enforce that the total Set-Cookie expression such as // `q_session_tenant-a=,Path=/somepath,Expires=...` does not exceed 4096 @@ -324,6 +326,7 @@ static QuarkusSecurityIdentity validateAndCreateIdentity(Map req builder.setPrincipal(jwtPrincipal); var vertxContext = getRoutingContextAttribute(request); setRoutingContextAttribute(builder, vertxContext); + OidcUtils.setOidcProviderClientAttribute(builder, resolvedContext.getOidcProviderClient()); setSecurityIdentityRoles(builder, config, rolesJson); setSecurityIdentityPermissions(builder, config, rolesJson); setSecurityIdentityUserInfo(builder, userInfo); @@ -372,6 +375,11 @@ public static void setRoutingContextAttribute(QuarkusSecurityIdentity.Builder bu builder.addAttribute(RoutingContext.class.getName(), routingContext); } + public static void setOidcProviderClientAttribute(QuarkusSecurityIdentity.Builder builder, + OidcProviderClient oidcProviderClient) { + builder.addAttribute(OidcProviderClient.class.getName(), oidcProviderClient); + } + public static void setSecurityIdentityUserInfo(QuarkusSecurityIdentity.Builder builder, UserInfo userInfo) { if (userInfo != null) { builder.addAttribute(USER_INFO_ATTRIBUTE, userInfo); @@ -794,4 +802,19 @@ static Long getJwtExpiresAtClaim(JsonObject claims) { return null; } } + + public static boolean isApplicationJwtContentType(String ct) { + if (ct == null) { + return false; + } + ct = ct.trim(); + if (!ct.startsWith(APPLICATION_JWT)) { + return false; + } + if (ct.length() == APPLICATION_JWT.length()) { + return true; + } + String remainder = ct.substring(APPLICATION_JWT.length()).trim(); + return remainder.indexOf(';') == 0; + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java index ee234563ba08a..47b1c41ef6f81 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java @@ -29,7 +29,7 @@ public sealed interface TenantConfigContext permits TenantConfigContextImpl, Laz OidcConfigurationMetadata getOidcMetadata(); - OidcProviderClient getOidcProviderClient(); + OidcProviderClientImpl getOidcProviderClient(); SecretKey getStateEncryptionKey(); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContextImpl.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContextImpl.java index 1e4840bccc5c1..3e7937b485e52 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContextImpl.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContextImpl.java @@ -201,7 +201,7 @@ public OidcConfigurationMetadata getOidcMetadata() { } @Override - public OidcProviderClient getOidcProviderClient() { + public OidcProviderClientImpl getOidcProviderClient() { return provider != null ? provider.client : null; } diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java index 9b874b1516f08..ca7b72c1cbe50 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcProviderTest.java @@ -1,7 +1,6 @@ package io.quarkus.oidc.runtime; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -312,14 +311,4 @@ public String validate(JwtContext jwtContext) throws MalformedClaimException { } } - @Test - public void testJwtContentTypeCheck() { - assertTrue(OidcProvider.isApplicationJwtContentType("application/jwt")); - assertTrue(OidcProvider.isApplicationJwtContentType(" application/jwt ")); - assertTrue(OidcProvider.isApplicationJwtContentType("application/jwt;charset=UTF-8")); - assertTrue(OidcProvider.isApplicationJwtContentType(" application/jwt ; charset=UTF-8")); - assertFalse(OidcProvider.isApplicationJwtContentType(" application/jwt-custom")); - assertFalse(OidcProvider.isApplicationJwtContentType(" application/json")); - assertFalse(OidcProvider.isApplicationJwtContentType(null)); - } } diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java index abcf91f4150ee..156ccb307222e 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java @@ -322,4 +322,14 @@ public static JsonObject read(InputStream input) throws IOException { } } + @Test + public void testJwtContentTypeCheck() { + assertTrue(OidcUtils.isApplicationJwtContentType("application/jwt")); + assertTrue(OidcUtils.isApplicationJwtContentType(" application/jwt ")); + assertTrue(OidcUtils.isApplicationJwtContentType("application/jwt;charset=UTF-8")); + assertTrue(OidcUtils.isApplicationJwtContentType(" application/jwt ; charset=UTF-8")); + assertFalse(OidcUtils.isApplicationJwtContentType(" application/jwt-custom")); + assertFalse(OidcUtils.isApplicationJwtContentType(" application/json")); + assertFalse(OidcUtils.isApplicationJwtContentType(null)); + } } diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java index 87ac6edf3bb4d..cfb36d9b1ad95 100644 --- a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java @@ -19,6 +19,7 @@ import io.quarkus.oidc.IdTokenCredential; import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcConfigurationMetadata; +import io.quarkus.oidc.OidcProviderClient; import io.quarkus.oidc.RefreshToken; import io.quarkus.oidc.UserInfo; import io.quarkus.oidc.common.runtime.OidcConstants; @@ -56,6 +57,9 @@ public class ProtectedResource { @Inject AccessTokenCredential accessTokenCredential; + @Inject + OidcProviderClient oidcProviderClient; + @Inject RefreshToken refreshToken; @@ -189,7 +193,13 @@ public String getAccessToken() { throw new OIDCException("Access token values are not equal"); } - return accessToken.getRawToken() != null && !accessToken.getRawToken().isEmpty() ? "AT injected" : "no access"; + return accessToken.getRawToken() != null && !accessToken.getRawToken().isEmpty() + ? "AT injected, active: " + isTokenActive() + : "no access"; + } + + private boolean isTokenActive() { + return oidcProviderClient.introspectAccessToken(accessTokenCredential.getToken()).await().indefinitely().isActive(); } @GET diff --git a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java index 3f2a60b94d61f..1fe06ac6f2ce6 100644 --- a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java +++ b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java @@ -1078,7 +1078,7 @@ public void testAccessTokenInjection() throws IOException { page = webClient.getPage("http://localhost:8081/web-app/access"); - assertEquals("AT injected", page.getBody().asNormalizedText()); + assertEquals("AT injected, active: true", page.getBody().asNormalizedText()); webClient.getCookieManager().clearCookies(); } } @@ -1241,7 +1241,7 @@ public void testDefaultSessionManagerSplitTokens() throws IOException, Interrupt page.getBody().asNormalizedText()); page = webClient.getPage("http://localhost:8081/web-app/access/tenant-split-tokens"); - assertEquals("tenant-split-tokens:AT injected", page.getBody().asNormalizedText()); + assertEquals("tenant-split-tokens:AT injected, active: true", page.getBody().asNormalizedText()); page = webClient.getPage("http://localhost:8081/web-app/refresh/tenant-split-tokens"); assertEquals("tenant-split-tokens:RT injected", page.getBody().asNormalizedText()); diff --git a/integration-tests/oidc-wiremock-logout/pom.xml b/integration-tests/oidc-wiremock-logout/pom.xml index 151ad1ec97221..546a318c317c7 100644 --- a/integration-tests/oidc-wiremock-logout/pom.xml +++ b/integration-tests/oidc-wiremock-logout/pom.xml @@ -39,6 +39,11 @@ rest-assured test + + org.awaitility + awaitility + test + org.htmlunit htmlunit diff --git a/integration-tests/oidc-wiremock-logout/src/main/java/io/quarkus/it/keycloak/SecurityEventListener.java b/integration-tests/oidc-wiremock-logout/src/main/java/io/quarkus/it/keycloak/SecurityEventListener.java new file mode 100644 index 0000000000000..5ff5c9cb4ba8f --- /dev/null +++ b/integration-tests/oidc-wiremock-logout/src/main/java/io/quarkus/it/keycloak/SecurityEventListener.java @@ -0,0 +1,24 @@ +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.ObservesAsync; + +import io.quarkus.oidc.AccessTokenCredential; +import io.quarkus.oidc.OidcProviderClient; +import io.quarkus.oidc.RefreshToken; +import io.quarkus.oidc.SecurityEvent; + +@ApplicationScoped +public class SecurityEventListener { + + public void event(@ObservesAsync SecurityEvent event) { + if (SecurityEvent.Type.OIDC_BACKCHANNEL_LOGOUT_COMPLETED == event.getEventType()) { + OidcProviderClient oidcProvider = event.getSecurityIdentity().getAttribute(OidcProviderClient.class.getName()); + String accessToken = event.getSecurityIdentity().getCredential(AccessTokenCredential.class).getToken(); + String refreshToken = event.getSecurityIdentity().getCredential(RefreshToken.class).getToken(); + oidcProvider.revokeAccessToken(accessToken).await().indefinitely(); + oidcProvider.revokeRefreshToken(refreshToken).await().indefinitely(); + } + } + +} diff --git a/integration-tests/oidc-wiremock-logout/src/main/resources/application.properties b/integration-tests/oidc-wiremock-logout/src/main/resources/application.properties index beaa3fa7f635e..5690b3fc83c33 100644 --- a/integration-tests/oidc-wiremock-logout/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock-logout/src/main/resources/application.properties @@ -10,6 +10,7 @@ quarkus.oidc.code-flow-form-post.discovery-enabled=false quarkus.oidc.code-flow-form-post.authorization-path=/ # reuse the wiremock access token stub for the `quarkus` realm - it is the same for the query and form post response mode quarkus.oidc.code-flow-form-post.token-path=${keycloak.url}/realms/quarkus/token +quarkus.oidc.code-flow-form-post.revoke-path=${keycloak.url}/realms/quarkus/revoke # reuse the wiremock JWK endpoint stub for the `quarkus` realm - it is the same for the query and form post response mode quarkus.oidc.code-flow-form-post.jwks-path=${keycloak.url}/realms/quarkus/protocol/openid-connect/certs quarkus.oidc.code-flow-form-post.logout.backchannel.path=/back-channel-logout @@ -17,4 +18,4 @@ quarkus.oidc.code-flow-form-post.token.audience=https://server.example.com,https quarkus.native.additional-build-args=-H:IncludeResources=private.*\\.*,-H:IncludeResources=.*\\.p12 -quarkus.http.root-path=/service \ No newline at end of file +quarkus.http.root-path=/service diff --git a/integration-tests/oidc-wiremock-logout/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java b/integration-tests/oidc-wiremock-logout/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java index 57201376da7b7..27d8bea4beff1 100644 --- a/integration-tests/oidc-wiremock-logout/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java +++ b/integration-tests/oidc-wiremock-logout/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java @@ -1,11 +1,17 @@ package io.quarkus.it.keycloak; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static org.awaitility.Awaitility.await; 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 java.io.IOException; import java.net.URI; +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; import org.htmlunit.SilentCssErrorHandler; import org.htmlunit.TextPage; @@ -17,8 +23,12 @@ import org.htmlunit.util.Cookie; import org.junit.jupiter.api.Test; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; + import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.oidc.server.OidcWireMock; import io.quarkus.test.oidc.server.OidcWiremockTestResource; import io.restassured.RestAssured; import io.restassured.http.ContentType; @@ -27,8 +37,12 @@ @QuarkusTestResource(OidcWiremockTestResource.class) public class CodeFlowAuthorizationTest { + @OidcWireMock + WireMockServer wireMockServer; + @Test - public void testCodeFlowFormPostAndBackChannelLogout() throws IOException { + public void testCodeFlowFormPostAndBackChannelLogout() throws Exception { + defineRevokeTokenStubs(); try (final WebClient webClient = createWebClient()) { webClient.getOptions().setRedirectEnabled(true); HtmlPage page = webClient.getPage("http://localhost:8081/service/code-flow-form-post"); @@ -64,6 +78,9 @@ public void testCodeFlowFormPostAndBackChannelLogout() throws IOException { // Session is still active assertNotNull(getSessionCookie(webClient, "code-flow-form-post")); + wireMockServer.verify(0, + postRequestedFor(urlPathMatching("/auth/realms/quarkus/revoke"))); + // request a back channel logout for the same subject RestAssured.given() .when().contentType(ContentType.URLENC).body("logout_token=" @@ -80,10 +97,44 @@ public void testCodeFlowFormPostAndBackChannelLogout() throws IOException { assertNull(getSessionCookie(webClient, "code-flow-form-post")); + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(Duration.ofSeconds(3)) + .until(new Callable() { + @Override + public Boolean call() throws Exception { + try { + wireMockServer.verify(2, + postRequestedFor(urlPathMatching("/auth/realms/quarkus/revoke"))); + return true; + } catch (Throwable t) { + return false; + } + } + }); + + wireMockServer.verify(2, + postRequestedFor(urlPathMatching("/auth/realms/quarkus/revoke"))); + wireMockServer.resetRequests(); + webClient.getCookieManager().clearCookies(); } } + private void defineRevokeTokenStubs() { + wireMockServer + .stubFor(WireMock.post("/auth/realms/quarkus/revoke") + .withRequestBody(containing("token")) + .withRequestBody(containing("token_type_hint=access_token")) + .willReturn(WireMock.aResponse() + .withStatus(200))); + wireMockServer + .stubFor(WireMock.post("/auth/realms/quarkus/revoke") + .withRequestBody(containing("token")) + .withRequestBody(containing("token_type_hint=refresh_token")) + .willReturn(WireMock.aResponse() + .withStatus(200))); + } + private WebClient createWebClient() { WebClient webClient = new WebClient(); webClient.setCssErrorHandler(new SilentCssErrorHandler()); diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index ebf73424c3731..5a24b390cc68b 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -245,8 +245,8 @@ quarkus.log.category."io.quarkus.oidc.runtime.CodeAuthenticationMechanism".min-l quarkus.log.category."io.quarkus.oidc.runtime.CodeAuthenticationMechanism".level=TRACE 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.category."io.quarkus.oidc.runtime.OidcProviderClientImpl".min-level=TRACE +quarkus.log.category."io.quarkus.oidc.runtime.OidcProviderClientImpl".level=TRACE quarkus.log.category."io.quarkus.it.keycloak.SignedUserInfoResponseFilter".min-level=TRACE quarkus.log.category."io.quarkus.it.keycloak.SignedUserInfoResponseFilter".level=TRACE quarkus.log.category."io.quarkus.it.keycloak.TokenRequestResponseFilter".min-level=TRACE