PKCE とは
PKCE をご存知でしょうか? これは、今から一年ほど前の 2015 年 9 月に RFC 7636 (Proof Key for Code Exchange by OAuth Public Clients) として公開された仕様を指しています。認可コード横取り攻撃 (authorization code interception attack) への対策として策定されました。
細かい条件は幾つかありますが、スマートフォンで OAuth クライアントを作る場合は、クライアント側も認可サーバー側もこの仕様の実装が強く推奨されます。これを実装しておかないと、悪意のあるアプリケーションに認可コードを横取りされてしまい、結果、悪意のあるアプリケーションがアクセストークンを取得できてしまいます。
この仕様自体のちょっとした解説は、「OAuth & OpenID Connect 関連仕様まとめ」の「7. 認可コード横取り攻撃への対抗策 (RFC 7636)」に書いてあります。また、英語ではありますが、懇切丁寧なイラスト付きで解説したものが Authlete 社* のウェブサイト上にあります。
Proof Key for Code Exchange (RFC 7636)
https://www.authlete.com/developers/pkce/
ですので、ここでは、PKCE の仕様自体の解説は省略します。
* この記事の筆者は Authlete 社の創業者です。
サーバー側実装でやること
簡単に言うと、二つです。
一つは、認可エンドポイントにおいて、認可コードフローによるリクエストを処理するとき、生成した認可コードと一緒に、認可リクエストに含まれていた code_challenge
リクエストパラメーターの値と code_challenge_method
リクエストパラメーターの値をデータベースに保存しておくことです。次の図は Authlete 社のウェブサイトからの転載です (以降同様です)。
もう一つは、トークンエンドポイントにおいて、トークンリクエストから code_verifier
リクエストパラメーターの値を取り出し、その code_verifier
とデータベースに保存しておいた code_challenge_method
を用いて code_challenge
を計算し、その計算した値がデータベースに保存しておいた code_challenge
の値と等しいかどうかチェックすることです。
Authlete の実装コード
認可エンドポイントでの処理は、単に code_challenge
と code_challenge_method
の値をデータベースに保存するだけの処理なので、特にコードを見ても面白いことはありません。知見としては、「PKCE をサポートする認可サーバーは、認可コードを管理するデータベーステーブルに、code_challenge
と code_challenge_method
を保存するカラムを追加する必要がある」、ということくらいです。
というわけで、Authlete 全体のコードは企業秘密ですが、トークンエンドポイントにおける code_verifier
の検証部分だけ公開します! (まぁ、たいしたことはやっていないです)
private void validatePKCE(AuthorizationCodeEntity acEntity)
{
// See RFC 7636 (Proof Key for Code Exchange) for details.
// Get the value of 'code_challenge' which was contained in
// the authorization request.
String challenge = acEntity.getCodeChallenge();
// The authorization request did not contain 'code_challenge'.
if (challenge == null)
{
// If the token request contains 'code_verifier' even if it does
// not have to do it.
if (extractFromParameters("code_verifier") != null)
{
// The token request contains 'code_verifier' although its
// corresponding authorization request did not contain
// 'code_challenge'.
throw toException(invalid_grant, A050317);
}
// PKCE is not used.
return;
}
// If the authorization request contained 'code_challenge', the
// token request must contain 'code_verifier'. Extract the value
// of 'code_verifier' from the token request.
String verifier = extractFromParameters(
"code_verifier", invalid_grant, A050312, A050313, A050314);
// If the format of the value of 'code_verifier' is invalid.
if (CODE_VERIFIER_PATTERN.matcher(verifier).matches() == false)
{
// The value of 'code_verifier' in the token request does not
// conform to the format defined in RFC 7636.
throw toException(invalid_grant, A050316);
}
// Compute the challenge using the verifier
String computedChallenge = computeChallenge(acEntity, verifier);
if (challenge.equals(computedChallenge))
{
// OK. The presented code_verifier is valid.
return;
}
// The code challenge value computed with 'code_verifier' is different
// from 'code_challenge' contained in the authorization request.
throw toException(invalid_grant, A050315);
}
private String computeChallenge(AuthorizationCodeEntity acEntity, String verifier)
{
CodeChallengeMethod method = acEntity.getCodeChallengeMethod();
// This should not happen, but just in case.
if (method == null)
{
// Use 'plain' as the default value required by RFC 7636.
method = CodeChallengeMethod.PLAIN;
}
switch (method)
{
case PLAIN:
// code_verifier
return verifier;
case S256:
// BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
return computeChallengeS256(verifier);
default:
// The value of code_challenge_method extracted from the database is not supported.
throw toException(server_error, A050102);
}
}
private String computeChallengeS256(String verifier)
{
// BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
// SHA256
byte[] hash = Digest.getInstanceSHA256().update(verifier).digest();
// BASE64URL
return SecurityUtils.encode(hash);
}
なお、computeChallengeS256(String)
メソッド内で使われている Digest
クラスですが、これは、私がオープンソースで公開している nv-digest というライブラリに含まれています。ダイジェスト計算を簡単に実行するためのライブラリです。このライブラリを使うと、SHA-256 ダイジェスト値を得るのも、ワンライナーで済みます。
byte[] hash = Digest.getInstanceSHA256().update(verifier).digest();
クライアント側実装でやること
こちらも二つです。
一つは、認可リクエストを投げる際、43 ~ 128 文字の code_verifier
を生成して、使用する code_challenge_method
のロジック (plain
もしくは S256
) で code_challenge
を計算し、code_challenge_method
と code_challenge
をリクエストに含めることです。
もう一つは、トークンリクエストを投げる際、code_verifier
をリクエストに含めることです。
クライアント側の実装例
code_verifier
から code_challenge
を計算するロジックは、上記に挙げた Authlete の実装コードと同じなのですが、ここでは特に次の二つを紹介しようと思います。
これらは、OAuth 2.0 & OpenID Connect サーバーとやりとりするためのクライアント SDK であり、諸々のベストプラクティスを含んでいます。PKCE もサポートされているとのことなので、参考になると思います。
code_challenge_method=S256
時の code_challenge
計算ロジックを自作した場合は、その動作確認として、
code_verifier
が dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
のときに code_challenge
の値が E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
になるかどうかを確認するという手があります。これらの値は、RFC 7636 の「Appendix B. Example for the S256 code_challenge_method」に例として挙げられているものです。
さいごに
認可サーバーを選ぶときは、PKCE をサポートしているものを選びましょう。Authlete は PKCE をサポートしていますが、さらに、「code_challenge
を伴わない認可リクエストを全て拒否する」という動作を設定で選択することも可能です。
以上です!
追記:2019 年 7 月 8 日
コードチャレンジメソッド S256 を強制するための設定項目を Authlete 2.1 に追加しました。PKCE を常に要求する設定と組み合わせれば、常に code_challenge_method=S256
を要求できるようになります。