以下の内容はhttps://syu-m-5151.hatenablog.com/entry/2026/01/11/064311より取得しました。


OAuth2認証をE2Eテストしたら、5つのバグが出てきた話

はじめに

認証が動いた。だがそれは始まりに過ぎなかった。

前回の記事では、Next.jsでOry Hydra認証を実装した。OAuth2認可コードフロー、Cookie管理、ID Token署名検証、マルチテナント認証について解説した。

前提知識: この記事は前回の記事の続編です。Next.jsでのOAuth2認証フロー実装を理解している前提で進めます。

Next.jsでOry Hydra認証を実装する ― マルチテナントSaaSでの実践 - じゃあ、おうちで学べる

今回は、実装した認証フローを検証する。Playwright MCPを使ったE2Eテスト、発見した5つのバグ、RBACの検証、そしてベストプラクティスとの比較までを一気に解説する。

Playwright MCPによるE2Eテスト

もう本当に10年くらい前は「E2Eテストなんて、デモ前に手動で確認すれば十分でしょ」と思っていた。仕事でフロントエンド書いたことなかったので…。今の自分から言わせてもらえば、それは個人の能力を過信している。あとはフロントエンドのテストの大変さを軽く見ている。

OAuth2フローのE2Eテストは手動では破綻する。複数のリダイレクト、Cookie管理、セッション状態の確認——これらを毎回手動で確認するのは、人間の注意力の限界を超えている。「今日は疲れていたから見落とした」で本番障害が起きるのは、個人の問題ではなく構造的な失敗だ。人間に頼らない仕組みを作る必要がある。

Claude CodeとPlaywright MCPの組み合わせ

Playwright MCPは、LLMがブラウザを直接操作できるModel Context Protocol(MCP)サーバーだ。Claude Codeと組み合わせることで、自然言語でE2Eテストを実行できる。

従来のPlaywrightとの違いは、スクリプトを書かずにテストできる点だ。

# セットアップ(プロジェクトごとに一度だけ)
claude mcp add --transport stdio playwright --scope project -- npx -y @playwright/mcp@latest

.mcp.jsonが生成される:

{
  "mcpServers": {
    "playwright": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@playwright/mcp@latest"]
    }
  }
}

実際のテスト実行例

Claude Codeで以下のように指示する:

Playwright MCPでOAuth2フローをE2Eテストしてください:
1. http://localhost:3001/ にアクセス
2. Sign Inをクリック
3. demo@example.com / password123 でログイン
4. Consentで Allow をクリック
5. ダッシュボードが表示されることを確認
6. スクリーンショットを取得

Claude Codeは以下のツールを順次実行する:

ステップ MCPツール 結果
1 browser_navigate ホームページ表示
2 browser_click (ref=e10) Hydra認可エンドポイントへリダイレクト
3 browser_fill_form ログインフォーム入力完了
4 browser_click (Sign In) Consent画面へリダイレクト
5 browser_click (Allow) トークン交換・フロントエンドへリダイレクト
6 browser_take_screenshot エビデンス取得

ARIA Snapshotの活用

Playwright MCPの特徴は、DOMではなくアクセシビリティツリーでページ構造を表現する点だ。各要素にはref=eXX形式の参照IDが付与される:

- banner:
  - navigation:
    - link "Sign In" [ref=e10] [cursor=pointer]:
      - /url: /api/auth/login

このref=e10を使ってクリック対象を指定する。セレクタの管理が不要になり、UIの変更に強いテストが書ける。

従来のE2Eテストとの比較

項目 従来のPlaywright Playwright MCP
テスト作成 スクリプト記述が必要 自然言語で指示
セレクタ管理 CSSセレクタ/XPath ARIA参照ID
リダイレクト追跡 手動でwait設定 自動追跡
デバッグ ログ/スクリーンショット 対話的に確認可能
再現性 高(スクリプト化) 中(LLMに依存)

Playwright MCPは「探索的テスト」に向いている。本番のCIには従来のPlaywrightスクリプトを使い、開発中の手動確認をPlaywright MCPで効率化する、という使い分けがよさそうだ。

E2Eテストで発見した5つのバグ

Playwright MCPシェルスクリプトによるE2Eテストを実行した結果、5つの重要なバグを発見・修正した。OAuth2+マルチテナント構成の複雑さを示す良い事例だ。

バグ1:CORS設定の欠如

症状:フロントエンド(localhost:3001)からバックエンド(localhost:3000)へのAPIリクエストがブロックされる

原因:Axumルーターにtower-httpのCorsLayerが設定されていなかった

修正src/main.rs):

use tower_http::cors::{Any, CorsLayer};

let app = Router::new()
    // ... routes ...
    .layer(
        CorsLayer::new()
            .allow_origin(Any)
            .allow_methods(Any)
            .allow_headers(Any),
    )

教訓:これは個人の注意力の問題ではない。フロントエンド・バックエンド分離構成では、CORSは「設定を忘れると動かない」構造になっている。チェックリストに入れる。プロジェクトテンプレートに含める。人間の記憶に頼らない仕組みを作る。「動かない」の原因がCORSだと気づくまでに時間がかかることがある。エラーメッセージが分かりにくいからだ。ブラウザのコンソールを見る習慣をつけるしかない。詳細はMDN: CORSを参照。

バグ2:Cookieパース時のJWTトークン切り詰め

症状:認証後のAPIリクエストで401エラーが発生

原因.split("=")[1]Cookieを取得すると、base64エンコードされたJWTの=パディング文字で切れてしまう

// ❌ 危険:JWTが途中で切れる
const token = document.cookie
  .split("; ")
  .find((row) => row.startsWith("auth_token="))
  ?.split("=")[1];  // "ory_at_abc...def=" → "ory_at_abc...def" で切れる

// ✅ 正しい:トークン全体を取得
const cookieRow = document.cookie
  .split("; ")
  .find((row) => row.startsWith("auth_token="));
const token = cookieRow ? cookieRow.substring("auth_token=".length) : null;

教訓:JWTは必ずbase64パディング(=)を含む可能性がある。文字列操作でトークンを扱う時は要注意。

バグ3:HydraトークンとJWTの不一致

症状:フロントエンドからのAPIリクエストで401エラー。curlでJWTを直接送ると成功する。

原因: - フロントエンドはHydra発行のアクセストークン(ory_at_...形式)を使用 - バックエンドは自前のJWTのみ対応していた

修正src/middleware/auth.rs):

// JWT検証を試み、失敗したらHydraイントロスペクションにフォールバック
let claims = match state.jwt.verify_access_token(token) {
    Ok(claims) => claims,
    Err(_) => {
        // Hydra Admin APIでトークンを検証
        let introspection = state.hydra.introspect_token(token).await?;
        // IntrospectionResponseからClaimsに変換
        Claims::from(introspection)
    }
};

教訓:OAuth2プロバイダー(Hydra)のトークンと自前JWTの両方をサポートするか、どちらか一方に統一するか、設計段階で決めておくべきだった。

バグ4:テナント抽出ミドルウェアの欠如

症状:テナントAPI/api/v1/tenant/*)で「No tenant context」エラー

原因tenant_apiルーターextract_tenantミドルウェアが適用されていなかった

修正src/main.rs):

let tenant_api = Router::new()
    // ... routes ...
    .layer(axum_middleware::from_fn_with_state(
        state.clone(),
        middleware::require_auth,
    ))
    .layer(axum_middleware::from_fn_with_state(
        state.clone(),
        middleware::extract_tenant,  // 追加
    ));

教訓ミドルウェアの適用漏れは見つけにくい。各ルートグループに必要なミドルウェアをリスト化しておくとよい。

バグ5:X-Tenant-Slugヘッダーの欠如

症状:ローカル開発環境でテナントが識別できない

原因: - 本番環境ではサブドメインtenant-a.example.com)でテナント識別 - ローカル開発ではlocalhost:3001のためサブドメインが使えない - フロントエンドがX-Tenant-Slugヘッダーを送信していなかった

修正frontend/src/lib/api.ts):

class ApiClient {
  private tenantSlug: string = "test-shop"; // デフォルトテナント

  private async fetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
    const headers: HeadersInit = {
      "Content-Type": "application/json",
      "X-Tenant-Slug": this.tenantSlug,  // 追加
      ...options.headers,
    };
    // ...
  }
}

教訓:マルチテナントのテナント識別は、サブドメイン方式とヘッダー方式の両方をサポートしておくとローカル開発が楽になる。

E2Eテスト実行結果

修正後のOAuth2フロー完全テスト:

=== DONADONA E2E Test v4 ===
1. Starting OAuth2 Flow...
   Login Challenge: LuAyzZfWTX03DnVcFC1xu0A-rntZcx...

2. Submitting Login (demo@example.com)...
   Consent Challenge obtained

3. Approving Consent...
   Final: http://localhost:3001/callback?code=ory_ac_d9jRSkWUb1YXm...

4. Token Exchange...
   Access Token: ory_at_dxBjsXjmRvMuTcSJercIxT_Kq2nUIR6OrUhdBEcEZIg...

5. Testing API Endpoints...
   Engineers Count: 3

6. Backend Verification:
   slug_from_header=Some("test-shop")
   Hydra token introspection successful: sub=Some("3767fa6a-...")

============================================
   E2E Test PASSED - All fixes verified!
============================================

複数アカウントでのRBAC検証

E2Eテストの最後に、異なるロールのアカウントでログインして、役割ベースアクセス制御(RBAC)が正しく機能しているかを検証した。

テスト結果のサマリー

以下は修正前のテスト結果だ。platform_adminがDashboardで403を返すなど、明らかな異常がある。詳細は後述する。

アカウント ロール ナビゲーションメニュー アクセス可能ページ
demo@example.com platform_admin 全メニュー Dashboard(403)、その他未テスト
manager@example.com manager 全メニュー Dashboard, Incidents, Projects, Engineers, Recruitment, Leaderboard
sato@example.com engineer 制限メニュー Dashboard, Incidents, Projects, Leaderboard
reporter@example.com reporter 制限メニュー すべてAccess Denied

発見1:フロントエンドとバックエンドのデータ不一致

ストアカウント一覧を表示するフロントエンドのホームページには、こう書いてあった:

Reporter | customer@example.com | Report incidents only

しかし実際にcustomer@example.comでログインすると、ヘッダーにはengineerと表示された。データベースとフロントエンドの表示が不一致だった。正しいReporterアカウントはreporter@example.comだった。

発見2:ロールごとのメニュー制御

Playwright MCPARIAスナップショットで、ロールごとのナビゲーションメニューの違いを確認できた。

Manager(manager@example.com)のメニュー:

- link "Dashboard" [ref=e10]
- link "Incidents" [ref=e11]
- link "Projects" [ref=e12]
- link "Engineers" [ref=e13]
- link "Recruitment" [ref=e14]
- link "Leaderboard" [ref=e15]

Engineer(sato@example.com)のメニュー:

- link "Dashboard" [ref=e10]
- link "Incidents" [ref=e11]
- link "Projects" [ref=e12]
- link "Leaderboard" [ref=e13]
# Engineers, Recruitmentが表示されない

発見3:Reporterの「何もできない」状態

reporter@example.comでログインして各ページにアクセスすると、すべて「Access Denied」が表示された。CLAUDE.mdによると、Reporterは「Report incidents only」という説明だったが、実際にはインシデントページすら見られない。

これは設計ミスだった。修正が必要だ。

フロントエンドとバックエンドの権限制御

問題の根本原因

Reporterロールがすべてのページでアクセス拒否されていた原因は、Next.jsのmiddleware.tsにあった:

// 修正前:ReporterはADMIN_PATHSに含まれていない
const ADMIN_PATHS = ["/dashboard", "/incidents", "/projects", "/engineers", "/recruitment", "/leaderboard"];

// ロールチェック:platform_admin, manager, engineerのみ許可
if (isAdminPath && !["platform_admin", "manager", "engineer"].includes(role)) {
  return NextResponse.redirect(new URL("/?error=unauthorized", request.url));
}

修正内容

// 修正後:ADMIN_PATHSから/incidentsを分離し、REPORTER_PATHSを新設
const ADMIN_PATHS = ["/dashboard", "/projects", "/engineers", "/recruitment", "/leaderboard"];
const REPORTER_PATHS = ["/incidents"];  // Reporter専用パス

// Reporter paths - reporter, engineer, manager, platform_admin can access
const isReporterPath = REPORTER_PATHS.some((p) => pathname.startsWith(p));
if (isReporterPath && !["platform_admin", "manager", "engineer", "reporter"].includes(role)) {
  return NextResponse.redirect(new URL("/?error=unauthorized", request.url));
}

// Admin paths - platform_admin, manager, engineer can access (not reporter)
const isAdminPath = ADMIN_PATHS.some((p) => pathname.startsWith(p));
if (isAdminPath && !["platform_admin", "manager", "engineer"].includes(role)) {
  return NextResponse.redirect(new URL("/?error=unauthorized", request.url));
}

これで権限階層が明確になった:

パス platform_admin manager engineer reporter
/tenants
/dashboard
/incidents
/projects

多層防御の実装

「フロントエンドで権限チェックすればいい」という意見と、「バックエンドだけでやるべき」という意見がある。どちらも正しく、どちらも不十分だ。

フロントエンドだけでは、攻撃者がcurlで直接APIを叩けば突破される。バックエンドだけでは、権限のないユーザーが画面を見てから「アクセス拒否」されるUXになる。答えは「両方やる」——多層防御と呼ばれる考え方だ。城の防壁が一重ではなく多重であるように、セキュリティも複数のレイヤーで守る。

フロントエンドのmiddleware.tsだけでは不十分だ。攻撃者はフロントエンドを完全にバイパスできる:

# フロントエンドを経由せずにAPIを直接叩ける
curl -s http://localhost:3000/api/v1/tenant/incidents \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Tenant-Slug: test-shop"

Rustバックエンド(Axum)では、権限制御が複数のレイヤーで行われている:

レイヤー1:require_auth(認証) - トークンが有効かどうかをチェック レイヤー2:extract_tenant(テナント抽出) - X-Tenant-Slugヘッダーからテナントを特定 レイヤー3:ハンドラー内のロールチェック - 特定の操作でロールをチェック

pub async fn assign_incident(/* ... */) -> Result<Json<IncidentWithStatus>, AppError> {
    let role = claims.get_role();
    if !role.can_manage_team() {
        return Err(AppError::Forbidden(
            "Only managers can assign incidents".to_string(),
        ));
    }
    // ...
}

多層防御が正解だ:

レイヤー 役割 目的
フロントエンド middleware 早期リダイレクト UX向上、不要なリクエスト削減
バックエンド require_auth 認証チェック 不正アクセス防止
バックエンド ハンドラー 操作ごとの認可 きめ細かい権限制御

ベストプラクティスとの比較

この実装が業界のベストプラクティスにどれだけ準拠しているかを評価する。

OWASP Top 10 2025との比較

OWASP Top 10 2025でBroken Access Controlが1位を維持している。

OWASP推奨事項 準拠状況 実装詳細
サーバーサイドでのアクセス制御 ✅ 準拠 Axumミドルウェアで全APIを保護
デフォルト拒否 ✅ 準拠 未認証リクエストは全て拒否
アクセス制御の再利用 ✅ 準拠 require_authを全ルートで共有
レコード所有権の検証 ⚠️ 部分的 テナント分離は実装、リソース単位は未実装
アクセス制御失敗のログ ⚠️ 部分的 tracingでログ出力、アラートは未実装
レート制限 ❌ 未実装 APIにレート制限なし
JWTの不正利用防止 ✅ 準拠 Hydraによるトークン検証
セキュリティヘッダ ⚠️ 部分的 HSTS, X-Frame-Options, X-Content-Type-Optionsの設定が必要
入力値バリデーション ✅ 準拠 サーバーサイドでバリデーション実施

Next.jsセキュリティガイドラインとの比較

Next.js Authentication Guideは、認証に関する重要な警告を含んでいる。

Next.js推奨事項 準拠状況 実装詳細
Middlewareだけに依存しない ✅ 準拠 バックエンドでも認証チェック
Data Access Layer (DAL)の使用 ⚠️ 部分的 サービス層で分離、専用DALなし
HttpOnly Cookieの使用 ⚠️ 部分的 auth_tokenは非HttpOnly

Next.jsチームは「middlewareは認証に安全ではない」と警告している。この多層防御は、CVE-2025-29927のようなmiddlewareバイパス脆弱性への対策にもなる。

RBACパターンとの比較

RBACベストプラクティス 準拠状況 実装詳細
バックエンドでのポリシー強制 ✅ 準拠 ハンドラー内でロールチェック
フロントエンドはUI適応のみ ✅ 準拠 メニュー表示/非表示で対応
権限キャッシュ ❌ 未実装 毎リクエストでHydra呼び出し
中央集権的ポリシー管理 ⚠️ 部分的 定義は分散している
ロール階層の明確化 ✅ 準拠 4段階のロール階層を定義

総合評価

評価軸 スコア コメント
OWASP Top 10 2025 7/10 基本的なアクセス制御は準拠、レート制限が不足
Next.js Security 8/10 多層防御を実装、HttpOnly Cookieが部分的
RBAC Patterns 7/10 フロントエンド/バックエンド分離は適切、権限定義が分散

強み:

  1. 多層防御の実装:フロントエンド + バックエンド + ハンドラーの3層
  2. テナント分離PostgreSQLスキーマレベルでのデータ分離
  3. OAuth2標準準拠:Ory Hydraによる標準的なOAuth2/OIDC実装
  4. トークン検証の二重化:自前JWT + Hydraイントロスペクションのフォールバック

弱み:

正直に言えば、見落としがあるかもしれない。セキュリティの評価は、「問題がない」ことを証明できない。見つかっていないだけかもしれない。だから、この記事を読んで「これで完璧だ」と思わないでほしい。OWASP Top 10のチェックリストを自分で回して、この記事で触れていない項目を確認してほしい。それを前提に、現時点で認識している弱みを列挙する。

  1. レート制限なしDoS攻撃への脆弱性
  2. 権限定義の分散:フロントエンドとバックエンドで定義が重複
  3. 権限キャッシュなし:毎リクエストでHydraに問い合わせ
  4. 監査ログの不足:アクセス制御失敗のアラート機能なし

改善ロードマップ

優先度順に改善すべき項目:

優先度 項目 工数 効果
レート制限の追加 DoS防止、OWASP準拠
監査ログとアラート インシデント検出
セキュリティヘッダの追加 HSTS, X-Frame-Options, X-Content-Type-Options
権限定義の一元化 保守性向上
権限キャッシュ(Redis) パフォーマンス向上
Cookie Prefix(__Host-)の導入 Cookie属性の強制
PKCE導入 認可コード横取り防止
HttpOnly Cookie XSS対策強化
// 改善案:tower-governor等でレート制限を追加
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};

let governor_conf = GovernorConfigBuilder::default()
    .per_second(10)
    .burst_size(50)
    .finish()?;

let app = Router::new()
    // ...
    .layer(GovernorLayer { config: governor_conf });

まとめ

OAuth2 + マルチテナントの認証システム実装を通じて学んだこと

  1. 「動く」と「正しく動く」は違う:ログインできても、APIが動くとは限らない。APIが動いても、全ロールで正しく動くとは限らない。全ロールで動いても、攻撃に耐えるとは限らない。5つのバグすべてが、「ログインできた」の後に発見された
  2. E2Eテストは必須:すべてユニットテストでは発見できなかった
  3. 多層防御が重要:フロントエンドだけ、バックエンドだけでは不十分
  4. 全ロールで検証する:「ログインできた」だけでは不十分
  5. ベストプラクティスとのギャップを把握する:何ができていて、何が不足しているかを明確にする

認証は地味だが重要だ。インシデント対応のように緊張感もないし、新機能開発のような達成感もない。でも、認証が崩れたときの被害は、他のどの機能障害よりも大きい。過去に見た事例では、セッション管理の不備で全ユーザーのデータが漏洩した。復旧に数ヶ月、信頼回復に1年以上かかった。

地味なものほど、丁寧にやる。例えば、この記事で示したE2Eテスト、全ロールでの検証、ベストプラクティスとの比較を、リリース前に必ず行う。それがインフラを支える人間の流儀だ。派手な仕事は誰でも丁寧にやる。地味な仕事を丁寧にやれるかどうかが、プロとアマチュアの違いだと思っている。

「ログインできる」は最低条件であり、「安全にログインできる」「快適にログインできる」「問題が起きたときに追跡できる」まで含めて、初めて「認証が実装できた」と言える。この認証実装は完成ではなく、継続的に改善していく起点だ。半年後、1年後に見直したとき、「あの時の判断は正しかったか」を検証できるように、今回の記事を残しておく。

次回予告

ここまでの4記事で、OAuth2認可サーバー(Hydra)+ 自前認証プロバイダー(Rust)+ フロントエンド(Next.js)の構成が完成した。E2Eテストも通り、RBACも検証できた。

しかし、レビューコメントが届いた。

「パスワードリセット機能は?」「MFA対応の予定は?」

全部、自分で実装するのか?——次回は、Ory Kratosを導入して認証機能を委譲する方法を解説する。

syu-m-5151.hatenablog.com

参考資料

E2Eテスト

Ory Hydra

セキュリティガイドライン

RBAC

Next.js

CORS




以上の内容はhttps://syu-m-5151.hatenablog.com/entry/2026/01/11/064311より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14