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


Next.jsでOry Hydra認証を実装する ― マルチテナントSaaSでの実践

はじめに

前回の記事では、RustでOry HydraのLogin/Consent Providerを実装した。5つのエンドポイント(GET/POST /login、GET/POST /consent、GET /logout)とHydra Admin APIの連携。Argon2idによるパスワードハッシュ、ユーザー列挙攻撃を防ぐテスト設計の話をした。

前提知識: この記事は前回の記事の続編です。OAuth2認可コードフローの基礎知識と、Ory HydraのLogin/Consent Providerの役割を理解している前提で進めます。

syu-m-5151.hatenablog.com

今回は、そのバックエンドと連携するフロントエンドをNext.js 15で実装する。なぜフロントエンドも自分で書くのか。認証フローを端から端まで把握しておきたいからだ。ちなみにフロントエンドは専門外なのである程度は許してほしいです。NextAuth.jsやAuth0のSDKを使えば楽だが、ブラックボックスのまま本番に出すのは怖い。何かが壊れたとき、「ライブラリの中で何が起きているかわからない」では障害対応で詰むことがある。もちろん、最終的なゴールは「理解した上でライブラリを使う」ことだ。車輪の再発明を推奨しているわけではない。

OAuth2/OIDCフローをブラウザ側でどう扱うか。Cookie管理の罠。マルチテナント環境での認証の複雑さ。実際に動かして気づいたことを記録する。

OAuth2認可コードフロー:フロントエンドから見た流れ

まず全体像を把握しておく。

┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Browser   │     │   Next.js   │     │ Rust Backend│     │  Ory Hydra  │
│  (User)     │     │  Frontend   │     │ (Provider)  │     │  (OAuth2)   │
└──────┬──────┘     └──────┬──────┘     └──────┬──────┘     └──────┬──────┘
       │                   │                   │                   │
       │ 1. Login Click    │                   │                   │
       │──────────────────>│                   │                   │
       │                   │                   │                   │
       │ 2. Redirect to    │                   │                   │
       │    /oauth2/auth   │                   │                   │
       │<──────────────────│                   │                   │
       │                   │                   │                   │
       │ 3. GET /oauth2/auth?client_id=...    │                   │
       │──────────────────────────────────────────────────────────>│
       │                   │                   │                   │
       │ 4. Redirect to /login?login_challenge=xxx                 │
       │<──────────────────────────────────────────────────────────│
       │                   │                   │                   │
       │ 5. GET /login                         │                   │
       │──────────────────────────────────────>│                   │
       │                   │                   │                   │
       │ 6. Login Form     │                   │                   │
       │<──────────────────────────────────────│                   │
       │                   │                   │                   │
       │ 7. POST /login (credentials)          │                   │
       │──────────────────────────────────────>│                   │
       │                   │                   │                   │
       │                   │                   │ 8. Accept Login   │
       │                   │                   │──────────────────>│
       │                   │                   │                   │
       │ 9. Redirect to /consent               │                   │
       │<──────────────────────────────────────│                   │
       │                   │                   │                   │
       │ ... Consent Flow ...                  │                   │
       │                   │                   │                   │
       │ 10. Redirect to /callback?code=xxx    │                   │
       │<──────────────────────────────────────────────────────────│
       │                   │                   │                   │
       │ 11. GET /callback │                   │                   │
       │──────────────────>│                   │                   │
       │                   │                   │                   │
       │                   │ 12. Exchange code for tokens          │
       │                   │──────────────────────────────────────>│
       │                   │                   │                   │
       │                   │ 13. Tokens (access, id, refresh)      │
       │                   │<──────────────────────────────────────│
       │                   │                   │                   │
       │ 14. Set Cookie &  │                   │                   │
       │     Redirect      │                   │                   │
       │<──────────────────│                   │                   │

このフローで重要なのは、フロントエンドは認証ロジックを持たないということだ。なぜか。フロントエンドのコードはユーザーのブラウザで動く。攻撃者は自由に改変できる。DevToolsを開けばJavaScriptは丸見えだし、リクエストも書き換えられる。認証ロジックをそこに置くということは、攻撃者に「好きに改ざんしていいですよ」と言っているようなものだ。

認証情報の検証はすべてRustバックエンド(Login Provider)で行う。フロントエンドの役割は:

  1. 認可エンドポイントへのリダイレクト開始
  2. コールバックで認可コードを受け取る
  3. 認可コードをトークンに交換
  4. トークンをCookieに保存
  5. 以降のAPI呼び出しでトークンを使用

Next.js App Routerでの実装

ディレクトリ構成

frontend/src/
├── app/
│   ├── layout.tsx
│   ├── page.tsx                    # ランディング
│   ├── dashboard/page.tsx          # 認証後のダッシュボード
│   ├── callback/page.tsx           # OAuth2コールバック
│   └── api/auth/
│       ├── login/route.ts          # ログイン開始
│       ├── callback/route.ts       # コールバック処理
│       └── logout/route.ts         # ログアウト
├── components/
│   └── shared/
│       └── Header.tsx
├── lib/
│   └── api.ts                      # APIクライアント
└── middleware.ts                   # 認証チェック

ログイン開始:認可エンドポイントへのリダイレクト

// app/api/auth/login/route.ts
import { NextResponse } from "next/server";
import crypto from "crypto";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const returnTo = searchParams.get("returnTo") || "/dashboard";

  // CSRF対策用のstate生成
  const state = crypto.randomBytes(16).toString("hex");

  // stateにリダイレクト先を含める(Base64エンコード)
  const stateWithReturn = `${state}:${Buffer.from(returnTo).toString("base64")}`;

  // Hydra認可エンドポイントへのURL構築
  const params = new URLSearchParams({
    client_id: process.env.OAUTH_CLIENT_ID!,
    response_type: "code",
    scope: "openid profile email",
    redirect_uri: `${process.env.NEXT_PUBLIC_URL}/callback`,
    state: stateWithReturn,
  });

  const authUrl = `${process.env.HYDRA_PUBLIC_URL}/oauth2/auth?${params}`;

  return NextResponse.redirect(authUrl);
}

stateパラメータは2つの役割を持つ:

  1. CSRF対策:ランダムな値を含めることで、攻撃者が生成したURLでのコールバックを防ぐ
  2. リダイレクト先の保持:認証後、元のページへ戻るためにreturnToをエンコードして含める

RFC 9700 (OAuth 2.0 Security Best Current Practice)では、stateパラメータによるCSRF対策が明記されている。認可サーバーがPKCEをサポートしていることを確認できるなら、PKCEでCSRF対策を兼ねることも可能だが、stateを使う方法が最も広くサポートされている。

cheatsheetseries.owasp.org

コールバック処理:トークン取得とCookie設定

ここが最も複雑な部分だ。

// app/api/auth/callback/route.ts
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  const body = await request.json();
  const { code, state } = body;

  // stateからリダイレクト先を取り出す
  const [, returnToBase64] = state.split(":");
  const returnTo = Buffer.from(returnToBase64, "base64").toString();

  // 認可コードをトークンに交換
  const tokenResponse = await fetch(
    `${process.env.HYDRA_PUBLIC_URL}/oauth2/token`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Authorization: `Basic ${Buffer.from(
          `${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}`
        ).toString("base64")}`,
      },
      body: new URLSearchParams({
        grant_type: "authorization_code",
        code,
        redirect_uri: `${process.env.NEXT_PUBLIC_URL}/callback`,
      }),
    }
  );

  if (!tokenResponse.ok) {
    const error = await tokenResponse.text();
    console.error("Token exchange failed:", error);
    return NextResponse.json(
      { error: "Token exchange failed" },
      { status: 401 }
    );
  }

  const tokens = await tokenResponse.json();

  // IDトークンをデコードしてユーザー情報を取得
  const idTokenPayload = JSON.parse(
    Buffer.from(tokens.id_token.split(".")[1], "base64").toString()
  );

  console.log("ID token decoded:", idTokenPayload);

  // レスポンスにCookieを設定
  const response = NextResponse.json({ success: true, returnTo });

  response.cookies.set("auth_token", tokens.access_token, {
    httpOnly: false,  // クライアントJSからアクセス可能に
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: tokens.expires_in,
    path: "/",
  });

  if (tokens.refresh_token) {
    response.cookies.set("refresh_token", tokens.refresh_token, {
      httpOnly: true,  // リフレッシュトークンはhttpOnlyで保護
      secure: process.env.NODE_ENV === "production",
      sameSite: "lax",
      maxAge: 30 * 24 * 60 * 60, // 30日
      path: "/",
    });
  }

  return response;
}

Cookie設定で学んだこと

最初、httpOnly: trueでアクセストークンを設定していた。OWASPのセッション管理チートシートによれば、これがセキュリティのベストプラクティスだ。しかし、クライアントサイドでAPIを呼び出す必要があった。

owasp.org

// クライアントコンポーネントでAPIを呼び出す
useEffect(() => {
  const token = document.cookie
    .split("; ")
    .find((row) => row.startsWith("auth_token="))
    ?.split("=")[1];

  if (token) {
    api.setToken(token);
  }
}, []);

httpOnly: trueだとdocument.cookieからアクセスできない。選択肢は2つ:

  1. アクセストークンをhttpOnly: falseにする - クライアントJSからアクセス可能
  2. Server Componentからのみ API を呼ぶ - httpOnlyのまま、サーバーサイドで処理

今回は1を選んだ。

「httpOnlyをfalseにするなんて、セキュリティの教科書に反している」——そう思う人がいるかもしれない。私もそう思った。OWASPのチートシートにも「httpOnly: trueにしろ」と書いてある。でも、教科書に書いてあることと、目の前のシステムで最善の選択は、必ずしも一致しない。

この判断には明確な理由がある。

まず、脅威モデルを整理する。httpOnlyの目的は「XSSトークンを盗まれること」を防ぐことだ。では、XSSが成功した場合に何が起きるか。攻撃者はユーザーのブラウザ上で任意のJavaScriptを実行できる。httpOnlyでトークンを保護しても、攻撃者はfetch('/api/user/delete', {credentials: 'include'})を実行できる。トークンを「盗む」ことはできなくても、「使う」ことはできる。

しかし、httpOnly: falseにすることで追加のリスクが生じる。トークンを読み取って攻撃者のサーバーに送信できるため、攻撃者は別のマシンからトークンを使用できる。httpOnly: trueなら被害はそのブラウザセッション内に限定されるが、falseなら攻撃者が任意の場所からAPIを叩ける。

つまり、httpOnlyは「トークンの窃取」を防ぐことで、XSS被害の範囲を限定する。しかし、XSS対策の本質は、そもそもXSSを発生させないことだ。CSP(Content Security Policy)、入力のサニタイズ、Reactの自動エスケープ——これらがXSS対策の本丸であり、httpOnlyは最後の砦にすぎない。

その上で、今回の判断基準は以下だ。

  • アクセストークンは短命(15分): 仮に窃取されても、15分で無効化される
  • リフレッシュトークンはhttpOnly: trueで保護: 長期間有効なトークンは絶対に保護する
  • クライアントサイドでのAPI呼び出しが必須: Server Componentだけでは実現できないリアルタイム機能がある

しかし、これはトレードオフだ。Auth0のToken Storageガイドでは、SPAの場合、インメモリストレージが最も安全とされている。将来的にはBFF(Backend for Frontend)パターンに移行し、トークンをサーバーサイドで完全に管理する構成を検討している。

Curity社のベストプラクティス記事では、JWTの安全な取り扱いについて詳しく解説されている。

owasp.org

ID Tokenの署名検証

なぜ署名検証が必要か

最初の実装では、ID Tokenを単純にBase64デコードしていた:

// ❌ 危険:署名検証なしのデコード
const payload = JSON.parse(
  Buffer.from(tokens.id_token.split(".")[1], "base64").toString()
);

これは動く。中身も読める。でも、これでは改ざんを検出できない

「tokenエンドポイントから直接取得しているから、改ざんされることはないのでは?」と思うかもしれない。確かに、バックエンドでtokenエンドポイントを呼び出し、その結果をそのまま使うなら、経路上で改ざんされるリスクは低い。しかし、問題は別のところにある。フロントエンドにトークンを渡す設計だと、ブラウザ側で別のトークンに差し替えられる可能性がある。また、マイクロサービス間でトークンを渡す際、悪意あるサービスが偽トークンを送る可能性もある。署名検証は「このトークンは本当にHydraが発行したものか」を確認する仕組みだ。

具体的に何が起きるか。攻撃者は以下のようなトークンを作成できる。

// 攻撃者が作成した偽のトークン
const fakePayload = {
  sub: "admin-user-id",  // 管理者のユーザーID
  email: "admin@example.com",
  role: "platform_admin",  // 権限昇格
  tenant_id: "target-tenant",  // 他テナントへのアクセス
  exp: 9999999999  // 無期限
};

const fakeToken = `eyJhbGciOiJub25lIn0.${btoa(JSON.stringify(fakePayload))}.`;

署名検証をしていなければ、このトークンは「有効」として受け入れられる。攻撃者は任意のユーザーになりすまし、任意の権限を持ち、任意のテナントにアクセスできる。認証システムが完全に無意味になる。

JWTは3つのパートで構成される:ヘッダー.ペイロード.署名。署名を検証しないということは、攻撃者が作った偽のトークンも受け入れてしまうということだ。これは「鍵のかかっていない金庫」と同じだ。中身は入っているが、誰でも開けられる。

OpenID Connect Core 1.0のID Token検証仕様では、以下の検証が必須とされている:

  1. 署名アルゴリズムの確認(alg)
  2. 発行者の検証(iss = Hydra URL)
  3. 対象者の検証(aud = クライアントID)
  4. 有効期限の確認(exp)
  5. 署名の検証(公開鍵で)

joseライブラリによる実装

joseライブラリを使うと、これらの検証を簡潔に実装できる。

npm install jose
// lib/auth.ts
import * as jose from "jose";

export interface IdTokenClaims {
  sub: string;
  aud: string | string[];
  iss: string;
  exp: number;
  iat: number;
  email?: string;
  role?: string;
  tenant_id?: string;
}

/**
 * ID Tokenの署名を検証し、クレームを返す
 * @see https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
 */
export async function verifyIdToken(idToken: string): Promise<IdTokenClaims> {
  const hydraUrl = process.env.HYDRA_PUBLIC_URL || "http://localhost:4444";
  const clientId = process.env.NEXT_PUBLIC_CLIENT_ID || "demo-client";

  // JWKSエンドポイントから公開鍵を取得
  // @see https://www.ory.sh/docs/hydra/reference/api#tag/jwk/operation/discoverJsonWebKeys
  const JWKS = jose.createRemoteJWKSet(
    new URL(`${hydraUrl}/.well-known/jwks.json`)
  );

  // 署名検証 + issuer/audience検証
  const { payload } = await jose.jwtVerify(idToken, JWKS, {
    issuer: hydraUrl,
    audience: clientId,
  });

  return payload as IdTokenClaims;
}

コールバックでの使用

// app/api/auth/callback/route.ts
import { verifyIdToken } from "@/lib/auth";

export async function POST(request: Request) {
  const { code } = await request.json();

  // トークン交換...
  const tokens = await exchangeCodeForTokens(code);

  // ✅ 署名検証付きでID Tokenをデコード
  try {
    const claims = await verifyIdToken(tokens.id_token);

    console.log("ID token verified:", {
      sub: claims.sub,
      email: claims.email,
      role: claims.role,
      iss: claims.iss,
    });

    // ユーザー情報をセッションに保存
    const user = {
      id: claims.sub,
      email: claims.email || "unknown",
      role: claims.role || "customer",
      tenant_id: claims.tenant_id,
    };

    // Cookie設定...
  } catch (error) {
    console.error("ID token verification failed:", error);
    return NextResponse.json(
      { error: "Token verification failed" },
      { status: 401 }
    );
  }
}

E2Eテストでの確認

実際にログインフローを実行して、署名検証が機能していることを確認した。verifyIdToken()の内部ログと、コールバックハンドラーのログが出力される:

ID token verified successfully: {
  sub: 'c128f3e7-5013-46b8-add2-fbe0e78bfec7',
  email: 'demo@example.com',
  role: 'platform_admin',
  iss: 'http://localhost:4444'
}
ID token verified and decoded: {
  sub: 'c128f3e7-5013-46b8-add2-fbe0e78bfec7',
  email: 'demo@example.com',
  role: 'platform_admin',
  tenant_id: undefined,
  iss: 'http://localhost:4444',
  aud: [ 'demo-client' ]
}
POST /api/auth/callback 200 in 609ms

verified successfullyと出力されれば、以下が確認できている:

  • JWKSエンドポイント(/.well-known/jwks.json)から公開鍵を取得できた
  • 署名が正しく検証された(RS256)
  • issがHydra URL(http://localhost:4444)と一致した
  • audにクライアントID(demo-client)が含まれていた
  • トークンが有効期限内だった

tenant_id: undefinedは、Platform Adminユーザーがテナントに所属していないため。通常のテナントユーザーでログインすると、ここにテナントIDが表示される。

開発環境でのフォールバック

開発環境ではJWKSエンドポイントにアクセスできない場合がある。その時は警告を出しつつ、署名なしデコードにフォールバックする:

try {
  const claims = await verifyIdToken(tokens.id_token);
  // 検証成功
} catch (verifyError) {
  console.warn("ID token verification failed, falling back to unsafe decode");
  console.warn("WARNING: Using unverified ID token claims. This is insecure!");

  // 開発環境のみ許容
  const unsafeClaims = decodeIdTokenUnsafe(tokens.id_token);
  // ...
}

本番環境では、このフォールバックを無効化すべきだ。

github.com

マルチテナント認証

JWTにテナント情報を含める

Ory HydraのConsent画面で、ユーザーのテナント情報をIDトークンに含める。ベストプラクティスとして、Login時にcontextに保存したユーザー情報をConsent時に取得する(DBルックアップを回避):

// Rustバックエンド側(Consent Provider)

// Best Practice: contextからユーザー情報を取得(DBルックアップ不要)
// Login時にUserContextとして保存した情報をここで復元
let user_context: Option<UserContext> = consent_request
    .context
    .as_ref()
    .and_then(|ctx| serde_json::from_value(ctx.clone()).ok());

let (user_email, user_role, user_tenant_id) = user_context
    .map(|ctx| (ctx.email, ctx.role, ctx.tenant_id))
    .unwrap_or_default();

// IDトークンにカスタムクレームを追加
let session = ConsentSession {
    id_token: serde_json::json!({
        "email": user_email,
        "role": user_role,
        "tenant_id": user_tenant_id,  // テナントIDを含める
    }),
};

hydra.accept_consent(&challenge, grant_scope, grant_audience, Some(session)).await?;

ここで重要なのは、user_emailuser_roleをDBから取得するのではなく、Login時にHydraのcontextに保存したUserContextから取得している点だ。これにより:

  1. Consent時のDBアクセスが不要になる
  2. Login時点のユーザー状態が保持される(整合性)
  3. パフォーマンスが向上する

フロントエンドでトークンをデコードすると、テナント情報が取得できる:

// IDトークンのペイロード例
{
  "aud": ["demo-client"],
  "email": "manager@example.com",
  "role": "manager",
  "tenant_id": "aa8d56f1-a083-439b-996a-4a7b73698dfb",
  "sub": "e5555555-5555-5555-5555-555555555555"
}

APIリクエストでのテナント分離

バックエンドAPI/api/v1/tenant/というプレフィックスでテナント固有のエンドポイントを提供:

/api/v1/tenant/incidents    # テナント内のインシデント
/api/v1/tenant/projects     # テナント内のプロジェクト
/api/v1/tenant/engineers    # テナント内のエンジニア

テナントIDはJWTから取得するため、URLにテナントIDを含める必要はない。これにより:

  • URLの推測による他テナントへのアクセス試行を防ぐ
  • テナントIDの改ざんを防ぐ(JWTは署名で保護されている)

なぜURLパスにテナントIDを含める方式が危険なのか、具体例で説明する。

# URLパス方式(危険)
GET /api/v1/tenants/tenant-123/incidents
GET /api/v1/tenants/tenant-456/incidents  ← tenant-123のユーザーがアクセスを試みる

この方式では、バックエンドで「リクエストしたユーザーがtenant-456に所属しているか」を毎回検証する必要がある。検証を忘れると、他テナントのデータが漏洩する。実際、この種のバグは「IDOR(Insecure Direct Object Reference)」として知られ、OWASPのトップ10に常に入る脆弱性だ。

# JWTクレーム方式(安全)
GET /api/v1/tenant/incidents
# JWTの中身: {"tenant_id": "tenant-123", ...}

この方式では、バックエンドはJWTからテナントIDを取得する。JWTは署名で保護されているため、ユーザーが改ざんできない。「どのテナントのデータを返すか」はJWTが決定し、URLは関与しない。URLパラメータとユーザー権限を照合する追加の検証が不要になるため、バグの入り込む余地が減る。

このアプローチはMicrosoft Azure Architecture Centerでも推奨されている。

ログアウト処理

OAuth2のログアウトは複雑だ。以下を考慮する必要がある:

  1. フロントエンドのCookie削除
  2. HydraのOAuth2セッション無効化
  3. バックエンドのセッション無効化(該当する場合)
// app/api/auth/logout/route.ts
export async function GET(request: Request) {
  const accessToken = request.cookies.get("auth_token")?.value;

  if (accessToken) {
    // 1. Hydraでトークンを無効化
    await fetch(`${process.env.HYDRA_PUBLIC_URL}/oauth2/revoke`, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Authorization: `Basic ${Buffer.from(
          `${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}`
        ).toString("base64")}`,
      },
      body: new URLSearchParams({
        token: accessToken,
      }),
    });

    // 2. Hydraのログインセッションも削除
    // (IDトークンからsubjectを取得して削除)
  }

  // 3. Cookieを削除してリダイレクト
  const response = NextResponse.redirect(new URL("/", request.url));
  response.cookies.delete("auth_token");
  response.cookies.delete("refresh_token");

  return response;
}

RP-Initiated Logout

OpenID ConnectにはRP-Initiated Logout 1.0という仕様がある。この仕様では、Relying Party(クライアントアプリケーション)からOpenID Providerに対してログアウトを要求する方法が定義されている。Hydraはこれをサポートしている。

www.ory.sh

// Hydraのログアウトエンドポイントを使う方法
const logoutUrl = new URL(`${process.env.HYDRA_PUBLIC_URL}/oauth2/sessions/logout`);
logoutUrl.searchParams.set("id_token_hint", idToken);
logoutUrl.searchParams.set("post_logout_redirect_uri", `${process.env.NEXT_PUBLIC_URL}/`);

return NextResponse.redirect(logoutUrl);

この方法だと、Hydraがログアウト処理を統括し、Login Providerの/logoutエンドポイントにリダイレクトしてくれる。

トラブルシューティング:実際に遭遇した問題

問題1:Cookie名の不一致

症状:ログイン後、ダッシュボードでAPIデータが取得できない

原因:コールバックで設定するCookie名と、各ページで読み取るCookie名が異なっていた

// コールバック
response.cookies.set("auth_token", ...);

// ダッシュボード(間違い)
.find((row) => row.startsWith("access_token="))

// 正しくは
.find((row) => row.startsWith("auth_token="))

教訓Cookie名は定数として一箇所で定義し、全体で共有する。

なぜこのミスが起きるのか。認証コードはコールバック処理から書き始め、ダッシュボードは後から書く。時間が空くと、最初に使った名前を忘れる。「書いた順番」と「読まれる順番」が異なるコードでは、定数化を最初に行うべきだ。

// lib/constants.ts
export const AUTH_COOKIE_NAME = "auth_token";
export const REFRESH_COOKIE_NAME = "refresh_token";

問題2:APIパスの構造

症状APIリクエストが404を返す

原因:テナントAPIのパスプレフィックスを間違えていた

// 間違い
fetch("/api/v1/incidents")  // 404

// 正しい
fetch("/api/v1/tenant/incidents")  // 200

教訓APIのベースパスはAPIクライアントクラスで管理する

class ApiClient {
  private baseUrl = process.env.NEXT_PUBLIC_API_URL;
  private tenantPath = "/api/v1/tenant";

  async getIncidents() {
    return this.request(`${this.tenantPath}/incidents`);
  }
}

問題3:トークン期限切れ

症状:しばらく操作しないとAPI呼び出しが失敗する

原因:アクセストークンの有効期限(15分)が切れていた

対策:リフレッシュトークンを使った自動更新

async request<T>(path: string, options?: RequestInit): Promise<T> {
  const response = await fetch(`${this.baseUrl}${path}`, {
    ...options,
    headers: {
      ...options?.headers,
      Authorization: `Bearer ${this.token}`,
    },
  });

  if (response.status === 401) {
    // トークンをリフレッシュして再試行
    await this.refreshToken();
    return this.request(path, options);
  }

  return response.json();
}

問題4:HydraのセッションとProviderのセッション

症状:ログアウト後、再度ログインしようとすると認証画面をスキップしてしまう

原因:Hydraのログインセッションが残っていた

Ory Hydraのドキュメントによると、HydraはLogin Providerでの認証成功を記憶している。skipフラグが立っている場合、ログイン画面をスキップする。これはSSO(シングルサインオン)の正しい動作だが、完全なログアウトを実装する際には注意が必要だ。

// Login Provider側
if login_request.skip {
    // 既にセッションがあるのでスキップ
    // Note: skip時はcontextが既に設定されているためNoneで良い
    let completed = hydra.accept_login(&challenge, &login_request.subject, false, None).await?;
    return Ok(Redirect::to(&completed.redirect_to));
}

完全なログアウトには、Hydraのセッションも削除する必要がある:

// ログアウト時にHydraのセッションも削除
await fetch(
  `${process.env.HYDRA_ADMIN_URL}/admin/oauth2/auth/sessions/login?subject=${userId}`,
  { method: "DELETE" }
);

エラーハンドリングのパターン

バックエンドから返されるエラーは統一された形式になっている:

{
  "error": "invalid_credentials",
  "error_description": "The provided credentials are invalid",
  "error_code": "AUTH_002"
}

フロントエンドではこれを適切に処理する:

async request<T>(path: string, options?: RequestInit): Promise<T> {
  const response = await fetch(`${this.baseUrl}${path}`, options);

  if (!response.ok) {
    const error = await response.json().catch(() => ({
      error: "unknown_error",
      error_description: "An unexpected error occurred",
    }));

    throw new ApiError(response.status, error);
  }

  return response.json();
}

class ApiError extends Error {
  constructor(
    public status: number,
    public body: { error: string; error_description: string; error_code?: string }
  ) {
    super(body.error_description);
  }
}

セキュリティチェックリスト

実装後に確認すべき項目。これは完璧なリストではない——セキュリティに完璧はない——が、最低限チェックすべきポイントをまとめた。

認証に関わるCookieの属性

  • [ ] HttpOnly属性: XSSの緩和策。クライアントJSからアクセス不要なCookieには必ず設定
  • [ ] SameSite属性: LaxもしくはStrictに設定。CSRF対策の基本。Laxの場合、GETリクエストで更新処理を行っていないか確認
  • [ ] Secure属性: HTTPS通信でのみCookieが送られるように。本番環境では必須
  • [ ] Domain属性: サブドメインへのCookie送信範囲を理解しているか。example.comCookiejobs.example.comにも送られる設定だと、他サブドメイン脆弱性がリスクになる
  • [ ] Cookie Prefix: Cookie名を__Host-で始めると、Domain属性が空でないCookieの指定を無視してくれる(参考: Cookie Prefixのバイパス

blog.tokumaru.org

レスポンスヘッダ

  • [ ] Strict-Transport-Security(HSTS): ブラウザにHTTPS接続を強制。max-age=31536000; includeSubDomains; preload
  • [ ] X-Frame-Options: DENYもしくはSAMEORIGINでクリックジャッキング対策。CSPのframe-ancestorsも検討
  • [ ] X-Content-Type-Options: nosniffを指定。MIMEタイプスニッフィング攻撃を防ぐ

認証フロー

  • [ ] stateパラメータでCSRF対策している
  • [ ] リフレッシュトークンはhttpOnlyで保護している
  • [ ] アクセストークンの有効期限は短く設定している(15分推奨)
  • [ ] ログアウト時にトークンを無効化している
  • [ ] メールアドレスの列挙ができないこと: ログイン画面やパスワード再設定画面で「このメールアドレスは登録されていません」のようなエラーを出さない
  • [ ] JWTの署名を検証している(バックエンド側)
  • [ ] テナント分離がJWTベースで行われている
  • [ ] 退会/メールアドレス変更などの重要操作で直前のログインを必須にしている: XSSセッションハイジャック発生時の緩和策

その他

まとめ

Next.jsでOry Hydra認証を実装する際の要点:

  1. OAuth2フローの理解:認可コードフローの各ステップでフロントエンドが何をすべきか把握する
  2. ID Token署名検証:JWKSを使って署名を検証し、issuer/audienceを確認する
  3. Cookie管理:httpOnly, Secure, SameSiteの設定を用途に応じて選択する
  4. マルチテナント:JWTにテナント情報を含め、APIトークンからテナントを識別する
  5. エラーハンドリング:OAuth2仕様に沿ったエラー形式を統一的に処理する
  6. ログアウト:Hydraのセッションとフロントエンドのセッション両方を考慮する

認証は「動いた」で終わりではない。Cookie名の不一致のような単純なミスから、セッション管理の複雑さまで、実際に動かして初めて見つかる問題が多い。

結局のところ、OAuth2は「誰かが決めた仕様に従う」ゲームだ。RFCを読み、OWASPを読み、Hydraのドキュメントを読む。自分で発明する余地は少ない。でも、それでいい。認証のような重要な仕組みを自己流で作るのは、傲慢だと思う。セキュリティの歴史は「賢い人が作ったものを、もっと賢い攻撃者が破る」の繰り返しだ。OAuth 1.0のセッション固定攻撃、JWTのalg=none脆弱性——仕様を作った人たちでさえ、穴を見落とす。自分がその歴史に新たな失敗を加える必要はない。先人の知恵に乗っかり、その上で自分のシステムに合った判断をする。それが現実的なアプローチだ。

前回のバックエンド実装でユーザー列挙攻撃を防ぐテストを書いたように、フロントエンドでも手動でのE2Eテストが重要だ。ログイン→操作→ログアウト→再ログイン。このサイクルを何度も試して、エッジケースを潰していく。

次回は、Playwright MCPを使ったE2Eテストの自動化と、テストで発見したバグについて解説する。

syu-m-5151.hatenablog.com

このブログが良ければ読者になったりnwiizoXGithubをフォローしてくれると嬉しいです。

おわりに

今日は社内で学生向けワークショップを担当した。終わった後、若い参加者が話しかけてきた。「ブログ読んでます」と言われた。

嬉しかった。嬉しかったが、すぐに釘を刺した。「あまり憧れないでくださいね」と。

憧れられるのがあまり得意ではない。偶像として崇拝されるのが苦手だし、偶像として振る舞って相手に応えるのも苦手だ。

それに、ブログで良いこと言っている人に若いうちから憧れすぎるのは良くない。自分がそうだったのでよく分かる。10代の頃、文章が上手くて考え方が明快な技術ブロガーを見つけて、「この人みたいになりたい」と思った。記事を読み漁った。でも、その人が実際にどんなコードを書いているかは知らなかった。ブログは編集された「ハイライト」にすぎない。裏側の泥臭い試行錯誤、失敗、妥協は見えない。数年後にそれを知ったとき、ちょっとがっかりした。がっかりした自分にもがっかりした。

若い技術者なら、現場に居る良い技術者に憧れてほしい。ブログを書く人ではなく。GitHubのコミット履歴を見てほしい。PRのレビューコメントを見てほしい。本番障害のポストモーテムを読んでほしい。そこに本当の技術者がいる。ブログの「正解」ではなく、コードの「試行錯誤」に学んでほしい。

正直に言えば、フロントエンドでの認証実装は想像以上に複雑だった。3年前の自分に言いたい。「Next.jsで認証?OAuth2知ってるし、すぐできるでしょ」と思っていた過去の自分に。そうじゃない。Cookieの属性一つでセキュリティモデルが変わる。ID Tokenの署名検証を省略した瞬間、認証システムの意味がなくなる。

OAuth2のフローは理解していたつもりだった。RFCも読んだ。でも、実際にNext.jsでCookieを扱い、ID Tokenの署名を検証し、マルチテナントのテナント分離を実装すると、「知っている」と「動かせる」の間には大きな溝があることを思い知らされた。RFCには「stateパラメータでCSRF対策」と書いてある。でも、実際にコードを書くと「stateはどこに保存する?」「検証はいつやる?」「不一致の場合のエラーメッセージは?」という判断が次々と必要になる。仕様書は「何をすべきか」は教えてくれるが、「どう実装すべきか」は教えてくれない。その溝を埋めるのは、結局、自分で書いて動かす経験しかない。

特にhttpOnlyの判断には時間を使った。OWASPのベストプラクティスを読み、Auth0のガイドを読み、それでも「これで正しいのか」という不安は消えない。セキュリティに100%の正解はない。トレードオフを理解し、判断し、記録する。それしかできることはない。

この記事を書いている人間も、悩みながら書いている。ブログに書かれている「正解」は、試行錯誤の結果を事後的に整理したものにすぎない。過程で何度も間違えている。それを知った上で、参考にしてもらえれば。

なんか総じてとても疲れた。でも、まあ、悪くない一日だった。

参考資料

Ory Hydra

OAuth2/OIDC仕様

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

Cookie属性

ライブラリ

Next.js




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

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