【入門編】 OAuth 2.0/OIDCにおける認可コード横取りとリダイレクトURI検証 – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

こんにちは!Webサービスの開発や、会社のセキュリティを担当するようになると、避けて通れないのが「認証・認可」の仕組みですよね。

「Googleでログイン」や「LINEでログイン」といった、外部サービスのアカウントを使って自社アプリにログインする機能(OAuth 2.0やOIDC)は、今やどんなシステムでも見かける必須の機能です。

でも、この便利で当たり前になった仕組み、裏側でどんなデータがやり取りされているか気にしたことはありますか?
「なんとなくライブラリを入れたから大丈夫!」と思って油断していると、実は家の鍵を開けっ放しにしているような危険な状態になっているかもしれません。

今回は、OAuth 2.0やOIDCの裏側でこっそり狙われる「認可コード横取り攻撃」と、それを防ぐ最強の盾である「PKCE(ピクシー)」について、身近な例えを交えながら一歩ずつ優しく紐解いていきましょう!

—

1. 身近な例えで理解する「OAuth」と「リダイレクトURI」

まずは、OAuth 2.0の世界観を身近な例えでイメージしてみましょう。

皆さんがよく行く近所のカフェを想像してください。そのカフェでは、スタンプカードの代わりに「A百貨店の会員証アプリ」を見せると、割引が受けられるサービスを始めました。
ここで、カフェの店員さんがあなたにこう言います。

> 「お客様、わざわざA百貨店の本物のアプリを開かなくても、この『特別な合言葉(認可コード)』を紙に書いて渡してくれれば、代わりにウチのシステムがA百貨店に確認してスタンプを押しておきますよ!」

この「特別な合言葉」を発行してもらい、カフェのシステム(アプリ)に持ち帰る一連の流れが OAuth 2.0 の仕組みです。

そして、この時カフェのシステムがA百貨店に対して、
> 「合言葉をもらったら、必ず『私のカフェのカウンター(指定の住所)』に直接持って帰ってきてくださいね!」

と事前に約束する場所。これが 「リダイレクトURI」 です。

攻撃者はどうやって付け入るのか?(合言葉の横取り)

もし、この「持って帰る場所(リダイレクトURI)」の確認がガバガバだったらどうなるでしょうか?

悪意ある泥棒(攻撃者)が、カフェの近くに偽のカウンター(悪意あるサイト)を作り、A百貨店に対して「合言葉はこっちの偽カウンターに持ってきて!」と嘘をついたらどうでしょう。
うっかり百貨店がその嘘を信じてしまうと、あなたの大切な「合言葉」が泥棒の手に渡ってしまいます。泥棒はこの合言葉を使って、あなたになりすましてカフェで不正な割引をうけてしまう……これが認可コード横取り攻撃のメカニズムです。

—

2. なぜリダイレクトURIの検証が必要なのか?

先ほどの例の通り、攻撃者は正規のアプリのふりをして、ユーザーから「認可コード」を自分の用意したサーバーへ盗み出そうとします。

これを防ぐための第一の関門が、リダイレクトURIの厳格なホワイトリスト検証(完全一致チェック)です。
認可サーバー(GoogleやLINEなど)は、アプリ側から送られてきたリダイレクト先のURLが、事前に登録されたものと一文字たりとも違わないかを厳しくチェックする必要があります。

開発現場によくある「やらかし」として、次のようなものがあります。

  • https://example.com/callback と登録すべきところを、テストだからと https://example.com のようにドメイン単位で緩く許可してしまった。
  • ワイルドカード(https://*.example.com)を安易に使ってしまい、サブドメインを乗っ取られた攻撃者にコードを奪われた。

認可サーバー側とクライアントアプリ側の双方で、リダイレクト先は必ず「完全一致(Exact Match)」で検証するよう実装しましょう。

—

3. 泥棒すら使えない最強の鍵「PKCE」の登場

「リダイレクトURIのホワイトリストを厳しくすれば完璧!」……と言いたいところですが、世の中そんなに甘くありません。もし、クライアントアプリ自体がスマホアプリ(ネイティブアプリ)や、ソースコードが丸見えのシングルページアプリケーション(SPA)だったらどうでしょう?

スマホアプリの場合、アプリ内で使うリダイレクトの「窓口(カスタムスキームなど)」が他の悪意あるアプリに乗っ取られるリスクがあります。

そこで登場するのが、今回の主役である PKCE(Proof Key for Code Exchange / ピクシーと読みます) です。名前は難しそうですが、やっていることはとてもスマートです。

PKCEを「合鍵の仕組み」に例えてみよう

先ほどのカフェの例に戻りましょう。
今度は、あなたがカウンターで「合言葉」をもらう前に、こんな一工夫を加えました。

1. あなたが家を出る前に、自分で作った「ランダムな秘密の呪文(Code Verifier)」をメモに書き、それを金庫にしまいました。
2. さらに、その呪文を特別な方法でグシャグシャに混ぜ合わせた「ヒント(Code Challenge)」だけを紙に書き、A百貨店に渡しました。
3. カフェの店員さんが「合言葉」をあなたに渡す際、A百貨店は「さっきのヒントに合う秘密の呪文を言えた人にだけ、この合言葉を本物として認めるよ!」と約束させます。
4. 最後に、合言葉をカフェのシステムに渡すとき、あなたは自分のポケットから「最初の秘密の呪文(Code Verifier)」を直接見せました。

ここで重要なのは、「悪意ある泥棒が途中で合言葉を盗み見ても、秘密の呪文(Code Verifier)を持っていないので、カフェでその合言葉を使うことができない」という点です。

—

4. 実装コードで見る PKCE の流れ

それでは、実際のシステム開発でPKCEがどのように実装されるのか、シンプルなPHPとJavaScript(または擬似コード)を交えて見ていきましょう。一歩ずつ確認していけば怖くありません!

ステップ1: 秘密の呪文(Verifier)とヒント(Challenge)の生成

まずはブラウザ側(フロントエンド)で、ランダムな文字列(Verifier)を作り、それをハッシュ化してヒント(Challenge)を作ります。

// ランダムな秘密の文字列(Code Verifier)を生成する関数
function generateCodeVerifier() {
    const array = new Uint8Array(32);
    window.crypto.getRandomValues(array);
    // URLセーフなBase64エンコードに変換
    return btoa(String.fromCharCode.apply(null, array))
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
}

// Verifierからハッシュ化されたヒント(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 btoa(String.fromCharCode.apply(null, new Uint8Array(digest)))
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
}

ステップ2: 認可サーバーへリクエストを送る

認可サーバーにリダイレクトする際、生成したヒント(code_challenge)と、ハッシュのアルゴリズム(code_challenge_method=S256)を一緒に送ります。

// 秘密の呪文とヒントを作成
const codeVerifier = generateCodeVerifier();
// セッションストレージなどにverifierを一時保存しておく(後で使います!)
sessionStorage.setItem('code_verifier', codeVerifier);

const codeChallenge = await generateCodeChallenge(codeVerifier);

// 認可エンドポイントへ飛ばすURLを組み立てる
const authUrl = new URL('https://auth.example.com/oauth/authorize');
authUrl.searchParams.append('response_type', 'code');
authUrl.append('client_id', 'YOUR_CLIENT_ID');
authUrl.append('redirect_uri', 'https://your-app.com/callback');
authUrl.append('code_challenge', codeChallenge);       // ← ここでヒントを渡す!
authUrl.append('code_challenge_method', 'S256'); // ← SHA-256を指定

// 認可画面へリダイレクト
window.location.href = authUrl.toString();

ステップ3: 認可コードと「秘密の呪文」を一緒にサーバーへ送る

ユーザーがログインを許可すると、認可コード(code)がアプリに戻ってきます。
アプリ側は、そのコードを受け取った後、「最初に隠し持っていた秘密の呪文(Code Verifier)」をアクセストークン要求時にこっそり添えて、認可サーバーに本番の身分証明を行います。

// PHPでのトークン交換リクエストの例 (callback.php)
session_start();

$authorizationCode = $_GET['code'] ?? null;
$codeVerifier = $_SESSION['code_verifier'] ?? null; // 先ほど保存した秘密の呪文を取り出す

if (!$authorizationCode || !$codeVerifier) {
    die('不正なリクエストです。');
}

// 認可サーバーへPOSTリクエストを送信するパラメータ
$postData = [
    'grant_type' => 'authorization_code',
    'client_id' => 'YOUR_CLIENT_ID',
    'code' => $authorizationCode,
    'redirect_uri' => 'https://your-app.com/callback',
    'code_verifier' => $codeVerifier // ← ここで本物の秘密の呪文を提出する!
];

$ch = curl_init('https://auth.example.com/oauth/token');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData));

$response = curl_exec($ch);
curl_close($ch);

$tokenData = json_decode($response, true);

if (isset($tokenData['access_token'])) {
    // 無事にアクセストークンゲット!ログイン成功です
    echo "ログインに成功しました!";
} else {
    // 泥棒がコードを盗んでいても、verifierが一致しないためここで弾かれます!
    echo "認証に失敗しました。不正なコードの可能性があります。";
}

このように、認可サーバー側で「最初にもらったヒント」と「後から提示された呪文」がピタリと一致するかを厳密に計算(検証)することで、たとえ途中で認可コードが盗み見られても、泥棒はトークンを手に入れることができなくなるのです。

—

5. まとめ:安全な認証フローのために今すぐできること

いかがでしたでしょうか?
OAuth 2.0やOIDCにおける「認可コード横取り攻撃」と、それを防ぐ「リダイレクトURIの厳格な検証」、そして「PKCE」の仕組みについてイメージがつかめましたか?

今日のまとめとして、明日から現場で実践できるチェックリストをお届けします。

1. リダイレクトURIは完全一致(Exact Match)で登録する(あいまいな前方一致やワイルドカードは絶対に避ける)。
2. パブリッククライアント(SPAやモバイルアプリ)では、必ずPKCEを実装する(近年のOAuth 2.1仕様では、機密情報を持たないアプリだけでなく、すべてのクライアントでPKCEの利用が推奨・義務化されています)。
3. ライブラリ任せにせず、裏側でどんなパラメータ(code_challengeやcode_verifier)がやり取りされているかを一度ログや開発者ツールで確認してみる。

セキュリティの対策は、難しく考えすぎず「誰がどこで何を持っていて、どうやって本物だと証明するのか」を一つずつ紐解いていけば、必ず理解できるようになります。
一歩ずつ、安全で堅牢なシステムを一緒に作っていきましょう!

コメント

タイトルとURLをコピーしました