【入門編】 OAuth 2.0 / OpenID Connect の認可コードフローとPKCEの活用 – 暗号理論・認証基盤 & エンドポイントセキュリティ防御ガイド

こんにちは!Webアプリの開発や、セキュリティの勉強に日々奮闘されている新人のエンジニアの皆さん、お疲れ様です。「OAuth 2.0」や「OpenID Connect」、そして「PKCE(ピクシー)」なんて言葉、最近よく耳にしませんか?

「なんだか呪文みたいで難しそう……」と感じている方もご安心ください。今回は、セキュリティのプロである私が、泥棒のテクニックや「家の鍵」に例えて、この仕組みをわかりやすく、かつ現場でそのまま使える実用的な知識として紐解いていきます。一歩ずつ、一緒に学んでいきましょう!

—

1. なぜ「認可コードフロー」に泥棒が入り込むのか?

まずは、私たちがよく使う「Googleでログイン」や「GitHub連携」の裏側を覗いてみましょう。これらは主に OAuth 2.0 / OpenID Connect という仕組みで動いています。

ここで使われるのが「認可コードフロー」です。仕組みをざっくり説明するとこうなります。

1. ユーザーが「ログインするぞ」とボタンを押す。
2. アプリが「この人は本物ですよ」と証明するための「認可コード」という小さなチケットを、ブラウザ(画面)経由で受け取る。
3. アプリはそのチケットを裏側で「アクセストークン(いわゆる合鍵)」に引き換えて、無事にログイン完了!

一見、とても安全に見えますよね。でも、ここに大きな落とし穴(盲点)があります。

身の回りの防犯に例えてみましょう

想像してみてください。あなたは宅配ボックスに荷物を入れるため、一時的に「荷物引換券(認可コード)」を郵便受けに入れました。しかし、その郵便受けの近くに、悪意ある泥棒(攻撃者)がちゃっかり張り込んでいたらどうでしょう?

泥棒は、あなたが郵便受けに入れた瞬間、その「引換券」をこっそり盗み見(横取り)してしまいます。そして、あなたが気づかないうちに、その引換券を先に窓口へ持って行き、あなたの代わりに「合鍵」を受け取ってしまうのです。これが、認可コードの横取り攻撃の正体です。

特に、スマホアプリ(ネイティブアプリ)や、ブラウザだけで動くシングルページアプリケーション(SPA)では、アプリとブラウザの間の通信を別の不正なアプリがのっとる隙(カスタムスキームの乗っ取りなど)があり、このリスクが現実に起こりやすかったのです。

—

2. そこで登場するのが「PKCE(ピクシー)」という最強の防犯グッズ

「じゃあ、引換券を盗まれても使えないようにすればいいじゃない!」

そう考えて生まれたのが、今回の主役である PKCE(Proof Key for Code Exchange:ピーケーシーイー、通称ピクシー) です。名前は難しそうですが、やっていることはとてもアナログで確実な防犯対策です。

鍵の仕組みを「南京錠」で例えてみる

PKCEのアイデアはこうです。

1. アプリ側で、出発する前に「秘密の合言葉(Code Verifier)」をこっそり自分で作ります。そして、その合言葉をガチャリとハッシュ化(不可逆な暗号に変換)して、「鍵の形(Code Challenge)」だけをサーバーに先に伝えておきます。この時点では、合言葉そのものはアプリのなかに隠されていて、外には絶対漏れません。
2. アプリは「この鍵の形に合う合言葉を知っている人だけ、合鍵をちょうだいね」とサーバーにお願いしながら、認可コードをもらいに行きます。
3. もし途中で泥棒に「認可コード」が盗まれたとしましょう。泥棒は慌ててそのコードをサーバーに持って行き、「合鍵をくれ!」と言います。
4. しかし、サーバーはこう言います。「おっ、コードは合ってるね。でも、このコードをもらう時にセットだった『秘密の合言葉』を教えてよ。それを知らないと合鍵は渡せないよ!」
5. 泥棒は合言葉を知らないので、あえなく撃退されます。本物のアプリだけが、こっそり持っていた「秘密の合言葉」を提示して、無事に合鍵をゲットできるというわけです。

非常にスマートで、かつ泥棒を完全にシャットアウトできる素晴らしい仕組みですよね!

—

3. 実装のステップ:コードとパラメーターを覗いてみよう

それでは、実際にフロントエンド(JavaScript等)やモバイルアプリで、このPKCEをどう実装するのか、具体的なコードの流れを見ていきましょう。

ステップ①:秘密の合言葉(Verifier)と、そのハッシュ(Challenge)を作る

まずは、ブラウザやアプリ側でランダムな文字列(Verifier)を生成し、それをSHA-256でハッシュ化して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(/=+$/, '');
}

// 秘密の合言葉から、サーバーに送る「鍵の形(Code Challenge)」をSHA-256で生成する関数
async function generateCodeChallenge(verifier) {
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const digest = await window.crypto.subtle.digest('SHA-256', data);
    
    // こちらもURLセーフなBase64に変換
    return btoa(String.fromCharCode.apply(null, new Uint8Array(digest)))
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
}

// 【使い方】
// 1. 秘密の合言葉を作る(これは後でトークン交換の時に使うのでブラウザの sessionStorage 等に一時保存!)
const codeVerifier = generateCodeVerifier();
sessionStorage.setItem('code_verifier', codeVerifier);

// 2. チャレンジを作って認証サーバーへ送る準備をする
generateCodeChallenge(codeVerifier).then(codeChallenge => {
    // 認証リクエストのURLパラメータに組み込む
    console.log("Code Challenge:", codeChallenge);
});

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

次に、認可エンドポイントへリクエストを送ります。ここで、先ほど作った code_challenge と、計算方法(code_challenge_method=S256)を一緒に含めるのがポイントです。

// 認証サーバーのURL(例)
const authServerURL = "https://auth.example.com/oauth/authorize";

const clientId = "your_client_id_12345";
const redirectUri = "https://yourapp.com/callback"; // 厳格な検証が必要なリダイレクト先!

// 先ほど生成したチャレンジをセット
generateCodeChallenge(codeVerifier).then(codeChallenge => {
    const loginUrl = `${authServerURL}?` +
        `response_type=code&` +
        `client_id=${clientId}&` +
        `redirect_uri=${encodeURIComponent(redirectUri)}&` +
        `code_challenge=${codeChallenge}&` +
        `code_challenge_method=S256`; // SHA-256を使うことを宣言

    // ユーザーを認証画面へリダイレクトさせる
    // window.location.href = loginUrl;
});

ステップ③:認可コードを受け取り、秘密の合言葉と一緒にトークンを要求する

ユーザーが無事にログインを終えて、アプリに戻ってきたら(redirect_uri にリダイレクトされたら)、URLに含まれる code(認可コード)を取得します。

そして、サーバーにトークン(合鍵)を要求する際、最初にとっておいた秘密の合言葉(code_verifier)を一緒に送信します。

// リダイレクト先のURLから「認可コード」を取り出す処理のイメージ
const urlParams = new URLSearchParams(window.location.search);
const authorizationCode = urlParams.get('code');

// 先ほどブラウザのストレージに隠しておいた「秘密の合言葉」を取り出す
const savedVerifier = sessionStorage.getItem('code_verifier');

// トークンエンドポイントへPOSTリクエストを投げるデータ
const tokenRequestBody = new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: 'your_client_id_12345',
    code: authorizationCode,
    redirect_uri: 'https://yourapp.com/callback',
    code_verifier: savedVerifier // ← ここで最初の合言葉を突き合わせる!これがPKCEのキモ!
});

// fetchを使ったサーバーへの送信例
fetch('https://auth.example.com/oauth/token', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: tokenRequestBody
})
.then(response => response.json())
.then(data => {
    console.log("やった!アクセストークン(合鍵)をゲットしました:", data.access_token);
    // 不要になった合言葉はセキュリティのため消去しておく
    sessionStorage.removeItem('code_verifier');
})
.catch(error => {
    console.error('トークン交換に失敗しました(合言葉が違うか、コードが不正です)', error);
});

このように、PKCEを導入することで、万が一途中で認可コードが盗み見られても、合言葉(code_verifier)がなければトークンに変換できないため、不正アクセスを完全にブロックできるのです。

—

4. もう一つの鉄則:リダイレクトURIの「厳格な検証」を忘れないで!

PKCEとセットで絶対にマスターしておかなければならないのが、「リダイレクトURIの厳格な検証」です。

先ほどコードの中でも出てきた redirect_uri ですが、これは「認証が終わったあと、ユーザーをどこにお戻しするか」という帰るべきおうちの住所です。

ここに緩みがあるとどうなるか?

もし、認証サーバー側がリダイレクト先のチェックを「前方一致(例: https://yourapp.com/ で始まっていれば何でもOK)」のようにガバガバな設定にしていたらどうでしょう?

悪意ある攻撃者は、次のような巧妙な罠を仕掛けます。
1. 自分の持っている悪意のあるサイト(例: https://yourapp.com.attacker-evil.com/)をリダイレクト先として指定して認証リクエストを送る。
2. 認証サーバーが「おっ、おなじ yourapp.com だから大丈夫だな」と勘違いして、その怪しい宛先に大切な認可コードを送りつけてしまう。
3. 攻撃者はまんまとコードをゲットしてログインを乗っ取る……!

現場で実践すべき対策

この事故を防ぐために、インフラや認証サーバー(Auth0、Keycloak、Cognitoなど)の設定、あるいは自社で認証基盤を構築する際には、以下の鉄則を必ず守りましょう。

  • 完全一致(Exact Match)を強制する:リダイレクトURIは、ワイルドカード(*)などを安易に使わず、プロトコル、ドメイン、パスに至るまで、1文字たりとも狂いのない完全一致で検証するように設定してください。
  • http:// の使用を本番環境では絶対に禁止する:ローカル開発(http://localhost)を除き、本番環境では必ず暗号化された https:// を強制します。中間者攻撃(通信の盗聴)を防ぐ基本中の基本です。

—

まとめ:セキュリティは「疑うこと」から始まる

いかがでしたでしょうか?今回は、認可コードフローの弱点を突く攻撃と、それを華麗に防ぐ「PKCE」、そして「リダイレクトURIの厳格な検証」について解説しました。

  • 認可コードは途中で盗まれるリスクがある(郵便受けの引換券)
  • PKCEで「秘密の合言葉(Verifier)」をペアにすることで、盗まれても使わせなくする
  • リダイレクトURIはガバガバな設定にせず、完全一致で厳格にチェックする

セキュリティの世界では、「もしかしたら通信路やブラウザはすべて盗聴・監視されているかもしれない」というゼロトラスト(誰も信用しない)の視点を持つことが、何よりも強力な盾になります。

最初は覚えることが多くて大変に感じるかもしれませんが、ひとつひとつの仕組みの「なぜ?」を紐解いていくと、パズルのように綺麗につながってとても面白くなってきます。

ぜひ、今ご自身が関わっているプロジェクトやローカルの検証環境で、PKCEの設定やリダイレクトURIのバリデーションがどうなっているか、一度コードや設定ファイルを覗いてみてくださいね。「一歩ずつ、確実に安全なアプリケーションを作っていきましょう!」応援しています!

コメント

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