はじめに
ではAuthorization Code Flowを体感するためにNode.jsのExpressでデモ用のWebアプリを作成して、実際にアクセストークンを取得しAPI実行までをやったみた。
その際には、
※今回はあえてCSRF対策やPKCE対策に必要になるパラメータを省略している。CSRF対策やPKCE対策については今後記事を執筆予定。
と書いたように、推奨されているセキュリティ対策のための実装をしていなかったので、今回はCSRF対策の実装の続きでPKCE対策の実装をやってみたいと思う。CSRF対策については以下の記事を参照。
※本記事中で筆者の理解に誤りがあればご指摘頂けると幸いです。
Authorization Code FlowのPKCE攻撃とは?
(通常のWebアプリの場合にはブラウザ上で全て完結するので問題にならないが)ネイティブアプリ(スマホ)の場合、ブラウザで完結しない(ブラウザから離れスマホのOSを介してアプリが動作する)ため、そこに脆弱性が生まれ、認可コード(つまり最終的にはアクセストークン)の横取りがされてしまう事。
具体的にどういうことか?を図示しながら見ていく。
が、その前に認可フローを実行する場面を具体的に考えた方が分かりやすいと思うので、認可フローを実施する場面について整理する。登場するものとしては以下。
- ストレージサービス
クラウドストレージ(Google Driveのようなもの) - Webアプリケーション
クラウドストレージのサービスと連携している文書・図表作成ができるアプリ(クラウドストレージと連携する便利機能のおかげでユーザは自分のクラウドストレージ上に随時ファイルを保存できるイメージ)
連携する時には、OAuth2.0の認可フローでアクセストークンを払い受け、クラウドストレージにアクセスする事で連携するものとする
このような状況下で、適切にWebアプリケーションが利用されている時の状態としては以下のような感じ。ユーザはWebアプリにクラウドストレージにアクセスする権限(認可)を与えて、文書・図表作成アプリはその認可で得たアクセストークンでユーザに代わりクラウドストレージにファイルを保存するという流れになる。

通常のパターン
まず通常のパターン(攻撃を受けなかった場合)の認可フローを見てみいく。通常の場合では以下のように意図したアプリに認可(アクセス権限)を与える事ができている。

攻撃を受け、認可コード(アクセストークン)を横取りされてしまうパターン
攻撃を受けると以下の図のように認可コード(つまり最終的にはアクセストークン)が簡単に奪われてしまう。図示した中で起きている事としては、
- 攻撃者が自分が作成した悪意のあるアプリを何らかの方法でユーザーにダウンロードさせる
- (利用者は悪意のあるアプリをインストールしたとは気付かずにそのまま)正規のフローで文書・図表作成アプリに対し認可フローを実行する
- 認可サーバから認可レスポンスがブラウザに返ってくるが、これをスマホの特性を悪用される事で、文書・図表作成アプリが起動すべきだが攻撃者の悪意のあるアプリが起動する
- 悪意のあるアプリは手に入れたアクセストークン発行用の一時コード(横取りした認可コード)をそのまま使い、認可サーバのトークンエンドポイントへのリクエストをし、まんまとアクセストークンを手に入れる
これが起きてしまうのは、
- 認可サーバに対する認可リクエスト(認可エンドポイントへのリクエスト)
- 認可サーバのトークンエンドポイントへのリクエスト
が同一のクライアントからのものであると認可サーバ側が判別する仕組みがないため。認可サーバ側で2つのリクエストを同一であると判別するための仕組み・仕様がProof Key for Code Exchange by OAuth Public Clients(RFC7636)。
※OAuth2.0のAuthorization Code Grantでstateが必要な理由を理解する 実際の実装もやってみたで取り上げたCSRF攻撃は、クライアント側で認可レスポンスが自分が送った認可リクエストのレスポンスであるか?を確かめない事で起きるもので、PKCE攻撃とは本質的に異なる(PKCE攻撃は認可サーバ側でリクエストの正当性を確かめないと起きるもの)。
- 参考:PKCE: 認可コード横取り攻撃対策のために OAuth サーバーとクライアントが実装すべきこと
- 参考:7. 認可コード横取り攻撃への対抗策 (RFC 7636)
- 参考:AUTHLETE Proof Key for Code Exchange (RFC 7636)
PKCE攻撃対策のための実装をAuthorization Code Flowに組み込む
OAuth2.0のAuthorization Code Grantでstateが必要な理由を理解する 実際の実装もやってみたで見た時同様に、実装としてはシンプル。CSRF攻撃対策でstateを追加したが、今度はcode_verifierからcode_challenge_methodに基づきcode_challengeを生成し、code_challengeとcode_challenge_methodを認可リクエストに追加し、トークンエンドポイントへのリクエスト時にcode_verifierをパラメータに追加するだけでいい(詳細は以下で見ていく)。
// 省略
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);
}
});
// 省略
ソースコード全体は以下。
上記の実装について一部補足する。
const codeVerifier = generators.codeVerifier();, const codeChallenge = generators.codeChallenge(codeVerifier);
ここで認可リクエスト時に追加するcode_challengeの値と、トークンエンドポイントへのリクエスト時に追加するcode_verifierを生成している。PKCE攻撃対策のためのこれらの値の計算方法については、RFC7636の4.1. Client Creates a Code Verifierや4.2. Client Creates the Code Challengeに書かれているが、実際に計算する場合はライブラリに頼った方が楽なので、今回はopenid-clientのgeneratorsを利用してそれぞれの値を算出している。
※openid-clientのAuthorization Code FlowにPKCE攻撃対策のための実装例もある。
session.codeVerifier = codeVerifier;
トークンエンドポイントへのリクエスト時にはcode_verifierをパラメータとして追加する必要があるので、今回はセッションに保存し、リダイレクト時のコールバックでそれを取り出すような実装をしている。
※※Expressにおけるセッションの実現方法はNode.js Expressでのセッションの実装を参照。
code_challenge_method: 'S256'
RFC7636の4.3. Client Sends the Code Challenge with the Authorization Requestを見ると、code_challenge_methodについては以下のように書かれており任意の項目になっているが、認可サーバによっては必須かつplainだとエラーになる場合もあるようである。具体的には認可リクエストでの PKCE 利用における "S256" 指定の強制化などがそれ。
OPTIONAL, defaults to "plain" if not present in the request. Code verifier transformation method is "S256" or "plain".(OPTIONAL、リクエストに存在しない場合、デフォルトは "plain "である。コードベリファイアの変換方式は "S256" または "plain" です。)
今回はライブラリの制約上、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の場合にはディスカバリードキュメントに書かれているが以下の通り)。
{
...
"code_challenge_methods_supported": [
"plain",
"S256"
]
}
params.append('code_verifier', session.codeVerifier);
トークンエンドポイントへのリクエスト時のパラメータにcode_verifierを渡す事で、認可サーバーは、認可リクエスト時に渡されたcode_challengeと比較して問題ないか?のチェックを行い、問題なければアクセストークンを返すという動きをするようになる。
これでPKCE攻撃の対策ができた事になる。
まとめ
Authorization Code Grant(Flow)でPKCE攻撃対策のために必要な対応について理解を深める事ができた。次はAuthorization Code Grant(Flow)ではなく、OpenId ConnectのメインとなるIDトークンを発行するImplicit flowについて、クライアント側の実装を通じて理解を深めてみたいと思う(OpenId ConnectはOAuth2.0を内包しているが、OpenId Connectとして新しく作られた部分はIDトークンを発行する仕組みなので、その意味でメインという言い方をしている)。
