はじめに
認証が動いた。だがそれは始まりに過ぎなかった。
前回の記事では、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 MCPのARIAスナップショットで、ロールごとのナビゲーションメニューの違いを確認できた。
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 | フロントエンド/バックエンド分離は適切、権限定義が分散 |
強み:
- 多層防御の実装:フロントエンド + バックエンド + ハンドラーの3層
- テナント分離:PostgreSQLスキーマレベルでのデータ分離
- OAuth2標準準拠:Ory Hydraによる標準的なOAuth2/OIDC実装
- トークン検証の二重化:自前JWT + Hydraイントロスペクションのフォールバック
弱み:
正直に言えば、見落としがあるかもしれない。セキュリティの評価は、「問題がない」ことを証明できない。見つかっていないだけかもしれない。だから、この記事を読んで「これで完璧だ」と思わないでほしい。OWASP Top 10のチェックリストを自分で回して、この記事で触れていない項目を確認してほしい。それを前提に、現時点で認識している弱みを列挙する。
- レート制限なし:DoS攻撃への脆弱性
- 権限定義の分散:フロントエンドとバックエンドで定義が重複
- 権限キャッシュなし:毎リクエストでHydraに問い合わせ
- 監査ログの不足:アクセス制御失敗のアラート機能なし
改善ロードマップ
優先度順に改善すべき項目:
| 優先度 | 項目 | 工数 | 効果 |
|---|---|---|---|
| 高 | レート制限の追加 | 小 | 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 + マルチテナントの認証システム実装を通じて学んだこと
- 「動く」と「正しく動く」は違う:ログインできても、APIが動くとは限らない。APIが動いても、全ロールで正しく動くとは限らない。全ロールで動いても、攻撃に耐えるとは限らない。5つのバグすべてが、「ログインできた」の後に発見された
- E2Eテストは必須:すべてユニットテストでは発見できなかった
- 多層防御が重要:フロントエンドだけ、バックエンドだけでは不十分
- 全ロールで検証する:「ログインできた」だけでは不十分
- ベストプラクティスとのギャップを把握する:何ができていて、何が不足しているかを明確にする
認証は地味だが重要だ。インシデント対応のように緊張感もないし、新機能開発のような達成感もない。でも、認証が崩れたときの被害は、他のどの機能障害よりも大きい。過去に見た事例では、セッション管理の不備で全ユーザーのデータが漏洩した。復旧に数ヶ月、信頼回復に1年以上かかった。
地味なものほど、丁寧にやる。例えば、この記事で示したE2Eテスト、全ロールでの検証、ベストプラクティスとの比較を、リリース前に必ず行う。それがインフラを支える人間の流儀だ。派手な仕事は誰でも丁寧にやる。地味な仕事を丁寧にやれるかどうかが、プロとアマチュアの違いだと思っている。
「ログインできる」は最低条件であり、「安全にログインできる」「快適にログインできる」「問題が起きたときに追跡できる」まで含めて、初めて「認証が実装できた」と言える。この認証実装は完成ではなく、継続的に改善していく起点だ。半年後、1年後に見直したとき、「あの時の判断は正しかったか」を検証できるように、今回の記事を残しておく。
次回予告
ここまでの4記事で、OAuth2認可サーバー(Hydra)+ 自前認証プロバイダー(Rust)+ フロントエンド(Next.js)の構成が完成した。E2Eテストも通り、RBACも検証できた。
しかし、レビューコメントが届いた。
「パスワードリセット機能は?」「MFA対応の予定は?」
全部、自分で実装するのか?——次回は、Ory Kratosを導入して認証機能を委譲する方法を解説する。
参考資料
E2Eテスト
- Playwright MCP - LLMがブラウザを操作するためのMCPサーバー
- Model Context Protocol (MCP) - LLMと外部ツールを接続するプロトコル
Ory Hydra
- Ory Hydra Documentation - Ory Hydra公式ドキュメント
- Token Introspection - トークンイントロスペクションAPI
- Login Flow - ログインフローの概念
- Consent Flow - 同意フローの概念
- OAuth2 Token Endpoint - トークンエンドポイントAPIリファレンス
- OAuth2 Revoke Token - トークン失効API
- JWKS Endpoint - 公開鍵配信エンドポイント
セキュリティガイドライン
- OWASP Top 10 2025 - Broken Access Control - アクセス制御の脆弱性
- OWASP Authorization Cheat Sheet - 認可チートシート
- OWASP Access Control Cheat Sheet - アクセス制御チートシート
- OWASP OAuth2 Cheat Sheet - OAuth2セキュリティチートシート
- Auth0 Token Storage - トークンストレージのベストプラクティス
- RFC 9700 - OAuth 2.0 Security Best Current Practice - OAuth2セキュリティBCP
RBAC
- Oso: RBAC Role Based Access Control
- LogRocket: Choosing the best access control model for frontend
- Leapcell: Implementing Robust RBAC Across Backend Frameworks