OAuth 2.0とOpenID Connectのセキュリティ:よくある脆弱性と正しい実装方法

Security tutorial - IT technology blog
Security tutorial - IT technology blog

認証が最大の弱点だと気づいた日

真夜中にサーバーがSSHブルートフォース攻撃を受けた経験から、セキュリティを最初から優先する大切さを学んだ。その事件をきっかけに、SSHキー・ファイアウォール・fail2banですべての入口を固めた。しかしWebアプリやAPIを作り始めると、もっと大きな盲点に気づいた。実際の侵害のほとんどが起きるのは、認証レイヤーだ。SSHではなく、OAuthで。

誤って設定されたOAuth 2.0フローは、驚くほど多くの深刻なデータ漏洩の背後にある。開発者が無関心なのではなく、このプロトコルには微妙な設定項目が何十もあるからだ。多くのチュートリアルは正常系しか示さない。オプションを一つ省略しても表面上は問題なく見える——ペネトレーションテスターか攻撃者が穴を見つけるまでは。

このガイドでは、何が問題になるのか、コアコンセプトの本当の意味、そして正しい実装方法を解説する。

OAuth 2.0とOpenID Connectの本質

問題を修正する前に、各プロトコルが実際に何をするのかを明確に理解する必要がある。

OAuth 2.0 — 認証ではなく認可

OAuth 2.0は委譲プロトコルだ。ユーザーがパスワードを共有することなく、サードパーティアプリケーションに自分のリソースへの限定的なアクセスを許可できる。バレーキーのようなもの——車を駐車させることはできるが、トランクやグローブボックスは開けられない。

4つの主要ロール:

  • リソースオーナー — データを所有するユーザー
  • クライアント — アクセスを要求するアプリケーション
  • 認可サーバー — トークンを発行する(例:Google、GitHub、自前のKeycloak
  • リソースサーバー — 保護されたデータを持つAPI

OpenID Connect — OAuth 2.0の上に構築された認証

OpenID Connect(OIDC)はOAuth 2.0の上にアイデンティティレイヤーを追加する。OAuth 2.0が「このアプリはこのリソースにアクセスできるか?」という質問に答えるのに対し、OIDCは別の質問に答える:「このユーザーは一体誰か?」

OIDCはIDトークンを導入する——認証済みユーザーに関するクレームを含む署名付きJWT:サブジェクトID、メール、名前、ログイン日時など。この署名こそがクレームを信頼できるものにする。

「Googleでサインイン」ボタンを実装したことがあるなら、意識していなくてもOIDCを使っていたことになる。

よくある脆弱性とその発生原因

1. stateパラメータの欠如または不備

stateパラメータは、認可フローへのCSRF攻撃を防ぐために存在する。これがなければ、攻撃者がユーザーのブラウザを操作して、攻撃者が開始したOAuthのやり取りを完了させることができ、サーバーには区別する手段がない。

脆弱なリクエストはこのようになる:

# 脆弱 — stateパラメータなし
https://accounts.google.com/o/oauth2/auth?
  client_id=YOUR_CLIENT_ID&
  redirect_uri=https://yourapp.com/callback&
  response_type=code&
  scope=openid email

暗号論的にランダムな値を生成し、ユーザーのセッションに紐づけ、リダイレクト後に検証することで修正できる:

import secrets
import hashlib

def generate_state():
    # 暗号論的に安全なランダムstate
    state = secrets.token_urlsafe(32)
    # リダイレクト前にサーバーサイドセッションに保存
    session['oauth_state'] = state
    return state

def verify_state(received_state):
    expected_state = session.get('oauth_state')
    if not expected_state or not secrets.compare_digest(received_state, expected_state):
        raise ValueError("stateの不一致 — CSRFの可能性あり")
    del session['oauth_state']  # 一度だけ使用

2. 未検証のredirect_uriによるオープンリダイレクト

認可サーバーはリダイレクトURIの厳密な許可リストを強制しなければならない。部分一致やワイルドカードを許可すると、攻撃者が認可コードを自分のサーバーに直接リダイレクトさせることができる。

# サーバーサイド:redirect_uriを厳密に検証
ALLOWED_REDIRECT_URIS = [
    "https://yourapp.com/callback",
    "https://yourapp.com/auth/callback",
]

def validate_redirect_uri(uri: str) -> bool:
    return uri in ALLOWED_REDIRECT_URIS

# 以下のような部分一致は絶対に行わないこと:
# uri.startswith("https://yourapp.com")  <-- サブドメインの悪用に脆弱

3. 認可コードの傍受 — PKCEで防ぐ

パブリッククライアント——シングルページアプリ、モバイルアプリ——はクライアントシークレットを安全に保存できない。攻撃者がアプリがコードを交換する前に認可コードを傍受した場合、自分でトークンと交換できてしまう。

PKCE(Proof Key for Code Exchange、「ピクシー」と読む)はそのギャップを埋める。クライアントはランダムなcode_verifierを生成し、それをハッシュ化してcode_challengeを作る。認可リクエスト時にチャレンジを事前に送信する。コードをトークンと交換する際に、元のベリファイアを送る。正規のクライアントだけが両方を提示できる——傍受されたコードだけでは無価値だ。

import secrets
import hashlib
import base64

def generate_pkce_pair():
    # ステップ1:code_verifierを生成(43〜128文字、URLセーフ)
    code_verifier = secrets.token_urlsafe(64)

    # ステップ2:code_challenge = BASE64URL(SHA256(code_verifier))を計算
    digest = hashlib.sha256(code_verifier.encode()).digest()
    code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()

    return code_verifier, code_challenge

# 認可リクエストで使用:
# &code_challenge=CODE_CHALLENGE
# &code_challenge_method=S256

# トークンとコードを交換する際に送信:
# &code_verifier=CODE_VERIFIER

注目すべき点として、PKCEはパブリッククライアントだけでなく、すべてのOAuthクライアントに推奨されるようになっている。機密情報を持つサーバーサイドクライアントも恩恵を受ける——クライアントシークレットが存在する場合でも多層防御になる。

4. IDトークンの不適切な検証

OIDCを使う場合、IDトークン内のクレームを信頼する前に必ず検証しなければならない。これを省略すると、偽造または再利用されたトークンに対して無防備になる。

確認が必要なフィールド一覧:

  • iss(発行者) — プロバイダーの期待する発行者URLと完全に一致しなければならない
  • aud(オーディエンス) — クライアントIDが含まれていなければならない
  • exp(有効期限) — トークンが期限切れであってはならない
  • iat(発行日時) — 過去に遡りすぎていないこと;わずかな時刻のずれも考慮すること
  • nonce — 認可リクエストで送ったnonceと一致しなければならない(リプレイ攻撃を防ぐ)
  • 署名 — プロバイダーの公開JWKSエンドポイントを使って検証
from authlib.integrations.requests_client import OAuth2Session
from authlib.jose import jwt
import requests

def validate_id_token(id_token: str, nonce: str, client_id: str, issuer: str):
    # プロバイダーの公開鍵を取得
    jwks_uri = f"{issuer}/.well-known/openid-configuration"
    oidc_config = requests.get(jwks_uri).json()
    jwks = requests.get(oidc_config['jwks_uri']).json()

    # デコードと検証
    claims = jwt.decode(id_token, jwks)
    claims.validate()  # exp、nbfを検証

    # クレームの手動チェック
    assert claims['iss'] == issuer, "発行者の不一致"
    assert client_id in claims['aud'], "オーディエンスの不一致"
    assert claims.get('nonce') == nonce, "nonceの不一致 — リプレイ攻撃の可能性?"

    return claims

JWTパースを自前で実装するのではなく、メンテナンスされているライブラリ——authlibpython-jose——を使うこと。これらのプロトコルにはエッジケースが多く、間違えやすく、本番環境で何かが壊れるまでほぼ気づけない。

5. クライアント側でのトークンの不安全な保存

保存場所は、ブラウザベースのアプリで最も過小評価されている設計判断の一つだ:

  • localStorage / sessionStorage — ページ上のあらゆるJavaScriptから読み取れる。XSS脆弱性が一つあれば、すべてのトークンが盗まれる。
  • HttpOnlyクッキー — JavaScriptからアクセスできない。Webアプリのアクセストークンにはこれが正しい選択だ。

SPAを扱う場合はBFF(Backend For Frontend)パターンを検討しよう。ブラウザはバックエンドと通信し、バックエンドがサーバーサイドセッションでトークンを保持してAPIコールをプロキシする。トークンはブラウザに届かない。CSPヘッダーを正しく設定することで、XSSリスクをさらに低減できる。

すべてをまとめる:安全なOAuth 2.0フロー

Python、Flask、authlibを使った完全なサーバーサイドOAuth 2.0 + OIDC実装を示す。state、PKCE、nonceはすべて処理済み——忘れるものは何もない:

from flask import Flask, session, redirect, url_for, request
from authlib.integrations.flask_client import OAuth
import secrets

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)  # 強力なシークレットキー

oauth = OAuth(app)
google = oauth.register(
    name='google',
    client_id='YOUR_CLIENT_ID',
    client_secret='YOUR_CLIENT_SECRET',
    server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
    client_kwargs={
        'scope': 'openid email profile',
        'code_challenge_method': 'S256',  # PKCEを有効化
    }
)

@app.route('/login')
def login():
    # IDトークンのリプレイ保護のためnonceを生成
    nonce = secrets.token_urlsafe(16)
    session['oauth_nonce'] = nonce

    # authlibがstateとPKCEを自動で処理
    redirect_uri = url_for('callback', _external=True)
    return google.authorize_redirect(redirect_uri, nonce=nonce)

@app.route('/callback')
def callback():
    # authlibがstateを自動で検証
    token = google.authorize_access_token()

    # nonceを使ってIDトークンを検証
    nonce = session.pop('oauth_nonce', None)
    user_info = google.parse_id_token(token, nonce=nonce)

    # 生のトークンではなく、必要な情報だけをセッションに保存
    session['user_id'] = user_info['sub']
    session['email'] = user_info['email']

    return redirect('/')

トークンの有効期間とリフレッシュ戦略

アクセストークンは短命に保つこと。15分から1時間が一般的な範囲——使えるほど長く、漏洩した場合の被害を抑えられるほど短く。リフレッシュトークンはバックグラウンドで静かに再認証を処理する。サーバーサイドに保存し、使用のたびにローテーションすること:

def refresh_access_token(refresh_token: str):
    response = requests.post(
        'https://oauth2.googleapis.com/token',
        data={
            'grant_type': 'refresh_token',
            'refresh_token': refresh_token,
            'client_id': CLIENT_ID,
            'client_secret': CLIENT_SECRET,
        }
    )
    data = response.json()
    # 新しいアクセストークンと、場合によっては新しいリフレッシュトークンを保存
    return data['access_token'], data.get('refresh_token', refresh_token)

守るべきいくつかのルール

  • クライアントシークレットをフロントエンドコードに含めない — サーバーの環境変数に置くこと、これが原則
  • 常にHTTPSを使う — 平文HTTPでのOAuthはトークンを通信中に露出させる
  • スコープの最小化 — 実際に必要な権限だけをリクエストする
  • トークンスコープの監査 — 最小スコープのアクセストークンが盗まれても、被害を限定できる
  • トークン失効の実装 — ログアウト時は認可サーバーで失効させること;セッションをクリアするだけでは不十分
def revoke_token(token: str):
    requests.post(
        'https://oauth2.googleapis.com/revoke',
        params={'token': token},
        headers={'Content-Type': 'application/x-www-form-urlencoded'}
    )

安全なOAuth実装とはどういうものか

チェックリストはシンプルだ:PKCEを使う、stateとnonceを検証する、IDトークンのすべてのクレームを確認する、トークンはHttpOnlyクッキーかサーバーサイドセッションに保存する、アクセストークンは短命に保つ。どれも複雑ではない。どれも重要だ。

上記の間違いは理論上の話ではない。本番アプリに実際に現れる——「クイックスタート」ガイドを信頼して正常系より先を読まなかった経験豊富な開発者が作ったものも含めて。セキュリティモデルを理解することが、本当に安全な実装と、誰かにテストされるまで安全に見える実装との違いを生む。

真夜中のSSH事件が教えてくれたのは、セキュリティの問題は警告メールを送ってこないということだ。認証については、誰かが代わりにテストする前に正しく実装しておこう。

Share: