Using OpenID Connect (OIDC) multitenancy

本指南演示您的开放 ID 连接 (OIDC) 应用程序如何支持多租户,以便于从一个应用程序为多个租户提供服务。这些租户可以在同一个 OIDC 提供商内是不同的领域或安全域,甚至是不同的 OIDC 提供商。 在从同一个应用程序中为多个客户提供服务时,例如在 SaaS 环境中,每个客户都作为一个不同的租户运行。通过为您的应用程序启用多租户支持,您可以为每个租户支持不同的认证策略,甚至可以针对不同的 OIDC 提供商(如 Keycloak 和 Google)进行认证。 若要使用 Bearer 令牌授权认证租户,请参阅 OpenID Connect (OIDC) Bearer token authentication 指南。 若要使用 OIDC 授权代码流认证并授权租户,请阅读 OpenID Connect authorization code flow mechanism for protecting web applications 指南。 此外,请参阅 OpenID Connect (OIDC) configuration properties 参考指南。

Prerequisites

如要完成本指南,您需要:

  • Roughly 15 minutes

  • An IDE

  • 安装了 JDK 17+,已正确配置 JAVA_HOME

  • Apache Maven ${proposed-maven-version}

  • 如果你想使用 Quarkus CLI, 则可以选择使用

  • 如果你想构建一个本机可执行文件(或如果你使用本机容器构建,则使用 Docker),则可以选择安装 Mandrel 或 GraalVM 以及 configured appropriately

Architecture

在此示例中,我们构建了一个非常简单的应用程序,该应用程序支持两种资源方法:

  • /{tenant}

此资源返回 OIDC 提供者签发给经验证用户和当前租户的 ID 令牌中获得的信息。

  • /{tenant}/bearer

此资源将返回由 OIDC 提供程序针对已验证用户和当前租户发出的访问令牌中获取的信息。

Solution

为了透彻地了解,我们建议您按照即将提供的分步说明构建该应用程序。

另外,如果您希望从完成的示例入手,请克隆 Git 存储库:git clone $${quickstarts-base-url}.git,或下载一个 $${quickstarts-base-url}/archive/main.zip[归档文档]。

该解决方案位于 security-openid-connect-multi-tenancy-quickstart directory中。

Creating the Maven project

首先,我们需要一个新项目。使用以下命令创建一个新项目:

CLI
quarkus create app {create-app-group-id}:{create-app-artifact-id} \
    --no-code
cd {create-app-artifact-id}

要创建一个 Gradle 项目,添加 --gradle--gradle-kotlin-dsl 选项。 有关如何安装和使用 Quarkus CLI 的详细信息,请参见 Quarkus CLI 指南。

Maven
mvn {quarkus-platform-groupid}:quarkus-maven-plugin:{quarkus-version}:create \
    -DprojectGroupId={create-app-group-id} \
    -DprojectArtifactId={create-app-artifact-id} \
    -DnoCode
cd {create-app-artifact-id}

要创建一个 Gradle 项目,添加 -DbuildTool=gradle-DbuildTool=gradle-kotlin-dsl 选项。

适用于 Windows 用户:

  • 如果使用 cmd,(不要使用反斜杠 \ ,并将所有内容放在同一行上)

  • 如果使用 Powershell,将 -D 参数用双引号引起来,例如 "-DprojectArtifactId={create-app-artifact-id}"

如果您已经配置了 Quarkus 项目,请通过在项目基本目录中运行以下命令将 `oidc`扩展添加到您的项目中:

CLI
quarkus extension add {add-extension-extensions}
Maven
./mvnw quarkus:add-extension -Dextensions='{add-extension-extensions}'
Gradle
./gradlew addExtension --extensions='{add-extension-extensions}'

这将把以下内容添加到您的构建文件中:

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-oidc</artifactId>
</dependency>
build.gradle
implementation("io.quarkus:quarkus-oidc")

Writing the application

首先,实现 `/{tenant}`端点。正如您从以下源代码中所看到的,它只是一个常规 Jakarta REST 资源:

package org.acme.quickstart.oidc;

import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;

import org.eclipse.microprofile.jwt.JsonWebToken;

import io.quarkus.oidc.IdToken;

@Path("/{tenant}")
public class HomeResource {
    /**
     * Injection point for the ID Token issued by the OIDC provider.
     */
    @Inject
    @IdToken
    JsonWebToken idToken;

    /**
     * Injection point for the Access Token issued by the OIDC provider.
     */
    @Inject
    JsonWebToken accessToken;

    /**
     * Returns the ID Token info.
     * This endpoint exists only for demonstration purposes.
     * Do not expose this token in a real application.
     *
     * @return ID Token info
     */
    @GET
    @Produces("text/html")
    public String getIdTokenInfo() {
        StringBuilder response = new StringBuilder().append("<html>")
                .append("<body>");

        response.append("<h2>Welcome, ").append(this.idToken.getClaim("email").toString()).append("</h2>\n");
        response.append("<h3>You are accessing the application within tenant <b>").append(idToken.getIssuer()).append(" boundaries</b></h3>");

        return response.append("</body>").append("</html>").toString();
    }

    /**
     * Returns the Access Token info.
     * This endpoint exists only for demonstration purposes.
     * Do not expose this token in a real application.
     *
     * @return Access Token info
     */
    @GET
    @Produces("text/html")
    @Path("bearer")
    public String getAccessTokenInfo() {
        StringBuilder response = new StringBuilder().append("<html>")
                .append("<body>");

        response.append("<h2>Welcome, ").append(this.accessToken.getClaim("email").toString()).append("</h2>\n");
        response.append("<h3>You are accessing the application within tenant <b>").append(accessToken.getIssuer()).append(" boundaries</b></h3>");

        return response.append("</body>").append("</html>").toString();
    }
}

若要从传入请求中解析租户并将它映射到 `application.properties`中的特定 `quarkus-oidc`租户配置,请为 `io.quarkus.oidc.TenantConfigResolver`接口创建一个实现,它可以动态解析租户配置:

package org.acme.quickstart.oidc;

import jakarta.enterprise.context.ApplicationScoped;

import org.eclipse.microprofile.config.ConfigProvider;

import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.OidcTenantConfig.ApplicationType;
import io.quarkus.oidc.TenantConfigResolver;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantResolver implements TenantConfigResolver {

    @Override
    public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {
        String path = context.request().path();

        if (path.startsWith("/tenant-a")) {
           String keycloakUrl = ConfigProvider.getConfig().getValue("keycloak.url", String.class);

            OidcTenantConfig config = new OidcTenantConfig();
            config.setTenantId("tenant-a");
            config.setAuthServerUrl(keycloakUrl + "/realms/tenant-a");
            config.setClientId("multi-tenant-client");
            config.getCredentials().setSecret("secret");
            config.setApplicationType(ApplicationType.HYBRID);
            return Uni.createFrom().item(config);
        } else {
            // resolve to default tenant config
            return Uni.createFrom().nullItem();
        }
    }
}

在前述实现中,租户将从请求路径中解析。如果无法推断出任何租户,则返回 `null`以指示应使用默认租户配置。

tenant-a`应用程序的类型是 `hybrid;如果提供了 HTTP 持久令牌,它可以接受 HTTP 持久令牌。否则,它会在需要验证时启动授权代码流程。

Configuring the application

# Default tenant configuration
%prod.quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus
quarkus.oidc.client-id=multi-tenant-client
quarkus.oidc.application-type=web-app

# Tenant A configuration is created dynamically in CustomTenantConfigResolver

# HTTP security configuration
quarkus.http.auth.permission.authenticated.paths=/*
quarkus.http.auth.permission.authenticated.policy=authenticated

第一个配置是默认租户配置,当无法从请求中推断出租户时应使用该配置。请注意,在 `quarkus.oidc.auth-server-url`中使用了 `%prod`配置文件前缀,以支持使用 Keycloak 的 Dev Services 测试多租户应用程序。该配置使用 Keycloak 实例对用户进行验证。

当传入请求映射到 `tenant-a`租户时,使用 `TenantConfigResolver`提供的第二个配置。

这两个配置都映射到同一个 Keycloak 服务器实例,同时使用不同的 realms

另外,您可以直接在 application.properties`中配置租户 `tenant-a

# Default tenant configuration
%prod.quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus
quarkus.oidc.client-id=multi-tenant-client
quarkus.oidc.application-type=web-app

# Tenant A configuration
quarkus.oidc.tenant-a.auth-server-url=http://localhost:8180/realms/tenant-a
quarkus.oidc.tenant-a.client-id=multi-tenant-client
quarkus.oidc.tenant-a.application-type=web-app

# HTTP security configuration
quarkus.http.auth.permission.authenticated.paths=/*
quarkus.http.auth.permission.authenticated.policy=authenticated

在这种情况下,还要使用自定义 `TenantConfigResolver`进行解析:

package org.acme.quickstart.oidc;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.oidc.TenantResolver;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantResolver implements TenantResolver {

    @Override
    public String resolve(RoutingContext context) {
        String path = context.request().path();
        String[] parts = path.split("/");

        if (parts.length == 0) {
            //Resolve to default tenant configuration
            return null;
        }

        return parts[1];
    }
}

您可以在配置文件中定义多个租户。在从 `TenantResolver`实现中解析租户时将它们正确映射,请确保每个租户都具有唯一别名。

但是,使用静态租户解析,具体涉及在 `application.properties`中配置租户并在 `TenantResolver`中解析它们,不适用于与 Dev Services for Keycloak 共同测试端点,因为它不知道如何将请求映射至各租户,也无法动态提供租户特定的 `quarkus.oidc.<tenant-id>.auth-server-url`值。因此,在测试和开发模式下,在 `application.properties`中使用具有租户特定 URL 的 `%prod`前缀都不起作用。

当当前租户表示 OIDC `web-app`应用程序时,当为所有请求完成代码验证流程并且已验证请求,或者当租户特定的状态或会话 Cookie 已经存在时,在为所有请求调用自定义租户解析器之前,当前 `io.vertx.ext.web.RoutingContext`包含一个 `tenant-id`属性。因此,在使用多个 OIDC 提供程序时,如果您希望解析租户 ID,只需一个特定于路径的检查,如果 `RoutingContext`未设置 `tenant-id`属性,例如:

package org.acme.quickstart.oidc;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.oidc.TenantResolver;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantResolver implements TenantResolver {

    @Override
    public String resolve(RoutingContext context) {
        String tenantId = context.get("tenant-id");
        if (tenantId != null) {
            return tenantId;
        } else {
            // Initial login request
            String path = context.request().path();
            String[] parts = path.split("/");

            if (parts.length == 0) {
                //Resolve to default tenant configuration
                return null;
            }
            return parts[1];
        }
    }
}

这是 Quarkus OIDC 在未注册任何自定义 `TenantResolver`的情况下解析静态自定义租户的方式。 类似的技术也可与 `TenantConfigResolver`一起使用,其中 `tenant-id`提供的 `OidcTenantConfig`可在准备好上一个请求后返回。

如果你还要使用 Hibernate ORM multitenancyMongoDB with Panache multitenancy,并且两个租户 Id 相同且必须从 Vert.x `RoutingContext`中提取,那么你可以将租户 Id 从 OIDC 租户解析器传递到 Hibernate ORM 租户解析器或 MongoDB 及 Panache Mongo 数据库解析器,作为一个 `RoutingContext`属性,例如:

public class CustomTenantResolver implements TenantResolver {

    @Override
    public String resolve(RoutingContext context) {
        String tenantId = extractTenantId(context);
        context.put("tenantId", tenantId);
        return tenantId;
    }
}

Starting and configuring the Keycloak server

要启动 Keycloak 服务器,可以使用 Docker 并运行以下命令:

docker run --name keycloak -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin -p 8180:8080 quay.io/keycloak/keycloak:{keycloak.version} start-dev

其中 `keycloak.version`被设置为 `25.0.2`或更高版本。

localhost:8180访问你的 Keycloak 服务器。

admin`用户身份登陆以访问 Keycloak 管理控制台。用户名和密码都是 `admin

现在,为这两个租户导入国度:

有关如何 create a new realm的更多信息,请参阅 Keycloak 文档。

Running and using the application

Running in developer mode

要在 dev 模式中运行微服务,请使用:

CLI
quarkus dev
Maven
./mvnw quarkus:dev
Gradle
./gradlew --console=plain quarkusDev

Running in JVM mode

在 dev 模式下探索应用程序后,您可以将其作为标准 Java 应用程序运行。

首先,对其进行编译:

CLI
quarkus build
Maven
./mvnw install
Gradle
./gradlew build

然后运行它:

java -jar target/quarkus-app/quarkus-run.jar

Running in native mode

可以将相同的演示编译成本机代码,无需改动。

这意味着你不再需要在产品环境中安装 JVM,因为运行时技术包含在生成的可执行文件中,并且经过优化,可以使用更少的资源运行。

编译需要花费更多时间,因此默认情况下此步骤被关闭;通过启用本机构建,让我们重新构建:

CLI
quarkus build --native
Maven
./mvnw install -Dnative
Gradle
./gradlew build -Dquarkus.native.enabled=true

过一会儿,你可以直接运行此可执行文件:

./target/security-openid-connect-multi-tenancy-quickstart-runner

Test the application

Use Dev Services for Keycloak

对于针对 Keycloak 的集成测试来说,Dev Services for Keycloak 是推荐的。Keycloak 的 Dev Services 启动并初始化一个测试容器:它会导入已配置的国度,并为 `CustomTenantResolver`设置一个基本的 Keycloak URL 以计算特定于领域的 URL。

首先,添加下列依赖项:

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-test-keycloak-server</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.htmlunit</groupId>
    <artifactId>htmlunit</artifactId>
    <scope>test</scope>
</dependency>
build.gradle
testImplementation("io.quarkus:quarkus-test-keycloak-server")
testImplementation("io.rest-assured:rest-assured")
testImplementation("org.htmlunit:htmlunit")

quarkus-test-keycloak-server`提供了一个实用类 `io.quarkus.test.keycloak.client.KeycloakTestClient,用于获取特定于领域的访问令牌,并且你可以使用 `RestAssured`来测试期望持有者访问令牌的 `/{tenant}/bearer`端点。`HtmlUnit`测试 `/{tenant}`端点和授权代码流。

接下来,配置所需的领域:

# Default tenant configuration
%prod.quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus
quarkus.oidc.client-id=multi-tenant-client
quarkus.oidc.application-type=web-app

# Tenant A configuration is created dynamically in CustomTenantConfigResolver

# HTTP security configuration
quarkus.http.auth.permission.authenticated.paths=/*
quarkus.http.auth.permission.authenticated.policy=authenticated

quarkus.keycloak.devservices.realm-path=default-tenant-realm.json,tenant-a-realm.json

最后,编写以 JVM 模式运行的测试:

package org.acme.quickstart.oidc;

import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;

import org.htmlunit.SilentCssErrorHandler;
import org.htmlunit.WebClient;
import org.htmlunit.html.HtmlForm;
import org.htmlunit.html.HtmlPage;
import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.keycloak.client.KeycloakTestClient;
import io.restassured.RestAssured;

@QuarkusTest
public class CodeFlowTest {

    KeycloakTestClient keycloakClient = new KeycloakTestClient();

    @Test
    public void testLogInDefaultTenant() throws IOException {
        try (final WebClient webClient = createWebClient()) {
            HtmlPage page = webClient.getPage("http://localhost:8081/default");

            assertEquals("Sign in to quarkus", page.getTitleText());

            HtmlForm loginForm = page.getForms().get(0);

            loginForm.getInputByName("username").setValueAttribute("alice");
            loginForm.getInputByName("password").setValueAttribute("alice");

            page = loginForm.getInputByName("login").click();

            assertTrue(page.asText().contains("tenant"));
        }
    }

    @Test
    public void testLogInTenantAWebApp() throws IOException {
        try (final WebClient webClient = createWebClient()) {
            HtmlPage page = webClient.getPage("http://localhost:8081/tenant-a");

            assertEquals("Sign in to tenant-a", page.getTitleText());

            HtmlForm loginForm = page.getForms().get(0);

            loginForm.getInputByName("username").setValueAttribute("alice");
            loginForm.getInputByName("password").setValueAttribute("alice");

            page = loginForm.getInputByName("login").click();

            assertTrue(page.asText().contains("alice@tenant-a.org"));
        }
    }

    @Test
    public void testLogInTenantABearerToken() throws IOException {
        RestAssured.given().auth().oauth2(getAccessToken()).when()
            .get("/tenant-a/bearer").then().body(containsString("alice@tenant-a.org"));
    }

    private String getAccessToken() {
        return keycloakClient.getRealmAccessToken("tenant-a", "alice", "alice", "multi-tenant-client", "secret");
    }

    private WebClient createWebClient() {
        WebClient webClient = new WebClient();
        webClient.setCssErrorHandler(new SilentCssErrorHandler());
        return webClient;
    }
}

以本机模式:

package org.acme.quickstart.oidc;

import io.quarkus.test.junit.QuarkusIntegrationTest;

@QuarkusIntegrationTest
public class CodeFlowIT extends CodeFlowTest {
}

有关其如何初始化和配置的更多信息,请参阅 Dev Services for Keycloak

Use the browser

要测试应用程序,请打开浏览器并访问以下 URL:

如果一切都如预期的那样正常工作,将会重定向到 Keycloak 服务器进行身份验证。请注意,请求的路径定义了一个 `default`租户,我们在配置文件中没有映射它。在这种情况下,将使用默认配置。

要对应用程序进行身份验证,请在 Keycloak 登录页面中输入以下凭据:

  • Username: alice

  • Password: alice

单击 *Login*按钮后,您将被重定向回该应用程序。

如果您现在尝试通过以下 URL 访问该应用程序:

您将再次被重定向到 Keycloak 登录页面。但是,这次您将使用不同的领域进行身份验证。

在这两种情况下,如果用户成功通过身份验证,则登录页面将显示用户的姓名和电子邮件。尽管 `alice`存在于两个租户中,但应用程序将它们视为不同领域中的不同用户。

Tenant resolution

Tenant resolution order

OIDC 租户按以下顺序解析:

  1. 如果已禁用主动身份验证,则首先检查 `io.quarkus.oidc.Tenant`注释。

  2. 使用自定义 `TenantConfigResolver`进行动态租户解析。

  3. 使用以下其中一个选项进行静态租户解析:自定义 TenantResolver、配置的租户路径以及将最后一个请求路径段默认为租户 ID。

最后,如果在前面的步骤后仍未解析出租户 ID,则将选择默认 OIDC 租户。

有关更多信息,请参阅以下部分:

此外,对于 OIDC `web-app`应用程序,状态和会话 Cookie 还会提供在授权代码流开始时使用上述选项之一解析的租户的提示。有关更多信息,请参阅 Tenant resolution for OIDC web-app applications部分。

Resolve with annotations

您可以使用 `io.quarkus.oidc.Tenant`注释来解析租户标识符,作为使用 `io.quarkus.oidc.TenantResolver`的替代方法。

必须禁用主动 HTTP 身份验证 (quarkus.http.auth.proactive=false),此方法才有效。有关详细信息,请参阅 Proactive authentication指南。

假设您的应用程序支持两个 OIDC 租户,即 `hr`和默认租户,那么所有带 `@Tenant("hr")`的资源方法和类均由 `quarkus.oidc.hr.auth-server-url`配置的 OIDC 提供程序进行验证。与此相反,所有其他类和方法仍由默认 OIDC 提供程序进行验证。

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

import io.quarkus.oidc.Tenant;
import io.quarkus.security.Authenticated;

@Authenticated
@Path("/api/hello")
public class HelloResource {

    @Tenant("hr") 1
    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String sayHello() {
        return "Hello!";
    }
}
1 `io.quarkus.oidc.Tenant`注释必须放在资源类或资源方法中。

在上面的示例中,`sayHello`端点的验证通过 `@Authenticated`注释执行。或者,如果您使用 HTTP Security policy保护端点,则要想 `@Tenant`注释生效,您必须延迟此策略的许可检查,如下面的示例所示:

quarkus.http.auth.permission.authenticated.paths=/api/hello
quarkus.http.auth.permission.authenticated.methods=GET
quarkus.http.auth.permission.authenticated.policy=authenticated
quarkus.http.auth.permission.authenticated.applies-to=JAXRS 1
1 使用 `@Tenant`注释告知 Quarkus 在选择租户后运行 HTTP 许可检查。

Dynamic tenant configuration resolution

如果您需要对想要支持的不同租户进行更动态的配置,并且不想在配置文件中有多个条目,您可以使用 io.quarkus.oidc.TenantConfigResolver

此界面允许您在运行时动态创建租户配置:

package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;
import java.util.function.Supplier;

import io.smallrye.mutiny.Uni;
import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TenantConfigResolver;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantConfigResolver implements TenantConfigResolver {

    @Override
    public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {
        String path = context.request().path();
        String[] parts = path.split("/");

        if (parts.length == 0) {
            //Resolve to default tenant configuration
            return null;
        }

        if ("tenant-c".equals(parts[1])) {
            // Do 'return requestContext.runBlocking(createTenantConfig());'
            // if a blocking call is required to create a tenant config,
            return Uni.createFromItem(createTenantConfig());
        }

        //Resolve to default tenant configuration
        return null;
    }

    private Supplier<OidcTenantConfig> createTenantConfig() {
        final OidcTenantConfig config = new OidcTenantConfig();

        config.setTenantId("tenant-c");
        config.setAuthServerUrl("http://localhost:8180/realms/tenant-c");
        config.setClientId("multi-tenant-client");
        OidcTenantConfig.Credentials credentials = new OidcTenantConfig.Credentials();

        credentials.setSecret("my-secret");

        config.setCredentials(credentials);

        // Any other setting supported by the quarkus-oidc extension

        return () -> config;
    }
}

此方法返回的 `OidcTenantConfig`与用于从 `application.properties`解析 `oidc`命名空间配置的 `OidcTenantConfig`相同。您可以使用 `quarkus-oidc`扩展支持的任何设置来填充该命名空间配置。

如果动态租户解析器返回 null,接下来将尝试 Static tenant configuration resolution

Static tenant configuration resolution

当您在 `application.properties`文件中设置多个租户配置时,您只需指定如何解析租户标识符即可。要配置租户标识符解析,请使用以下选项之一:

这些租户解析选项将按列出的顺序进行尝试,直到租户 ID 得到解析。如果租户 ID 仍未解析 (null),则选择默认(未命名)租户配置。

Resolve with TenantResolver

以下 `application.properties`示例展示了如何使用 `TenantResolver`方法解析名为 `a`和 `b`的两个租户的租户标识符:

# Tenant 'a' configuration
quarkus.oidc.a.auth-server-url=http://localhost:8180/realms/quarkus-a
quarkus.oidc.a.client-id=client-a
quarkus.oidc.a.credentials.secret=client-a-secret

# Tenant 'b' configuration
quarkus.oidc.b.auth-server-url=http://localhost:8180/realms/quarkus-b
quarkus.oidc.b.client-id=client-b
quarkus.oidc.b.credentials.secret=client-b-secret

您可以从 `quarkus.oidc.TenantResolver`返回 `a`或 `b`的租户 ID:

import quarkus.oidc.TenantResolver;

public class CustomTenantResolver implements TenantResolver {

    @Override
    public String resolve(RoutingContext context) {
        String path = context.request().path();
        if (path.endsWith("a")) {
            return "a";
        } else if (path.endsWith("b")) {
            return "b";
        } else {
            // default tenant
            return null;
        }
    }
}

在此示例中,最后请求路径段的值是租户 ID,但如果需要,您可以实现更复杂的租户标识符解析逻辑。

Configure tenant paths

作为使用 `io.quarkus.oidc.TenantResolver`的替代方法,您可以使用 `quarkus.oidc.tenant-paths`配置属性来解析租户标识符。以下是为上一个示例中使用的 `HelloResource`资源的 `sayHello`端点选择 `hr`租户的方法:

quarkus.oidc.hr.tenant-paths=/api/hello 1
quarkus.oidc.a.tenant-paths=/api/* 2
quarkus.oidc.b.tenant-paths=/*/hello 3
1 与上一个示例中的 `quarkus.http.auth.permission.authenticated.paths=/api/hello`配置属性相同,相同的路径匹配规则适用。
2 路径末尾放置的通配符表示任意数量的路径段。然而,此路径不如 `/api/hello`具体,因此 `hr`租户将用于保护 `sayHello`端点。
3 `/*/hello`中的通配符恰好表示一个路径段。然而,此通配符不如 `api`具体,因此 `hr`租户将被用于。

路径匹配机制的工作原理与 Authorization using configuration中完全相同。

Use last request path segment as tenant id

租户标识符的默认解析基于约定,即身份验证请求必须在请求路径的最后一段中包含租户标识符。

以下 application.properties 示例展示了如何为 googlegithub 配置两个租户:

# 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

在提供的示例中,两个租户均配置 OIDC web-app 应用程序,使用授权代码流对用户进行身份验证并要求在身份验证后生成会话 Cookie。在 Google 或 GitHub 对当前用户进行身份验证后,用户将返回到 /signed-in 区域(如 JAX-RS 端点上的安全资源路径)以供经过身份验证的用户使用。

最后,为完成默认租户解析,请设置以下配置属性:

quarkus.http.auth.permission.login.paths=/google,/github
quarkus.http.auth.permission.login.policy=authenticated

如果端点正在 http://localhost:8080 中运行,则还可以为用户提供 UI 选项,让他们登录到 http://localhost:8080/googlehttp://localhost:8080/github,无需添加特定的 /google/github JAX-RS 资源路径。在身份验证完成之后,租户标识符也会记录在会话 Cookie 名称中。因此,经过身份验证的用户可以在无需在安全 URL 中包含 googlegithub 路径值的情况下访问安全应用程序区域。

默认解析还可以用于 Bearer 令牌身份验证。但实用性可能较差,因为租户标识符始终必须设置为最后路径段值。

Resolve tenants with a token issuer claim

支持 Bearer 令牌身份验证的 OIDC 租户可以使用访问令牌的颁发者进行解析。颁发者为基础的解析能够工作,必须满足以下条件:

  • 访问令牌必须采用 JWT 格式并且包含颁发者 (iss) 令牌声明。

  • 仅考虑具有 servicehybrid 应用程序类型的 OIDC 租户。这些租户必须具有已发现或已配置的令牌颁发者。

颁发者为基础的解析使用 quarkus.oidc.resolve-tenants-with-issuer 属性启用。例如:

quarkus.oidc.resolve-tenants-with-issuer=true 1

quarkus.oidc.tenant-a.auth-server-url=${tenant-a-oidc-provider} 2
quarkus.oidc.tenant-a.client-id=${tenant-a-client-id}
quarkus.oidc.tenant-a.credentials.secret=${tenant-a-client-secret}

quarkus.oidc.tenant-b.auth-server-url=${tenant-b-oidc-provider} 3
quarkus.oidc.tenant-b.discover-enabled=false
quarkus.oidc.tenant-b.token.issuer=${tenant-b-oidc-provider}/issuer
quarkus.oidc.tenant-b.jwks-path=/jwks
quarkus.oidc.tenant-b.token-path=/tokens
quarkus.oidc.tenant-b.client-id=${tenant-b-client-id}
quarkus.oidc.tenant-b.credentials.secret=${tenant-b-client-secret}
1 租户 tenant-atenant-b 使用 JWT 访问令牌的颁发者 iss 声明值进行解析。
2 租户 tenant-a 从 OIDC 提供者的众所周知的配置端点中发现 issuer
3 租户 tenant-b 配置 issuer 因为它 OIDC 提供者不支持发现。

Tenant resolution for OIDC web-app applications

对于 OIDC web-app 应用程序,租户解析必须在授权代码流期间执行至少 3 次,此时 OIDC 特定的租户配置将影响如何运行以下每个步骤。

Step 1: Unauthenticated user accesses an endpoint and is redirected to OIDC provider

当未经身份验证的用户访问安全路径时,用户将被重定向至 OIDC 提供者进行身份验证,并且将租户配置用于构建重定向 URI。

Static tenant configuration resolutionDynamic tenant configuration resolution 部分中列出的所有静态和动态租户解析选项均可用于解析租户。

Step 2: The user is redirected back to the endpoint

在进行提供者身份验证后,用户会被重定向回 Quarkus 端点,并且将使用租户配置完成授权代码流。

Static tenant configuration resolutionDynamic tenant configuration resolution 部分中列出的所有静态和动态租户解析选项均可用于解析租户。在开始租户解析之前,将使用授权代码流 state cookie 设置已解析的租户配置 ID 作为 RoutingContext tenant-id 属性:自定义动态 TenantConfigResolver 和静态 TenantResolver 租户解析器均可检查它。

例如,以下是自定义 TenantConfigResolver 如何避免创建已解析的租户配置,否则可能需要阻止对数据库或其他远程源的读取:

package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.OidcTenantConfig.ApplicationType;
import io.quarkus.oidc.TenantConfigResolver;
import io.quarkus.oidc.runtime.OidcUtils;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantConfigResolver implements TenantConfigResolver {

    @Override
    public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {
        String resolvedTenantId = context.get(OidcUtils.TENANT_ID_ATTRIBUTE);
        if (resolvedTenantId != null) { 1
            return null;
        }

        String path = context.request().path(); 2
        if (path.endsWith("tenant-a")) {
            return Uni.createFromItem(createTenantConfig("tenant-a", "client-a", "secret-a"));
        } else if (path.endsWith("tenant-b")) {
            return Uni.createFromItem(createTenantConfig("tenant-b", "client-b", "secret-b"));
        }

        // Default tenant id
        return null;
    }

    private OidcTenantConfig createTenantConfig(String tenantId, String clientId, String secret) {
        final OidcTenantConfig config = new OidcTenantConfig();
        config.setTenantId(tenantId);
        config.setAuthServerUrl("http://localhost:8180/realms/"  + tenantId);
        config.setClientId(clientId);
        config.getCredentials().setSecret(secret);
        config.setApplicationType(ApplicationType.WEB_APP);
        return config;
    }
}
1 让 Quarkus 使用已解析的租户配置(如果它已在之前解析过)。
2 检查请求路径以创建租户配置。

默认配置可能如下所示:

quarkus.oidc.auth-server-url=http://localhost:8180/realms/default
quarkus.oidc.client-id=client-default
quarkus.oidc.credentials.secret=secret-default
quarkus.oidc.application-type=web-app

前述示例假定 tenant-atenant-b 和默认租户都会用于保护相同的端点路径。换句话说,用户使用 tenant-a 配置认证后,此用户在注销并清除或过期会话 Cookie 之前将无法选择使用 tenant-b 或默认配置进行认证。

多个 OIDC web-app 租户保护特定租户路径的情况较为少见,且还要求格外小心。当多个 OIDC web-app 租户(如 tenant-atenant-b 和默认租户)用于控制对特定租户路径的访问后,使用一个 OIDC 提供程序认证的用户不能访问要求使用另一个提供程序认证的路径,否则结果将不可预测,最有可能导致意外的认证失败。例如,如果 tenant-a 认证需要 Keycloak 认证,而 tenant-b 认证需要 Auth0 认证,那么,如果 tenant-a 认证的用户尝试访问受 tenant-b 配置保护的路径,则不会验证会话 Cookie,因为 Auth0 公共验证密钥不能用于验证 Keycloak 签名的令牌。避免多个 web-app 租户互相冲突的一种简单且建议的方式是设置特定租户会话路径,如以下示例所示:

package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.OidcTenantConfig.ApplicationType;
import io.quarkus.oidc.TenantConfigResolver;
import io.quarkus.oidc.runtime.OidcUtils;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantConfigResolver implements TenantConfigResolver {

    @Override
    public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {
        String resolvedTenantId = context.get(OidcUtils.TENANT_ID_ATTRIBUTE);
        if (resolvedTenantId != null) { 1
            return null;
        }

        String path = context.request().path(); 2
        if (path.endsWith("tenant-a")) {
            return Uni.createFromItem(createTenantConfig("tenant-a", "/tenant-a", "client-a", "secret-a"));
        } else if (path.endsWith("tenant-b")) {
            return Uni.createFromItem(createTenantConfig("tenant-b", "/tenant-b", "client-b", "secret-b"));
        }

        // Default tenant id
        return null;
    }

    private OidcTenantConfig createTenantConfig(String tenantId, String cookiePath, String clientId, String secret) {
        final OidcTenantConfig config = new OidcTenantConfig();
        config.setTenantId(tenantId);
        config.setAuthServerUrl("http://localhost:8180/realms/"  + tenantId);
        config.setClientId(clientId);
        config.getCredentials().setSecret(secret);
        config.setApplicationType(ApplicationType.WEB_APP);
        config.getAuthentication().setCookiePath(cookiePath); 3
        return config;
    }
}
1 让 Quarkus 使用已解析的租户配置(如果它已在之前解析过)。
2 检查请求路径以创建租户配置。
3 设置特定租户 Cookie 路径,确保会话 Cookie 仅对创建它的租户可见。

应该如下调整默认租户配置:

quarkus.oidc.auth-server-url=http://localhost:8180/realms/default
quarkus.oidc.client-id=client-default
quarkus.oidc.credentials.secret=secret-default
quarkus.oidc.authentication.cookie-path=/default
quarkus.oidc.application-type=web-app

当多个 OIDC web-app 租户保护特定租户路径时,不建议采用相同的会话 Cookie 路径,应该避免这样做,因为它要求自定义解析器更加小心,例如:

package io.quarkus.it.keycloak;

import jakarta.enterprise.context.ApplicationScoped;
import io.quarkus.oidc.OidcRequestContext;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.OidcTenantConfig.ApplicationType;
import io.quarkus.oidc.TenantConfigResolver;
import io.quarkus.oidc.runtime.OidcUtils;
import io.smallrye.mutiny.Uni;
import io.vertx.ext.web.RoutingContext;

@ApplicationScoped
public class CustomTenantConfigResolver implements TenantConfigResolver {

    @Override
    public Uni<OidcTenantConfig> resolve(RoutingContext context, OidcRequestContext<OidcTenantConfig> requestContext) {

        String path = context.request().path(); 1
        if (path.endsWith("tenant-a")) {
            String resolvedTenantId = context.get(OidcUtils.TENANT_ID_ATTRIBUTE);
	    if (resolvedTenantId != null) {
	        if ("tenant-a".equals(resolvedTenantId)) { 2
	            return null;
	        } else {
	           // Require a "tenant-a" authentication
                   context.remove(OidcUtils.TENANT_ID_ATTRIBUTE); 3
	        }
            }
            return Uni.createFromItem(createTenantConfig("tenant-a", "client-a", "secret-a"));
        } else if (path.endsWith("tenant-b")) {
            String resolvedTenantId = context.get(OidcUtils.TENANT_ID_ATTRIBUTE);
	    if (resolvedTenantId != null) {
	        if ("tenant-b".equals(resolvedTenantId)) { 2
	            return null;
	        } else {
	            // Require a "tenant-b" authentication
                   context.remove(OidcUtils.TENANT_ID_ATTRIBUTE); 3
	        }
            }
            return Uni.createFromItem(createTenantConfig("tenant-b", "client-b", "secret-b"));
        }

        // Set default tenant id
        context.put(OidcUtils.TENANT_ID_ATTRIBUTE, OidcUtils.DEFAULT_TENANT_ID); 4
        return null;
    }

    private OidcTenantConfig createTenantConfig(String tenantId, String clientId, String secret) {
        final OidcTenantConfig config = new OidcTenantConfig();
        config.setTenantId(tenantId);
        config.setAuthServerUrl("http://localhost:8180/realms/"  + tenantId);
        config.setClientId(clientId);
        config.getCredentials().setSecret(secret);
        config.setApplicationType(ApplicationType.WEB_APP);
        return config;
    }
}
1 检查请求路径以创建租户配置。
2 如果已解析租户符合当前路径,则让 Quarkus 使用已解析租户配置。
3 如果已解析租户配置不符合当前路径,则移除 tenant-id 特性。
4 对所有其他路径使用默认租户。这等同于移除 tenant-id 特性。

Disabling tenant configurations

自定义 TenantResolverTenantConfigResolver 实现可能会在无法从当前请求推断出租户、且需要回退到默认租户配置时返回 null

如果您希望自定义解析器始终解决租户,则不需要配置默认租户解决。

  • 要关闭默认租户配置,请设置 quarkus.oidc.tenant-enabled=false

quarkus.oidc.auth-server-url 未配置、但有自定义租户配置可用或已注册 TenantConfigResolver,默认租户配置会自动禁用。

请注意,特定租户配置也可以禁用,例如:quarkus.oidc.tenant-a.tenant-enabled=false