diff --git a/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc b/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc index b98845ca537e8..0cf4335782bc8 100644 --- a/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc @@ -597,8 +597,9 @@ When you set multiple tenant configurations in the `application.properties` file To configure the resolution of the tenant identifier, use one of the following options: * <> -* <> * <> +* <> +* <> These tenant resolution options are tried in the order they are listed until the tenant id gets resolved. If the tenant id remains unresolved (`null`), the default (unnamed) tenant configuration is selected. @@ -646,46 +647,6 @@ public class CustomTenantResolver implements TenantResolver { In this example, the value of the last request path segment is a tenant id, but if required, you can implement a more complex tenant identifier resolution logic. -[[default-tenant-resolver]] -=== Default resolution - -The default resolution for a tenant identifier is convention based, whereby the authentication request must include the tenant identifier in the last segment of the request path. - -The following `application.properties` example shows how you can configure two tenants named `google` and `github`: - -[source,properties] ----- -# Tenant 'google' configuration -quarkus.oidc.google.provider=google -quarkus.oidc.google.client-id=${google-client-id} -quarkus.oidc.google.credentials.secret=${google-client-secret} -quarkus.oidc.google.authentication.redirect-path=/signed-in - -# Tenant 'github' configuration -quarkus.oidc.github.provider=google -quarkus.oidc.github.client-id=${github-client-id} -quarkus.oidc.github.credentials.secret=${github-client-secret} -quarkus.oidc.github.authentication.redirect-path=/signed-in ----- - -In the provided example, both tenants configure OIDC `web-app` applications to use an authorization code flow to authenticate users and require session cookies to be generated after authentication. -After Google or GitHub authenticates the current user, the user gets returned to the `/signed-in` area for authenticated users, such as a secured resource path on the JAX-RS endpoint. - -Finally, to complete the default tenant resolution, set the following configuration property: - -[source,properties] ----- -quarkus.http.auth.permission.login.paths=/google,/github -quarkus.http.auth.permission.login.policy=authenticated ----- - -If the endpoint is running on `http://localhost:8080`, you can also provide UI options for users to log in to either `http://localhost:8080/google` or `http://localhost:8080/github`, without having to add specific `/google` or `/github` JAX-RS resource paths. -Tenant identifiers are also recorded in the session cookie names after the authentication is completed. -Therefore, authenticated users can access the secured application area without requiring either the `google` or `github` path values to be included in the secured URL. - -Default resolution can also work for Bearer token authentication. -Still, it might be less practical because a tenant identifier must always be set as the last path segment value. - [[annotations-tenant-resolver]] === Resolve with annotations @@ -737,6 +698,64 @@ quarkus.http.auth.permission.authenticated.applies-to=JAXRS <1> ---- <1> Tell Quarkus to run the HTTP permission check after the tenant has been selected with the `@Tenant` annotation. +[[configuration-based-tenant-resolver]] +=== Resolve with configuration + +You can use the `quarkus.oidc.tenant-paths` configuration property for resolving the tenant identifier as an alternative to using `io.quarkus.oidc.TenantResolver`. +Here is how you can select the `hr` tenant for the `sayHello` endpoint of the `HelloResource` resource used in the previous example: + +[source,properties] +---- +quarkus.oidc.hr.tenant-paths=/api/hello <1> +quarkus.oidc.google.tenant-paths=/api/* <2> +quarkus.oidc.google.tenant-paths=/*/hello <3> +---- +<1> Same path-matching rules apply as for the `quarkus.http.auth.permission.authenticated.paths=/api/hello` configuration property from the previous example. +<2> The wildcard placed at the end of the path represents any number of path segments. However the path is less specific than the `/api/hello`, therefore the `hr` tenant will be used to secure the `sayHello` endpoint. +<3> The wildcard in the `/*/hello` represents exactly one path segment. Nevertheless, the wildcard is less specific than the `api`, therefore the `hr` tenant will be used. + +TIP: Path-matching mechanism works exactly same as in the xref:security-authorize-web-endpoints-reference.adoc#authorization-using-configuration[Authorization using configuration]. + +[[default-tenant-resolver]] +=== Default resolution + +The default resolution for a tenant identifier is convention based, whereby the authentication request must include the tenant identifier in the last segment of the request path. + +The following `application.properties` example shows how you can configure two tenants named `google` and `github`: + +[source,properties] +---- +# Tenant 'google' configuration +quarkus.oidc.google.provider=google +quarkus.oidc.google.client-id=${google-client-id} +quarkus.oidc.google.credentials.secret=${google-client-secret} +quarkus.oidc.google.authentication.redirect-path=/signed-in + +# Tenant 'github' configuration +quarkus.oidc.github.provider=google +quarkus.oidc.github.client-id=${github-client-id} +quarkus.oidc.github.credentials.secret=${github-client-secret} +quarkus.oidc.github.authentication.redirect-path=/signed-in +---- + +In the provided example, both tenants configure OIDC `web-app` applications to use an authorization code flow to authenticate users and require session cookies to be generated after authentication. +After Google or GitHub authenticates the current user, the user gets returned to the `/signed-in` area for authenticated users, such as a secured resource path on the JAX-RS endpoint. + +Finally, to complete the default tenant resolution, set the following configuration property: + +[source,properties] +---- +quarkus.http.auth.permission.login.paths=/google,/github +quarkus.http.auth.permission.login.policy=authenticated +---- + +If the endpoint is running on `http://localhost:8080`, you can also provide UI options for users to log in to either `http://localhost:8080/google` or `http://localhost:8080/github`, without having to add specific `/google` or `/github` JAX-RS resource paths. +Tenant identifiers are also recorded in the session cookie names after the authentication is completed. +Therefore, authenticated users can access the secured application area without requiring either the `google` or `github` path values to be included in the secured URL. + +Default resolution can also work for Bearer token authentication. +Still, it might be less practical because a tenant identifier must always be set as the last path segment value. + [[tenant-config-resolver]] == Dynamic tenant configuration resolution 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 4dd066ef7d662..20a4d3e3587ba 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 @@ -92,6 +92,17 @@ public class OidcTenantConfig extends OidcCommonConfig { @ConfigItem public Optional endSessionPath = Optional.empty(); + /** + * The paths which must be secured by this tenant. Tenant with the most specific path wins. + * Please see the xref:security-openid-connect-multitenancy.adoc#configuration-based-tenant-resolver[Resolve with + * configuration] + * section of the OIDC multitenancy guide for explanation of allowed path patterns. + * + * @asciidoclet + */ + @ConfigItem + public Optional> tenantPaths = Optional.empty(); + /** * The public key for the local JWT token verification. * OIDC server connection is not created when this property is set. 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 114f9f8e375de..6e4e72bfd7569 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 @@ -28,6 +28,7 @@ import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.spi.runtime.BlockingSecurityExecutor; import io.quarkus.security.spi.runtime.SecurityEventHelper; +import io.quarkus.vertx.http.runtime.security.ImmutablePathMatcher; import io.smallrye.mutiny.Uni; import io.vertx.ext.web.RoutingContext; @@ -38,8 +39,12 @@ public class DefaultTenantConfigResolver { private static final String CURRENT_STATIC_TENANT_ID = "static.tenant.id"; private static final String CURRENT_STATIC_TENANT_ID_NULL = "static.tenant.id.null"; private static final String CURRENT_DYNAMIC_TENANT_CONFIG = "dynamic.tenant.config"; - - private DefaultStaticTenantResolver defaultStaticTenantResolver = new DefaultStaticTenantResolver(); + private final ConcurrentHashMap backChannelLogoutTokens = new ConcurrentHashMap<>(); + private final DefaultStaticTenantResolver defaultStaticTenantResolver = new DefaultStaticTenantResolver(); + private final TenantResolver pathMatchingTenantResolver; + private final BlockingTaskRunner blockingRequestContext; + private final boolean securityEventObserved; + private final TenantConfigBean tenantConfigBean; @Inject Instance tenantResolver; @@ -50,9 +55,6 @@ public class DefaultTenantConfigResolver { @Inject Instance javaScriptRequestChecker; - @Inject - TenantConfigBean tenantConfigBean; - @Inject Instance tokenStateManager; @@ -69,17 +71,15 @@ public class DefaultTenantConfigResolver { @ConfigProperty(name = "quarkus.http.proxy.enable-forwarded-prefix") boolean enableHttpForwardedPrefix; - private final BlockingTaskRunner blockingRequestContext; - - private final boolean securityEventObserved; - - private ConcurrentHashMap backChannelLogoutTokens = new ConcurrentHashMap<>(); - - public DefaultTenantConfigResolver(BlockingSecurityExecutor blockingExecutor, BeanManager beanManager, - @ConfigProperty(name = "quarkus.security.events.enabled") boolean securityEventsEnabled) { + DefaultTenantConfigResolver(BlockingSecurityExecutor blockingExecutor, BeanManager beanManager, + @ConfigProperty(name = "quarkus.security.events.enabled") boolean securityEventsEnabled, + @ConfigProperty(name = "quarkus.http.root-path") String rootPath, TenantConfigBean tenantConfigBean) { this.blockingRequestContext = new BlockingTaskRunner(blockingExecutor); this.securityEventObserved = SecurityEventHelper.isEventObserved(new SecurityEvent(null, (SecurityIdentity) null), beanManager, securityEventsEnabled); + this.tenantConfigBean = tenantConfigBean; + this.pathMatchingTenantResolver = PathMatchingTenantResolver.of(tenantConfigBean.getStaticTenantsConfig(), rootPath, + tenantConfigBean.getDefaultTenant()); } @PostConstruct @@ -152,30 +152,48 @@ private Uni initializeTenantIfContextNotReady(TenantConfigC } private TenantConfigContext getStaticTenantContext(RoutingContext context) { - String tenantId = context.get(CURRENT_STATIC_TENANT_ID); - if (tenantId == null && context.get(CURRENT_STATIC_TENANT_ID_NULL) == null) { - if (tenantResolver.isResolvable()) { - tenantId = tenantResolver.get().resolve(context); + tenantId = resolveStaticTenantId(context); + if (tenantId != null) { + context.put(CURRENT_STATIC_TENANT_ID, tenantId); + } else { + context.put(CURRENT_STATIC_TENANT_ID_NULL, true); } + } - if (tenantId == null && tenantConfigBean.getStaticTenantsConfig().size() > 0) { - tenantId = defaultStaticTenantResolver.resolve(context); - } + return getStaticTenantContext(tenantId); + } + + private String resolveStaticTenantId(RoutingContext context) { + String tenantId; + if (tenantResolver.isResolvable()) { + tenantId = tenantResolver.get().resolve(context); - if (tenantId == null) { - tenantId = context.get(OidcUtils.TENANT_ID_ATTRIBUTE); + if (tenantId != null) { + return tenantId; } } + tenantId = context.get(OidcUtils.TENANT_ID_ATTRIBUTE); + if (tenantId != null) { - context.put(CURRENT_STATIC_TENANT_ID, tenantId); - } else { - context.put(CURRENT_STATIC_TENANT_ID_NULL, true); + return tenantId; } - return getStaticTenantContext(tenantId); + if (pathMatchingTenantResolver != null) { + tenantId = pathMatchingTenantResolver.resolve(context); + + if (tenantId != null) { + return tenantId; + } + } + + if (!tenantConfigBean.getStaticTenantsConfig().isEmpty()) { + tenantId = defaultStaticTenantResolver.resolve(context); + } + + return tenantId; } private TenantConfigContext getStaticTenantContext(String tenantId) { @@ -274,10 +292,6 @@ private class DefaultStaticTenantResolver implements TenantResolver { @Override public String resolve(RoutingContext context) { - String tenantId = context.get(OidcUtils.TENANT_ID_ATTRIBUTE); - if (tenantId != null) { - return tenantId; - } String[] pathSegments = context.request().path().split("/"); if (pathSegments.length > 0) { String lastPathSegment = pathSegments[pathSegments.length - 1]; @@ -287,7 +301,44 @@ public String resolve(RoutingContext context) { } return null; } + } + + private static class PathMatchingTenantResolver implements TenantResolver { + private static final String DEFAULT_TENANT = "PathMatchingTenantResolver#DefaultTenant"; + private final ImmutablePathMatcher staticTenantPaths; + + private PathMatchingTenantResolver(ImmutablePathMatcher staticTenantPaths) { + this.staticTenantPaths = staticTenantPaths; + } + private static PathMatchingTenantResolver of(Map staticTenantsConfig, String rootPath, + TenantConfigContext defaultTenant) { + final var builder = ImmutablePathMatcher. builder().rootPath(rootPath); + addPath(DEFAULT_TENANT, defaultTenant.oidcConfig, builder); + for (Map.Entry e : staticTenantsConfig.entrySet()) { + addPath(e.getKey(), e.getValue().oidcConfig, builder); + } + return builder.hasPaths() ? new PathMatchingTenantResolver(builder.build()) : null; + } + + @Override + public String resolve(RoutingContext context) { + String tenantId = staticTenantPaths.match(context.normalizedPath()).getValue(); + if (tenantId != null && tenantId != DEFAULT_TENANT) { + return tenantId; + } + return null; + } + + private static ImmutablePathMatcher.ImmutablePathMatcherBuilder addPath(String tenant, OidcTenantConfig config, + ImmutablePathMatcher.ImmutablePathMatcherBuilder builder) { + if (config != null && config.tenantPaths.isPresent()) { + for (String path : config.tenantPaths.get()) { + builder.addPath(path, tenant); + } + } + return builder; + } } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java index 1520d7734003e..21fbdbb326576 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/AbstractPathMatchingHttpSecurityPolicy.java @@ -46,7 +46,8 @@ public AbstractPathMatchingHttpSecurityPolicy(Map p boolean hasNoPermissions = permissions.isEmpty(); var namedHttpSecurityPolicies = toNamedHttpSecPolicies(rolePolicy, installedPolicies); List>> sharedPermsMatchers = new ArrayList<>(); - final var builder = ImmutablePathMatcher.> builder().handlerAccumulator(List::addAll); + final var builder = ImmutablePathMatcher.> builder().handlerAccumulator(List::addAll) + .rootPath(rootPath); for (PolicyMappingConfig policyMappingConfig : permissions.values()) { if (appliesTo != policyMappingConfig.appliesTo) { continue; @@ -55,11 +56,12 @@ public AbstractPathMatchingHttpSecurityPolicy(Map p hasNoPermissions = false; } if (policyMappingConfig.shared) { - final var builder1 = ImmutablePathMatcher.> builder().handlerAccumulator(List::addAll); - addPermissionToPathMatcher(namedHttpSecurityPolicies, rootPath, policyMappingConfig, builder1); + final var builder1 = ImmutablePathMatcher.> builder().handlerAccumulator(List::addAll) + .rootPath(rootPath); + addPermissionToPathMatcher(namedHttpSecurityPolicies, policyMappingConfig, builder1); sharedPermsMatchers.add(builder1.build()); } else { - addPermissionToPathMatcher(namedHttpSecurityPolicies, rootPath, policyMappingConfig, builder); + addPermissionToPathMatcher(namedHttpSecurityPolicies, policyMappingConfig, builder); } } this.hasNoPermissions = hasNoPermissions; @@ -149,7 +151,7 @@ private static String getAuthMechanismName(RoutingContext routingContext, return null; } - private static void addPermissionToPathMatcher(Map permissionCheckers, String rootPath, + private static void addPermissionToPathMatcher(Map permissionCheckers, PolicyMappingConfig policyMappingConfig, ImmutablePathMatcher.ImmutablePathMatcherBuilder> builder) { HttpSecurityPolicy checker = permissionCheckers.get(policyMappingConfig.policy); @@ -159,10 +161,6 @@ private static void addPermissionToPathMatcher(Map p if (policyMappingConfig.enabled.orElse(Boolean.TRUE)) { for (String path : policyMappingConfig.paths.orElse(Collections.emptyList())) { - path = path.trim(); - if (!path.startsWith("/")) { - path = rootPath + path; - } HttpMatcher m = new HttpMatcher(policyMappingConfig.authMechanism.orElse(null), new HashSet<>(policyMappingConfig.methods.orElse(Collections.emptyList())), checker); List perms = new ArrayList<>(); diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutablePathMatcher.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutablePathMatcher.java index 25f3052d5bbcc..81b6d47beead6 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutablePathMatcher.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutablePathMatcher.java @@ -132,6 +132,8 @@ public static class ImmutablePathMatcherBuilder { private final Map additionalExactPathMatches = new HashMap<>(); private final Map> pathsWithWildcard = new HashMap<>(); private BiConsumer handlerAccumulator; + private String rootPath; + private boolean empty = true; private ImmutablePathMatcherBuilder() { } @@ -146,9 +148,22 @@ public ImmutablePathMatcherBuilder handlerAccumulator(BiConsumer handle return this; } + public boolean hasPaths() { + return !empty; + } + + /** + * @param rootPath Path to which relative patterns (paths not starting with a separator) are linked. + * @return ImmutablePathMatcherBuilder + */ + public ImmutablePathMatcherBuilder rootPath(String rootPath) { + this.rootPath = rootPath; + return this; + } + public ImmutablePathMatcher build() { T defaultHandler = null; - SubstringMap paths = new SubstringMap<>(); + var paths = ImmutableSubstringMap. builder(); boolean hasPathWithInnerWildcard = false; // process paths with a wildcard first, that way we only create inner path matcher when really needed for (Path p : pathsWithWildcard.values()) { @@ -200,7 +215,7 @@ public void accept(SubstringMatch match1, SubstringMatch match2) { exactPathMatches.putIfAbsent(e.getKey(), e.getValue()); } int[] lengths = buildLengths(paths.keys()); - return new ImmutablePathMatcher<>(defaultHandler, paths.asImmutableMap(), exactPathMatches, lengths, + return new ImmutablePathMatcher<>(defaultHandler, paths.build(), exactPathMatches, lengths, hasPathWithInnerWildcard); } @@ -227,6 +242,13 @@ public void accept(SubstringMatch match1, SubstringMatch match2) { * @return self */ public ImmutablePathMatcherBuilder addPath(String path, T handler) { + if (empty) { + empty = false; + } + path = path.trim(); + if (rootPath != null && !path.startsWith("/")) { + path = rootPath + path; + } return addPath(path, path, handler); } @@ -363,13 +385,6 @@ public void addPrefixPath(T prefixPathHandler, BiConsumer handlerAccumulat } } - private static class PathWithInnerWildcard { - private final String remaining; - private final T handler; - - private PathWithInnerWildcard(String remaining, T handler) { - this.remaining = remaining; - this.handler = handler; - } + private record PathWithInnerWildcard(String remaining, T handler) { } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutableSubstringMap.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutableSubstringMap.java index fd0e572b83cfd..d765a1afdb9a5 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutableSubstringMap.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/ImmutableSubstringMap.java @@ -1,6 +1,8 @@ package io.quarkus.vertx.http.runtime.security; import java.util.Arrays; +import java.util.Iterator; +import java.util.NoSuchElementException; import io.quarkus.vertx.http.runtime.security.ImmutablePathMatcher.PathMatch; @@ -132,4 +134,101 @@ boolean hasSubPathMatcher() { return hasSubPathMatcher; } } + + static SubstringMapBuilder builder() { + return new SubstringMapBuilder<>(); + } + + static final class SubstringMapBuilder { + private Object[] table = new Object[16]; + private int size; + + private SubstringMapBuilder() { + } + + void put(String key, V value, ImmutablePathMatcher> subPathMatcher) { + if (key == null) { + throw new NullPointerException(); + } + + Object[] newTable; + if (table.length / (double) size < 4 && table.length != Integer.MAX_VALUE) { + newTable = new Object[table.length << 1]; + for (int i = 0; i < table.length; i += 2) { + if (table[i] != null) { + doPut(newTable, (String) table[i], table[i + 1]); + } + } + } else { + newTable = new Object[table.length]; + System.arraycopy(table, 0, newTable, 0, table.length); + } + doPut(newTable, key, new SubstringMatch<>(key, value, subPathMatcher)); + this.table = newTable; + size++; + } + + private void doPut(Object[] newTable, String key, Object value) { + int hash = hash(key, key.length()); + int pos = tablePos(newTable, hash); + while (newTable[pos] != null && !newTable[pos].equals(key)) { + pos += 2; + if (pos >= newTable.length) { + pos = 0; + } + } + newTable[pos] = key; + newTable[pos + 1] = value; + } + + public Iterable keys() { + return new Iterable() { + @Override + public Iterator iterator() { + final Object[] tMap = table; + int i = 0; + while (i < table.length && tMap[i] == null) { + i += 2; + } + final int startPos = i; + + return new Iterator() { + + private Object[] map = tMap; + + private int pos = startPos; + + @Override + public boolean hasNext() { + return pos < table.length; + } + + @Override + public String next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + String ret = (String) map[pos]; + + pos += 2; + while (pos < table.length && tMap[pos] == null) { + pos += 2; + } + return ret; + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + }; + } + }; + + } + + ImmutableSubstringMap build() { + return new ImmutableSubstringMap<>(table); + } + } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PathMatcher.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PathMatcher.java deleted file mode 100644 index c069fe2645a0c..0000000000000 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/PathMatcher.java +++ /dev/null @@ -1,223 +0,0 @@ -package io.quarkus.vertx.http.runtime.security; - -import java.util.Comparator; -import java.util.Map; -import java.util.Set; -import java.util.TreeSet; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; - -import io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.SubstringMatch; - -/** - * Handler that dispatches to a given handler based of a prefix match of the path. - *

- * This only matches a single level of a request, e.g. if you have a request that takes the form: - *

- * /foo/bar - *

- * - * @author Stuart Douglas - * - * @deprecated use {@link ImmutablePathMatcher} instead - */ -@Deprecated -public class PathMatcher { - - private static final String STRING_PATH_SEPARATOR = "/"; - - private volatile T defaultHandler; - private final SubstringMap paths = new SubstringMap<>(); - private final ConcurrentMap exactPathMatches = new ConcurrentHashMap<>(); - - /** - * lengths of all registered paths - */ - private volatile int[] lengths = {}; - - public PathMatcher(final T defaultHandler) { - this.defaultHandler = defaultHandler; - } - - public PathMatcher() { - } - - /** - * Matches a path against the registered handlers. - * - * @param path The relative path to match - * @return The match match. This will never be null, however if none matched its value field will be - */ - public PathMatch match(String path) { - if (!exactPathMatches.isEmpty()) { - T match = getExactPath(path); - if (match != null) { - return new PathMatch<>(path, "", match); - } - } - - int length = path.length(); - final int[] lengths = this.lengths; - for (int pathLength : lengths) { - if (pathLength == length) { - SubstringMatch next = paths.get(path, length); - if (next != null) { - return new PathMatch<>(path, "", next.getValue()); - } - } else if (pathLength < length) { - char c = path.charAt(pathLength); - if (c == '/') { - - //String part = path.substring(0, pathLength); - SubstringMatch next = paths.get(path, pathLength); - if (next != null) { - return new PathMatch<>(next.getKey(), path.substring(pathLength), next.getValue()); - } - } - } - } - return new PathMatch<>("", path, defaultHandler); - } - - /** - * Adds a path prefix and a handler for that path. If the path does not start - * with a / then one will be prepended. - *

- * The match is done on a prefix bases, so registering /foo will also match /bar. Exact - * path matches are taken into account first. - *

- * If / is specified as the path then it will replace the default handler. - * - * @param path The path - * @param handler The handler - */ - public synchronized PathMatcher addPrefixPath(final String path, final T handler) { - if (path.isEmpty()) { - throw new IllegalArgumentException("Path not specified"); - } - - if (PathMatcher.STRING_PATH_SEPARATOR.equals(path)) { - this.defaultHandler = handler; - return this; - } - - paths.put(path, handler); - - buildLengths(); - return this; - } - - public synchronized PathMatcher addExactPath(final String path, final T handler) { - if (path.isEmpty()) { - throw new IllegalArgumentException("Path not specified"); - } - exactPathMatches.put(path, handler); - return this; - } - - public T getExactPath(final String path) { - return exactPathMatches.get(path); - } - - public T getPrefixPath(final String path) { - - // enable the prefix path mechanism to return the default handler - SubstringMatch match = paths.get(path); - if (PathMatcher.STRING_PATH_SEPARATOR.equals(path) && match == null) { - return this.defaultHandler; - } - if (match == null) { - return null; - } - - // return the value for the given path - return match.getValue(); - } - - private void buildLengths() { - final Set lengths = new TreeSet<>(new Comparator() { - @Override - public int compare(Integer o1, Integer o2) { - return -o1.compareTo(o2); - } - }); - for (String p : paths.keys()) { - lengths.add(p.length()); - } - - int[] lengthArray = new int[lengths.size()]; - int pos = 0; - for (int i : lengths) { - lengthArray[pos++] = i; - } - this.lengths = lengthArray; - } - - @Deprecated - public synchronized PathMatcher removePath(final String path) { - return removePrefixPath(path); - } - - public synchronized PathMatcher removePrefixPath(final String path) { - if (path == null || path.isEmpty()) { - throw new IllegalArgumentException("Path not specified"); - } - - if (PathMatcher.STRING_PATH_SEPARATOR.equals(path)) { - defaultHandler = null; - return this; - } - - paths.remove(path); - - buildLengths(); - return this; - } - - public synchronized PathMatcher removeExactPath(final String path) { - if (path == null || path.isEmpty()) { - throw new IllegalArgumentException("Path not specified"); - } - - exactPathMatches.remove(path); - - return this; - } - - public synchronized PathMatcher clearPaths() { - paths.clear(); - exactPathMatches.clear(); - this.lengths = new int[0]; - defaultHandler = null; - return this; - } - - public Map getPaths() { - return paths.toMap(); - } - - public static final class PathMatch { - private final String matched; - private final String remaining; - private final T value; - - public PathMatch(String matched, String remaining, T value) { - this.matched = matched; - this.remaining = remaining; - this.value = value; - } - - public String getRemaining() { - return remaining; - } - - public String getMatched() { - return matched; - } - - public T getValue() { - return value; - } - } - -} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/SubstringMap.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/SubstringMap.java deleted file mode 100644 index 75867de490fe0..0000000000000 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/SubstringMap.java +++ /dev/null @@ -1,221 +0,0 @@ -package io.quarkus.vertx.http.runtime.security; - -import static io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.doEquals; -import static io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.hash; -import static io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.tablePos; - -import java.util.HashMap; -import java.util.Iterator; -import java.util.Map; -import java.util.NoSuchElementException; - -import io.quarkus.vertx.http.runtime.security.ImmutableSubstringMap.SubstringMatch; - -/** - * A string keyed map that can be accessed as a substring, eliminating the need to allocate a new string - * to do a key comparison against. - *

- * This class uses linear probing and is thread safe due to copy on write semantics. As such it is not recommended - * for data that changes frequently. - *

- * This class does not actually implement the map interface to avoid implementing unnecessary operations. - * - * @author Stuart Douglas - */ -public class SubstringMap { - - private volatile Object[] table = new Object[16]; - private int size; - - /** - * @deprecated use {@link ImmutablePathMatcher} - */ - @Deprecated - public SubstringMatch get(String key, int length) { - return get(key, length, false); - } - - /** - * @deprecated use {@link ImmutablePathMatcher} - */ - @Deprecated - public SubstringMatch get(String key) { - return get(key, key.length(), false); - } - - @SuppressWarnings("unchecked") - private SubstringMatch get(String key, int length, boolean exact) { - if (key.length() < length) { - throw new IllegalArgumentException(); - } - Object[] table = this.table; - int hash = hash(key, length); - int pos = tablePos(table, hash); - int start = pos; - while (table[pos] != null) { - if (exact) { - if (table[pos].equals(key)) { - return (SubstringMatch) table[pos + 1]; - } - } else { - if (doEquals((String) table[pos], key, length)) { - return (SubstringMatch) table[pos + 1]; - } - } - pos += 2; - if (pos >= table.length) { - pos = 0; - } - if (pos == start) { - return null; - } - } - return null; - } - - /** - * @deprecated use {@link ImmutablePathMatcher} - */ - @Deprecated - public synchronized void put(String key, V value) { - put(key, value, null); - } - - void put(String key, V value, ImmutablePathMatcher> subPathMatcher) { - if (key == null) { - throw new NullPointerException(); - } - - Object[] newTable; - if (table.length / (double) size < 4 && table.length != Integer.MAX_VALUE) { - newTable = new Object[table.length << 1]; - for (int i = 0; i < table.length; i += 2) { - if (table[i] != null) { - doPut(newTable, (String) table[i], table[i + 1]); - } - } - } else { - newTable = new Object[table.length]; - System.arraycopy(table, 0, newTable, 0, table.length); - } - doPut(newTable, key, new SubstringMatch<>(key, value, subPathMatcher)); - this.table = newTable; - size++; - } - - /** - * @deprecated use {@link ImmutablePathMatcher} - */ - @Deprecated - public synchronized V remove(String key) { - if (key == null) { - throw new NullPointerException(); - } - //we just assume it is present, and always do a copy - //for this maps intended use cases as a path matcher it won't be called when - //the value is not present anyway - V value = null; - Object[] newTable = new Object[table.length]; - for (int i = 0; i < table.length; i += 2) { - if (table[i] != null && !table[i].equals(key)) { - doPut(newTable, (String) table[i], table[i + 1]); - } else if (table[i] != null) { - value = (V) table[i + 1]; - size--; - } - } - this.table = newTable; - if (value == null) { - return null; - } - return ((SubstringMatch) value).getValue(); - } - - private void doPut(Object[] newTable, String key, Object value) { - int hash = hash(key, key.length()); - int pos = tablePos(newTable, hash); - while (newTable[pos] != null && !newTable[pos].equals(key)) { - pos += 2; - if (pos >= newTable.length) { - pos = 0; - } - } - newTable[pos] = key; - newTable[pos + 1] = value; - } - - /** - * @deprecated use {@link ImmutablePathMatcher} - */ - @Deprecated - public Map toMap() { - Map map = new HashMap<>(); - Object[] t = this.table; - for (int i = 0; i < t.length; i += 2) { - if (t[i] != null) { - map.put((String) t[i], ((SubstringMatch) t[i + 1]).getValue()); - } - } - return map; - } - - /** - * @deprecated use {@link ImmutablePathMatcher} - */ - @Deprecated - public synchronized void clear() { - size = 0; - table = new Object[16]; - } - - public Iterable keys() { - return new Iterable() { - @Override - public Iterator iterator() { - final Object[] tMap = table; - int i = 0; - while (i < table.length && tMap[i] == null) { - i += 2; - } - final int startPos = i; - - return new Iterator() { - - private Object[] map = tMap; - - private int pos = startPos; - - @Override - public boolean hasNext() { - return pos < table.length; - } - - @Override - public String next() { - if (!hasNext()) { - throw new NoSuchElementException(); - } - String ret = (String) map[pos]; - - pos += 2; - while (pos < table.length && tMap[pos] == null) { - pos += 2; - } - return ret; - } - - @Override - public void remove() { - throw new UnsupportedOperationException(); - } - }; - } - }; - - } - - ImmutableSubstringMap asImmutableMap() { - return new ImmutableSubstringMap<>(table); - } - -} diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java index 11a58bb552cd2..b96c7e6bfec02 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java @@ -1,5 +1,8 @@ package io.quarkus.it.keycloak; +import java.util.function.Predicate; +import java.util.stream.Stream; + import jakarta.enterprise.context.ApplicationScoped; import io.quarkus.oidc.TenantResolver; @@ -9,11 +12,14 @@ @ApplicationScoped public class CustomTenantResolver implements TenantResolver { + private static final Predicate USE_DEFAULT_STATIC_RESOLVER = path -> Stream + .of("/api/tenant-echo", "/api/tenant-paths/") + .anyMatch(path::contains); + @Override public String resolve(RoutingContext context) { - if (context.request().path().contains("/api/tenant-echo")) { - // do what DefaultStaticTenantResolver does so that we can test @Tenant - return context.get(OidcUtils.TENANT_ID_ATTRIBUTE); + if (USE_DEFAULT_STATIC_RESOLVER.test(context.request().path())) { + return null; } // Make sure this resolver is called only once during a given request if (context.get("static_config_resolved") != null) { diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantPathsResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantPathsResource.java new file mode 100644 index 0000000000000..909f6201dc063 --- /dev/null +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantPathsResource.java @@ -0,0 +1,67 @@ +package io.quarkus.it.keycloak; + +import jakarta.annotation.security.PermitAll; +import jakarta.enterprise.event.Observes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.quarkus.security.Authenticated; +import io.vertx.ext.web.Router; + +@Authenticated +@Path("api/tenant-paths") +public class TenantPathsResource { + + @PermitAll + void observe(@Observes Router router) { + router.route().order(0).handler(rc -> { + if (rc.request().path().equals("/api/tenant-paths///public-key//match")) { + rc.end("public-key"); + } else { + rc.next(); + } + }); + } + + @GET + @Path("tenant-b/default") + public String defaultTenant() { + return "default"; + } + + @GET + @Path("tenant-b/default-b") + public String tenantB2() { + return "tenant-b"; + } + + @GET + @Path("tenant-b/public-key") + public String tenantB3() { + return "tenant-b"; + } + + @GET + @Path("tenant-c/public-key") + public String tenantPublicKey() { + return "public-key"; + } + + @GET + @Path("public-key/match") + public String tenantPublicKey2() { + return "public-key"; + } + + @GET + @Path("public-key-c/match") + public String defaultTenant2() { + return "public-key"; + } + + @GET + @Path("tenant-b") + public String tenantB() { + return "tenant-b"; + } +} diff --git a/integration-tests/oidc-tenancy/src/main/resources/application.properties b/integration-tests/oidc-tenancy/src/main/resources/application.properties index 6034f53577352..9cd3b8499e0cb 100644 --- a/integration-tests/oidc-tenancy/src/main/resources/application.properties +++ b/integration-tests/oidc-tenancy/src/main/resources/application.properties @@ -10,6 +10,7 @@ quarkus.oidc.token-cache.max-size=3 quarkus.oidc.client-id=quarkus-app-a quarkus.oidc.credentials.secret=secret quarkus.oidc.application-type=service +quarkus.oidc.tenant-paths=/api/tenant-paths/tenant-b/default # Oidc Client quarkus.test.native-image-profile=test @@ -20,6 +21,7 @@ quarkus.oidc.tenant-b.client-id=quarkus-app-b quarkus.oidc.tenant-b.credentials.secret=secret quarkus.oidc.tenant-b.token.issuer=${keycloak.url}/realms/quarkus-b quarkus.oidc.tenant-b.application-type=service +quarkus.oidc.tenant-b.tenant-paths=/api/tenant-paths/tenant-b* # Tenant B - 2 clients quarkus.oidc.tenant-b2.auth-server-url=${keycloak.url}/realms/quarkus-b @@ -121,6 +123,9 @@ quarkus.oidc.tenant-requiredclaim.token.required-claims.azp=quarkus-app-b quarkus.oidc.tenant-public-key.client-id=test quarkus.oidc.tenant-public-key.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlivFI8qB4D0y2jy0CfEqFyy46R0o7S8TKpsx5xbHKoU1VWg6QkQm+ntyIv1p4kE1sPEQO73+HY8+Bzs75XwRTYL1BmR1w8J5hmjVWjc6R2BTBGAYRPFRhor3kpM6ni2SPmNNhurEAHw7TaqszP5eUF/F9+KEBWkwVta+PZ37bwqSE4sCb1soZFrVz/UT/LF4tYpuVYt3YbqToZ3pZOZ9AX2o1GCG3xwOjkc4x0W7ezbQZdC9iftPxVHR8irOijJRRjcPDtA6vPKpzLl6CyYnsIYPd99ltwxTHjr3npfv/3Lw50bAkbT4HeLFxTx4flEoZLKO/g0bAoV2uqBhkA9xnQIDAQAB +quarkus.oidc.tenant-public-key.tenant-paths=/api/tenant-paths/*/public-key,/api/tenant-paths/public-key/* +quarkus.http.auth.permission.authenticated.paths=/api/tenant-paths/public-key/match +quarkus.http.auth.permission.authenticated.policy=authenticated smallrye.jwt.sign.key.location=/privateKey.pem smallrye.jwt.new-token.lifespan=5 diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/AnnotationBasedTenantTest.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/AnnotationBasedTenantTest.java index 2dc353245f789..1d53ce36f731d 100644 --- a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/AnnotationBasedTenantTest.java +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/AnnotationBasedTenantTest.java @@ -80,7 +80,7 @@ public void testJaxRsIdentityAugmentation() { .body(Matchers.equalTo(("tenant-id=tenant-public-key, static.tenant.id=tenant-public-key, name=alice"))); } - private static String getTokenWithRole(String... roles) { + static String getTokenWithRole(String... roles) { return Jwt.claim("scope", "read:data").preferredUserName("alice").groups(Set.of(roles)).sign(); } } 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 12fe6f454dd4e..baa0238a4caec 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 @@ -30,7 +30,11 @@ import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.keycloak.client.KeycloakTestClient; import io.restassured.RestAssured; +import io.vertx.core.http.HttpMethod; import io.vertx.core.json.JsonObject; +import io.vertx.mutiny.core.Vertx; +import io.vertx.mutiny.core.buffer.Buffer; +import io.vertx.mutiny.core.http.HttpClient; /** * @author Pedro Igor @@ -685,6 +689,85 @@ public void testOpaqueTokenScopePermission() { .statusCode(403); } + @Test + public void testResolveStaticTenantsByPathPatterns() { + // default tenant path pattern is more specific, therefore it wins over tenant-b pattern that is also matched + assertStaticTenantSuccess("a", "default", "tenant-b/default"); + assertStaticTenantFailure("a", "tenant-b/default-b"); + assertStaticTenantFailure("a", "tenant-b/default-b/"); + assertStaticTenantFailure("b", "tenant-b/default"); + assertStaticTenantFailure("b", "tenant-b/default/"); + assertStaticTenantSuccess("b", "tenant-b", "tenant-b"); + assertStaticTenantSuccess("b", "tenant-b", "tenant-b/"); + assertStaticTenantSuccess("b", "tenant-b", "tenant-b/default-b"); + assertStaticTenantSuccess("b", "tenant-b", "tenant-b/default-b/"); + assertStaticTenantSuccess("b", "tenant-b", "tenant-b/public-key"); + assertStaticTenantSuccess("b", "tenant-b", "tenant-b/public-key"); + assertStaticTenantFailure("public-key", "tenant-b/public-key"); + assertStaticTenantFailure("public-key", "tenant-b/public-key/"); + assertStaticTenantSuccess("public-key", "public-key", "tenant-c/public-key"); + assertStaticTenantSuccess("public-key", "public-key", "tenant-c/public-key/"); + assertStaticTenantSuccess("public-key", "public-key", "public-key/match"); + assertStaticTenantSuccess("public-key", "public-key", "public-key/match/"); + assertStaticTenantFailure("b", "public-key/match"); + assertStaticTenantFailure("b", "public-key/match/"); + assertStaticTenantFailure("public-key", "public-key-c/match"); + assertStaticTenantFailure("public-key", "public-key-c/match/"); + assertStaticTenantSuccess("a", "public-key", "public-key-c/match"); + + // assert path is normalized and tenant is selected by path-matching pattern before HTTP perms are checked + Vertx vertx = Vertx.vertx(); + HttpClient httpClient = null; + try { + httpClient = vertx.createHttpClient(); + httpClient + .request(HttpMethod.GET, RestAssured.port, URI.create(RestAssured.baseURI).getHost(), + "/api/tenant-paths///public-key//match") + .flatMap(r -> r.putHeader("Authorization", "Bearer " + getAccessToken("public-key")).send()) + .flatMap(r -> { + assertEquals(200, r.statusCode()); + return r.body(); + }) + .map(Buffer::toString) + .invoke(b -> assertEquals("public-key", b)) + .await().indefinitely(); + httpClient + .request(HttpMethod.GET, RestAssured.port, URI.create(RestAssured.baseURI).getHost(), + "/api/tenant-paths///public-key//match") + .flatMap(r -> r.putHeader("Authorization", "Bearer " + getAccessToken("b")).send()) + .invoke(r -> assertEquals(401, r.statusCode())) + .await().indefinitely(); + } finally { + if (httpClient != null) { + httpClient.closeAndAwait(); + } + vertx.closeAndAwait(); + } + } + + private void assertStaticTenantSuccess(String clientId, String tenant, String subPath) { + // tenant is resolved based on path pattern and access token is valid + final String accessToken = getAccessToken(clientId); + RestAssured.given().auth().oauth2(accessToken).when().get("/api/tenant-paths/" + subPath).then().statusCode(200) + .body(equalTo(tenant)); + } + + private String getAccessToken(String clientId) { + final String accessToken; + if ("public-key".equals(clientId)) { + accessToken = AnnotationBasedTenantTest.getTokenWithRole(); + } else { + accessToken = getAccessToken("alice", clientId); + } + return accessToken; + } + + private void assertStaticTenantFailure(String clientId, String subPath) { + // tenant is not resolved based on path pattern or access token is not valid + final String accessToken = getAccessToken(clientId); + RestAssured.given().auth().oauth2(accessToken).when().get("/api/tenant-paths/" + subPath).then().statusCode(401); + } + private String getAccessToken(String userName, String clientId) { return getAccessToken(userName, clientId, clientId); }