Authorization Code Flow with PKCE Clientの実装（Node.js）

# はじめに
[RFC7636 PKCE（Proof Key for Code Exchange by OAuth Public Clients）](https://tools.ietf.org/html/draft-ietf-oauth-v2-1-00)は認可コード横取り攻撃の対策(authorization code interception attack)として策定された仕様です。

また、PKCEは認可コード横取り攻撃にかかわらず、OAuth2.0におけるCSRFの対策としても機能します。
なお、stateによるCSRF対策ではクライアントが検証を実施するのに対して、PKCEによるCSRF対策では認可サーバーが検証を実施するという違いがあります。（PKCEを利用した場合でも、クライアントが固定値などの脆弱なcode_challenge、code_verifierを利用するとCSRFに対して脆弱になるためクライアントに対策の責務がないわけではありません）

PKCEはスマートフォンアプリなどのPublic Clientにおいて利用が強く推奨されている仕様ですが、現在策定中の[The OAuth 2.1 Authorization Framework (OAuth2.1)](https://tools.ietf.org/html/draft-ietf-oauth-v2-1-00)においてクライアントの種別にかかわらず認可コードフローを利用する場合は必須になっており、今後利用する機会が増える可能性があります。

一方、PKCEの仕様説明は多く存在しているものの、実装に関する説明はまだ少ないと感じています。
そこで、本記事ではNode.jsのopenid-clientというパッケージを元に、PKCEを利用したクライアントの実装について（個人的なメモとして）記載します。

# 準備

今回は認可サーバーとしてGoogleを利用します。
このため、事前に以下の[Google APIのダッシュボード](https://console.developers.google.com/)で必要な情報を登録する必要があります。
すでに利用可能なクライアントがある場合などはこちらの手順を省略してください。

## プロジェクトの作成

まず、画面上部の``[プロジェクトの選択]``を押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/f3cb3cb0-8dc2-69f7-7d3c-97521369cca0.png)

今回新たにプロジェクトを作成する場合は``[新しいプロジェクト]``を押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/2c2f5ce7-8f37-eb13-d206-cc60304c0122.png)

プロジェクト名などを設定します。ここではデフォルトで設定されているMy Project 1958をプロジェクト名として設定しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/e4b8c6e7-3010-ff29-221c-60091726705e.png)
以上でプロジェクトの作成は終了です。続いてOAuth同意画面の設定をおこないます。

## OAuth同意画面

User Typeを設定します。動作確認に利用する想定のユーザーに適したUser Typeを指定して``[作成]``を押下してください。ここでは外部を選択しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/8b07f738-d8e6-6d7d-6f9d-fdbb109f0d5c.png)

アプリ名、ユーザーサポートメール、デベロッパーの連絡先情報などの必須項目を入力します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/8b93002d-ce8a-f02d-c559-df3b68806d9a.png)


続いて``[スコープを追加または削除]``を押下し、適切なscopeを設定しておきます。ここではopenidを追加しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/57209a28-b403-defe-72f9-90b38739c63d.png)

次のテストユーザーは必要に応じて設定し、順に画面を進めてOAuth同意画面の設定を終了してください。（今回は未設定）

## 認証情報

``[認証情報]>[認証情報を作成]>[OAuthクライアントID]``を順に押下してクライアントに関する情報を登録していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/147e9306-e803-89cd-3503-d9888e9960d8.png)

アプリケーションの種類や名前、リダイレクトURIを登録します。
今回はあくまでPKCEの動作確認をすることが目的であるため、アプリケーションの種類をウェブアプリケーション、承認済みのリダイレクト URIを``http://localhost:3000/cb``として作成します。
<b>実際のサービスで利用する際は、必ずそのアプリケーションに適したアプリケーションの種類を選択し、リダイレクトURIもHTTPSのURIを設定してください。</b>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/07b920b9-f757-62ac-ffe6-7240caeb7bc6.png)

``[作成]``を押下すると、クライアントIDとクライアントシークレットが表示されます。
こちらは、後ほど利用するのでメモしておいてください。（後から確認することもできます）

以上で認可サーバーにおけるクライアントの設定は終了です。
ここまででお気づきかもしれませんが、PKCEを利用する場合において特別な設定は必要ありません。

# 実装
今回は[node openid-client](https://www.npmjs.com/package/openid-client)というパッケージを利用して実装します。
node openid-clientは[Certified OpenID Connect Implementations](https://openid.net/developers/certified/)に記載されており、一定の信頼が可能なライブラリであるという判断で今回利用しました。
その他の言語で利用するライブラリを選定する際にも[Certified OpenID Connect Implementations](https://openid.net/developers/certified/)はきっと役に立つでしょう。

実装については、[openid-client](https://www.npmjs.com/package/openid-client)のREADMEに記載のコードをもとに説明しますが、[サンプルコード](https://github.com/kg0r0/google-pkce-client)も用意しているので細かい設定値などはこちらを参照いただければ幸いです。

なお、今回の利用環境については次の通りです。

- OS: macOS Catalina
- Node.js: 12.0
- openid-client: 4.3.1

## インストール 

以下のコマンドでインストールします。なお、サポートしているNode.jsのバージョンは適宜以下のリンクから確認してください。

```bash

$ npm install openid-client
```
(参考: https://www.npmjs.com/package/openid-client#install)

## OpenID Connect Discovery

[OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)で定義されているとおり``/.well-known/openid-configuration``のURLからissuerに関する情報を取得する設定をおこないます。

```javascript

const { Issuer } = require('openid-client');
Issuer.discover('https://accounts.google.com/.well-known/openid-configuration') // => Promise
  .then(function (googleIssuer) {
    console.log('Discovered issuer %s %O', googleIssuer.issuer, googleIssuer.metadata);
  });
```

(引用元: https://github.com/panva/node-openid-client#quick-start)

Googleであれば``https://accounts.google.com/.well-known/openid-configuration``から情報を取得することができます。

## 認可コードフロー

クライアントに関する情報を次の通り設定していきます。
トークンエンドポイントにおける認証で利用するclient_id、client_secretとして先ほど事前準備でメモしておいた値を指定します。
また、オープンリダイレクト対策としてリダイレクトURIの検証が実施されるため、redirect_urisに先ほど事前準備で登録したURIを指定します。

```javascript

const client = new googleIssuer.Client({
  client_id: '<CLIENT_ID>',
  client_secret: '<CLIENT_SECRET>',
  redirect_uris: ['http://localhost:3000/cb'],
  response_types: ['code'],
  // id_token_signed_response_alg (default "RS256")
  // token_endpoint_auth_method (default "client_secret_basic")
}); // => Client
```

(引用元: https://github.com/panva/node-openid-client#authorization-code-flow)

### 認可リクエスト

認可リクエストのパラメーターを指定します。
PKCEでは認可リクエストのパラメーターとしてcode_challengeとcode_challenge_methodを含める必要があります。
openid-clientでは[generators](https://github.com/panva/node-openid-client/blob/master/docs/README.md#generators)というstate、nonce、などのパラメーターを生成してくれる便利なユーティリティが用意されています。
PKCEで利用するcode_challengeやcode_verifierといったパラメーターも生成可能なのでこちらを利用します。

```javascript

const { generators } = require('openid-client');
const code_verifier = generators.codeVerifier();
// store the code_verifier in your framework's session mechanism, if it is a cookie based solution
// it should be httpOnly (not readable by javascript) and encrypted.
 
const code_challenge = generators.codeChallenge(code_verifier);
const nonce = generators.nonce();
const state = generators.state(); 

client.authorizationUrl({
  scope: 'openid',
  state,
  nonce,
  code_challenge,
  code_challenge_method: 'S256',
});
```

(引用元: https://github.com/panva/node-openid-client#authorization-code-flow)

### トークンリクエスト

認可レスポンスを受け取り、トークンリクエストを送信する部分に関する設定をします。
ここで、``client.callback()``の第三引数のオブジェクトとしてcode_verifierを含めることにより、code_verifierがトークンリクエストにおいて送信されます。

```javascript

const params = client.callbackParams(req);
client.callback('http://localhost:3000/cb', params, { code_verifier, state, nonce }) // => Promise
  .then(function (tokenSet) {
    console.log('received and validated tokens %j', tokenSet);
    console.log('validated ID Token claims %j', tokenSet.claims());
  });
```

これにより、認可サーバーは認可リクエストで送信したcode_challenge_methodおよびcode_challengeとトークンリクエストで送信されたcode_verifierをもとに認可コードが横取りされていないか検証可能になります。

# 動作確認

今回、[実装済みのコード](https://github.com/kg0r0/google-pkce-client)を用意しているので、こちらをもとに動作確認しています。


```bash
$ git clone https://github.com/kg0r0/google-pkce-client.git
$ cd google-pkce-client
$ npm install
$ node index.js
```

動作確認は``client.callback()``の第三引数のcode_verifierの値ををデタラメな文字列に変更することなどで確認できます。
ただ、今回はせっかくなのでローカルプロキシ (Burp Suite) を利用してなるべく攻撃者視点っぽく確認してみます。


## 認可コード横取り攻撃対策

PKCEが認可コード横取り攻撃の対策としてうまく動作しているか確認します。

まず、http://localhost:3000 にアクセスします。
すると、次のとおりGoogleにリダイレクトされるのでログインをしてみます。なお、このときのURLをみると``code_challenge``と``code_challenge_method``がパラメーターとして送信されていることが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/ee8b211e-0043-805f-68bc-7c9512027e45.png)

ログイン処理を進めていくと、``/signin/oauth/consent``のレスポンスとしてLocationヘッダに認可コードが返ってきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/b5118042-38cb-d7c8-44fd-909cbf101d72.png)

ここで、悪意ある第三者のクライアントに認可コードが横取りされたと想定します。
攻撃者は横取りした認可コードを利用して、以下のようなリクエストをトークンエンドポイントに対して送信し、トークンの取得を試みます。

```bash

$ curl \
-d "client_id=<CLIENT_ID>" \
-d "client_secret=<CLIENT_SECRET>" \
-d "redirect_uri=http://localhost:3000/cb" \
-d "grant_type=authorization_code" \
-d "code=4%2F0AY0e-g6f5-HCQi5pqliTGbY5cXBa9uWkyucNO7g2VMRuVMamBwFEWE2296NLoVNKVfYrUQ" \
https://oauth2.googleapis.com/token

{
  "error": "invalid_grant",
  "error_description": "Missing code verifier."
}
```

無事、``Missing code verifier``と表示され、横取りした認可コードからトークンは取得できなかったようです。
ここまでの攻撃者が取得した認可サーバーからのレスポンスからはcode verifierを特定することは不可能なので、横取りした認可コードの悪用ができないことがわかりました。

## CSRF対策

続いて、PKCEがCSRF対策になっているか確認します。
事前にPKCEがCSRF対策になっていることを確認するため、``client.authorizationURL()``および`` client.callback()``の引数からそのほかのCSRF対策となるパラメーターの設定を除外しておきます。

```javascript

client.authorizationUrl({
      scope: 'openid',
      code_challenge,
      code_challenge_method: 'S256',
});

. . . 


client.callback(redirect_uri, params, { code_verifier });
```

上記の設定変更ができたら http://localhost:3000 にアクセスし、認可サーバーにリダイレクトされるのでログイン処理を進めていきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/ee8b211e-0043-805f-68bc-7c9512027e45.png)

ログイン処理を進めていくと、先ほど同様に``/signin/oauth/consent``のレスポンスとしてLocationヘッダが返ってきます。
ここで、Locationヘッダに設定されているURLを保存しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/233ec3f9-86b6-5ffd-4660-388368f59532.png)

次に、被害者として攻撃者が配置した上記のURLを踏んでしまったと仮定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213529/da37d4f0-2c03-2921-825b-4a7ab55bdfd8.png)

ここで、``Missing code verifier``と表示され、CSRFが成功しませんでした。
よって、PKCEがCSRFの対策としても機能していることがわかりました。

# おわりに

今回はPKCEを利用したクライアントの実装例と動作確認を紹介しました。
PKCEの詳しい解説は多数記事が存在していると思うので、それらを参照いただければ幸いです。

# 参考
- https://tools.ietf.org/html/rfc7636
- https://tools.ietf.org/html/draft-ietf-oauth-v2-1-00
- https://www.npmjs.com/package/openid-client
- https://openid.net/developers/certified/