OpenID Connect (OIDC) Bearer token authentication

Quarkus OpenID Connect(OIDC)エクステンションを使用することで、ベアラートークン認証を使って、アプリケーション内のJakarta REST(旧称JAX-RS)エンドポイントへのHTTPアクセスを保護できます。

Quarkusのベアラートークン認証メカニズムの概要

Quarkusは、Quarkus OpenID Connect(OIDC)エクステンションを通じて、ベアラートークン認証メカニズムをサポートしています。

ベアラートークンは、 Keycloak などのOIDCやOAuth2.0に準拠した認可サーバーで発行されます。

ベアラートークン認証は、ベアラートークンの存在と有効性に基づいて HTTP リクエストを認可するプロセスです。 ベアラートークンは、呼び出しの主体に関する情報を提供します。この情報は、HTTP リソースにアクセスできるかどうかを判断するために使用されます。

以下の図に、Quarkusのベアラートークン認証メカニズムの概要を示します:

Bearer token authentication
Figure 1. Quarkusのシングルページアプリケーションにおけるベアラー認証メカニズム
  1. Quarkusサービスでは、OpenID Connectプロバイダーから検証キーを取得します。検証キーは、Bearerアクセストークンの署名を検証するために使用されます。

  2. Quarkusユーザーは、シングルページアプリケーションにアクセスします。

  3. シングルページアプリケーションでは、Authorization Code Flowを使用してユーザーを認証し、OpenID Connectプロバイダーからトークンを取得します。

  4. シングルページアプリケーションでは、アクセストークンを使用して、Quarkusサービスからサービスデータを取得します。

  5. Quarkusサービスは、検証キーを使用してBearerアクセストークンの署名を検証し、トークンの有効期限やその他のクレームをチェックし、トークンが有効であればリクエストを続行させ、サービス応答をシングルページアプリケーションに返します。

  6. シングルページアプリケーションは、同じデータをQuarkusユーザーに返します。

Bearer token authentication
Figure 2. Quarkus のベアラートークン認証メカニズムとJava またはコマンドラインクライアント
  1. Quarkusサービスでは、OpenID Connectプロバイダーから検証キーを取得します。検証キーは、Bearerアクセストークンの署名を検証するために使用されます。

  2. クライアントは、OpenID Connectプロバイダからアクセストークンを取得するために、クライアントIDとシークレットを必要とする client_credentials 、またはクライアントID、シークレット、ユーザー名、パスワードも必要とするpassword grantを使用します。

  3. クライアントはアクセストークンを使用して、Quarkusサービスからサービスデータを取得します。

  4. Quarkusサービスは、検証キーを使用してBearerアクセストークンの署名を検証し、トークンの有効期限やその他のクレームをチェックし、トークンが有効であればリクエストの実行を許可し、サービスレスポンスをクライアントに返します。

OpenID Connect認可コードフローを使用してユーザーを認証および認可する必要がある場合は、 Webアプリケーションを保護するためのOIDCコードフロー メカニズムを参照してください。また、Keycloakとベアラートークンを使用する場合は、 Keycloakを使用して認証を一元化 するを参照してください。

OIDCベアラートークン認証を使用してサービスアプリケーションを保護する方法については、 OIDCベアラートークン認証チュートリアル を参照してください。

OIDC認可コードフロー認証を使用してWebアプリケーションを保護したい場合は、 OIDC認可コードフロー 認証を参照してください。

マルチテナントへの対応方法については、 OpenID Connect (OIDC) マルチテナンシーの使用 ガイドをお読みください。

JWT クレームへのアクセス

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";
    }
}

JsonWebToken の注入は、 @ApplicationScoped、 @Singleton、および @RequestScoped スコープでサポートされていますが、個々のクレームがシンプル型として注入される場合は、 @RequestScoped の使用が必要です。 JsonWebToken およびクレームの注入スコープのサポート を参照してください。

ユーザー情報

Set quarkus.oidc.authentication.user-info-required=true if a UserInfo JSON object from the OIDC userinfo endpoint has to be requested. A request will be sent to the OpenID Provider UserInfo endpoint and an io.quarkus.oidc.UserInfo (a simple jakarta.json.JsonObject wrapper) object will be created. io.quarkus.oidc.UserInfo can be either injected or accessed as a SecurityIdentity userinfo attribute.

設定メタデータ

現在のテナントが検出した OpenID Connect 設定メタデータ は io.quarkus.oidc.OidcConfigurationMetadata で表され、 SecurityIdentity configuration-metadata 属性として注入またはアクセスが可能です。

エンドポイントがパブリックの場合、デフォルトのテナントの OidcConfigurationMetadata が注入されます。

トークンクレームとセキュリティーアイデンティティロール

SecurityIdentity ロールは、検証済みの JWT アクセストークンから以下のようにマッピングすることができます。

  • quarkus.oidc.roles.role-claim-path プロパティーが設定されており、一致する配列または文字列のクレームが見つかった場合、このクレームからロールが抽出されます。例えば、 customroles、 customroles/array、 scope、 "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が発行するトークンをサポートします。

トークンが不透明(バイナリー)の場合は、リモートトークンイントロスペクションレスポンスの scope プロパティーが使用されます。

UserInfoがロールのソースである場合は、 quarkus.oidc.authentication.user-info-required=true と quarkus.oidc.roles.source=userinfo 、必要に応じて quarkus.oidc.roles.role-claim-path を設定します。

さらに、カスタム SecurityIdentityAugmentor をロールの追加に使用することもできます。詳しくは、 Security Identityのカスタマイズ を参照してください。

トークンの検証とイントロスペクション

トークンが JWT トークンの場合、デフォルトでは、OpenID Connect プロバイダーの JWK エンドポイントから取得したローカルの JsonWebKeySet からの JsonWebKey (JWK) キーで検証されます。トークンのキー識別子 kid ヘッダー値は、一致する JWK キーを見つけるために使用されます。一致する JWK がローカルで利用できない場合、 JsonWebKeySet は、JWK エンドポイントから現在のキーセットを取得することによって更新されます。 JsonWebKeySet の更新は、 quarkus.oidc.token.forced-jwk-refresh-interval (デフォルトは 10 分) の期限が切れた後にのみ繰り返すことができます。更新後に一致する JWK が利用できない場合、JWT トークンは OpenID Connectプロバイダーのトークンイントロスペクションエンドポイントに送信されます。

トークンが不透明な場合 (バイナリートークンまたは暗号化された JWT トークンの場合があります)、常に OpenID Connectプロバイダーのトークンイントロスペクションエンドポイントに送信されます。

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トークンがリモートでイントロスペクションされるだけのこの間接的な実施方法の利点は、2つのリモートコールが避けられることです。リモートOIDCメタデータディスカバリー呼出と、続く使用されない検証キーを取得する別のリモートコールを避けることが出来ます。一方欠点として、ユーザーがイントロスペクションエンドポイントアドレスを知って、それを手動で構成する必要があることです。

別のアプローチは、OIDCメタデータのディスカバリーは許可するが(これはデフォルトのオプション)、リモートJWTイントロスペクションのみが実行されることを要求することです:

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

この方法の利点は、設定がシンプルでわかりやすいことですが、欠点は、イントロスペクションのエンドポイントアドレスを発見するために、リモートOIDCメタデータディスカバリー呼び出しが必要なことです(検証キーもフェッチされない)。

Note that io.quarkus.oidc.TokenIntrospection (a simple jakarta.json.JsonObject wrapper) object will be created and can be either injected or accessed as a SecurityIdentity introspection attribute if either JWT or opaque token has been successfully introspected.

トークンイントロスペクションと UserInfo キャッシュ

すべての不透明な、場合によっては JWT Bearer アクセストークンは、リモートで検査する必要があります。 UserInfo も必要な場合は、同じアクセストークンを使用して OpenID Connectプロバイダーへのリモート呼び出しを再度実行します。したがって、 UserInfo が必要で、現在のアクセストークンが不透明な場合、そのようなトークンごとに 2 つのリモート呼び出しが行われます。1 つはそれを検査し、もう 1 つは UserInfo を取得します。トークンが JWT の場合、通常は UserInfo を取得するには、1 回のリモート呼び出しだけが必要になります。

着信ベアラーまたはコードフローアクセストークンごとに最大 2 つのリモート呼び出しを行うコストが、問題になる場合があります。

本番環境でこれに当てはまる場合は、トークンのイントロスペクションと UserInfo データを短時間、たとえば 3 - 5 分間キャッシュすることをお勧めします。

quarkus-oidc は quarkus.oidc.TokenIntrospectionCache と quarkus.oidc.UserInfoCache インターフェイスを提供します。これらは @ApplicationScoped キャッシュ実装を実装するために使用できます。これらは quarkus.oidc.TokenIntrospection や quarkus.oidc.UserInfo オブジェクトを取得および保存するのに使用できます。以下に例を示します。

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

各 OIDC テナントは、ブール値 quarkus.oidc."tenant".allow-token-introspection-cache および quarkus.oidc."tenant".allow-user-info-cache プロパティーを使用して、その quarkus.oidc.TokenIntrospection や quarkus.oidc.UserInfo データを保存することを許可または拒否できます。

さらに、 quarkus-oidc は、 quarkus.oidc.TokenIntrospectionCache および quarkus.oidc.UserInfoCache インターフェイスの両方を実装するシンプルなデフォルトのメモリーベースのトークンキャッシュを提供します。

これは、次のようにアクティブ化および設定できます。

# '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

デフォルトのキャッシュはトークンをキーとして使用し、各エントリーは TokenIntrospection や UserInfo を持つことができます。 max-size のエントリー数までしか保持しません。新しいエントリーを追加するときにキャッシュがいっぱいな場合、期限切れのエントリーを 1 つ削除して、スペースを見つけようとします。さらに、クリーンアップタイマーがアクティブになっている場合は、期限切れのエントリーを定期的にチェックして削除します。

デフォルトのキャッシュ実装を試すか、カスタム実装を登録してください。

JSON Web トークンのクレーム検証

ベアラのJWTトークンの署名が検証され、 expires at ( exp) のクレームが確認されると、次に iss ( issuer) クレーム値が検証されます。

デフォルトでは、 iss クレーム値は、well-knownプロバイダの設定で発見された issuer プロパティと比較されます。しかし、 quarkus.oidc.token.issuer プロパティが設定されている場合は、代わりに iss クレーム値がそれと比較されます。

場合によっては、この iss クレームの検証がうまくいかないことがあります。例えば、発見された issuer プロパティに内部の HTTP/IP アドレスが含まれている一方で、トークン iss クレーム値に外部の HTTP/IP アドレスが含まれている場合です。あるいは、発見された issuer プロパティにテンプレートのテナント変数が含まれているが、トークン iss クレーム値には完全なテナント固有の発行者の値が含まれている場合です。

このような場合には、 quarkus.oidc.token.issuer=any を設定して、issuer検証を省略することを検討してください。なお、この設定は推奨されておらず、他に選択肢がない場合を除き、避けるべきです。

  • Keycloakを使用していて、ホストアドレスが異なるために発行者検証エラーが発生する場合は、Keycloakに KEYCLOAK_FRONTEND_URL プロパティを設定して、同じホストアドレスが使用されるようにしてください。

  • If the iss property is tenant specific in a multi-tenant deployment then you can use the SecurityIdentity tenant-id attribute to check the issuer is correct in the endpoint itself or the custom Jakarta REST filter, for example:

import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
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());
        }
    }
}

なお、トークン aud ( audience) のクレーム値を検証するために quarkus.oidc.token.audience プロパティを使用することも推奨します。

シングルページアプリケーション

シングルページアプリケーション (SPA) は通常、 XMLHttpRequest (XHR) と OpenID Connect プロバイダーが提供する JavaScript ユーティリティーコードを使用してBearer Tokenを取得し、それを使用してQuarkus service アプリケーションにアクセスします。

例えば、 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>

クロスオリジンリソース共有

別のドメインで動作する Single Page Application から OpenID Connect service アプリケーションを利用する場合は、CORS (Cross-Origin Resource Sharing) を設定する必要があります。詳細については、 HTTP CORSのドキュメントを参照してください。

プロバイダーエンドポイント設定

OIDC service アプリケーションは、OpenID Connect プロバイダのトークン、 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

トークンの伝播

下流サービスへのベアラアクセストークンの伝播については、トークン伝播 の項を参照してください。

Oidc プロバイダークライアント認証

quarkus.oidc.runtime.OidcProviderClient is used when a remote request to an OpenID Connect Provider has to be done. If the bearer token has to be introspected then OidcProviderClient has to authenticate to the OpenID Connect Provider. Please see OidcProviderClient Authentication for more information about all the supported authentication options.

テスト

テストプロジェクトに以下の依存関係を追加することから始めます。

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}/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.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.oidc.server.OidcWiremockTestResource;
import io.restassured.RestAssured;
import io.smallrye.jwt.build.Jwt;

@QuarkusTest
@QuarkusTestResource(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 () 操作を使用してトークンに署名できます。

quarkus-oidc service アプリケーションを OidcWiremockTestResource でテストすると、通信チャネルまで Wiremock HTTP スタブに対してテストされるため、最高のカバレッジが得られます。 OidcWiremockTestResource は今後、より複雑なベアラートークンのテストシナリオをサポートするために拡張される予定です。

現在 OidcWiremockTestResource でサポートされていない Wiremock スタブを定義するテストがすぐに必要な場合は、テストクラスに注入された WireMockServer インスタンスを介して行うことができます。次に例を示します。

Wiremock サーバーはテストを実行している JVM で実行されており、Quarkus アプリケーションを実行している Docker コンテナーからはアクセスできないため、 OidcWiremockTestResource は Docker コンテナーに対して @QuarkusIntegrationTest では機能しません。

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"));
    }
}

Dev Services for Keycloak

Keycloak に対する統合テストには、Dev Services for Keycloak を使用することをお勧めします。 Dev Services for Keycloak は、テストコンテナーを起動して初期化します。これにより、 quarkus レルム、 quarkus-app クライアント (secret シークレット) が作成され、 alice (admin および user ロール) および bob (user ロール) ユーザーが追加されます。これらのプロパティーはすべてカスタマイズできます。

まず、次の依存関係を追加する必要があります。

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.client.KeycloakTestClient を提供します。

次に、 application.properties を準備します。完全に空の application.properties から始めることができます。これは、 Dev Services for Keycloak が実行中のテストコンテナーをポイントする quarkus.oidc.auth-server-url ならびに quarkus.oidc.client-id=quarkus-app および quarkus.oidc.credentials.secret=secret を登録するためです。

ただし、必要なすべての quarkus-oidc プロパティーがすでに設定されている場合は、 quarkus.oidc.auth-server-url を Dev Services for Keycloak の prod プロファイルに関連付けるだけでコンテナーを起動できます。以下に例を示します。

%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 モードで実行されるテストを作成します。

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);
    }
}

および、ネイティブモードで以下を実行します。

package org.acme.security.openid.connect;

import io.quarkus.test.junit.QuarkusIntegrationTest;

@QuarkusIntegrationTest
public class NativeBearerTokenAuthenticationIT extends BearerTokenAuthenticationTest {
}

初期化および設定方法の詳細については、Dev Services for Keycloak を参照してください。

KeycloakTestResourceLifecycleManager

Keycloakとの統合テストが必要な場合は、 Dev Services For 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")

これは、Keycloak コンテナーを開始する io.quarkus.test.common.QuarkusTestResourceLifecycleManager の実装である io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager を提供します。

そして、Maven Surefire プラグインを次のように設定します。

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

(同様に、ネイティブイメージでテストする場合は maven.failsafe.plugin)。

RESTテストエンドポイントを用意し、例えば以下のように application.properties を設定します。

# keycloak.url is set by KeycloakTestResourceLifecycleManager
quarkus.oidc.auth-server-url=${keycloak.url}/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.hamcrest.Matchers;
import org.junit.jupiter.api.Test;

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

@QuarkusTest
@QuarkusTestResource(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"));
    }

}

KeycloakTestResourceLifecycleManager は alice と admin ユーザーを登録します。ユーザー alice にはデフォルトで user ロールしかありませんが、 keycloak.token.user-roles システムプロパティーでカスタマイズできます。ユーザー admin にはデフォルトで user と admin ロールがありますが、 keycloak.token.admin-roles システムプロパティーでカスタマイズできます。

デフォルトでは、 KeycloakTestResourceLifecycleManager は HTTPS を使用して Keycloak インスタンスを初期化しますが、 keycloak.use.https=false で無効にできます。デフォルトのレルム名は quarkus で、クライアント ID quarkus-service-app は、必要に応じて値をカスタマイズするために keycloak.realm および keycloak.service.client システムプロパティーを設定します。

ローカル公開鍵

また、 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

integration-tests/oidc-tenancy から main のQuarkusリポジトリに privateKey.pem をコピーし、上記 Wiremock のセクションと同様のテストコードを使用してJWTトークンを生成します。必要であれば、独自のテストキーを使用することもできます。

このアプローチは、Wiremockのアプローチと比較して、より限定された範囲をカバーします。例えば、リモート通信コードはカバーされません。

TestSecurity アノテーション

@TestSecurity および @OidcSecurity アノテーションを使用して、注入された JsonWebToken ならびに UserInfo および OidcConfigurationMetadata に依存する service アプリケーションエンドポイントコードをテストできます。

次の依存関係を追加します。

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 io.quarkus.oidc.OidcConfigurationMetadata;
import io.quarkus.oidc.UserInfo;
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 アノテーションはオプションであり、追加のトークンクレームおよび UserInfo プロパティーと OidcConfigurationMetadata プロパティーを設定するために使用できます。さらに、 quarkus.oidc.token.issuer プロパティーが設定されている場合、 OidcConfigurationMetadata issuer プロパティー値として使用されます。

不透明なトークンを使用する場合は、次のようにテストできます。

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 io.quarkus.oidc.TokenIntrospection;
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");
    }
}

@TestSecurity user および roles 属性は TokenIntrospection username および scope プロパティーとして使用可能で、 io.quarkus.test.security.oidc.TokenIntrospection を使用してさらに 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 {

    }

これは、同じセキュリティ設定のセットを複数のテストメソッドで使用する必要がある場合に特に便利です。

ログでエラーを確認する方法

トークン検証エラーの詳細を確認するには、 io.quarkus.oidc.runtime.OidcProvider TRACE レベルのログを有効にしてください。

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

OidcProvider クライアント初期化エラーの詳細を確認するには、 io.quarkus.oidc.runtime.OidcRecorder TRACE レベルのログを有効にしてください。

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

OpenID Connect プロバイダーへの外部および内部アクセス

Note that the OpenID Connect Provider externally accessible token and other endpoints may have different HTTP(S) URLs compared to the URLs auto-discovered or configured relative to quarkus.oidc.auth-server-url internal URL. For example, if your SPA acquires a token from an external token endpoint address and sends it to Quarkus as a bearer token then an issuer verification failure may be reported by the endpoint.

このような場合、Keycloak を使用する場合は、外部からアクセス可能なベース URL に設定された KEYCLOAK_FRONTEND_URL システムプロパティーで起動してください。他の Openid Connect プロバイダーと連携している場合は、プロバイダーのドキュメントを確認してください。

'client-id' プロパティーの使用方法

quarkus.oidc.client-id プロパティーは、現在のBearer Tokenを要求した OpenIDConnect クライアントを識別します。これは、ブラウザーで実行されている SPA アプリケーション、またはアクセストークンを Quarkus service アプリケーションに伝播する Quarkus web-app 機密クライアントアプリケーションの場合があります。

このプロパティーは、 service アプリケーションがトークンをリモートで検査することが期待される場合に必要です。これは不透明なトークンの場合は常に当てはまります。ローカルの Json Web Key トークン検証のみが使用される場合、このプロパティーはオプションです。

それでも、エンドポイントがリモートイントロスペクションエンドポイントへのアクセスを必要としない場合でも、このプロパティーを設定することをお勧めします。その背後にある理由は、 client-id が設定されている場合、トークンオーディエンスを検証するために使用でき、トークンの検証が失敗したときにログに含まれるため、長い期間分析対象となる特定のクライアントに発行されたトークンのトレーサビリティが向上するためです。

たとえば、OpenID Connect プロバイダーがトークンオーディエンスを設定する場合、次の設定パターンが推奨されます。

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

quarkus.oidc.client-id を設定したが、エンドポイントが OpenID Connect プロバイダーエンドポイントの 1 つへのリモートアクセスを必要としない場合は (イントロスペクション、トークン取得など)、 quarkus.oidc.credentials または同様のプロパティーでクライアントシークレットを設定しないでください。使用されないためです。

Quarkus の web-app アプリケーションには、常に quarkus.oidc.client-id プロパティーが必要である点に注意してください。