これは、なにをしたくて書いたもの?
MicroProfile JWT Authというものを、1度試しておきたいなということで。
WildFlyとKeycloakを使って試すことにします。
MicroProfile JWT Auth?
MicroProfile JWT Authのページはこちら。
現在のバージョンは2.1です。
そもそもなのですが、この仕様はなんという名前が正しいんでしょうね?
Eclipse Foundationのページを見ると、MicroProfile JWT AuthもしくはMicroProfile JWT Authenticationに見えます。
GitHubリポジトリーだと、JWT RBAC for MicroProfileという名前がREADME.md
に書かれています。
GitHub - eclipse/microprofile-jwt-auth
仕様書を見ると「Eclipse MicroProfile Interoperable JWT RBAC」になっています。
Eclipse MicroProfile Interoperable JWT RBAC
今回は深く考えずに、「MicroProfile JWT Auth」と表記することにします。
MicroProfile JWT Auth
気を取り直して、MicroProfile JWT Authがどういうものなのか、仕様書から少し見ていこうと思います。
Eclipse MicroProfile Interoperable JWT RBAC
MicroProfile JWT Authは、(マイクロサービスの)エンドポイントにOIDC(OpenID Connect)ベースのJWT(JSON Web Tokens)を使った
ロールベースのアクセス制御(RBAC)を使えるようにするものです。
This specification outlines a proposal for using OpenID Connect (OIDC) based JSON Web Tokens (JWT) for Role Based Access Control (RBAC) of microservice endpoints.
Eclipse MicroProfile Interoperable JWT RBAC / Introduction
MicroProfile JWT Authではクライアントからサービス、サービスからサービスへセキュリティ状態の伝播にトークンを使うことを考えています。
OAuth 2.0、OpenID Connect、JWTは動機の時点で意識していますね。
Eclipse MicroProfile Interoperable JWT RBAC / Motivation
登場人物は、以下の4つです。
- Issuer … 認証に成功した結果としてセキュリティトークンを発行する役割を担う(通常はIDプロバイダー)
- Client … トークンを発行するアプリケーション
- Subject … トークン内のエンティティに関する情報
- Resource Server … トークンが保護されたリソースへアクセスを許可するかどうかを確認するために、実際にトークンを使用するアプリケーション
用語としてはOAuth 2.0のものに近い感じでしょうか。
トークンベースの認証は、以下のステップで行われます。
- リクエストからセキュリティトークンを抽出する
- これは通常
Authorization
ヘッダーから取得する
- これは通常
- トークンの検証を行う
- トークンをイントロスペクションし、Subjectに関する情報を抽出する
- Subjectのセキュリティコンテキストを作成する
- アプリケーションは、トークンから抽出された情報にもとづいて保護されたリソースを提供する時に必要な場所で、必要な情報にアクセスできるようにSubjectに関するセキュリティコンテキストを作成する
Eclipse MicroProfile Interoperable JWT RBAC / Token Based Authentication
トークンの種類についてはここまで言及していなかったのですが、JWTを想定していそうですね。
Eclipse MicroProfile Interoperable JWT RBAC / Using JWT Bearer Tokens to Protect Services
MicroProfile JWT Authで必要、もしくは推奨されるJWTヘッダーおよびクレーム。
- 必須のJWTヘッダー
- 推奨されるJWTヘッダー
- typ
- トークンをRFC7519(JWT)として識別し、値は「JWT」である必要がある
- kid
- JWTを保護するために使用されたキーを示すヒント。検証キーがJWK(RFC7515)フォーマットの場合はに必要
- typ
- 必須のJWTクレーム
- 推奨されるJWTクレーム
クレームは、Claims
列挙型で定義されています。
JWTトークンが以下の要件を満たさない場合、MicroProfile JWT Authは無効なJWTトークンとして拒否することがあります。
- トークンが署名されていることを期待されている場合、RS256またはES256に設定されたalgヘッダーがない
- トークンが暗号化されていることを期待されている場合、RSA-OAEPまたはRSA-OAEP-256で鍵管理され、またはA256GCMが指定されたalgまたはencヘッダーがない
- issヘッダーがない、またはissヘッダーが設定されているホワイトリストに一致しない
- iatクレームがない
- expクレームがない
- upn、preferred_username、subのいずれのクレームも含まれていない
- 署名されたJWTトークンの検証に失敗するか、暗号化されたJWTトークンの復号に失敗する
Eclipse MicroProfile Interoperable JWT RBAC / Requirements for Rejecting MP-JWT Tokens
次はAPIの使い方です。
MicroProfile JWT Authを使うには、まずJakarta RESTful Web Services(JAX-RS)のApplication
のサブクラスに
@LoginConfig
アノテーションを付与します。この時、authMethod
属性にMP-JWT
を指定します。
@LoginConfig(authMethod = "MP-JWT", realmName = "TCK-MP-JWT") @ApplicationPath("/") public class TCKApplication extends Application { }
つまり、MicroProfile JWT AuthはJAX-RSと組み合わせて使うことになります。
RBACということなので、先に権限制御に関するAPIについて。これはJSR-250を使います。
現在はJakarta Annotationsですね。
Jakarta Annotations 2.1 | The Eclipse Foundation
クラスおよびメソッドに付与できる、次の3つのアノテーションがあります。
@RolesAllowed
- 指定されたロール名を持つ場合にアクセス可能
- MicroProfile JWT Authでは、ロール名はgroupsクレームに対応する
- Jakarta Annotations / Annotations / jakarta.annotation.security.RolesAllowed
@PermitAll
- セキュリティロールに関するチェックを行わない
- Jakarta Annotations / Annotations / jakarta.annotation.security.PermitAll
@DenyAll
これらのアノテーションは、クラスとメソッドの両方に付与されている場合はメソッドに付与されたものが優先されます。
Jakarta Annotations / Annotations / PermitAll, DenyAll and RolesAllowed interactions
MicroProfile JWT Authとして見ると、ロール名を@RolesAllowed
アノテーションで制御すればいいことになりますね。ちなみにgroupsという
クレームは、MicroProfile JWT Auth独自のものみたいです。実装によっては他のクレームに読み替えを設定できるようですが。
@PermitAll
はなかなか豪快で、認証不要ということになります。
CDIとのインテグレーションでは、JWTを表すJsonWebToken
、JsonWebToken
に含まれるクレームの値を表すClaimValue
を
インジェクションできます。
@Path("/endp") @DenyAll @ApplicationScoped public class RolesEndpoint { @Inject private JsonWebToken callerPrincipal; ... @Inject @Claim(standard = Claims.iss) private ClaimValue<String> issuer;
ClaimValue
ではなく、クレームの値をString
などで直接インジェクションすることもできるようですが、やめた方がよさげです。
あとはJAX-RSのSecurityContext#getUserPrincipal
でJsonWebToken
を返し、SecurityContext#isUserInRole
でユーザーが指定した
groupクレームを保持しているかどうかを確認できます。
その他のJakarta EEのAPI使用とのインテグレーションについて。Servletやweb.xml
でのオーバーライドについても触れられています。
仕様書の以降では、設定項目などが挙げられているのですがここでは省略します。サンプルコード作成時に、必要に応じて見ていきます。
Javadocはこちらです。
参考)
MicroProfile JWT Authがやってくれること・できること | 豆蔵デベロッパーサイト
MicroProfile JWTを使ってマイクロサービスをセキュアにしよう - Speaker Deck
MicroProfile JWTによるスケーラブルでセキュアなマイクロサービス構築 -入門編(Part 1)-|富士通技術者ブログ~Javaミドルウェア~ : 富士通
MicroProfile JWTによるスケーラブルでセキュアなマイクロサービス構築 -入門編(Part 2)-|富士通技術者ブログ~Javaミドルウェア~ : 富士通
SmallRye JWT
WildFlyが使用するMicroProfile JWT Authの実装は、SmallRye JWTです。
SmallRye JWT Documentation :: SmallRye documentation
GitHub - smallrye/smallrye-jwt
SmallRye JWT固有のプロパティがあったり、JWTの作成やパースができるAPIがあるなどがポイントでしょうか。
Configuration :: SmallRye documentation
WildFly MicroProfile JWT Subsystem
WildFlyでは、SmallRye JWTはサブシステムとして組み込まれています。
WildFly Admin Guide / Subsystem configuration / MicroProfile JWT Subsystem
サブシステムとして設定できる項目などはありませんが、ここではMicroProfile JWT AuthのプロパティやAPIの使い方、それから
SmallRye JWTのプロパティが紹介されています。SmallRye JWTのドキュメントを見ているだけでよいかもしれません。
ここにしか内容としては、EJBの呼び出しに関する補足があります。
ただ、注意事項としてWildFlyにはMicroProfile JWT AuthのJAX-RSとのインテグレーションは含まれていないようです。
具体的には、smallrye-jwt-jaxrsが含まれていません。
https://github.com/smallrye/smallrye-jwt/tree/4.3.1/implementation/jwt-jaxrs
WildFlyでJAX-RSと組み合わせる場合には、自分でsmallrye-jwt-jaxrsを依存関係に追加して有効化する必要がありそうです。試した時は
これに気づかずだいぶハマりました…。
ではMicroProfile JWT Subsystemに含まれているのはなにかというと、SmallRye JWTの以下のモジュールです。
- smallrye-jwt-common
- smallrye-jwt
- smallrye-jwt-http-mechanism
- smallrye-jwt-cdi-extension
これらがなにをしてくれるかというと、CookieまたはAuthorization
ヘッダーに含まれるJWTをパースしてSecurityContext
として
利用できるようにしてくれます。
JAX-RSとインテグレーションして認可の機能を使うには、smallrye-jwt-jaxrsを追加しましょう。
少し脱線しましたが…各ドキュメントを見ていくのはこれくらいにして、実際にMicroProfile JWT Authを試してみることにします。
お題
以下の構成でMicroProfile JWT Authを試してみたいと思います。
flowchart LR A[curl] --> |HTTP| B[Keycloak] A --> |HTTP+JWT| C[JAX-RS with MicroProfile JWT Auth/WildFly]
MicroProfile JWT Authの登場人物の名前に合わせると、こうなります。
flowchart LR A[Client/curl] --> |HTTP| B[Issuer/Keycloak] A --> |HTTP+JWT| C[Resource Server/WildFly]
Keycloakには以下のユーザーおよびロールを作成しておきます。
- leader-a … leader-roleを設定
- member-a … member-roleを設定
- other-a … ロールなし
また、簡単のためDirect Access Grants(リソースオーナーパスワードクレデンシャルズフロー)でトークンを発行できるように設定します。
WildFly側では、ロールに応じた認可設定を行ってみます。
なお、Keycloak上のリソースはTerraformで作成するものとします。
環境
今回の環境はこちら。
$ java --version openjdk 21.0.4 2024-07-16 OpenJDK Runtime Environment (build 21.0.4+7-Ubuntu-1ubuntu222.04) OpenJDK 64-Bit Server VM (build 21.0.4+7-Ubuntu-1ubuntu222.04, mixed mode, sharing) $ mvn --version Apache Maven 3.9.9 (8e8579a9e76f7d015ee5ec7bfcdc97d260186937) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 21.0.4, vendor: Ubuntu, runtime: /usr/lib/jvm/java-21-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.15.0-122-generic", arch: "amd64", family: "unix"
Keycloak。
$ bin/kc.sh --version Keycloak 25.0.6 JVM: 21.0.4 (Eclipse Adoptium OpenJDK 64-Bit Server VM 21.0.4+7-LTS) OS: Linux 5.15.0-122-generic amd64
Keycloakは172.17.0.2で動作しているものとし、以下のコマンドで起動させておきます。
$ KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD=password bin/kc.sh start-dev
Terraform。
$ terraform version Terraform v1.9.6 on linux_amd64
TerraformでKeycloakのリソースを作成する
まずはTerraformでKeycloakのリソースを作成します。
使用するTerraformとKeycloak Providerのバージョン指定。
versions.tf
terraform { required_version = "1.9.6" required_providers { keycloak = { source = "mrparkers/keycloak" version = "4.4.0" } } }
作成するリソース。
main.tf
provider "keycloak" { client_id = "admin-cli" username = "admin" password = "password" url = "http://172.17.0.2:8080" } ## Realm resource "keycloak_realm" "sample_realm" { realm = "sample-realm" display_name = " Realm" enabled = true } ## Client resource "keycloak_openid_client" "mp_client" { realm_id = keycloak_realm.sample_realm.id client_id = "mp-client" name = "MicroProfile Client" enabled = true access_type = "CONFIDENTIAL" standard_flow_enabled = true direct_access_grants_enabled = true # テスト用 root_url = "http://localhost:8080" valid_redirect_uris = ["http://localhost:8080/*"] } ## microprofile-jwtスコープをOptionalからDefaultに移動する resource "keycloak_openid_client_optional_scopes" "client_optional_scopes" { realm_id = keycloak_realm.sample_realm.id client_id = keycloak_openid_client.mp_client.id optional_scopes = [ "address", "offline_access", "phone", ## microprofile-jwtを削除 ] } resource "keycloak_openid_client_default_scopes" "client_default_scopes" { realm_id = keycloak_realm.sample_realm.id client_id = keycloak_openid_client.mp_client.id default_scopes = [ "acr", "basic", "email", "profile", "roles", "microprofile-jwt", ## 追加 ] ## microprofile-jwtを先にOptionalから削除するため、明示的に依存関係を定義 depends_on = [keycloak_openid_client_optional_scopes.client_optional_scopes] } ## アクセストークンにaudienceを追加したい場合は、Protocol Mapperを定義(IDトークンには含まれている) resource "keycloak_openid_audience_protocol_mapper" "audience_protocol_mapper" { realm_id = keycloak_realm.sample_realm.id client_id = keycloak_openid_client.mp_client.id name = "audience" included_client_audience = keycloak_openid_client.mp_client.client_id add_to_access_token = true ## default true add_to_id_token = false ## default true } ## User resource "keycloak_user" "leader_a" { realm_id = keycloak_realm.sample_realm.id username = "leader-a" email = "leader-a@example.com" first_name = "A" last_name = "Leader" enabled = true initial_password { value = "password" temporary = false } } resource "keycloak_user" "member_a" { realm_id = keycloak_realm.sample_realm.id username = "member-a" email = "member-a@example.com" first_name = "A" last_name = "Member" enabled = true initial_password { value = "password" temporary = false } } resource "keycloak_user" "other_a" { realm_id = keycloak_realm.sample_realm.id username = "other-a" email = "other-a@example.com" first_name = "A" last_name = "Other" enabled = true initial_password { value = "password" temporary = false } } ## Role resource "keycloak_role" "leader_role" { realm_id = keycloak_realm.sample_realm.id name = "leader-role" } resource "keycloak_role" "member_role" { realm_id = keycloak_realm.sample_realm.id name = "member-role" } ## Assign Role resource "keycloak_user_roles" "leader_a_assign_leader_role" { realm_id = keycloak_realm.sample_realm.id user_id = keycloak_user.leader_a.id role_ids = [ keycloak_role.leader_role.id ] } resource "keycloak_user_roles" "member_a_assign_member_role" { realm_id = keycloak_realm.sample_realm.id user_id = keycloak_user.member_a.id role_ids = [ keycloak_role.member_role.id ] } resource "keycloak_user_roles" "other_a_assign_member_role" { realm_id = keycloak_realm.sample_realm.id user_id = keycloak_user.other_a.id role_ids = [] ## 紐付けるロールなし }
少し説明を入れましょう。
まずはクライアントの定義。
## Client resource "keycloak_openid_client" "mp_client" { realm_id = keycloak_realm.sample_realm.id client_id = "mp-client" name = "MicroProfile Client" enabled = true access_type = "CONFIDENTIAL" standard_flow_enabled = true direct_access_grants_enabled = true # テスト用 root_url = "http://localhost:8080" valid_redirect_uris = ["http://localhost:8080/*"] }
テストを簡単にするため、Direct Access Grantsを有効にしています。
スコープの設定。今回のテーマだと、microprofile-jwtスコープをDefaultに設定した方がよさそうなので以下のようにしました。
## microprofile-jwtスコープをOptionalからDefaultに移動する resource "keycloak_openid_client_optional_scopes" "client_optional_scopes" { realm_id = keycloak_realm.sample_realm.id client_id = keycloak_openid_client.mp_client.id optional_scopes = [ "address", "offline_access", "phone", ## microprofile-jwtを削除 ] } resource "keycloak_openid_client_default_scopes" "client_default_scopes" { realm_id = keycloak_realm.sample_realm.id client_id = keycloak_openid_client.mp_client.id default_scopes = [ "acr", "basic", "email", "profile", "roles", "microprofile-jwt", ## 追加 ] ## microprofile-jwtを先にOptionalから削除するため、明示的に依存関係を定義 depends_on = [keycloak_openid_client_optional_scopes.client_optional_scopes] }
もともとOptionalだったmicroprofile-jwtをDefaultに移動しています。この時、Optional → Defaultの順に実行されないと以下のように
Defaultに設定する前にすでにOptionalになっているということでエラーになります。
│ Error: validation error: scope microprofile-jwt is already attached to client as an optional scope
この部分ですね。
## microprofile-jwtを先にOptionalから削除するため、明示的に依存関係を定義 depends_on = [keycloak_openid_client_optional_scopes.client_optional_scopes]
ちなみに、microprofile-jwtというのはMicroProfile JWT Authで定義されているクレームを扱うスコープです。このスコープを使うと
upnクレームを追加し、Realmロールをgroupsクレームに設定するようになります。
microprofile-jwt
This scope handles claims defined in the MicroProfile/JWT Auth Specification. This scope defines a user property mapper for the upn claim and a realm role mapper for the groups claim. These mappers can be changed so different properties can be used to create the MicroProfile/JWT specific claims.
Server Administration Guide / Managing OpenID Connect and SAML Clients / Client scopes / Protocol
Audience Protocol Mapper。こちらを使って、アクセストークンにaudクレームを設定しています。
## アクセストークンにaudienceを追加したい場合は、Protocol Mapperを定義(IDトークンには含まれている) resource "keycloak_openid_audience_protocol_mapper" "audience_protocol_mapper" { realm_id = keycloak_realm.sample_realm.id client_id = keycloak_openid_client.mp_client.id name = "audience" included_client_audience = keycloak_openid_client.mp_client.client_id add_to_access_token = true ## default true add_to_id_token = false ## default true }
MicroProfile JWT Authではmp.jwt.verify.audiences
プロパティでaudクレームのバリデーションを行うことができますが、こちらを
アクセストークンに対して行いたい場合に設定するとよいでしょう。IDトークンにはデフォルトでaudクレームが含まれています。
Server Administration Guide / Managing OpenID Connect and SAML Clients / Audience support
最後にユーザーとロール、そしてユーザーに対するロールの紐付けを行っています。
other-aというユーザーは、デフォルトで割り当てられているロールを削除するためのリソース定義になっています。
resource "keycloak_user_roles" "other_a_assign_member_role" { realm_id = keycloak_realm.sample_realm.id user_id = keycloak_user.other_a.id role_ids = [] ## 紐付けるロールなし }
initして、リソース作成。
$ terraform init $ terraform apply
リソースオーナーパスワードクレデンシャルズフロー(Direct Access Grants)でアクセストークン、IDトークンを取得するのに
クライアントシークレットが必要になるので、管理CLIを使って取得しておきます。最初のコマンドでログイン、次のコマンドで
クライアントシークレットがコンソールに表示されます。
$ bin/kcadm.sh config credentials --server http://localhost:8080 --realm master --user admin --password password $ bin/kcadm.sh get -r sample-realm clients/$(bin/kcadm.sh get clients -r sample-realm -q 'clientId=mp-client' -F id | jq -r '.[].id')/client-secret | jq -r '.value'
アプリケーションを作成する
それでは、アプリケーションを作成します。
Maven依存関係など。
<packaging>war</packaging> <properties> <maven.compiler.release>21</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> </properties> <dependencies> <dependency> <groupId>jakarta.platform</groupId> <artifactId>jakarta.jakartaee-web-api</artifactId> <version>10.0.0</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.eclipse.microprofile</groupId> <artifactId>microprofile</artifactId> <version>6.1</version> <type>pom</type> <scope>provided</scope> </dependency> <dependency> <groupId>io.smallrye</groupId> <artifactId>smallrye-jwt-jaxrs</artifactId> <version>4.3.1</version> <!-- WildFlyに入っていないし、wildfly-microprofileにもない --> <scope>runtime</scope> <exclusions> <exclusion> <groupId>io.smallrye</groupId> <artifactId>*</artifactId> </exclusion> <exclusion> <groupId>org.jboss.logging</groupId> <artifactId>*</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <scope>test</scope> <version>5.11.0</version> </dependency> <dependency> <groupId>org.jboss.resteasy</groupId> <artifactId>resteasy-client</artifactId> <version>6.2.10.Final</version> <scope>test</scope> </dependency> <dependency> <groupId>org.jboss.resteasy</groupId> <artifactId>resteasy-jackson2-provider</artifactId> <version>6.2.10.Final</version> <scope>test</scope> </dependency> <dependency> <groupId>io.rest-assured</groupId> <artifactId>rest-assured</artifactId> <version>5.5.0</version> <scope>test</scope> </dependency> </dependencies> <build> <finalName>ROOT</finalName> <plugins> <plugin> <groupId>org.wildfly.plugins</groupId> <artifactId>wildfly-maven-plugin</artifactId> <version>5.0.1.Final</version> <executions> <execution> <goals> <goal>package</goal> </goals> </execution> </executions> <configuration> <bootable-jar>true</bootable-jar> <bootable-jar-name>${project.artifactId}-${project.version}-server-bootable.jar</bootable-jar-name> <overwrite-provisioned-server>true</overwrite-provisioned-server> <discover-provisioning-info> <version>33.0.2.Final</version> </discover-provisioning-info> </configuration> </plugin> </plugins> </build>
今回のポイントはsmallrye-jwt-jaxrsを依存関係に追加することですね。こちらはWildFlyに含まれていません。smallrye-jwt-jaxrsが依存する
ライブラリーはWildFlyにすべて含まれているので除外しています。
<dependency> <groupId>io.smallrye</groupId> <artifactId>smallrye-jwt-jaxrs</artifactId> <version>4.3.1</version> <!-- WildFlyに入っていないし、wildfly-microprofileにもない --> <scope>runtime</scope> <exclusions> <exclusion> <groupId>io.smallrye</groupId> <artifactId>*</artifactId> </exclusion> <exclusion> <groupId>org.jboss.logging</groupId> <artifactId>*</artifactId> </exclusion> </exclusions> </dependency>
WildFlyは、WildFly Glowを使ってプロビジョニングしました。この範囲だと、ふつうにWildFlyのstandalone.sh(xml)
で実行しても
問題ないですが。
あとはテスト用のライブラリーです。今回はあらかじめ起動したWildFlyにREST-assuredでテストを行うことにしました。
作成したソースコードを載せていきます。
Application
のサブクラス。MicroProfile JWT Authを使うには、Application
のサブクラスに@LoginConfig
アノテーションを付与して
authMethod
属性にMP-JWT
と指定する必要があります。
src/main/java/org/littlewings/keycloak/mpjwt/RestApplication.java
package org.littlewings.keycloak.mpjwt; import jakarta.ws.rs.ApplicationPath; import jakarta.ws.rs.core.Application; import org.eclipse.microprofile.auth.LoginConfig; @LoginConfig(authMethod = "MP-JWT") @ApplicationPath("") public class RestApplication extends Application { }
以降は、JAX-RSリソースクラスです。
特になにも設定しないJAX-RSリソースクラス。
src/main/java/org/littlewings/keycloak/mpjwt/PlainResource.java
package org.littlewings.keycloak.mpjwt; import jakarta.enterprise.context.ApplicationScoped; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; @Path("plain") @ApplicationScoped public class PlainResource { @GET @Produces(MediaType.TEXT_PLAIN) public String message() { return "No Protect Resource"; } }
@PermitAll
アノテーションを付与したJAX-RSリソースクラス。
src/main/java/org/littlewings/keycloak/mpjwt/PermitAllResource.java
package org.littlewings.keycloak.mpjwt; import jakarta.annotation.security.PermitAll; import jakarta.enterprise.context.ApplicationScoped; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; @Path("permit-all") @PermitAll @ApplicationScoped public class PermitAllResource { @GET @Produces(MediaType.TEXT_PLAIN) public String message() { return "Permit All Resource"; } }
@DenyAll
アノテーションを付与したJAX-RSリソースクラス。
src/main/java/org/littlewings/keycloak/mpjwt/DenyAllResource.java
package org.littlewings.keycloak.mpjwt; import jakarta.annotation.security.DenyAll; import jakarta.enterprise.context.ApplicationScoped; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; @Path("deny-all") @DenyAll @ApplicationScoped public class DenyAllResource { @GET @Produces(MediaType.TEXT_PLAIN) public String message() { return "Deny All Resource"; } }
各エンドポイントのメソッドに対して、@RolesAllowed
アノテーションを付与したJAX-RSリソースクラス。
src/main/java/org/littlewings/keycloak/mpjwt/RolesAllowsResource.java
package org.littlewings.keycloak.mpjwt; import jakarta.annotation.security.RolesAllowed; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.SecurityContext; import org.eclipse.microprofile.jwt.JsonWebToken; @Path("roles-allow") @ApplicationScoped public class RolesAllowsResource { @Inject private JsonWebToken jwt; @Inject private SecurityContext securityContext; @GET @Path("leader") @Produces(MediaType.TEXT_PLAIN) @RolesAllowed("leader-role") public String allowLeaderRole() { JsonWebToken jwtFromSecurityContext = (JsonWebToken) securityContext.getUserPrincipal(); return String.format( "Hello Leader[%s], groups = %s, leader-role = %b, member-role = %b!!", jwt.claim("preferred_username").orElse("anonymous-user"), jwtFromSecurityContext.claim("groups").orElse("[no-role]"), securityContext.isUserInRole("leader-role"), securityContext.isUserInRole("member-role") ); } @GET @Path("member") @Produces(MediaType.TEXT_PLAIN) @RolesAllowed("member-role") public String allowMemberRole() { return String.format( "Hello Member[%s], groups = %s!!", jwt.claim("preferred_username").orElse("anonymous-user"), jwt.claim("groups").orElse("[no-role]") ); } @GET @Path("both") @Produces(MediaType.TEXT_PLAIN) @RolesAllowed({"leader-role", "member-role"}) public String allowBothRole() { return String.format( "Hello Leader or Member[%s], groups = %s!!", jwt.claim("preferred_username").orElse("anonymous-user"), jwt.claim("groups").orElse("[no-role]") ); } @GET @Path("wildcard") @Produces(MediaType.TEXT_PLAIN) @RolesAllowed("*") public String allowWildcard() { return String.format( "Hello User[%s], groups = %s!!", jwt.claim("preferred_username").orElse("anonymous-user"), jwt.claim("groups").orElse("[no-role]") ); } }
上から順に、
- leader-roleを保持していればアクセス可能
- member-roleを保持していればアクセス可能
- leader-roleまたはmember-roleのどちらかを保持していればアクセス可能
- ロールを問わず(ロールを保持していなくても)アクセス可能
という設定になっています。
@RolesAllowed("leader-role") @RolesAllowed("member-role") @RolesAllowed({"leader-role", "member-role"}) @RolesAllowed("*")
あとはJWTをインジェクションして
@Inject private JsonWebToken jwt; @Inject private SecurityContext securityContext;
preferred_usernameクレームとgroupsクレームをレスポンスに含めることにしました。
@GET @Path("member") @Produces(MediaType.TEXT_PLAIN) @RolesAllowed("member-role") public String allowMemberRole() { return String.format( "Hello Member[%s], groups = %s!!", jwt.claim("preferred_username").orElse("anonymous-user"), jwt.claim("groups").orElse("[no-role]") ); }
なお、leader-role向けのものだけSecurityContext
を使う例にもしています。
@Inject private JsonWebToken jwt; @Inject private SecurityContext securityContext;
SecurityContext#getUserPrincipal
から取得できるのがJsonWebToken
であり、@Inject
でインジェクションしたJsonWebToken
と同様に
クレームの値が取得できること。それからSecurityContext#isUserInRole
でロールを保持しているかを判定しています。
@GET @Path("leader") @Produces(MediaType.TEXT_PLAIN) @RolesAllowed("leader-role") public String allowLeaderRole() { JsonWebToken jwtFromSecurityContext = (JsonWebToken) securityContext.getUserPrincipal(); return String.format( "Hello Leader[%s], groups = %s, leader-role = %b, member-role = %b!!", jwt.claim("preferred_username").orElse("anonymous-user"), jwtFromSecurityContext.claim("groups").orElse("[no-role]"), securityContext.isUserInRole("leader-role"), securityContext.isUserInRole("member-role") ); }
WildFlyの場合、@LoginConfig(authMethod = "MP-JWT")
だけではMicroProfile JWT AuthのJAX-RSインテグレーションは使えないので、
以下のようにServiceLoaderの設定を行います。
src/main/resources/META-INF/services/jakarta.ws.rs.core.Feature
io.smallrye.jwt.auth.jaxrs.SmallRyeJWTAuthJaxRsFeature
最後にMicroProfile JWT Authの設定です。今回はこれくらいの設定にしました。
src/main/resources/META-INF/microprofile-config.properties
mp.jwt.verify.issuer=http://172.17.0.2:8080/realms/sample-realm mp.jwt.verify.publickey.location=http://172.17.0.2:8080/realms/sample-realm/protocol/openid-connect/certs mp.jwt.verify.audiences=mp-client
mp.jwt.verify.issuer
プロパティでissクレーム、mp.jwt.verify.audiences
プロパティでaudクレームの検証を行うようにします。
mp.jwt.verify.publickey.location
プロパティではJWTの署名を検証するための公開鍵の設定を行います。
公開鍵の取得先はKeycloakにします。http://[Keycloakのホスト]:[ポート]/realms/[Realm名]/.well-known/openid-configuration
にアクセスすると
各種エンドポイントがわかるので、その中からJWKSのURLを取得します。
$ curl -s 172.17.0.2:8080/realms/sample-realm/.well-known/openid-configuration | jq '.jwks_uri'
準備ができたのでWildFlyを起動。
$ mvn wildfly:run
プロビジョニングする際にWildFly Glowが検出したfeature-packはこちらです。
[INFO] --- wildfly:5.0.1.Final:package (default) @ microprofile-jwt-auth-example --- [INFO] Glow is scanning... [INFO] Glow scanning DONE. [INFO] context: bare-metal [INFO] enabled profile: none [INFO] galleon discovery [INFO] - feature-packs org.wildfly:wildfly-galleon-pack:33.0.2.Final - layers ee-core-profile-server jaxrs microprofile-jwt
では、各エンドポイントにアクセスしてみます。
まずはleader-aのアクセストークンとIDトークンをcurlで取得。これができるのは、Direct Access Grantsを有効にしているからですね。
※通常は有効にしません
$ CLIENT_SECRET=..... $ ACCESS_TOKEN=$(curl -s 172.17.0.2:8080/realms/sample-realm/protocol/openid-connect/token \ -d client_id=mp-client \ -d client_secret=${CLIENT_SECRET} \ -d username=leader-a \ -d password=password \ -d scope=openid \ -d grant_type=password | jq -r '.access_token') $ ID_TOKEN=$(curl -s 172.17.0.2:8080/realms/sample-realm/protocol/openid-connect/token \ -d client_id=mp-client \ -d client_secret=${CLIENT_SECRET} \ -d username=leader-a \ -d password=password \ -d scope=openid \ -d grant_type=password | jq -r '.id_token')
JWTをデコードした結果の一例を載せておきましょう。JWTのデコードはこちらで行いました。
アクセストークンのデコード結果。
ヘッダー。
{ "alg": "RS256", "typ": "JWT", "kid": "NQxi4mxni06U4uJIktSgo7WoG78DWi-aOIX1S9ElvBg" }
{ "exp": 1727522700, "iat": 1727522400, "jti": "62043b29-99d2-4b0a-bc7e-b09483f22b2d", "iss": "http://172.17.0.2:8080/realms/sample-realm", "aud": "mp-client", "sub": "3cc7511c-dbf8-4faf-a5d5-c7d548324f90", "typ": "Bearer", "azp": "mp-client", "sid": "2303a3da-1017-4d96-81be-3f0b70e0e857", "acr": "1", "realm_access": { "roles": [ "leader-role" ] }, "scope": "openid email microprofile-jwt profile", "upn": "leader-a", "email_verified": false, "name": "A Leader", "groups": [ "leader-role" ], "preferred_username": "leader-a", "given_name": "A", "family_name": "Leader", "email": "leader-a@example.com" }
繰り返しますが、アクセストークンにaudクレームが入るのはAudience Protocol Mapperを設定しているからです。
IDトークン。
ヘッダー。
{ "alg": "RS256", "typ": "JWT", "kid": "NQxi4mxni06U4uJIktSgo7WoG78DWi-aOIX1S9ElvBg" }
{ "exp": 1727522725, "iat": 1727522425, "jti": "e0e8de7c-b87e-4c8d-b814-54e75616e2c5", "iss": "http://172.17.0.2:8080/realms/sample-realm", "aud": "mp-client", "sub": "3cc7511c-dbf8-4faf-a5d5-c7d548324f90", "typ": "ID", "azp": "mp-client", "sid": "3899ba83-a5c6-4f75-ac75-ba6d95507fcf", "at_hash": "UbIf2igs3ft2rEkxklML-g", "acr": "1", "upn": "leader-a", "email_verified": false, "name": "A Leader", "groups": [ "leader-role" ], "preferred_username": "leader-a", "given_name": "A", "family_name": "Leader", "email": "leader-a@example.com" }
アクセストークンとIDトークンの両方でupnクレームとgroupsクレームが入っているのは、microprofile-jwtスコープを設定しているからですね。
では、各エンドポイントにアクセスしてみます。
まずはなにも認可に関するアノテーションを設定していないエンドポイントから。JWTトークンは設定しません。
$ curl -i localhost:8080/plain
すると、なんとHTTPステータスコード500になりました…。
HTTP/1.1 500 Internal Server Error Connection: keep-alive Content-Type: application/octet-stream Content-Length: 121 Date: Sat, 28 Sep 2024 11:23:02 GMT Cannot invoke "io.smallrye.jwt.auth.principal.JWTAuthContextInfo.getTokenHeader()" because "this.authContextInfo" is null
20:23:02,499 ERROR [org.jboss.resteasy.core.providerfactory.DefaultExceptionMapper] (default task-1) RESTEASY002375: Error processing request GET /plain: java.lang.NullPointerException: Cannot invoke "io.smallrye.jwt.auth.principal.JWTAuthContextInfo.getTokenHeader()" because "this.authContextInfo" is null at io.smallrye.jwt@4.3.1//io.smallrye.jwt.auth.AbstractBearerTokenExtractor.getBearerToken(AbstractBearerTokenExtractor.java:52) at deployment.ROOT.war//io.smallrye.jwt.auth.jaxrs.JWTAuthenticationFilter.filter(JWTAuthenticationFilter.java:65) at org.jboss.resteasy.resteasy-core@6.2.10.Final//org.jboss.resteasy.core.interception.jaxrs.PreMatchContainerRequestContext.filter(PreMatchContainerRequestContext.java:276) at org.jboss.resteasy.resteasy-core@6.2.10.Final//org.jboss.resteasy.core.SynchronousDispatcher.preprocess(SynchronousDispatcher.java:157) at org.jboss.resteasy.resteasy-core@6.2.10.Final//org.jboss.resteasy.core.SynchronousDispatcher.invoke(SynchronousDispatcher.java:229) at org.jboss.resteasy.resteasy-core@6.2.10.Final//org.jboss.resteasy.plugins.server.servlet.ServletContainerDispatcher.service(ServletContainerDispatcher.java:222) at org.jboss.resteasy.resteasy-core@6.2.10.Final//org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher.service(HttpServletDispatcher.java:55) at org.jboss.resteasy.resteasy-core@6.2.10.Final//org.jboss.resteasy.plugins.server.servlet.HttpServletDispatcher.service(HttpServletDispatcher.java:51) at jakarta.servlet.api@6.0.0//jakarta.servlet.http.HttpServlet.service(HttpServlet.java:614) at io.undertow.servlet@2.3.17.Final//io.undertow.servlet.handlers.ServletHandler.handleRequest(ServletHandler.java:74) at io.undertow.servlet@2.3.17.Final//io.undertow.servlet.handlers.security.ServletSecurityRoleHandler.handleRequest(ServletSecurityRoleHandler.java:62) at io.undertow.servlet@2.3.17.Final//io.undertow.servlet.handlers.ServletChain$1.handleRequest(ServletChain.java:68)
ではなにか適当にAuthorization: Bearer
を設定すればいいのかと試すと、これだと認証エラーになります。
$ curl -i -H 'Authorization: Bearer test' localhost:8080/plain HTTP/1.1 401 Unauthorized Connection: keep-alive Content-Type: text/html;charset=UTF-8 Content-Length: 71 Date: Sat, 28 Sep 2024 11:28:29 GMT <html><head><title>Error</title></head><body>Unauthorized</body></html>
JWTが事実上必須になっているのはどうなんでしょう…。
MicroProfile JWT AuthとしてはJWTが送信された時のみ検証すべきだという主張のようですが
Payaraなどでも問題があったりしたようです。
有効なJWTをAuthorization: Bearer
に付与すると、アクセスできるようになります。
$ curl -i -H "Authorization: Bearer $ACCESS_TOKEN" localhost:8080/plain HTTP/1.1 200 OK Connection: keep-alive Content-Type: text/plain;charset=UTF-8 Content-Length: 19 Date: Sat, 28 Sep 2024 11:32:03 GMT No Protect Resource $ curl -i -H "Authorization: Bearer $ID_TOKEN" localhost:8080/plain HTTP/1.1 200 OK Connection: keep-alive Content-Type: text/plain;charset=UTF-8 Content-Length: 19 Date: Sat, 28 Sep 2024 11:32:37 GMT No Protect Resource
このエンドポイントは、特にアノテーションは設定していないんですけどね…?
ちなみに、@PermitAll
アノテーションを付与したエンドポイントへのアクセスもJWTを送信しないと同様に500エラーになります。
$ curl -i localhost:8080/permit-all HTTP/1.1 500 Internal Server Error Connection: keep-alive Content-Type: application/octet-stream Content-Length: 121 Date: Sat, 28 Sep 2024 11:33:39 GMT Cannot invoke "io.smallrye.jwt.auth.principal.JWTAuthContextInfo.getTokenHeader()" because "this.authContextInfo" is null
この挙動はどうなんでしょうね…。
では仕方がないので、今回はJWT(アクセストークンまたはIDトークン)を付与して各エンドポイントにアクセスしてみます。
アノテーションを設定していないエンドポイント(再掲)。
$ curl -i -H "Authorization: Bearer $ACCESS_TOKEN" localhost:8080/plain HTTP/1.1 200 OK Connection: keep-alive Content-Type: text/plain;charset=UTF-8 Content-Length: 19 Date: Sat, 28 Sep 2024 11:35:49 GMT No Protect Resource
`@PermitAllを設定したエンドポイント。
$ curl -i -H "Authorization: Bearer $ID_TOKEN" localhost:8080/permit-all HTTP/1.1 200 OK Connection: keep-alive Content-Type: text/plain;charset=UTF-8 Content-Length: 19 Date: Sat, 28 Sep 2024 11:36:27 GMT Permit All Resource
@DenyAll
を設定したエンドポイント。これは403が返ります。
$ curl -i -H "Authorization: Bearer $ACCESS_TOKEN" localhost:8080/deny-all HTTP/1.1 403 Forbidden Connection: keep-alive Content-Length: 0 Date: Sat, 28 Sep 2024 11:36:48 GMT
@RolesAllowed("leader-role")
を設定したエンドポイント。今回アクセスしているユーザーは、leader-roleを保持しているのでアクセスできます。
$ curl -i -H "Authorization: Bearer $ID_TOKEN" localhost:8080/roles-allow/leader HTTP/1.1 200 OK Connection: keep-alive Content-Type: text/plain;charset=UTF-8 Content-Length: 89 Date: Sat, 28 Sep 2024 11:38:53 GMT Hello Leader[leader-a], groups = [leader-role], leader-role = true, member-role = false!!
こちらの処理の結果も確認できましたね。
public String allowLeaderRole() { JsonWebToken jwtFromSecurityContext = (JsonWebToken) securityContext.getUserPrincipal(); return String.format( "Hello Leader[%s], groups = %s, leader-role = %b, member-role = %b!!", jwt.claim("preferred_username").orElse("anonymous-user"), jwtFromSecurityContext.claim("groups").orElse("[no-role]"), securityContext.isUserInRole("leader-role"), securityContext.isUserInRole("member-role") ); }
@RolesAllowed("member-role")
を設定したエンドポイント。このユーザーはmember-roleを保持していないのでアクセスできません。
$ curl -i -H "Authorization: Bearer $ACCESS_TOKEN" localhost:8080/roles-allow/member HTTP/1.1 403 Forbidden Connection: keep-alive Content-Length: 0 Date: Sat, 28 Sep 2024 11:39:50 GMT
@RolesAllowed({"leader-role", "member-role"})
を設定したエンドポイント。leader-roleを保持しているのでアクセスできます。
$ curl -i -H "Authorization: Bearer $ID_TOKEN" localhost:8080/roles-allow/both HTTP/1.1 200 OK Connection: keep-alive Content-Type: text/plain;charset=UTF-8 Content-Length: 58 Date: Sat, 28 Sep 2024 11:40:16 GMT Hello Leader or Member[leader-a], groups = [leader-role]!!
@RolesAllowed("*")
を設定したエンドポイント。
$ curl -i -H "Authorization: Bearer $ACCESS_TOKEN" localhost:8080/roles-allow/wildcard HTTP/1.1 200 OK Connection: keep-alive Content-Type: text/plain;charset=UTF-8 Content-Length: 46 Date: Sat, 28 Sep 2024 11:40:36 GMT Hello User[leader-a], groups = [leader-role]!!
@RolesAllowed
アノテーションを付与したエンドポイントについては、ユーザーを切り替えないと確認が足りませんね。
アクセスするユーザーをmember-aに切り替えます。
$ ACCESS_TOKEN=$(curl -s 172.17.0.2:8080/realms/sample-realm/protocol/openid-connect/token \ -d client_id=mp-client \ -d client_secret=${CLIENT_SECRET} \ -d username=member-a \ -d password=password \ -d scope=openid \ -d grant_type=password | jq -r '.access_token') $ ID_TOKEN=$(curl -s 172.17.0.2:8080/realms/sample-realm/protocol/openid-connect/token \ -d client_id=mp-client \ -d client_secret=${CLIENT_SECRET} \ -d username=member-a \ -d password=password \ -d scope=openid \ -d grant_type=password | jq -r '.id_token')
結果はそれぞれ以下です。
## @RolesAllowed("leader-role") $ curl -i -H "Authorization: Bearer $ID_TOKEN" localhost:8080/roles-allow/leader HTTP/1.1 403 Forbidden Connection: keep-alive Content-Length: 0 Date: Sat, 28 Sep 2024 11:42:34 GMT ## @RolesAllowed("member-role") $ curl -i -H "Authorization: Bearer $ACCESS_TOKEN" localhost:8080/roles-allow/member HTTP/1.1 200 OK Connection: keep-alive Content-Type: text/plain;charset=UTF-8 Content-Length: 48 Date: Sat, 28 Sep 2024 11:42:40 GMT Hello Member[member-a], groups = [member-role]!! ## @RolesAllowed({"leader-role", "member-role"}) $ curl -i -H "Authorization: Bearer $ID_TOKEN" localhost:8080/roles-allow/both HTTP/1.1 200 OK Connection: keep-alive Content-Type: text/plain;charset=UTF-8 Content-Length: 58 Date: Sat, 28 Sep 2024 11:42:46 GMT Hello Leader or Member[member-a], groups = [member-role]!! ## @RolesAllowed("*") $ curl -i -H "Authorization: Bearer $ACCESS_TOKEN" localhost:8080/roles-allow/wildcard HTTP/1.1 200 OK Connection: keep-alive Content-Type: text/plain;charset=UTF-8 Content-Length: 46 Date: Sat, 28 Sep 2024 11:42:51 GMT Hello User[member-a], groups = [member-role]!!
@RolesAllowed("leader-role")
および@RolesAllowed("member-role")
の結果が逆転しましたね。
最後に、なにもロールを設定しないother-aで確認してみます。
$ ACCESS_TOKEN=$(curl -s 172.17.0.2:8080/realms/sample-realm/protocol/openid-connect/token \ -d client_id=mp-client \ -d client_secret=${CLIENT_SECRET} \ -d username=other-a \ -d password=password \ -d scope=openid \ -d grant_type=password | jq -r '.access_token') $ ID_TOKEN=$(curl -s 172.17.0.2:8080/realms/sample-realm/protocol/openid-connect/token \ -d client_id=mp-client \ -d client_secret=${CLIENT_SECRET} \ -d username=other-a \ -d password=password \ -d scope=openid \ -d grant_type=password | jq -r '.id_token')
結果はこちら。想定どおり、@RolesAllowed("*")
を設定したエンドポイントのみアクセス可能です。
## @RolesAllowed("leader-role") $ curl -i -H "Authorization: Bearer $ID_TOKEN" localhost:8080/roles-allow/leader HTTP/1.1 403 Forbidden Connection: keep-alive Content-Length: 0 Date: Sat, 28 Sep 2024 11:45:45 GMT ## @RolesAllowed("member-role") $ curl -i -H "Authorization: Bearer $ACCESS_TOKEN" localhost:8080/roles-allow/member HTTP/1.1 403 Forbidden Connection: keep-alive Content-Length: 0 Date: Sat, 28 Sep 2024 11:45:47 GMT ## @RolesAllowed({"leader-role", "member-role"}) $ curl -i -H "Authorization: Bearer $ID_TOKEN" localhost:8080/roles-allow/both HTTP/1.1 403 Forbidden Connection: keep-alive Content-Length: 0 Date: Sat, 28 Sep 2024 11:45:49 GMT ## @RolesAllowed("*") $ curl -i -H "Authorization: Bearer $ACCESS_TOKEN" localhost:8080/roles-allow/wildcard HTTP/1.1 200 OK Connection: keep-alive Content-Type: text/plain;charset=UTF-8 Content-Length: 34 Date: Sat, 28 Sep 2024 11:45:52 GMT Hello User[other-a], groups = []!!
公開鍵の検証については、KeycloakのJWKSエンドポイントにアクセスが来るかどうかで確認しています。
2024-09-28 11:28:29,710 INFO [io.quarkus.http.access-log] (executor-thread-1) 172.17.0.1 - - [28/Sep/2024:11:28:29 +0000] "GET /realms/sample-realm/protocol/openid-connect/certs HTTP/1.1" 200 2949
Keycloakでのアクセスログを有効にする方法はこちら。
Keycloak 25でアクセスログを有効にする(Quarkusのプロパティを指定する) - CLOVER🍀
テストを書く
せっかくなのでテストコードを書いておきましょう。今回は起動済みのWildFlyに対して、REST-Assuredでテストを書くことにしました。
まずはJWTなし、適当な値をAuthorization: Bearer
に設定した場合。
src/test/java/org/littlewings/keycloak/mpjwt/NoLoginAccessTest.java
package org.littlewings.keycloak.mpjwt; import jakarta.ws.rs.core.Response; import org.junit.jupiter.api.Test; import java.util.List; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.equalTo; class NoLoginAccessTest { @Test void noAuthorizationHeader() { List<String> urls = List.of( "http://localhost:8080/plain", "http://localhost:8080/permit-all", "http://localhost:8080/deny-all", "http://localhost:8080/roles-allow/leader", "http://localhost:8080/roles-allow/member", "http://localhost:8080/roles-allow/both", "http://localhost:8080/roles-allow/wildcard" ); for (String url : urls) { given() .when() .get(url) .then() .assertThat() .statusCode(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()) .body(equalTo("Cannot invoke \"io.smallrye.jwt.auth.principal.JWTAuthContextInfo.getTokenHeader()\" because \"this.authContextInfo\" is null")); } } @Test void withAuthorizationHeader() { List<String> urls = List.of( "http://localhost:8080/plain", "http://localhost:8080/permit-all", "http://localhost:8080/deny-all", "http://localhost:8080/roles-allow/leader", "http://localhost:8080/roles-allow/member", "http://localhost:8080/roles-allow/both", "http://localhost:8080/roles-allow/wildcard" ); for (String url : urls) { given() .when() .header("Authorization", "Bearer hoge") .get(url) .then() .assertThat() .statusCode(Response.Status.UNAUTHORIZED.getStatusCode()) .body(equalTo("<html><head><title>Error</title></head><body>Unauthorized</body></html>")); } } }
どのエンドポイントもJWTをつけなければ500エラーになりますし、適当なJWTを設定すると401になりますね…。
以降のテストでは、KeycloakにログインしてアクセストークンまたはIDトークンが必要になるので、
リソースオーナーパスワードクレデンシャルズフロー(Direct Access Grants)を使って取得するクラスを作成。
src/test/java/org/littlewings/keycloak/mpjwt/KeycloakTestSupport.java
package org.littlewings.keycloak.mpjwt; import jakarta.ws.rs.client.Client; import jakarta.ws.rs.client.ClientBuilder; import jakarta.ws.rs.client.Entity; import jakarta.ws.rs.core.Form; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.util.HashMap; import java.util.Map; public class KeycloakTestSupport { private Map<String, Token> tokens = new HashMap<>(); public record Token(String accessToken, String idToken) { } public Token directAccessAuthentication(String username, String password) { if (tokens.containsKey(username + ":" + password)) { return tokens.get(username + ":" + password); } Client client = ClientBuilder.newClient(); Form form = new Form() .param("client_id", "mp-client") .param("client_secret", System.getProperty("oidc.client.secret")) .param("username", username) .param("password", password) .param("scope", "openid") .param("grant_type", "password"); try (Response response = client .target("http://172.17.0.2:8080/realms/sample-realm/protocol/openid-connect/token") .request() .post(Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED))) { if (response.getStatus() != Response.Status.OK.getStatusCode()) { throw new RuntimeException("login failed"); } @SuppressWarnings("unchecked") Map<String, String> map = (Map<String, String>) response.readEntity(Map.class); Token token = new Token(map.get("access_token"), map.get("id_token")); tokens.put(username + ":" + password, token); return token; } } }
クライアントシークレットはシステムプロパティで設定する前提にしました。
あとはテストを書いていきます。各ユーザーごとにテストクラスを用意して、アクセストークンとIDトークンの両方でエンドポイントを
呼び出すようにテストを作成しました。
leader-a向けのテスト。
src/test/java/org/littlewings/keycloak/mpjwt/LeaderRoleAccessTest.java
package org.littlewings.keycloak.mpjwt; import jakarta.ws.rs.core.Response; import org.junit.jupiter.api.Test; import java.util.List; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.emptyString; import static org.hamcrest.Matchers.equalTo; class LeaderRoleAccessTest { @Test void accessAsLeader() { KeycloakTestSupport keycloakTestSupport = new KeycloakTestSupport(); KeycloakTestSupport.Token oauthToken = keycloakTestSupport.directAccessAuthentication("leader-a", "password"); for (String token : List.of(oauthToken.accessToken(), oauthToken.idToken())) { // アクセストークン、IDトークンの両方で確認 // セキュリティに関するJakarta Annotationsなし given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/plain") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("No Protect Resource")); // @PermitAll given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/permit-all") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("Permit All Resource")); // @DenyAll given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/deny-all") .then() .assertThat() .statusCode(Response.Status.FORBIDDEN.getStatusCode()) .body(emptyString()); // @RolesAllowed("leader-role") given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/roles-allow/leader") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("Hello Leader[leader-a], groups = [leader-role], leader-role = true, member-role = false!!")); // @RolesAllowed("member-role") given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/roles-allow/member") .then() .assertThat() .statusCode(Response.Status.FORBIDDEN.getStatusCode()) .body(emptyString()); // @RolesAllowed({"leader-role", "member-role"}) given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/roles-allow/both") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("Hello Leader or Member[leader-a], groups = [leader-role]!!")); // @RolesAllowed("*") given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/roles-allow/wildcard") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("Hello User[leader-a], groups = [leader-role]!!")); } } }
member-a向けのテスト。
src/test/java/org/littlewings/keycloak/mpjwt/MemberRoleAccessTest.java
package org.littlewings.keycloak.mpjwt; import jakarta.ws.rs.core.Response; import org.junit.jupiter.api.Test; import java.util.List; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.emptyString; import static org.hamcrest.Matchers.equalTo; class MemberRoleAccessTest { @Test void accessAsMember() { KeycloakTestSupport keycloakTestSupport = new KeycloakTestSupport(); KeycloakTestSupport.Token oauthToken = keycloakTestSupport.directAccessAuthentication("member-a", "password"); for (String token : List.of(oauthToken.accessToken(), oauthToken.idToken())) { // アクセストークン、IDトークンの両方で確認 // セキュリティに関するJakarta Annotationsなし given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/plain") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("No Protect Resource")); // @PermitAll given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/permit-all") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("Permit All Resource")); // @DenyAll given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/deny-all") .then() .assertThat() .statusCode(Response.Status.FORBIDDEN.getStatusCode()) .body(emptyString()); // @RolesAllowed("leader-role") given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/roles-allow/leader") .then() .assertThat() .statusCode(Response.Status.FORBIDDEN.getStatusCode()) .body(emptyString()); // @RolesAllowed("member-role") given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/roles-allow/member") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("Hello Member[member-a], groups = [member-role]!!")); // @RolesAllowed({"leader-role", "member-role"}) given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/roles-allow/both") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("Hello Leader or Member[member-a], groups = [member-role]!!")); // @RolesAllowed("*") given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/roles-allow/wildcard") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("Hello User[member-a], groups = [member-role]!!")); } } }
other-a向けのテスト。
src/test/java/org/littlewings/keycloak/mpjwt/OtherAccessTest.java
package org.littlewings.keycloak.mpjwt; import jakarta.ws.rs.core.Response; import org.junit.jupiter.api.Test; import java.util.List; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.emptyString; import static org.hamcrest.Matchers.equalTo; class OtherAccessTest { @Test void accessAsOther() { KeycloakTestSupport keycloakTestSupport = new KeycloakTestSupport(); KeycloakTestSupport.Token oauthToken = keycloakTestSupport.directAccessAuthentication("other-a", "password"); for (String token : List.of(oauthToken.accessToken(), oauthToken.idToken())) { // アクセストークン、IDトークンの両方で確認 // セキュリティに関するJakarta Annotationsなし given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/plain") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("No Protect Resource")); // @PermitAll given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/permit-all") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("Permit All Resource")); // @DenyAll given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/deny-all") .then() .assertThat() .statusCode(Response.Status.FORBIDDEN.getStatusCode()) .body(emptyString()); // @RolesAllowed("leader-role") given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/roles-allow/leader") .then() .assertThat() .statusCode(Response.Status.FORBIDDEN.getStatusCode()) .body(emptyString()); // @RolesAllowed("member-role") given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/roles-allow/member") .then() .assertThat() .statusCode(Response.Status.FORBIDDEN.getStatusCode()) .body(emptyString()); // @RolesAllowed({"leader-role", "member-role"}) given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/roles-allow/both") .then() .assertThat() .statusCode(Response.Status.FORBIDDEN.getStatusCode()) .body(emptyString()); // @RolesAllowed("*") given() .header("Authorization", "Bearer " + token) .when() .get("http://localhost:8080/roles-allow/wildcard") .then() .assertThat() .statusCode(Response.Status.OK.getStatusCode()) .body(equalTo("Hello User[other-a], groups = []!!")); } } }
説明は省略します。
このテストを実行する際には、以下のようにシステムプロパティでクライアントシークレットを設定します。
$ mvn test -Doidc.client.secret=...
オマケ
認可に対する各アノテーションに対する、SmallRye JWTのソースコードを見ておきましょう。
@DenyAll
アノテーションに対応するContainerRequestFilter
。
問答無用でForbiddenです。
@RolesAllowed
アノテーションに対応するContainerRequestFilter
。
@RolesAllowed
に*
を設定すると、SecurityContext#getUserPrincipal
がnull
ではない=ログインしていればOKという実装になっていることが
わかります。
それ以外の場合は、@RolesAllowed
に設定した値を使ってSecurityContext#isUserInRole
を呼び出し、いずれかがtrue
を返すとOKに
なっています。
@PermitAll
アノテーションには、対応する処理そのものがありません。
JWTを送信しないとエラーになる件は、いい加減に長くなってきたので今回は深追いしませんでした…。
おわりに
WildFlyとKeycloakでMicroProfile JWT Authを試してみました。
MicroProfile JWT Authの仕様を見て、Keycloakの設定と辻褄を合わせながらリソースを作り、MicroProfile JWT Authの実装であるWildFlyと
SmallRye JWTにだいぶハマるなどかなりてこずりましたが、なんとかなりました…。
MicroProfile JWT Auth固有の概念などはあるのですが、この手のテーマに慣れるきっかけになると思うので、もう少しこの周辺技術を
見ていこうと思います。
Jakarta Securityも見た方がいいというか、最初はJakarta Securityからやった方がよかったのではないかと後から思いました(笑)。