Spring SecurityでOIDCで認証しているアプリにRP-Initiated Logoutを実装する

GxPの[@h-uminoue-gxp](https://qiita.com/h-uminoue-gxp)です。
この記事は[グロースエクスパートナーズ Advent Calendar 2022](https://qiita.com/advent-calendar/2022/gxp)の没ネタを単独記事にしたものです。

# 概要
長いことドラフト仕様だったOIDCのRP-Initiated Logoutですが、昨年9月にようやく仕様が確定したようです。
[OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)

以前ログアウトの実装考えてた時は意外と悩むことも多かったんですが、今回はSpring SecurityでOIDCで認証しているSpring BootアプリにRP-Initiated Logoutの確定仕様に沿ってRPとOPのシングルサインアウトができるように実装してみたいと思います。

※RP → アプリ
※OP → 認証サーバ(Idp)

# RP-Initiated Logout
OIDCで定義されているログアウト仕様の1つです。公式ドキュメントによると

>This specification complements the OpenID Connect Core 1.0 [OpenID.Core] specification by enabling the Relying Party to request that an End-User be logged out by the OpenID Provider.
>
>この仕様は、OpenID Connect Core 1.0 [OpenID.Core] 仕様を補完し、Relying Party(RP)が OpenID Provider によるエンドユーザのログアウトを要求できるようにするものです。

要はRPからOPに対してログアウトしてねとリクエストする際の仕様です。更に、OPでログアウト後した後はまたRPにリダイレクトで戻すことといった内容も盛り込まれているため、RPから認証ごとログアウトするリクエストを送ったあとRPの認証前画面に戻す、といった動きができます。

# 前提
- RPが複数ある場合、Back-Channel Logoutなども併用してログイン中の全RPにログアウトを通知しないと厳密な意味でのシングルサインアウトにはなりませんが、ここではそれは扱いませんのでご了承ください。
- 本文中の例ではOP(Idp)はKeycloakを使用して例を示します。後の方でKeycloakでない場合についても触れます。
- Spring SecurityでOIDCを有効化する実装方法そのものは扱いません。それ自体はできていること前提の内容になります。
- Spring Securityのバージョンは5.7を使用しています。5.4辺りから大幅に書き方が変わっていますので、古いバージョンを使っている方はご注意ください。また、5.8以降でもまた色々と変わってますので新しいバージョンを使っている方もご注意ください。
- KeycloakはあらかじめDocker等で立ち上げていて、レルム、クライアント、ユーザの登録は済ませているものとします。

# 元のサンプルアプリ
プロジェクト構造はこんな感じで
```
└─src
    ├─main
    │  ├─java
    │  │  └─com
    │  │      └─example
    │  │          └─logoutsample
    │  │              │  LogoutSampleApplication.java
    │  │              │  ServletInitializer.java
    │  │              │
    │  │              ├─config
    │  │              │      SecurityConfig.java
    │  │              │
    │  │              └─controller
    │  │                      MainController.java
    │  │
    │  └─resources
    │      │  application.yaml
    │      │
    │      ├─static
    │      └─templates
    │              index.html
    │              main.html
```

画面は2つ。
```html:index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>ログイン前画面</title>
</head>
<body>
  <a href="/main">ログイン</a>
</body>
</html>
```
```html:main.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>ログイン中画面</title>
</head>
<body>
    <p>[[${user.getName()}]]でログイン中です。</p>
    <p><a href="/logout">ログアウト</a></p>
</body>
</html>
```
main.htmlへマッピングするcontroller
```MainController.java
@Controller
@RequestMapping("/main")
public class MainController {
    @GetMapping
    public String main(Authentication authentication, Model model) {
        if (authentication instanceof OAuth2AuthenticationToken) {
            OAuth2User user = ((OAuth2AuthenticationToken) authentication).getPrincipal();
            model.addAttribute("user", (user));
        }
        return "main";
    }
}
```
SecurityConfig
```SecurityConfig.java
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authz -> authz
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                .antMatchers("/").permitAll()
                .anyRequest()
                .authenticated())
                .oauth2Login(Customizer.withDefaults())
                ;

        return http.build();
    }
```
application.yamlの設定
```yaml:application.yaml
spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: logout-sample
            client-secret: secret
            provider: keycloak
            scope: openid
            authorization-grant-type: authorization_code
            redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
        provider:
          keycloak:
            authorization-uri: http://localhost:18080/realms/demo/protocol/openid-connect/auth
            token-uri: http://localhost:18080/realms/demo/protocol/openid-connect/token
            user-info-uri: http://localhost:18080/realms/demo/protocol/openid-connect/userinfo
            jwk-set-uri: http://localhost:18080/realms/demo/protocol/openid-connect/certs
            user-name-attribute: name
```
実行してトップ画面に行くとこうなって
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982759/ad20d80f-ea39-d1fb-3d97-bce08a65a899.png)

Keycloakでログインし
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982759/00b0db67-ffc8-7d6a-c904-07e7a0a17ba4.png)

ログイン中のユーザが表示される。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982759/e4b32f25-d58a-1380-296f-c7a0b2c6d0e2.png)

たったこれだけの超簡単作例ですみませんが、ここにRP-Initiated Logoutを実装してみます。

※すでにログアウトのリンクがありますが、あらかじめ用意してあるだけで、現時点では正常に動作しません。Spring Securityのデフォルトで```/logout```がデフォルトのログアウトのエンドポイントになっており、これを叩けば一応RPからログアウトはされるのですが、OP側Keycloakのセッションが残ったままなので、再度ログインをクリックすると認証済とみなされてすぐにmain.htmlに入れてしまいます。

# 一番簡単なやり方 OidcClientInitiatedLogoutSuccessHandler
もっとも手軽に実装できます。OPがKeycloakならこれで十分です。

まずSecurityConfigにログアウトハンドラを、それを使用する設定を付け足します。
```SecurityConfig.java
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final ClientRegistrationRepository clientRegistrationRepository;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authz -> authz
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                .antMatchers("/").permitAll()
                .anyRequest()
                .authenticated())
                .oauth2Login(Customizer.withDefaults())
                .logout(logout -> logout
                        .logoutSuccessHandler(oidcLogoutSuccessHandler())
                        .logoutUrl("/logout")
                        .invalidateHttpSession(true)
                        .clearAuthentication(true))
                ;

        return http.build();
    }

    private OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler() {
        OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler = new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
        oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
        return oidcLogoutSuccessHandler;
    }
}
```
setPostLogoutRedirectUriはログアウト後の遷移先です。ここではbaseUrlを指定しているのでトップ画面(http\://localhost:8080)に戻ります。これと同じUrlをKeycloakのクライアントのValid post logout redirect URIsに指定しておいてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982759/452e0dc3-0b46-d477-a996-19cb4e48ff67.png)

次にapplication.yamlにissuer-uriを付け足します。これはOpenID Provider Metadataを取得するためのエンドポイントで、これがないとend_session_endpointが取得できずOPからログアウトができません。
```yaml:application.yaml
spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: logout-sample
            client-secret: secret
            provider: keycloak
            scope: openid
            authorization-grant-type: authorization_code
            redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
        provider:
          keycloak:
            authorization-uri: http://localhost:18080/realms/demo/protocol/openid-connect/auth
            token-uri: http://localhost:18080/realms/demo/protocol/openid-connect/token
            user-info-uri: http://localhost:18080/realms/demo/protocol/openid-connect/userinfo
            jwk-set-uri: http://localhost:18080/realms/demo/protocol/openid-connect/certs
            issuer-uri: http://localhost:18080/realms/demo
            user-name-attribute: name
```
以上で修正完了です。簡単ですね。起動して確認してみます。
さっきと同じようにログインした後、ログアウトをクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982759/e4b32f25-d58a-1380-296f-c7a0b2c6d0e2.png)

Spring Securityデフォルトのログアウト画面が表示されて、本当にログアウトするのかと訊かれるのでLog Outをクリック。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982759/49e7187d-8532-2042-35af-1918b5d9bb3a.png)

トップ画面に戻ります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982759/ad20d80f-ea39-d1fb-3d97-bce08a65a899.png)

Keycloakのセッションを確認してみると、セッションは残っておらず、OPからもログアウトできていることが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982759/ce2d3502-0569-f5f4-440e-5c505b73fab3.png)

# これだけで対応できないケースを考えてみる
Keycloakならこれで良いのですが、実際の案件では例えば、客先に納品するアプリについて、認証は客先が用意したOPを使うように求められた、なんてケースもあるかと思います。
ここで[公式のドキュメント OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html#DynRegRegistrations)を見直してみると、
> At the Logout Endpoint, the OP SHOULD ask the End-User whether to log out of the OP as well. Furthermore, the OP MUST ask the End-User this question if an id_token_hint was not provided or if the supplied ID Token does not belong to the current OP session with the RP and/or currently logged in End-User. If the End-User says "yes", then the OP MUST log out the End-User.
>
>ログアウト・エンドポイントでは、OPはエンドユーザーにOPからログアウトするかどうかを尋ねるべきであ る。さらに、OPは、id_token_hintが提供されなかった場合、または提供されたIDトークンがRPおよび／または現在ログインしているエンドユーザーとの現在のOPセッションに属さない場合、この質問をエンドユーザーに尋ねなければならない（MUST）。エンドユーザーが「はい」と答えた場合、OPはエンドユーザーをログアウトさせなければならない（MUST）。

>It is up to the RP whether to locally log out the End-User before redirecting the User Agent to the OP's Logout Endpoint.
>
>ユーザーエージェントをOPのログアウトエンドポイントにリダイレクトする前に、エンドユーザーをローカルにログアウトするかどうかは、RPの判断に委ねられる。

といった記述が見つかりますが、Keycloakはログアウトするかどうかを尋ねません(先ほどのログアウトの確認画面はSpring Securityのデフォルトのログアウト画面)。
また、上記のOidcClientInitiatedLogoutSuccessHandlerに任せる例では、OPのログアウトエンドポイントにリダイレクトする前にRPからログアウトしています。

もしも、OP側が画面出してログアウトするかどうか尋ねてくるようになっていた場合、Spring Securityのデフォルトのログアウト画面は無効化しないと2重に確認されますし、OP側での確認でログアウトを中止した場合でも、RPではその時点でログアウトされてしまっています。

他、stateなどいくつかオプショナルなパラメータが定義されていますが、上記の方法ではid_token_hintとpost_logout_redirect_uriしか送っていません。OPがそれ以外のオプショナルなパラメータを要求している場合に対応できません。

こういった問題の対応を以下で考えますが、ここでは仮に

- OP側で専用の確認画面が用意されている
- オプショナルパラメータとしてstateを要求されている

というOPの場合を例に考えてみたいと思います。

# Spring Securityデフォルトのログアウト画面を無効化する
ユーザへの確認はOPで行われるため、Spring Securityのログアウト画面は出したくありません。
簡単なのは、```SecurityConfig```の```logoutUrl```を```logoutRequestMatcher```に差し替えてしまう方法です。
```SecurityConfig.java
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final ClientRegistrationRepository clientRegistrationRepository;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authz -> authz
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                .antMatchers("/").permitAll()
                .anyRequest()
                .authenticated())
                .oauth2Login(Customizer.withDefaults())
                .logout(logout -> logout
                        .logoutSuccessHandler(oidcLogoutSuccessHandler())
                        .logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET")) //ココ
                        .invalidateHttpSession(true)
                        .clearAuthentication(true))
                ;

        return http.build();
    }

    private OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler() {
        OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler = new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
        oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
        return oidcLogoutSuccessHandler;
    }
}
```
起動して確認してみると、ログアウトをクリックした瞬間にトップページに戻り、RPもOPもログアウトされているのが確認できるかと思います。

# OP → RPの順でログアウトさせる
OP(Keycloak)のログアウトエンドポイントにアクセス→OPでログアウト処理→RPのログアウトエンドポイントにリダイレクトとしたいので、まずKeycloakのValid post logout redirect URIsを変更しておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982759/fcafdc3d-7851-07c1-376a-7a7df4373b59.png)

mainページでログアウトをクリックした時に、先にOPのログアウトエンドポイントへのリダイレクトするようにします。
新しくcontrollerを1つ追加しました。
```java:PreLogoutController.java
@Controller
@RequestMapping("/prelogout")
@RequiredArgsConstructor
public class PreLogoutController {

    private final ClientRegistrationRepository clientRegistrationRepository;
    private final String postLogoutRedirectUri = "http://localhost:8080/logout";

    @GetMapping
    public String preLogout(HttpServletRequest request, Authentication authentication) {
        if (authentication instanceof OAuth2AuthenticationToken) {
            String registrationId = ((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId();
            URI endSessionEndpoint = getEndSessionEndpoint(registrationId);

            OidcUser oidcUser = (OidcUser) authentication.getPrincipal();
            UriComponentsBuilder builder = UriComponentsBuilder.fromUri(endSessionEndpoint)
                    .queryParam("id_token_hint", oidcUser.getIdToken().getTokenValue())
                    .queryParam("post_logout_redirect_uri", postLogoutRedirectUri);

            return "redirect:" + builder.toUriString();
        }

        return "/main";
    }

    private URI getEndSessionEndpoint(String registrationId) {
        ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
        ClientRegistration.ProviderDetails providerDetails = clientRegistration.getProviderDetails();
        Object endSessionEndpoint = providerDetails.getConfigurationMetadata().get("end_session_endpoint");
        return URI.create(endSessionEndpoint.toString());
    }
}
```
stateは後で追加するとして、ひとまずpost_logout_redirect_uriをOPのensSessionEndPointに送っています。OPでログアウト処理が行われた後post_logout_redirect_uriのURLにリダイレクトされてきますが、そこがSecurityconfigで指定したRPのログアウトのエンドポイントなので、そこでRPのログアウトが行われる、OPでログアウトが中止された場合返ってこないのでRPもログアウトされない、という算段です。
当然ですがpost_logout_redirect_uriはKeycloakのValid post logout redirect URIsと一致させておきます。


main.htmlのログアウトのリンク先も変更します。
```html:main.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>ログイン中画面</title>
</head>
<body>
    <p>[[${user.getName()}]]でログイン中です。</p>
    <p><a href="/prelogout">ログアウト</a></p>
</body>
</html>
```

OidcClientInitiatedLogoutSuccessHandlerはもう使いません。ログアウト後任意のURLにリダイレクトするだけなので、この場合SimpleUrlLogoutSuccessHandlerを使います。
```java:SecurityConfig.java
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final ClientRegistrationRepository clientRegistrationRepository;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authz -> authz
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                .antMatchers("/").permitAll()
                .anyRequest()
                .authenticated())
                .oauth2Login(Customizer.withDefaults())
                .logout(logout -> logout
                        .logoutSuccessHandler(simpleUrlLogoutSuccessHandler())
                        .logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))
                        .invalidateHttpSession(true)
                        .clearAuthentication(true))
                ;

        return http.build();
    }

    private SimpleUrlLogoutSuccessHandler simpleUrlLogoutSuccessHandler() {
        SimpleUrlLogoutSuccessHandler simpleUrlLogoutSuccessHandler = new SimpleUrlLogoutSuccessHandler();
        simpleUrlLogoutSuccessHandler.setDefaultTargetUrl("http://localhost:8080/");
        return simpleUrlLogoutSuccessHandler;
    }
}
```
Keycloakで検証しているのでOPの確認画面が出せないのが残念ですが、起動して確認してみると、先ほどと同じくOPからもRPからも正常にログアウトされていますが、ブレークポイント等で処理を追ってみると、RPのログアウトの方が後で行われているのが確認できると思います。

# 追加パラメータの例 state
最後にOPへの追加パラメータの例ということで、ここではstateを付けてみます。

>OPTIONAL. Opaque value used by the RP to maintain state between the logout request and the callback to the endpoint specified by the post_logout_redirect_uri parameter. If included in the logout request, the OP passes this value back to the RP using the state parameter when redirecting the User Agent back to the RP.
>
>OPTIONAL。ログアウト要求とpost_logout_redirect_uriパラメータで指定されたエンドポイントへのコールバックの間の状態を維持するためにRPが使用する不透明な値です。ログアウト要求に含まれる場合、OPはユーザーエージェントをRPにリダイレクトする際にstateパラメータを使用してこの値をRPに返します。

だそうですので、OPが返してきたstateをRP側で検証できるように考えてみたいと思います。
まずstate自体をユーザパラメータの1つとしてSecurityContextに保存できるようにしてみます。
```java: MyOidcUserService.java
@Service
public class MyOidcUserService extends OidcUserService {

    private final StringKeyGenerator stateGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder());

    @Override
    public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
        OidcUser oidcUser = super.loadUser(userRequest);
        String state = stateGenerator.generateKey();
        return new MyOidcUser(oidcUser.getAuthorities(), oidcUser.getIdToken(), oidcUser.getUserInfo(), "name", state);
    }
}
```
```java: MyOidcUser.java
@Getter
public class MyOidcUser extends DefaultOidcUser {

    private final String state;

    public MyOidcUser(Collection<? extends GrantedAuthority> authorities, OidcIdToken idToken, OidcUserInfo userInfo, String nameAttributeKey, String state) {
        super(authorities, idToken, userInfo, nameAttributeKey);
        this.state = state;
    }
}
```
DefaultOidcUserにstateを持たせた拡張のMyOidcUserを定義し、userInfoからOidcUserを生成する際に、MyOidcUserを生成するようにしています。stateもこの時生成して持たせています。

このMyOidcUserServiceを使用するようにSecurityConfigを修正します。
```java:SecurityConfig.java
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final MyOidcUserService myOidcUserService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.authorizeHttpRequests(authz -> authz
            .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
            .antMatchers("/").permitAll()
            .anyRequest()
            .authenticated())
            .oauth2Login()
            .userInfoEndpoint()
            .oidcUserService(myOidcUserService);

        http.logout(logout -> logout
            .logoutSuccessHandler(simpleUrlLogoutSuccessHandler())
            .logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))
            .invalidateHttpSession(true)
            .clearAuthentication(true));

        return http.build();
    }

    private SimpleUrlLogoutSuccessHandler simpleUrlLogoutSuccessHandler() {
        SimpleUrlLogoutSuccessHandler simpleUrlLogoutSuccessHandler = new SimpleUrlLogoutSuccessHandler();
        simpleUrlLogoutSuccessHandler.setDefaultTargetUrl("http://localhost:8080/");
        return simpleUrlLogoutSuccessHandler;
    }
}
```

stateを検証するためのハンドラも用意します。
```java:SecurityConfig.java
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final MyOidcUserService myOidcUserService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.authorizeHttpRequests(authz -> authz
            .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
            .antMatchers("/").permitAll()
            .anyRequest()
            .authenticated())
            .oauth2Login()
            .userInfoEndpoint()
            .oidcUserService(myOidcUserService);

        http.logout(logout -> logout
            .logoutSuccessHandler(simpleUrlLogoutSuccessHandler())
            .addLogoutHandler(new MyLogoutHandler())
            .logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))
            .invalidateHttpSession(true)
            .clearAuthentication(true));

        return http.build();
    }

    private SimpleUrlLogoutSuccessHandler simpleUrlLogoutSuccessHandler() {
        SimpleUrlLogoutSuccessHandler simpleUrlLogoutSuccessHandler = new SimpleUrlLogoutSuccessHandler();
        simpleUrlLogoutSuccessHandler.setDefaultTargetUrl("http://localhost:8080/");
        return simpleUrlLogoutSuccessHandler;
    }

    public class MyLogoutHandler extends SecurityContextLogoutHandler {
        @Override
        public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {

            String userState = ((MyOidcUser)authentication.getPrincipal()).getState();
            if (request.getParameter("state") == null || !request.getParameter("state").equals(userState)) {
                //stateが無効なことによるエラー処理はここで
                return;
            }

            super.logout(request, response, authentication);
        }
    }
}
```
MyLogoutHandler(SecurityContextLogoutHandler)はSimpleUrlLogoutSuccessHandlerの前に実行されます。SecurityContextLogoutHandlerのlogoutメソッドでSecurityContextの削除などのRPのログアウト処理が行われますが、その前にstateが正しいか検証しています。stateが無効なことによるエラー処理が必要な場合は```super.logout(request, response, authentication)```の前で行います。

後はOPへのログアウトリクエストでstateを送るようにして完成です。
```java:PreLogoutController.java
@Controller
@RequestMapping("/prelogout")
@RequiredArgsConstructor
public class PreLogoutController {

    private final ClientRegistrationRepository clientRegistrationRepository;
    private final String postLogoutRedirectUri = "http://localhost:8080/logout";

    @GetMapping
    public String preLogout(HttpServletRequest request, Authentication authentication) {
        if (authentication instanceof OAuth2AuthenticationToken) {
            String registrationId = ((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId();
            URI endSessionEndpoint = getEndSessionEndpoint(registrationId);

            MyOidcUser oidcUser = (MyOidcUser) authentication.getPrincipal();
            UriComponentsBuilder builder = UriComponentsBuilder.fromUri(endSessionEndpoint)
                    .queryParam("id_token_hint", oidcUser.getIdToken().getTokenValue())
                    .queryParam("post_logout_redirect_uri", postLogoutRedirectUri)
                    .queryParam("state", oidcUser.getState());

            return "redirect:" + builder.toUriString();
        }

        return "/main";
    }

    private URI getEndSessionEndpoint(String registrationId) {
        ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
        ClientRegistration.ProviderDetails providerDetails = clientRegistration.getProviderDetails();
        Object endSessionEndpoint = providerDetails.getConfigurationMetadata().get("end_session_endpoint");
        return URI.create(endSessionEndpoint.toString());
    }
}
```

起動して確認してみると相変わらず動きは変わりませんが、ログアウトでOPから返ってきた時のリクエストパラメータにstateが付与されているのが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982759/2b1ecbcb-7a22-c0d2-69af-3ab90a953fcc.png)
どうやらKeycloakはstateに関しては確定仕様に沿って送ったらちゃんと返してくれるようです。検証できてありがたいです。

# あとがき
以上、簡単な作例で失礼いたしましたが、こんな感じでSpring Security + OIDCのログアウトについては実装できるのかなと思います。
以前にログアウト実装した時は手探りで四苦八苦しながら進めてたりしたのですが、今回改めて書き出してみると思いの外すっきり書けたなと思いました。
ログアウト実装の参考になれば幸いです。