これは、なにをしたくて書いたもの?
前に、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の仕様書はこちらです。
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
- HAM
- Jakarta Securityで定義されているインターフェースである、HttpAuthenticationMechanismの略称
- Identity Store(アイデンティティーストア)
- SAM
- Jakarta Authenticationで定義されているインターフェースであるServerAuthModuleの略称
Jakarta Securityに対する一般的な要件はこちら。
Jakarta Security / Concepts and General Requirements / General Requirements
- グループとロールのマッピング
- グループ名からロールへのデフォルトのマッピングを提供する必要がある
- 例として、グループ"foo"のメンバーである呼び出し元は、ロール"foo"を持つとみなされる
- 明示的な独自の構成によって上書きされることもある
- Caller Principalの種類
- この仕様では、アプリケーションの呼び出し元のアイデンティティーを表すCallerPrincipalと呼ばれるプリンシパルタイプを定義する
- CallerPrincipalには「コンテナ呼び出し元プリンシパル」と「アプリケーション呼び出し元プリンシパル」の2種類がある
- コンテナ呼び出し元プリンシパルは、コンテナが呼び出し元のアイデンティティーを表すために使用するもの
- アプリケーション呼び出し元プリンシパルは、アプリケーションまたはHttpAuthenticationMechanismなどの実装が呼び出し元のアイデンティティーを表すために使用するもの
- コンテナ呼び出し元プリンシパルとアプリケーション呼び出し元プリンシパルの両方が存在する場合、両方のプリンシパルでの
getNameの結果は同じである必要がある - 認証中に特定のアプリケーション呼び出し元プリンシパルが提供されない場合、呼び出し元のアイデンティティーは単一のプリンシパル(コンテナ呼び出し元プリンシパル)によって表される必要がある
- Jakarta Expression Language(EL式)のサポート
- この仕様ではいくつかのアノテーションが定義されているが、このアノテーションの値にJakarta Expression Language 5.0式を指定できること
- 定義されているアノテーションの例
DatabaseIdentityStoreDefinition、LdapIdentityStoreDefinition、BasicAuthenticationMechanismDefinition、CustomFormAuthenticationMechanismDefinition、FormAuthenticationMechanismDefinition、OpenIdAuthenticationMechanismDefinition、LoginToContinue、RememberMe
次は認証メカニズムについて少し見てみます。
Jakarta Security / Authentication Mechanism
こちらのインターフェースと動作原理を見ると、ざっくり以下のことがわかります。
HttpAuthenticationMechanismはJakarta Authenticationで定義されているServerAuthインターフェースと密接に連携することHttpAuthenticationMechanismインターフェースはServlet上で動作すること
Jakarta Security / Authentication Mechanism / Interface and Theory of Operation
HttpAuthenticationMechanismはJakarta Contexts and Dependency Injection(CDI)管理Beanとなる必要があるようです。
Jakarta Security / Authentication Mechanism / Installation and Configuration
アノテーションと組み込みのHttpAuthenticationMechanismインターフェース実装によるCDI管理Beanについてはこちら。
認証方法に関するアノテーションはこちら。
これでおよそサポートしている認証方法がわかりますね。これらのアノテーションを使用することで、アプリケーションに対して認証を
設定することができます。
この仕様書ではどこで使用したらいいのかがほぼ読み取れないのですが…。
アイデンティティーストアについては今回はパス。
Jakarta Security / Identity Store
利用者から見た時に、HttpAuthenticationMechanism以外に関わるのはこちらのSecurityContextかなと思います。
Jakarta Security / Security Context
SecurityContext (Jakarta Security API documentation)
Jakarta Securityはアプリケーションリソースを保護するための宣言的セキュリティモデルを定義しますが、もっと複雑な制約がある場合には
プログラムによる保護も行えます。
ここで使うのがSecurityContextのようです。
Jakarta Security / Security Context / Introduction
SecurityContextでは以下のことができます。
- Principalの取得
- リソースへのアクセスのテスト
- 認証プロセスのトリガー
またSecurityContextはCDI管理Beanであり、その他のJakarta EE仕様との関係も書かれています。
Jakarta Security / Security Context / Relationship to Other Specifications
SecurityContextは、最終的に以下に書かれている内容の代替になることを目指しているようです。
- Servlet:
HttpServletRequest#getUserPrincipal、HttpServletRequest#isUserInRole - Enterprise Beans:
EJBContext#getCallerPrincipal、EJBContext#isCallerInRole - XML Web Services:
WebServiceContext#getUserPrincipal、WebServiceContext#isUserInRole - RESTful Web Services:
SecurityContext#getUserPrincipal、SecurityContext#isUserInRole - Server Faces:
ExternalContext#getUserPrincipal、ExternalContext#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を使った認証を行うことを示しています。
また認可には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?
@OpenIdAuthenticationMechanismDefinitionはCDI管理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.xmlでsecurity-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); } } }
この時、OpenIdConstantのORIGINAL_REQUESTを使ってOpenIdContextから値を取得することで、未ログイン時にアクセスしたURLに
リダイレクトさせています。
近い動きをredirectToOriginalResourceをtrueにすることで実現できるのですが、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 / Client scopes
今回は専用のクライアントプロトコルマッパーとして構成しています。
結果として、アクセストークンにロールの情報が以下のように含まれることになります。
"roles": [ "leader-role" ],
ここを理解するのがかなり大変でした…。
@OpenIdAuthenticationMechanismDefinitionなどはどこで定義すればいいのか?
ドキュメントなどを見ていると、@OpenIdAuthenticationMechanismDefinitionアノテーションなどはだいたいServletに付けてあります。
このためにServletを用意するのはどうなのかなと思ってEclipse Soteriaを見てみると、@OpenIdAuthenticationMechanismDefinitionなどの
アノテーションは、CDI管理Beanに付与すれば検出してくれるようです。
ちなみに、Servletに付与しても検出されました。
おわりに
Jakarta Securityを使ったOpenID Connectを使った認証、それから認可設定を試してみました。
もうめちゃくちゃ苦労しましたが…Keycloak、Jakarta Servletまわりでハマったところは次ではもうハマらないと思うので、まあよしと
しましょう…。
Keycloakのよい勉強にはなったと思います。
一方でJakarta SecurityをJAX-RSと組み合わせるのがうまくいかなかったりしたので、そのあたりはまたの機会に見ていきたいですね。
それからJakarta Security以外にもpac4jも見た方がいいのかな?という気分になりました。