"悪い" OpenID Connect OP を作る

ちょっとテスト用にOpenID Connect OPを実装する必要があったので、ついでに"異常な"ふるまいをするOpenID Connect OPを作ってみた。

- デモ: https://oidcbadop-client.glitch.me/ (Glitchでできているので起動までちょっと待つ必要がある)
- GitHub: https://github.com/okuoku/oidcbadop/

やっぱり `userinfo` が無いとあんまり役に立たないな。。今回作ったものは、GitLabとかGiteaのようなOpenID Connectをユーザ認証に使えるツールでの認証に使えない。これらのツールはuserinfoエンドポイントからemail等をfetchしなおしてアカウントの作成に使用しているため。

# デモ

これどうやってデモを用意するかも難しいな。。

## デモサイトでアクセスしてみる

[デモURL](https://oidcbadop-client.glitch.me/) にアクセスすると、Glitchの起動画面の後、エラーの種類を選ぶページが出る。

![SnapCrab_NoName_2019-11-4_19-16-30_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67446/21019209-1006-e862-70a3-be4240d898c5.png)

ここで選べるエラーは今のところ3種類で、

1. `Valid` - **誰でも** 特定のユーザ(`dummy`)として正常に認証できる。
2. `Expired` - 常に期限切れのJWTを返す
3. `Algnone` - 認証部分をカットした正常なJWTを返す

Validをクリックすると `id_token` を含むJSONが、それ以外をクリックするとクライアントで使っている `openid-client` のエラーが直接表示される。

id_tokenは例えば JWT.io ( https://jwt.io/ ) でデコードして中身を見ることができる。

## 他所のRPから使ってみる

Note: **このOPを登録したRPは自動的に脆弱になるので注意** 。世間のRPのエラー時のふるまいは [OpenID Connect、OAuthプロバイダの調査](https://qiita.com/okuoku/items/7a84c516e79826b406e0) の記事で調べてフォローするつもり。

まだuserinfoを実装していないので、大抵のOpenID Connect RPでは正常にテストできない。

純粋にID tokenの取得のみを行うRPであれば使うことができる。例えば https://openidconnect.net/ に必要事項を入力して`Expired`や`Algnone`をテストしてみると、

```
THERE WAS AN ERROR VERIFYING YOUR TOKEN. TRY AGAIN.
```

と表示されて、正常に却下されることがわかる。

![SnapCrab_NoName_2019-11-4_17-50-25_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67446/0b40ac86-7841-451b-8e36-0ae09452001f.png)

他のRPで今回実装したOPを使うには、以下のパラメタを使う:

- `client_id` : `testing`
- `client_secret` : `testing`
- ディスカバリURI: https://oidcbadop.glitch.me/op/.well-known/openid-configuration

ディスカバリを実装していないRPや、OAuth2では、

- Authorization endpoint: https://oidcbadop.glitch.me/op/auth
- Token endpoint: https://oidcbadop.glitch.me/op/token

の設定も追加で必要かもしれない。

(もっとも、今回の"悪い"OPは `id_token` の内容が悪いだけなので、OAuth2では単に"常に認証が通るOP"というだけになり悪さを発揮できない。)

# 実装したエラーケース

OpenID Connect自体にはコンフォーマンステストは存在するものの https://openid.net/wordpress-content/uploads/2018/06/OpenID-Connect-Conformance-Profiles.pdf 、基本的には仕様を正確に実装していれば安全なため、明示的にいじわるなOP / RPを用意したテストを実施しているわけではないようだ。

例えば、今回で言えば `Expired` はJWTのライブラリ側で担保されるべき内容で、コンフォーマンスではこれは前提にしている。( `exp` JWT claimに関するテストはコンフォーマンステストには無い)

今のところエラーケースは2個しか実装していない。もっと充実させたい気持ちはあるが。。

## 常に期限切れのJWTを返す

- https://github.com/okuoku/oidcbadop/blob/3c93b420a730a2f4213fc9a9a18a3c75b6022b32/index.js#L117

```javascript
    if(ctx.oidc.route == "token"){
        let token = ctx.response.body;
        const valid_token = token.id_token;
        if(valid_token){
            let q = Jose.JWT.decode(valid_token);
            switch(q.modify){
                case "expire":
                    q.exp = q.iat - 120;
                    token.id_token = 
                        Jose.JWT.sign(q, keystore.get({alg: "RS256"}));
                    break;
```

Expireを `iat` claimよりも120秒前に設定して署名しなおす。

ちょっと気になったのは、 `openid-client` ライブラリは **発行日時(iat)が期限切れ時刻(exp)よりも未来であるTokenを受け入れる** ことで、JWTが期限切れかどうかを判別する際に発行日時の情報は使用していないということになる。 `openid-client` はデフォルトでは時刻誤差 `clock_tolerance` を 0 秒に設定しているため、設定を変更しない限りはこの現象は発生しない。

例えば、 `clock_tolerance` パラメタが十分に大きければ、 [発行時点では既に期限切れとなっているようなJWT](https://jwt.io/#debugger-io?token=eyJraWQiOiJNUW84UWR2SnpxWm5iNlp1RzcxckRnRXhKWmpxYVZYaU94UU1ITnlqemRvIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJkdW1teSIsIm1vZGlmeSI6ImV4cGlyZSIsImF1dGhfdGltZSI6MTU3Mjg1MjY2OCwiYXRfaGFzaCI6IlhySHMtM0dJRWgtWXJXcE53NFVQVVEiLCJhdWQiOiJ0ZXN0aW5nIiwiZXhwIjoxNTcyODUyNjY3LCJpYXQiOjE1NzI4NTI2NjgsImlzcyI6Imh0dHBzOi8vb2lkY2JhZG9wLmdsaXRjaC5tZS9vcCJ9.m3EtcxJR-BEtIgctNN4P1QRt8uxszoXah4siwtUA1NPnM3YQgUnvW45yVkprSdtHCc3XMdM31VBreXxYCgboBP28KtZMVpEBtdao4K4EPo6CkoU67BZSOB5KBg17S9n9eY1rW88U3dEdgXGorNz26GkSVEBU10UCPyrAuMhlBYXiD1mztSRzt5cbIx7Ks-bObDoXLR9mm3OGC4mYu2QJn3dU1QaisWLT3wskesZz-KA0jSeIwNUgkrWJN76MAH65w6y6677Rr6G0c3CCzn5jUOrfISDGpUpPRovXCQ0g7r_uFJhu_rsmv8EFKZuZNyRU9hOWJEvBxkTmFOuO1nSHpe8P31Lqcve4Jn_3d38SvLVgh8ZBL9RAuW6d2cA3bQdPg7UfymSV-ZYfNBIRb_enicUdrm2R2mM43WdmbhE3uVmxs_9KR2f6zTiUQ5n_C5BhomhT_MJjZRp4nAy-O-9UJK-Y5ff3kVLFlJoiX4VHsT7ANo5JttOdn6kP4f3B-0GirKDoCHqvVbyb5e55g5VHbtvCgVjhyrW917PQz1hv8yOKTu9Fb_VIF6mqz8YJWHqWZHozsZe8FnOktSDgrw6gVgg1Bbt_t35dBtU9WpIkkGM5wq9kKDHt65qXP4j3eZ-wZZxhz3DiEJ-Icntn4-844O8irg_pTBXTZ42YHgCKYYI&publicKey=-----BEGIN%20PUBLIC%20KEY-----%0AMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA4rQK9xu8P%2BvvNksZNRJ7%0AhCYA%2Bk9z4hUm9xEpZS%2FGym6Nn4iHP5BbYYtJeJ%2F%2BWKHtZqsaWawJ6%2F7uqVU%2B%2B4Fz%0Ai%2FeE087sAf4BfSaBkbvC8qWD02eHDipVIEP9zBLu73rAJEpAiOcZvZZokJGUJTMu%0AtP8mquDD%2FXUWD2%2FYc%2Bt58Zlf%2Bdv%2F3ctgjgLBxkKoSjaRTrypMowuO7YqczLdMnV0%0AcvK%2BPjiP8HIlCskjkZwoyROcLl1mMYySmSfj6XMWpEjhaF9NUQc9738uXLDjuysp%0A8WgeAaCX8g5rDXbu0ij9HbwCnjkkVzjgUsZ94PkG%2FxmBJv7KiQBHAloYOVvJCOG7%0ASR6lvcVg2SckxxbktF5UIOd8fOzlbGTjbGPqXkDnGrc5x6KQaZB0w0Vt4rlMIOay%0Ac96uRq2I3gSrFERofkufGKduvEf%2FEYHquSzb6pqPdhoHn7PrnKgULW%2Bh1Z5yqFiF%0A4i7PNwgALkjAyS2jnkJGzNq4TX1F285d4xWL7E%2BudtaDitEHYYSuSMT8F6SvqJh4%0AIKBX3BfvAZxZigoOoBW2vwKb1hKN0IZeTKPQAGjjasC%2FXbjRKRCi3IW0RPh1jdO5%0Au3Bt6foF5HFPZ3LqpY0AZTJd3HezvR2G5d6L%2BLiKajMFKjt%2FwAzzZfMvFxAD9LgI%0AwszG%2FmW2PAWrxTuxowfnXX0CAwEAAQ%3D%3D%0A-----END%20PUBLIC%20KEY-----%0A) を受け入れてしまう。JWTやOpenID Connect仕様としてはこれが正しい挙動に見えるが、暗黙に `iat` < `exp` となるような仮定を持っていると不味いかもしれない。

## 署名部分の無いJWTを返す

- https://github.com/okuoku/oidcbadop/blob/3c93b420a730a2f4213fc9a9a18a3c75b6022b32/index.js#L122

```javascript
function base64jwt(obj){
    const str = JSON.stringify(obj);
    const orig = Buffer.from(str).toString("base64");
    return orig.replace(/=/g,"");
}
...
                case "algnone":
                    token.id_token =
                        base64jwt({typ: "JWT", alg: "none"})
                        +
                        "."
                        +
                        base64jwt(q)
                        +
                        ".";
                    break;
```

JWTを手作りしているだけ。つまり、JSONをstringfyし、ヘッダ `{typ: "JWT", alg: "none"}` を付ける。

このようなJWTは署名部分がまるごと存在しないため、 [ピリオドで終わるJWT](https://jwt.io/#debugger-io?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzdWIiOiJkdW1teSIsIm1vZGlmeSI6ImFsZ25vbmUiLCJhdXRoX3RpbWUiOjE1NzI4NjUzNTQsImF0X2hhc2giOiJWRENuODJ2LVhud3V3YVJyYnBwNElRIiwiYXVkIjoidGVzdGluZyIsImV4cCI6MTU3Mjg2ODk1NSwiaWF0IjoxNTcyODY1MzU1LCJpc3MiOiJodHRwczovL29pZGNiYWRvcC5nbGl0Y2gubWUvb3AifQ.) となる。

デモで`Algnone`を選択した場合にはエラーメッセージとして

```
unexpected JWT alg received, expected RS256, got: none
```

が表示されるように、今回のOPでは `none` を https://oidcbadop.glitch.me/op/.well-known/openid-configuration の `id_token_signing_alg_values_supported` に含めていないためRPサイドで弾かれることになる。

常識的なOPは`none`は含めていないものの、`HS256`は含めていることがそれなりに有るため、 [JOSEは、絶対に避けるべき悪い標準規格である](https://postd.cc/jwt-json-web-tokens-is-bad-standard-that-everyone-should-avoid/) で言われているようなバグは依然踏める可能性がある。もっとも、これらの問題は既にとても有名になっていて、その辺の実装を見る限りではこの問題を持っているものは見つけられなかった。

## `oidc-provider` でリダイレクトURIの検証を無効にする

明示的なエラーではないが、今回 `oidc-provider` でリダイレクトURIの検証を無効にしている。いわゆるOpen redirectorに相当するのでこれをやる簡単な方法は用意されていないが、プロトタイプに含まれる `redirectUriAllowed` を直接overrideして常に `true` を返却するようにしてしまった。

- https://github.com/okuoku/oidcbadop/blob/3c93b420a730a2f4213fc9a9a18a3c75b6022b32/index.js#L144

```javascript
// Disable client redirect uri check
oidc.Client.prototype.redirectUriAllowed = (bogus) => true;
```

リダイレクトURIはRP側が設定するもので、このような挙動をするOPが世間に存在してもそれを信頼しなければ良いだけなので大きな問題にはならない。(もちろん真剣なOPであればリダイレクトURIの検証は必須になる。)

# 何の役に立つのか / 感想

個人的にはコレを "エラーが発生したらどう見えるのか" の調査に使っている。スクリーンショットを収集するとか、エラーログが届くかどうかチェックするとか。。例えば、 `expired` ケースはサーバ間の時刻差ができている時に現実的に発生する可能性もあるため、事前に起こしておくことで訓練ができると考えている。

エラーケースだけではなく、正解ケースも返せるものにしておいた方が良いかもしれない。

`oidc-provider` はちょっとこのような目的に使うには大きすぎるので、もうちょっと簡単なライブラリに纏められないものかと思っている。。passport.jsとのブリッジに特化したものがあっても良いんじゃないか的な。