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 a2f0fe5df9c6e..d75a3aac952e2 100644 --- a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc @@ -437,6 +437,13 @@ Please see link:security-openid-connect#token-verification-introspection for det Note that in case of `web-app` applications only `IdToken` is verified by default since the access token is not used by default to access the current Quarkus `web-app` endpoint and instead meant to be propagated to the services expecting this access token, for example, to the OpenId Connect Provider's UserInfo endpoint, etc. However if you expect the access token to contain the roles required to access the current Quarkus endpoint (`quarkus.oidc.roles.source=accesstoken`) then it will also be verified. +[[token-introspection-userinfo-cache]] +== Token Introspection and UserInfo Cache + +Code flow access tokens are not introspected unless they are expected to be the source of roles but will be used to get `UserInfo`. So there will be one or two remote calls with the code flow access token, if the token introspection and/or `UserInfo` are required. + +Please see link:security-openid-connect#token-introspection-userinfo-cache for more information about using a default token cache or registering a custom cache implementation. + [[session-management]] == Session Management diff --git a/docs/src/main/asciidoc/security-openid-connect.adoc b/docs/src/main/asciidoc/security-openid-connect.adoc index ebcaf31308ab2..4592323237292 100644 --- a/docs/src/main/asciidoc/security-openid-connect.adoc +++ b/docs/src/main/asciidoc/security-openid-connect.adoc @@ -388,6 +388,46 @@ quarkus.oidc.introspection-path=/protocol/openid-connect/tokens/introspect Note that `io.quarkus.oidc.TokenIntrospection` (a simple `javax.json.JsonObject` wrapper) object will be created and can be either injected or accessed as a SecurityIdentity `introspection` attribute if either JWT or opaque token has been successfully introspected. +[[token-introspection-userinfo-cache]] +== Token Introspection and UserInfo Cache + +All opaque and sometimes JWT Bearer access tokens have to be remotely introspected. If `UserInfo` is also required then the same access token will be used to do a remote call to OpenId Connect Provider again. So, if `UserInfo` is required and the current access token is opaque then for every such token there will be 2 remote calls done - one to introspect it and one to get UserInfo with it, and if the token is JWT then usually only a single remote call will be needed - to get UserInfo with it. + +The cost of making up to 2 remote calls per every incoming bearer or code flow access token can sometimes be problematic. + +If it is the case in your production then it can be recommended that the token introspection and `UserInfo` data are cached for a short period of time, for example, for 3 or 5 minutes. + +`quarkus-oidc` provides `quarkus.oidc.TokenIntrospectionCache` and `quarkus.oidc.UserInfoCache` interfaces which can be used to implement `@ApplicationScoped` cache implementation which can be used to store and retrieve `quarkus.oidc.TokenIntrospection` and/or `quarkus.oidc.UserInfo` objects, for example: + +[source, java] +---- +@ApplicationScoped +@AlternativePriority(1) +public class CustomIntrospectionUserInfoCache implements TokenIntrospectionCache, UserInfoCache { +... +} +---- + +Each OIDC tenant can either permit or deny storing its `quarkus.oidc.TokenIntrospection` and/or `quarkus.oidc.UserInfo` data with boolean `quarkus.oidc."tenant".allow-token-introspection-cache` and `quarkus.oidc."tenant".allow-user-info-cache` properties. + +Additionally, `quarkus-oidc` provides a simple default memory based token cache which implements both `quarkus.oidc.TokenIntrospectionCache` and `quarkus.oidc.UserInfoCache` interfaces. + +It can be activated and configured as follows: + +[source, properties] +---- +# 'max-size' is 0 by default so the cache can be activated by setting 'max-size' to a positive value. +quarkus.oidc.token-cache.max-size=1000 +# 'time-to-live' specifies how long a cache entry can be valid for and will be used by a clean up timer. +quarkus.oidc.token-cache.time-to-live=3M +# 'clean-up-timer-interval' is not set by default so the clean up timer can be activated by setting 'clean-up-timer-interval'. +quarkus.oidc.token-cache.clean-up-timer-interval=1M +---- + +The default cache uses a token as a key and each entry can have `TokenIntrospection` and/or `UserInfo`. It will only keep up to a `max-size` number of entries. If the cache is full when a new entry is to be added then an attempt will be made to find a space for it by removing a single expired entry. Additionally, the clean up timer, if activated, will periodically check for the expired entries and remove them. + +Please experiment with the default cache implementation or register a custom one. + [[single-page-applications]] == Single Page Applications 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 fad473c1b05b7..aebd1c9dbc3e7 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 @@ -27,6 +27,7 @@ import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.oidc.SecurityEvent; import io.quarkus.oidc.runtime.DefaultTenantConfigResolver; +import io.quarkus.oidc.runtime.DefaultTokenIntrospectionUserInfoCache; import io.quarkus.oidc.runtime.DefaultTokenStateManager; import io.quarkus.oidc.runtime.OidcAuthenticationMechanism; import io.quarkus.oidc.runtime.OidcConfig; @@ -85,7 +86,8 @@ public void additionalBeans(BuildProducer additionalBea .addBeanClass(OidcConfigurationMetadataProducer.class) .addBeanClass(OidcIdentityProvider.class) .addBeanClass(DefaultTenantConfigResolver.class) - .addBeanClass(DefaultTokenStateManager.class); + .addBeanClass(DefaultTokenStateManager.class) + .addBeanClass(DefaultTokenIntrospectionUserInfoCache.class); additionalBeans.produce(builder.build()); } 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 6004a7e19c844..76fe8f27cf632 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 @@ -112,6 +112,24 @@ public class OidcTenantConfig extends OidcCommonConfig { @ConfigItem public TokenStateManager tokenStateManager = new TokenStateManager(); + /** + * Allow caching the token introspection data. + * Note enabling this property does not enable the cache itself but only permits to cache the token introspection + * for a given tenant. If the default token cache can be used then please see {@link OidcConfig.TokenCache} how to enable + * it. + */ + @ConfigItem(defaultValue = "true") + public boolean allowTokenIntrospectionCache = true; + + /** + * Allow caching the user info data. + * Note enabling this property does not enable the cache itself but only permits to cache the user info data + * for a given tenant. If the default token cache can be used then please see {@link OidcConfig.TokenCache} how to enable + * it. + */ + @ConfigItem(defaultValue = "true") + public boolean allowUserInfoCache = true; + @ConfigGroup public static class Logout { @@ -870,4 +888,20 @@ public ApplicationType getApplicationType() { public void setApplicationType(ApplicationType type) { this.applicationType = type; } + + public boolean isAllowTokenIntrospectionCache() { + return allowTokenIntrospectionCache; + } + + public void setAllowTokenIntrospectionCache(boolean allowTokenIntrospectionCache) { + this.allowTokenIntrospectionCache = allowTokenIntrospectionCache; + } + + public boolean isAllowUserInfoCache() { + return allowUserInfoCache; + } + + public void setAllowUserInfoCache(boolean allowUserInfoCache) { + this.allowUserInfoCache = allowUserInfoCache; + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenIntrospection.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenIntrospection.java index f955ab69d0071..11a606fefc88c 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenIntrospection.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenIntrospection.java @@ -20,4 +20,8 @@ public TokenIntrospection(String introspectionJson) { public TokenIntrospection(JsonObject json) { super(json); } + + public String getIntrospectionString() { + return getNonNullJsonString(); + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenIntrospectionCache.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenIntrospectionCache.java new file mode 100644 index 0000000000000..429b9926a4f2c --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/TokenIntrospectionCache.java @@ -0,0 +1,57 @@ +package io.quarkus.oidc; + +import java.util.function.Supplier; + +import io.smallrye.mutiny.Uni; + +/** + * Token introspection cache. + */ +public interface TokenIntrospectionCache { + + /** + * Add a new {@link TokenIntrospection} result to the cache. + * + * @param token the token which has been introspected + * @param introspection the token introspection result + * @param oidcConfig the tenant configuration + * @param requestContext the request context which can be used to run the blocking tasks + */ + Uni addIntrospection(String token, TokenIntrospection introspection, OidcTenantConfig oidcConfig, + AddIntrospectionRequestContext requestContext); + + /** + * Get the cached {@link TokenIntrospection} result. + * + * @param token the token which has to be introspected + * @param oidcConfig the tenant configuration + * @param requestContext the request context which can be used to run the blocking tasks + */ + Uni getIntrospection(String token, OidcTenantConfig oidcConfig, + GetIntrospectionRequestContext requestContext); + + /** + * A context object that can be used to add a new token introspection result by running a blocking task. + *

+ * Blocking {@code TokenIntrospectionCache} providers should use this context object to run blocking tasks, to prevent + * excessive and unnecessary delegation to thread pools. + */ + interface AddIntrospectionRequestContext { + + Uni runBlocking(Supplier function); + + } + + /** + * A context object that can be used to get the existing token introspection result by running a blocking task. + *

+ * Blocking {@code TokenIntrospectionCache} providers should use this context object to run blocking tasks, to prevent + * excessive and unnecessary delegation to thread pools. + */ + interface GetIntrospectionRequestContext { + + Uni runBlocking(Supplier function); + + } + +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/UserInfo.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/UserInfo.java index 44952cc5b6d5a..d510c81f76082 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/UserInfo.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/UserInfo.java @@ -16,4 +16,8 @@ public UserInfo(String userInfoJson) { public UserInfo(JsonObject json) { super(json); } + + public String getUserInfoString() { + return getNonNullJsonString(); + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/UserInfoCache.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/UserInfoCache.java new file mode 100644 index 0000000000000..4076e72978529 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/UserInfoCache.java @@ -0,0 +1,60 @@ +package io.quarkus.oidc; + +import java.util.function.Supplier; + +import io.smallrye.mutiny.Uni; + +/** + * UserInfo cache. + */ +public interface UserInfoCache { + + /** + * Add a new {@link UserInfo} to the cache. + * + * @param token the token which was used to get {@link UserInfo} + * @param userInfo {@link UserInfo} + * @param oidcConfig the tenant configuration + * @param requestContext the request context which can be used to run the blocking tasks + */ + Uni addUserInfo(String token, UserInfo userInfo, OidcTenantConfig oidcConfig, + AddUserInfoRequestContext requestContext); + + /** + * Get the cached {@link UserInfo}. + * + * @param token the token which will be used to get new {@link UserInfo} if no {@link UserInfo} is cached. + * Effectively this token is a cache key which has to be stored when + * {@link #addUserInfo(String, UserInfo, OidcTenantConfig, AddUserInfoRequestContext)} + * is called. + * @param oidcConfig the tenant configuration + * @param requestContext the request context which can be used to run the blocking tasks + */ + Uni getUserInfo(String token, OidcTenantConfig oidcConfig, + GetUserInfoRequestContext requestContext); + + /** + * A context object that can be used to add a new @{link UserInfo} by running a blocking task. + *

+ * Blocking {@code UserInfoCache} providers should use this context object to run blocking tasks, to prevent + * excessive and unnecessary delegation to thread pools. + */ + interface AddUserInfoRequestContext { + + Uni runBlocking(Supplier function); + + } + + /** + * A context object that can be used to get the existing token introspection result by running a blocking task. + *

+ * Blocking {@code TokenIntrospectionCache} providers should use this context object to run blocking tasks, to prevent + * excessive and unnecessary delegation to thread pools. + */ + interface GetUserInfoRequestContext { + + Uni runBlocking(Supplier function); + + } + +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractBlockingTaskRunner.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractBlockingTaskRunner.java new file mode 100644 index 0000000000000..89e9d96359d96 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractBlockingTaskRunner.java @@ -0,0 +1,42 @@ +package io.quarkus.oidc.runtime; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +import io.quarkus.runtime.BlockingOperationControl; +import io.quarkus.runtime.ExecutorRecorder; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.subscription.UniEmitter; + +public abstract class AbstractBlockingTaskRunner { + public Uni runBlocking(Supplier function) { + return Uni.createFrom().deferred(new Supplier>() { + @Override + public Uni get() { + if (BlockingOperationControl.isBlockingAllowed()) { + try { + return Uni.createFrom().item(function.get()); + } catch (Throwable t) { + return Uni.createFrom().failure(t); + } + } else { + return Uni.createFrom().emitter(new Consumer>() { + @Override + public void accept(UniEmitter uniEmitter) { + ExecutorRecorder.getCurrent().execute(new Runnable() { + @Override + public void run() { + try { + uniEmitter.complete(function.get()); + } catch (Throwable t) { + uniEmitter.fail(t); + } + } + }); + } + }); + } + } + }); + } +} \ No newline at end of file diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractJsonObjectResponse.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractJsonObjectResponse.java index 3ae4e5b39ce98..070a39b683fe5 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractJsonObjectResponse.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/AbstractJsonObjectResponse.java @@ -13,13 +13,15 @@ import javax.json.JsonValue; public class AbstractJsonObjectResponse { + private String jsonString; private JsonObject json; public AbstractJsonObjectResponse() { } - public AbstractJsonObjectResponse(String introspectionJson) { - this(toJsonObject(introspectionJson)); + public AbstractJsonObjectResponse(String jsonString) { + this(toJsonObject(jsonString)); + this.jsonString = jsonString; } public AbstractJsonObjectResponse(JsonObject json) { @@ -47,6 +49,10 @@ public JsonObject getObject(String name) { return json.getJsonObject(name); } + public JsonObject getJsonObject() { + return json; + } + public Object get(String name) { return json.get(name); } @@ -63,6 +69,10 @@ public Set> getAllProperties() { return Collections.unmodifiableSet(json.entrySet()); } + protected String getNonNullJsonString() { + return jsonString == null ? json.toString() : jsonString; + } + private static JsonObject toJsonObject(String userInfoJson) { try (JsonReader jsonReader = Json.createReader(new StringReader(userInfoJson))) { return jsonReader.readObject(); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java index 8e6e5e656fa49..2c555d6571114 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java @@ -10,9 +10,7 @@ import java.util.Optional; import java.util.UUID; import java.util.function.BiFunction; -import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.Supplier; import java.util.regex.Pattern; import org.jboss.logging.Logger; @@ -28,8 +26,6 @@ import io.quarkus.oidc.TokenStateManager; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; -import io.quarkus.runtime.BlockingOperationControl; -import io.quarkus.runtime.ExecutorRecorder; import io.quarkus.security.AuthenticationCompletionException; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.AuthenticationRedirectException; @@ -37,7 +33,6 @@ import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.vertx.http.runtime.security.ChallengeData; import io.smallrye.mutiny.Uni; -import io.smallrye.mutiny.subscription.UniEmitter; import io.vertx.core.http.Cookie; import io.vertx.core.http.HttpHeaders; import io.vertx.core.http.impl.CookieImpl; @@ -650,48 +645,15 @@ static String getCookieSuffix(String tenantId) { return !"Default".equals(tenantId) ? "_" + tenantId : ""; } - private static class CreateTokenStateRequestContext extends BlockingTaskRunner + private static class CreateTokenStateRequestContext extends AbstractBlockingTaskRunner implements TokenStateManager.CreateTokenStateRequestContext { } - private static class GetTokensRequestContext extends BlockingTaskRunner + private static class GetTokensRequestContext extends AbstractBlockingTaskRunner implements TokenStateManager.GetTokensRequestContext { } - private static class DeleteTokensRequestContext extends BlockingTaskRunner + private static class DeleteTokensRequestContext extends AbstractBlockingTaskRunner implements TokenStateManager.DeleteTokensRequestContext { } - - private static class BlockingTaskRunner { - public Uni runBlocking(Supplier function) { - return Uni.createFrom().deferred(new Supplier>() { - @Override - public Uni get() { - if (BlockingOperationControl.isBlockingAllowed()) { - try { - return Uni.createFrom().item(function.get()); - } catch (Throwable t) { - return Uni.createFrom().failure(t); - } - } else { - return Uni.createFrom().emitter(new Consumer>() { - @Override - public void accept(UniEmitter uniEmitter) { - ExecutorRecorder.getCurrent().execute(new Runnable() { - @Override - public void run() { - try { - uniEmitter.complete(function.get()); - } catch (Throwable t) { - uniEmitter.fail(t); - } - } - }); - } - }); - } - } - }); - } - } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java index 660b05aa28b45..6250a5810fd8c 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java @@ -1,8 +1,6 @@ package io.quarkus.oidc.runtime; -import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.Supplier; import javax.annotation.PostConstruct; import javax.enterprise.context.ApplicationScoped; @@ -18,11 +16,11 @@ import io.quarkus.oidc.SecurityEvent; import io.quarkus.oidc.TenantConfigResolver; import io.quarkus.oidc.TenantResolver; +import io.quarkus.oidc.TokenIntrospectionCache; import io.quarkus.oidc.TokenStateManager; -import io.quarkus.runtime.BlockingOperationControl; -import io.quarkus.runtime.ExecutorRecorder; +import io.quarkus.oidc.UserInfo; +import io.quarkus.oidc.UserInfoCache; import io.smallrye.mutiny.Uni; -import io.smallrye.mutiny.subscription.UniEmitter; import io.vertx.ext.web.RoutingContext; @ApplicationScoped @@ -45,6 +43,12 @@ public class DefaultTenantConfigResolver { @Inject Instance tokenStateManager; + @Inject + Instance tokenIntrospectionCache; + + @Inject + Instance userInfoCache; + @Inject Event securityEvent; @@ -52,41 +56,7 @@ public class DefaultTenantConfigResolver { @ConfigProperty(name = "quarkus.http.proxy.enable-forwarded-prefix") boolean enableHttpForwardedPrefix; - private final TenantConfigResolver.TenantConfigRequestContext blockingRequestContext = new TenantConfigResolver.TenantConfigRequestContext() { - @Override - public Uni runBlocking(Supplier function) { - return Uni.createFrom().deferred(new Supplier>() { - @Override - public Uni get() { - if (BlockingOperationControl.isBlockingAllowed()) { - try { - OidcTenantConfig result = function.get(); - return Uni.createFrom().item(result); - } catch (Throwable t) { - return Uni.createFrom().failure(t); - } - } else { - return Uni.createFrom().emitter(new Consumer>() { - @Override - public void accept(UniEmitter uniEmitter) { - ExecutorRecorder.getCurrent().execute(new Runnable() { - @Override - public void run() { - try { - uniEmitter.complete(function.get()); - } catch (Throwable t) { - uniEmitter.fail(t); - } - } - }); - } - }); - } - } - }); - } - - }; + private final TenantConfigRequestContext blockingRequestContext = new TenantConfigRequestContext(); private volatile boolean securityEventObserved; @@ -101,6 +71,12 @@ public void verifyResolvers() { if (tokenStateManager.isAmbiguous()) { throw new IllegalStateException("Multiple " + TokenStateManager.class + " beans registered"); } + if (tokenIntrospectionCache.isAmbiguous()) { + throw new IllegalStateException("Multiple " + TokenIntrospectionCache.class + " beans registered"); + } + if (userInfoCache.isAmbiguous()) { + throw new IllegalStateException("Multiple " + UserInfo.class + " beans registered"); + } } Uni resolveConfig(RoutingContext context) { @@ -190,6 +166,14 @@ TokenStateManager getTokenStateManager() { return tokenStateManager.get(); } + TokenIntrospectionCache getTokenIntrospectionCache() { + return tokenIntrospectionCache.get(); + } + + UserInfoCache getUserInfoCache() { + return userInfoCache.get(); + } + private Uni getDynamicTenantConfig(RoutingContext context) { if (tenantConfigResolver.isResolvable()) { Uni oidcConfig = context.get(CURRENT_DYNAMIC_TENANT_CONFIG); @@ -230,4 +214,7 @@ boolean isEnableHttpForwardedPrefix() { return enableHttpForwardedPrefix; } + private static class TenantConfigRequestContext extends AbstractBlockingTaskRunner + implements TenantConfigResolver.TenantConfigRequestContext { + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenIntrospectionUserInfoCache.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenIntrospectionUserInfoCache.java new file mode 100644 index 0000000000000..0da5f918eac44 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenIntrospectionUserInfoCache.java @@ -0,0 +1,186 @@ +package io.quarkus.oidc.runtime; + +import java.time.Duration; +import java.util.Collections; +import java.util.Iterator; +import java.util.Map; +import java.util.Optional; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.ConcurrentHashMap; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.TokenIntrospection; +import io.quarkus.oidc.TokenIntrospectionCache; +import io.quarkus.oidc.UserInfo; +import io.quarkus.oidc.UserInfoCache; +import io.smallrye.mutiny.Uni; + +/** + * Default TokenIntrospection and UserInfo Cache implementation. + * A single cache entry can keep TokenIntrospection and/or UserInfo. + * + * In most cases it is the opaque bearer access tokens which are introspected + * but the code flow access tokens can also be introspected if they have the roles claims. + * + * In either case, if a remote request to fetch UserInfo is required then it will be the same access token + * which has been introspected which will be used to request UserInfo. + */ +@ApplicationScoped +public class DefaultTokenIntrospectionUserInfoCache implements TokenIntrospectionCache, UserInfoCache { + private static final Uni NULL_INTROSPECTION_UNI = Uni.createFrom().nullItem(); + private static final Uni NULL_USERINFO_UNI = Uni.createFrom().nullItem(); + + @Inject + @ConfigProperty(name = "quarkus.oidc.token-cache.max-size") + int maxSize; + + @Inject + @ConfigProperty(name = "quarkus.oidc.token-cache.time-to-live") + Duration timeToLive; + + @Inject + @ConfigProperty(name = "quarkus.oidc.token-cache.clean-up-timer-interval") + Optional cleanUpTimerInterval; + + Map cacheMap; + Timer cleanUpTimer; + + @PostConstruct + public void init() { + cacheMap = maxSize > 0 ? new ConcurrentHashMap<>() : Collections.emptyMap(); + if (cleanUpTimerInterval.isPresent()) { + TimerTask cleanUpTask = new TimerTask() { + @Override + public void run() { + // Remove all the entries which have expired + removeInvalidEntries(true); + } + }; + cleanUpTimer = new Timer("OIDC introspection cache cleanup timer"); + cleanUpTimer.scheduleAtFixedRate(cleanUpTask, 3000, cleanUpTimerInterval.get().toMillis()); + } + } + + @PreDestroy + public void stop() { + if (cleanUpTimer != null) { + cleanUpTimer.cancel(); + cleanUpTimer = null; + } + } + + @Override + public Uni addIntrospection(String token, TokenIntrospection introspection, OidcTenantConfig oidcConfig, + AddIntrospectionRequestContext requestContext) { + if (maxSize > 0) { + CacheEntry entry = findValidCacheEntry(token); + if (entry != null) { + entry.introspection = introspection; + } else if (prepareSpaceForNewCacheEntry()) { + cacheMap.put(token, new CacheEntry(introspection)); + } + } + + return CodeAuthenticationMechanism.VOID_UNI; + } + + @Override + public Uni getIntrospection(String token, OidcTenantConfig oidcConfig, + GetIntrospectionRequestContext requestContext) { + CacheEntry entry = findValidCacheEntry(token); + return entry == null ? NULL_INTROSPECTION_UNI : Uni.createFrom().item(entry.introspection); + } + + @Override + public Uni addUserInfo(String token, UserInfo userInfo, OidcTenantConfig oidcConfig, + AddUserInfoRequestContext requestContext) { + if (maxSize > 0) { + CacheEntry entry = findValidCacheEntry(token); + if (entry != null) { + entry.userInfo = userInfo; + } else if (prepareSpaceForNewCacheEntry()) { + cacheMap.put(token, new CacheEntry(userInfo)); + } + } + + return CodeAuthenticationMechanism.VOID_UNI; + } + + @Override + public Uni getUserInfo(String token, OidcTenantConfig oidcConfig, + GetUserInfoRequestContext requestContext) { + CacheEntry entry = findValidCacheEntry(token); + return entry == null ? NULL_USERINFO_UNI : Uni.createFrom().item(entry.userInfo); + } + + public int getCacheSize() { + return cacheMap.size(); + } + + public void clearCache() { + cacheMap.clear(); + } + + private void removeInvalidEntries(boolean removeAll) { + long now = now(); + for (Iterator> it = cacheMap.entrySet().iterator(); it.hasNext();) { + Map.Entry next = it.next(); + if (isEntryExpired(next.getValue(), now)) { + it.remove(); + if (!removeAll) { + break; + } + } + } + } + + private boolean prepareSpaceForNewCacheEntry() { + if (cacheMap.size() >= maxSize) { + removeInvalidEntries(false); + } + return cacheMap.size() < maxSize; + } + + private CacheEntry findValidCacheEntry(String token) { + CacheEntry entry = cacheMap.get(token); + if (entry != null) { + long now = now(); + if (isEntryExpired(entry, now)) { + // Entry has expired, remote introspection will be required + entry = null; + cacheMap.remove(token); + } + } + return entry; + } + + private boolean isEntryExpired(CacheEntry entry, long now) { + return entry.createdTime + timeToLive.toMillis() < now; + } + + private static long now() { + return System.currentTimeMillis(); + } + + private class CacheEntry { + volatile TokenIntrospection introspection; + volatile UserInfo userInfo; + long createdTime = System.currentTimeMillis(); + + public CacheEntry(TokenIntrospection introspection) { + this.introspection = introspection; + } + + public CacheEntry(UserInfo userInfo) { + this.userInfo = userInfo; + } + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfig.java index 828c6393804d3..84d2caca749de 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfig.java @@ -1,10 +1,13 @@ package io.quarkus.oidc.runtime; +import java.time.Duration; import java.util.Map; +import java.util.Optional; import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.runtime.annotations.ConfigDocMapKey; import io.quarkus.runtime.annotations.ConfigDocSection; +import io.quarkus.runtime.annotations.ConfigGroup; import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; @@ -25,4 +28,36 @@ public class OidcConfig { @ConfigDocMapKey("tenant") @ConfigItem(name = ConfigItem.PARENT) public Map namedTenants; + + /** + * Default TokenIntrospection and UserInfo Cache configuration which is used for all the tenants if it is enabled. + */ + @ConfigItem + public TokenCache tokenCache = new TokenCache(); + + /** + * Default TokenIntrospection and UserInfo cache configuration. + */ + @ConfigGroup + public static class TokenCache { + /** + * Maximum number of cache entries. + * Set it to a positive value if the cache has to be enabled. + */ + @ConfigItem(defaultValue = "0") + public int maxSize = 0; + + /** + * Maximum amount of time a given cache entry is valid for. + */ + @ConfigItem(defaultValue = "3M") + public Duration timeToLive = Duration.ofMinutes(3); + + /** + * Clean up timer interval. + * If this property is set then a timer will check and remove the stale entries periodically. + */ + @ConfigItem + public Optional cleanUpTimerInterval = Optional.empty(); + } } 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 85db5680b3043..c1a147720887a 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 @@ -17,6 +17,10 @@ import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.OidcTenantConfig.Roles.Source; import io.quarkus.oidc.OidcTokenCredential; +import io.quarkus.oidc.TokenIntrospection; +import io.quarkus.oidc.TokenIntrospectionCache; +import io.quarkus.oidc.UserInfo; +import io.quarkus.oidc.UserInfoCache; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.credential.TokenCredential; @@ -36,12 +40,17 @@ public class OidcIdentityProvider implements IdentityProvider NULL_CODE_ACCESS_TOKEN_UNI = Uni.createFrom().nullItem(); - private static final Uni NULL_USER_INFO_UNI = Uni.createFrom().nullItem(); + private static final Uni NULL_USER_INFO_UNI = Uni.createFrom().nullItem(); private static final String CODE_ACCESS_TOKEN_RESULT = "code_flow_access_token_result"; @Inject DefaultTenantConfigResolver tenantResolver; + private AddIntrospectionRequestContext addIntrospectionRequestContext = new AddIntrospectionRequestContext(); + private GetIntrospectionRequestContext getIntrospectionRequestContext = new GetIntrospectionRequestContext(); + private AddUserInfoRequestContext addUserInfoRequestContext = new AddUserInfoRequestContext(); + private GetUserInfoRequestContext getUserInfoRequestContext = new GetUserInfoRequestContext(); + @Override public Class getRequestType() { return TokenAuthenticationRequest.class; @@ -105,12 +114,14 @@ private Uni validateTokenWithOidcServer(RoutingContext vertxCo vertxContext.put(CODE_ACCESS_TOKEN_RESULT, codeAccessTokenResult); } - Uni userInfo = getUserInfoUni(vertxContext, request, resolvedContext); + Uni userInfo = resolvedContext.oidcConfig.authentication.isUserInfoRequired() + ? getUserInfoUni(vertxContext, request, resolvedContext) + : NULL_USER_INFO_UNI; return userInfo.onItemOrFailure().transformToUni( - new BiFunction>() { + new BiFunction>() { @Override - public Uni apply(JsonObject userInfo, Throwable t) { + public Uni apply(UserInfo userInfo, Throwable t) { if (t != null) { return Uni.createFrom().failure(new AuthenticationFailedException(t)); } @@ -120,7 +131,7 @@ public Uni apply(JsonObject userInfo, Throwable t) { } private Uni createSecurityIdentityWithOidcServer(RoutingContext vertxContext, - TokenAuthenticationRequest request, TenantConfigContext resolvedContext, final JsonObject userInfo) { + TokenAuthenticationRequest request, TenantConfigContext resolvedContext, final UserInfo userInfo) { Uni tokenUni = verifyTokenUni(resolvedContext, request.getToken().getToken()); @@ -190,7 +201,8 @@ public String getName() { } } if (userInfo != null) { - OidcUtils.setSecurityIdentityRoles(builder, resolvedContext.oidcConfig, userInfo); + OidcUtils.setSecurityIdentityRoles(builder, resolvedContext.oidcConfig, + new JsonObject(userInfo.getJsonObject().toString())); } OidcUtils.setBlockinApiAttribute(builder, vertxContext); OidcUtils.setTenantIdAttribute(builder, resolvedContext.oidcConfig); @@ -219,11 +231,11 @@ private static boolean tokenAutoRefreshPrepared(JsonObject tokenJson, RoutingCon private static JsonObject getRolesJson(RoutingContext vertxContext, TenantConfigContext resolvedContext, TokenCredential tokenCred, - JsonObject tokenJson, JsonObject userInfo) { + JsonObject tokenJson, UserInfo userInfo) { JsonObject rolesJson = tokenJson; if (resolvedContext.oidcConfig.roles.source.isPresent()) { if (resolvedContext.oidcConfig.roles.source.get() == Source.userinfo) { - rolesJson = userInfo; + rolesJson = new JsonObject(userInfo.getJsonObject().toString()); } else if (tokenCred instanceof IdTokenCredential && resolvedContext.oidcConfig.roles.source.get() == Source.accesstoken) { rolesJson = ((TokenVerificationResult) vertxContext.get(CODE_ACCESS_TOKEN_RESULT)).localVerificationResult; @@ -282,7 +294,32 @@ private Uni refreshJwksAndVerifyTokenUni(TenantConfigCo } private Uni introspectTokenUni(TenantConfigContext resolvedContext, String token) { - return resolvedContext.provider.introspectToken(token); + Uni tokenIntrospectionUni = tenantResolver.getTokenIntrospectionCache() + .getIntrospection(token, resolvedContext.oidcConfig, getIntrospectionRequestContext); + if (tokenIntrospectionUni == null) { + tokenIntrospectionUni = newTokenIntrospectionUni(resolvedContext, token); + } else { + tokenIntrospectionUni = tokenIntrospectionUni.onItem().ifNull() + .switchTo(newTokenIntrospectionUni(resolvedContext, token)); + } + return tokenIntrospectionUni.onItem().transform(t -> new TokenVerificationResult(null, t)); + } + + private Uni newTokenIntrospectionUni(TenantConfigContext resolvedContext, String token) { + return resolvedContext.provider.introspectToken(token).call(new Function>() { + + @Override + public Uni apply(TokenIntrospection introspection) { + if (resolvedContext.oidcConfig.allowTokenIntrospectionCache) { + return tenantResolver.getTokenIntrospectionCache().addIntrospection(token, introspection, + resolvedContext.oidcConfig, addIntrospectionRequestContext); + } else { + return CodeAuthenticationMechanism.VOID_UNI; + } + + } + + }); } private static Uni validateTokenWithoutOidcServer(TokenAuthenticationRequest request, @@ -298,13 +335,54 @@ private static Uni validateTokenWithoutOidcServer(TokenAuthent } } - private Uni getUserInfoUni(RoutingContext vertxContext, TokenAuthenticationRequest request, + private Uni getUserInfoUni(RoutingContext vertxContext, TokenAuthenticationRequest request, TenantConfigContext resolvedContext) { - if (resolvedContext.oidcConfig.authentication.isUserInfoRequired()) { - return resolvedContext.provider.getUserInfo(vertxContext, request); + String accessToken = vertxContext.get(OidcConstants.ACCESS_TOKEN_VALUE); + if (accessToken == null) { + accessToken = request.getToken().getToken(); + } + + Uni userInfoUni = tenantResolver.getUserInfoCache() + .getUserInfo(accessToken, resolvedContext.oidcConfig, getUserInfoRequestContext); + if (userInfoUni == null) { + userInfoUni = newUserInfoUni(resolvedContext, accessToken); } else { - return NULL_USER_INFO_UNI; + userInfoUni = userInfoUni.onItem().ifNull() + .switchTo(newUserInfoUni(resolvedContext, accessToken)); } + return userInfoUni; + } + + private Uni newUserInfoUni(TenantConfigContext resolvedContext, String accessToken) { + return resolvedContext.provider.getUserInfo(accessToken).call(new Function>() { + + @Override + public Uni apply(UserInfo userInfo) { + if (resolvedContext.oidcConfig.allowUserInfoCache) { + return tenantResolver.getUserInfoCache().addUserInfo(accessToken, userInfo, + resolvedContext.oidcConfig, addUserInfoRequestContext); + } else { + return CodeAuthenticationMechanism.VOID_UNI; + } + + } + + }); } + private static class AddIntrospectionRequestContext extends AbstractBlockingTaskRunner + implements TokenIntrospectionCache.AddIntrospectionRequestContext { + } + + private static class GetIntrospectionRequestContext extends AbstractBlockingTaskRunner + implements TokenIntrospectionCache.GetIntrospectionRequestContext { + } + + private static class AddUserInfoRequestContext extends AbstractBlockingTaskRunner + implements UserInfoCache.AddUserInfoRequestContext { + } + + private static class GetUserInfoRequestContext extends AbstractBlockingTaskRunner + implements UserInfoCache.GetUserInfoRequestContext { + } } 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 49eb2fbb5bbd5..d06c0bcb4fd07 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 @@ -23,14 +23,12 @@ 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.runtime.OidcConstants; import io.quarkus.security.AuthenticationFailedException; -import io.quarkus.security.identity.request.TokenAuthenticationRequest; import io.smallrye.jwt.algorithm.SignatureAlgorithm; import io.smallrye.jwt.util.KeyUtils; import io.smallrye.mutiny.Uni; -import io.vertx.core.json.JsonObject; -import io.vertx.ext.web.RoutingContext; public class OidcProvider implements Closeable { @@ -144,7 +142,7 @@ public Uni apply(Void v) { }); } - public Uni introspectToken(String token) { + public Uni introspectToken(String token) { if (client.getMetadata().getIntrospectionUri() == null) { LOG.debugf( "Token issued to client %s can not be introspected because the introspection endpoint address is unknown - " @@ -153,10 +151,10 @@ public Uni introspectToken(String token) { throw new AuthenticationFailedException(); } return client.introspectToken(token).onItemOrFailure() - .transform(new BiFunction() { + .transform(new BiFunction() { @Override - public TokenVerificationResult apply(TokenIntrospection introspectionResult, Throwable t) { + public TokenIntrospection apply(TokenIntrospection introspectionResult, Throwable t) { if (t != null) { throw new AuthenticationFailedException(t); } @@ -175,17 +173,13 @@ public TokenVerificationResult apply(TokenIntrospection introspectionResult, Thr } } - return new TokenVerificationResult(null, introspectionResult); + return introspectionResult; } }); } - public Uni getUserInfo(RoutingContext vertxContext, TokenAuthenticationRequest request) { - String accessToken = vertxContext.get(OidcConstants.ACCESS_TOKEN_VALUE); - if (accessToken == null) { - accessToken = request.getToken().getToken(); - } + public Uni getUserInfo(String accessToken) { return client.getUserInfo(accessToken); } 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 01b9363f59de6..af9bdf1b7ae2c 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 @@ -12,6 +12,7 @@ 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.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; import io.quarkus.oidc.common.runtime.OidcEndpointAccessException; @@ -55,7 +56,7 @@ public Uni getJsonWebKeySet() { .transform(resp -> getJsonWebKeySet(resp)); } - public Uni getUserInfo(String token) { + public Uni getUserInfo(String token) { return client.getAbs(metadata.getUserInfoUri()) .putHeader(AUTHORIZATION_HEADER, OidcConstants.BEARER_SCHEME + " " + token) .send().onItem().transform(resp -> getUserInfo(resp)); @@ -126,8 +127,8 @@ private AuthorizationCodeTokens getAuthorizationCodeTokens(HttpResponse return new AuthorizationCodeTokens(idToken, accessToken, refreshToken); } - private JsonObject getUserInfo(HttpResponse resp) { - return getJsonObject(resp); + private UserInfo getUserInfo(HttpResponse resp) { + return new UserInfo(getString(resp)); } private TokenIntrospection getTokenIntrospection(HttpResponse resp) { 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 7b4fe1eef1e4c..40457d081d364 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 @@ -142,7 +142,7 @@ private static List convertJsonArrayToList(JsonArray claimValue) { static QuarkusSecurityIdentity validateAndCreateIdentity( RoutingContext vertxContext, TokenCredential credential, - TenantConfigContext resolvedContext, JsonObject tokenJson, JsonObject rolesJson, JsonObject userInfo) { + TenantConfigContext resolvedContext, JsonObject tokenJson, JsonObject rolesJson, UserInfo userInfo) { OidcTenantConfig config = resolvedContext.oidcConfig; QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); @@ -195,9 +195,9 @@ public static void setTenantIdAttribute(QuarkusSecurityIdentity.Builder builder, builder.addAttribute(TENANT_ID_ATTRIBUTE, config.tenantId.orElse("Default")); } - public static void setSecurityIdentityUserInfo(QuarkusSecurityIdentity.Builder builder, JsonObject userInfo) { + public static void setSecurityIdentityUserInfo(QuarkusSecurityIdentity.Builder builder, UserInfo userInfo) { if (userInfo != null) { - builder.addAttribute(USER_INFO_ATTRIBUTE, new UserInfo(userInfo.encode())); + builder.addAttribute(USER_INFO_ATTRIBUTE, userInfo); } } diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CacheResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CacheResource.java new file mode 100644 index 0000000000000..a1bcca6c36718 --- /dev/null +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CacheResource.java @@ -0,0 +1,26 @@ +package io.quarkus.it.keycloak; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; + +@Path("cache") +public class CacheResource { + + @Inject + CustomIntrospectionUserInfoCache tokenCache; + + @POST + @Path("clear") + public int clear() { + tokenCache.clearCache(); + return tokenCache.getCacheSize(); + } + + @GET + @Path("size") + public int size() { + return tokenCache.getCacheSize(); + } +} diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomIntrospectionUserInfoCache.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomIntrospectionUserInfoCache.java new file mode 100644 index 0000000000000..bea56fd2284e6 --- /dev/null +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomIntrospectionUserInfoCache.java @@ -0,0 +1,52 @@ +package io.quarkus.it.keycloak; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +import io.quarkus.arc.AlternativePriority; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.TokenIntrospection; +import io.quarkus.oidc.TokenIntrospectionCache; +import io.quarkus.oidc.UserInfo; +import io.quarkus.oidc.UserInfoCache; +import io.quarkus.oidc.runtime.DefaultTokenIntrospectionUserInfoCache; +import io.smallrye.mutiny.Uni; + +@ApplicationScoped +@AlternativePriority(1) +public class CustomIntrospectionUserInfoCache implements TokenIntrospectionCache, UserInfoCache { + @Inject + DefaultTokenIntrospectionUserInfoCache tokenCache; + + @Override + public Uni addIntrospection(String token, TokenIntrospection introspection, OidcTenantConfig oidcConfig, + AddIntrospectionRequestContext requestContext) { + return tokenCache.addIntrospection(token, introspection, oidcConfig, requestContext); + } + + @Override + public Uni getIntrospection(String token, OidcTenantConfig oidcConfig, + GetIntrospectionRequestContext requestContext) { + return tokenCache.getIntrospection(token, oidcConfig, requestContext); + } + + @Override + public Uni addUserInfo(String token, UserInfo userInfo, OidcTenantConfig oidcConfig, + AddUserInfoRequestContext requestContext) { + return tokenCache.addUserInfo(token, userInfo, oidcConfig, requestContext); + } + + @Override + public Uni getUserInfo(String token, OidcTenantConfig oidcConfig, + GetUserInfoRequestContext requestContext) { + return tokenCache.getUserInfo(token, oidcConfig, requestContext); + } + + public int getCacheSize() { + return tokenCache.getCacheSize(); + } + + public void clearCache() { + tokenCache.clearCache(); + } +} \ No newline at end of file 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 46719187af055..fa8f31f489be2 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 @@ -36,6 +36,7 @@ public OidcTenantConfig get() { config.getCredentials().setSecret("secret"); config.getToken().setIssuer(getIssuerUrl() + "/realms/quarkus-d"); config.getAuthentication().setUserInfoRequired(true); + config.setAllowUserInfoCache(false); return config; } else if ("tenant-oidc".equals(tenantId)) { OidcTenantConfig config = new OidcTenantConfig(); @@ -46,6 +47,7 @@ public OidcTenantConfig get() { : uri.replace("/tenant/tenant-oidc/api/user", "/oidc"); config.setAuthServerUrl(authServerUri); config.setClientId("client"); + config.setAllowTokenIntrospectionCache(false); return config; } else if ("tenant-oidc-no-discovery".equals(tenantId)) { OidcTenantConfig config = new OidcTenantConfig(); @@ -66,14 +68,21 @@ public OidcTenantConfig get() { config.token.setAllowJwtIntrospection(false); config.setClientId("client"); return config; - } else if ("tenant-oidc-introspection-only".equals(tenantId)) { + } else if ("tenant-oidc-introspection-only".equals(tenantId) + || "tenant-oidc-introspection-only-cache".equals(tenantId)) { OidcTenantConfig config = new OidcTenantConfig(); - config.setTenantId("tenant-oidc-introspection-only"); + config.setTenantId(tenantId); String uri = context.request().absoluteURI(); - String authServerUri = uri.replace("/tenant/tenant-oidc-introspection-only/api/user", "/oidc"); + String authServerUri = uri.replace("/tenant/" + tenantId + "/api/user", "/oidc"); config.setAuthServerUrl(authServerUri); config.setDiscoveryEnabled(false); + config.authentication.setUserInfoRequired(true); + if ("tenant-oidc-introspection-only".equals(tenantId)) { + config.setAllowTokenIntrospectionCache(false); + config.setAllowUserInfoCache(false); + } config.setIntrospectionPath("introspect"); + config.setUserInfoPath("userinfo"); config.setClientId("client"); return config; } else if ("tenant-oidc-no-opaque-token".equals(tenantId)) { @@ -93,6 +102,7 @@ public OidcTenantConfig get() { config.getCredentials().setSecret("secret"); config.getAuthentication().setUserInfoRequired(true); config.getRoles().setSource(Source.userinfo); + config.setAllowUserInfoCache(false); config.setApplicationType(ApplicationType.WEB_APP); return config; } 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 1337c00db6e4d..7cdbc4d39d09d 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 @@ -25,6 +25,7 @@ public class OidcResource { private volatile boolean rotate; private volatile int jwkEndpointCallCount; private volatile int introspectionEndpointCallCount; + private volatile int userInfoEndpointCallCount; @PostConstruct public void init() throws Exception { @@ -42,6 +43,7 @@ public String discovery() { return "{" + " \"token_endpoint\":" + "\"" + baseUri + "/token\"," + " \"introspection_endpoint\":" + "\"" + baseUri + "/introspect\"," + + " \"userinfo_endpoint\":" + "\"" + baseUri + "/userinfo\"," + " \"jwks_uri\":" + "\"" + baseUri + "/jwks\"" + " }"; } @@ -101,6 +103,30 @@ public String introspect() { " }"; } + @GET + @Path("userinfo-endpoint-call-count") + public int userInfoEndpointCallCount() { + return userInfoEndpointCallCount; + } + + @POST + @Path("userinfo-endpoint-call-count") + public int resetUserInfoEndpointCallCount() { + userInfoEndpointCallCount = 0; + return userInfoEndpointCallCount; + } + + @GET + @Produces("application/json") + @Path("userinfo") + public String userinfo() { + userInfoEndpointCallCount++; + + return "{" + + " \"preferred_username\": \"alice\"" + + " }"; + } + @POST @Path("token") @Produces("application/json") 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 08a9e6573719b..758140bc4b211 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 @@ -28,6 +28,9 @@ public class TenantResource { @Inject AccessTokenCredential accessTokenCred; + @Inject + CustomIntrospectionUserInfoCache tokenCache; + @Inject @IdToken JsonWebToken idToken; @@ -48,7 +51,12 @@ public String userNameService(@PathParam("tenant") String tenant) { name = name + "." + userInfo.getString(Claims.preferred_username.name()); } } - return tenant + ":" + name; + + String response = tenant + ":" + name; + if (tenant.startsWith("tenant-oidc-introspection-only")) { + response += (":" + tokenCache.getCacheSize()); + } + return response; } @GET diff --git a/integration-tests/oidc-tenancy/src/main/resources/application.properties b/integration-tests/oidc-tenancy/src/main/resources/application.properties index 53b83fa9c91d8..b5b68a95062fe 100644 --- a/integration-tests/oidc-tenancy/src/main/resources/application.properties +++ b/integration-tests/oidc-tenancy/src/main/resources/application.properties @@ -1,5 +1,7 @@ quarkus.http.cors=true +quarkus.oidc.token-cache.max-size=3 + # Default Tenant quarkus.oidc.auth-server-url=${keycloak.url}/realms/quarkus-a quarkus.oidc.client-id=quarkus-app-a @@ -22,6 +24,8 @@ quarkus.oidc.tenant-b-no-discovery.auth-server-url=${keycloak.url}/realms/quarku quarkus.oidc.tenant-b-no-discovery.discovery-enabled=false quarkus.oidc.tenant-b-no-discovery.user-info-path=/protocol/openid-connect/userinfo quarkus.oidc.tenant-b-no-discovery.introspection-path=protocol/openid-connect/token/introspect +quarkus.oidc.tenant-b-no-discovery.allow-token-introspection-cache=false +quarkus.oidc.tenant-b-no-discovery.allow-user-info-cache=false quarkus.oidc.tenant-b-no-discovery.client-id=quarkus-app-b quarkus.oidc.tenant-b-no-discovery.credentials.secret=secret quarkus.oidc.tenant-b-no-discovery.application-type=service @@ -41,6 +45,7 @@ quarkus.oidc.tenant-web-app.credentials.secret=secret quarkus.oidc.tenant-web-app.application-type=web-app quarkus.oidc.tenant-web-app.authentication.user-info-required=true quarkus.oidc.tenant-web-app.roles.source=userinfo +quarkus.oidc.tenant-web-app.allow-user-info-cache=false # Tenant Web App No Discovery (Introspection + User Info) quarkus.oidc.tenant-web-app-no-discovery.auth-server-url=${keycloak.url}/realms/quarkus-webapp @@ -49,12 +54,13 @@ quarkus.oidc.tenant-web-app-no-discovery.authorization-path=/protocol/openid-con quarkus.oidc.tenant-web-app-no-discovery.token-path=/protocol/openid-connect/token quarkus.oidc.tenant-web-app-no-discovery.user-info-path=/protocol/openid-connect/userinfo quarkus.oidc.tenant-web-app-no-discovery.introspection-path=protocol/openid-connect/token/introspect -#quarkus.oidc.tenant-web-app-no-discovery.jwks-path=/protocol/openid-connect/certs +quarkus.oidc.tenant-web-app-no-discovery.allow-token-introspection-cache=false quarkus.oidc.tenant-web-app-no-discovery.client-id=quarkus-app-webapp quarkus.oidc.tenant-web-app-no-discovery.credentials.secret=secret quarkus.oidc.tenant-web-app-no-discovery.application-type=web-app quarkus.oidc.tenant-web-app-no-discovery.authentication.user-info-required=true quarkus.oidc.tenant-web-app-no-discovery.roles.source=userinfo +quarkus.oidc.tenant-web-app-no-discovery.allow-user-info-cache=false # Tenant Web App2 quarkus.oidc.tenant-web-app2.auth-server-url=${keycloak.url}/realms/quarkus-webapp2 @@ -95,3 +101,4 @@ quarkus.oidc.tenant-public-key.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCg smallrye.jwt.sign.key.location=/privateKey.pem quarkus.log.category."com.gargoylesoftware.htmlunit.javascript.host.css.CSSStyleSheet".level=FATAL +quarkus.http.auth.proactive=false \ No newline at end of file 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 cc89520b75acb..8929327b86897 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 @@ -2,6 +2,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -305,6 +306,7 @@ public void testSimpleOidcJwtWithJwkRefresh() { RestAssured.when().get("/oidc/jwk-endpoint-call-count").then().body(equalTo("2")); // both requests with kid `3` and with the opaque token required the remote introspection RestAssured.when().get("/oidc/introspection-endpoint-call-count").then().body(equalTo("3")); + RestAssured.when().post("/oidc/disable-introspection").then().body(equalTo("false")); RestAssured.when().post("/oidc/disable-rotate").then().body(equalTo("false")); } @@ -338,23 +340,76 @@ public void testJwtTokenIntrospectionDisallowed() { // JWT introspection is disallowed RestAssured.when().get("/oidc/introspection-endpoint-call-count").then().body(equalTo("0")); RestAssured.when().post("/oidc/disable-rotate").then().body(equalTo("false")); + RestAssured.when().post("/oidc/disable-introspection").then().body(equalTo("false")); } @Test - public void testJwtTokenIntrospectionOnly() { + public void testJwtTokenIntrospectionOnlyAndUserInfo() { RestAssured.when().post("/oidc/jwk-endpoint-call-count").then().body(equalTo("0")); RestAssured.when().post("/oidc/introspection-endpoint-call-count").then().body(equalTo("0")); + RestAssured.when().post("/oidc/userinfo-endpoint-call-count").then().body(equalTo("0")); RestAssured.when().post("/oidc/enable-introspection").then().body(equalTo("true")); + RestAssured.when().post("/cache/clear").then().body(equalTo("0")); + + // Caching token introspection and userinfo is not allowed for this tenant, + // 3 calls to introspection and user info endpoints are expected. + // Cache size must stay 0. + for (int i = 0; i < 3; i++) { + // unique token is created each time + RestAssured.given().auth().oauth2(getAccessTokenFromSimpleOidc("2")) + .when().get("/tenant/tenant-oidc-introspection-only/api/user") + .then() + .statusCode(200) + .body(equalTo("tenant-oidc-introspection-only:alice:0")); + } - // JWK is available now in Quarkus OIDC, confirm that no timeout is needed - RestAssured.given().auth().oauth2(getAccessTokenFromSimpleOidc("2")) - .when().get("/tenant/tenant-oidc-introspection-only/api/user") - .then() - .statusCode(200) - .body(equalTo("tenant-oidc-introspection-only:alice")); + RestAssured.when().get("/oidc/jwk-endpoint-call-count").then().body(equalTo("0")); + RestAssured.when().get("/oidc/introspection-endpoint-call-count").then().body(equalTo("3")); + RestAssured.when().post("/oidc/disable-introspection").then().body(equalTo("false")); + RestAssured.when().get("/oidc/userinfo-endpoint-call-count").then().body(equalTo("3")); + RestAssured.when().get("/cache/size").then().body(equalTo("0")); + } + + @Test + public void testJwtTokenIntrospectionOnlyAndUserInfoCache() { + RestAssured.when().post("/oidc/jwk-endpoint-call-count").then().body(equalTo("0")); + RestAssured.when().post("/oidc/introspection-endpoint-call-count").then().body(equalTo("0")); + RestAssured.when().post("/oidc/userinfo-endpoint-call-count").then().body(equalTo("0")); + RestAssured.when().post("/oidc/enable-introspection").then().body(equalTo("true")); + RestAssured.when().get("/cache/size").then().body(equalTo("0")); + + // Max cache size is 3 + String token1 = getAccessTokenFromSimpleOidc("2"); + // 3 calls are made, only 1 call to introspection and user info endpoints is expected, and only one entry in the cache is expected + verifyTokenIntrospectionAndUserInfoAreCached(token1, 1); + String token2 = getAccessTokenFromSimpleOidc("2"); + assertNotEquals(token1, token2); + // next 3 calls are made, only 1 call to introspection and user info endpoints is expected, and only two entries in the cache are expected + verifyTokenIntrospectionAndUserInfoAreCached(token2, 2); + String token3 = getAccessTokenFromSimpleOidc("2"); + assertNotEquals(token1, token3); + assertNotEquals(token2, token3); + // next 3 calls are made, only 1 call to introspection and user info endpoints is expected, and only three entries in the cache are expected + verifyTokenIntrospectionAndUserInfoAreCached(token3, 3); RestAssured.when().get("/oidc/jwk-endpoint-call-count").then().body(equalTo("0")); + RestAssured.when().post("/oidc/disable-introspection").then().body(equalTo("false")); + RestAssured.when().get("/cache/size").then().body(equalTo("3")); + } + + private void verifyTokenIntrospectionAndUserInfoAreCached(String token1, int expectedCacheSize) { + // Each token is unique, each sequence of 3 calls should only result in a single introspection endpoint call + for (int i = 0; i < 3; i++) { + RestAssured.given().auth().oauth2(token1) + .when().get("/tenant/tenant-oidc-introspection-only-cache/api/user") + .then() + .statusCode(200) + .body(equalTo("tenant-oidc-introspection-only-cache:alice:" + expectedCacheSize)); + } RestAssured.when().get("/oidc/introspection-endpoint-call-count").then().body(equalTo("1")); + RestAssured.when().post("/oidc/introspection-endpoint-call-count").then().body(equalTo("0")); + RestAssured.when().get("/oidc/userinfo-endpoint-call-count").then().body(equalTo("1")); + RestAssured.when().post("/oidc/userinfo-endpoint-call-count").then().body(equalTo("0")); } @Test