おい、最近のモダンなWebアプリケーション開発の現場を見ていると、「OAuth 2.0を使っているからうちは安全だ」という謎の全能感に満ち溢れているエンジニアが多すぎる。
「Googleログイン対応しました!」「API連携バッチリです!」――だがな、その裏側でリダイレクトURIのバリデーションをガバガバにしていたり、ネイティブアプリやSPA(シングルページアプリケーション)だからといってPKCE(Proof Key for Code Exchange)をサボっていたりすると、攻撃者からすれば「どうぞ機密情報を盗んでください」と言っているようなものだ。
今回は、OAuth 2.0の認可フローにおける最大の罠の一つである「認可コード横取り攻撃(Authorization Code Interception)」について、実際の攻撃シナリオから、現場で即座に使える完全防御の実装までを叩き込んでやる。教科書通りの綺麗ごとは抜きだ。手を動かすエンジニアなら、自分のコードがどう狙われ、どう守るべきかを骨の髄まで理解しろ。
—
1. 認可コード横取り攻撃のメカニズムと現場のリアルなリスク
OAuth 2.0の「認可コード付与フロー(Authorization Code Grant)」は、アクセストークンを直接ブラウザに露出させない安全な仕組みとして広く普及している。しかし、これは「正しく実装されていれば」という大前提の上での話だ。
攻撃が成立する2つの大穴
1. リダイレクトURIの不備(オープン・リダイレクトや部分一致の悪用)
2. PKCEの欠如(特にパブリッククライアントやSPA)
攻撃の流れはこうだ。
ユーザーが正当なクライアントアプリからログインを試みると、認可サーバー(IdP)へリクエストが飛ぶ。このとき、攻撃者が何らかの手法(悪意あるリンクの踏ませ、カスタムスキームのハイジャック、拡張機能の悪用など)で、認可サーバーが発行する code(認可コード)を横取りする。
[ユーザー] ---> 1. 認可リクエスト (不正なredirect_uri指定も可?) ---> [認可サーバー]
| |
|<-- 2. 認可コードを攻撃者にリダイレクト -------------------------|
| v
[攻撃者] ---> 3. 盗んだ認可コードでトークンを要求 ---> [認可サーバー] (アクセストークン奪取成功)
認可コードは一回きりの使い捨てだが、攻撃者がユーザーより先にそのコードをトークンエンドポイントに送りつければ、正規のユーザーになりすましてアクセストークンが発行されてしまう。特に、ネイティブアプリやSPAのような「クライアントシークレットを安全に隠せない環境(パブリッククライアント)」では、これが致命傷になる。
—
2. 脆弱な実装と攻撃者の視点
まずは、どこに落とし穴があるのか。よくある「やってはいけない実装」を見てみよう。
脆弱な認可リクエストの例 (JavaScript / SPA)
以下のコードは、SPAから認可サーバーへリクエストを送るものだが、code_challenge が含まれておらず、さらにバックエンド側で redirect_uri の完全一致検証が甘い状態を想定している。
// 【危険な実装例】PKCEなし、リダイレクトURIの検証が緩い
function startOAuthLogin() {
const authEndpoint = "https://auth.example.com/oauth/authorize";
const clientId = "client_id_12345";
const redirectUri = "https://app.example.com/callback"; // ここを部分一致やワイルドカードで受けているサーバーは危険
// 認可コードを取得するためのURLを構築
const targetUrl = `${authEndpoint}?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=read write`;
// ユーザーを認可画面へ飛ばす
window.location.href = targetUrl;
}
もし認可サーバー側が redirect_uri のパラメータを https://app.example.com/ のように前方一致でしか検証していなかったらどうなるか? 攻撃者は https://app.example.com.attacker.com/callback のような偽のURIを指定してリクエストを改ざんし、ユーザーから吐き出された code を自分のサーバーに回収できてしまう。
—
3. 完全防御のためのセキュア実装・設定ガイド
この脅威を完全に無力化するためには、「厳格なリダイレクトURIのバリデーション」と「PKCE(RFC 7636)の強制」の2つを確実に実装する必要がある。
① 認可サーバー・バックエンド側でのリダイレクトURI検証
ワイルドカード(*)を使ったリダイレクトURIの登録は絶対に許すな。ドメイン、パスともに完全一致(Exact Match)で比較すること。
② クライアント側(SPA / モバイル)でのPKCE実装
PKCEを導入することで、仮に認可コードが途中で盗聴されても、クライアントシークレットを持たないパブリッククライアントからトークンを勝手に取得できなくなる。
以下に、Python(Flask)を用いたバックエンド側での安全な認可コード検証および、フロントエンド(JavaScript)でのPKCE生成・検証のサンプルコードを示す。
フロントエンド実装:PKCE(code_verifier / code_challenge)の生成とリクエスト
// 乱数を生成するヘルパー関数
function generateRandomString(length) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
let result = '';
const randomValues = new Uint8Array(length);
window.crypto.getRandomValues(randomValues);
for (let i = 0; i < length; i++) {
result += charset[randomValues[i] % charset.length];
}
return result;
}
// SHA-256ハッシュを計算してBase64URLエンコードする関数
async function pkceChallengeFromVerifier(v) {
const encoder = new TextEncoder();
const data = encoder.encode(v);
const digest = await window.crypto.subtle.digest('SHA-256', data);
// Base64URLEncode
let base64 = btoa(String.fromCharCode(...new Uint8Array(digest)));
base64 = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return base64;
}
async function secureLoginWithPKCE() {
const authEndpoint = "https://auth.example.com/oauth/authorize";
const clientId = "client_id_12345";
// 本番環境では必ずHTTPSかつ完全一致するURIを指定
const redirectUri = "https://app.example.com/callback";
// 1. code_verifierを生成し、セッションストレージ等に一時保存
const codeVerifier = generateRandomString(64);
sessionStorage.setItem('code_verifier', codeVerifier);
// 2. code_challengeを生成
const codeChallenge = await pkceChallengeFromVerifier(codeVerifier);
// 3. 認可リクエストURLの構築(code_challengeとS256メソッドを追加)
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: 'read write',
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `${authEndpoint}?${params.toString()}`;
}
バックエンド側実装:PKCE対応のトークン交換処理 (Python / Flask)
クライアントから送られてきた code と、フロントエンドが保持していた code_verifier を検証し、アクセストークンを発行する堅牢なエンドポイントの例だ。
import hashlib
import base64
from flask import Flask, request, jsonify
app = Flask(__name__)
# 仮のDBストア(実際はRedisやRDBを使用すること)
AUTHORIZATION_CODES = {
# 'auth_code_xyz': { 'code_challenge': '...', 'redirect_uri': '...' }
}
def verify_code_verifier(code_verifier, code_challenge):
"""code_verifierをSHA-256でハッシュ化し、Base64URLエンコードしてcode_challengeと比較する"""
if not code_verifier or not code_challenge:
return False
# SHA-256ハッシュ計算
sha256_hash = hashlib.sha256(code_verifier.encode('utf-8')).digest()
# Base64URLエンコード(パディング '=' を削除、+と/を置換)
calculated_challenge = base64.urlsafe_b64encode(sha256_hash).decode('utf-8').rstrip('=')
return calculated_challenge == code_challenge
@app.route('/oauth/token', methods=['POST'])
def token_endpoint():
data = request.form
grant_type = data.get('grant_type')
code = data.get('code')
redirect_uri = data.get('redirect_uri')
code_verifier = data.get('code_verifier')
if grant_type != 'authorization_code':
return jsonify({"error": "unsupported_grant_type"}), 400
# 1. 認可コードの存在確認
auth_data = AUTHORIZATION_CODES.get(code)
if not auth_data:
return jsonify({"error": "invalid_grant", "error_description": "認可コードが無効か、すでに使用されています。"}), 400
# 2. リダイレクトURIの完全一致検証(重要!)
if auth_data['redirect_uri'] != redirect_uri:
return jsonify({"error": "invalid_grant", "error_description": "リダイレクトURIが一致しません。"}), 400
# 3. PKCE (code_verifier) の検証
if not verify_code_verifier(code_verifier, auth_data['code_challenge']):
return jsonify({"error": "invalid_grant", "error_description": "PKCEの検証に失敗しました。"}), 400
# すでに使用された認可コードを即座に破棄(ワンタイム性の担保)
del AUTHORIZATION_CODES[code]
# 4. アクセストークンの発行処理へ進む
access_token = "secure_access_token_abc123"
return jsonify({
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 3600
})
if __name__ == '__main__':
app.run(port=5000)
—
4. チーフエンジニアからの実務インスペクション・チェックリスト
最後に、明日からお前のチームで即座に実施すべき監査項目をまとめた。これをクリアしていないシステムは、リリース差し戻しレベルの重大インシデント予備軍だ。
1. リダイレクトURIの棚卸し
- 認可サーバーの設定で、
http://(ローカル開発環境を除く)やワイルドカード(*)が含まれていないか確認したか? - 完全一致(Exact Match)のバリデーションがコードレベルで強制されているか?
2. PKCEの義務化
- モバイルアプリ(iOS/Android)やSPAなどのパブリッククライアントにおいて、
code_challenge_method=S256の指定を必須(強制)にしているか?(平文のplainは脆弱なので許可するな)
3. 認可コードの厳格なライフサイクル管理
- 発行された認可コードは「1回限り」の使い捨てになっているか?
- 有効期限は極めて短く(通常60秒以内)設定されているか?
セキュリティは「動けばいい」の世界じゃない。「不正を許さない」という執念の積み重ねだ。面倒くさい設計こそが、未来の会社と顧客の信頼を守る最大の盾になる。自分の担当しているリポジトリを今すぐ確認しろ。
コメント