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 5d0dae14c6469..c5934c6b01268 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 @@ -153,7 +153,7 @@ public Uni get() { private Uni initializeStaticTenantIfContextNotReady(TenantConfigContext tenantContext) { requireNonNull(tenantContext, "tenantContext must never be null"); - if (!tenantContext.ready) { + if (!tenantContext.isReady()) { return tenantConfigBean.getOrCreateTenantContext(tenantContext.oidcConfig, false); } 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 7980b899d887e..f6f949583a00a 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 @@ -3,6 +3,7 @@ import java.time.Duration; import java.util.Map; import java.util.Optional; +import java.util.OptionalInt; import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.runtime.annotations.ConfigDocMapKey; @@ -43,6 +44,13 @@ public class OidcConfig { @ConfigItem(defaultValue = "false") public boolean resolveTenantsWithIssuer; + /** + * If configured, limit the number of active dynamic OIDC tenant contexts to this value. + * If not set, the number of active dynamic OIDC tenant contexts is unlimited. + */ + @ConfigItem + public OptionalInt dynamicTenantLimit; + /** * Default TokenIntrospection and UserInfo cache configuration. */ 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 ebc30e803d356..e723915f81f14 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 @@ -16,6 +16,7 @@ import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.LongSupplier; import java.util.function.Supplier; import jakarta.enterprise.inject.CreationException; @@ -123,7 +124,13 @@ public TenantConfigBean setup(OidcConfig config, Vertx vertxValue, TlsConfigurat createStaticTenantContext(vertxValue, tenant.getValue(), false, tenant.getKey(), defaultTlsConfiguration)); } - return new TenantConfigBean(staticTenantsConfig, defaultTenantContext, + return new TenantConfigBean(staticTenantsConfig, defaultTenantContext, new LongSupplier() { + @Override + public long getAsLong() { + return System.nanoTime(); + } + }, + Math.max(0, config.dynamicTenantLimit.orElse(0)), new TenantConfigBean.TenantContextFactory() { @Override public Uni create(OidcTenantConfig oidcConfig, boolean dynamicTenant, @@ -157,14 +164,14 @@ public TenantConfigContext apply(Throwable t) { + " Access to resources protected by this tenant may fail" + " if OIDC server will not become available", tenantId, t.getMessage()); - return new TenantConfigContext(null, oidcConfig, false); + return TenantConfigContext.notReadyContext(oidcConfig); } logTenantConfigContextFailure(t, tenantId); if (t instanceof ConfigurationException && oidcConfig.authServerUrl.isEmpty() && LaunchMode.DEVELOPMENT == LaunchMode.current()) { // Let it start if it is a DEV mode and auth-server-url has not been configured yet - return new TenantConfigContext(null, oidcConfig, false); + return TenantConfigContext.notReadyContext(oidcConfig); } // fail in all other cases throw new OIDCException(t); @@ -175,7 +182,7 @@ public TenantConfigContext apply(Throwable t) { LOG.warnf("Tenant '%s': OIDC server is not available after a %d seconds timeout, an attempt to connect will be made" + " during the first request. Access to resources protected by this tenant may fail if OIDC server" + " will not become available", tenantId, oidcConfig.getConnectionTimeout().getSeconds()); - return new TenantConfigContext(null, oidcConfig, false); + return TenantConfigContext.notReadyContext(oidcConfig); } } @@ -197,7 +204,8 @@ private Uni createTenantContext(Vertx vertx, OidcTenantConf if (!oidcConfig.tenantEnabled) { LOG.debugf("'%s' tenant configuration is disabled", tenantId); - return Uni.createFrom().item(new TenantConfigContext(new OidcProvider(null, null, null, null), oidcConfig)); + return Uni.createFrom() + .item(TenantConfigContext.readyContext(new OidcProvider(null, null, null, null), oidcConfig)); } if (oidcConfig.getAuthServerUrl().isEmpty()) { @@ -224,7 +232,7 @@ private Uni createTenantContext(Vertx vertx, OidcTenantConf + " or named tenants are configured."); oidcConfig.setTenantEnabled(false); return Uni.createFrom() - .item(new TenantConfigContext(new OidcProvider(null, null, null, null), oidcConfig)); + .item(TenantConfigContext.readyContext(new OidcProvider(null, null, null, null), oidcConfig)); } } throw new ConfigurationException( @@ -350,7 +358,7 @@ private Uni createTenantContext(Vertx vertx, OidcTenantConf .onItem().transform(new Function() { @Override public TenantConfigContext apply(OidcProvider p) { - return new TenantConfigContext(p, oidcConfig); + return TenantConfigContext.readyContext(p, oidcConfig); } }); } @@ -380,7 +388,7 @@ private static TenantConfigContext createTenantContextFromPublicKey(OidcTenantCo LOG.debug("'public-key' property for the local token verification is set," + " no connection to the OIDC server will be created"); - return new TenantConfigContext( + return TenantConfigContext.readyContext( new OidcProvider(oidcConfig.publicKey.orElseThrow(), oidcConfig, readTokenDecryptionKey(oidcConfig)), oidcConfig); } @@ -391,7 +399,7 @@ private static TenantConfigContext createTenantContextToVerifyCertChain(OidcTena "Currently only 'service' applications can be used to verify tokens with inlined certificate chains"); } - return new TenantConfigContext( + return TenantConfigContext.readyContext( new OidcProvider(null, oidcConfig, readTokenDecryptionKey(oidcConfig)), oidcConfig); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigBean.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigBean.java index 174e84cc1cf70..adbec445309b8 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigBean.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigBean.java @@ -4,7 +4,9 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; +import java.util.function.LongSupplier; import jakarta.enterprise.context.spi.CreationalContext; @@ -26,6 +28,10 @@ public class TenantConfigBean { private final Map dynamicTenantsConfig; private final TenantConfigContext defaultTenant; private final TenantContextFactory tenantContextFactory; + private final LongSupplier clock; + private final int dynamicTenantLimit; + // VisibleForTesting + final AtomicBoolean tenantEvictionRunning = new AtomicBoolean(false); @FunctionalInterface public interface TenantContextFactory { @@ -35,9 +41,13 @@ public interface TenantContextFactory { TenantConfigBean( Map staticTenantsConfig, TenantConfigContext defaultTenant, + LongSupplier clock, + int dynamicTenantLimit, TenantContextFactory tenantContextFactory) { this.staticTenantsConfig = new ConcurrentHashMap<>(staticTenantsConfig); this.dynamicTenantsConfig = new ConcurrentHashMap<>(); + this.clock = clock; + this.dynamicTenantLimit = dynamicTenantLimit; this.defaultTenant = defaultTenant; this.tenantContextFactory = tenantContextFactory; } @@ -46,7 +56,7 @@ Uni getOrCreateTenantContext(OidcTenantConfig oidcConfig, b var tenantId = oidcConfig.getTenantId().orElseThrow(); var tenants = dynamicTenant ? dynamicTenantsConfig : staticTenantsConfig; var tenant = tenants.get(tenantId); - if (tenant == null || !tenant.ready) { + if (tenant == null || !tenant.isReady()) { LOG.tracef("Creating %s tenant config for %s", dynamicTenant ? "dynamic" : "static", tenantId); if (dynamicTenant && oidcConfig.logout.backchannel.path.isPresent()) { throw new ConfigurationException( @@ -54,16 +64,27 @@ Uni getOrCreateTenantContext(OidcTenantConfig oidcConfig, b } Uni uniContext = tenantContextFactory.create(oidcConfig, dynamicTenant, tenantId); return uniContext.onItem().transform( - new Function() { + new Function<>() { @Override public TenantConfigContext apply(TenantConfigContext t) { LOG.debugf("Updating %s %s tenant config for %s", dynamicTenant ? "dynamic" : "static", - t.ready ? "ready" : "not-ready", tenantId); - tenants.put(tenantId, t); + t.isReady() ? "ready" : "not-ready", tenantId); + t.lastUsed = clock.getAsLong(); + TenantConfigContext previous = tenants.put(tenantId, t); + if (previous != null) { + // Concurrent calls to createTenantContext may race, better "destroy" the previous + // provider, if there's one. + destroyContext(previous); + } else if (dynamicTenant) { + enforceDynamicTenantLimit(); + } return t; } }); } + if (dynamicTenant) { + tenant.lastUsed = clock.getAsLong(); + } LOG.tracef("Immediately returning ready %s tenant config for %s", dynamicTenant ? "dynamic" : "static", tenantId); return Uni.createFrom().item(tenant); } @@ -94,7 +115,12 @@ public Map getStaticTenantsConfig() { * Returns a dynamic tenant's config context or {@code null}, if the tenant does not exist. */ public TenantConfigContext getDynamicTenantConfigContext(String tenantId) { - return dynamicTenantsConfig.get(tenantId); + TenantConfigContext context = dynamicTenantsConfig.get(tenantId); + if (context == null) { + return null; + } + context.lastUsed = clock.getAsLong(); + return context; } /** @@ -119,24 +145,120 @@ public TenantConfigContext getDefaultTenant() { return defaultTenant; } + static void destroyContext(TenantConfigContext context) { + if (context != null && context.provider != null) { + context.provider.close(); + } + } + public static class Destroyer implements BeanDestroyer { @Override public void destroy(TenantConfigBean instance, CreationalContext creationalContext, Map params) { - if (instance.defaultTenant != null && instance.defaultTenant.provider != null) { - instance.defaultTenant.provider.close(); - } + destroyContext(instance.defaultTenant); for (var i : instance.staticTenantsConfig.values()) { - if (i.provider != null) { - i.provider.close(); - } + destroyContext(i); } for (var i : instance.dynamicTenantsConfig.values()) { - if (i.provider != null) { - i.provider.close(); - } + destroyContext(i); } } } + + record EvictionCandidate(String tenantId, TenantConfigContext context, long lastUsed) { + } + + /** + * Enforces the dynamic tenants limit, if configured. + * + *

+ * Eviction runs at max on one thread at any time. + * + *

+ * Iterate over all tenants at best only once, unless an eviction candidate was used during the eviction run. + */ + private void enforceDynamicTenantLimit() { + int limit = dynamicTenantLimit; + if (limit == 0) { + // No dynamic tenant limit, nothing to do + return; + } + int toEvict = dynamicTenantsConfig.size() - limit; + if (toEvict <= 0) { + // Nothing to evict + return; + } + if (!tenantEvictionRunning.compareAndSet(false, true)) { + // Eviction running in another thread, don't start a concurrent one. + return; + } + try { + do { + EvictionCandidate[] candidates = new EvictionCandidate[toEvict]; + int numCandidates = 0; + // Current max + long maxLastUsed = Long.MAX_VALUE; + + // Collect the required number of tenants to evict by visiting each dynamic tenant + for (Map.Entry e : dynamicTenantsConfig.entrySet()) { + TenantConfigContext c = e.getValue(); + long lastUsed = c.lastUsed; + if (lastUsed >= maxLastUsed) { + // Tenant is too young, skip + continue; + } + + // Found a candidate with a lastUsed less than the current oldest + EvictionCandidate evictionCandidate = new EvictionCandidate(e.getKey(), c, lastUsed); + if (numCandidates < toEvict) { + // Collect until we hit the number of tenants to evict + candidates[numCandidates++] = evictionCandidate; + if (numCandidates == toEvict) { + // Calculate the new max lastUsed from the list of eviction candidates + maxLastUsed = evictionCandidatesMaxLastUsed(candidates); + } + } else { + // Replace the current newest eviction candidate with the current candidate + for (int i = 0; i < numCandidates; i++) { + if (candidates[i].lastUsed == maxLastUsed) { + candidates[i] = evictionCandidate; + break; + } + } + // Recalculate the max lastUsed + maxLastUsed = evictionCandidatesMaxLastUsed(candidates); + } + } + + // Evict the tenants that haven't been used since eviction started + for (EvictionCandidate candidate : candidates) { + // Only evict the tenant, if it hasn't been used since eviction started + evictTenant(candidate); + } + + toEvict = dynamicTenantsConfig.size() - limit; + } while (toEvict > 0); + } finally { + tenantEvictionRunning.set(false); + } + } + + // VisibleForTesting + boolean evictTenant(EvictionCandidate candidate) { + if (candidate != null && candidate.lastUsed == candidate.context.lastUsed) { + dynamicTenantsConfig.remove(candidate.tenantId); + destroyContext(candidate.context); + return true; + } + return false; + } + + private static long evictionCandidatesMaxLastUsed(EvictionCandidate[] candidates) { + long max = 0L; + for (EvictionCandidate candidate : candidates) { + max = Math.max(max, candidate.lastUsed); + } + return max; + } } 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 01fa617414923..5048ae2480282 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 @@ -51,17 +51,23 @@ public class TenantConfigContext { */ private final SecretKey internalIdTokenGeneratedKey; - final boolean ready; + volatile long lastUsed; - public TenantConfigContext(OidcProvider client, OidcTenantConfig config) { - this(client, config, true); + public static TenantConfigContext notReadyContext(OidcTenantConfig config) { + return new TenantConfigContext(null, config, + getRedirectFiltersMap(TenantFeatureFinder.find(config, OidcRedirectFilter.class))); } - public TenantConfigContext(OidcProvider provider, OidcTenantConfig config, boolean ready) { + public static TenantConfigContext readyContext(OidcProvider client, OidcTenantConfig config) { + return new TenantConfigContext(client, config, + getRedirectFiltersMap(TenantFeatureFinder.find(config, OidcRedirectFilter.class))); + } + + TenantConfigContext(OidcProvider provider, OidcTenantConfig config, + Map> redirectFilters) { this.provider = provider; this.oidcConfig = config; - this.redirectFilters = getRedirectFiltersMap(TenantFeatureFinder.find(config, OidcRedirectFilter.class)); - this.ready = ready; + this.redirectFilters = redirectFilters; boolean isService = OidcUtils.isServiceApp(config); stateSecretKey = !isService && provider != null && provider.client != null ? createStateSecretKey(config) : null; @@ -175,6 +181,10 @@ private static SecretKey generateIdTokenSecretKey(OidcTenantConfig config, OidcP } } + public boolean isReady() { + return provider != null; + } + public OidcTenantConfig getOidcTenantConfig() { return oidcConfig; } diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/TenantConfigBeanTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/TenantConfigBeanTest.java new file mode 100644 index 0000000000000..22de55660ddf2 --- /dev/null +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/TenantConfigBeanTest.java @@ -0,0 +1,246 @@ +package io.quarkus.oidc.runtime; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import io.quarkus.oidc.OidcTenantConfig; +import io.smallrye.mutiny.Uni; + +public class TenantConfigBeanTest { + + /** + * Straight forward dynamic-tenant limit test, create 10 tenants - implementation limits the number of dynamic tenants. + */ + @Test + public void dynamicTenantsLimit() { + AtomicLong clock = new AtomicLong(); + + int limit = 3; + + Set evictedTenants = new HashSet<>(); + + TenantConfigBean bean = new TenantConfigBean(Collections.emptyMap(), testContext("Default"), clock::get, limit, + (oidcTenantConfig, dynamicTenant, tenantId) -> Uni.createFrom().item(testContext(tenantId))) { + @Override + boolean evictTenant(EvictionCandidate candidate) { + boolean evicted = super.evictTenant(candidate); + if (evicted) { + evictedTenants.add(candidate.tenantId()); + } + return evicted; + } + }; + + for (int i = 0; i < 10; i++) { + clock.incrementAndGet(); + bean.getOrCreateTenantContext(tenantConfig("tenant-" + i), true).await().indefinitely(); + + for (int i1 = 0; i1 < i - limit; i1++) { + String tenantId = "tenant-" + i1; + assertNull(bean.getDynamicTenantConfigContext(tenantId), tenantId); + assertTrue(evictedTenants.contains(tenantId), tenantId); + } + } + } + + /** + * Simulate delayed eviction (already running). + * + *

    + *
  1. Create 10 tenants (no/blocked eviction). + *
  2. Unblock eviction. + *
  3. Create another tenant. + *
  4. First 8 created tenants must have been evicted. + *
+ */ + @Test + public void dynamicTenantsLimitDelayedEviction() { + AtomicLong clock = new AtomicLong(); + + int limit = 3; + + Set evictedTenants = new HashSet<>(); + + TenantConfigBean bean = new TenantConfigBean(Collections.emptyMap(), testContext("Default"), clock::get, limit, + (oidcTenantConfig, dynamicTenant, tenantId) -> Uni.createFrom().item(testContext(tenantId))) { + @Override + boolean evictTenant(EvictionCandidate candidate) { + boolean evicted = super.evictTenant(candidate); + if (evicted) { + evictedTenants.add(candidate.tenantId()); + } + return evicted; + } + }; + + bean.tenantEvictionRunning.set(true); + + for (int i = 0; i < 10; i++) { + clock.incrementAndGet(); + String tenantId = "tenant-" + i; + bean.getOrCreateTenantContext(tenantConfig(tenantId), true).await().indefinitely(); + assertTrue(evictedTenants.isEmpty(), tenantId); + } + + bean.tenantEvictionRunning.set(false); + + clock.incrementAndGet(); + bean.getOrCreateTenantContext(tenantConfig("tenant-X"), true).await().indefinitely(); + for (int i = 0; i < 8; i++) { + String tenantId = "tenant-" + i; + assertNull(bean.getDynamicTenantConfigContext(tenantId), tenantId); + assertTrue(evictedTenants.contains(tenantId), tenantId); + } + } + + /** + * Simulate delayed eviction (already running). + * + *
    + *
  1. Create 10 tenants (no/blocked eviction). + *
  2. Simulate usa of the first 5 tenants. + *
  3. Unblock eviction. + *
  4. Create another tenant. + *
  5. Tenants 0-2 + 5-9 must have been evicted (3,4,X are the three newest). + *
+ */ + @Test + public void dynamicTenantsLimitDelayedEvictionRecentlyUsed() { + AtomicLong clock = new AtomicLong(); + + int limit = 3; + + Set evictedTenants = new HashSet<>(); + + TenantConfigBean bean = new TenantConfigBean(Collections.emptyMap(), testContext("Default"), clock::get, limit, + (oidcTenantConfig, dynamicTenant, tenantId) -> Uni.createFrom().item(testContext(tenantId))) { + @Override + boolean evictTenant(EvictionCandidate candidate) { + boolean evicted = super.evictTenant(candidate); + if (evicted) { + evictedTenants.add(candidate.tenantId()); + } + return evicted; + } + }; + + bean.tenantEvictionRunning.set(true); + + for (int i = 0; i < 10; i++) { + clock.incrementAndGet(); + String tenantId = "tenant-" + i; + bean.getOrCreateTenantContext(tenantConfig(tenantId), true).await().indefinitely(); + assertTrue(evictedTenants.isEmpty(), tenantId); + } + + for (int i = 0; i < 5; i++) { + clock.incrementAndGet(); + bean.getDynamicTenantConfigContext("tenant-" + i); + } + + bean.tenantEvictionRunning.set(false); + + clock.incrementAndGet(); + bean.getOrCreateTenantContext(tenantConfig("tenant-X"), true).await().indefinitely(); + + Stream.of(0, 1, 2, 5, 6, 7, 8, 9).forEach(i -> { + String tenantId = "tenant-" + i; + assertNull(bean.getDynamicTenantConfigContext(tenantId), tenantId); + assertTrue(evictedTenants.contains(tenantId), tenantId); + }); + { + String tenantId = "tenant-X"; + assertNotNull(bean.getDynamicTenantConfigContext(tenantId), tenantId); + assertFalse(evictedTenants.contains(tenantId), tenantId); + } + } + + /** + * Simulate delayed eviction (already running). + * + *
    + *
  1. Create 10 tenants (no/blocked eviction). + *
  2. Unblock eviction. + *
  3. Create another tenant. + *
  4. Simulate use of the first 5 tenants _during_ eviction. + *
  5. Tenants 0-2 + 5-9 must have been evicted (3,4,X are the three newest). + *
+ */ + @Test + public void dynamicTenantsLimitDelayedEvictionConcurrentAccess() { + AtomicLong clock = new AtomicLong(); + + int limit = 3; + + Set evictedTenants = new HashSet<>(); + Map newMaxLastUsed = new HashMap<>(); + + TenantConfigBean bean = new TenantConfigBean(Collections.emptyMap(), testContext("Default"), clock::get, limit, + (oidcTenantConfig, dynamicTenant, tenantId) -> Uni.createFrom().item(testContext(tenantId))) { + @Override + boolean evictTenant(EvictionCandidate candidate) { + Long newLastUsed = newMaxLastUsed.get(candidate.tenantId()); + if (newLastUsed != null) { + candidate.context().lastUsed = newLastUsed; + } + boolean evicted = super.evictTenant(candidate); + if (evicted) { + evictedTenants.add(candidate.tenantId()); + } + return evicted; + } + }; + + bean.tenantEvictionRunning.set(true); + + for (int i = 0; i < 10; i++) { + clock.incrementAndGet(); + String tenantId = "tenant-" + i; + bean.getOrCreateTenantContext(tenantConfig(tenantId), true).await().indefinitely(); + assertTrue(evictedTenants.isEmpty(), tenantId); + } + + bean.tenantEvictionRunning.set(false); + + for (int i = 0; i < 5; i++) { + newMaxLastUsed.put("tenant-" + i, clock.incrementAndGet()); + } + + clock.incrementAndGet(); + bean.getOrCreateTenantContext(tenantConfig("tenant-X"), true).await().indefinitely(); + + Stream.of(0, 1, 2, 5, 6, 7, 8, 9).forEach(i -> { + String tenantId = "tenant-" + i; + assertNull(bean.getDynamicTenantConfigContext(tenantId), tenantId); + assertTrue(evictedTenants.contains(tenantId), tenantId); + }); + { + String tenantId = "tenant-X"; + assertNotNull(bean.getDynamicTenantConfigContext(tenantId), tenantId); + assertFalse(evictedTenants.contains(tenantId), tenantId); + } + } + + static TenantConfigContext testContext(String tenantId) { + OidcTenantConfig config = tenantConfig(tenantId); + return new TenantConfigContext(null, config, Collections.emptyMap()); + } + + static OidcTenantConfig tenantConfig(String tenantId) { + OidcTenantConfig config = new OidcTenantConfig(); + config.setTenantId(tenantId); + return config; + } +}