Spring SecurityのOpenID Connectの設定方法

# はじめに
Spring Securityを使っているアプリで、OpenID Connect(以下OIDC）を別のものに移行する機会があり、その際にOIDC周りの設定について見直しました。そのときに得られてた知見のまとめです。

OIDCの仕様についてはここでは特に解説はしません。
Auth屋さんのスライドや書籍が参考になりますので、気になる方はこちらもどうぞ。

https://speakerdeck.com/authyasan/shi-yang-gadu-meruyouninaruoauth2-dot-0-openid-connect-ru-men

https://techbookfest.org/organization/67930001

# まとめ

| 項目 | 概要 |
|:-:|:-:|
| application.yamlでの設定できること  |  OIDCの接続などの基本的なことの設定 |
| JWTの署名のアルゴリズム  | ID tokenの署名のアルゴリズム  |
| SecurityFilterChain  | セキュリテイの設定をするためのオブジェクト  |
|  OIDC user service |  ID tokenからログインユーザを生成する部分 |
|  Authentication success handler |  認証処理が成功したときの処理 |
|  Authentivation failure handler | 認証処理が失敗したときの処理  |


# application.ymlで設定できること

yamlから設定できる内容の詳細は公式ドキュメントあります。

https://spring.pleiades.io/spring-security/reference/servlet/oauth2/login/core.html

ほとんどの場合、OIDCの認可コードフローを使うので、その場合は下記のようになります。

```yaml:application.yaml
spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            client-id: okta-client-id
            client-secret: okta-client-secret
            authorization-grant-type: authorization_code
            redirect-url: {baseUrl}/login/oauth2/code/{registrationId}
            scope:
              - openid
              - profile
        provider:
          okta:	
            authorization-uri: https://your-subdomain.oktapreview.com/oauth2/v1/authorize
            token-uri: https://your-subdomain.oktapreview.com/oauth2/v1/token
            user-info-uri: https://your-subdomain.oktapreview.com/oauth2/v1/userinfo
            user-name-attribute: sub
            jwk-set-uri: https://your-subdomain.oktapreview.com/oauth2/v1/keys
```

ログイン方法を複数提供したい場合は、registartion,providerの組を、複数設定できます。

```yaml:
spring:
  security:
    oauth2:
      client:
        registration:
          okta:
             ...
          google:
             ...
         provider:
            okta:
               ...
            google:   
```

## well-known endpoint

OIDCの仕様で、`/.well-known/openid-configuration` から取得できます。

https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig

例としてyahooのものを一部抜粋したものをあげます。
https://auth.login.yahoo.co.jp/yconnect/v2/.well-known/openid-configuration

```json
{
  "issuer": "https://auth.login.yahoo.co.jp/yconnect/v2",
  "authorization_endpoint": "https://auth.login.yahoo.co.jp/yconnect/v2/authorization",
  "token_endpoint": "https://auth.login.yahoo.co.jp/yconnect/v2/token",
  "userinfo_endpoint": "https://userinfo.yahooapis.jp/yconnect/v2/attribute",
  "jwks_uri": "https://auth.login.yahoo.co.jp/yconnect/v2/jwks",
}  
```

ここにあるissuerのURLを、providerのissuer-uriを設定すれば、このwell-known endpointから取得して、必要な設定をしてくれます。provider側の設定を減らすことができます。

```yaml
provider:
   yahoo:
     issuer-uri: https://auth.login.yahoo.co.jp/yconnect/v2
```

また、well-known endpointがend_session_endpointをもっている場合、`OidcClientInitiatedLogoutSuccessHandler`を設定することで、OIDC側も含めてログアウト処理を実装できます。

https://spring.pleiades.io/spring-security/reference/servlet/oauth2/login/logout.html

注意するのは、issuer-uriにアクセスできない場合は、このやり方はつかえません。
spring securityのissuer-uriへのアクセスはRestTemplateが使われていますが、隠蔽されているので、この部分だけにproxyを適用することが難しいです。

## max_age

OIDC側の仕様で、ログインしてから再認証が必要になる経過時間を設定できます。

>max_age
>OPTIONAL. Authentication Age の最大値. End-User が OP によって明示的に認証されてからの経過時間の最大許容値 (秒). もし経過時間がこの値より大きい場合, OP は End-User を明示的に再認証しなければならない (MUST). (max_age リクエストパラメータは OpenID 2.0 PAPE [OpenID.PAPE] の max_auth_age リクエストパラメータに相当する) max_age が指定された場合, 発行される ID Token は auth_time Claim を含まねばならない (MUST).

https://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html

この仕組みによって、自分たちのアプリのセッションタイムアウトまでの時間を制御できます。
一番簡単な使い方は、authorization-uriでの指定です。

```yaml
provider:
   yahoo:
     authorization-url: https://auth.login.yahoo.co.jp/yconnect/v2/authorization?max_age=3600
```


## prompt

OIDCの仕様で、再認証やアカウントの切り替え・確認を要求するためのパラメタです。
`prompt=login`とすれば、認証を再度要求できます。決済やアカウント情報の変更など、高いセキュリテイが要求される場面で使えます。

>prompt
OPTIONAL. Authorization Server が End-User に再認証および同意を再度要求するかどうか指定するための, スペース区切りの ASCII 文字列のリスト. 以下の値が定義されている.
>login
Authorization Server は End-User を再認証するべきである (SHOULD). 再認証が不可能な場合はエラーを返す (MUST). 典型的なエラーコードは login_required である.


https://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html

こちらも、authorization-uriでの設定が一番簡単なやり方です。

```yaml
provider:
   yahoo:
     authorization-url: https://auth.login.yahoo.co.jp/yconnect/v2/authorization?prompt=login
```
    

# JWTの署名のアルゴリズム

ID tokenは署名付きJWT(JWS)で、署名のアルゴリズムの設定方法です。
ECDSAでキーペアをつくることが標準になりつつあるので、ES256を設定したい場合は下記のようにすればいいです。

```java
@Bean
JwtDecoderFactory<ClientRegistartion> decoderFactory() {
   OidcIdTokenDecoderFactory decoderFactory = new OidcIdTokenFactory();
   decoderFactory.setJwsAlgorithmResolver(clientRegistration -> SignatureAlgorithm.ES256);
   return decoderFactory;
}
```

https://spring.pleiades.io/spring-security/reference/servlet/oauth2/login/advanced.html#oauth2login-advanced-idtoken-verify

# SecurityFilterChain

requestに対して、どのようなセキュリテイの処理をするかを決められるオブジェクトです。

https://spring.pleiades.io/spring-security/reference/servlet/architecture.html#servlet-securityfilterchain

OIDCの場合、下記のようになります。

```java
@Bean
SecurityFilterChain oidc(HttpSecurity http,
        SampleUserService userService,
        SampleAuthenticationSuccessHandler successHandler,
        SampleAuthenticationFailuerHandler failureHandler) {
   http.authorizeHttpRequest(authz -> 
           authz.anyRequest().authenticated())
        .oauth2Login(login -> login
             .userInfoEndPoint(userinfo -> userinfo.oidcUserService(userService))
             .successHandler(successHandler)
             .failureHandler(failureHandler)
         ); 
    return http.build();
}
```

## Multiple SecurityFilterChain

URLごとにセキュリティの設定を変えたい場合は、SecurityFilterChainを複数定義しつつ、HttpSecurity#securityMatcherでURLパターンを指定すればいいです

```java
@Bean
SecurityFilterChain okta(...) {
http.securityMatcher("/okta/**")
       ....;

}

@Bean
SecurityFilterChain google(...) {
http.securityMatcher("/google/**") 
       ...;

}

```

FYI

https://zenn.dev/masatoshi_tada/articles/be9932c38b68c4

https://docs.spring.io/spring-security/reference/servlet/configuration/java.html#_securityfilterchain_endpoints

https://github.com/spring-projects/spring-security/issues/12864#issuecomment-1473770406




# OIDC user service

ID tokenからログインユーザを生成する部分です。Spring Securityでデフォルトの挙動を実現するためのサービスクラスがあります。ID tokenに加えて、自分たちのDBの情報も付け加えるなど、追加の処理がいる場合は、`OAuth2UserService<OidcUserRequest, OidcUser>` を実装したクラスを作成すればいいです。また、ログインユーザを表すオブジェクトは、OidcUserを実装していればよいです。

デフォルトで問題ない場合でも、委譲の形でラップしておくとその後に変更があっても対応しやすいです。

```Java
public class SampleOidcUser implements OidcUser, Serializable {

   private final OidcUser oidcUser;

   public SampleOidcUser(OidcUser oidcUser) {
      this.oidcUser = oidcUser;
   }

   ...

   // OidcUserに必要な実装はthis.oidcUserのものをそのまま使う

   @Override
   public Map<String, Object> getAttributes() {
       return oidcUser.getAttributes();
   }

   ...

}
```

```Java
// DIしやすくするためBeanに登録
@Bean
OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
    return new OidcUserService();
}
```

```Java
@Service
public class SampleUserService implements OAuth2UserService<OidcUserRequest, OidcUser> {

   private final OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService;

   // 実際にはOidcUserServiceのインスタンスを使う想定
   // 変更に強くするために、型の指定はゆるめている
   public SampleUserService(OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService) {
         this.oidcUserService = oidcService;
   }

   @Override
   public OidcUser loadUser(OidcUserRequest userRequest) {
       // ID tokenを取得して、OidcUserを作成
       OidcUser oidcUser = oidcUserService.loadUser(userRequest);
       // 必要ならここで追加処理をいれる
       ...
       // 作成したログインユーザクラスを返す
       return new SampleOidcUser(oidcUser);
   }
   
}
```


# Authentication success handler

認証成功後の処理です。Spring securityのデフォルトの挙動では、認証前に最初にアクセスしたURLへリダイレクトします。

SavedRequestAwareAuthenticationSuccessHandlerがそのためのhandlerです。

https://spring.pleiades.io/spring-security/site/docs/current/api/org/springframework/security/web/authentication/SavedRequestAwareAuthenticationSuccessHandler.html

この挙動を維持したまま、追加の処理をいれたい場合、委譲の形でAuthticationSuccessHandlerを実装するといいでしょう。

```Java
public class SampleAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private final SavedRequestAwareAuthenticationSuccessHandler delegatedHandler;

    ...

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        // 必要ならここに追加処理をいれる 
        delegatedHandler.onAuthentication(request, response, authentication);
    }

}
```

## RequestCache

SavedRequestAwareAuthenticationSuccessHandlerは、RequestCacheに最初のrequestを保持しています。
この保持したものをつかって、最初のURLへのリダイレクトを実現しています。そこの設定を変えたい場合は、下記のようにします。

```Java
@Bean
RequestCache requetCache() {
   HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
   requestCache.setSessionAttrName("sample_saved_request")
   return requetCache; 
}

@Bean
SavedRequestAwareAuthenticationSuccessHandler(RequestCache requestCache) {
   SavedRequestAwareAuthenticationSuccessHandler handler = new SavedRequestAwareAuthenticationSuccessHandler();
   handler.setRequestCache(requestCache);
   return handler;
}
```



# Authentication failure handler

認証に失敗したときの処理です。認証処理中に、AuthenticationExceptionが投げられると、このhandlerに入ってきます。自分たちの処理で例外をなげてfailure handlerでまとめて処理させたい場合は、AuthenticationExceptionや、それを継承した例外を投げればいいです。
ただし、Success handler内でAuthenticationExceptionを投げても、failure handlerには入ってこないです。ログインページや、専用のエラーページにリダイレクトさせることが多いです。

SimpleUrlAuthenticationFailureHandlerを使えば簡単にできます。

https://spring.pleiades.io/spring-security/reference/api/java/org/springframework/security/web/authentication/SimpleUrlAuthenticationFailureHandler.html

このクラスを継承してもいいですが、委譲とDIの形にしておくと変更やテストがやりやすいです。

```java
public class SampleAuthenticationFailureHandler implements AuthenticationFailureHandler {

   private final SimpleUrlAuthenticationFailureHandler delegatedHandler;

   ...

   
  @Override
  public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
      // 必要ならここに追加の処理をいれる
      delegatedHandler.onAuthenticationFailure(request, response, exception);
  }
  
}
```