SinatraでOpenID Connect Providerを利用した認証を利用する

# はじめに

SinatraでDex(dexidp/dex, OpenID Connect Provider)を利用した認証を利用するための検証用コードを作成してみました。

初稿では参考にした docs.openathens.net の記事と重複しないようdiff出力の差分で解説していましたが、読み替えしてみて自分でも補足がないと読むのが難しかった点などがあったので少し見直しました。

またopenapi-generatorが生成するsinatraのスケルトンコードも変更する可能性がありますので、差分だけではpatchが当たらない可能性があります。
そのため、コードはできるだけ全体を見通せる分量を掲載するようにしています。

[blog.s21g.comの記事](http://blog.s21g.com/articles/1635)では、Sinatraで利用可能な認証モジュールのリストがありますが、現在はメンテナンスされていないライブラリも含めて掲載されています。

最終的に、[docs.openathens.netの記事](http://docs.openathens.net/display/public/OAAccess/Ruby+OpenID+Connect+example)のクラスを利用しました。

OpenAPIの本来の目的で利用する場合には、API-Keyを個別に発行してAuthorization: Bearerの利用をクライアントに要求するのが作法だと思います。今回はopenapi-generatorをWebアプリケーションのスケルトンコード生成器として使用していますので、対話的なクライアントアクセスのためにOIDCを利用しています。

# References

* http://blog.s21g.com/articles/1635
* https://docs.openathens.net/display/public/OAAccess/Ruby+OpenID+Connect+example

# 環境

記事を見直すタイミングで環境を更新しました。

* OpenAPI Generator: 5.2.1
* OpenID Connect Provider: Dex (https://github.com/dexidp/dex)
* OS: Ubuntu 20.04 LTS
* ruby: 2.7.0 (Ubuntu付属のパッケージ) and 3.0.0 (Dockerコンテナ)
* ruby-bundler: 2.1.4 (Ubuntu付属のパッケージ)

DexはTLSによる接続を受け付けるように構成しています。デフォルトの構成ではOpenID Connect ProviderへのアクセスにTLS接続を仮定するので、OIDC Serverへのアクセスに **http://** を指定するとエラーになります。

openapi-generator-cliを利用して、定義ファイル(openapi.yaml)からcode/ディレクトリにスケルトンコードを生成します。

```yaml:openapi.yaml
openapi: 3.0.1
info:
  contact:
    email: yasu@yasundial.org
    name: Yasuhiro ABE
    url: https://www.yasundial.org/
  description: This is the test site
  title: The test client of the OpenID Connect
  version: 0.1.0
servers:
- url: https://localhost:8080/
paths:
  /:
    get:
      description: The landing page.
      responses:
        200:
          description: If authenticated, it shows user information.
  /protected:
    get:
      description: Protected contents only authenticated user can view.
      responses:
        200:
          description: If authenticated, it shows user information.
        303:
          description: If not-authenticated, then redirecting to the /login.
  /login:
    get:
      description: The OIDC login entrypoint.
      responses:
        200:
          description: Show the login form.
    post:
      description: OIDC login page
      responses:
        303:
          description: Redirect to the dex oids provider
  /callback:
    get:
      description: |
        The callback endpoint from an OIDC server.
        e.g. /callback?code=xxxx&amp;state=xxxx
      parameters:
      - explode: false
        in: path
        name: code
        required: true
        schema:
          type: string
        style: simple
      - explode: false
        in: path
        name: state
        required: true
        schema:
          type: string
        style: simple
      responses:
        303:
          description: Redirect to the top landing page or somewhere.
  /logout:
    get:
      description: The OIDC logout endpoint to clear the session object.
      responses:
        200:
          description: stay on this page.
        303:
          description: redirect to /.
components:
  schemas: {}
```

```bash
$ openapi-generator-cli generate -g ruby-sinatra -o code -i openapi.yaml
```

code/以下の各ファイルの差分は次のようになっています。実際の作業では実装を追加したコードはcode.impl/ディレクトリに作成しています。

最終的な成果物は、GitHubとDockerHubで公開しています。

* [GitHub - YasuhiroABE/docker-sinatra-oidc-sample](https://github.com/YasuhiroABE/docker-sinatra-oidc-sample)
* [DockerHub - yasuhiroabe/sinatra-oidc-sample](https://hub.docker.com/repository/docker/yasuhiroabe/sinatra-oidc-sample)

# docs.openathens.net を参考にする上での注意点

Module名は、MyOIDCProvider に変更しています。

この他、以前の記事を参考にしたところ、そのままでは動かない点がいくつかありました。

## OIDC Scopeの指定

将来的に **groups** を利用したいので、MyOIDCProviderモジュールの中での scope変数に :groups を追加しています。

```ruby:該当のコード
        def auth_uri(nonce)
            authz_url = init_client.authorization_uri(
                scope: [:profile, :email, :groups],
                state: nonce,
                nonce: nonce
            )
        end
```

## Session管理

sessionオブジェクト(session[:origin])にredirect元のURLを記録していますが、ここではRedisを利用しています。

Gemfileで **redis-rack** を指定しています。

また、あらかじめredisをdockerから起動しています。

```bash:redisの起動
sudo docker run -it --rm -d \                                                                                 
        -p 6379:6379 \                                
        --name redis redis:latest
```

##  JSON::JWK::Set::KidNotFound エラーへの対応

openathens.netのブログで紹介されているコードを利用した時に、openid-configurationの結果を``@disco``オブジェクトにキャッシュしている部分でエラーの原因となることがあります。

アプリケーションがOIDC Providerから渡された認証情報を検証しようとする時にjwks_uriをキャッシュした``@disco``の情報を利用することがあります。

これは時間の経過を考慮していないので、Provider側が公開鍵情報を更新したタイミングで利用できなくなります。そのタイミングはProvider側の実装次第なので発生しないか、比較的短期間に発生するか予測することはできません。

myoidcprovider.rbのコードはこういった状況を考慮していないので、このままでは正常に動作しない可能性がありました。

ユーザーのログインが短時間に集中することが見込まれれば``@disco``によるキャッシュはネットワークトラフィックとProvider側の負荷軽減に役立ちますが、そうでもなければ検証時の例外をトラップして更新処理と再検証のロジックを埋め込む必要があります。

公開しているサンプルコードでは単純に``@disco``オブジェクトの利用を止めることにしました。

```diff:myoidcprovider.rbの修正箇所
diff --git a/_docker/myoidcprovider.rb b/_docker/myoidcprovider.rb
index a523072..7a374e9 100644
--- a/_docker/myoidcprovider.rb
+++ b/_docker/myoidcprovider.rb
@@ -63,7 +63,8 @@ module MyOIDCProvider
     end
     
     def discover
-      @disco ||= OpenIDConnect::Discovery::Provider::Config.discover! @provider_uri
+      OpenIDConnect::Discovery::Provider::Config.discover! @provider_uri
+      ## Removed "@disco ||=" by YasuhiroABE <yasu@yasundial.org>
     end
   end
 end
```