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-user … tokenというRealmロールを割り当てる
  • message-role-user … messageという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プロジェクトを作成する

では、アプリケーションを作成していきます。依存関係にwebとoauth2-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/messageはmessage(ROLE_message)を権限として要求し、/role/tokenは同じくtoken(ROLE_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というクラスに認証・認可設定をまとめましたが、こちらに関する内容をもうちょっと掘り下げましょう。

片方コメントアウトされていますが、oauth2Login → userInfoEndpointで、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.rolesをGrantedAuthorityにマッピングするものになります。

まずは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ロールのクレームが含まれない

あたりですね。

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