OAuth 2.0の「認可コード横取り」をPKCEで封殺する:エンジニアが知るべき実装の急所
現場でコードを書いていると、「とりあえず動く」認証フローを組むことに注力しがちだが、セキュリティチーフとして一つだけ断言しておく。PKCE(Proof Key for Code Exchange)なしの認可コードフローは、現代のWebアプリケーションにおいて「鍵のかかっていない玄関」と同じだ。
今回は、なぜPKCEが必須なのか、その攻撃手法と、明日から現場で使える実装パターンを叩き込む。
—
1. なぜPKCEが必要なのか:攻撃者の視点から見る脆弱性
従来の認可コードフロー(Authorization Code Grant)は、サーバーサイドアプリケーションでは強力だが、モバイルアプリやSPA(シングルページアプリケーション)では致命的な脆弱性を抱えている。
認可コード横取り攻撃 (Authorization Code Interception Attack) のPoC
攻撃者は、OSの「カスタムURLスキーム」を悪用する。
1. 悪意あるアプリのインストール: 攻撃者が作成した不正アプリをユーザーにインストールさせる。
2. 認可リクエストの傍受: ユーザーが正当なアプリでログインしようとした際、攻撃者のアプリが同じカスタムURLスキーム(例: myapp://callback)を登録し、OSが発行した「認可コード(Code)」を横取りする。
3. トークン交換: 攻撃者はそのコードをIDP(認可サーバー)に提示し、アクセストークンを奪取する。
これを防ぐのがPKCEだ。「最初に送ったリクエストと、後にコードを交換するリクエストが同一のクライアントであること」を、暗号学的に証明する。
—
2. PKCEの仕組み:秘密の「使い捨てパスワード」
PKCEは、以下の二つの要素を使ってリクエストを検証する。
1. Code Verifier: クライアントが生成するランダムな文字列(秘密の値)。
2. Code Challenge: Verifierをハッシュ化(通常はSHA-256)して変換したもの。
認可リクエスト時に Code Challenge を送信し、トークン交換時に Code Verifier を送信する。IDPは、受け取った Verifier をハッシュ化し、最初に受け取った Challenge と一致するか確認する。攻撃者が Verifier を知らなければ、コードを交換することはできない。
—
3. 【実務向け】PKCEの実装サンプル
ここでは、フロントエンド(JavaScript)で Code Verifier を生成し、検証を準備するまでの核心部分を解説する。
Step 1: Code Verifier と Challenge の生成
// 乱数生成器でVerifierを作成 (43〜128文字のランダム文字列)
function generateCodeVerifier() {
const array = new Uint8Array(32);
window.crypto.getRandomValues(array);
return base64UrlEncode(array);
}
// SHA-256でハッシュ化し、Code Challengeを作成
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await window.crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(digest));
}
// Base64URLエンコード(パディング除去)
function base64UrlEncode(buffer) {
return btoa(String.fromCharCode.apply(null, buffer))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
Step 2: 認可リクエストの送信(パラメータ付与)
認可サーバーへのリクエストURLを構築する際、以下のパラメータを必ず含める。
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
// セッションストレージ等にVerifierを保存(トークン交換時に必要)
sessionStorage.setItem('pkce_verifier', verifier);
const authUrl = `https://idp.example.com/authorize?` +
`response_type=code` +
`&client_id=YOUR_CLIENT_ID` +
`&redirect_uri=YOUR_CALLBACK_URL` +
`&code_challenge=${challenge}` + // これがPKCEの鍵
`&code_challenge_method=S256`; // SHA-256を指定
window.location.href = authUrl;
—
4. セキュリティチーフからの「泥臭い」アドバイス
PKCEを実装する際、多くのエンジニアが躓くポイントが二つある。
1. Verifierのライフサイクル: Code Verifier は、トークン交換が完了するまで確実にセッションストレージ等で保持する必要がある。タブを閉じたり、ブラウザをリロードしても消えない設計、あるいはメモリ上のグローバル変数で安全に管理する設計を徹底してほしい。
2. IDP側の設定確認: AWS Cognito、Auth0、あるいは自前のIdentityServerを使うにしても、「PKCE必須(Require PKCE)」の設定がオンになっているか必ず確認すること。コード側だけPKCEに対応しても、サーバー側が古い仕様を受け入れていれば、脆弱性は残ったままだ。
Nginxでの防御的設定(おまけ)
Webアプリケーションの入り口であるNginxでは、認可リクエストが不正なパラメータで送られてこないよう、バリデーションをかけることも重要だ。
# 認可リクエストのクエリパラメータを厳格にチェックする例
location /authorize {
if ($arg_code_challenge_method != "S256") {
return 403; # PKCEなしの古いリクエストは門前払い
}
# その他のプロキシ設定...
}
—
最後に:セキュリティは「積み重ね」だ
PKCEは銀の弾丸ではない。これだけで全ての攻撃を防げるわけではなく、あくまで「認可コード横取り」に対する強力な防壁だ。
しかし、こうした「面倒な実装」を一つ一つ徹底できるかどうかが、重大インシデントを起こすエンジニアと、それを未然に防ぐエンジニアの分かれ道になる。「セキュリティは機能の一つ」と捉え、今日からあなたのプロダクトの認証フローを見直してほしい。
もし実装で詰まったら、仕様書(RFC 7636)の原典に戻ることを忘れるな。それが一番の近道だ。
コメント