SilhouetteからPAC4Jに乗り換えてついでにOpenID Connectを使う

# はじめに
　Play Frameworkで使えるアカウント認証ライブラリ[Silhouette](https://github.com/mohiva/play-silhouette)が、2021年9月に今後一切のアップデートを停止し、アーカイブされてしまいました。メインでコミットされていた方曰く[^1]、
> Hi, sorry for the current situation. In the last year I had no time to maintain the project. Therefore I have decided to step back as a maintainer and archive the project. The project can be forked by the community if there is an interest. The documentation is still available under the URL: https://silhouette.readme.io

面倒を見る時間がなくなってしまったそうです。仕方ありませんね。

　Silhouetteにはとてもお世話になっていたため、早々に認証周りの実装を換装していかなければなりません。そこで換装先として注目したのが、JAVA製セキュリティエンジンのPAC4Jです。

https://www.pac4j.org/index.html

PAC4JはPlayを含む様々なフレームワークで利用できる上、公式にサンプルアプリをオープンソースで公開しており、非常にとっつきやすい製品です。対応している認証方法も多く、組織的にアカウント認証を一元管理する動きがあったこともあり、せっかくなのでOpenID Connect(OIDC)も使ってみることにしました。そんなわけで、Silhouette+DB認証からPAC4J+OIDC認証に切り替えた話をします。


# 方針
　大きく分けて2段階の作業を行います。

- SilhouetteのモジュールをPAC4Jのモジュールに置き換える
- OIDCの各種設定

　モジュール置き換えにあたっては、OIDCの導入によりアーキテクチャが変わります。この点について触れておきましょう。
　従来のシステムでは、ユーザ情報を内部のDBに持ち、認証認可処理もSilhouetteを利用しながら内部に実装していました。
<img width=400 alt="DBauth" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316162/2a41939d-3471-d523-013b-710c201a664e.png">[^2]
OIDCを使うと、下図のようにユーザ情報や認証認可処理を全て外部サービスに託す形になります。外部サービスへのリダイレクトや、ユーザ情報からのセッション生成をPAC4Jが担います。
<img width=600 alt="OIDCauth" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316162/0994b99e-0c9f-a084-e68b-5823e0d6fbc3.png">[^3]
OIDCにPAC4Jがどのように絡むかは公式の説明図がわかりやすいので、以下に引用します[^4]。
図でCASと書かれているものは、IDプロバイダ兼認証サーバです。実例としてはGoogleやOktaなどが当てはまります。
![sequence_diagram.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316162/4f410f2a-6868-ad83-8100-31202737863b.jpeg)
プロセスを書き出すと次の通りです。

1. ユーザが、認証が必要なページにアクセスする
1. 外部サイト(CAS)のログインページにリダイレクトする
1. ユーザが、外部サイトにログインする
1. 外部サイトから返された認証結果を受け取る
1. 認証結果からセッションを生成する
1. ページを表示する

上記の1,3以外、ユーザ情報の参照・認証・認可は実装せずとも全てPAC4Jがやってくれます。ログインフォームも要りません。
逆に言うと、Silhouetteを使って実装していたユーザ情報の参照・認証・認可機能やログインフォームは全て不要となります。

# 実装
環境は次の通りです。

- scala: 2.12.11
- play: 2.8.7
- play-silhouette: 7.0.0
- play-pac4j: 11.0.0-PLAY2.8
- pac4j-http: 5.1.3
- pac4j-oidc: 5.1.3
- scala-guice: 4.2.6

## モジュールの置き換え
　モジュールを置き換える必要があるのは、要認証ページの表示部分です。例として、Silhouetteを利用した次のようなコントローラを書き換えてみます。ただし、`CustomEnv`は`com.mohiva.play.silhouette.api.Env`を継承したトレイト、`UserService`は`com.mohiva.play.silhouette.api.repositories`の実装クラス、`ErrorHandler`は`com.mohiva.play.silhouette.api.actions.SecuredErrorHandler`の実装クラスとします。

```controllers/ProtectedPageController.scala
import com.google.inject.Inject
import models.CustomEnv
import services.UserService
import services.ErrorHandler
import play.api.mvc.{AbstractController, Action, AnyContent, ControllerComponents}

class ProtectedPageController @Inject() (
    cc: ControllerComponents,
    silhouette: Silhouette[CustomEnv],
    userService: UserService,
    errorHandler: ErrorHandler
)(implicit val ec: ExecutionContext) extends AbstractController(cc) {
  def index: Action[AnyContent] = silhouette.SecuredAction(userService)(errorHandler) { implicit req =>
    val user = req.identity
    Ok(views.html.protected(user))
  }
}
```
これを次のようにPAC4Jを利用した形に置き換えます。

```controllers/ProtectedPageController.scala
import com.google.inject.Inject
import org.pac4j.core.profile.UserProfile
import org.pac4j.play.scala.{Security, SecurityComponents}
import play.api.mvc.{Action, AnyContent}

class ProtectedPageController @Inject() (
    val controllerComponents: SecurityComponents,
)(implicit val ec: ExecutionContext) extends Security[UserProfile] {
  def index: Action[AnyContent] = Secure("OidcClient") { implicit req =>
    val user = req.profiles
    Ok(views.html.protected(user))
  }
}
```
`user`の型がSilhouetteとPAC4Jで異なっていますが、ここでは問題にしていません。実際に置き換える際はうまく吸収してください。

　ポイントは2点です。

- `val controllerComponents: SecurityComponents`
    - 親クラスがPAC4Jの`Security[UserProfile]`になったことで登場しています。必ずこの形で書かないとoverrideされないため怒られてしまいます。気をつけましょう。
- `Secure("OidcClient")`
    - 親クラスのメソッドで、この中で認証認可処理が行われます。方針の章で挙げたOIDCのプロセスで言うと、2番から5番までのステップを全て実行します。`OidcClient`はこの後登場するので、字面だけ覚えておきましょう。

ここまでに説明した通り、上記の置き換えで認証認可処理が全て終わってしまいました。よって手元にユーザ情報を保持する必要がなくなりましたので、従来利用していたSilhouetteモジュールをはじめ、ユーザ情報のCRUD処理やデータモデルも全て不要となります。削除しておきましょう。

## OIDCの各種設定
### 外部サイト(CAS)側の設定
CASの公式ドキュメント等に従い、アプリケーションの登録を済ませましょう。
例としてOktaのドキュメントを掲載しておきます。

https://help.okta.com/en/prod/Content/Topics/Apps/Apps_App_Integration_Wizard_OIDC.htm

次の3つの設定値はアプリケーションの設定にも加えるため、控えておきましょう。

- Sign-in redirect URIs（自分で設定）
- ClientId（自動生成）
- ClientSecret（自動生成）

### アプリケーションの設定
　この辺りは最早公式のデモリポジトリ（[GitHub](https://github.com/pac4j/play-pac4j-scala-demo)）を見た方が早いのですが、念の為公式リポジトリの中から要点を掻い摘んでお話しします。

　まずは外部サイト(CAS)の設定を反映させましょう。`clientId`と`clientSecret`はそのまま記入します。

```application.conf
oidc {
  baseUrl = {yourAppHost}
  discoveryUri = "https://{yourCasDomain}/.well-known/openid-configuration"
  clientId = {clientId}
  clientSecret = {clientSecret}
}
```
　`baseUrl`にはアプリケーションのホストを記入してください。`discoveryUri`とは、末尾が`.well-known/openid-configuration`であるようなURIで、CASのプロバイダ情報を返してくれるエンドポイントです。お使いのCASに固有のエンドポイントが決まっているため、調べて記入しましょう。

　続いてPAC4Jの設定を行うモジュールを作成します。

```modules/Pac4JModule.scala
import com.google.inject.{AbstractModule, Provides}
import net.codingwell.scalaguice.ScalaModule
import org.pac4j.core.client.Clients
import org.pac4j.core.client.direct.AnonymousClient
import org.pac4j.core.config.Config
import org.pac4j.core.context.session.SessionStore
import org.pac4j.oidc.client.OidcClient
import org.pac4j.oidc.config.OidcConfiguration
import org.pac4j.play.http.PlayHttpActionAdapter
import org.pac4j.play.{CallbackController, LogoutController}
import org.pac4j.play.scala.{DefaultSecurityComponents, SecurityComponents}
import org.pac4j.play.store.{PlayCookieSessionStore, ShiroAesDataEncrypter}
import play.api.{Configuration, Environment}

class Pac4JModule(environment: Environment, configuration: Configuration) extends AbstractModule with ScalaModule {

  val baseUrl: String = configuration.get[String]("oidc.baseUrl")

  override def configure(): Unit = {
    val playSecretKey    = configuration.get[String]("play.http.secret.key").substring(0, 16)
    val dataEncrypter    = new ShiroAesDataEncrypter(playSecretKey.getBytes)
    val playSessionStore = new PlayCookieSessionStore(dataEncrypter)
    bind(classOf[SessionStore]).toInstance(playSessionStore)
    bind(classOf[SecurityComponents]).to(classOf[DefaultSecurityComponents])

    // CASログイン後の処理(callback)を行うコントローラの設定
    val callbackController = new CallbackController()
    callbackController.setDefaultUrl("/")
    bind(classOf[CallbackController]).toInstance(callbackController)

    // CASログアウト後の処理を行うコントローラの設定
    val logoutController = new LogoutController()
    logoutController.setDefaultUrl("/")
    bind(classOf[LogoutController]).toInstance(logoutController)
  }

  @Provides
  def provideOidcClient: OidcClient = {
    val oidcConfiguration = new OidcConfiguration()
    oidcConfiguration.setDiscoveryURI(configuration.get[String]("oidc.discoveryUri"))
    oidcConfiguration.setClientId(configuration.get[String]("oidc.clientId"))
    oidcConfiguration.setSecret(configuration.get[String]("oidc.clientSecret"))
    val oidcClient = new OidcClient(oidcConfiguration)
    oidcClient
  }

  @Provides
  def provideConfig(oidcClient: OidcClient): Config = {
    // CASで設定した Sign-in redirect URI が生成・設定される
    // この場合は {baseUrl}/callback?client_name=OidcClient というURIになる
    val clients = new Clients(baseUrl + "/callback", oidcClient)
    val config  = new Config(clients)
    config.setHttpActionAdapter(new PlayHttpActionAdapter())
    config
  }
}
```
DIを有効にするため、設定にも追記しておきましょう。

```application.conf
modules {
    enabled += "modules.Pac4JModule"
  }
```
ここに現れる`OidcClient`が、モジュールの置き換えで出てきた`Secure("OidcClient")`の引数を決めています。`OidcClient`を使って認証認可処理を通す、という意味だったのですね。
　また、順番が前後して申し訳ないのですが、Sign-in redirect URIsも実際にはここで決まっています。思っていたのと違う！となったら、CASの設定をアプリケーションに合わせましょう。

　最後にエンドポイントを設定します。

```conf/routes
GET   /index                         @controllers.ProtectedPageController.index()
GET   /callback                      @org.pac4j.play.CallbackController.callback(request: Request)
POST  /callback                      @org.pac4j.play.CallbackController.callback(request: Request)
GET   /logout                        @org.pac4j.play.LogoutController.logout(request: Request)
```
アクセスしたいページ以外は全てPAC4Jに向けます。ありがたい。

# おわりに
　SilhouetteやPAC4Jの話よりもOpenID Connectの話の方が長いので、もしかしてOpenID Connectの話がしたかったのでは？とお思いかもしれませんが、その通りです。ありがとうございました。


[^1]: Gitter, mohiva/play-silhouette, https://gitter.im/mohiva/play-silhouette?at=61450c78fd7409696e189e6a, viewed at 2021-11-26
[^2]: This work is made of a composition of "[User icon](https://www.iconfinder.com/icons/285645/user_icon)" by [Paomedia](https://www.iconfinder.com/paomedia) and "[Computer, essential, app icon](https://www.iconfinder.com/icons/2124260/computer_essential_app_icon)" by [Just Icon](https://www.iconfinder.com/justicon) and "[Id, user icon](https://www.iconfinder.com/icons/285641/id_user_icon)" by [Paomedia](https://www.iconfinder.com/paomedia) and "[Database icon](https://www.iconfinder.com/icons/185097/database_icon)" by [Webalys](https://www.iconfinder.com/webalys), used under [CC BY](https://creativecommons.org/licenses/by/3.0/), this work is licensed under [CC BY](https://creativecommons.org/licenses/by/3.0/) by ocome.
[^3]: This work is made of a composition of "[User icon](https://www.iconfinder.com/icons/285645/user_icon)" by [Paomedia](https://www.iconfinder.com/paomedia) and "[Computer, essential, app icon](https://www.iconfinder.com/icons/2124260/computer_essential_app_icon)" by [Just Icon](https://www.iconfinder.com/justicon) and "[Id, user icon](https://www.iconfinder.com/icons/285641/id_user_icon)" by [Paomedia](https://www.iconfinder.com/paomedia) and "[Key, lock, password, project, protect icon](https://www.iconfinder.com/icons/653272/key_lock_password_project_protect_protection_safe_icon) by [Artem White](https://www.iconfinder.com/ArtWhite), used under [CC BY](https://creativecommons.org/licenses/by/3.0/), this work is licensed under [CC BY](https://creativecommons.org/licenses/by/3.0/) by ocome.
[^4]: PAC4J, https://www.pac4j.org/docs/authentication-flows.html, viewed at 2021-11-26