CLOVER🍀

That was when it all began.

WildFly 34とKeycloak 26でJakarta SecurityのOpenID Connectを使った認証と認可設定を試す

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

前に、MicroProfile JWT AuthをWildFly 33とKeycloak 25で試してみました。

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

この時に思ったのですが、Jakarta SecurityでOpenID Connectを扱っており、先にこちらをやった方がよかったのではないかなと思っていまして。
今回試してみることにしました。

Jakarta Security

Jakarta Securityのページはこちら。

Jakarta Security | Jakarta EE | The Eclipse Foundation

Jakarta EE 10でのJakarta Securityのバージョンは3.0です。

Jakarta Security 3.0 | Jakarta EE | The Eclipse Foundation

Jakarta Security 3.0の仕様書はこちらです。

Jakarta Security

Javadocはこちら。

jakarta.security (Jakarta Security API documentation)

Jakarta SecurityはJava EEの頃を含めて今までまったく情報を見ていなかったので、今回は概念と興味がある部分をピックアップして
見ていきたいと思います。

まずは用語と略称について。

Jakarta Security / Concepts and General Requirements / Terminology And Acronyms

  • Authentication Mechanism(認証メカニズム)
  • Caller(呼び出し元)、Caller Principal
    • アプリケーションにリクエストを送信したり、アプリケーションAPIを呼び出したりするユーザー
    • Caller Principal(呼び出し元プリンシパル)とは、そのユーザーを表すプリンシパルオブジェクト
  • HAM
    • Jakarta Securityで定義されているインターフェースである、HttpAuthenticationMechanismの略称
  • Identity Store(アイデンティティーストア)
    • ユーザー、グループ、ロール、権限などのアプリケーション固有のセキュリティデータにアクセスできるコンポーネント
    • 通常、RDBMSLDAPファイルシステム、その他類似のリソースなどのデータソースと1対1で関連付けられている
    • 同義語: セキュリティプロバイダー、リポジトリー、ストア、ログインモジュール(JAAS)、アイデンティティーマネージャー、サービスプロバイダー、Relying Party、Authenticator、ユーザーサービス
  • SAM
    • Jakarta Authenticationで定義されているインターフェースであるServerAuthModuleの略称

Jakarta Securityに対する一般的な要件はこちら。

Jakarta Security / Concepts and General Requirements / General Requirements

  • グループとロールのマッピング
    • グループ名からロールへのデフォルトのマッピングを提供する必要がある
    • 例として、グループ"foo"のメンバーである呼び出し元は、ロール"foo"を持つとみなされる
    • 明示的な独自の構成によって上書きされることもある
  • Caller Principalの種類
  • Jakarta Expression Language(EL式)のサポート
    • この仕様ではいくつかのアノテーションが定義されているが、このアノテーションの値にJakarta Expression Language 5.0式を指定できること
    • 定義されているアノテーションの例
      • DatabaseIdentityStoreDefinitionLdapIdentityStoreDefinitionBasicAuthenticationMechanismDefinitionCustomFormAuthenticationMechanismDefinitionFormAuthenticationMechanismDefinitionOpenIdAuthenticationMechanismDefinitionLoginToContinueRememberMe

次は認証メカニズムについて少し見てみます。

Jakarta Security / Authentication Mechanism

こちらのインターフェースと動作原理を見ると、ざっくり以下のことがわかります。

  • HttpAuthenticationMechanismJakarta Authenticationで定義されているServerAuthインターフェースと密接に連携すること
  • HttpAuthenticationMechanismインターフェースはServlet上で動作すること

Jakarta Security / Authentication Mechanism / Interface and Theory of Operation

HttpAuthenticationMechanismJakarta Contexts and Dependency Injection(CDI)管理Beanとなる必要があるようです。

Jakarta Security / Authentication Mechanism / Installation and Configuration

アノテーションと組み込みのHttpAuthenticationMechanismインターフェース実装によるCDI管理Beanについてはこちら。

Jakarta Security / Authentication Mechanism / Annotations and Built-In HttpAuthenticationMechanism Beans

認証方法に関するアノテーションはこちら。

これでおよそサポートしている認証方法がわかりますね。これらのアノテーションを使用することで、アプリケーションに対して認証を
設定することができます。

この仕様書ではどこで使用したらいいのかがほぼ読み取れないのですが…。

アイデンティティーストアについては今回はパス。

Jakarta Security / Identity Store

利用者から見た時に、HttpAuthenticationMechanism以外に関わるのはこちらのSecurityContextかなと思います。

Jakarta Security / Security Context

SecurityContext (Jakarta Security API documentation)

Jakarta Securityはアプリケーションリソースを保護するための宣言的セキュリティモデルを定義しますが、もっと複雑な制約がある場合には
プログラムによる保護も行えます。

ここで使うのがSecurityContextのようです。

Jakarta Security / Security Context / Introduction

SecurityContextでは以下のことができます。

またSecurityContextCDI管理Beanであり、その他のJakarta EE仕様との関係も書かれています。

Jakarta Security / Security Context / Relationship to Other Specifications

SecurityContextは、最終的に以下に書かれている内容の代替になることを目指しているようです。

  • ServletHttpServletRequest#getUserPrincipalHttpServletRequest#isUserInRole
  • Enterprise Beans: EJBContext#getCallerPrincipalEJBContext#isCallerInRole
  • XML Web Services: WebServiceContext#getUserPrincipalWebServiceContext#isUserInRole
  • RESTful Web Services: SecurityContext#getUserPrincipalSecurityContext#isUserInRole
  • Server Faces: ExternalContext#getUserPrincipalExternalContext#isUserInRole
  • Contexts and Dependency Injection: @Inject Principal
  • WebSocket: Session#getUserPrincipal

OpenID Connect認証を使った場合は、OpenIdContextというCDI管理Beanが使えるようになるようです。

Jakarta Security / Security Context / Other context interfaces / OpenIdContext

OpenIdContextは、アクセストークンやIDトークン、クレームなどを取得できるインターフェースのようです。

OpenIdContext (Jakarta Security API documentation)

概念や気になる要素などは、個人的にはこのくらいでしょうか。

もっと詳細に追っていくと、Jakarta AuthenticationやJakarta Authorizationなども見ていくことになる気がしますね。

Jakarta Authentication 3.0 | Jakarta EE | The Eclipse Foundation

Jakarta Authorization 2.1 | Jakarta EE | The Eclipse Foundation

使い方は?

と、ここまでJakarta Securityの仕様書を見てきましたが、使用例もなにもないので使い方がまったくわかりません。

いくつか参考になりそうなコードを探してみました。OpenID Connectを使ったもの(OpenIdAuthenticationMechanismDefinition)で
探しています。

Open Libertyから。

Protect your applications with Jakarta Security, MicroProfile JWT, and Keycloak - OpenLiberty.io

こちらではJakarta RESTful Web Services(JAX-RS)のリソースクラスに@OpenIdAuthenticationMechanismDefinitionアノテーション
付与して、OpenID Connectを使った認証を行うことを示しています。

https://github.com/OpenLiberty/sample-keycloak/blob/main/src/gateway/src/main/java/io/openliberty/guides/gateway/auth/AuthResource.java

また認可にはJakarta AnnotationsのアノテーションRolesAllowedなど)を使っていますね。

Jakarta Annotations 2.1 | Jakarta EE | The Eclipse Foundation

OpenID Connectのクライアントの設定例。

Enable an OpenID Connect client for an application :: Open Liberty Docs

Payaraから。

Securing Jakarta EE Applications with OIDC and Keycloak

Auth0から。

Use Jakarta EE 10 with OpenID Connect Authentication

その他。

What’s new in Jakarta Security 3?

@OpenIdAuthenticationMechanismDefinitionCDI管理Beanやサーブレットに付与して、認可はJakarta AnnotationsやJakarta Servlet
@ServletSecurityを使ったものなどが多かったですね。

Jakarta Servlet Specification / Security / Programmatic Security Policy Configuration

今回は、CDI管理Beanに@OpenIdAuthenticationMechanismDefinitionアノテーションを付与したり、認可にJakarta Servletを使ってみようと
思います。
※本当はJAX-RSで試したかったのですが、うまくいかなかったので今回はJakatra Servletまでにしておきました

…ちなみに、参照実装のリポジトリー(Jakarta Security 4.0では仕様のリポジトリー)を見るとサンプルがありました。

Jakarta Securityの参照実装

Jakarta Securityの参照実装はEclipse Soteriaです。

GitHub - eclipse-ee4j/soteria: Soteria, a Jakarta Security implementation

WildFlyでも使われているようです。

Webサイトもあったようなのですが現在はNot Foundなので、こちらのサンプルを参考にすることになりそうです。

https://github.com/eclipse-ee4j/soteria/tree/3.0.3-RELEASE/test

ちなみに、これらのサンプルはJakarta Security 4.0になるとTCKとしてJakarta Securityのリポジトリーに移されているようです。

https://github.com/jakartaee/security/tree/4.0.0-RELEASE/tck

またAuth0のJakarta SecurityとOpenID Connectのサンプルも参考にしています。

Use Jakarta EE 10 with OpenID Connect Authentication

GitHub - oktadev/auth0-jakarta-ee-oidc-example: Jakarta EE OIDC Example

お題

以下の構成で、Jakarta SecurityのOpenID Connectを使った認証を試してみたいと思います。

flowchart LR
    A[Browser] --> |HTTP| B[WildFly]
    A --> |Redirect / HTTP| C[Keycloak]
    B --> |Get Token / Verify Token| C

OpenID Connectの用語の合わせると、こうなりますね。

flowchart LR
    A[Browser] --> |HTTP| B[Relying Party/WildFly]
    A --> |Redirect / HTTP| C[OpenID Provider/Keycloak]
    B --> |Get Token / Verify Token| C

アクセスするユーザーとしては、リーダーと一般ユーザーの2種類(のロール)を用意することにします。

Keycloadk上のリソースはTerraformで作成するものとします。

環境

今回の環境はこちら。

$ java --version
openjdk 21.0.5 2024-10-15
OpenJDK Runtime Environment (build 21.0.5+11-Ubuntu-1ubuntu124.04)
OpenJDK 64-Bit Server VM (build 21.0.5+11-Ubuntu-1ubuntu124.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.9.9 (8e8579a9e76f7d015ee5ec7bfcdc97d260186937)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 21.0.5, vendor: Ubuntu, runtime: /usr/lib/jvm/java-21-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "6.8.0-49-generic", arch: "amd64", family: "unix"

Keycloak。

$ bin/kc.sh --version
Keycloak 26.0.7
JVM: 21.0.5 (Eclipse Adoptium OpenJDK 64-Bit Server VM 21.0.5+11-LTS)
OS: Linux 6.8.0-49-generic amd64

Keycloakは172.17.0.2で動作しているものとし、以下のコマンドで起動させておきます。

$ KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD=password bin/kc.sh start-dev

Terraform。

$ terraform version
Terraform v1.10.1
on linux_amd64

TerraformでKeycloakのリソースを作成する

まずはTerraformでKeycloak上のリソースを定義します。

versions.tf

terraform {
  required_version = "1.10.1"

  required_providers {
    keycloak = {
      source  = "keycloak/keycloak"
      version = "4.5.0"
    }
  }
}

いきなり脱線しますが、KeycloakのTerraform ProviderがKeycloakのorganizationに移りましたね。

GitHub - keycloak/terraform-provider-keycloak: Terraform provider for Keycloak

最近更新が滞っていたので、これでまた更新されるようになるといいのですが。

ちなみにKeycloakのTerraform Providerはとてもよく使われているようです。

Keycloak Realm Configuration Management Tools Survey Results - Keycloak

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" "ee_client" {
  realm_id  = keycloak_realm.sample_realm.id
  client_id = "ee-client"

  name    = "Jakarta Security 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/*"]
}

## Client Protocol Mapper
data "keycloak_openid_client_scope" "roles" { # client scopeのmapperとして使う場合
  realm_id = keycloak_realm.sample_realm.id
  name     = "roles"
}

resource "keycloak_openid_user_realm_role_protocol_mapper" "role_mapper" {
  realm_id  = keycloak_realm.sample_realm.id
  name      = "roles as top"
  client_id = keycloak_openid_client.ee_client.id # dedicated mapperとして使う場合
  #client_scope_id = data.keycloak_openid_client_scope.roles.id # client scopeのmapperとして使う場合

  claim_name       = "roles"
  claim_value_type = "String"
  multivalued      = true

  add_to_access_token = true
  add_to_id_token     = false
}

## User
resource "keycloak_user" "leader_user" {
  realm_id   = keycloak_realm.sample_realm.id
  username   = "leader-user"
  email      = "leader-user@example.com"
  first_name = "Leader"
  last_name  = "User"

  enabled = true

  initial_password {
    value     = "password"
    temporary = false
  }
}

resource "keycloak_user" "general_user" {
  realm_id   = keycloak_realm.sample_realm.id
  username   = "general-user"
  email      = "general-user@example.com"
  first_name = "General"
  last_name  = "User"

  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" "general_role" {
  realm_id = keycloak_realm.sample_realm.id
  name     = "general-role"
}

## Assign Role
resource "keycloak_user_roles" "leader_user_assign_leader_role" {
  realm_id = keycloak_realm.sample_realm.id
  user_id  = keycloak_user.leader_user.id

  role_ids = [
    keycloak_role.leader_role.id
  ]
}

resource "keycloak_user_roles" "general_user_assign_general_role" {
  realm_id = keycloak_realm.sample_realm.id
  user_id  = keycloak_user.general_user.id

  role_ids = [
    keycloak_role.general_role.id
  ]
}

あとでまた説明しますが、今回のポイントはクライアントプロトコルマッパーです。

## Client Protocol Mapper
data "keycloak_openid_client_scope" "roles" { # client scopeのmapperとして使う場合
  realm_id = keycloak_realm.sample_realm.id
  name     = "roles"
}

resource "keycloak_openid_user_realm_role_protocol_mapper" "role_mapper" {
  realm_id  = keycloak_realm.sample_realm.id
  name      = "roles as top"
  client_id = keycloak_openid_client.ee_client.id # dedicated mapperとして使う場合
  #client_scope_id = data.keycloak_openid_client_scope.roles.id # client scopeのmapperとして使う場合

  claim_name       = "roles"
  claim_value_type = "String"
  multivalued      = true

  add_to_access_token = true
  add_to_id_token     = false
}

これをやらないと、Jakarta Securityで認証した後にユーザーに割り当てたロールを認識できません。

リソースを作成。

$ terraform init
$ terraform apply

作成した後は、クライアントシークレットを取得しておきます。

$ 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=ee-client' -F id | jq -r '.[].id')/client-secret | jq -r '.value'

PpPOIGaghPz1btAZN57wsTdpD5X9Vaig

ソースコードを作成する

それでは、ソースコードを作成していきます。

Maven依存関係など。

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

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>jakarta.platform</groupId>
                <artifactId>jakarta.jakartaee-bom</artifactId>
                <version>10.0.0</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.eclipse.microprofile</groupId>
                <artifactId>microprofile</artifactId>
                <version>6.1</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>jakarta.enterprise</groupId>
            <artifactId>jakarta.enterprise.cdi-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>jakarta.inject</groupId>
            <artifactId>jakarta.inject-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>jakarta.annotation</groupId>
            <artifactId>jakarta.annotation-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>jakarta.security.enterprise</groupId>
            <artifactId>jakarta.security.enterprise-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.eclipse.microprofile.config</groupId>
            <artifactId>microprofile-config-api</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>ROOT</finalName>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.4.0</version>
                <configuration>
                    <failOnMissingWebXml>false</failOnMissingWebXml>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.wildfly.plugins</groupId>
                <artifactId>wildfly-maven-plugin</artifactId>
                <version>5.0.1.Final</version>
                <executions>
                    <execution>
                        <id>package</id>
                        <goals>
                            <goal>package</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <overwrite-provisioned-server>true</overwrite-provisioned-server>
                    <discover-provisioning-info>
                        <version>34.0.1.Final</version>
                    </discover-provisioning-info>
                    <commands>
                        <command>/subsystem=undertow/application-security-domain=other:write-attribute(name=integrated-jaspi, value=false)</command>
                    </commands>
                </configuration>
            </plugin>
        </plugins>
    </build>

ここでのポイントはこちらで、WildFlyではJASPIとのインテグレーションを無効にしておかないとうまく動きません…。

                    <commands>
                        <command>/subsystem=undertow/application-security-domain=other:write-attribute(name=integrated-jaspi, value=false)</command>
                    </commands>

これはAuth0のブログに書いてありました。JASPIを無効にして資格情報の検証を無効にする必要があるようです。

It took a little digging to figure out, but the obscure command block is required, at least according to the experts I asked. It disables integrated JASPI (Java Authentication SPI for Containers) in the server and delegates validation of credentials to a non-integrated ServerAuthModule. This allows identities to be dynamically created instead of statically stored in an integrated security domain. Look at the Elytron and Java EE Security section of the docs for more on this.

Use Jakarta EE 10 with OpenID Connect Authentication

実際、これを無効にしないとKeycloakにログインした後にこんな例外を見ることになります。

20:12:23,505 ERROR [io.undertow.request] (default task-1) UT005023: Exception handling request to /callback: java.lang.IllegalStateException: java.io.IOException: java.io.IOException: ELY01177: Authorization failed.
        at org.glassfish.soteria@3.0.3//org.glassfish.soteria.mechanisms.jaspic.Jaspic.handleCallbacks(Jaspic.java:180)
        at org.glassfish.soteria@3.0.3//org.glassfish.soteria.mechanisms.jaspic.Jaspic.notifyContainerAboutLogin(Jaspic.java:153)
        at org.glassfish.soteria@3.0.3//org.glassfish.soteria.mechanisms.HttpMessageContextImpl.notifyContainerAboutLogin(HttpMessageContextImpl.java:220)
        at org.glassfish.soteria@3.0.3//org.glassfish.soteria.mechanisms.HttpMessageContextImpl.notifyContainerAboutLogin(HttpMessageContextImpl.java:198)
        at org.wildfly.security.jakarta.security@3.0.3.Final//org.wildfly.security.soteria.original.OpenIdAuthenticationMechanism.validateAuthorizationCode(OpenIdAuthenticationMechanism.java:365)
        at org.wildfly.security.jakarta.security@3.0.3.Final//org.wildfly.security.soteria.original.OpenIdAuthenticationMechanism.authenticate(OpenIdAuthenticationMechanism.java:273)
        at org.wildfly.security.jakarta.security@3.0.3.Final//org.wildfly.security.soteria.original.OpenIdAuthenticationMechanism.validateRequest(OpenIdAuthenticationMechanism.java:171)
        at org.wildfly.security.jakarta.security@3.0.3.Final//org.wildfly.security.soteria.original.OpenIdAuthenticationMechanism$Proxy$_$$_WeldClientProxy.validateRequest(Unknown Source)
        at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
        at java.base/java.lang.reflect.Method.invoke(Method.java:580)
        at org.jboss.weld.core@5.1.3.Final//org.jboss.weld.bean.proxy.AbstractBeanInstance.invoke(AbstractBeanInstance.java:38)
        at org.jboss.weld.core@5.1.3.Final//org.jboss.weld.bean.proxy.ProxyMethodHandler.invoke(ProxyMethodHandler.java:109)
        at org.wildfly.security.jakarta.security@3.0.3.Final//org.jboss.weld.generated.proxies.security.enterprise.authentication.mechanism.http.HttpAuthenticationMechanism$397943940$Proxy$_$$_WeldClientProxy.validateRequest(Unknown Source)
        at org.glassfish.soteria@3.0.3//org.glassfish.soteria.mechanisms.jaspic.HttpBridgeServerAuthModule.validateRequest(HttpBridgeServerAuthModule.java:89)
        at org.wildfly.security.jakarta.authentication@3.0.3.Final//org.wildfly.security.auth.jaspi.impl.ElytronServerAuthContext.validateRequest(ElytronServerAuthContext.java:85)
        at org.wildfly.security.jakarta.authentication@3.0.3.Final//org.wildfly.security.auth.jaspi.impl.WrappingServerAuthContext.lambda$validateRequest$0(WrappingServerAuthContext.java:50)
        at org.wildfly.security.jakarta.authentication@3.0.3.Final//org.wildfly.security.auth.jaspi.impl.ThreadLocalCallbackHandler.get(ThreadLocalCallbackHandler.java:56)
        at org.wildfly.security.jakarta.authentication@3.0.3.Final//org.wildfly.security.auth.jaspi.impl.WrappingServerAuthContext.validateRequest(WrappingServerAuthContext.java:50)
        at org.wildfly.security.elytron-web.undertow-server-servlet@4.1.0.Final//org.wildfly.elytron.web.undertow.server.servlet.ServletSecurityContextImpl.authenticate(ServletSecurityContextImpl.java:176)
        at org.wildfly.security.elytron-web.undertow-server-servlet@4.1.0.Final//org.wildfly.elytron.web.undertow.server.servlet.ServletSecurityContextImpl.authenticate(ServletSecurityContextImpl.java:101)
        at io.undertow.servlet@2.3.18.Final//io.undertow.servlet.handlers.security.ServletAuthenticationCallHandler.handleRequest(ServletAuthenticationCallHandler.java:55)
        at io.undertow.core@2.3.18.Final//io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
        at io.undertow.core@2.3.18.Final//io.undertow.security.handlers.AuthenticationConstraintHandler.handleRequest(AuthenticationConstraintHandler.java:53)
        at io.undertow.core@2.3.18.Final//io.undertow.security.handlers.AbstractConfidentialityHandler.handleRequest(AbstractConfidentialityHandler.java:46)
        at io.undertow.servlet@2.3.18.Final//io.undertow.servlet.handlers.security.ServletConfidentialityConstraintHandler.handleRequest(ServletConfidentialityConstraintHandler.java:64)
        at io.undertow.servlet@2.3.18.Final//io.undertow.servlet.handlers.security.ServletSecurityConstraintHandler.handleRequest(ServletSecurityConstraintHandler.java:60)
        at io.undertow.core@2.3.18.Final//io.undertow.security.handlers.AbstractSecurityContextAssociationHandler.handleRequest(AbstractSecurityContextAssociationHandler.java:43)
        at org.wildfly.security.elytron-web.undertow-server-servlet@4.1.0.Final//org.wildfly.elytron.web.undertow.server.servlet.CleanUpHandler.handleRequest(CleanUpHandler.java:38)
        at io.undertow.core@2.3.18.Final//io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
        at org.wildfly.extension.undertow@34.0.1.Final//org.wildfly.extension.undertow.security.jacc.JACCContextIdHandler.handleRequest(JACCContextIdHandler.java:44)
        at io.undertow.core@2.3.18.Final//io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
        at org.wildfly.extension.undertow@34.0.1.Final//org.wildfly.extension.undertow.deployment.GlobalRequestControllerHandler.handleRequest(GlobalRequestControllerHandler.java:51)
        at io.undertow.servlet@2.3.18.Final//io.undertow.servlet.handlers.SendErrorPageHandler.handleRequest(SendErrorPageHandler.java:52)
        at io.undertow.core@2.3.18.Final//io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
        at io.undertow.servlet@2.3.18.Final//io.undertow.servlet.handlers.ServletInitialHandler.handleFirstRequest(ServletInitialHandler.java:276)
        at io.undertow.servlet@2.3.18.Final//io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:135)
        at io.undertow.servlet@2.3.18.Final//io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:132)
        at io.undertow.servlet@2.3.18.Final//io.undertow.servlet.core.ServletRequestContextThreadSetupAction$1.call(ServletRequestContextThreadSetupAction.java:48)
        at io.undertow.servlet@2.3.18.Final//io.undertow.servlet.core.ContextClassLoaderSetupAction$1.call(ContextClassLoaderSetupAction.java:43)
        at org.wildfly.extension.undertow@34.0.1.Final//org.wildfly.extension.undertow.deployment.UndertowDeploymentInfoService$UndertowThreadSetupAction.lambda$create$0(UndertowDeploymentInfoService.java:1421)
        at org.wildfly.extension.undertow@34.0.1.Final//org.wildfly.extension.undertow.deployment.UndertowDeploymentInfoService$UndertowThreadSetupAction.lambda$create$0(UndertowDeploymentInfoService.java:1421)
        at io.undertow.servlet@2.3.18.Final//io.undertow.servlet.handlers.ServletInitialHandler.dispatchRequest(ServletInitialHandler.java:256)
        at io.undertow.servlet@2.3.18.Final//io.undertow.servlet.handlers.ServletInitialHandler$1.handleRequest(ServletInitialHandler.java:101)
        at io.undertow.core@2.3.18.Final//io.undertow.server.Connectors.executeRootHandler(Connectors.java:395)
        at io.undertow.core@2.3.18.Final//io.undertow.server.HttpServerExchange$1.run(HttpServerExchange.java:861)
        at org.jboss.threads@2.4.0.Final//org.jboss.threads.ContextClassLoaderSavingRunnable.run(ContextClassLoaderSavingRunnable.java:35)
        at org.jboss.threads@2.4.0.Final//org.jboss.threads.EnhancedQueueExecutor.safeRun(EnhancedQueueExecutor.java:1990)
        at org.jboss.threads@2.4.0.Final//org.jboss.threads.EnhancedQueueExecutor$ThreadBody.doRunTask(EnhancedQueueExecutor.java:1486)
        at org.jboss.threads@2.4.0.Final//org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1377)
        at org.jboss.xnio@3.8.16.Final//org.xnio.XnioWorker$WorkerThreadFactory$1$1.run(XnioWorker.java:1282)
        at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.io.IOException: java.io.IOException: ELY01177: Authorization failed.
        at org.wildfly.security.jakarta.authentication@3.0.3.Final//org.wildfly.security.auth.jaspi.impl.JaspiAuthenticationContext$1.handle(JaspiAuthenticationContext.java:149)
        at org.wildfly.security.jakarta.authentication@3.0.3.Final//org.wildfly.security.auth.jaspi.impl.ThreadLocalCallbackHandler.handle(ThreadLocalCallbackHandler.java:49)
        at org.glassfish.soteria@3.0.3//org.glassfish.soteria.mechanisms.jaspic.Jaspic.handleCallbacks(Jaspic.java:174)
        ... 50 more
Caused by: java.io.IOException: ELY01177: Authorization failed.
        at org.wildfly.security.jakarta.authentication@3.0.3.Final//org.wildfly.security.auth.jaspi.impl.JaspiAuthenticationContext$1.handleOne(JaspiAuthenticationContext.java:261)
        at org.wildfly.security.jakarta.authentication@3.0.3.Final//org.wildfly.security.auth.jaspi.impl.JaspiAuthenticationContext$1.lambda$handle$0(JaspiAuthenticationContext.java:138)
        at org.wildfly.security.jakarta.authentication@3.0.3.Final//org.wildfly.security.auth.jaspi.impl.SecurityActions.doPrivileged(SecurityActions.java:39)
        at org.wildfly.security.jakarta.authentication@3.0.3.Final//org.wildfly.security.auth.jaspi.impl.JaspiAuthenticationContext$1.handle(JaspiAuthenticationContext.java:137)
        ... 52 more

Eclipse SoteriaのJASPIとのインテグレーションがうまくいかないようです。

UT005023: Exception handling request to /callback: java.lang.IllegalStateException: java.io.IOException: java.io.IOException: ELY01177: Authorization failed.
        at org.glassfish.soteria@3.0.3//org.glassfish.soteria.mechanisms.jaspic.Jaspic.handleCallbacks(Jaspic.java:180)
        at org.glassfish.soteria@3.0.3//org.glassfish.soteria.mechanisms.jaspic.Jaspic.notifyContainerAboutLogin(Jaspic.java:153)

Jakarta Securityを使った、OpenID Connectの設定はこちら。

src/main/java/org/littlewings/security/oidc/OidcConfig.java

package org.littlewings.security.oidc;

import jakarta.annotation.security.DeclareRoles;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.security.enterprise.authentication.mechanism.http.OpenIdAuthenticationMechanismDefinition;
import jakarta.security.enterprise.authentication.mechanism.http.openid.ClaimsDefinition;
import jakarta.security.enterprise.authentication.mechanism.http.openid.OpenIdConstant;
import org.eclipse.microprofile.config.inject.ConfigProperty;

@OpenIdAuthenticationMechanismDefinition(
        providerURI = "${oidcConfig.providerUri}",
        clientId = "${oidcConfig.clientId}",
        clientSecret = "${oidcConfig.clientSecret}",
        scope = {OpenIdConstant.OPENID_SCOPE, OpenIdConstant.EMAIL_SCOPE, OpenIdConstant.PROFILE_SCOPE},
        redirectURI = "${baseURL}/callback",
        //redirectToOriginalResource = true,  // trueにすると、アクセスしたページに戻ろうとする
        tokenAutoRefresh = true,
        claimsDefinition = @ClaimsDefinition(callerGroupsClaim = "roles")
)
@DeclareRoles({"leader-role", "general-role"})
@ApplicationScoped
@Named("oidcConfig")  // ELで参照するのに必要
public class OidcConfig {
    @Inject
    @ConfigProperty(name = "security.oidc.provider-uri")
    private String providerUri;

    @Inject
    @ConfigProperty(name = "security.oidc.client-id")
    private String clientId;

    @Inject
    @ConfigProperty(name = "security.oidc.client-secret")
    private String clientSecret;

    public String getProviderUri() {
        return providerUri;
    }

    public void setProviderUri(String providerUri) {
        this.providerUri = providerUri;
    }

    public String getClientId() {
        return clientId;
    }

    public void setClientId(String clientId) {
        this.clientId = clientId;
    }

    public String getClientSecret() {
        return clientSecret;
    }

    public void setClientSecret(String clientSecret) {
        this.clientSecret = clientSecret;
    }
}
  "realm_access": {
    "roles": [
      "admin-role"
    ]
  },

@OpenIdAuthenticationMechanismDefinitionアノテーションの各プロパティの意味はJavadocを見てもらえればとは思うのですが、
値自体は環境に応じて変わることが多いと思うのでEL式で指定するようにしています。

@OpenIdAuthenticationMechanismDefinition(
        providerURI = "${oidcConfig.providerUri}",
        clientId = "${oidcConfig.clientId}",
        clientSecret = "${oidcConfig.clientSecret}",
        scope = {OpenIdConstant.OPENID_SCOPE, OpenIdConstant.EMAIL_SCOPE, OpenIdConstant.PROFILE_SCOPE},
        redirectURI = "${baseURL}/callback",
        //redirectToOriginalResource = true,  // trueにすると、アクセスしたページに戻ろうとする
        tokenAutoRefresh = true,
        claimsDefinition = @ClaimsDefinition(callerGroupsClaim = "roles")
)

OpenIdAuthenticationMechanismDefinition (Jakarta Security API documentation)

参照しているのはこのCDI管理Bean自身ですが。

@ApplicationScoped
@Named("oidcConfig")  // ELで参照するのに必要
public class OidcConfig {

で、値自体はMicroProfile Configでプロパティファイルに書くことにしました。

src/main/resources/META-INF/microprofile-config.properties

security.oidc.provider-uri=http://172.17.0.2:8080/realms/sample-realm
security.oidc.client-id=ee-client
security.oidc.client-secret=PpPOIGaghPz1btAZN57wsTdpD5X9Vaig

インジェクションしているのはここですね。

    @Inject
    @ConfigProperty(name = "security.oidc.provider-uri")
    private String providerUri;

    @Inject
    @ConfigProperty(name = "security.oidc.client-id")
    private String clientId;

    @Inject
    @ConfigProperty(name = "security.oidc.client-secret")
    private String clientSecret;

これで実行時に環境変数で上書きできるようになります。

ロールもここで宣言しています。

@DeclareRoles({"leader-role", "general-role"})

あとはアクセス制御を行うServletを書いていきます。

src/main/java/org/littlewings/security/oidc/LeaderOrGeneralAccessServlet.java

package org.littlewings.security.oidc;

import java.io.IOException;
import java.io.PrintWriter;
import java.security.Principal;
import java.util.Map;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.security.enterprise.SecurityContext;
import jakarta.security.enterprise.identitystore.openid.AccessToken;
import jakarta.security.enterprise.identitystore.openid.IdentityToken;
import jakarta.security.enterprise.identitystore.openid.OpenIdContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.HttpConstraint;
import jakarta.servlet.annotation.ServletSecurity;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@WebServlet("/leader-or-general")
@ServletSecurity(@HttpConstraint(rolesAllowed = {"leader-role", "general-role"}))
@ApplicationScoped
public class LeaderOrGeneralAccessServlet extends HttpServlet {
    @Inject
    private SecurityContext securityContext;

    @Inject
    private OpenIdContext openIdContext;

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        PrintWriter writer = response.getWriter();

        Principal principal = securityContext.getCallerPrincipal();

        writer.printf("=============== Principal ===============%n");
        writer.printf("name = %s%n", principal.getName());
        writer.printf("=============== Principal ===============%n");

        writer.println();

        if (!"anonymous".equals(principal.getName())) {
            writer.printf("=============== OpenIdContext ===============%n");

            writer.printf("subject = %s%n", openIdContext.getSubject());

            writer.println();

            writer.printf("claims preferred_username = %s%n", openIdContext.getClaims().getPreferredUsername().get());
            writer.printf("claims roles = %s%n", openIdContext.getClaims().getArrayStringClaim("roles"));

            writer.println();

            AccessToken accessToken = openIdContext.getAccessToken();
            writer.printf("access token string = %s%n", accessToken.getToken());
            writer.println();
            writer.printf("access tokens claims%n");

            for (Map.Entry<String, Object> claim : accessToken.getClaims().entrySet()) {
                writer.printf("  %s = %s%n", claim.getKey(), claim.getValue());
            }

            writer.println();

            IdentityToken idToken = openIdContext.getIdentityToken();
            writer.printf("id token string = %s%n", idToken.getToken());
            writer.println();
            writer.printf("id tokens claims%n");

            for (Map.Entry<String, Object> claim : idToken.getClaims().entrySet()) {
                writer.printf("  %s = %s%n", claim.getKey(), claim.getValue());
            }

            writer.printf("=============== OpenIdContext ===============%n");
        }
    }
}

リーダーと一般ユーザーの両方がアクセスできるServletです。

@WebServlet("/leader-or-general")
@ServletSecurity(@HttpConstraint(rolesAllowed = {"leader-role", "general-role"}))
@ApplicationScoped
public class LeaderOrGeneralAccessServlet extends HttpServlet {

doGetメソッドの中は他のServletでも使いまわすのですが、WildFlyのモジュールに依存せずにログインしているかどうかを判定する
いい方法がなかったので、とりあえずこうしました。

        if (!"anonymous".equals(principal.getName())) {

この部分は、もう少しちゃんと調べた方がいいですね…。

この感じで、アクセスできるロールを変えたServletを作っていきます。

リーダーだけがアクセスできるServlet

src/main/java/org/littlewings/security/oidc/LeaderOnlyAccessServlet.java

package org.littlewings.security.oidc;

// import文は省略

@WebServlet("/leader-only")
@ServletSecurity(@HttpConstraint(rolesAllowed = "leader-role"))
@ApplicationScoped
public class LeaderOnlyAccessServlet extends HttpServlet {

    // 他は同じ
}

一般ユーザーだけがアクセスできるServlet

src/main/java/org/littlewings/security/oidc/GeneralOnlyAccessServlet.java

package org.littlewings.security.oidc;

// import文は省略

@WebServlet("/general-only")
@ServletSecurity(@HttpConstraint(rolesAllowed = "general-role"))
@ApplicationScoped
public class GeneralOnlyAccessServlet extends HttpServlet {

    // 他は同じ
}

どのロールでもアクセスできないServlet

src/main/java/org/littlewings/security/oidc/AccessDenyAccessServlet.java

package org.littlewings.security.oidc;

// import文は省略

@WebServlet("/deny")
@ServletSecurity(@HttpConstraint(ServletSecurity.EmptyRoleSemantic.DENY))
@ApplicationScoped
public class AccessDenyAccessServlet extends HttpServlet {

    // 他は同じ
}

どれもパスと@ServletSecurityアノテーションに設定する@HttpConstraintアノテーションの内容が違うだけですね。

@ServletSecurity(@HttpConstraint(rolesAllowed = {"leader-role", "general-role"}))

@ServletSecurity(@HttpConstraint(rolesAllowed = "leader-role"))

@ServletSecurity(@HttpConstraint(rolesAllowed = "general-role"))

@ServletSecurity(@HttpConstraint(ServletSecurity.EmptyRoleSemantic.DENY))

ここまで来ると気づくかもしれませんが、web.xmlsecurity-constraintの設定をアノテーションで書いていることになります。

    <security-constraint>
        <web-resource-collection>
            <web-resource-name>...</web-resource-name>
            <url-pattern>...</url-pattern>
        </web-resource-collection>
        <auth-constraint>
            <role-name>...</role-name>
        </auth-constraint>
    </security-constraint>

アクセス制限をつけないServletも用意。

src/main/java/org/littlewings/security/oidc/PlainServlet.java

package org.littlewings.security.oidc;

// import文は省略

@WebServlet("/plain")
@ApplicationScoped
public class PlainServlet extends HttpServlet {

    // 他は同じ
}

それから、アクセス制限がかかっているServletに未ログイン状態でアクセスするとKeycloakにリダイレクトされ、ログインすると
コールバック用のURLに戻ってきます。

それがredirectURIの設定です。ちなみにデフォルト値は"${baseURL}/Callback"ですが(Cが大文字)。

@OpenIdAuthenticationMechanismDefinition(
        providerURI = "${oidcConfig.providerUri}",
        clientId = "${oidcConfig.clientId}",
        clientSecret = "${oidcConfig.clientSecret}",
        scope = {OpenIdConstant.OPENID_SCOPE, OpenIdConstant.EMAIL_SCOPE, OpenIdConstant.PROFILE_SCOPE},
        redirectURI = "${baseURL}/callback",
        //redirectToOriginalResource = true,  // trueにすると、アクセスしたページに戻ろうとする
        tokenAutoRefresh = true,
        claimsDefinition = @ClaimsDefinition(callerGroupsClaim = "roles")
)

これに対応するServletを用意します。

src/main/java/org/littlewings/security/oidc/CallbackServlet.java

package org.littlewings.security.oidc;

import java.io.IOException;
import java.security.Principal;
import java.util.Optional;

import jakarta.inject.Inject;
import jakarta.security.enterprise.SecurityContext;
import jakarta.security.enterprise.authentication.mechanism.http.openid.OpenIdConstant;
import jakarta.security.enterprise.identitystore.openid.OpenIdContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@WebServlet("/callback")
public class CallbackServlet extends HttpServlet {
    @Inject
    private SecurityContext securityContext;

    @Inject
    private OpenIdContext context;

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        Principal principal = securityContext.getCallerPrincipal();

        if (!"anonymous".equals(principal.getName())) {
            Optional<String> originalRequest = context.getStoredValue(request, response, OpenIdConstant.ORIGINAL_REQUEST);
            String originalRequestString = originalRequest.get();
            response.sendRedirect(originalRequestString);
        }
    }
}

この時、OpenIdConstantORIGINAL_REQUESTを使ってOpenIdContextから値を取得することで、未ログイン時にアクセスしたURLに
リダイレクトさせています。

近い動きをredirectToOriginalResourcetrueにすることで実現できるのですが、OpenID Connectのフローで使われるQueryStringも
付いたままになるのでやめました…。

@OpenIdAuthenticationMechanismDefinition(
        providerURI = "${oidcConfig.providerUri}",
        clientId = "${oidcConfig.clientId}",
        clientSecret = "${oidcConfig.clientSecret}",
        scope = {OpenIdConstant.OPENID_SCOPE, OpenIdConstant.EMAIL_SCOPE, OpenIdConstant.PROFILE_SCOPE},
        redirectURI = "${baseURL}/callback",
        //redirectToOriginalResource = true,  // trueにすると、アクセスしたページに戻ろうとする
        tokenAutoRefresh = true,
        claimsDefinition = @ClaimsDefinition(callerGroupsClaim = "roles")
)

動作確認する

それでは動作確認してみます。

WildFlyを起動。
※今回のWildFly Maven Pluginの設定だと、パッケージングして起動する方法だとJASPIが無効にならずうまくいきません

$ mvn wildfly:run

まずは未ログイン状態でhttp://localhost:8080/plainにアクセスしてみます。

結果。

次にhttp://localhost:8080/leader-or-generalにアクセスすると、Keycloakのログイン画面にリダイレクトされます。

まずはリーダーとしてログイン。

ログインすると、コールバック用のSerlvetに1度戻ってきてから、見ていたページにリダイレクトします。

内容をテキストでも書いておきましょう。

=============== Principal ===============
name = leader-user
=============== Principal ===============

=============== OpenIdContext ===============
subject = 86a38e0e-4745-4e42-ada1-fee9de5d4cae

claims preferred_username = leader-user
claims roles = [leader-role]

access token string = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJMeUR0OU9KOGcyRi1QSVN5OHVUNkdJdTRnZDhTZVlnQTZ0Z2ZsMzdLMkdNIn0.eyJleHAiOjE3MzM2NTkwMDMsImlhdCI6MTczMzY1ODcwMywiYXV0aF90aW1lIjoxNzMzNjU4NTE5LCJqdGkiOiI2ODk5MzkzYS01MjNmLTRjNDctOGI4MS05ZDMxYjBkYzkzNjAiLCJpc3MiOiJodHRwOi8vMTcyLjE3LjAuMjo4MDgwL3JlYWxtcy9zYW1wbGUtcmVhbG0iLCJzdWIiOiI4NmEzOGUwZS00NzQ1LTRlNDItYWRhMS1mZWU5ZGU1ZDRjYWUiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJlZS1jbGllbnQiLCJzaWQiOiJiODc5MWI1Ny1kZmNjLTQ5MjgtYmZhZi02MWE1NTE4YjI2NjEiLCJhY3IiOiIwIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImxlYWRlci1yb2xlIl19LCJzY29wZSI6Im9wZW5pZCBlbWFpbCBwcm9maWxlIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJyb2xlcyI6WyJsZWFkZXItcm9sZSJdLCJuYW1lIjoiTGVhZGVyIFVzZXIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJsZWFkZXItdXNlciIsImdpdmVuX25hbWUiOiJMZWFkZXIiLCJmYW1pbHlfbmFtZSI6IlVzZXIiLCJlbWFpbCI6ImxlYWRlci11c2VyQGV4YW1wbGUuY29tIn0.cjapWgdl4iwEGcp9lu0zdzYHdiZrLckQyoRQr-JsTVYHpiSHI699KcvdltrhkRJHII64BglFTvCgTOMKztiGQsYf2oswLTVjJe-BvM6ugnU2BgRQztKTPlc5_E1d9175Ky9H3pji0bDR1BKN_2kuVtdbxgOiVVSIdRjYdabL95u4I3lJsf-peZ-rvTnLwSBFg6xl1eqtAsocVh-7b3Mgknh81iIP6XDMaQxGElzHwc1_OJZ7OsiVdJ31LZFw1LxYVVrjkAlAoPl-kc0Xv6KeWVEabrkSI6P4bComtpI2w9cfKP0hXANPRrNellqw0IPXbMN5mntWrWZoMuyBMkblow

access tokens claims
  exp = Sun Dec 08 20:56:43 JST 2024
  iat = Sun Dec 08 20:51:43 JST 2024
  auth_time = 1733658519
  jti = 6899393a-523f-4c47-8b81-9d31b0dc9360
  iss = http://172.17.0.2:8080/realms/sample-realm
  sub = 86a38e0e-4745-4e42-ada1-fee9de5d4cae
  typ = Bearer
  azp = ee-client
  sid = b8791b57-dfcc-4928-bfaf-61a5518b2661
  acr = 0
  realm_access = {roles=[leader-role]}
  scope = openid email profile
  email_verified = false
  roles = [leader-role]
  name = Leader User
  preferred_username = leader-user
  given_name = Leader
  family_name = User
  email = leader-user@example.com

id token string = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJMeUR0OU9KOGcyRi1QSVN5OHVUNkdJdTRnZDhTZVlnQTZ0Z2ZsMzdLMkdNIn0.eyJleHAiOjE3MzM2NTkwMDMsImlhdCI6MTczMzY1ODcwMywiYXV0aF90aW1lIjoxNzMzNjU4NTE5LCJqdGkiOiI1YjFiMmZkYS01ZWU1LTQxOWEtYjcyOC1kNDBjZDRjNmE1NzEiLCJpc3MiOiJodHRwOi8vMTcyLjE3LjAuMjo4MDgwL3JlYWxtcy9zYW1wbGUtcmVhbG0iLCJhdWQiOiJlZS1jbGllbnQiLCJzdWIiOiI4NmEzOGUwZS00NzQ1LTRlNDItYWRhMS1mZWU5ZGU1ZDRjYWUiLCJ0eXAiOiJJRCIsImF6cCI6ImVlLWNsaWVudCIsIm5vbmNlIjoicXp4SjQxdXAyQzZKTXRKc1hWXzNSZVRvRjVad3BnczAxQkxEN0NUYkZBYyIsInNpZCI6ImI4NzkxYjU3LWRmY2MtNDkyOC1iZmFmLTYxYTU1MThiMjY2MSIsImF0X2hhc2giOiI4VDk5ZUVGZEtPRDAwVjN3aTE4Um9nIiwiYWNyIjoiMCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IkxlYWRlciBVc2VyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibGVhZGVyLXVzZXIiLCJnaXZlbl9uYW1lIjoiTGVhZGVyIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwiZW1haWwiOiJsZWFkZXItdXNlckBleGFtcGxlLmNvbSJ9.mg2yppplBou84pWoeRSAvZKgsOfE3LFt5bqxcAEouet0IIJuZ8a5fmACs0ezXvccIsUuS8pVrXxL4Ty46I0Y_62fBNbvqmlNT2vcvp3aEKvRaSiOSEu_w92N4RKg-VOK6Aq0c8_ANPpHBUVJoW1cEMsiqsR31hMb3CD4H6YTxrMnPz_7BPNmnmiFalefiDyanAdCtdMjiYdv2e2lCejXf8LBstfocqP7-xJNl431L3GM6DWcjaVM3Oa7fHDbFH_pJ1nL3HXvSQDmXgHNzfFop8TiU3h2gIy_SiFvVojfwZDZi5YFlXUBNZ6JmDM-BnQiQ43HT2SJvfM_wIlRin8UIQ

id tokens claims
  exp = Sun Dec 08 20:56:43 JST 2024
  iat = Sun Dec 08 20:51:43 JST 2024
  auth_time = 1733658519
  jti = 5b1b2fda-5ee5-419a-b728-d40cd4c6a571
  iss = http://172.17.0.2:8080/realms/sample-realm
  aud = [ee-client]
  sub = 86a38e0e-4745-4e42-ada1-fee9de5d4cae
  typ = ID
  azp = ee-client
  nonce = qzxJ41up2C6JMtJsXV_3ReToF5Zwpgs01BLD7CTbFAc
  sid = b8791b57-dfcc-4928-bfaf-61a5518b2661
  at_hash = 8T99eEFdKOD00V3wi18Rog
  acr = 0
  email_verified = false
  name = Leader User
  preferred_username = leader-user
  given_name = Leader
  family_name = User
  email = leader-user@example.com
=============== OpenIdContext ===============

次に一般ユーザーのみがアクセスできるhttp://localhost:8080/general-onlyを表示してみると、Forbiddenになります。

あとは表示内容は記載しませんが、アクセス結果を書いておきます。

  • /leader-only … アクセス可能、/leader-or-generalと同じ結果が表示される
  • /plain … アクセス可能、/leader-or-generalと同じ結果が表示される
  • /deny … Forbidden

次は一般ユーザーでアクセスします。

http://localhost:8080/leader-or-generalへのアクセス結果。

=============== Principal ===============
name = general-user
=============== Principal ===============

=============== OpenIdContext ===============
subject = b9af2f37-709a-4953-9710-99e5a0b66eb2

claims preferred_username = general-user
claims roles = [general-role]

access token string = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJMeUR0OU9KOGcyRi1QSVN5OHVUNkdJdTRnZDhTZVlnQTZ0Z2ZsMzdLMkdNIn0.eyJleHAiOjE3MzM2NTkzNjMsImlhdCI6MTczMzY1OTA2MywiYXV0aF90aW1lIjoxNzMzNjU5MDYzLCJqdGkiOiJkMDQxYTY2OS1jZDUzLTRmNGUtOTczMy1hNGQ4MDBmNmVlMDEiLCJpc3MiOiJodHRwOi8vMTcyLjE3LjAuMjo4MDgwL3JlYWxtcy9zYW1wbGUtcmVhbG0iLCJzdWIiOiJiOWFmMmYzNy03MDlhLTQ5NTMtOTcxMC05OWU1YTBiNjZlYjIiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJlZS1jbGllbnQiLCJzaWQiOiIwNTY1NTE2OS03NTc1LTRkNjYtODM2NC1kNGY5OTg3MzY5YjQiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImdlbmVyYWwtcm9sZSJdfSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicm9sZXMiOlsiZ2VuZXJhbC1yb2xlIl0sIm5hbWUiOiJHZW5lcmFsIFVzZXIiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJnZW5lcmFsLXVzZXIiLCJnaXZlbl9uYW1lIjoiR2VuZXJhbCIsImZhbWlseV9uYW1lIjoiVXNlciIsImVtYWlsIjoiZ2VuZXJhbC11c2VyQGV4YW1wbGUuY29tIn0.OcyWb9G7gb-fTooonYjIXvK4K2HKYNTtVPbZwfbw-7io7sihTDlxmDGMBjybWAD5PdfEJttDyn0GGLIJ-vVRrx5Znz-gBlIPjNRp9rG2jHh1wD-_MjCWC_JeKvkoMuTNd4RUPU3Lxn7-yUS8RUszVfCcLQdm0T6Eo7ufhZ4KNyCZ-awHtq2e0C-TPPi4qy7aU6uwnqXTOxMK8dntExPXgFqMwksHDN1MSOlpp9RkL-li-WjQ98_5pFxaglF5D5CCizZ_VVhBQcq494SclGJLVsP72Bt7fqEueEzQFg7t-5EuQu18giR_dhlgCarlh5cHTp30305qQuIeqc_r2W-VUQ

access tokens claims
  exp = Sun Dec 08 21:02:43 JST 2024
  iat = Sun Dec 08 20:57:43 JST 2024
  auth_time = 1733659063
  jti = d041a669-cd53-4f4e-9733-a4d800f6ee01
  iss = http://172.17.0.2:8080/realms/sample-realm
  sub = b9af2f37-709a-4953-9710-99e5a0b66eb2
  typ = Bearer
  azp = ee-client
  sid = 05655169-7575-4d66-8364-d4f9987369b4
  acr = 1
  realm_access = {roles=[general-role]}
  scope = openid email profile
  email_verified = false
  roles = [general-role]
  name = General User
  preferred_username = general-user
  given_name = General
  family_name = User
  email = general-user@example.com

id token string = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJMeUR0OU9KOGcyRi1QSVN5OHVUNkdJdTRnZDhTZVlnQTZ0Z2ZsMzdLMkdNIn0.eyJleHAiOjE3MzM2NTkzNjMsImlhdCI6MTczMzY1OTA2MywiYXV0aF90aW1lIjoxNzMzNjU5MDYzLCJqdGkiOiIyODEwZjE2Mi1kYzY0LTRkYTktYjk5Ny1mMWFiNzE4MjljMjIiLCJpc3MiOiJodHRwOi8vMTcyLjE3LjAuMjo4MDgwL3JlYWxtcy9zYW1wbGUtcmVhbG0iLCJhdWQiOiJlZS1jbGllbnQiLCJzdWIiOiJiOWFmMmYzNy03MDlhLTQ5NTMtOTcxMC05OWU1YTBiNjZlYjIiLCJ0eXAiOiJJRCIsImF6cCI6ImVlLWNsaWVudCIsIm5vbmNlIjoiSDhXdlYxYjE3SXA4ZmljMHJmbjVZVjNVYXlidlA3S1dxc3pibkFqVXctdyIsInNpZCI6IjA1NjU1MTY5LTc1NzUtNGQ2Ni04MzY0LWQ0Zjk5ODczNjliNCIsImF0X2hhc2giOiJJZXdJcGljbHVtSC1Pa1BTcDF3X0dnIiwiYWNyIjoiMSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwibmFtZSI6IkdlbmVyYWwgVXNlciIsInByZWZlcnJlZF91c2VybmFtZSI6ImdlbmVyYWwtdXNlciIsImdpdmVuX25hbWUiOiJHZW5lcmFsIiwiZmFtaWx5X25hbWUiOiJVc2VyIiwiZW1haWwiOiJnZW5lcmFsLXVzZXJAZXhhbXBsZS5jb20ifQ.Pn3tCStIDP999gneiimfjpA0z0zMRz3UiWv9VZAyoRTkwYzTBc6ukHrwq5wIHtg6MRGV33kyjdDyZ4IJUufVYDaWsdqzkwPLLDQWq8M1AY6CX7VtdYNUjuK2w02sKiWyDSHw81h84xs-dN6FkKxWFrthkg63Qzp-mV1O3toa47ac-Bsd-VYSl0RvJoKdfuEGUcId9-7Vk5_GDwbkgUMsN1gS-cG3jM-KqnmakPgrgatrEKTGo0xzXI-qdIxWDu8vDRRgI0y13h2Kbjy2t2ga96MQnJpUfoMqH7qF0_VEi5XHe5zB_wMbR6-v_D9gsuDDu9hDMJs5LRWTDhLD2CeBJA

id tokens claims
  exp = Sun Dec 08 21:02:43 JST 2024
  iat = Sun Dec 08 20:57:43 JST 2024
  auth_time = 1733659063
  jti = 2810f162-dc64-4da9-b997-f1ab71829c22
  iss = http://172.17.0.2:8080/realms/sample-realm
  aud = [ee-client]
  sub = b9af2f37-709a-4953-9710-99e5a0b66eb2
  typ = ID
  azp = ee-client
  nonce = H8WvV1b17Ip8fic0rfn5YV3UaybvP7KWqszbnAjUw-w
  sid = 05655169-7575-4d66-8364-d4f9987369b4
  at_hash = IewIpiclumH-OkPSp1w_Gg
  acr = 1
  email_verified = false
  name = General User
  preferred_username = general-user
  given_name = General
  family_name = User
  email = general-user@example.com
=============== OpenIdContext ===============

他の結果も記載しておきます。

  • /leader-only … Forbidden
  • /general-only … アクセス可能、/leader-or-generalと同じ結果が表示される
  • /plain … アクセス可能、/leader-or-generalと同じ結果が表示される
  • /deny … Forbidden

というわけで、設定どおり動きました。

オマケ

ここからは、少し補足を書いていきます。

KeycloakのロールをJakarta Securityのグループに割り当てる

Terraformで構成していた、クライアントプロトコルマッパーの話です。

特になにも設定しないと、Keycloakのデフォルトの状態だとロールの情報はアクセストークンに以下のように入ります。

  "realm_access": {
    "roles": [
      "leader-role"
    ]
  },

これはクライアントスコープのrolesに設定されている、realm rolesマッパーがこのようにトークンに設定するようになっているからですね。

このロールの情報をJakarta Securityのグループに紐付ける必要があるのですが、それが@ClaimsDefinitionアノテーション
callerGroupsClaim属性です。名前のとおり、デフォルト値はgroupsでKeycloakのロール名とは合いません。

@OpenIdAuthenticationMechanismDefinition(
        providerURI = "${oidcConfig.providerUri}",
        clientId = "${oidcConfig.clientId}",
        clientSecret = "${oidcConfig.clientSecret}",
        scope = {OpenIdConstant.OPENID_SCOPE, OpenIdConstant.EMAIL_SCOPE, OpenIdConstant.PROFILE_SCOPE},
        redirectURI = "${baseURL}/callback",
        //redirectToOriginalResource = true,  // trueにすると、アクセスしたページに戻ろうとする
        tokenAutoRefresh = true,
        claimsDefinition = @ClaimsDefinition(callerGroupsClaim = "roles")
)

このためKeycloak側に合わせる必要があるのですが、あいにく@ClaimsDefinitionアノテーションcallerGroupsClaim属性は
JSONのネストしたフィールドを指定できません。

よって、なんとかトップレベルのフィールドにロールの情報を持ってくる必要があります。

それを行ったのがクライアントプロトコルマッパーの設定です。

## Client Protocol Mapper
data "keycloak_openid_client_scope" "roles" { # client scopeのmapperとして使う場合
  realm_id = keycloak_realm.sample_realm.id
  name     = "roles"
}

resource "keycloak_openid_user_realm_role_protocol_mapper" "role_mapper" {
  realm_id  = keycloak_realm.sample_realm.id
  name      = "roles as top"
  client_id = keycloak_openid_client.ee_client.id # dedicated mapperとして使う場合
  #client_scope_id = data.keycloak_openid_client_scope.roles.id # client scopeのmapperとして使う場合

  claim_name       = "roles"
  claim_value_type = "String"
  multivalued      = true

  add_to_access_token = true
  add_to_id_token     = false
}

少しコメントアウトが入っていますが、これは特定のクライアント専用のプロトコルマッパーになっています。

コメントアウトを外すと、ロール全体で有効なマッパーにできます。この例だと、rolesクライアントスコープに対して
クライアントプロトコルマッパーを追加するので、このレルムに属するすべてのクライアントで有効になります。

Server Administration Guide / Managing OpenID Connect and SAML Clients / OIDC token and SAML assertion mappings

Server Administration Guide / Managing OpenID Connect and SAML Clients / Client scopes

今回は専用のクライアントプロトコルマッパーとして構成しています。

結果として、アクセストークンにロールの情報が以下のように含まれることになります。

  "roles": [
    "leader-role"
  ],

ここを理解するのがかなり大変でした…。

@OpenIdAuthenticationMechanismDefinitionなどはどこで定義すればいいのか?

ドキュメントなどを見ていると、@OpenIdAuthenticationMechanismDefinitionアノテーションなどはだいたいServletに付けてあります。

このためにServletを用意するのはどうなのかなと思ってEclipse Soteriaを見てみると、@OpenIdAuthenticationMechanismDefinitionなどの
アノテーションは、CDI管理Beanに付与すれば検出してくれるようです。

https://github.com/eclipse-ee4j/soteria/blob/3.0.3-RELEASE/impl/src/main/java/org/glassfish/soteria/cdi/CdiExtension.java#L119-L247

ちなみに、Servletに付与しても検出されました。

おわりに

Jakarta Securityを使ったOpenID Connectを使った認証、それから認可設定を試してみました。

もうめちゃくちゃ苦労しましたが…Keycloak、Jakarta Servletまわりでハマったところは次ではもうハマらないと思うので、まあよしと
しましょう…。

Keycloakのよい勉強にはなったと思います。

一方でJakarta SecurityをJAX-RSと組み合わせるのがうまくいかなかったりしたので、そのあたりはまたの機会に見ていきたいですね。

それからJakarta Security以外にもpac4jも見た方がいいのかな?という気分になりました。