OpenID Connect (OIDC) Bearer token authentication

Overview of the Bearer token authentication mechanism in Quarkus

Quarkus 通过 Quarkus OpenID Connect (OIDC) 扩展支持 Bearer 令牌认证机制。

Bearer 令牌由 OIDC 和 OAuth 2.0 兼容授权服务器(例如 Keycloak)颁发。

Bearer 令牌认证是在基于 Bearer 令牌的存在及有效性的过程中授权 HTTP 请求。Bearer 令牌提供有关调用主题的信息,此信息用于确定能否访问 HTTP 资源。

以下示意图概述了 Quarkus 中的 Bearer 令牌认证机制:

security bearer token authorization mechanism 1
Figure 1. Bearer token authentication mechanism in Quarkus with single-page application
  1. Quarkus 服务从 OIDC 提供程序检索验证密钥。验证密钥用于验证 Bearer 访问令牌签名。

  2. Quarkus 用户访问单页面应用程序 (SPA)。

  3. 单页面应用程序使用授权码流程验证用户并从 OIDC 提供程序中检索令牌。

  4. 单页面应用程序使用访问令牌从 Quarkus 服务检索服务数据。

  5. Quarkus 服务使用验证密钥验证 Bearer 访问令牌签名,检查令牌有效期和其他声明,如果令牌有效则允许请求继续,并将服务响应返回给单页面应用程序。

  6. 单页面应用程序将相同数据返回给 Quarkus 用户。

security bearer token authorization mechanism 2
Figure 2. Bearer token authentication mechanism in Quarkus with Java or command line client
  1. Quarkus 服务从 OIDC 提供程序检索验证密钥。验证密钥用于验证 Bearer 访问令牌签名。

  2. 客户端使用需要客户端 ID 和密匙或密码授予的 `client_credentials`来从 OIDC 提供程序检索访问令牌。

  3. 客户端使用此访问令牌从 Quarkus 服务检索服务数据。

  4. Quarkus 服务使用验证密钥验证携带者访问令牌签名,检查令牌到期日期和其他权利声明,如果令牌有效,则允许请求继续并向客户端返回服务响应。

如果您需要通过使用 OIDC 授权代码流对用户进行身份验证和授权,请参阅 Quarkus OpenID Connect authorization code flow mechanism for protecting web applications指南。此外,如果您使用 Keycloak 和携带者令牌,请参阅 Quarkus Using Keycloak to centralize authorization指南。

要了解如何使用 OIDC 携带者令牌身份验证保护服务应用程序,请参阅以下自学教程:* Protect a web application by using OpenID Connect (OIDC) authorization code flow.

有关如何支持多租户的信息,请参阅 Quarkus Using OpenID Connect Multi-Tenancy指南。

Accessing JWT claims

如果您需要访问 JWT 令牌权利声明,可以注入 JsonWebToken

package org.acme.security.openid.connect;

import org.eclipse.microprofile.jwt.JsonWebToken;
import jakarta.inject.Inject;
import jakarta.annotation.security.RolesAllowed;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/api/admin")
public class AdminResource {

    @Inject
    JsonWebToken jwt;

    @GET
    @RolesAllowed("admin")
    @Produces(MediaType.TEXT_PLAIN)
    public String admin() {
        return "Access for subject " + jwt.getSubject() + " is granted";
    }
}

@ApplicationScoped@Singleton`和 `@RequestScoped`范围内支持注入 `JsonWebToken。但是,如果将各个权利声明作为简单类型注入,则需要使用 @RequestScoped。有关详细信息,请参阅 Quarkus “使用 JWT RBAC”指南的 Supported injection scopes部分。

UserInfo

如果您必须从 OIDC UserInfo`端点请求 UserInfo JSON 对象,请设置 `quarkus.oidc.authentication.user-info-required=true。将向 OIDC 提供程序 UserInfo`端点发送请求,并将创建一个 `io.quarkus.oidc.UserInfo(一个简单的 javax.json.JsonObject`包装器)对象。`io.quarkus.oidc.UserInfo`可以用作 `SecurityIdentity `userinfo`属性注入或访问。

如果满足以下某个条件,则 `quarkus.oidc.authentication.user-info-required`将自动启用:

  • 如果将 quarkus.oidc.roles.source`设置为 `userinfo`或将 `quarkus.oidc.token.verify-access-token-with-user-info`设置为 `true`或将 `quarkus.oidc.authentication.id-token-required`设置为 `false,则当前 OIDC 租户在此情况下必须支持 UserInfo 端点。

  • 如果检测到 `io.quarkus.oidc.UserInfo`注入点,但仅当当前 OIDC 租户支持 UserInfo 端点时。

Configuration metadata

io.quarkus.oidc.OidcConfigurationMetadata`表示当前租户发现的 OpenID Connect Configuration Metadata,可以用作 `SecurityIdentity `configuration-metadata`属性注入或访问。

如果端点为公共端点,则会注入默认租户的 OidcConfigurationMetadata

Token claims and SecurityIdentity roles

您可以按如下方式映射已验证 JWT 访问令牌中的 `SecurityIdentity`角色:

  • 如果设置了 quarkus.oidc.roles.role-claim-path`属性,并且找到了匹配的数组或字符串权利声明,则从这些权利声明中提取角色。例如,`customrolescustomroles/arrayscope"http://namespace-qualified-custom-claim"/roles"http://namespace-qualified-roles"

  • 如果存在 `groups`权利声明,则使用其值。

  • 如果存在 realm_access/roles`或 `resource_access/client_id/roles(其中 `client_id`是 `quarkus.oidc.client-id`属性的值)权利声明,则使用其值。此检查支持 Keycloak 发行的令牌。

例如,以下 JWT 令牌具有包含包含角色的 `roles`数组的复杂 `groups`权利声明:

{
    "iss": "https://server.example.com",
    "sub": "24400320",
    "upn": "jdoe@example.com",
    "preferred_username": "jdoe",
    "exp": 1311281970,
    "iat": 1311280970,
    "groups": {
        "roles": [
          "microprofile_jwt_user"
        ],
    }
}

您必须将 microprofile_jwt_user 角色映射到 SecurityIdentity 角色,可以通过以下配置进行: quarkus.oidc.roles.role-claim-path=groups/roles

如果令牌是不透明的(二进制),则使用远程令牌内省响应中的一个 scope 属性。

如果 UserInfo 是角色的来源,则设置 quarkus.oidc.authentication.user-info-required=truequarkus.oidc.roles.source=userinfo,并且在需要时设置 quarkus.oidc.roles.role-claim-path

此外,还可以使用自定义 SecurityIdentityAugmentor 来添加角色。更多信息请参阅 Quarkus “安全提示和技巧”指南的 Security identity customization 部分。

您还可以使用 HTTP Security policy 将从令牌声明创建的 SecurityIdentity 角色映射到部署特定角色。

Token scopes and SecurityIdentity permissions

SecurityIdentity 权限以 io.quarkus.security.StringPermission 的形式从 source of the roles 的范围参数映射,并使用相同的声明分隔符。

import java.util.List;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;

import org.eclipse.microprofile.jwt.Claims;
import org.eclipse.microprofile.jwt.JsonWebToken;

import io.quarkus.security.PermissionsAllowed;

@Path("/service")
public class ProtectedResource {

    @Inject
    JsonWebToken accessToken;

    @PermissionsAllowed("email") 1
    @GET
    @Path("/email")
    public Boolean isUserEmailAddressVerifiedByUser() {
        return accessToken.getClaim(Claims.email_verified.name());
    }

    @PermissionsAllowed("orders_read") 2
    @GET
    @Path("/order")
    public List<Order> listOrders() {
        return List.of(new Order(1));
    }

    public static class Order {
        String id;
        public Order() {
        }
        public Order(String id) {
            this.id = id;
        }
        public String getId() {
            return id;
        }
        public void setId() {
            this.id = id;
        }
    }
}
1 只有具有 OpenID Connect 范围 email 的请求才会被授予访问权限。
2 读取访问受限于具有 orders_read 范围的客户端请求。

有关 io.quarkus.security.PermissionsAllowed 注释的更多信息,请参阅 “Web 端点授权” 指南的 Permission annotation 部分。

Token verification and introspection

如果令牌是 JWT 令牌,则默认情况下,它将使用来自本地 JsonWebKeySetJsonWebKey (JWK) 密钥进行验证,该密钥是通过 OIDC 提供程序的 JWK 端点检索的。令牌的密钥标识符 (kid) 头部值用于查找匹配的 JWK 密钥。如果本地没有可用的匹配 JWK,则通过从 JWK 端点获取当前密钥集刷新 JsonWebKeySetJsonWebKeySet 刷新只能在 quarkus.oidc.token.forced-jwk-refresh-interval 过期之后才能重复执行。默认过期时间为 10 分钟。如果刷新后仍没有可用的匹配 JWK,则 JWT 令牌将发送到 OIDC 提供程序的令牌内省端点。

如果令牌是不透明的,这意味着它可以是二进制令牌或加密的 JWT 令牌,则它总是会被发送到 OIDC 提供程序的令牌内省端点。

如果您仅使用 JWT 令牌并期望始终提供匹配的 JsonWebKey,例如在刷新密钥集之后,则必须禁用令牌内省,如下例所示:

quarkus.oidc.token.allow-jwt-introspection=false
quarkus.oidc.token.allow-opaque-token-introspection=false

在某些情况下,必须仅通过内省验证 JWT 令牌,可以通过仅配置内省端点地址来强制执行。以下属性配置展示了如何使用 Keycloak 实现此目的:

quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus
quarkus.oidc.discovery-enabled=false
# Token Introspection endpoint: http://localhost:8180/realms/quarkus/protocol/openid-connect/tokens/introspect
quarkus.oidc.introspection-path=/protocol/openid-connect/tokens/introspect

远程强制执行 JWT 令牌内省具有优缺点。一种优势是,它消除了对两个远程调用的需求:一个远程 OIDC 元数据发现调用,随后是另一个远程调用来获取不会被使用的验证密钥。一种劣势是,您需要了解内省端点地址并手动进行配置。

另一种方法是允许 OIDC 元数据发现的默认选项,但也要求仅执行远程 JWT 内省,如下例所示:

quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus
quarkus.oidc.token.require-jwt-introspection-only=true

此方法的一个优点是配置更简单且更容易理解。一种劣势是,即使不会获取验证密钥,也需要进行远程 OIDC 元数据发现调用来发现内省端点地址。

将创建一个 io.quarkus.oidc.TokenIntrospection,一个简单的 jakarta.json.JsonObject 包装对象。它可以作为 SecurityIdentity introspection 属性进行注入或访问,前提是已经成功内省了 JWT 或不透明令牌。

Token introspection and UserInfo cache

所有不透明访问令牌必须远程内省。有时,JWT 访问令牌也可能必须内省。如果 UserInfo 也是必需的,则在后续的远程调用中向 OIDC 提供程序使用相同的访问令牌。因此,如果 UserInfo 是必需的,并且当前访问令牌是不透明的,则针对每个此类令牌进行两个远程调用;一个远程调用来内省令牌,另一个远程调用来获取 UserInfo。如果令牌是 JWT,则只需要进行一个远程调用来获取 UserInfo,除非它也必须被内省。

对于每个传入承载令牌或代码流程访问令牌进行多达两个远程调用的成本有时可能是有问题的。

如果在生产过程中出现了这种情况,请考虑将令牌自省和 UserInfo 数据缓存一小段时间,例如 3 或 5 分钟。

quarkus-oidc 提供 quarkus.oidc.TokenIntrospectionCachequarkus.oidc.UserInfoCache 接口,可用于 @ApplicationScoped 缓存实现。使用 @ApplicationScoped 缓存实现来存储和检索 quarkus.oidc.TokenIntrospection 和/或 quarkus.oidc.UserInfo 对象,如下例所示:

@ApplicationScoped
@Alternative
@Priority(1)
public class CustomIntrospectionUserInfoCache implements TokenIntrospectionCache, UserInfoCache {
...
}

每个 OIDC 租户都可以通过布尔 quarkus.oidc."tenant".allow-token-introspection-cachequarkus.oidc."tenant".allow-user-info-cache 属性允许或拒绝存储其 quarkus.oidc.TokenIntrospection 数据、quarkus.oidc.UserInfo 数据,或两者都存储。

此外,quarkus-oidc 提供了一个简单的基于内存的默认令牌缓存,它同时实现了 quarkus.oidc.TokenIntrospectionCachequarkus.oidc.UserInfoCache 接口。

您可以按如下方式配置和激活 OIDC 令牌缓存:

# 'max-size' is 0 by default, so the cache can be activated by setting 'max-size' to a positive value:
quarkus.oidc.token-cache.max-size=1000
# 'time-to-live' specifies how long a cache entry can be valid for and will be used by a cleanup timer:
quarkus.oidc.token-cache.time-to-live=3M
# 'clean-up-timer-interval' is not set by default, so the cleanup timer can be activated by setting 'clean-up-timer-interval':
quarkus.oidc.token-cache.clean-up-timer-interval=1M

默认的缓存以令牌作为键,并且每个条目可以具有 TokenIntrospectionUserInfo`或两者。它只会保留最多 `max-size 个条目。当添加新条目时,如果缓存已满,则会尝试通过删除单个过期的条目来寻找空间。此外,如果激活了清理计时器,则它会定期检查过期的条目并将其删除。

您可以试用默认的缓存实现或注册自定义缓存。

JSON Web Token claim verification

在验证承载人 JWT 令牌的签名并检查其 expires at (exp) 声明后,接下来将验证 iss (issuer) 声明值。

默认情况下,会将 iss 声明值与已知提供程序配置中可能已发现的 issuer 属性进行比较。但是,如果设置了 quarkus.oidc.token.issuer 属性,则会将 iss 声明值与之进行比较。

在某些情况下,此 iss 声明验证可能无法正常工作。例如,如果发现的 issuer 属性包含一个内部 HTTP/IP 地址,而令牌 iss 声明值包含一个外部 HTTP/IP 地址。或者当发现的 issuer 属性包含模板租户变量时,而令牌 iss 声明值具有完整的租户特定的颁发者值。

在这些情况下,请考虑通过设置 quarkus.oidc.token.issuer=any 来跳过颁发者验证。仅在没有其他选项可用时才跳过颁发者验证:

  • 如果您正在使用 Keycloak 并且观察到由不同主机地址引起的颁发者验证错误,请使用 KEYCLOAK_FRONTEND_URL 属性配置 Keycloak 以确保使用相同的主机地址。

  • 如果在多租户部署中 iss 属性是租户特定的,请使用 SecurityIdentity tenant-id 属性来检查颁发者在端点或自定义 Jakarta 过滤器中是否正确。例如:

import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;

import org.eclipse.microprofile.jwt.JsonWebToken;
import io.quarkus.oidc.OidcConfigurationMetadata;
import io.quarkus.security.identity.SecurityIdentity;

@Provider
public class IssuerValidator implements ContainerRequestFilter {
    @Inject
    OidcConfigurationMetadata configMetadata;

    @Inject JsonWebToken jwt;
    @Inject SecurityIdentity identity;

    public void filter(ContainerRequestContext requestContext) {
        String issuer = configMetadata.getIssuer().replace("{tenant-id}", identity.getAttribute("tenant-id"));
        if (!issuer.equals(jwt.getIssuer())) {
            requestContext.abortWith(Response.status(401).build());
        }
    }
}

考虑使用 quarkus.oidc.token.audience 属性来验证令牌 aud (audience) 声明值。

Jose4j Validator

在初始化 org.eclipse.microprofile.jwt.JsonWebToken 之前,您可以注册一个自定义 Jose4j Validator 以自定义 JWT 声明验证流程。例如:

package org.acme.security.openid.connect;

import static org.eclipse.microprofile.jwt.Claims.iss;

import io.quarkus.arc.Unremovable;
import jakarta.enterprise.context.ApplicationScoped;

import org.jose4j.jwt.MalformedClaimException;
import org.jose4j.jwt.consumer.JwtContext;
import org.jose4j.jwt.consumer.Validator;

@Unremovable
@ApplicationScoped
public class IssuerValidator implements Validator { 1

    @Override
    public String validate(JwtContext jwtContext) throws MalformedClaimException {
        if (jwtContext.getJwtClaims().hasClaim(iss.name())
                && "my-issuer".equals(jwtContext.getJwtClaims().getClaimValueAsString(iss.name()))) {
            return "wrong issuer"; 2
        }
        return null; 3
    }
}
1 注册 Jose4j 验证器来验证所有 OIDC 租户的 JWT 令牌。
2 返回声明验证错误描述。
3 返回 null 以确认该验证器已成功验证令牌。

使用 @quarkus.oidc.TenantFeature 注释将自定义验证器仅绑定到特定 OIDC 租户。

Single-page applications

单页应用程序 (SPA) 通常使用 XMLHttpRequest(XHR) 和 OIDC 提供商提供的 JavaScript 实用工具代码获取承载令牌来访问 Quarkus `service`应用程序。

例如,如果你使用 Keycloak,你可以用 `keycloak.js`对用户进行身份验证,并且刷新 SPA 中过期的令牌:

<html>
<head>
    <title>keycloak-spa</title>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="http://localhost:8180/js/keycloak.js"></script>
    <script>
        var keycloak = new Keycloak();
        keycloak.init({onLoad: 'login-required'}).success(function () {
            console.log('User is now authenticated.');
        }).error(function () {
            window.location.reload();
        });
        function makeAjaxRequest() {
            axios.get("/api/hello", {
                headers: {
                    'Authorization': 'Bearer ' + keycloak.token
                }
            })
            .then( function (response) {
                console.log("Response: ", response.status);
            }).catch(function (error) {
                console.log('refreshing');
                keycloak.updateToken(5).then(function () {
                    console.log('Token refreshed');
                }).catch(function () {
                    console.log('Failed to refresh token');
                    window.location.reload();
                });
            });
    }
    </script>
</head>
<body>
    <button onclick="makeAjaxRequest()">Request</button>
</body>
</html>

Cross-origin resource sharing

如果你计划在你运行于不同域的单页应用程序中使用你的 OIDC `service`应用程序,你必须配置跨源资源共享 (CORS)。更多信息,请参阅“跨源资源共享”指南的 CORS filter部分。

Provider endpoint configuration

一个 OIDC service`应用程序需要知道 OIDC 提供商的令牌、`JsonWebKey (JWK) 集合,可能还有 `UserInfo`和内省终结点地址。

默认情况下,它们是通过向配置的 `quarkus.oidc.auth-server-url`添加一个 `/.well-known/openid-configuration`路径而被发现的。

此外,如果发现终结点不可用,或者如果你想节约发现终结点往返时间,你可以禁用发现并使用相对路径值对其进行配置。例如:

quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus
quarkus.oidc.discovery-enabled=false
# Token endpoint: http://localhost:8180/realms/quarkus/protocol/openid-connect/token
quarkus.oidc.token-path=/protocol/openid-connect/token
# JWK set endpoint: http://localhost:8180/realms/quarkus/protocol/openid-connect/certs
quarkus.oidc.jwks-path=/protocol/openid-connect/certs
# UserInfo endpoint: http://localhost:8180/realms/quarkus/protocol/openid-connect/userinfo
quarkus.oidc.user-info-path=/protocol/openid-connect/userinfo
# Token Introspection endpoint: http://localhost:8180/realms/quarkus/protocol/openid-connect/tokens/introspect
quarkus.oidc.introspection-path=/protocol/openid-connect/tokens/introspect

Token propagation

有关承载访问令牌传播到下游服务的信息,请参阅 Quarkus“OpenID Connect (OIDC) 和 OAuth2 客户端及过滤器参考”指南的 Token propagation部分。

JWT token certificate chain

在某些情况下,JWT 承载令牌有一个 `x5c`头,它表示一条 X509 证书链,其末级证书包含一个必须用来验证该令牌签名的公钥。在接受该公钥来验证签名之前,必须首先验证证书链。证书链验证涉及几个步骤:

  1. 确认根证书之外的每张证书都是由父证书签名的。

  2. 确认链的根证书也已导入到信托库中。

  3. 验证链的末级证书。如果配置了末级证书的公用名,则链的末级证书的公用名必须与之匹配。否则,信托库中还必须存在链的末级证书,除非注册了一个或多个自定义 `TokenCertificateValidator`实现。

  4. `quarkus.oidc.TokenCertificateValidator`可用于添加自定义证书链验证步骤。它可以被期望拥有证书链或者绑定到带有 `@quarkus.oidc.TenantFeature`注释的特定 OIDC 租户的所有租户使用。

例如,以下是如何配置 Quarkus OIDC 来验证令牌证书链,而无需使用 quarkus.oidc.TokenCertificateValidator

quarkus.oidc.certificate-chain.trust-store-file=truststore-rootcert.p12 1
quarkus.oidc.certificate-chain.trust-store-password=storepassword
quarkus.oidc.certificate-chain.leaf-certificate-name=www.quarkusio.com 2
1 信托库中必须包含证书链的根证书。
2 证书链的末级证书的公用名必须等于 www.quarkusio.com。如果未配置该属性,则信托库中必须包含证书链的末级证书,除非注册了一个或多个自定义 `TokenCertificateValidator`实现。

你可以通过注册自定义 `quarkus.oidc.TokenCertificateValidator`来添加自定义证书链验证步骤,例如:

package io.quarkus.it.keycloak;

import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.List;

import jakarta.enterprise.context.ApplicationScoped;

import io.quarkus.arc.Unremovable;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TokenCertificateValidator;
import io.quarkus.oidc.runtime.TrustStoreUtils;
import io.vertx.core.json.JsonObject;

@ApplicationScoped
@Unremovable
public class BearerGlobalTokenChainValidator implements TokenCertificateValidator {

    @Override
    public void validate(OidcTenantConfig oidcConfig, List<X509Certificate> chain, String tokenClaims) throws CertificateException {
        String rootCertificateThumbprint = TrustStoreUtils.calculateThumprint(chain.get(chain.size() - 1));
        JsonObject claims = new JsonObject(tokenClaims);
        if (!rootCertificateThumbprint.equals(claims.getString("root-certificate-thumbprint"))) { 1
            throw new CertificateException("Invalid root certificate");
        }
    }
}
1 确认证书链的根证书绑定到自定义 JWT 令牌的声明。

OIDC provider client authentication

当需要向 OIDC 提供商发出远程请求时,则使用 quarkus.oidc.runtime.OidcProviderClient。如果必须内省承载令牌,则 `OidcProviderClient`必须对 OIDC 提供商进行身份验证。有关支持的身份验证选项的更多信息,请参阅 Quarkus“保护 Web 应用程序的 OpenID Connect 授权代码流机制”指南的 OIDC provider client authentication部分。

Testing

如果你必须测试需要 Keycloak authorization的 Quarkus OIDC 服务终结点,请按照 Test Keycloak authorization部分进行操作。

你可以通过向测试项目中添加以下依赖项,开始测试:

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

WireMock

向测试项目中添加以下依赖项:

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-test-oidc-server</artifactId>
    <scope>test</scope>
</dependency>
build.gradle
testImplementation("io.quarkus:quarkus-test-oidc-server")

准备 REST 测试端点,并设置 application.properties 。例如:

# keycloak.url is set by OidcWiremockTestResource
quarkus.oidc.auth-server-url=${keycloak.url:replaced-by-test-resource}/realms/quarkus/
quarkus.oidc.client-id=quarkus-service-app
quarkus.oidc.application-type=service

最后,编写测试代码。例如:

import static org.hamcrest.Matchers.equalTo;

import java.util.Set;

import org.junit.jupiter.api.Test;

import io.quarkus.test.common.WithTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.oidc.server.OidcWiremockTestResource;
import io.restassured.RestAssured;
import io.smallrye.jwt.build.Jwt;

@QuarkusTest
@WithTestResource(OidcWiremockTestResource.class)
public class BearerTokenAuthorizationTest {

    @Test
    public void testBearerToken() {
        RestAssured.given().auth().oauth2(getAccessToken("alice", Set.of("user")))
            .when().get("/api/users/me")
            .then()
            .statusCode(200)
            // The test endpoint returns the name extracted from the injected `SecurityIdentity` principal.
            .body("userName", equalTo("alice"));
    }

    private String getAccessToken(String userName, Set<String> groups) {
        return Jwt.preferredUserName(userName)
                .groups(groups)
                .issuer("https://server.example.com")
                .audience("https://service.example.com")
                .sign();
    }
}

quarkus-test-oidc-server 扩展包括一个采用 JSON Web Key (JWK) 格式的签名 RSA 私有密钥文件,并使用 smallrye.jwt.sign.key.location 配置属性指向它。它允许你使用无参数 sign() 操作签署令牌。

使用 OidcWiremockTestResource 测试你的 quarkus-oidc service 应用程序,可以提供最佳覆盖率,因为即使通信通道也能针对 WireMock HTTP 存根进行测试。如果你需要使用 OidcWiremockTestResource 尚未支持的 WireMock 存根运行测试,你可以将 WireMockServer 实例注入到测试类中,如下例所示:

OidcWiremockTestResource 不能配合 @QuarkusIntegrationTest 用于针对 Docker 容器的测试,因为 WireMock 服务器运行在运行测试的 JVM 中,而 Docker 容器运行的 Quarkus 应用程序无法访问该 JVM。

package io.quarkus.it.keycloak;

import static com.github.tomakehurst.wiremock.client.WireMock.matching;
import static org.hamcrest.Matchers.equalTo;

import org.junit.jupiter.api.Test;

import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.oidc.server.OidcWireMock;
import io.restassured.RestAssured;

@QuarkusTest
public class CustomOidcWireMockStubTest {

    @OidcWireMock
    WireMockServer wireMockServer;

    @Test
    public void testInvalidBearerToken() {
        wireMockServer.stubFor(WireMock.post("/auth/realms/quarkus/protocol/openid-connect/token/introspect")
                .withRequestBody(matching(".*token=invalid_token.*"))
                .willReturn(WireMock.aResponse().withStatus(400)));

        RestAssured.given().auth().oauth2("invalid_token").when()
                .get("/api/users/me/bearer")
                .then()
                .statusCode(401)
                .header("WWW-Authenticate", equalTo("Bearer"));
    }
}

OidcTestClient

如果你使用 SaaS OIDC 提供程序(如 Auth0),并且想针对测试(开发)域名运行测试,或针对远程 keycloak 测试领域运行测试,则可以在已经配置 quarkus.oidc.auth-server-url 的前提下使用 OidcTestClient

例如,你采用了以下配置:

%test.quarkus.oidc.auth-server-url=https://dev-123456.eu.auth0.com/
%test.quarkus.oidc.client-id=test-auth0-client
%test.quarkus.oidc.credentials.secret=secret

开始时,添加相同的依赖项 quarkus-test-oidc-server,如 WireMock 部分中所述。

接下来,按如下方式编写测试代码:

package org.acme;

import org.junit.jupiter.api.AfterAll;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.is;

import java.util.Map;

import org.junit.jupiter.api.Test;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.oidc.client.OidcTestClient;

@QuarkusTest
public class GreetingResourceTest {

    static OidcTestClient oidcTestClient = new OidcTestClient();

    @AfterAll
    public static void close() {
        oidcTestClient.close();
    }

    @Test
    public void testHelloEndpoint() {
        given()
          .auth().oauth2(getAccessToken("alice", "alice"))
          .when().get("/hello")
          .then()
             .statusCode(200)
             .body(is("Hello, Alice"));
    }

    private String getAccessToken(String name, String secret) {
        return oidcTestClient.getAccessToken(name, secret,
            Map.of("audience", "https://dev-123456.eu.auth0.com/api/v2/",
	           "scope", "profile"));
    }
}

此测试代码使用测试 Auth0 域中的 password 授权,从该域获取令牌,该域已使用客户端 ID test-auth0-client 注册了应用程序,并使用密码 alice 创建了用户 alice。此类测试要起作用,测试 Auth0 应用程序必须启用 password 授权。此示例代码还展示了如何传递其他参数。对于 Auth0 来说,这些是 audiencescope 参数。

Dev Services for Keycloak

针对 Keycloak 进行集成测试的首选方法为 Dev Services for KeycloakDev Services for Keycloak 将启动和初始化一个测试容器。然后,它将创建一个 quarkus 领域和一个 quarkus-app 客户端 (secret 机密),并添加 alice (adminuser 角色) 和 bob (user 角色) 用户,所有这些属性都可自定义。

首先,添加以下依赖项,它提供了一个实用程序类 io.quarkus.test.keycloak.client.KeycloakTestClient,可以在测试中使用它来获取访问令牌:

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

接下来,准备 application.properties 配置文件。你可以从一个空 application.properties 文件开始,因为 Dev Services for Keycloak 会注册 quarkus.oidc.auth-server-url,并将其指向运行中的测试容器 quarkus.oidc.client-id=quarkus-appquarkus.oidc.credentials.secret=secret

然而,如果你已配置必需的 quarkus-oidc 属性,那么你只需要将 quarkus.oidc.auth-server-urlprod 配置关联,以便为 Keycloak 启动一个容器,如下例所示:

%prod.quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus

如果在运行测试前需要将一个自定义领域文件导入 Keycloak,请按如下方式配置 Dev Services for Keycloak

%prod.quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus
quarkus.keycloak.devservices.realm-path=quarkus-realm.json

最后,编写你的测试,它将在 JVM 模式中执行,如下例所示:

Example of a test executed in JVM mode:
package org.acme.security.openid.connect;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.keycloak.client.KeycloakTestClient;
import io.restassured.RestAssured;
import org.junit.jupiter.api.Test;

@QuarkusTest
public class BearerTokenAuthenticationTest {

    KeycloakTestClient keycloakClient = new KeycloakTestClient();

    @Test
    public void testAdminAccess() {
        RestAssured.given().auth().oauth2(getAccessToken("alice"))
                .when().get("/api/admin")
                .then()
                .statusCode(200);
        RestAssured.given().auth().oauth2(getAccessToken("bob"))
                .when().get("/api/admin")
                .then()
                .statusCode(403);
    }

    protected String getAccessToken(String userName) {
        return keycloakClient.getAccessToken(userName);
    }
}
Example of a test executed in native mode:
package org.acme.security.openid.connect;

import io.quarkus.test.junit.QuarkusIntegrationTest;

@QuarkusIntegrationTest
public class NativeBearerTokenAuthenticationIT extends BearerTokenAuthenticationTest {
}

有关为 Keycloak 初始化和配置 Dev Services 的更多信息,请参阅 Dev Services for Keycloak 指南。

KeycloakTestResourceLifecycleManager

你也可以使用 KeycloakTestResourceLifecycleManager 与 Keycloak 进行集成测试。

除非你有特殊需求需要使用 KeycloakTestResourceLifecycleManager,否则,在与 Keycloak 进行集成测试时,使用 Dev Services for Keycloak 来替代 KeycloakTestResourceLifecycleManager

首先,添加以下依赖关系:

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

它提供了 io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager,这是一个实现 `io.quarkus.test.common.QuarkusTestResourceLifecycleManager`的 Keycloak 容器启动。

像下面一样配置 Maven Surefire 插件,或使用 maven.failsafe.plugin 类似地进行本机映像测试:

<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <systemPropertyVariables>
            <!-- Or, alternatively, configure 'keycloak.version' -->
            <keycloak.docker.image>${keycloak.docker.image}</keycloak.docker.image>
            <!--
              Disable HTTPS if required:
              <keycloak.use.https>false</keycloak.use.https>
            -->
        </systemPropertyVariables>
    </configuration>
</plugin>

准备 REST 测试端点并设置 application.properties,如下例所示:

# keycloak.url is set by KeycloakTestResourceLifecycleManager
quarkus.oidc.auth-server-url=${keycloak.url:replaced-by-test-resource}/realms/quarkus/
quarkus.oidc.client-id=quarkus-service-app
quarkus.oidc.credentials=secret
quarkus.oidc.application-type=service

最后,编写测试代码。例如:

import static io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager.getAccessToken;
import static org.hamcrest.Matchers.equalTo;

import org.junit.jupiter.api.Test;

import io.quarkus.test.common.WithTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager;
import io.restassured.RestAssured;

@QuarkusTest
@WithTestResource(KeycloakTestResourceLifecycleManager.class)
public class BearerTokenAuthorizationTest {

    @Test
    public void testBearerToken() {
        RestAssured.given().auth().oauth2(getAccessToken("alice"))
            .when().get("/api/users/preferredUserName")
            .then()
            .statusCode(200)
            // The test endpoint returns the name extracted from the injected SecurityIdentity Principal
            .body("userName", equalTo("alice"));
    }

}
Summary

在提供的示例中, KeycloakTestResourceLifecycleManager 注册了两个用户: aliceadmin。默认情况下:*用户 alice 拥有 user 角色,你可以使用 keycloak.token.user-roles 系统属性进行自定义。*用户 admin 拥有 useradmin 角色,你可以使用 keycloak.token.admin-roles 系统属性进行自定义。

默认情况下, KeycloakTestResourceLifecycleManager 使用 HTTPS 初始化 Keycloak 实例,你可以使用 keycloak.use.https=false 来禁用它。默认域名称是 quarkus,客户端 ID 是 quarkus-service-app。如果你想自定义这些值,请设置 keycloak.realmkeycloak.service.client 系统属性。

Local public key

你可以使用本地内嵌公钥来测试你的 quarkus-oidc service 应用程序,如下例所示:

quarkus.oidc.client-id=test
quarkus.oidc.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlivFI8qB4D0y2jy0CfEqFyy46R0o7S8TKpsx5xbHKoU1VWg6QkQm+ntyIv1p4kE1sPEQO73+HY8+Bzs75XwRTYL1BmR1w8J5hmjVWjc6R2BTBGAYRPFRhor3kpM6ni2SPmNNhurEAHw7TaqszP5eUF/F9+KEBWkwVta+PZ37bwqSE4sCb1soZFrVz/UT/LF4tYpuVYt3YbqToZ3pZOZ9AX2o1GCG3xwOjkc4x0W7ezbQZdC9iftPxVHR8irOijJRRjcPDtA6vPKpzLl6CyYnsIYPd99ltwxTHjr3npfv/3Lw50bAkbT4HeLFxTx4flEoZLKO/g0bAoV2uqBhkA9xnQIDAQAB

smallrye.jwt.sign.key.location=/privateKey.pem

要生成 JWT token,从 main Quarkus 存储库中的 integration-tests/oidc-tenancy 复制 privateKey.pem,并使用类似于前面 WireMock 部分中的测试代码。如果你愿意,可以使用自己的测试密钥。

与 WireMock 方法相比,此方法提供的覆盖范围有限。例如,远程通信代码未涵盖。

TestSecurity annotation

你可以使用 @TestSecurity@OidcSecurity 注解来测试 service 应用程序端点代码,它依赖于以下三种注入中的任意一种,或全部三种:

  • JsonWebToken

  • UserInfo

  • OidcConfigurationMetadata

首先,添加以下依赖关系:

pom.xml
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-test-security-oidc</artifactId>
    <scope>test</scope>
</dependency>
build.gradle
testImplementation("io.quarkus:quarkus-test-security-oidc")

按照以下示例编写测试代码:

import static org.hamcrest.Matchers.is;
import org.junit.jupiter.api.Test;
import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import io.quarkus.test.security.oidc.Claim;
import io.quarkus.test.security.oidc.ConfigMetadata;
import io.quarkus.test.security.oidc.OidcSecurity;
import io.quarkus.test.security.oidc.OidcConfigurationMetadata;
import io.quarkus.test.security.oidc.UserInfo;
import io.restassured.RestAssured;

@QuarkusTest
@TestHTTPEndpoint(ProtectedResource.class)
public class TestSecurityAuthTest {

    @Test
    @TestSecurity(user = "userOidc", roles = "viewer")
    public void testOidc() {
        RestAssured.when().get("test-security-oidc").then()
                .body(is("userOidc:viewer"));
    }

    @Test
    @TestSecurity(user = "userOidc", roles = "viewer")
    @OidcSecurity(claims = {
            @Claim(key = "email", value = "user@gmail.com")
    }, userinfo = {
            @UserInfo(key = "sub", value = "subject")
    }, config = {
            @ConfigMetadata(key = "issuer", value = "issuer")
    })
    public void testOidcWithClaimsUserInfoAndMetadata() {
        RestAssured.when().get("test-security-oidc-claims-userinfo-metadata").then()
                .body(is("userOidc:viewer:user@gmail.com:subject:issuer"));
    }

}

本代码示例中使用的 ProtectedResource 类可能如下所示:

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

import io.quarkus.oidc.OidcConfigurationMetadata;
import io.quarkus.oidc.UserInfo;
import io.quarkus.security.Authenticated;

import org.eclipse.microprofile.jwt.JsonWebToken;

@Path("/service")
@Authenticated
public class ProtectedResource {

    @Inject
    JsonWebToken accessToken;
    @Inject
    UserInfo userInfo;
    @Inject
    OidcConfigurationMetadata configMetadata;

    @GET
    @Path("test-security-oidc")
    public String testSecurityOidc() {
        return accessToken.getName() + ":" + accessToken.getGroups().iterator().next();
    }

    @GET
    @Path("test-security-oidc-claims-userinfo-metadata")
    public String testSecurityOidcWithClaimsUserInfoMetadata() {
        return accessToken.getName() + ":" + accessToken.getGroups().iterator().next()
                + ":" + accessToken.getClaim("email")
                + ":" + userInfo.getString("sub")
                + ":" + configMetadata.get("issuer");
    }
}

你必须始终使用 @TestSecurity 注解。它的 user 属性返回 JsonWebToken.getName(),它的 roles 属性返回 JsonWebToken.getGroups()@OidcSecurity 注解是可选的,你可以使用它设置其他 token 声明以及 UserInfoOidcConfigurationMetadata 属性。此外,如果 quarkus.oidc.token.issuer 属性已配置,则它将用作 OidcConfigurationMetadata issuer 属性值。

如果你使用不透明 token,可以按照以下代码示例进行测试:

import static org.hamcrest.Matchers.is;
import org.junit.jupiter.api.Test;
import io.quarkus.test.common.http.TestHTTPEndpoint;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import io.quarkus.test.security.oidc.OidcSecurity;
import io.quarkus.test.security.oidc.TokenIntrospection;
import io.restassured.RestAssured;

@QuarkusTest
@TestHTTPEndpoint(ProtectedResource.class)
public class TestSecurityAuthTest {

    @Test
    @TestSecurity(user = "userOidc", roles = "viewer")
    @OidcSecurity(introspectionRequired = true,
        introspection = {
            @TokenIntrospection(key = "email", value = "user@gmail.com")
        }
    )
    public void testOidcWithClaimsUserInfoAndMetadata() {
        RestAssured.when().get("test-security-oidc-claims-userinfo-metadata").then()
                .body(is("userOidc:viewer:userOidc:viewer"));
    }

}

本代码示例中使用的 ProtectedResource 类可能如下所示:

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

import io.quarkus.oidc.TokenIntrospection;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;

@Path("/service")
@Authenticated
public class ProtectedResource {

    @Inject
    SecurityIdentity securityIdentity;
    @Inject
    TokenIntrospection introspection;

    @GET
    @Path("test-security-oidc-opaque-token")
    public String testSecurityOidcOpaqueToken() {
        return securityIdentity.getPrincipal().getName() + ":" + securityIdentity.getRoles().iterator().next()
            + ":" + introspection.getString("username")
            + ":" + introspection.getString("scope")
            + ":" + introspection.getString("email");
    }
}

@TestSecurityuser、和 roles 属性可用作 TokenIntrospectionusername、和 scope 属性。使用 io.quarkus.test.security.oidc.TokenIntrospection 添加其他 introspection 响应属性,例如 email,等等。

可以将 @TestSecurity@OidcSecurity 组合在一个元注解中,如下例所示:

    @Retention(RetentionPolicy.RUNTIME)
    @Target({ ElementType.METHOD })
    @TestSecurity(user = "userOidc", roles = "viewer")
    @OidcSecurity(introspectionRequired = true,
        introspection = {
            @TokenIntrospection(key = "email", value = "user@gmail.com")
        }
    )
    public @interface TestSecurityMetaAnnotation {

    }

如果多个测试方法必须使用同一组安全设置,这将特别有用。

Check errors in the logs

要查看令牌验证错误的更多详细信息,请启用 @[3] 和 @[4] 级别日志记录:

quarkus.log.category."io.quarkus.oidc.runtime.OidcProvider".level=TRACE
quarkus.log.category."io.quarkus.oidc.runtime.OidcProvider".min-level=TRACE

要查看 @[5] 客户端初始化错误的更多详细信息,请启用 @[6] 和 @[7] 级别日志记录,如下所示:

quarkus.log.category."io.quarkus.oidc.runtime.OidcRecorder".level=TRACE
quarkus.log.category."io.quarkus.oidc.runtime.OidcRecorder".min-level=TRACE

External and internal access to OIDC providers

OIDC 提供商的外部分发令牌和其他端点与自动发现的 URL 或相对于 @[8] 内部 URL 配置的 URL,可能具有不同的 HTTP(S) URL。例如,假设您的 SPA 从外部令牌端点地址获取令牌,并将其作为持有者令牌发送到 Quarkus。在这种情况下,端点可能会报告颁发者验证失败。

在这些情况下,如果您使用 Keycloak,请使用 @[9] 系统属性将其设置为外部可访问的 URL。如果您使用其他 OIDC 提供商,请参阅您的提供商的文档。

Using the client-id property

@[10] 属性标识请求当前持有者令牌的 OIDC 客户端。OIDC 客户端可以是在浏览器中运行的 SPA 应用程序或将访问令牌传播到 Quarkus @[11] 机密客户端应用程序的 Quarkus @[12] 应用程序。

如果 @[13] 应用程序预期远程检查令牌(对于不透明令牌始终如此),则此属性是必需的。对于本地 JSON Web 令牌 (JWT) 验证,此属性是可选的。

即使端点不需要访问远程 introspection 端点,也建议设置 @[14] 属性。这是因为在设置 @[15] 时,它可用于验证令牌受众。当令牌验证失败时,它也会包含在日志中,从而可以更好地追溯颁发给特定客户端的令牌,并在较长时间内进行分析。

例如,如果您的 OIDC 提供商设置了令牌受众,请考虑以下配置模式:

# Set client-id
quarkus.oidc.client-id=quarkus-app
# Token audience claim must contain 'quarkus-app'
quarkus.oidc.token.audience=${quarkus.oidc.client-id}

如果您设置了 @[16],但是您的端点不要求远程访问 OIDC 提供商端点(自省、令牌获取等)之一,则不要使用 @[17] 或类似属性设置客户端机密,因为它将不会被使用。

Quarkus @[18] 应用程序始终需要 @[19] 属性。

Authentication after an HTTP request has completed

有时,当没有活动的 HTTP 请求上下文时,必须为给定令牌创建 @[20]。@[21] 扩展提供了 @[22],用于将令牌转换为 @[23] 实例。例如,HTTP 请求完成后必须验证令牌的一种情况是,您正在处理带 @[25] 的消息。下面的示例在不同的 CDI 请求上下文中使用“product-order”消息。因此,注入的 @[24] 不会正确地表示已验证的身份,并且是匿名的。

package org.acme.quickstart.oidc;

import static jakarta.ws.rs.core.HttpHeaders.AUTHORIZATION;

import jakarta.inject.Inject;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import io.vertx.core.eventbus.EventBus;

@Path("order")
public class OrderResource {

    @Inject
    EventBus eventBus;

    @POST
    public void order(String product, @HeaderParam(AUTHORIZATION) String bearer) {
        String rawToken = bearer.substring("Bearer ".length()); 1
        eventBus.publish("product-order", new Product(product, rawToken));
    }

    public static class Product {
         public String product;
         public String customerAccessToken;
         public Product() {
         }
         public Product(String product, String customerAccessToken) {
             this.product = product;
             this.customerAccessToken = customerAccessToken;
         }
    }
}
1 此时,当主动认证被禁用时,不会验证令牌。
package org.acme.quickstart.oidc;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.Tenant;
import io.quarkus.oidc.TenantIdentityProvider;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.ConsumeEvent;
import io.smallrye.common.annotation.Blocking;

@ApplicationScoped
public class OrderService {

    @Tenant("tenantId")
    @Inject
    TenantIdentityProvider identityProvider;

    @Inject
    TenantIdentityProvider defaultIdentityProvider; 1

    @Blocking
    @ConsumeEvent("product-order")
    void processOrder(Product product) {
        AccessTokenCredential tokenCredential = new AccessTokenCredential(product.customerAccessToken);
        SecurityIdentity securityIdentity = identityProvider.authenticate(tokenCredential).await().indefinitely(); 2
        ...
    }

}
1 对于默认租户,@[26] 限定符是可选的。
2 执行令牌验证并将令牌转换为 @[27]。

当在 HTTP 请求期间使用提供程序时,可以按 @[29] 指南所述解析租户配置。但是,当没有活动 HTTP 请求时,您必须使用 @[28] 限定符明确选择租户。

@[30] 目前不受支持。需要动态租户的身份验证将失败。

OIDC request filters

您可以通过注册一个或多个 @[31] 实现来筛选 Quarkus 对 OIDC 提供商发出的 OIDC 请求,这些实现可以更新或添加新的请求头并记录请求。要了解更多信息,请参阅 @[32]。