CLOVER🍀

That was when it all began.

WildFly 33 × Keycloak 25でMicroProfile JWT Authを試す

これは、なにをしたくて書いたもの?

MicroProfile JWT Authというものを、1度試しておきたいなということで。

WildFlyとKeycloakを使って試すことにします。

MicroProfile JWT Auth?

MicroProfile JWT Authのページはこちら。

eclipse/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のものに近い感じでしょうか。

トークンベースの認証は、以下のステップで行われます。

  1. リクエストからセキュリティトークンを抽出する
    • これは通常Authorizationヘッダーから取得する
  2. トークンの検証を行う
    • この手順は、使用しているトークンの種類とセキュルティプロトコルで異なる
    • トークンが有効であり、アプリケーションで使用できることを確認するためのステップ
    • 署名、暗号、有効期限のチェックが含まれることがある
  3. トークンをイントロスペクションし、Subjectに関する情報を抽出する
    • この手順は、使用しているトークンの種類とセキュルティプロトコルで異なる
    • トークンから利用者にとって必要な情報を取得する
  4. 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ヘッダーおよびクレーム。

Eclipse MicroProfile Interoperable JWT RBAC / Recommendations for Interoperability / Required and Recommended MP-JWT Headers and Claims

  • 必須のJWTヘッダー
    • alg
      • JWTを保護するために必要な暗号化アルゴリズム
        • クレームに署名する必要がある場合は、P-256またはSHA-256を使ったRSASSA-PKCS1-v1_5、SHA-256、ECDSAが必要であり、RS256またはES256として指定する必要がある
        • クレームを暗号化する必要がある場合は、RSAES、SHA-256を使うRSAES OAEPまたはMGF1のサポートが必要
    • enc
      • クレームまたはネストされたJWTトークンを暗号化する必要がある場合のみ要求される
      • GCMモードのAESのサポートは必須で、A256GCMを指定する
  • 推奨されるJWTヘッダー
    • typ
      • トークンをRFC7519(JWT)として識別し、値は「JWT」である必要がある
    • kid
      • JWTを保護するために使用されたキーを示すヒント。検証キーがJWK(RFC7515)フォーマットの場合はに必要
  • 必須のJWTクレーム
    • iss
    • iat
      • JWTが発行された時刻
    • exp
      • JWTの有効期限
    • upn または preferred_username または sub
      • ユーザーの名前、ユーザーの識別子(ユーザープリンシパル
      • upnがない場合はpreferred_username、preferred_usernameがない場合はsubへフォールバックする
  • 推奨されるJWTクレーム
    • sub
    • jti
      • JWTの一意の識別子
    • aud
      • JWTでアクセスできるMP JWTエンドポイントを識別する
      • トークンの行使先

クレームは、Claims列挙型で定義されています。

Eclipse MicroProfile Interoperable JWT RBAC / Recommendations for Interoperability / The Claims Enumeration Utility Class, and the Set of Claim Value Types

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

Eclipse MicroProfile Interoperable JWT RBAC / Marking a JAX-RS Application as Requiring MP-JWT Access Control

つまり、MicroProfile JWT AuthはJAX-RSと組み合わせて使うことになります。

RBACということなので、先に権限制御に関するAPIについて。これはJSR-250を使います。

Eclipse MicroProfile Interoperable JWT RBAC / Mapping MP-JWT Tokens to Jakarta EE Container APIs / Using the Common Security Annotations for the Java Platform (JSR-250)

現在はJakarta Annotationsですね。

Jakarta Annotations 2.1 | The Eclipse Foundation

クラスおよびメソッドに付与できる、次の3つのアノテーションがあります。

これらのアノテーションは、クラスとメソッドの両方に付与されている場合はメソッドに付与されたものが優先されます。

Jakarta Annotations / Annotations / PermitAll, DenyAll and RolesAllowed interactions

MicroProfile JWT Authとして見ると、ロール名を@RolesAllowedアノテーションで制御すればいいことになりますね。ちなみにgroupsという
クレームは、MicroProfile JWT Auth独自のものみたいです。実装によっては他のクレームに読み替えを設定できるようですが。

@PermitAllはなかなか豪快で、認証不要ということになります。

CDIとのインテグレーションでは、JWTを表すJsonWebTokenJsonWebTokenに含まれるクレームの値を表すClaimValue
インジェクションできます。

@Path("/endp")
@DenyAll
@ApplicationScoped
public class RolesEndpoint {

    @Inject
    private JsonWebToken callerPrincipal;


...


    @Inject
    @Claim(standard = Claims.iss)
    private ClaimValue<String> issuer;

Eclipse MicroProfile Interoperable JWT RBAC / Mapping MP-JWT Tokens to Jakarta EE Container APIs / CDI Injection Requirements

ClaimValueではなく、クレームの値をStringなどで直接インジェクションすることもできるようですが、やめた方がよさげです。

あとはJAX-RSSecurityContext#getUserPrincipalJsonWebTokenを返し、SecurityContext#isUserInRoleでユーザーが指定した
groupクレームを保持しているかどうかを確認できます。

Eclipse MicroProfile Interoperable JWT RBAC / Mapping MP-JWT Tokens to Jakarta EE Container APIs / JAX-RS Container API Integration

その他のJakarta EEのAPI使用とのインテグレーションについて。Servletweb.xmlでのオーバーライドについても触れられています。

Eclipse MicroProfile Interoperable JWT RBAC / Mapping MP-JWT Tokens to Jakarta EE Container APIs / Recommendations for Optional Container Integration

仕様書の以降では、設定項目などが挙げられているのですがここでは省略します。サンプルコード作成時に、必要に応じて見ていきます。

Javadocはこちらです。

MicroProfile JWT Auth API

参考)

MicroProfile JWT Authがやってくれること・できること | 豆蔵デベロッパーサイト

MicroProfile JWTを使ってマイクロサービスをセキュアにしよう - Speaker Deck

Using JWT RBAC - Quarkus

MicroProfile JWTによるスケーラブルでセキュアなマイクロサービス構築 -入門編(Part 1)-|富士通技術者ブログ~Javaミドルウェア~ : 富士通

MicroProfile JWTによるスケーラブルでセキュアなマイクロサービス構築 -入門編(Part 2)-|富士通技術者ブログ~Javaミドルウェア~ : 富士通

SmallRye JWT

WildFlyが使用するMicroProfile JWT Authの実装は、SmallRye JWTです。

SmallRye JWT Documentation :: SmallRye documentation

GitHubリポジトリーはこちら。

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

WildFlyJAX-RSと組み合わせる場合には、自分でsmallrye-jwt-jaxrsを依存関係に追加して有効化する必要がありそうです。試した時は
これに気づかずだいぶハマりました…。

ではMicroProfile JWT Subsystemに含まれているのはなにかというと、SmallRye JWTの以下のモジュールです。

これらがなにをしてくれるかというと、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を使ってプロビジョニングしました。この範囲だと、ふつうにWildFlystandalone.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のデコードはこちらで行いました。

JSON Web Tokens - jwt.io

アクセストークンのデコード結果。

ヘッダー。

{
  "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が送信された時のみ検証すべきだという主張のようですが

Unauthorized response on a unprotected Endpoint, when a token is sent · Issue #282 · eclipse/microprofile-jwt-auth · GitHub

Payaraなどでも問題があったりしたようです。

Unauthorized response on a unprotected endpoint, when a JWT is sent but not injected /FISH-6022 · Issue #5582 · payara/Payara · GitHub

FISH-6022 FISH-6299: Allow access to unprotected endpoints with invalid JWT by pdudits · Pull Request #6021 · payara/Payara · GitHub

有効な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

https://github.com/smallrye/smallrye-jwt/blob/4.3.1/implementation/jwt-jaxrs/src/main/java/io/smallrye/jwt/auth/jaxrs/DenyAllFilter.java#L35

問答無用でForbiddenです。

@RolesAllowedアノテーションに対応するContainerRequestFilter

https://github.com/smallrye/smallrye-jwt/blob/4.3.1/implementation/jwt-jaxrs/src/main/java/io/smallrye/jwt/auth/jaxrs/RolesAllowedFilter.java#L52-L56

@RolesAllowed*を設定すると、SecurityContext#getUserPrincipalnullではない=ログインしていればOKという実装になっていることが
わかります。

それ以外の場合は、@RolesAllowedに設定した値を使ってSecurityContext#isUserInRoleを呼び出し、いずれかがtrueを返すとOKに
なっています。

@PermitAllアノテーションには、対応する処理そのものがありません。

https://github.com/smallrye/smallrye-jwt/blob/4.3.1/implementation/jwt-jaxrs/src/main/java/io/smallrye/jwt/auth/jaxrs/JWTAuthorizationFilterRegistrar.java#L49-L65

JWTを送信しないとエラーになる件は、いい加減に長くなってきたので今回は深追いしませんでした…。

おわりに

WildFlyとKeycloakでMicroProfile JWT Authを試してみました。

MicroProfile JWT Authの仕様を見て、Keycloakの設定と辻褄を合わせながらリソースを作り、MicroProfile JWT Authの実装であるWildFly
SmallRye JWTにだいぶハマるなどかなりてこずりましたが、なんとかなりました…。

MicroProfile JWT Auth固有の概念などはあるのですが、この手のテーマに慣れるきっかけになると思うので、もう少しこの周辺技術を
見ていこうと思います。

Jakarta Securityも見た方がいいというか、最初はJakarta Securityからやった方がよかったのではないかと後から思いました(笑)。