Skip to content

Commit

Permalink
Merge pull request quarkusio#39236 from michalvavrik/feature/oidc-ten…
Browse files Browse the repository at this point in the history
…ant-path-matching

Resolve OIDC tenants with path-matching configuration as alternative to TenantResolver
  • Loading branch information
sberyozkin authored Mar 12, 2024
2 parents 926db7c + db379cf commit 9d46911
Show file tree
Hide file tree
Showing 13 changed files with 448 additions and 538 deletions.
101 changes: 60 additions & 41 deletions docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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:

* <<tenant-resolver>>
* <<default-tenant-resolver>>
* <<annotations-tenant-resolver>>
* <<configuration-based-tenant-resolver>>
* <<default-tenant-resolver>>

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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ public class OidcTenantConfig extends OidcCommonConfig {
@ConfigItem
public Optional<String> 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<List<String>> tenantPaths = Optional.empty();

/**
* The public key for the local JWT token verification.
* OIDC server connection is not created when this property is set.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<String, BackChannelLogoutTokenCache> backChannelLogoutTokens = new ConcurrentHashMap<>();
private final DefaultStaticTenantResolver defaultStaticTenantResolver = new DefaultStaticTenantResolver();
private final TenantResolver pathMatchingTenantResolver;
private final BlockingTaskRunner<OidcTenantConfig> blockingRequestContext;
private final boolean securityEventObserved;
private final TenantConfigBean tenantConfigBean;

@Inject
Instance<TenantResolver> tenantResolver;
Expand All @@ -50,9 +55,6 @@ public class DefaultTenantConfigResolver {
@Inject
Instance<JavaScriptRequestChecker> javaScriptRequestChecker;

@Inject
TenantConfigBean tenantConfigBean;

@Inject
Instance<TokenStateManager> tokenStateManager;

Expand All @@ -69,17 +71,15 @@ public class DefaultTenantConfigResolver {
@ConfigProperty(name = "quarkus.http.proxy.enable-forwarded-prefix")
boolean enableHttpForwardedPrefix;

private final BlockingTaskRunner<OidcTenantConfig> blockingRequestContext;

private final boolean securityEventObserved;

private ConcurrentHashMap<String, BackChannelLogoutTokenCache> 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<OidcTenantConfig>(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
Expand Down Expand Up @@ -152,30 +152,48 @@ private Uni<TenantConfigContext> 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) {
Expand Down Expand Up @@ -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];
Expand All @@ -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<String> staticTenantPaths;

private PathMatchingTenantResolver(ImmutablePathMatcher<String> staticTenantPaths) {
this.staticTenantPaths = staticTenantPaths;
}

private static PathMatchingTenantResolver of(Map<String, TenantConfigContext> staticTenantsConfig, String rootPath,
TenantConfigContext defaultTenant) {
final var builder = ImmutablePathMatcher.<String> builder().rootPath(rootPath);
addPath(DEFAULT_TENANT, defaultTenant.oidcConfig, builder);
for (Map.Entry<String, TenantConfigContext> 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<String> addPath(String tenant, OidcTenantConfig config,
ImmutablePathMatcher.ImmutablePathMatcherBuilder<String> builder) {
if (config != null && config.tenantPaths.isPresent()) {
for (String path : config.tenantPaths.get()) {
builder.addPath(path, tenant);
}
}
return builder;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ public AbstractPathMatchingHttpSecurityPolicy(Map<String, PolicyMappingConfig> p
boolean hasNoPermissions = permissions.isEmpty();
var namedHttpSecurityPolicies = toNamedHttpSecPolicies(rolePolicy, installedPolicies);
List<ImmutablePathMatcher<List<HttpMatcher>>> sharedPermsMatchers = new ArrayList<>();
final var builder = ImmutablePathMatcher.<List<HttpMatcher>> builder().handlerAccumulator(List::addAll);
final var builder = ImmutablePathMatcher.<List<HttpMatcher>> builder().handlerAccumulator(List::addAll)
.rootPath(rootPath);
for (PolicyMappingConfig policyMappingConfig : permissions.values()) {
if (appliesTo != policyMappingConfig.appliesTo) {
continue;
Expand All @@ -55,11 +56,12 @@ public AbstractPathMatchingHttpSecurityPolicy(Map<String, PolicyMappingConfig> p
hasNoPermissions = false;
}
if (policyMappingConfig.shared) {
final var builder1 = ImmutablePathMatcher.<List<HttpMatcher>> builder().handlerAccumulator(List::addAll);
addPermissionToPathMatcher(namedHttpSecurityPolicies, rootPath, policyMappingConfig, builder1);
final var builder1 = ImmutablePathMatcher.<List<HttpMatcher>> 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;
Expand Down Expand Up @@ -149,7 +151,7 @@ private static String getAuthMechanismName(RoutingContext routingContext,
return null;
}

private static void addPermissionToPathMatcher(Map<String, HttpSecurityPolicy> permissionCheckers, String rootPath,
private static void addPermissionToPathMatcher(Map<String, HttpSecurityPolicy> permissionCheckers,
PolicyMappingConfig policyMappingConfig,
ImmutablePathMatcher.ImmutablePathMatcherBuilder<List<HttpMatcher>> builder) {
HttpSecurityPolicy checker = permissionCheckers.get(policyMappingConfig.policy);
Expand All @@ -159,10 +161,6 @@ private static void addPermissionToPathMatcher(Map<String, HttpSecurityPolicy> 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<HttpMatcher> perms = new ArrayList<>();
Expand Down
Loading

0 comments on commit 9d46911

Please sign in to comment.