OAuth 2.0 フローの実装で『状態管理』が甘くなりやすい理由──本番環境のセッション固定攻撃対策

OAuth 2.0 の state パラメータが「単なる検証トークン」に矮小化される現場

OAuth 2.0 の認可コードフローを実装する際、多くのエンジニアが state パラメータの役割を「CSRF 対策のためのランダム文字列」くらいに考えがちです。仕様書にもそう書いてあるし、ライブラリもデフォルトで生成してくれるので、ついそこで思考が止まってしまう。

しかし現場では、この「単なる検証」という認識が、本番環境でセッション固定攻撃(session fixation attack)につながる実装の落とし穴を生み出しています。特に、複数の認可プロバイダを組み合わせたり、モバイルアプリとWebを並行運用したりする案件では、この甘さが顕在化しやすいのです。

今回は、実装段階で見落としやすい state 管理の設計ポイントを、検証を通じて整理します。

state パラメータの「本当の役割」を改めて整理する

OAuth 2.0 の state は、以下の2つの役割を持っています。

  1. CSRF 対策:認可サーバーからのコールバックが、ユーザーが開始した認可リクエストに対応するものであることを検証する
  2. セッション状態の紐付け:認可前のセッション(またはリクエスト)と、認可後のセッションを同一ユーザーのものとして結びつける

多くの実装は 1 番目だけに注力して、2 番目を軽視しています。ここが問題の根源です。

検証の前提条件と構成

以下のシナリオで検証しました。

  • 環境:Node.js + Express、認可サーバーは標準的な OAuth 2.0 実装を想定
  • 攻撃シナリオ:攻撃者が被害者のブラウザに特定の state 値を仕込み、認可完了後も被害者のセッションを乗っ取る
  • 比較観点:state の生成・保存・検証のタイミング、セッションの初期化タイミング、マルチデバイス環境での動作

よくある実装:state を生成して返すだけでは不十分

典型的な「不十分な」実装は、こんな形です。

// ❌ よくある不十分な実装
app.get('/auth/login', (req, res) => {
  const state = crypto.randomBytes(16).toString('hex');
  // state をセッションに保存
  req.session.oauthState = state;
  
  const authUrl = new URL('https://oauth-provider.example.com/authorize');
  authUrl.searchParams.append('client_id', CLIENT_ID);
  authUrl.searchParams.append('redirect_uri', REDIRECT_URI);
  authUrl.searchParams.append('state', state);
  authUrl.searchParams.append('scope', 'openid profile email');
  
  res.redirect(authUrl.toString());
});

app.get('/auth/callback', (req, res) => {
  const { code, state } = req.query;
  
  // state の検証(ここまでは正しい)
  if (state !== req.session.oauthState) {
    return res.status(400).send('Invalid state');
  }
  
  // トークンを取得して、ユーザー情報を取得
  // ...
  
  // ❌ 問題:認可完了後、セッションを初期化していない
  req.session.userId = userInfo.sub;
  res.redirect('/dashboard');
});

このコードの何が問題なのか。攻撃シナリオで見てみましょう。

  1. 攻撃者が /auth/login にアクセスし、state = “attacker-state-1” を取得する
  2. 攻撃者が、この state 値を被害者に仕込む(URLを送信するなど)
  3. 被害者がそのURLをクリックしてログインフローを開始する
  4. 被害者のセッションに oauthState = "attacker-state-1" が上書きされる
  5. 認可サーバーへリダイレクト
  6. 被害者が認可を許可すると、コールバックで state が検証される
  7. ここで state は一致するが、セッション ID 自体は被害者のもの
  8. 攻撃者が同じセッション ID を持っていれば、被害者になりすましできる

つまり、state の検証は「攻撃者が自分の認可リクエストに対応するレスポンスを受け取ったか」は確認できますが、「そのセッションがもともと被害者のものではなく、攻撃者が用意したものではないか」までは検証できていないのです。

正しい実装:認可完了時にセッションを再生成する

セッション固定攻撃を防ぐには、認可完了後にセッション ID を再生成する必要があります。

// ✅ セッション固定攻撃対策を含む実装
app.get('/auth/login', (req, res) => {
  const state = crypto.randomBytes(16).toString('hex');
  const nonce = crypto.randomBytes(16).toString('hex'); // OpenID Connect の場合
  
  // state と nonce を保存
  req.session.oauthState = state;
  req.session.oauthNonce = nonce;
  // ログイン前のセッション ID を記録(検証用)
  req.session.preAuthSessionId = req.sessionID;
  
  const authUrl = new URL('https://oauth-provider.example.com/authorize');
  authUrl.searchParams.append('client_id', CLIENT_ID);
  authUrl.searchParams.append('redirect_uri', REDIRECT_URI);
  authUrl.searchParams.append('state', state);
  authUrl.searchParams.append('nonce', nonce);
  authUrl.searchParams.append('scope', 'openid profile email');
  
  res.redirect(authUrl.toString());
});

app.get('/auth/callback', (req, res) => {
  const { code, state } = req.query;
  
  // state の検証
  if (!req.session.oauthState || state !== req.session.oauthState) {
    return res.status(400).send('Invalid state');
  }
  
  // トークン取得と ID トークン検証
  const tokenResponse = await exchangeCodeForToken(code);
  const idToken = verifyIdToken(tokenResponse.id_token, {
    nonce: req.session.oauthNonce
  });
  
  // ✅ 重要:セッション ID を再生成(セッション固定攻撃対策)
  const previousSessionId = req.sessionID;
  req.session.regenerate((err) => {
    if (err) {
      return res.status(500).send('Session error');
    }
    
    // 新しいセッションにユーザー情報を保存
    req.session.userId = idToken.sub;
    req.session.email = idToken.email;
    req.session.authenticatedAt = Date.now();
    
    // ✅ 古いセッションデータをクリア
    delete req.session.oauthState;
    delete req.session.oauthNonce;
    delete req.session.preAuthSessionId;
    
    res.redirect('/dashboard');
  });
});

session.regenerate() は、古いセッション ID を無効化し、新しいセッション ID を生成します。これにより、攻撃者が事前に仕込んだセッション ID は無効になり、被害者のセッションのみが有効な状態になります。

マルチデバイス・マルチプロバイダ環境での追加考慮点

実際の案件では、単一の認可プロバイダだけでなく、複数のプロバイダ(Google、GitHub、企業 OIDC など)を組み合わせることが多いです。その場合、state 管理はさらに複雑になります。

状態の一貫性を保つ設計

// ✅ マルチプロバイダ対応の state 管理
app.get('/auth/login/:provider', (req, res) => {
  const { provider } = req.params;
  
  // 各プロバイダの設定を取得
  const providerConfig = OAUTH_PROVIDERS[provider];
  if (!providerConfig) {
    return res.status(400).send('Unknown provider');
  }
  
  // state にプロバイダ情報とタイムスタンプを含める
  const statePayload = {
    provider,
    random: crypto.randomBytes(16).toString('hex'),
    timestamp: Date.now()
  };
  const state = Buffer.from(JSON.stringify(statePayload)).toString('base64');
  
  // セッションに保存
  req.session.oauthState = state;
  req.session.oauthProvider = provider;
  
  // リダイレクト
  const authUrl = buildAuthUrl(providerConfig, state);
  res.redirect(authUrl);
});

app.get('/auth/callback', (req, res) => {
  const { code, state } = req.query;
  
  // state を検証
  if (!req.session.oauthState || state !== req.session.oauthState) {
    return res.status(400).send('Invalid state');
  }
  
  // state からプロバイダ情報を復号
  const statePayload = JSON.parse(
    Buffer.from(state, 'base64').toString('utf-8')
  );
  
  // プロバイダの一貫性を確認
  if (statePayload.provider !== req.session.oauthProvider) {
    return res.status(400).send('Provider mismatch');
  }
  
  // state の有効期限を確認(例:5分以内)
  if (Date.now() - statePayload.timestamp > 5 * 60 * 1000) {
    return res.status(400).send('State expired');
  }
  
  // 以降、トークン取得とセッション再生成...
});

このアプローチにより、以下が実現できます。

  • プロバイダの検証:コールバックが正しいプロバイダから来たものか確認
  • タイムスタンプ検証:古い state の再利用を防止
  • リプレイ攻撃対策:同じ state の二重使用を検出

実装時に追加で確認すべきポイント

実務投入の前に、以下を確認してください。

1. セッション保存先の安全性

Express のデフォルト(メモリ保存)では本番運用できません。Redis や MongoDB など、暗号化されたストレージを使用してください。

2. HTTPS の強制

state パラメータは URL に含まれるため、HTTPS なしでは傍受されます。本番環境では必須です。

セッション Cookie に `Sam