Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolve OIDC tenants with path-matching configuration as alternative to TenantResolver #39236

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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 @@

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,8 +698,66 @@
----
<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
michalvavrik marked this conversation as resolved.
Show resolved Hide resolved

You can use the `quarkus.oidc.tenant-paths` configuration property for resolving the tenant identifier as an alternative to using `io.quarkus.oidc.TenantResolver`.

Check warning on line 704 in docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc", "range": {"start": {"line": 704, "column": 77}}}, "severity": "INFO"}
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.

Check warning on line 713 in docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc", "range": {"start": {"line": 713, "column": 9}}}, "severity": "INFO"}
<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].

Check warning on line 717 in docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc", "range": {"start": {"line": 717, "column": 22}}}, "severity": "INFO"}

Check warning on line 717 in docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'by using' or 'that uses' rather than 'using'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'by using' or 'that uses' rather than 'using'.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc", "range": {"start": {"line": 717, "column": 132}}}, "severity": "INFO"}

[[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.

Check warning on line 757 in docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc", "range": {"start": {"line": 757, "column": 55}}}, "severity": "INFO"}

[[tenant-config-resolver]]
== Dynamic tenant configuration resolution

Check warning on line 760 in docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.SentenceLength] Try to keep sentences to an average of 32 words or fewer. Raw Output: {"message": "[Quarkus.SentenceLength] Try to keep sentences to an average of 32 words or fewer.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc", "range": {"start": {"line": 760, "column": 18}}}, "severity": "INFO"}

If you need a more dynamic configuration for the different tenants you want to support and don't want to end up with multiple
entries in your configuration file, you can use the `io.quarkus.oidc.TenantConfigResolver`.
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
Loading