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

おい、最近のOAuth 2.0の実装レビューをしていて、また胃が痛くなるようなコードを見つけてしまったよ。

「うちはバックエンドできちんとトークン交換してるから認可コードフローは安全です」――そう言い切る開発者に限って、SPA(Single Page Application)やネイティブアプリからのリクエストでPKCE(Proof Key for Code Exchange)をサボり、挙句の果てにリダイレクトURIのバリデーションを部分一致でガバガバに通している。

現場のエンジニアなら耳タコかもしれないが、あえて言おう。現代のWebアプリケーションにおいて、純粋なOAuth 2.0の認可コードフロー(PKCEなし)は、特にパブリッククライアント環境においては地雷原を裸足で歩くようなものだ。

今回は、攻撃者がどのようにしてその「安全だと思い込んでいる認可コード」をスニッフィングし、アカウントを乗っ取るのかという生々しいリスクの現実と、それを完全に無力化するPKCEの実装、そしてインフラレイヤーでのリダイレクトURIの厳格な防衛策について、現場の泥臭い知見を交えて徹底的に解説する。

—

1. なぜ「普通の認可コードフロー」は破綻するのか?(攻撃シナリオのリアル)

OAuth 2.0の認可コードフロー(RFC 6749)は、バックエンドサーバーを持つ信頼できるクライアント(機密クライアント)を前提に設計されている。ブラウザからリダイレクトされてきた code(認可コード)を、サーバーサイドのバックチャンネルでアクセストークンに交換する仕組みだ。

しかし、このフローをスマホアプリやSPAといったパブリッククライアントにそのまま適用した瞬間、セキュリティの歯車が狂い出す。

攻撃者による「認可コード横取り(Authorization Code Interception)」のPoC

1. 攻撃者は、悪意あるカスタムスキーム(例: myapp://callback)を自身の悪性アプリにOS登録する。
2. 被害者が正当なアプリでログインを開始し、認可サーバー(Auth0やCognitoなど)へリクエストを送る。
3. 認可サーバーが処理を完了し、ブラウザ(またはOS)に対して myapp://callback?code=ATTACKER_INTERCEPTED_CODE を発行する。
4. この時、OSのインテント(Intent)やカスタムスキームのハンドリングが曖昧な環境では、先着順や優先度の設定ミスにより、攻撃者のアプリがその認可コードを先に受け取ってしまう。
5. 攻撃者は、手に入れたその code をそのまま自分のバックエンドや攻撃用スクリプトから認可サーバーの /token エンドポイントに送る。
6. クライアントシークレットを持たないパブリッククライアントの場合、認可サーバーは「コードが正しい」という理由だけでアクセストークンをホイホイと発行してしまう。

結果、被害者のセッションは完全に攻撃者の手に渡る。これが、シークレットを持てない環境における認可コードフローの致命的な盲点だ。

—

2. PKCE(RFC 7636)がもたらす「暗号学的な縛り」の原理

この脆弱性を根底から叩き潰すために登場したのが PKCE(Proof Key for Code Exchange) だ。元々はモバイルアプリ向けに策定されたが、現在ではSPAを含むすべてのパブリッククライアントで必須の標準(OAuth 2.0 Security Best Current Practice)となっている。

PKCEの仕組みは非常にエレガントかつ堅牢だ。要するに、認可リクエストの時点で「後からコードを交換する奴が、最初にリクエストを発行した本人であることを証明する暗号の鍵(証拠)」をあらかじめコミットさせておく。

1. Code Verifier(検証用ランダム文字列)の生成:
クライアント側(ブラウザやアプリ)で、高エントロピーなランダム文字列(43〜128文字)を生成する。これが code_verifier だ。
2. Code Challenge(ハッシュ値)の生成:
code_verifier を SHA-256 でハッシュ化し、それを Base64URL エンコードしたものが code_challenge となる。
3. 認可リクエストへの付与:
認可サーバーへリクエストを送る際に、code_challenge と共に code_challenge_method=S256 を送信する。認可サーバーはこのハッシュ値を一時的に保持(バインド)する。
4. トークン交換時の証明:
認可コード (code) をアクセストークンに交換する際、今度は生データの code_verifier を一緒に送信する。
5. 検証:
認可サーバー側で受け取った code_verifier を再び SHA-256 でハッシュ化し、最初に受け取った code_challenge と一致するかを数学的に検証する。ここで一致しない場合、あるいは code_verifier が提示されない場合は、トークン発行を即座に拒絶する。

仮に攻撃者が途中で認可コード(code)を横取りしたとしても、元のリクエストを生成したクライアントしか持っていない code_verifier を持っていないため、アクセストークンに換金することは不可能になるのだ。

—

3. 【実務実装】フロントエンド (JavaScript) とバックエンドの連携コード

では、実際のモダンなWebアプリケーション(SPA)における実装を見ていこう。ここでは、ライブラリに頼らずネイティブの Web Crypto API を使った PKCE の生成と、トークン交換の処理をTypeScript/JavaScriptで記述する。

フロントエンド: PKCEパラメータの生成と認可リクエスト

/**
 * 暗号学的に安全なランダム文字列(Code Verifier)を生成する
 */
function generateCodeVerifier() {
    const array = new Uint8Array(32);
    window.crypto.getRandomValues(array);
    // Base64URLエンコードに変換
    return btoa(String.fromCharCode.apply(null, array))
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
}

/**
 * Code Verifierから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);
    
    // ArrayBufferをBase64URLに変換
    const bytes = new Uint8Array(digest);
    let binary = '';
    for (let i = 0; i < bytes.byteLength; i++) {
        binary += String.fromCharCode(bytes[i]);
    }
    return btoa(binary)
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
}

// 認可フロー開始時の処理
async function startAuthFlow() {
    const codeVerifier = generateCodeVerifier();
    const codeChallenge = await generateCodeChallenge(codeVerifier);

    // リロードや別タブ遷移後も検証できるようにセッションストレージに一時保存
    sessionStorage.setItem('code_verifier', codeVerifier);

    const clientId = 'your_spa_client_id';
    const redirectUri = 'https://app.example.com/callback';
    const authEndpoint = 'https://auth.example.com/oauth/authorize';

    const authUrl = `${authEndpoint}?response_type=code` +
        `&client_id=${encodeURIComponent(clientId)}` +
        `&redirect_uri=${encodeURIComponent(redirectUri)}` +
        `&code_challenge=${encodeURIComponent(codeChallenge)}` +
        `&code_challenge_method=S256` +
        `&scope=${encodeURIComponent('openid profile email')}`;

    // 認可サーバーへリダイレクト
    window.location.href = authUrl;
}

バックエンド(またはリダイレクト先SPA): トークン交換時の処理 (Python / FastAPI)

認可サーバーからコールバックされてきた code を受け取り、バックエンド側(またはBFFパターンをとるサーバー)でトークンを要求する際の実装だ。

import base64
import hashlib
from fastapi import FastAPI, HTTPException, Query
import httpx

app = FastAPI()

# 認可サーバーのエンドポイント設定
TOKEN_ENDPOINT = "https://auth.example.com/oauth/token"
CLIENT_ID = "your_spa_client_id"
REDIRECT_URI = "https://app.example.com/callback"

@app.get("/api/callback")
async def oauth_callback(code: str = Query(...), code_verifier: str = Query(...)):
    """
    認可コードと、クライアント側から送られてきたcode_verifierを受け取り、
    認可サーバーへアクセストークンを要求する
    """
    payload = {
        "grant_type": "authorization_code",
        "client_id": CLIENT_ID,
        "code": code,
        "redirect_uri": REDIRECT_URI,
        "code_verifier": code_verifier,  # ここで生のVerifierを渡す
    }

    async with httpx.AsyncClient() as client:
        response = await client.post(TOKEN_ENDPOINT, data=payload)
        
        if response.status_code != 200:
            # 攻撃者が不正なcode_verifierを送ってきた場合などはここで弾かれる
            raise HTTPException(
                status_code=400, 
                detail=f"Token exchange failed: {response.text}"
            )

        token_data = response.json()
        
        # 本来はここでアクセストークンをセキュアなCookieに格納するなどの処理を行う
        return {
            "status": "success",
            "access_token": token_data.get("access_token")
        }

—

4. 忘れてはならないもう一つの防衛線:リダイレクトURIの厳格な検証

どれだけ完璧にPKCEを実装しても、アプリケーション側のリダイレクトURI(redirect_uri)のバリデーションがガバガバであれば、システム全体のセキュリティは一瞬で崩壊する。

よくある現場の悪手は次のようなものだ:

  • 「テスト環境だから」とプレフィックスの部分一致(例: https://example.com/ で始まるものは全て許可)を認可サーバーに設定している。
  • ワイルドカード(https://*.example.com/callback)を安易に使っている。

もし攻撃者が https://example.com.attacker-domain.com/callback のようなドメインを用意できた場合、部分一致のバリデーションはこれをすり抜けてしまう。結果として、被害者の貴重な code が攻撃者のサーバーへと盛大にリダイレクトされてしまうのだ。

対策: 完全一致(Exact Match)の徹底とインフラ・WAFでの水際防御

1. 認可サーバーの設定:
原則として、リダイレクトURIは完全一致(Exact Matching)のみを許可する設定にすること。クエリパラメータやフラグメント(#)が含まれるURIの動的な変更は厳禁だ。
2. Nginx / API Gatewayでのリクエストヘッダー検証:
リダイレクトを受け付けるエンドポイントや認可サーバーの前段にあるリバースプロキシで、怪しいリクエストを弾く設定を入れておこう。

以下に、Nginxで不正なリダイレクト先やホストヘッダーのインジェクションを防ぐための設定サンプルを示す。

server {
    listen 443 ssl;
    server_name auth.example.com;

    ssl_certificate /etc/ssl/certs/auth_server.crt;
    ssl_certificate_key /etc/ssl/private/auth_server.key;

    # ホストヘッダー偽装攻撃(Host Header Injection)の防止
    if ($host != $server_name) {
        return 444;
    }

    location /oauth/authorize {
        # クエリパラメータの不正なredirect_uriを厳格にチェック
        # 本番環境では認可サーバー自体のバリデーションに依存するが、
        # WAFルール等で不審なドメインが含まれていないか正規表現で検査するのも有効
        
        # 例外的なリクエストメソッドの制限
        limit_except GET {
            deny all;
        }

        proxy_pass http://auth_backend_upstream;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

—

5. まとめ:セキュリティチーフからの実践的な提言

認証・認可まわりの実装は、動けば正義、動かなければ悪という世界ではない。「動くけれど、脆弱性だらけの動くゴミ」がいかに多くのインシデントを引き起こしてきたか、私たちは幾度となく目撃してきた。

今日のまとめとして、チームのコードレビューや設計時に必ずチェックすべきポイントを叩き込んでおいてほしい。

1. パブリッククライアント(SPA、モバイルアプリ等)では、例外なくPKCE(code_challenge_method=S256)を強制すること。 「めんどくさい」「ライブラリのバージョンが古い」という言い訳は、セキュリティインシデントの前では一切通用しない。
2. リダイレクトURIは完全一致のみを許可する。 部分一致やワイルドカードの甘い設定は、設計段階の怠慢とみなせ。
3. セキュリティは「多層防御」で守る。 アプリケーションコードでの適切な暗号学的実装と、認可サーバー・インフラ側(NginxやクラウドIAM)での厳格なバリデーションの両輪を回し続けろ。

妥協のない堅牢なコードこそが、夜中の緊急アラートから君たちを救う最大の防壁となる。さっそく、今動いているプロダクトのコードベースを確認しに行こう。

コメント

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