OAuth2.0のAuthorization Code GrantでPKCE（認可コード横取り攻撃）対策について理解する　実際に実装もやってみた

## はじめに
https://qiita.com/yuta-katayama-23/items/b074e85504fc05ac76dc

https://qiita.com/yuta-katayama-23/items/4214114a21c11ec4f934

ではAuthorization Code Flowを体感するためにNode.jsのExpressでデモ用のWebアプリを作成して、実際にアクセストークンを取得しAPI実行までをやったみた。

その際には、

> ※今回はあえてCSRF対策やPKCE対策に必要になるパラメータを省略している。CSRF対策やPKCE対策については今後記事を執筆予定。

と書いたように、推奨されているセキュリティ対策のための実装をしていなかったので、今回はCSRF対策の実装の続きでPKCE対策の実装をやってみたいと思う。CSRF対策については以下の記事を参照。

https://qiita.com/yuta-katayama-23/items/020169b66d1abe242b37

※本記事中で筆者の理解に誤りがあればご指摘頂けると幸いです。

## Authorization Code FlowのPKCE攻撃とは？
（通常のWebアプリの場合にはブラウザ上で全て完結するので問題にならないが）ネイティブアプリ（スマホ）の場合、ブラウザで完結しない（ブラウザから離れスマホのOSを介してアプリが動作する）ため、そこに脆弱性が生まれ、認可コード（つまり最終的にはアクセストークン）の横取りがされてしまう事。

具体的にどういうことか？を図示しながら見ていく。

が、その前に認可フローを実行する場面を具体的に考えた方が分かりやすいと思うので、認可フローを実施する場面について整理する。登場するものとしては以下。

- ストレージサービス<br>クラウドストレージ（Google Driveのようなもの）
- Webアプリケーション<br>クラウドストレージのサービスと連携している文書・図表作成ができるアプリ（クラウドストレージと連携する便利機能のおかげでユーザは自分のクラウドストレージ上に随時ファイルを保存できるイメージ）<br>連携する時には、OAuth2.0の認可フローでアクセストークンを払い受け、クラウドストレージにアクセスする事で連携するものとする

このような状況下で、適切にWebアプリケーションが利用されている時の状態としては以下のような感じ。ユーザはWebアプリにクラウドストレージにアクセスする権限（認可）を与えて、文書・図表作成アプリはその認可で得たアクセストークンでユーザに代わりクラウドストレージにファイルを保存するという流れになる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/974ba6b8-ba59-dde6-9edd-9d5064be49e5.png)

### 通常のパターン
まず通常のパターン（攻撃を受けなかった場合）の認可フローを見てみいく。通常の場合では以下のように意図したアプリに認可（アクセス権限）を与える事ができている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/cfb6c760-d047-a044-6e86-a871e58edf8a.png)

### 攻撃を受け、認可コード（アクセストークン）を横取りされてしまうパターン
攻撃を受けると以下の図のように認可コード（つまり最終的にはアクセストークン）が簡単に奪われてしまう。図示した中で起きている事としては、

1. 攻撃者が自分が作成した悪意のあるアプリを何らかの方法でユーザーにダウンロードさせる
1. （利用者は悪意のあるアプリをインストールしたとは気付かずにそのまま）正規のフローで文書・図表作成アプリに対し認可フローを実行する
1. 認可サーバから認可レスポンスがブラウザに返ってくるが、これをスマホの特性を悪用される事で、文書・図表作成アプリが起動すべきだが攻撃者の悪意のあるアプリが起動する
1. 悪意のあるアプリは手に入れたアクセストークン発行用の一時コード（横取りした認可コード）をそのまま使い、認可サーバのトークンエンドポイントへのリクエストをし、まんまとアクセストークンを手に入れる

という事。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/ac988bae-bbdb-4a16-431a-8be3162c6ca7.png)

これが起きてしまうのは、

- 認可サーバに対する認可リクエスト（認可エンドポイントへのリクエスト）
- 認可サーバのトークンエンドポイントへのリクエスト

が同一のクライアントからのものであると認可サーバ側が判別する仕組みがないため。認可サーバ側で2つのリクエストを同一であると判別するための仕組み・仕様が[Proof Key for Code Exchange by OAuth Public Clients（RFC7636）](https://datatracker.ietf.org/doc/html/rfc7636.html)。

※[OAuth2.0のAuthorization Code Grantでstateが必要な理由を理解する　実際の実装もやってみた](https://qiita.com/yuta-katayama-23/items/020169b66d1abe242b37)で取り上げたCSRF攻撃は、**クライアント側で**認可レスポンスが自分が送った認可リクエストのレスポンスであるか？を確かめない事で起きるもので、PKCE攻撃とは本質的に異なる（PKCE攻撃は**認可サーバ側で**リクエストの正当性を確かめないと起きるもの）。

- 参考：[PKCE: 認可コード横取り攻撃対策のために OAuth サーバーとクライアントが実装すべきこと](https://qiita.com/TakahikoKawasaki/items/00f333c72ed96c4da659)
- 参考：[7. 認可コード横取り攻撃への対抗策 (RFC 7636)](https://qiita.com/TakahikoKawasaki/items/185d34814eb9f7ac7ef3#7-%E8%AA%8D%E5%8F%AF%E3%82%B3%E3%83%BC%E3%83%89%E6%A8%AA%E5%8F%96%E3%82%8A%E6%94%BB%E6%92%83%E3%81%B8%E3%81%AE%E5%AF%BE%E6%8A%97%E7%AD%96-rfc-7636)
- 参考：[AUTHLETE　Proof Key for Code Exchange (RFC 7636)](https://www.authlete.com/developers/pkce/)

## PKCE攻撃対策のための実装をAuthorization Code Flowに組み込む
[OAuth2.0のAuthorization Code Grantでstateが必要な理由を理解する　実際の実装もやってみた](https://qiita.com/yuta-katayama-23/items/020169b66d1abe242b37)で見た時同様に、実装としてはシンプル。CSRF攻撃対策でstateを追加したが、今度は`code_verifier`から`code_challenge_method`に基づき`code_challenge`を生成し、`code_challenge`と`code_challenge_method`を認可リクエストに追加し、トークンエンドポイントへのリクエスト時に`code_verifier`をパラメータに追加するだけでいい（詳細は以下で見ていく）。

```js
// 省略
import { generators } from 'openid-client';
// 省略

app.get('/begin', async (req, res) => {
	const { session } = req;

	// 省略
	const codeVerifier = generators.codeVerifier();
	const codeChallenge = generators.codeChallenge(codeVerifier);
	session.codeVerifier = codeVerifier;

	const params = {
		// 省略
		code_challenge: codeChallenge,
		code_challenge_method: 'S256'
	};

	res.redirect(
		`${openidConfig.authorization_endpoint}?${qs.stringify(params)}`
	);
});

app.get('/oauth2/callback', async (req, res) => {
	const {
		session,
		query: { code, state }
	} = req;

	// 省略

	try {
		const { data: openidConfig } = await axios.get(config.get('discovery'));

		const params = new URLSearchParams();
		// 省略
		params.append('code_verifier', session.codeVverifier);

		const { data } = await axios.post(openidConfig.token_endpoint, params);
		const camelCaseData = camelcaseKeys(data);

		// 省略
		res.render('./redirect.ejs', camelCaseData);
	} catch (error) {
		res.end(error.message);
	}
});

// 省略
```

ソースコード全体は以下。

https://github.com/yuta-katayama-23/openid-connect-client-demo/commit/d926ca90cb733d3dc24174ee74a340678a9090e0

上記の実装について一部補足する。

### const codeVerifier = generators.codeVerifier();, const codeChallenge = generators.codeChallenge(codeVerifier);
ここで認可リクエスト時に追加する`code_challenge`の値と、トークンエンドポイントへのリクエスト時に追加する`code_verifier`を生成している。PKCE攻撃対策のためのこれらの値の計算方法については、RFC7636の[4.1.  Client Creates a Code Verifier](https://datatracker.ietf.org/doc/html/rfc7636#section-4.1)や[4.2.  Client Creates the Code Challenge](https://datatracker.ietf.org/doc/html/rfc7636#section-4.2)に書かれているが、実際に計算する場合はライブラリに頼った方が楽なので、今回は[openid-client](https://github.com/panva/node-openid-client)の[generators](https://github.com/panva/node-openid-client/blob/main/docs/README.md#generators)を利用してそれぞれの値を算出している。

※openid-clientの[Authorization Code Flow](https://github.com/panva/node-openid-client#authorization-code-flow)にPKCE攻撃対策のための実装例もある。

### session.codeVerifier = codeVerifier;
トークンエンドポイントへのリクエスト時には`code_verifier`をパラメータとして追加する必要があるので、今回はセッションに保存し、リダイレクト時のコールバックでそれを取り出すような実装をしている。

※※Expressにおけるセッションの実現方法は[Node.js Expressでのセッションの実装](https://qiita.com/yuta-katayama-23/items/4ea30b13e8002853402b#nodejs-express%E3%81%A7%E3%81%AE%E3%82%BB%E3%83%83%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%AE%E5%AE%9F%E8%A3%85)を参照。

### code_challenge_method: 'S256'
RFC7636の[4.3.  Client Sends the Code Challenge with the Authorization Request](https://datatracker.ietf.org/doc/html/rfc7636#section-4.3)を見ると、`code_challenge_method`については以下のように書かれており任意の項目になっているが、認可サーバによっては必須かつ`plain`だとエラーになる場合もあるようである。具体的には[認可リクエストでの PKCE 利用における "S256" 指定の強制化](https://kb.authlete.com/ja/s/oauth-and-openid-connect/a/requiring-s256)などがそれ。

> OPTIONAL, defaults to "plain" if not present in the request.  Code verifier transformation method is "S256" or "plain".（OPTIONAL、リクエストに存在しない場合、デフォルトは "plain "である。コードベリファイアの変換方式は "S256" または "plain" です。）

今回は[ライブラリの制約上](https://github.com/panva/node-openid-client/blob/main/docs/README.md#generators)、S256によりcode_challengeが計算されるので、`code_challenge_method: 'S256'`と明示的に指定している（もし指定しないとデフォルトの`plain`が設定されている事になり400エラーになる）。

> Calculates the S256 PKCE code challenge for an arbitrary code verifier.（任意のコード検証機に対する S256 PKCE コードチャレンジを計算します。）

※ちなみに、認可サーバーのディスカバリーエンドポイント（Googleだとhttps://accounts.google.com/.well-known/openid-configuration ）から、`code_challenge_method`として利用可能な方法を参照する事もできる（Googleの場合には[ディスカバリードキュメント](https://developers.google.com/identity/protocols/oauth2/openid-connect#discovery)に書かれているが以下の通り）。

```json
{
  ...
  "code_challenge_methods_supported": [
    "plain",
    "S256"
  ]
}
```

### params.append('code_verifier', session.codeVerifier);
トークンエンドポイントへのリクエスト時のパラメータに`code_verifier`を渡す事で、認可サーバーは、認可リクエスト時に渡された`code_challenge`と比較して問題ないか？のチェックを行い、問題なければアクセストークンを返すという動きをするようになる。

これでPKCE攻撃の対策ができた事になる。

- 参考：[Proof Key for Code Exchange by OAuth Public Clients（RFC7636）](https://datatracker.ietf.org/doc/html/rfc7636.html)

## まとめ
Authorization Code Grant(Flow)でPKCE攻撃対策のために必要な対応について理解を深める事ができた。次はAuthorization Code Grant(Flow)ではなく、OpenId ConnectのメインとなるIDトークンを発行するImplicit flowについて、クライアント側の実装を通じて理解を深めてみたいと思う（OpenId ConnectはOAuth2.0を内包しているが、OpenId Connectとして新しく作られた部分はIDトークンを発行する仕組みなので、その意味でメインという言い方をしている）。