Help us understand the problem. What is going on with this article?

PKCE: 認可コード横取り攻撃対策のために OAuth サーバーとクライアントが実装すべきこと

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 社のウェブサイトからの転載です (以降同様です)。

pkce_authorization_response.png

もう一つは、トークンエンドポイントにおいて、トークンリクエストから code_verifier リクエストパラメーターの値を取り出し、その code_verifier とデータベースに保存しておいた code_challenge_method を用いて code_challenge を計算し、その計算した値がデータベースに保存しておいた code_challenge の値と等しいかどうかチェックすることです。

pkce_token_response.png

Authlete の実装コード

認可エンドポイントでの処理は、単に code_challengecode_challenge_method の値をデータベースに保存するだけの処理なので、特にコードを見ても面白いことはありません。知見としては、「PKCE をサポートする認可サーバーは、認可コードを管理するデータベーステーブルに、code_challengecode_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_methodcode_challenge をリクエストに含めることです。

pkce_authorization_request.png

もう一つは、トークンリクエストを投げる際、code_verifier をリクエストに含めることです。

pkce_token_request.png

クライアント側の実装例

code_verifier から code_challenge を計算するロジックは、上記に挙げた Authlete の実装コードと同じなのですが、ここでは特に次の二つを紹介しようと思います。

  1. AppAuth for Android
  2. AppAuth for iOS and macOS

これらは、OAuth 2.0 & OpenID Connect サーバーとやりとりするためのクライアント SDK であり、諸々のベストプラクティスを含んでいます。PKCE もサポートされているとのことなので、参考になると思います。

code_challenge_method=S256 時の code_challenge 計算ロジックを自作した場合は、その動作確認として、
code_verifierdBjftJeZ4CVP-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 を要求できるようになります。
pkce_s256_required.png

TakahikoKawasaki
株式会社 Authlete の共同創業者。プログラマー兼代表取締役社長。
https://www.authlete.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした