CLOVER🍀

That was when it all began.

Spring SecurityのOAuth 2.0サポートで、Keycloak 19.0のRealmロールを使った認可を試す

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

前に、Spring SecurityでKeycloakのクライアントスコープを使った認可を試してみました。

Spring SecurityのOAuth 2.0サポートで、Keycloak 19.0のクライアントスコープを使った認可を試す - CLOVER🍀

今度は、Realmロールを使った認可を試してみたいと思います。

Realmロール

Realmロールを使用することで、ユーザーに対する権限の割り当てを表現することができます。

Server Administration Guide / Assigning permissions using roles and groups

Realmロールを作成して

Server Administration Guide / Assigning permissions using roles and groups / Creating a realm role

ユーザーに割り当てて使用します。

Server Administration Guide / Assigning permissions using roles and groups / Assigning role mappings

そして、クライアントスコープの設定でアクセストークンやIDトークンのクレーム、User Infoエンドポイントのレスポンスに含めることもできます。

You can use most OIDC mappers to control where the claim gets placed. You opt to include or exclude the claim from the id and access tokens by adjusting the Add to ID token and Add to access token switches.

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

今回は、こちらを使ってRealmロールをIDトークンに含め、アプリケーションでの認可に使いたいと思います。

なお、RealmロールをIDトークンやアクセストークン、およびUser Infoエンドポイントのレスポンスに含めるかどうかの制御は、
rolesクライアントスコープのrealm rolesマッパーで行います。
※使用したKeycloak 19.0.1の問題なのかはわかりませんが、ハマりどころがありました

ところで今回は扱いませんが、クライアントにもロールを割り当てられるようです。

Server Administration Guide / Assigning permissions using roles and groups / Client roles

それでは、進めていきましょう。

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.4 2022-07-19
OpenJDK Runtime Environment (build 17.0.4+8-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.4+8-Ubuntu-120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.6 (84538c9988a25aec085021c365c560670ad80f63)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.4, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-125-generic", arch: "amd64", family: "unix"

Keycloakは19.0.1を使い、172.17.0.2で動作しているものとします。

$ bin/kc.sh --version
Keycloak 19.0.1
JVM: 17.0.4 (Eclipse Adoptium OpenJDK 64-Bit Server VM 17.0.4+8)
OS: Linux 5.4.0-125-generic amd64

Keycloakの管理ユーザーは、起動時に作成。

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

お題

KeycloakのRealm内に、以下の2種類のユーザーとRealmロールの割り当てを行います。

  • token-role-usertokenというRealmロールを割り当てる
  • message-role-usermessageというRealmロールを割り当てる

名前は適当ですが、この名前に応じた認可制御を行います。

RealmロールはIDトークンに含め、アプリケーション側で利用する形にします。

Keycloakの準備

まずは、Keycloakの準備をしておきます。

以下の情報、手順で作成していきます。

  • Realm
    • Realmの名前をsample-realmとして作成

特に、デフォルト設定から変更するところがわかるようにしたいと思います。

  • クライアント(Client)
    • 作成時
    • 作成後
      • Settingsタブ
        • Root URLをhttp://localhost:8080に設定
        • Valid redirect URIsにhttp://localhost:8080/login/oauth2/code/keycloakを設定
          • ※アプリケーション側のspring.security.oauth2.client.registration.keycloak.redirect-uriに指定する値と同等
        • 保存

また、CredentialsタブのClient secretの値を控えておきます。

  • ユーザー(User)
    • token-role-userの作成
      • 作成時
        • Usernameをtoken-role-userとして作成
      • 作成後
        • Credentialsタブ
          • パスワードを設定
          • TemporaryをOffに設定
          • 保存
    • message-role-userの作成
      • 作成時
        • Usernameをmessage-role-userとして作成
      • 作成後
        • Credentialsタブ
          • パスワードを設定
          • TemporaryをOffに設定
          • 保存

Realmロールは次で作成、ユーザーへの割り当てを行います。

Realmロールの作成とユーザーへの割り当て

では、Realmロールを作成します。

今回は、以下の2つのRealmロールを作成します。

  • token
  • message

メニューの「Realm roles」を選択して

「Create role」を選択して、Realmロールを作成します。

入力したのは、今回はNameのみです。

作成したRealmロールは、token-role-userユーザーに割り当てます。ユーザーを選択して、「Role mapping」タブを選択。

付与するRealmロールを選択して、「Assign」。

messageロールも、同様にmessage-role-userユーザーに割り当てます。

ここで、クライアントスコープを確認します。「Client scopes」から

rolesスコープの「Mappers」タブを選択します。

「Token Claim Name」を見るとrealm_access.rolesと書かれています。

「Token Claim Name」はトークン内のクレームにどのような名前で含めるかを示しています。.はネストしたオブジェクトを指すので、

realm_access内にrolesがある、ということになりますね。

ここで「Add to ID token」を1度「Off」にして「Save」、そしてもう1度「On」にして「Save」と繰り返します。
※「Add to userinfo」でもOKみたいです

Keycloak 19.0.1の問題なのかもしれませんが、なぜこれが必要は後で書きます。

これで、Keycloak側の準備は完了です。

Spring Bootプロジェクトを作成する

では、アプリケーションを作成していきます。依存関係にweboauth2-clientを指定して、プロジェクトを作成。

$ curl -s https://start.spring.io/starter.tgz \
  -d bootVersion=2.7.3 \
  -d javaVersion=17 \
  -d name=spring-security-oauth2-client-realm-role-authz-example \
  -d groupId=org.littlewings \
  -d artifactId=spring-security-oauth2-client-realm-role-authz-example \
  -d version=0.0.1-SNAPSHOT \
  -d packageName=org.littlewings.keycloak.spring \
  -d dependencies=web,oauth2-client \
  -d baseDir=spring-security-oauth2-client-realm-role-authz-example | tar zxvf -

プロジェクト内に移動。

$ cd spring-security-oauth2-client-realm-role-authz-example

Mavenプラグインや依存関係など。

        <properties>
                <java.version>17</java.version>
        </properties>
        <dependencies>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-oauth2-client</artifactId>
                </dependency>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-web</artifactId>
                </dependency>

                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-test</artifactId>
                        <scope>test</scope>
                </dependency>
        </dependencies>

        <build>
                <plugins>
                        <plugin>
                                <groupId>org.springframework.boot</groupId>
                                <artifactId>spring-boot-maven-plugin</artifactId>
                        </plugin>
                </plugins>
        </build>

自動生成されたソースコードは、削除しておきます。

$ rm src/main/java/org/littlewings/keycloak/spring/SpringSecurityOauth2ClientRealmRoleAuthzExampleApplication.java src/test/java/org/littlewings/keycloak/spring/SpringSecurityOauth2ClientRealmRoleAuthzExampleApplicationTests.java

ソースコードを作成しましょう。

Spring SecurityのOAuth 2.0に関する設定。こちらは、また後で説明していきます。

src/main/java/org/littlewings/keycloak/spring/OAuth2ClientSecurityConfig.java

package org.littlewings.keycloak.spring;

import java.util.HashSet;
import java.util.Map;
import java.util.Set;

import com.nimbusds.jose.shaded.json.JSONArray;
import com.nimbusds.jose.shaded.json.JSONObject;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
import org.springframework.security.oauth2.core.user.OAuth2UserAuthority;
import org.springframework.security.web.SecurityFilterChain;

@EnableWebSecurity
public class OAuth2ClientSecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize -> authorize
                        .mvcMatchers("/auth/**").authenticated()
                        .mvcMatchers("/role/message").hasRole("message")
                        .mvcMatchers("/role/token").hasRole("token")
                        .anyRequest().permitAll())
                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfo -> userInfo
                                .oidcUserService(oidcUserService())))
                                //.userAuthoritiesMapper(userAuthoritiesMapper())))
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/"));

        return http.build();
    }

    OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
        OidcUserService delegate = new OidcUserService();

        return userRequest -> {
            OidcUser oidcUser = delegate.loadUser(userRequest);

            System.out.println("access_token value = " + userRequest.getAccessToken().getTokenValue());
            System.out.println("id_token value = " + oidcUser.getIdToken().getTokenValue());
            System.out.println("claims = " + oidcUser.getClaims());
            System.out.println("id_token claims = " + oidcUser.getIdToken().getClaims());
            System.out.println("userInfo claims = " + oidcUser.getUserInfo().getClaims());

            Set<GrantedAuthority> mappedAuthorities = new HashSet<>(oidcUser.getAuthorities());

            Map<String, Object> claims = oidcUser.getIdToken().getClaims();
            // Map<String, Object> claims = oidcUser.getClaims();
            JSONObject realmAccess = (JSONObject) claims.get("realm_access");
            JSONArray roles = (JSONArray) realmAccess.get("roles");
            roles.forEach(role -> {
                String roleName = (String) role;
                mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + roleName));
            });

            System.out.println("authorities = " + mappedAuthorities);

            return new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo());
        };
    }

    GrantedAuthoritiesMapper userAuthoritiesMapper() {
        return authorities -> {
            System.out.println("authorities = " + authorities);

            Set<GrantedAuthority> mappedAuthorities = new HashSet<>(authorities);

            authorities.forEach(authority -> {
                if (authority instanceof OidcUserAuthority) {
                    System.out.println("oidc user authority = " + authority);

                    OidcUserAuthority oidcUserAuthority = (OidcUserAuthority) authority;

                    OidcIdToken idToken = oidcUserAuthority.getIdToken();
                    Map<String, Object> claims = idToken.getClaims();

                    System.out.println("oidc user authority claims = " + claims);

                    JSONObject realmAccess = (JSONObject) claims.get("realm_access");
                    JSONArray roles = (JSONArray) realmAccess.get("roles");
                    roles.forEach(role -> {
                        String roleName = (String) role;
                        mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + roleName));
                    });
                }
                /*
                else if (authority instanceof OAuth2UserAuthority) {
                    OAuth2UserAuthority oauth2UserAuthority = (OAuth2UserAuthority) authority;

                    Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();

                    // ...
                }
                */
            });

            System.out.println("authorities = " + mappedAuthorities);

            return mappedAuthorities;
        };
    }
}

先に、パスの認証・認可設定だけ取り上げておきましょう。

/auth配下は認証必須、/role/messagemessageROLE_message)を権限として要求し、/role/tokenは同じくtokenROLE_token
ロールを要求します。それ以外のパスは、認証なしでアクセスできるようにしています。

        http
                .authorizeHttpRequests(authorize -> authorize
                        .mvcMatchers("/auth/**").authenticated()
                        .mvcMatchers("/role/message").hasRole("message")
                        .mvcMatchers("/role/token").hasRole("token")
                        .anyRequest().permitAll())

RestController。

src/main/java/org/littlewings/keycloak/spring/RoleAuthzController.java

package org.littlewings.keycloak.spring;

import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class RoleAuthzController {
    @GetMapping
    public String index() {
        return "Hello, Spring Security Application!!";
    }

    @GetMapping("token")
    public Object token(OAuth2AuthenticationToken authenticationToken) {
        return authenticationToken != null ? authenticationToken : "not login";
    }

    @GetMapping("auth/message")
    public String authenticatedMessage() {
        return "Authenticated!!";
    }

    @GetMapping("role/message")
    public String authorizedMessage() {
        return "Authorized!!";
    }

    @GetMapping("role/token")
    public OAuth2AuthenticationToken authorizedToken(OAuth2AuthenticationToken authenticationToken) {
        return authenticationToken;
    }
}

以下の3つのメソッドは、それぞれ認証必須、messageロール、tokenロールを要求するメソッドです。

    @GetMapping("auth/message")
    public String authenticatedMessage() {
        return "Authenticated!!";
    }

    @GetMapping("role/message")
    public String authorizedMessage() {
        return "Authorized!!";
    }

    @GetMapping("role/token")
    public OAuth2AuthenticationToken authorizedToken(OAuth2AuthenticationToken authenticationToken) {
        return authenticationToken;
    }

それ以外は、ログインしていなくてもアクセス可能です。

OAuth2AuthenticationTokenクラスは、Spring SecurityのOAuth 2.0サポートにおける認証情報です。

mainクラス。

src/main/java/org/littlewings/keycloak/spring/App.java

package org.littlewings.keycloak.spring;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }
}

設定。

src/main/resources/application.properties

spring.security.oauth2.client.registration.keycloak.client-id=spring-security-client
spring.security.oauth2.client.registration.keycloak.client-secret=B3lqawajjRxyZxy13dWPEGAPCCqiRRTI
spring.security.oauth2.client.registration.keycloak.client-name=spring security oauth2 authorization example
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.scope=openid
spring.security.oauth2.client.registration.keycloak.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}

spring.security.oauth2.client.provider.keycloak.authorization-uri=http://172.17.0.2:8080/realms/sample-realm/protocol/openid-connect/auth
spring.security.oauth2.client.provider.keycloak.token-uri=http://172.17.0.2:8080/realms/sample-realm/protocol/openid-connect/token
spring.security.oauth2.client.provider.keycloak.user-info-uri=http://172.17.0.2:8080/realms/sample-realm/protocol/openid-connect/userinfo
spring.security.oauth2.client.provider.keycloak.jwk-set-uri=http://172.17.0.2:8080/realms/sample-realm/protocol/openid-connect/certs
spring.security.oauth2.client.provider.keycloak.user-name-attribute=preferred_username

spring.jackson.serialization.indent_output=true
#logging.level.org.springframework.security=DEBUG

Spring SecurityのOAuth 2.0を使ったクラスの掘り下げ

OAuth2ClientSecurityConfigというクラスに認証・認可設定をまとめましたが、こちらに関する内容をもうちょっと掘り下げましょう。

片方コメントアウトされていますが、oauth2LoginuserInfoEndpointで、OAuth2UserServiceまたはGrantedAuthoritiesMapper
設定しています。

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize -> authorize
                        .mvcMatchers("/auth/**").authenticated()
                        .mvcMatchers("/role/message").hasRole("message")
                        .mvcMatchers("/role/token").hasRole("token")
                        .anyRequest().permitAll())
                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfo -> userInfo
                                .oidcUserService(oidcUserService())))
                                //.userAuthoritiesMapper(userAuthoritiesMapper())))
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/"));

        return http.build();
    }

これは、以下に書かれている内容を参考に作成したもので、OAuth 2.0のシーケンス完了時にユーザーの権限をマッピングのカスタマイズを
行うものです。

Advanced Configuration / UserInfo Endpoint / Mapping User Authorities

方法としては、2つ記載があります。

記載したソースコードは両方のパターンを書いており、うち片方はコメントアウトしています。

どちらのパターンも、realm_access.rolesGrantedAuthorityマッピングするものになります。

まずはOAuth2UserServiceの委譲パターンから。

Advanced Configuration / UserInfo Endpoint / Mapping User Authorities / Delegation-based strategy with OAuth2UserService

コメントアウト箇所と、System.out.printlnを削除するとこんな感じになります。

@EnableWebSecurity
public class OAuth2ClientSecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize -> authorize
                        .mvcMatchers("/auth/**").authenticated()
                        .mvcMatchers("/role/message").hasRole("message")
                        .mvcMatchers("/role/token").hasRole("token")
                        .anyRequest().permitAll())
                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfo -> userInfo
                                .oidcUserService(oidcUserService())))
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/"));

        return http.build();
    }

    OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
        OidcUserService delegate = new OidcUserService();

        return userRequest -> {
            OidcUser oidcUser = delegate.loadUser(userRequest);

            Set<GrantedAuthority> mappedAuthorities = new HashSet<>(oidcUser.getAuthorities());

            Map<String, Object> claims = oidcUser.getIdToken().getClaims();
            // Map<String, Object> claims = oidcUser.getClaims();
            JSONObject realmAccess = (JSONObject) claims.get("realm_access");
            JSONArray roles = (JSONArray) realmAccess.get("roles");
            roles.forEach(role -> {
                String roleName = (String) role;
                mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + roleName));
            });

            return new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo());
        };
    }
}

ポイントはこちらと

                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfo -> userInfo
                                .oidcUserService(oidcUserService())))

IDトークンまたはOidcUser#getClaimsの結果からrealm_access.rolesに含まれる値を取得し、GrantedAuthorityに変換しているところですね。

            Set<GrantedAuthority> mappedAuthorities = new HashSet<>(oidcUser.getAuthorities());

            Map<String, Object> claims = oidcUser.getIdToken().getClaims();
            // Map<String, Object> claims = oidcUser.getClaims();
            JSONObject realmAccess = (JSONObject) claims.get("realm_access");
            JSONArray roles = (JSONArray) realmAccess.get("roles");
            roles.forEach(role -> {
                String roleName = (String) role;
                mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + roleName));
            });

            return new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo());

認証後に保持しているもともとのGrantedAuthorityはクライアントスコープのものになるのですが、

            Set<GrantedAuthority> mappedAuthorities = new HashSet<>(oidcUser.getAuthorities());

今回はクライアントスコープとRealmロールの内容の両方を権限として扱うようにしました。

SimpleGrantedAuthorityを使ったロールへのマッピングの際には、ROLE_接頭辞を付与しています。

                mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + roleName));

ところでコメントアウトしていますが、IDトークンのクレームかOidcUser#getClaimsの違いについて。後者はuserInfo+IDトークンの
クレームを含めたものになっています。

https://github.com/spring-projects/spring-security/blob/5.7.3/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/DefaultOidcUser.java#L93

https://github.com/spring-projects/spring-security/blob/5.7.3/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/oidc/user/OidcUserAuthority.java#L117-L125

今回の内容だと、どちらを使っても良いと言えます。

ただOAuth 2.0のクライアントとして扱う場合は、アクセストークンを使ってrealm_access.rolesにアクセスするのはかなり面倒な感じが
したので、この点には注意ですね(今回、それなりに時間がかかったのですが、この方法を模索したいのが理由のひとつです)。

次に、こちら。

Advanced Configuration / UserInfo Endpoint / Mapping User Authorities / Using a GrantedAuthoritiesMapper

@EnableWebSecurity
public class OAuth2ClientSecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize -> authorize
                        .mvcMatchers("/auth/**").authenticated()
                        .mvcMatchers("/role/message").hasRole("message")
                        .mvcMatchers("/role/token").hasRole("token")
                        .anyRequest().permitAll())
                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfo -> userInfo
                                .userAuthoritiesMapper(userAuthoritiesMapper())))
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/"));

        return http.build();
    }

    GrantedAuthoritiesMapper userAuthoritiesMapper() {
        return authorities -> {
            Set<GrantedAuthority> mappedAuthorities = new HashSet<>(authorities);

            authorities.forEach(authority -> {
                if (authority instanceof OidcUserAuthority) {
                    OidcUserAuthority oidcUserAuthority = (OidcUserAuthority) authority;

                    OidcIdToken idToken = oidcUserAuthority.getIdToken();
                    Map<String, Object> claims = idToken.getClaims();

                    JSONObject realmAccess = (JSONObject) claims.get("realm_access");
                    JSONArray roles = (JSONArray) realmAccess.get("roles");
                    roles.forEach(role -> {
                        String roleName = (String) role;
                        mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + roleName));
                    });
                }
                /*
                else if (authority instanceof OAuth2UserAuthority) {
                    OAuth2UserAuthority oauth2UserAuthority = (OAuth2UserAuthority) authority;

                    Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();

                    // ...
                }
                */
            });

            return mappedAuthorities;
        };
    }
}

こちらが変わり

                .oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfo -> userInfo
                                .userAuthoritiesMapper(userAuthoritiesMapper())))

権限の読み替えは、こういう処理になりました。

    GrantedAuthoritiesMapper userAuthoritiesMapper() {
        return authorities -> {
            Set<GrantedAuthority> mappedAuthorities = new HashSet<>(authorities);

            authorities.forEach(authority -> {
                if (authority instanceof OidcUserAuthority) {
                    OidcUserAuthority oidcUserAuthority = (OidcUserAuthority) authority;

                    OidcIdToken idToken = oidcUserAuthority.getIdToken();
                    Map<String, Object> claims = idToken.getClaims();

                    JSONObject realmAccess = (JSONObject) claims.get("realm_access");
                    JSONArray roles = (JSONArray) realmAccess.get("roles");
                    roles.forEach(role -> {
                        String roleName = (String) role;
                        mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + roleName));
                    });
                }
                /*
                else if (authority instanceof OAuth2UserAuthority) {
                    OAuth2UserAuthority oauth2UserAuthority = (OAuth2UserAuthority) authority;

                    Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();

                    // ...
                }
                */
            });

            return mappedAuthorities;
        };
    }

結局のところ、realm_access.rolesクレームを読み取ってGrantedAuthorityに変換するという点では先ほどのパターンと同じですね。

なお、ドキュメントではOAuth2UserAuthorityに関する分岐もあるのですが、今回はOidcUserAuthorityの方だけを残しています。
今回の構成ではOidcUserAuthorityのみが使われ、OAuth2UserAuthorityは使われないからです(動作確認ができないので)。

OidcUserAuthorityインスタンスを作成している箇所は、こちら。

https://github.com/spring-projects/spring-security/blob/5.7.3/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcUserService.java#L131

https://github.com/spring-projects/spring-security/blob/5.7.3/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultOAuth2UserService.java#L111

2パターン書きましたが、今回のテーマにおける認可に関する動作はどちらも同じ挙動になるので、結果記載は1パターンのみとします。

動作確認してみる

それでは、動作確認をしていきましょう。

適用するのは、こちらの処理とします。

    OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
        OidcUserService delegate = new OidcUserService();

        return userRequest -> {
            OidcUser oidcUser = delegate.loadUser(userRequest);

            System.out.println("access_token value = " + userRequest.getAccessToken().getTokenValue());
            System.out.println("id_token value = " + oidcUser.getIdToken().getTokenValue());
            System.out.println("claims = " + oidcUser.getClaims());
            System.out.println("id_token claims = " + oidcUser.getIdToken().getClaims());
            System.out.println("userInfo claims = " + oidcUser.getUserInfo().getClaims());

            Set<GrantedAuthority> mappedAuthorities = new HashSet<>(oidcUser.getAuthorities());

            Map<String, Object> claims = oidcUser.getIdToken().getClaims();
            // Map<String, Object> claims = oidcUser.getClaims();
            JSONObject realmAccess = (JSONObject) claims.get("realm_access");
            JSONArray roles = (JSONArray) realmAccess.get("roles");
            roles.forEach(role -> {
                String roleName = (String) role;
                mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + roleName));
            });

            System.out.println("authorities = " + mappedAuthorities);

            return new DefaultOidcUser(mappedAuthorities, oidcUser.getIdToken(), oidcUser.getUserInfo());
        };
    }

アプリケーションを起動。

$ mvn spring-boot:run

まずはログインしないまま、http://localhost:8080/にアクセスしてみます。

http://localhost:8080/tokenにアクセス。

ログインを必要とするhttp://localhost:8080/auth/messageにアクセスしてみます。
すると、Keycloakのログインページにリダイレクトされるので、先ほど作成したユーザー名とパスワード入力してログイン。

最初はtoken-role-userを使うことにします。

ログイン後、リダイレクトしてhttp://localhost:8080/auth/messageにアクセスできました。

System.out.printlnで出力していた、トークンやクレームの情報。

access_token value = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI3T0Fqa3Vxekw0THlqNUJUR0JVa2xlQnFuYVd5SHRWTWs4cXlKbDhfcWM4In0.eyJleHAiOjE2NjI1NjgyODcsImlhdCI6MTY2MjU2Nzk4NywiYXV0aF90aW1lIjoxNjYyNTY3ODQ2LCJqdGkiOiJjZTAyYTRiMi1kYmY5LTQ5M2MtOGIwYS1jNDFlNzQzY2RkYmQiLCJpc3MiOiJodHRwOi8vMTcyLjE3LjAuMjo4MDgwL3JlYWxtcy9zYW1wbGUtcmVhbG0iLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiMDkxMDdiZDItOWQ5Ny00OTcwLWE2Y2ItZGViNzhjYzU2MzE0IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoic3ByaW5nLXNlY3VyaXR5LWNsaWVudCIsIm5vbmNlIjoia3RFazV2YXFsa0hCdFhYTDhoMVhRVG92bC1BMzZMUXpxd040WF9QTzhQbyIsInNlc3Npb25fc3RhdGUiOiJiNzEwZWZhMy0xNGJiLTQ0ZGItYjhjOS1jOTBiNDBlY2EwZTgiLCJhY3IiOiIwIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtc2FtcGxlLXJlYWxtIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsInRva2VuIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsInNpZCI6ImI3MTBlZmEzLTE0YmItNDRkYi1iOGM5LWM5MGI0MGVjYTBlOCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoidG9rZW4tcm9sZS11c2VyIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIn0.VlAgieBkufKAwNhUXNOj1myP76Cp1bnZDusn4VbR0DWDbXOWegWsxc1m73mtCwAEZ81jaVD5rCXV6DfjdIYh7kw0dbyJSmoDxb-69To7Ur7804OMoQQU0OudCvTV9sEphe9M8MHOyzV2Hhs1097DbazNmFE9rUD3ZZ9gQIwfupXy0FQk5pmszkKrnZIUL8RhnU71rxy4YMp2aNR5IlSy2lB4jMdSgqaK9Iw93cH-qlb9rP3uV_fy6jT0mra9BbmjKDgU2n6zjei8QLBmVgYhryskzt1BIUYwDH31NmQzVFpH1JHEMkRW9U5gwE6CmoKpZPMFJavmvJGJwMNQujCyig


id_token value = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI3T0Fqa3Vxekw0THlqNUJUR0JVa2xlQnFuYVd5SHRWTWs4cXlKbDhfcWM4In0.eyJleHAiOjE2NjI1NjgyODcsImlhdCI6MTY2MjU2Nzk4NywiYXV0aF90aW1lIjoxNjYyNTY3ODQ2LCJqdGkiOiJiZmRiY2RmNC00OTlmLTQyM2EtOTlhNC03MmFkYjY2MGE3NzIiLCJpc3MiOiJodHRwOi8vMTcyLjE3LjAuMjo4MDgwL3JlYWxtcy9zYW1wbGUtcmVhbG0iLCJhdWQiOiJzcHJpbmctc2VjdXJpdHktY2xpZW50Iiwic3ViIjoiMDkxMDdiZDItOWQ5Ny00OTcwLWE2Y2ItZGViNzhjYzU2MzE0IiwidHlwIjoiSUQiLCJhenAiOiJzcHJpbmctc2VjdXJpdHktY2xpZW50Iiwibm9uY2UiOiJrdEVrNXZhcWxrSEJ0WFhMOGgxWFFUb3ZsLUEzNkxRenF3TjRYX1BPOFBvIiwic2Vzc2lvbl9zdGF0ZSI6ImI3MTBlZmEzLTE0YmItNDRkYi1iOGM5LWM5MGI0MGVjYTBlOCIsImF0X2hhc2giOiJKRDRaWjZ4YzY3WlNQSWRRRHZLd3lnIiwiYWNyIjoiMCIsInNpZCI6ImI3MTBlZmEzLTE0YmItNDRkYi1iOGM5LWM5MGI0MGVjYTBlOCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtc2FtcGxlLXJlYWxtIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsInRva2VuIl19LCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ0b2tlbi1yb2xlLXVzZXIiLCJnaXZlbl9uYW1lIjoiIiwiZmFtaWx5X25hbWUiOiIifQ.cSUJFkTmqQXo0uJaogFmW8hkZQl4XzFgBBS5a3VH6kFm9VTuUKSLHIQUSZtK4i8D_aPuX_MMxLtkm5f38Mhr55sZmlbsxMZfif5hHvPMs2Vd-rUMWRSe5h-JAiKPMYpQf8KsUcfb-C3sBfUUCt9TPQUAjYUG6LOTF2XMPAI64K3LTZxw-BoieQuv1rqL-AAOzxIRrfC17E0a22NI8tmZdsI4uNGv9ueAKwQDyIXIX7Mwy5PWYhcIgnTRmy8X5hMKtxiehJiHDH8z7o6kfvEQY97y9OGE9UNvN0pQtfLjvTqHkzkjibYe9BscYEdTXIqRsUJxlV5JqALB4-vuxMbEKg


claims = {at_hash=JD4ZZ6xc67ZSPIdQDvKwyg, sub=09107bd2-9d97-4970-a6cb-deb78cc56314, email_verified=false, iss=http://172.17.0.2:8080/realms/sample-realm, typ=ID, preferred_username=token-role-user, given_name=, nonce=ktEk5vaqlkHBtXXL8h1XQTovl-A36LQzqwN4X_PO8Po, sid=b710efa3-14bb-44db-b8c9-c90b40eca0e8, aud=[spring-security-client], acr=0, realm_access={"roles":["default-roles-sample-realm","offline_access","uma_authorization","token"]}, azp=spring-security-client, auth_time=2022-09-07T16:24:06Z, exp=2022-09-07T16:31:27Z, session_state=b710efa3-14bb-44db-b8c9-c90b40eca0e8, family_name=, iat=2022-09-07T16:26:27Z, jti=bfdbcdf4-499f-423a-99a4-72adb660a772}


id_token claims = {at_hash=JD4ZZ6xc67ZSPIdQDvKwyg, sub=09107bd2-9d97-4970-a6cb-deb78cc56314, email_verified=false, iss=http://172.17.0.2:8080/realms/sample-realm, typ=ID, preferred_username=token-role-user, given_name=, nonce=ktEk5vaqlkHBtXXL8h1XQTovl-A36LQzqwN4X_PO8Po, sid=b710efa3-14bb-44db-b8c9-c90b40eca0e8, aud=[spring-security-client], acr=0, realm_access={"roles":["default-roles-sample-realm","offline_access","uma_authorization","token"]}, azp=spring-security-client, auth_time=2022-09-07T16:24:06Z, exp=2022-09-07T16:31:27Z, session_state=b710efa3-14bb-44db-b8c9-c90b40eca0e8, iat=2022-09-07T16:26:27Z, family_name=, jti=bfdbcdf4-499f-423a-99a4-72adb660a772}


userInfo claims = {sub=09107bd2-9d97-4970-a6cb-deb78cc56314, email_verified=false, realm_access={roles=[default-roles-sample-realm, offline_access, uma_authorization, token]}, preferred_username=token-role-user, given_name=, family_name=}

IDトークンのJWTをデコードすると、こんな感じになっています。

{
  "exp": 1662568287,
  "iat": 1662567987,
  "auth_time": 1662567846,
  "jti": "bfdbcdf4-499f-423a-99a4-72adb660a772",
  "iss": "http://172.17.0.2:8080/realms/sample-realm",
  "aud": "spring-security-client",
  "sub": "09107bd2-9d97-4970-a6cb-deb78cc56314",
  "typ": "ID",
  "azp": "spring-security-client",
  "nonce": "ktEk5vaqlkHBtXXL8h1XQTovl-A36LQzqwN4X_PO8Po",
  "session_state": "b710efa3-14bb-44db-b8c9-c90b40eca0e8",
  "at_hash": "JD4ZZ6xc67ZSPIdQDvKwyg",
  "acr": "0",
  "sid": "b710efa3-14bb-44db-b8c9-c90b40eca0e8",
  "email_verified": false,
  "realm_access": {
    "roles": [
      "default-roles-sample-realm",
      "offline_access",
      "uma_authorization",
      "token"
    ]
  },
  "preferred_username": "token-role-user",
  "given_name": "",
  "family_name": ""
}

付与したロールが含まれていることが確認できます。

そして、Spring Securityとして認識する権限はこのようになりました。

authorities = [ROLE_offline_access, SCOPE_openid, SCOPE_email, ROLE_uma_authorization, ROLE_default-roles-sample-realm, SCOPE_profile, ROLE_token, ROLE_USER]

次に、認可が必要なページにアクセスしてみます。

今のユーザーはtokenロールを保持しているので、http://localhost:8080/role/tokenにはアクセスできます。

messageロールは保持していないのでhttp://localhost:8080/role/messageにはアクセスできず、403になります。

OKそうですね。次は1度ログアウトして、今度はmessage-role-userでアクセスしてみます。

message-role-userの場合はmessageロールを保持しているので、http://localhost:8080/role/messageにアクセスすることができます。

反対に、tokenロールを保持していないのでhttp://localhost:8080/role/tokenにはアクセスできなくなります。

OKそうですね。

message-role-userでのログイン時に出力される情報も、参考までに。

access_token value = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI3T0Fqa3Vxekw0THlqNUJUR0JVa2xlQnFuYVd5SHRWTWs4cXlKbDhfcWM4In0.eyJleHAiOjE2NjI1Njg2OTksImlhdCI6MTY2MjU2ODM5OSwiYXV0aF90aW1lIjoxNjYyNTY4Mzk5LCJqdGkiOiJlNjFjYTBjNC1hMzgwLTRjOTgtODkxOS1jY2IzZmRiMjNiYzUiLCJpc3MiOiJodHRwOi8vMTcyLjE3LjAuMjo4MDgwL3JlYWxtcy9zYW1wbGUtcmVhbG0iLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiZjc5ZjFkZjgtYTNkZC00MjIwLTgwOTYtOGQwZjI5MTBhNWQ0IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoic3ByaW5nLXNlY3VyaXR5LWNsaWVudCIsIm5vbmNlIjoibU9rU29jUkZtcjBuSHh2RFNDNWNMWVBkWW93M3duU1lxbURpX2tsdS1uUSIsInNlc3Npb25fc3RhdGUiOiI2YmY0MjhmOC1lYzNjLTRlNzEtYjZlYy00ZDg4MDM5MGNjNDIiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtc2FtcGxlLXJlYWxtIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsIm1lc3NhZ2UiXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIiwic2lkIjoiNmJmNDI4ZjgtZWMzYy00ZTcxLWI2ZWMtNGQ4ODAzOTBjYzQyIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJtZXNzYWdlLXJvbGUtdXNlciIsImdpdmVuX25hbWUiOiIiLCJmYW1pbHlfbmFtZSI6IiJ9.dElZjCV7BUZIgHgmRYwuqQpHwDr9GfWGlktfUpwavahjJN-vJkLtn0srX8JH8prSUSn-LxF0EbpPo6tCsK5kH53t0Y6K1-8bGUl0P6MYFU1q11Y8I5-hQ8JGfKSsiZQhmos16iJ700555LIi2vGO_8aNyF8aVem75teMiU7-e1e7ZSnPr5fBVa_hr0Ny3AfaJvjBIAIUvkMTTpR4pksZv5224OhmUTXDYPU0zMTN1sMMx34LKg13Bg5vJ8MkQp9VoyUx9C4SdhvD3uL5T2od_J-JgwfrrYPNLZyiBxrt-Tm27NjOmo9fhIzWt3j-xrd3nYfgpD2KjpS185eMz56_5A


id_token value = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI3T0Fqa3Vxekw0THlqNUJUR0JVa2xlQnFuYVd5SHRWTWs4cXlKbDhfcWM4In0.eyJleHAiOjE2NjI1Njg2OTksImlhdCI6MTY2MjU2ODM5OSwiYXV0aF90aW1lIjoxNjYyNTY4Mzk5LCJqdGkiOiJjZjg5ZGM1NS0zZmRlLTQ2MjAtYmJjZC0xN2I5MmNjZjcyNzMiLCJpc3MiOiJodHRwOi8vMTcyLjE3LjAuMjo4MDgwL3JlYWxtcy9zYW1wbGUtcmVhbG0iLCJhdWQiOiJzcHJpbmctc2VjdXJpdHktY2xpZW50Iiwic3ViIjoiZjc5ZjFkZjgtYTNkZC00MjIwLTgwOTYtOGQwZjI5MTBhNWQ0IiwidHlwIjoiSUQiLCJhenAiOiJzcHJpbmctc2VjdXJpdHktY2xpZW50Iiwibm9uY2UiOiJtT2tTb2NSRm1yMG5IeHZEU0M1Y0xZUGRZb3czd25TWXFtRGlfa2x1LW5RIiwic2Vzc2lvbl9zdGF0ZSI6IjZiZjQyOGY4LWVjM2MtNGU3MS1iNmVjLTRkODgwMzkwY2M0MiIsImF0X2hhc2giOiJZc2doQXI2cS0xSUNFR0RpUTdZREpnIiwiYWNyIjoiMSIsInNpZCI6IjZiZjQyOGY4LWVjM2MtNGU3MS1iNmVjLTRkODgwMzkwY2M0MiIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtc2FtcGxlLXJlYWxtIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsIm1lc3NhZ2UiXX0sInByZWZlcnJlZF91c2VybmFtZSI6Im1lc3NhZ2Utcm9sZS11c2VyIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIn0.L4ZVVgoDGsI6FNzM6DIWSpfpJCQoUqzeXab8Gb-En4RziWIQFT1kzBw4GANPJSlITahz3zPWk6PyLi6e4SGEvsBbUcGugvayf_orn2puKVh412V4cMGQHwGZT91toYChzlI6b3sW25PrDqgsquz_0s_3nE1njfDmBTqh7XYY0eSnzg46BBJN_uXO2rWNZv_6aOEK4G6UnwXQeztgWk0-0NnESO9UWDMA_Cw-xzbzCHL3E-4Od48nh5M4bu4EcN7XsUAVvKz8u8LY1fUTh9hFaVAZ04mUFg4Cd7O_yEgXOxoB-HYgh6074Yu9SifQGk5hqHMNltFM832cv6LbyhNixg


claims = {at_hash=YsghAr6q-1ICEGDiQ7YDJg, sub=f79f1df8-a3dd-4220-8096-8d0f2910a5d4, email_verified=false, iss=http://172.17.0.2:8080/realms/sample-realm, typ=ID, preferred_username=message-role-user, given_name=, nonce=mOkSocRFmr0nHxvDSC5cLYPdYow3wnSYqmDi_klu-nQ, sid=6bf428f8-ec3c-4e71-b6ec-4d880390cc42, aud=[spring-security-client], acr=1, realm_access={"roles":["default-roles-sample-realm","offline_access","uma_authorization","message"]}, azp=spring-security-client, auth_time=2022-09-07T16:33:19Z, exp=2022-09-07T16:38:19Z, session_state=6bf428f8-ec3c-4e71-b6ec-4d880390cc42, family_name=, iat=2022-09-07T16:33:19Z, jti=cf89dc55-3fde-4620-bbcd-17b92ccf7273}


id_token claims = {at_hash=YsghAr6q-1ICEGDiQ7YDJg, sub=f79f1df8-a3dd-4220-8096-8d0f2910a5d4, email_verified=false, iss=http://172.17.0.2:8080/realms/sample-realm, typ=ID, preferred_username=message-role-user, given_name=, nonce=mOkSocRFmr0nHxvDSC5cLYPdYow3wnSYqmDi_klu-nQ, sid=6bf428f8-ec3c-4e71-b6ec-4d880390cc42, aud=[spring-security-client], acr=1, realm_access={"roles":["default-roles-sample-realm","offline_access","uma_authorization","message"]}, azp=spring-security-client, auth_time=2022-09-07T16:33:19Z, exp=2022-09-07T16:38:19Z, session_state=6bf428f8-ec3c-4e71-b6ec-4d880390cc42, iat=2022-09-07T16:33:19Z, family_name=, jti=cf89dc55-3fde-4620-bbcd-17b92ccf7273}


userInfo claims = {sub=f79f1df8-a3dd-4220-8096-8d0f2910a5d4, email_verified=false, realm_access={roles=[default-roles-sample-realm, offline_access, uma_authorization, message]}, preferred_username=message-role-user, given_name=, family_name=}


authorities = [ROLE_offline_access, SCOPE_openid, ROLE_USER, SCOPE_email, ROLE_uma_authorization, ROLE_default-roles-sample-realm, ROLE_message, SCOPE_profile]

これで、今回確認したかった内容は見ることができました。

オマケ

最後に、Keycloakの設定時に「Add to ID token」を1度「Off」にして「Save」、そしてもう1度「On」みたいなことをしていた理由を
書いておきます。

結局、やったことは(見た目は)初期状態に戻しただけなのですが。

デフォルトで、rolesクライアントスコープのrealm rolesの内容としては、以下の状態になっています。

  • クレームをIDトークンに含める
  • クレームをアクセストークンに含める
  • クレームをuserinfoに含める

なのですが、実際の動作はどうなるかというとアクセストークンにしかクレームが含まれない状態になります。

access_token value = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI3T0Fqa3Vxekw0THlqNUJUR0JVa2xlQnFuYVd5SHRWTWs4cXlKbDhfcWM4In0.eyJleHAiOjE2NjI1NjgxNDYsImlhdCI6MTY2MjU2Nzg0NiwiYXV0aF90aW1lIjoxNjYyNTY3ODQ2LCJqdGkiOiIzMTY3OWRlYy00MzViLTQ4ZmMtYjY3ZS1iMTJjYTQ4NjFmYWEiLCJpc3MiOiJodHRwOi8vMTcyLjE3LjAuMjo4MDgwL3JlYWxtcy9zYW1wbGUtcmVhbG0iLCJhdWQiOiJhY2NvdW50Iiwic3ViIjoiMDkxMDdiZDItOWQ5Ny00OTcwLWE2Y2ItZGViNzhjYzU2MzE0IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoic3ByaW5nLXNlY3VyaXR5LWNsaWVudCIsIm5vbmNlIjoiYzdMR2hlVTNaVnRIQ19ydUlweWdsRU8xTGdxYUlTM0xyYlJBRWVUdkREOCIsInNlc3Npb25fc3RhdGUiOiJiNzEwZWZhMy0xNGJiLTQ0ZGItYjhjOS1jOTBiNDBlY2EwZTgiLCJhY3IiOiIxIiwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImRlZmF1bHQtcm9sZXMtc2FtcGxlLXJlYWxtIiwib2ZmbGluZV9hY2Nlc3MiLCJ1bWFfYXV0aG9yaXphdGlvbiIsInRva2VuIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsInNpZCI6ImI3MTBlZmEzLTE0YmItNDRkYi1iOGM5LWM5MGI0MGVjYTBlOCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoidG9rZW4tcm9sZS11c2VyIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIn0.EZN2CCAJq_D8jAntWNIZMq_1biRGhLikiVYYlv2ChqFOLrYTu8A-N-u4Rl5bFV1Svt6_a4Pnevwql2Dtyz5SD5NE5Gf7LHWE13KYTjkI97lBXTNXcDC9XROsATFB3VEq76U6cqA1Bt79AbIzkH6q5RRHvlWE8IDdF2ashvfqjcJqFJ_fsjY0x7vbjex32ULQE3r6W7vYom8lhhSvIJgd1JeEtR4ev_8d3y9sP-9Jh4pNbktkxDo5q1nrNxG4rm4qKm9T1xknmx-8eZsE__jnu0scwkUGOesU6OPJWQSAiNgWdyWarAWwUgQ2qnobjWoyAbJUHVJgCBc9P3eMCYWvoA


id_token value = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI3T0Fqa3Vxekw0THlqNUJUR0JVa2xlQnFuYVd5SHRWTWs4cXlKbDhfcWM4In0.eyJleHAiOjE2NjI1NjgxNDYsImlhdCI6MTY2MjU2Nzg0NiwiYXV0aF90aW1lIjoxNjYyNTY3ODQ2LCJqdGkiOiJlNmE4NTFhZi0yYjIzLTRiMmMtODUzYi05ZGViN2FjYTg3ZjgiLCJpc3MiOiJodHRwOi8vMTcyLjE3LjAuMjo4MDgwL3JlYWxtcy9zYW1wbGUtcmVhbG0iLCJhdWQiOiJzcHJpbmctc2VjdXJpdHktY2xpZW50Iiwic3ViIjoiMDkxMDdiZDItOWQ5Ny00OTcwLWE2Y2ItZGViNzhjYzU2MzE0IiwidHlwIjoiSUQiLCJhenAiOiJzcHJpbmctc2VjdXJpdHktY2xpZW50Iiwibm9uY2UiOiJjN0xHaGVVM1pWdEhDX3J1SXB5Z2xFTzFMZ3FhSVMzTHJiUkFFZVR2REQ4Iiwic2Vzc2lvbl9zdGF0ZSI6ImI3MTBlZmEzLTE0YmItNDRkYi1iOGM5LWM5MGI0MGVjYTBlOCIsImF0X2hhc2giOiItdnhqd2pJZExnSm1nX3BLRHlEYjlnIiwiYWNyIjoiMSIsInNpZCI6ImI3MTBlZmEzLTE0YmItNDRkYi1iOGM5LWM5MGI0MGVjYTBlOCIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoidG9rZW4tcm9sZS11c2VyIiwiZ2l2ZW5fbmFtZSI6IiIsImZhbWlseV9uYW1lIjoiIn0.obijJwONjgsRchUkXzfS3UIBaV1VanXFb1CAD0RO917M_TiY0uyMSXOVwOWcKwotc7LnPHrXs00tXRqPFB6uWCTUWOwfhE4Iim2jTk-A1ziLOvqfhQ1p36ChKna0-XmJ1kI_x3pA63TKN9gOYmPtbIP_RCgS1EEYwg1xhthr342O117sgbmWQrQhMxLZK4Mlt_O4UItAp2nU1cILqpMbeKN4QWqHHbdPdcTWMGkBlBUmFA0sUQ9qGmQq9nizy2uUzZec-Y7Mnj7pSzoB7BsV7vCQ8hfcSvbH0UFP1KjGhgbofPPnWEEWpiZwZmftWWkCjSQMLlFEFb59j3DEhHvVgQ


claims = {at_hash=-vxjwjIdLgJmg_pKDyDb9g, sub=09107bd2-9d97-4970-a6cb-deb78cc56314, email_verified=false, iss=http://172.17.0.2:8080/realms/sample-realm, typ=ID, preferred_username=token-role-user, given_name=, nonce=c7LGheU3ZVtHC_ruIpyglEO1LgqaIS3LrbRAEeTvDD8, sid=b710efa3-14bb-44db-b8c9-c90b40eca0e8, aud=[spring-security-client], acr=1, azp=spring-security-client, auth_time=2022-09-07T16:24:06Z, exp=2022-09-07T16:29:06Z, session_state=b710efa3-14bb-44db-b8c9-c90b40eca0e8, family_name=, iat=2022-09-07T16:24:06Z, jti=e6a851af-2b23-4b2c-853b-9deb7aca87f8}


id_token claims = {at_hash=-vxjwjIdLgJmg_pKDyDb9g, sub=09107bd2-9d97-4970-a6cb-deb78cc56314, email_verified=false, iss=http://172.17.0.2:8080/realms/sample-realm, typ=ID, preferred_username=token-role-user, given_name=, nonce=c7LGheU3ZVtHC_ruIpyglEO1LgqaIS3LrbRAEeTvDD8, sid=b710efa3-14bb-44db-b8c9-c90b40eca0e8, aud=[spring-security-client], acr=1, azp=spring-security-client, auth_time=2022-09-07T16:24:06Z, exp=2022-09-07T16:29:06Z, session_state=b710efa3-14bb-44db-b8c9-c90b40eca0e8, iat=2022-09-07T16:24:06Z, family_name=, jti=e6a851af-2b23-4b2c-853b-9deb7aca87f8}


userInfo claims = {sub=09107bd2-9d97-4970-a6cb-deb78cc56314, email_verified=false, preferred_username=token-role-user, given_name=, family_name=}

よって、今回のソースコードではRealmロールがIDトークンに含まれていないことを想定していないのでエラーになります。

2022-09-08 01:24:07.054 ERROR 20716 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception

java.lang.NullPointerException: Cannot invoke "com.nimbusds.jose.shaded.json.JSONObject.get(Object)" because "realmAccess" is null
        at org.littlewings.keycloak.spring.OAuth2ClientSecurityConfig.lambda$oidcUserService$5(OAuth2ClientSecurityConfig.java:63) ~[classes/:na]
        at org.springframework.security.oauth2.client.oidc.authentication.OidcAuthorizationCodeAuthenticationProvider.authenticate(OidcAuthorizationCodeAuthenticationProvider.java:156) ~[spring-security-oauth2-client-5.7.3.jar:5.7.3]
        at org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:182) ~[spring-security-core-5.7.3.jar:5.7.3]
        at org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter.attemptAuthentication(OAuth2LoginAuthenticationFilter.java:195) ~[spring-security-oauth2-client-5.7.3.jar:5.7.3]
        at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:227) ~[spring-security-web-5.7.3.jar:5.7.3]

〜省略〜

結局デフォルト値に戻しているだけなのですが、なぜか「Off」、「On」と操作するとIDトークン等にもクレームが含まれるようになったので、
今回はこのようにしました。

どうしてでしょうね?

まとめ

Spring SecurityのOAuth 2.0サポートで、KeycloakのRealmロールを使った認可を試してみました。

クライアントスコープの方はすんなりいきましたが、こちらはかなりてこずりました。

困ったのは

  • Spring SecurityでIDトークンに含まれるRealmにアクセスする方法
  • Keycloakが返却するIDトークンに、Realmロールのクレームが含まれない

あたりですね。

最終的に、目的は達成できたのでよかったのですが。

Spring Statemachineのアクションを試す

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

以前に、Spring Statemachineをとりあえず動かしてみました。

Spring Statemachineを試してみる - CLOVER🍀

今回は、「アクション」というものを扱ってみたいと思います。

Spring Statemachineにおけるアクション

アクションがどういうものなのかは、Spring Statemachineの用語集を確認してみましょう。

「アクション」は、遷移の最中にトリガーされ、実行されるものみたいです。

Action

A action is a behavior run during the triggering of the transition.

Spring Statemachine / Appendices / Appendix B: State Machine Concepts / Glossary

クラッシュコースでのアクションの説明を見てみましょう。

アクションは、ステートマシンの状態変化をユーザーのコードに結びつけるものということになっています。ステートマシンは、ステートマシンに
おける様々な変化やステップ(ステートの開始、終了など)、状態遷移において、アクションを実行できます。

Actions really glue state machine state changes to a user’s own code. A state machine can run an action on various changes and on the steps in a state machine (such as entering or exiting a state) or doing a state transition.

Spring Statemachine / Appendices / Appendix B: State Machine Concepts / A State Machine Crash Course / Actions

また、アクションはステートのコンテキストにアクセスできるため、アクションを構成するコード内でステートマシンとやり取りすることが
できます。

Actions usually have access to a state context, which gives running code a choice to interact with a state machine in various ways. State context exposes a whole state machine so that a user can access extended state variables, event headers (if a transition is based on an event), or an actual transition (where it is possible to see more detailed about where this state change is coming from and where it is going).

というわけで、アクションとはステートマシンの状態変化などに合わせて、なにか処理を実行するもののようですね。

こう書くと、近いものとしてリスナーがあった気がします。リスナーは、ステートマシンの開始、終了なども含めて、ステートマシンに
起こったイベントに対して紐付けるものです。アクションよりも、もうちょっと範囲が広そうですね。

Spring Statemachine / Using Spring Statemachine / Listening to State Machine Events

一方で、こちらを見るとアクションに対してもリスナーと似たようなことが書かれていたりもしますが…。

You can run actions in various places in a state machine and its states lifecycle

Spring Statemachine / Using Spring Statemachine / Using Actions

この箇所では、ステートの開始、終了にSpringのBeanとして定義したアクションを紐付けている例になっています。

@Override
public void configure(StateMachineStateConfigurer<States, Events> states)
        throws Exception {
    states
        .withStates()
            .initial(States.SI)
            .state(States.S1, action1(), action2())
            .state(States.S2, action1(), action2())
            .state(States.S3, action1(), action3());
}

Bean定義。

@Bean
public Action<States, Events> action1() {
    return new Action<States, Events>() {

        @Override
        public void execute(StateContext<States, Events> context) {
        }
    };
}

@Bean
public BaseAction action2() {
    return new BaseAction();
}

@Bean
public SpelAction action3() {
    ExpressionParser parser = new SpelExpressionParser();
    return new SpelAction(
            parser.parseExpression(
                    "stateMachine.sendEvent(T(org.springframework.statemachine.docs.Events).E1)"));
}

public class BaseAction implements Action<States, Events> {

    @Override
    public void execute(StateContext<States, Events> context) {
    }
}

public class SpelAction extends SpelExpressionAction<States, Events> {

    public SpelAction(Expression expression) {
        super(expression);
    }
}

というわけで、SpringのBeanをアクションとして使えるようです。内容は、SpELでも書けるようですが。記述できることには、自由度が
ありそうですね。

アクションの構成に関する記述を見てみましょう。

Spring Statemachine / Using Spring Statemachine / Statemachine Configuration / Configuring Actions

アクションは、遷移(Transition)とステートに対して定義できると書かれています。

You can define actions to be executed with transitions and states.

こちらは、遷移に対する定義の例になっています。

   @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
            .withExternal()
                .source(States.S1)
                .target(States.S2)
                .event(Events.E1)
                .action(action());
    }

こちらはステートに対する定義の例。

   @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        states
            .withStates()
                .initial(States.S1, action())
                .state(States.S1, action(), null)
                .state(States.S2, null, action())
                .state(States.S2, action())
                .state(States.S3, action(), action());
    }

StateMachineTransitionConfigurerを使って定義するか、StateMachineStateConfigurerを使って定義するか、というところですね。
実際にアクションを定義するインターフェースはこちらのようですが。

ここまで見ていると、アクションというのは大きく以下の2つのものに分けられそうですね。

  • 遷移に対するアクション … ソースとなるステートから、ターゲットとなるステートへの遷移時に実行される
  • ステートに対するアクション … あるステートの開始、終了時に実行される

ドキュメントをもう少し追ってみましょう。

遷移に対するアクション

遷移に対してアクションを定義した場合は、状態変化をトリガーとして発生した遷移の結果として、常にアクションが実行されます。

An action is always run as a result of a transition that originates from a trigger.

遷移に紐付けたアクションでエラーが発生した場合は、actionメソッドの第2引数に渡したアクションでハンドリングできるようです。

Spring Statemachine / Using Spring Statemachine / Statemachine Configuration / Configuring ActionsTransition Action Error Handling

TransitionConfigurer (Spring State Machine 3.2.0 API)

こちらの例ですね。

   @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
            .withExternal()
                .source(States.S1)
                .target(States.S2)
                .event(Events.E1)
                .action(action(), errorAction());
    }

エラーハンドリング用のアクションには、発生した例外が含まれたStateContextが渡されるようです。

なお、Actions#errorCallingActionを使用することで、通常のアクションとエラー用のアクションを合成してひとつのアクションとして
設定することもできるようです。

@Override
public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
        throws Exception {
    transitions
        .withExternal()
            .source(States.S1)
            .target(States.S2)
            .event(Events.E1)
            .action(Actions.errorCallingAction(action(), errorAction()));
}

ステートに対するアクション

ステートに対するアクションは、こちらに記述があります。

アクションの定義は、こちらで行います。

StateConfigurer (Spring State Machine 3.2.0 API)

ステートに対するアクションは、ステートの開始と終了に関連付けられたアクションがあり、それぞれ異なる方法で実行されます。
これは、ステートの開始で実行され、特定のアクションが完了する前にステートが終了するとそのアクションをキャンセルする可能性が
あるからです。

State actions are run differently compared to entry and exit actions, because execution happens after state has been entered and can be cancelled if state exit happens before a particular action has been completed.

アクションはReactorのスケジューラーを使ってサブスクライブすることで実行されます。このため、スレッドの割り込みをハンドリングする
必要があることを意味しています。

State actions are executed using normal reactive flow by subscribing with a Reactor’s default parallel scheduler. This means that, whatever you do in your action, you need to be able to catch InterruptedException or, more generally, periodically check whether Thread is interrupted.

アクションに対する実行ポリシーも指定できるようです。状態が完了した時、またはタイムアウトした時にキャンセルするかどうか、ですね。

StateDoActionPolicy (Spring State Machine 3.2.0 API)

アクションのエラーハンドリングに対する記述は、こちら。

Spring Statemachine / Using Spring Statemachine / Statemachine Configuration / Configuring Actions / State Action Error Handling

こちらは、3種類のタイミングすべてにアクションおよびエラーアクションを紐付けた例です。

   @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        states
            .withStates()
                .initial(States.S1)
                .stateEntry(States.S2, action(), errorAction())
                .stateDo(States.S2, action(), errorAction())
                .stateExit(States.S2, action(), errorAction())
                .state(States.S3);
    }

エラーアクションに渡されるStateContextには、例外情報が含まれることは遷移に対するアクションと同じですね。

ところで、Doのタイミングがドキュメントに出てきていない気がしますね…。あとで確認してみましょう…。

ステートに対するアクションは紐付けを行うバリエーションが多く、コレクションで複数のアクションを紐付けることができます。

https://github.com/spring-projects/spring-statemachine/blob/v3.2.0/spring-statemachine-core/src/main/java/org/springframework/statemachine/config/configurers/DefaultStateConfigurer.java#L173-L282

とりあえず、ドキュメントを眺めるのはこんなところでしょうか。次は、実際にSpring Statemachineでアクションを動かしてみましょう。

環境

今回の環境は、こちらです。

$ java --version
openjdk 17.0.4 2022-07-19
OpenJDK Runtime Environment (build 17.0.4+8-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.4+8-Ubuntu-120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.6 (84538c9988a25aec085021c365c560670ad80f63)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.4, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-125-generic", arch: "amd64", family: "unix"

プロジェクトを作成する

Spring Bootプロジェクトを作成します。

$ curl -s https://start.spring.io/starter.tgz \
  -d bootVersion=2.6.7 \
  -d javaVersion=17 \
  -d name=statemachine-action \
  -d groupId=org.littlewings \
  -d artifactId=statemachine-action \
  -d version=0.0.1-SNAPSHOT \
  -d packageName=org.littlewings.spring.statemachine \
  -d baseDir=statemachine-action | tar zxvf -

少し古めですが、ドキュメントに習ってSpring Bootのバージョンは2.6.7にしておきます。

Spring Statemachine / Getting started / Using Maven

プロジェクト内へ移動。

$ cd statemachine-action

自動生成されたソースコードは削除しておきます。

$ rm src/main/java/org/littlewings/spring/statemachine/StatemachineActionApplication.java src/test/java/org/littlewings/spring/statemachine/StatemachineActionApplicationTests.java

Maven依存関係など。

        <properties>
                <java.version>17</java.version>
        </properties>
        <dependencies>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter</artifactId>
                </dependency>

                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-test</artifactId>
                        <scope>test</scope>
                </dependency>
        </dependencies>

        <build>
                <plugins>
                        <plugin>
                                <groupId>org.springframework.boot</groupId>
                                <artifactId>spring-boot-maven-plugin</artifactId>
                        </plugin>
                </plugins>
        </build>

spring-boot-starterspring-statemachine-starterに変更します。

 <dependencies>
        <dependency>
            <groupId>org.springframework.statemachine</groupId>
            <artifactId>spring-statemachine-starter</artifactId>
            <version>3.2.0</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

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

ステートを定義したenum

src/main/java/org/littlewings/spring/statemachine/States.java

package org.littlewings.spring.statemachine;

public enum States {
    INITIAL_STATE,
    STATE1,
    STATE2,
    STATE3,
    STATE4,
    STATE5,
    END_STATE
}

ちょっと多めですが、アクションを紐付けるバリエーションをいろいろ試そうとした結果です…。

イベントを定義したenum

src/main/java/org/littlewings/spring/statemachine/Events.java

package org.littlewings.spring.statemachine;

public enum Events {
    EVENT1,
    EVENT2,
    EVENT3,
    EVENT4,
    EVENT5,
    EVENT6
}

ステートマシンの定義。ちょっと長いですが、これは後で説明します。

src/main/java/org/littlewings/spring/statemachine/StateMachineConfig.java

package org.littlewings.spring.statemachine;

import java.util.List;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.action.Action;
import org.springframework.statemachine.config.EnableStateMachine;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
import org.springframework.statemachine.config.builders.StateMachineConfigurationConfigurer;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;

@Configuration
@EnableStateMachine
public class StateMachineConfig extends EnumStateMachineConfigurerAdapter<States, Events> {
    @Override
    public void configure(StateMachineConfigurationConfigurer<States, Events> config)
            throws Exception {
        config
                .withConfiguration()
                .autoStartup(true)
                .machineId("my-statemachine");
    }

    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        states
                .withStates()
                .initial(States.INITIAL_STATE)
                //.initial(States.INITIAL_STATE, stateAction())

                .state(States.STATE1)
                //.state(States.STATE1, stateAction())

                .state(States.STATE2)
                //.state(States.STATE2, stateAction(), stateAction())

                .state(States.STATE3)
                //.stateEntry(States.STATE3, stateEntryAction())
                //.stateDo(States.STATE3, stateDoAction())  // synonym state(state, action)
                //.stateExit(States.STATE3, stateExitAction())

                .state(States.STATE4)
                //.state(States.STATE4, stateAction(), stateAction())
                //.stateEntry(States.STATE4, stateActionThrowException(), stateActionHandleError())
                //.stateDo(States.STATE4, stateActionThrowException(), stateActionHandleError())
                //.stateExit(States.STATE4, stateActionThrowException(), stateActionHandleError())

                .state(States.STATE5)
                //.state(States.STATE5, List.of(stateAction(), stateAction()), List.of(stateAction(), stateAction()))  // collection

                .end(States.END_STATE);  // endにはactionなし
    }

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
                .withExternal()
                .source(States.INITIAL_STATE)
                .target(States.STATE1)
                .event(Events.EVENT1)
                //.action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE1)
                .target(States.STATE2)
                .event(Events.EVENT2)
                //.action(transitionAction())
                //.action(transitionActionThrowException(), transitionActionHandleError())

                .and()
                .withExternal()
                .source(States.STATE2)
                .target(States.STATE3)
                .event(Events.EVENT3)
                //.action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE3)
                .target(States.STATE4)
                .event(Events.EVENT4)
                //.action(transitionAction())
                //.action(transitionActionThrowException(), transitionActionHandleError())

                .and()
                .withExternal()
                .source(States.STATE4)
                .target(States.STATE5)
                .event(Events.EVENT5)
                //.action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE5)
                .target(States.END_STATE)
                .event(Events.EVENT6);
                //.action(transitionAction());
    }

    @Bean
    public Action<States, Events> stateAction() {
        return stateContext ->
                System.out.printf(
                        "state action, stage = %s, state = %s, event = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }

    @Bean
    public Action<States, Events> stateEntryAction() {
        return stateContext ->
                System.out.printf(
                        "state entry action, stage = %s, state = %s, event = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }

    @Bean
    public Action<States, Events> stateDoAction() {
        return stateContext ->
                System.out.printf(
                        "state do action, stage = %s, state = %s, event = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }

    @Bean
    public Action<States, Events> stateExitAction() {
        return stateContext ->
                System.out.printf(
                        "state exit action, stage = %s, state = %s, event = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }

    @Bean
    public Action<States, Events> stateActionThrowException() {
        return stateContext -> {
            System.out.printf(
                    "state action throw exception, stage = %s, state = %s, event = %s%n",
                    stateContext.getStage(),
                    stateContext.getTarget().getId(),
                    stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
            );

            throw new RuntimeException("state action error!!");
        };
    }

    @Bean
    public Action<States, Events> stateActionHandleError() {
        return stateContext ->
                System.out.printf(
                        "state action handling exception, stage = %s, state = %s, event = %s, exception message = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]",
                        stateContext.getException().getMessage()
                );
    }

    @Bean
    public Action<States, Events> transitionAction() {
        return stateContext ->
                System.out.printf(
                        "transition action, stage = %s, state = %s, event = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }

    @Bean
    public Action<States, Events> transitionActionThrowException() {
        return stateContext -> {
            System.out.printf(
                    "transition action throw exception, stage = %s, state = %s, event = %s%n",
                    stateContext.getStage(),
                    stateContext.getTarget().getId(),
                    stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
            );

            throw new RuntimeException("transition action error!!");
        };
    }

    @Bean
    public Action<States, Events> transitionActionHandleError() {
        return stateContext ->
                System.out.printf(
                        "transition action handling exception, stage = %s, state = %s, event = %s, exception message = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]",
                        stateContext.getException().getMessage()
                );
    }
}

ステートマシンを使うクラス。

src/main/java/org/littlewings/spring/statemachine/Runner.java

package org.littlewings.spring.statemachine;

import java.util.concurrent.TimeUnit;

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Component
public class Runner implements ApplicationRunner {
    StateMachine<States, Events> stateMachine;

    public Runner(StateMachine<States, Events> stateMachine) {
        this.stateMachine = stateMachine;
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT1).build()))
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT2).build()))
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT3).build()))
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT4).build()))
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT5).build()))
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT6).build()))
                .blockFirst();
    }
}

mainメソッドを持ったクラス。

src/main/java/org/littlewings/spring/statemachine/App.java

package org.littlewings.spring.statemachine;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }
}

今回のメインはもちろんステートマシンに定義したステート、遷移に紐付けたアクションの確認なのですが。
最初に載せたものがいろいろとごちゃごちゃしていたので、まずはステートと遷移のベースの定義を載せましょう。

@Configuration
@EnableStateMachine
public class StateMachineConfig extends EnumStateMachineConfigurerAdapter<States, Events> {
    @Override
    public void configure(StateMachineConfigurationConfigurer<States, Events> config)
            throws Exception {
        config
                .withConfiguration()
                .autoStartup(true)
                .machineId("my-statemachine");
    }

    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        states
                .withStates()
                .initial(States.INITIAL_STATE)
                .state(States.STATE1)
                .state(States.STATE2)
                .state(States.STATE3)
                .state(States.STATE4)
                .state(States.STATE5)
                .end(States.END_STATE);
    }

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
                .withExternal()
                .source(States.INITIAL_STATE)
                .target(States.STATE1)
                .event(Events.EVENT1)

                .and()
                .withExternal()
                .source(States.STATE1)
                .target(States.STATE2)
                .event(Events.EVENT2)

                .and()
                .withExternal()
                .source(States.STATE2)
                .target(States.STATE3)
                .event(Events.EVENT3)

                .and()
                .withExternal()
                .source(States.STATE3)
                .target(States.STATE4)
                .event(Events.EVENT4)

                .and()
                .withExternal()
                .source(States.STATE4)
                .target(States.STATE5)
                .event(Events.EVENT5)

                .and()
                .withExternal()
                .source(States.STATE5)
                .target(States.END_STATE)
                .event(Events.EVENT6);
    }

    〜省略〜
}

まだいずれにもアクションは紐付けていません。

この状態で、アプリケーションを実行。

$ mvn spring-boot:run

まあ、特になにも表示されないのですが。

2022-09-02 00:48:08.062  INFO 25583 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'org.springframework.statemachine.config.configuration.StateMachineAnnotationPostProcessorConfiguration' of type [org.springframework.statemachine.config.configuration.StateMachineAnnotationPostProcessorConfiguration$$EnhancerBySpringCGLIB$$95adde42] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2022-09-02 00:48:08.450  INFO 25583 --- [           main] org.littlewings.spring.statemachine.App  : Started App in 1.26 seconds (JVM running for 1.549)
2022-09-02 00:48:08.498  INFO 25583 --- [ionShutdownHook] o.s.s.support.LifecycleObjectSupport     : destroy called

ここから、ステートや遷移にアクションを紐付けていきます。

ステートにアクションを紐付ける

まずは、ステートにアクションを紐付けてみます。

以下のようにアクションを紐付けました。

    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        states
                .withStates()
                .initial(States.INITIAL_STATE, stateAction())

                .state(States.STATE1, stateAction())

                .state(States.STATE2, stateAction(), stateAction())

                .stateEntry(States.STATE3, stateEntryAction())
                .stateDo(States.STATE3, stateDoAction())  // synonym state(state, action)
                .stateExit(States.STATE3, stateExitAction())

                .state(States.STATE4, stateAction(), stateAction())

                .state(States.STATE5, List.of(stateAction(), stateAction()), List.of(stateAction(), stateAction()))  // collection

                .end(States.END_STATE);  // endにはactionなし
    }

ステートに対してひとつアクションを紐付けた場合は、ステートの開始時への紐付け

                .state(States.STATE1, stateAction())

2つの場合は開始と終了、

                .state(States.STATE2, stateAction(), stateAction())

開始と終了のタイミング別の紐付(DoEntryの別名のようです)、

                .stateEntry(States.STATE3, stateEntryAction())
                .stateDo(States.STATE3, stateDoAction())  // synonym state(state, action)
                .stateExit(States.STATE3, stateExitAction())

開始と終了のタイミングで、複数のアクションを紐付というバリエーションです。

                .state(States.STATE5, List.of(stateAction(), stateAction()), List.of(stateAction(), stateAction()))  // collection

最後のステートには、アクションは紐付けられないみたいです。

                .end(States.END_STATE);  // endにはactionなし

紐付けたアクションの定義はこちらで、ステージやステート、発生したイベントの情報を標準出力に書き出しています。

    @Bean
    public Action<States, Events> stateAction() {
        return stateContext ->
                System.out.printf(
                        "state action, stage = %s, state = %s, event = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }

    @Bean
    public Action<States, Events> stateEntryAction() {
        return stateContext ->
                System.out.printf(
                        "state entry action, stage = %s, state = %s, event = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }

    @Bean
    public Action<States, Events> stateDoAction() {
        return stateContext ->
                System.out.printf(
                        "state do action, stage = %s, state = %s, event = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }

    @Bean
    public Action<States, Events> stateExitAction() {
        return stateContext ->
                System.out.printf(
                        "state exit action, stage = %s, state = %s, event = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }

では、動かしてみます。

$ mvn spring-boot:run

System.out.printlnしていた部分を抜き出すと、こういう出力が得られました。

state action, stage = TRANSITION, state = INITIAL_STATE, event = [none]
state action, stage = STATE_ENTRY, state = STATE1, event = EVENT1
state action, stage = STATE_ENTRY, state = STATE2, event = EVENT2
state action, stage = STATE_EXIT, state = STATE3, event = EVENT3
state entry action, stage = STATE_ENTRY, state = STATE3, event = EVENT3
state do action, stage = STATE_ENTRY, state = STATE3, event = EVENT3
state exit action, stage = STATE_EXIT, state = STATE4, event = EVENT4
state action, stage = STATE_ENTRY, state = STATE4, event = EVENT4
state action, stage = STATE_EXIT, state = STATE5, event = EVENT5
state action, stage = STATE_ENTRY, state = STATE5, event = EVENT5
state action, stage = STATE_ENTRY, state = STATE5, event = EVENT5
state action, stage = STATE_EXIT, state = END_STATE, event = EVENT6
state action, stage = STATE_EXIT, state = END_STATE, event = EVENT6

ソースコードと紐付けると、こんな感じでしょうか。

// .initial(States.INITIAL_STATE, stateAction())
state action, stage = TRANSITION, state = INITIAL_STATE, event = [none]

// .state(States.STATE1, stateAction())
state action, stage = STATE_ENTRY, state = STATE1, event = EVENT1

.state(States.STATE2, stateAction(), stateAction())
state action, stage = STATE_ENTRY, state = STATE2, event = EVENT2
state action, stage = STATE_EXIT, state = STATE3, event = EVENT3

// .stateEntry(States.STATE3, stateEntryAction())
// .stateDo(States.STATE3, stateDoAction())  // synonym state(state, action)
// .stateExit(States.STATE3, stateExitAction())
state entry action, stage = STATE_ENTRY, state = STATE3, event = EVENT3
state do action, stage = STATE_ENTRY, state = STATE3, event = EVENT3
state exit action, stage = STATE_EXIT, state = STATE4, event = EVENT4

// .state(States.STATE4, stateAction(), stateAction())
state action, stage = STATE_ENTRY, state = STATE4, event = EVENT4
state action, stage = STATE_EXIT, state = STATE5, event = EVENT5

// .state(States.STATE5, List.of(stateAction(), stateAction()), List.of(stateAction(), stateAction()))
state action, stage = STATE_ENTRY, state = STATE5, event = EVENT5
state action, stage = STATE_ENTRY, state = STATE5, event = EVENT5
state action, stage = STATE_EXIT, state = END_STATE, event = EVENT6
state action, stage = STATE_EXIT, state = END_STATE, event = EVENT6

endにはアクションは紐付けられませんでしたね。

次に、一部のアクションの紐付けを変更してみます。

        states
                .withStates()
                .initial(States.INITIAL_STATE, stateAction())

                .state(States.STATE1, stateAction())

                .state(States.STATE2, stateAction(), stateAction())

                .stateEntry(States.STATE3, stateEntryAction())
                .stateDo(States.STATE3, stateDoAction())  // synonym state(state, action)
                .stateExit(States.STATE3, stateExitAction())

                .stateEntry(States.STATE4, stateActionThrowException(), stateActionHandleError())
                .stateDo(States.STATE4, stateActionThrowException(), stateActionHandleError())
                .stateExit(States.STATE4, stateActionThrowException(), stateActionHandleError())

                .state(States.STATE5, List.of(stateAction(), stateAction()), List.of(stateAction(), stateAction()))  // collection

                .end(States.END_STATE);  // endにはactionなし

変わったのは、STATE4ですね。

こちらには、例外を投げるアクションと、アクションから投げられた例外をハンドリングするアクションを紐付けています。

アクションの定義は、こちら。

    @Bean
    public Action<States, Events> stateActionThrowException() {
        return stateContext -> {
            System.out.printf(
                    "state action throw exception, stage = %s, state = %s, event = %s%n",
                    stateContext.getStage(),
                    stateContext.getTarget().getId(),
                    stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
            );

            throw new RuntimeException("state action error!!");
        };
    }

    @Bean
    public Action<States, Events> stateActionHandleError() {
        return stateContext ->
                System.out.printf(
                        "state action handling exception, stage = %s, state = %s, event = %s, exception message = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]",
                        stateContext.getException().getMessage()
                );
    }

実行。

$ mvn spring-boot:run

スタックトレースを出力しつつも、すべてのステートを経て終了しました。

state action, stage = TRANSITION, state = INITIAL_STATE, event = [none]
2022-09-02 01:05:35.735  INFO 26623 --- [           main] org.littlewings.spring.statemachine.App  : Started App in 1.017 seconds (JVM running for 1.256)
state action, stage = STATE_ENTRY, state = STATE1, event = EVENT1
state action, stage = STATE_ENTRY, state = STATE2, event = EVENT2
state action, stage = STATE_EXIT, state = STATE3, event = EVENT3
state entry action, stage = STATE_ENTRY, state = STATE3, event = EVENT3
state do action, stage = STATE_ENTRY, state = STATE3, event = EVENT3
state exit action, stage = STATE_EXIT, state = STATE4, event = EVENT4
state action throw exception, stage = STATE_ENTRY, state = STATE4, event = EVENT4
state action handling exception, stage = STATE_ENTRY, state = STATE4, event = EVENT4, exception message = state action error!!
2022-09-02 01:05:35.777  WARN 26623 --- [           main] o.s.statemachine.state.ObjectState       : Entry action execution error

java.lang.RuntimeException: state action error!!
        at org.littlewings.spring.statemachine.StateMachineConfig.lambda$stateActionThrowException$4(StateMachineConfig.java:159) ~[classes/:na]
        at org.springframework.statemachine.action.Actions$2.execute(Actions.java:71) ~[spring-statemachine-core-3.2.0.jar:3.2.0]
        at org.springframework.statemachine.action.Actions.lambda$null$0(Actions.java:98) ~[spring-statemachine-core-3.2.0.jar:3.2.0]
        at reactor.core.publisher.MonoRunnable.call(MonoRunnable.java:73) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.MonoRunnable.call(MonoRunnable.java:32) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.subscribeNext(MonoIgnoreThen.java:252) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.MonoIgnoreThen.subscribe(MonoIgnoreThen.java:51) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.Mono.subscribe(Mono.java:4400) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.FluxFlatMap.trySubscribeScalarMap(FluxFlatMap.java:203) ~[reactor-core-3.4.17.jar:3.4.17]

        〜省略〜
       
        at reactor.core.publisher.Flux.subscribe(Flux.java:8455) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.Flux.blockFirst(Flux.java:2599) ~[reactor-core-3.4.17.jar:3.4.17]
        at org.littlewings.spring.statemachine.Runner.run(Runner.java:33) ~[classes/:na]
        at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:768) ~[spring-boot-2.6.7.jar:2.6.7]
        at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:758) ~[spring-boot-2.6.7.jar:2.6.7]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:310) ~[spring-boot-2.6.7.jar:2.6.7]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1312) ~[spring-boot-2.6.7.jar:2.6.7]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1301) ~[spring-boot-2.6.7.jar:2.6.7]
        at org.littlewings.spring.statemachine.App.main(App.java:9) ~[classes/:na]

state action throw exception, stage = STATE_ENTRY, state = STATE4, event = EVENT4
state action handling exception, stage = STATE_ENTRY, state = STATE4, event = EVENT4, exception message = state action error!!
state action throw exception, stage = STATE_EXIT, state = STATE5, event = EVENT5
state action handling exception, stage = STATE_EXIT, state = STATE5, event = EVENT5, exception message = state action error!!
2022-09-02 01:05:35.784  WARN 26623 --- [           main] o.s.statemachine.state.ObjectState       : Exit action execution error

java.lang.RuntimeException: state action error!!
        at org.littlewings.spring.statemachine.StateMachineConfig.lambda$stateActionThrowException$4(StateMachineConfig.java:159) ~[classes/:na]
        at org.springframework.statemachine.action.Actions$2.execute(Actions.java:71) ~[spring-statemachine-core-3.2.0.jar:3.2.0]
        at org.springframework.statemachine.action.Actions.lambda$null$0(Actions.java:98) ~[spring-statemachine-core-3.2.0.jar:3.2.0]
        at reactor.core.publisher.MonoRunnable.call(MonoRunnable.java:73) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.MonoRunnable.call(MonoRunnable.java:32) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain.subscribeNext(MonoIgnoreThen.java:252) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.MonoIgnoreThen.subscribe(MonoIgnoreThen.java:51) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.Mono.subscribe(Mono.java:4400) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.FluxFlatMap.trySubscribeScalarMap(FluxFlatMap.java:203) ~[reactor-core-3.4.17.jar:3.4.17]

        〜省略〜
       
        at reactor.core.publisher.Flux.subscribe(Flux.java:8469) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.FluxFlatMap.trySubscribeScalarMap(FluxFlatMap.java:200) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.MonoFlatMapMany.subscribeOrReturn(MonoFlatMapMany.java:49) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.Flux.subscribe(Flux.java:8455) ~[reactor-core-3.4.17.jar:3.4.17]
        at reactor.core.publisher.Flux.blockFirst(Flux.java:2599) ~[reactor-core-3.4.17.jar:3.4.17]
        at org.littlewings.spring.statemachine.Runner.run(Runner.java:36) ~[classes/:na]
        at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:768) ~[spring-boot-2.6.7.jar:2.6.7]
        at org.springframework.boot.SpringApplication.callRunners(SpringApplication.java:758) ~[spring-boot-2.6.7.jar:2.6.7]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:310) ~[spring-boot-2.6.7.jar:2.6.7]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1312) ~[spring-boot-2.6.7.jar:2.6.7]
        at org.springframework.boot.SpringApplication.run(SpringApplication.java:1301) ~[spring-boot-2.6.7.jar:2.6.7]
        at org.littlewings.spring.statemachine.App.main(App.java:9) ~[classes/:na]

state action, stage = STATE_ENTRY, state = STATE5, event = EVENT5
state action, stage = STATE_ENTRY, state = STATE5, event = EVENT5
state action, stage = STATE_EXIT, state = END_STATE, event = EVENT6
state action, stage = STATE_EXIT, state = END_STATE, event = EVENT6

Spring StatemachineとReactorが例外を拾っていますが、実行はそのまま続いていますね。

例外を投げ、ハンドリングしているアクションとソースコードの対比を載せるとこんな感じです。

// .stateEntry(States.STATE4, stateActionThrowException(), stateActionHandleError())
state action throw exception, stage = STATE_ENTRY, state = STATE4, event = EVENT4
state action handling exception, stage = STATE_ENTRY, state = STATE4, event = EVENT4, exception message = state action error!!

// .stateDo(States.STATE4, stateActionThrowException(), stateActionHandleError())
state action throw exception, stage = STATE_ENTRY, state = STATE4, event = EVENT4
state action handling exception, stage = STATE_ENTRY, state = STATE4, event = EVENT4, exception message = state action error!!

// .stateExit(States.STATE4, stateActionThrowException(), stateActionHandleError())
state action throw exception, stage = STATE_EXIT, state = STATE5, event = EVENT5
state action handling exception, stage = STATE_EXIT, state = STATE5, event = EVENT5, exception message = state action error!!

だいたい雰囲気はわかりましたね。

ちなみに、エラーハンドリング用のアクションで例外は扱っているはずなのに、どうしてSpring StatemachineとReactorでスタックトレース
現れるかというと、エラー用のアクションを呼び出した後で例外を再スローしているからのようです…。

https://github.com/spring-projects/spring-statemachine/blob/v3.2.0/spring-statemachine-core/src/main/java/org/springframework/statemachine/action/Actions.java#L82

では、1度ステートの定義からアクションの紐付けを外します。

    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        states
                .withStates()
                .initial(States.INITIAL_STATE)
                .state(States.STATE1)
                .state(States.STATE2)
                .state(States.STATE3)
                .state(States.STATE4)
                .state(States.STATE5)
                .end(States.END_STATE);
    }

遷移にアクションを紐付ける

次は、遷移に対してアクションを紐付けてみます。

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
                .withExternal()
                .source(States.INITIAL_STATE)
                .target(States.STATE1)
                .event(Events.EVENT1)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE1)
                .target(States.STATE2)
                .event(Events.EVENT2)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE2)
                .target(States.STATE3)
                .event(Events.EVENT3)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE3)
                .target(States.STATE4)
                .event(Events.EVENT4)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE4)
                .target(States.STATE5)
                .event(Events.EVENT5)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE5)
                .target(States.END_STATE)
                .event(Events.EVENT6)//;
                .action(transitionAction());
    }

今回は、すべての遷移に対して同じアクションを紐付けています。

アクションの定義は、こちら。

    @Bean
    public Action<States, Events> transitionAction() {
        return stateContext ->
                System.out.printf(
                        "transition action, stage = %s, state = %s, event = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
                );
    }

実行してみます。

$ mvn spring-boot:run

結果は、とてもシンプルです。

transition action, stage = TRANSITION, state = STATE1, event = EVENT1
transition action, stage = TRANSITION, state = STATE2, event = EVENT2
transition action, stage = TRANSITION, state = STATE3, event = EVENT3
transition action, stage = TRANSITION, state = STATE4, event = EVENT4
transition action, stage = TRANSITION, state = STATE5, event = EVENT5
transition action, stage = TRANSITION, state = END_STATE, event = EVENT6

では、ここで遷移の2つに例外を扱うアクションを追加してみましょう。

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
                .withExternal()
                .source(States.INITIAL_STATE)
                .target(States.STATE1)
                .event(Events.EVENT1)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE1)
                .target(States.STATE2)
                .event(Events.EVENT2)
                .action(transitionActionThrowException(), transitionActionHandleError())

                .and()
                .withExternal()
                .source(States.STATE2)
                .target(States.STATE3)
                .event(Events.EVENT3)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE3)
                .target(States.STATE4)
                .event(Events.EVENT4)
                .action(transitionActionThrowException(), transitionActionHandleError())

                .and()
                .withExternal()
                .source(States.STATE4)
                .target(States.STATE5)
                .event(Events.EVENT5)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE5)
                .target(States.END_STATE)
                .event(Events.EVENT6)//;
                .action(transitionAction());
    }

例外を扱うアクションは、こちら。内容自体はステートの時と同じです。

    @Bean
    public Action<States, Events> transitionActionThrowException() {
        return stateContext -> {
            System.out.printf(
                    "transition action throw exception, stage = %s, state = %s, event = %s%n",
                    stateContext.getStage(),
                    stateContext.getTarget().getId(),
                    stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]"
            );

            throw new RuntimeException("transition action error!!");
        };
    }

    @Bean
    public Action<States, Events> transitionActionHandleError() {
        return stateContext ->
                System.out.printf(
                        "transition action handling exception, stage = %s, state = %s, event = %s, exception message = %s%n",
                        stateContext.getStage(),
                        stateContext.getTarget().getId(),
                        stateContext.getMessage() != null ? stateContext.getMessage().getPayload() : "[none]",
                        stateContext.getException().getMessage()
                );
    }

実行してみます。

$ mvn spring-boot:run

こちらは、ステートの時とは異なった結果になりました。

transition action, stage = TRANSITION, state = STATE1, event = EVENT1
transition action throw exception, stage = TRANSITION, state = STATE2, event = EVENT2
transition action handling exception, stage = TRANSITION, state = STATE2, event = EVENT2, exception message = transition action error!!

アクションで発生した例外をハンドリングした時点で、ステートマシンが終了してしまいました。

こちらの処理自体はすべて実行されているのですが、EVENT2を送信したところでそれ以降は受け付けなくなっているようです。

    @Override
    public void run(ApplicationArguments args) throws Exception {
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT1).build()))
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT2).build()))
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT3).build()))
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT4).build()))
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT5).build()))
                .blockFirst();
        stateMachine
                .sendEvent(Mono.just(MessageBuilder.withPayload(Events.EVENT6).build()))
                .blockFirst();
    }

遷移に紐付けたアクションの場合は、例外をハンドリングしても停止してしまうんですね…。

ちなみに、アクションが例外をハンドリングした後も例外を再スローするのは、やっぱり以下の部分です。

https://github.com/spring-projects/spring-statemachine/blob/v3.2.0/spring-statemachine-core/src/main/java/org/springframework/statemachine/action/Actions.java#L82

ステートと遷移の両方にアクションを紐付ける

最後に、ステートと遷移の両方にアクションを紐付けた時の動作を見てみましょう。今回は、例外は使いません。

    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        states
                .withStates()
                .initial(States.INITIAL_STATE, stateAction())

                .state(States.STATE1, stateAction())

                .state(States.STATE2, stateAction(), stateAction())

                .stateEntry(States.STATE3, stateEntryAction())
                .stateExit(States.STATE3, stateExitAction())

                .state(States.STATE4, stateAction(), stateAction())

                .state(States.STATE5)

                .end(States.END_STATE);  // endにはactionなし
    }

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
                .withExternal()
                .source(States.INITIAL_STATE)
                .target(States.STATE1)
                .event(Events.EVENT1)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE1)
                .target(States.STATE2)
                .event(Events.EVENT2)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE2)
                .target(States.STATE3)
                .event(Events.EVENT3)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE3)
                .target(States.STATE4)
                .event(Events.EVENT4)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE4)
                .target(States.STATE5)
                .event(Events.EVENT5)
                .action(transitionAction())

                .and()
                .withExternal()
                .source(States.STATE5)
                .target(States.END_STATE)
                .event(Events.EVENT6)//;
                .action(transitionAction());
    }

実行。

$ mvn spring-boot:run

結果は、こんな感じになりました。

state action, stage = TRANSITION, state = INITIAL_STATE, event = [none]
transition action, stage = TRANSITION, state = STATE1, event = EVENT1
state action, stage = STATE_ENTRY, state = STATE1, event = EVENT1
transition action, stage = TRANSITION, state = STATE2, event = EVENT2
state action, stage = STATE_ENTRY, state = STATE2, event = EVENT2
transition action, stage = TRANSITION, state = STATE3, event = EVENT3
state action, stage = STATE_EXIT, state = STATE3, event = EVENT3
state entry action, stage = STATE_ENTRY, state = STATE3, event = EVENT3
transition action, stage = TRANSITION, state = STATE4, event = EVENT4
state exit action, stage = STATE_EXIT, state = STATE4, event = EVENT4
state action, stage = STATE_ENTRY, state = STATE4, event = EVENT4
transition action, stage = TRANSITION, state = STATE5, event = EVENT5
state action, stage = STATE_EXIT, state = STATE5, event = EVENT5
transition action, stage = TRANSITION, state = END_STATE, event = EVENT6

ソースコードと紐付けてみましょう。

// .initial(States.INITIAL_STATE, stateAction())
state action, stage = TRANSITION, state = INITIAL_STATE, event = [none]

// .source(States.INITIAL_STATE)
// .target(States.STATE1)
// .event(Events.EVENT1)
// .action(transitionAction())
transition action, stage = TRANSITION, state = STATE1, event = EVENT1

// .state(States.STATE1, stateAction())
state action, stage = STATE_ENTRY, state = STATE1, event = EVENT1

// .source(States.STATE1)
// .target(States.STATE2)
// .event(Events.EVENT2)
// .action(transitionAction())
transition action, stage = TRANSITION, state = STATE2, event = EVENT2

// .state(States.STATE2, stateAction(), stateAction())
state action, stage = STATE_ENTRY, state = STATE2, event = EVENT2

// .source(States.STATE2)
// .target(States.STATE3)
// .event(Events.EVENT3)
// .action(transitionAction())
transition action, stage = TRANSITION, state = STATE3, event = EVENT3

// .state(States.STATE2, stateAction(), stateAction())
state action, stage = STATE_EXIT, state = STATE3, event = EVENT3

// .stateExit(States.STATE3, stateExitAction())
state entry action, stage = STATE_ENTRY, state = STATE3, event = EVENT3

// .source(States.STATE3)
// .target(States.STATE4)
// .event(Events.EVENT4)
// .action(transitionAction())
transition action, stage = TRANSITION, state = STATE4, event = EVENT4

// .stateExit(States.STATE3, stateExitAction())
state exit action, stage = STATE_EXIT, state = STATE4, event = EVENT4

// .state(States.STATE4, stateAction(), stateAction())
state action, stage = STATE_ENTRY, state = STATE4, event = EVENT4

// .source(States.STATE4)
// .target(States.STATE5)
// .event(Events.EVENT5)
// .action(transitionAction())
transition action, stage = TRANSITION, state = STATE5, event = EVENT5

// .state(States.STATE4, stateAction(), stateAction())
state action, stage = STATE_EXIT, state = STATE5, event = EVENT5

// .source(States.STATE5)
// .target(States.END_STATE)
// .event(Events.EVENT6)//;
// .action(transitionAction());
transition action, stage = TRANSITION, state = END_STATE, event = EVENT6

こう見ると、ステート開始・終了の間に遷移が挟まっている感じに見えますが、どうなんでしょうね?

まあ、なんとなくアクションをステートおよび遷移に紐付けると、どういう動作になるかはわかった気がします。

まとめ

Spring Statemachineのアクションを試してみました。

最初は雰囲気で初めてみたのですが、ちゃんとドキュメントを見ているとアクションの紐付け先はステートと遷移の2種類あることがわかり、
それぞれ考え方が微妙に違ったので、ドキュメントを読んでおくのは大事だなという気にはなりました。

とりあえず、雰囲気はわかったので今回はこんなところで。