# 複数のAIツールを束ねる認証ハブを自作する：BFF × OIDC × バックチャネルログアウト（PKCE必須・PII暗号化・監査ログ）

> 性質の異なる複数のAIツールを単一SSOで束ねる社内プラットフォームの認証ハブ（BFF）を、実コードを唯一の真実源として解剖します。自作OIDCプロバイダ、per-tool audienceに絞った短命JWT、PKCE S256必須、URLにトークンを載せない自動POST、HMAC署名付きバックチャネルログアウト、AES-256-GCMによるPII暗号化と監査ログまでを実装レベルで解説。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: 認証基盤, OIDC, Next.js, TypeScript, アーキテクチャ設計, セキュリティ
- URL: https://tomodahinata.com/blog/multi-tool-saas-oidc-auth-hub-bff

## 要点

- 各ツールに認証を実装させず、アイデンティティの責務をBFF（認証ハブ）へ完全集約する
- PKCE S256は「任意」にせず必須にし、認可コードは単回使用・短寿命でDB保存する
- トークンは用途別に短命化（アクセス10分・ID1時間）し、ツールへの受け渡しはURLでなく自動POSTで行う
- ログアウト・権限変更はHMAC署名付き・冪等・リトライ付きのバックチャネルイベントで疎結合のまま全社へ伝播する
- PIIはAES-256-GCMで暗号化し、検索は復号せずHMACトークンで引いて秘匿性と検索性を両立する

---

社内向けのAIプラットフォームを作っていると、ほぼ必ず同じ壁にぶつかります。**「ツールが増えるたびに、認証・権限・ログアウトがバラバラに増殖していく」**という壁です。

音声合成ツール、テロップ校正ツール、生成AIの考査支援ツール……それぞれが独立したアプリとして育つのは健全です。けれど、ユーザーから見れば「一度ログインしたら全部使えてほしい」し、運営から見れば「退職者を1箇所で締め出したい」「ツールごとに権限を変えたい」。この2つの要求を同時に満たすのが**認証ハブ（BFF: Backend for Frontend）**の役割です。

本記事は、国内大手放送事業者向けに構築した社内AIプラットフォームの認証ハブを題材に、その設計を実装レベルで解説します。守秘義務のため、固有名詞・ドメイン・プロジェクトIDは伏せ、コードは構造を保ったまま匿名化していますが、**設計判断と仕組みはすべて実プロダクトのもの**です。

> **この記事のルール**：唯一の真実源は実コードです。「こうあるべき」という一般論ではなく、「実際にこう作って本番で動いている」という設計判断だけを書きます。一方で、利用者数やコスト削減額といった事業数値はクライアントの実データが必要なため扱いません。

---

## 1. なぜ「各ツールに認証を実装させない」のか

最初の、そして最も重要な設計判断はこれです。**ツール側に認証ロジックを一切持たせない。** アイデンティティに関する責務をBFF（認証ハブ）へ完全に集約します。

各ツールが自前で OAuth クライアントを実装すると、次の問題が起きます。

- **品質のばらつき**：あるツールは PKCE を使い、別のツールは state 検証を忘れる。セキュリティの最弱点がプラットフォーム全体の強度になる。
- **ログアウトが伝播しない**：ツールAでログアウトしても、ツールBのセッションが生き残る。退職者の即時失効ができない。
- **権限変更が反映されない**：管理画面で権限を落としても、各ツールが古いトークンを掴んだまま動き続ける。

そこで採った構成が「BFFを唯一のアイデンティティプロバイダ（IdP）にする」というものです。全体像はこうなります。

```text
[社員ブラウザ]
   │  ① Google Workspace SSO でBFFにログイン（NextAuth.js v5 / Identity Platform）
   ▼
[BFF（認証ハブ）]── OIDCプロバイダ ──┐
   │  ② /api/oauth/authorize（PKCE S256 必須）
   │  ③ /api/oauth/token → ツールごとの短命JWTを発行
   │                         （aud = ツールID, 寿命 10分）
   │  ④ /api/auth/tool-callback（自動POSTフォームでトークンを渡す）
   ▼
[各AIツール（音声合成 / テロップ校正 / 考査支援 …）]
   ▲  ⑤ 受け取ったJWTの aud / 署名 / 失効を検証して認証
   │
[BFF]── バックチャネル ──> 各ツール
        ⑥ ログアウト・権限変更を HMAC署名付きで非同期配信
```

ポイントは、**ツールが知っているのは「自分宛ての短命トークンの検証方法」だけ**ということです。ユーザーDB・SSO・パスワード・MFA……アイデンティティの本体はすべてBFFに閉じています。ツールは疎結合のまま、全社横断のログイン・ログアウトに追従できます。

---

## 2. OIDC認可エンドポイント：PKCE S256 を「任意」にしない

BFFは小さなOIDCプロバイダとして振る舞います。認可エンドポイント（`/api/oauth/authorize`）の肝は、**PKCE（Proof Key for Code Exchange）を必須にする**ことです。

PKCE は元々モバイル/SPA向けの仕様ですが、社内のサーバサイドWebでも必須にします。理由は単純で、「認可コードが何らかの経路で漏れても、`code_verifier` を持たない攻撃者はトークンに交換できない」状態を、設定漏れの余地なく保証したいからです。

```ts
// app/api/oauth/authorize/route.ts（匿名化・抜粋）
export async function GET(req: NextRequest) {
  const params = AuthorizeQuery.parse(/* Zodで検証 */);

  // PKCE は「あれば使う」ではなく「無ければ拒否」
  if (params.code_challenge_method !== "S256" || !params.code_challenge) {
    return oauthError("invalid_request", "PKCE S256 is required");
  }

  // 登録済みツールだけを許可（aud = client_id をDBで照合）
  const tool = await registeredTools.findByAudience(params.client_id);
  if (!tool || !tool.isActive) {
    return oauthError("unauthorized_client");
  }
  // redirect_uri はツール登録時のホワイトリストと完全一致
  if (!tool.allowedRedirectUris.includes(params.redirect_uri)) {
    return oauthError("invalid_request", "redirect_uri mismatch");
  }

  // 認可コードは単回使用・短寿命でDB保存（code_challenge も一緒に保存）
  const code = await authorizationCodes.issue({
    sub: session.userId,
    aud: params.client_id,
    codeChallenge: params.code_challenge,
    nonce: params.nonce,
    expiresInSec: 600,
  });
  return redirectWithCode(params.redirect_uri, code, params.state);
}
```

ここで効いている設計判断は3つです。

1. **`client_id`（= audience）はDB登録制**。野良ツールがトークンを要求できない。
2. **`redirect_uri` は登録ホワイトリストと完全一致**。オープンリダイレクタを潰す。
3. **認可コードは単回使用・短寿命でDB保存**し、`code_challenge` を紐づける。次のトークンエンドポイントで `code_verifier` と突き合わせる。

---

## 3. トークンエンドポイント：寿命は「短く・用途別に」

トークンエンドポイント（`/api/oauth/token`）は、認可コードを検証して**ツール専用の短命JWT**を発行します。設計上の決め事はこうです。

| トークン | 寿命 | 役割 |
| --- | --- | --- |
| アクセストークン | **10分** | API呼び出しの認可。短命にして漏洩時の被害窓を最小化 |
| IDトークン | **1時間** | ユーザー identity の表明（`sub` / `aud` / `roles`） |
| 認可コード | **10分・単回** | code→token 交換専用 |

```ts
// app/api/oauth/token/route.ts（匿名化・抜粋）
const ACCESS_TOKEN_EXPIRY_SEC = 600;   // 10分
const ID_TOKEN_EXPIRY_SEC = 3600;      // 1時間

export async function POST(req: NextRequest) {
  const body = await parseForm(req);
  if (body.grant_type !== "authorization_code") {
    return oauthError("unsupported_grant_type");
  }

  const auth = await authorizationCodes.consume(body.code); // 単回使用：消費したら無効化
  if (!auth || auth.expired) return oauthError("invalid_grant");

  // PKCE 検証：S256(code_verifier) === 保存した code_challenge
  const challenge = base64url(sha256(body.code_verifier));
  if (!timingSafeEqual(challenge, auth.codeChallenge)) {
    return oauthError("invalid_grant", "PKCE verification failed");
  }

  // ツールに対する実効ロールを解決（後述：Redisキャッシュ）
  const role = await roleResolver.getRoleForAudience(auth.sub, auth.aud);
  if (role === "NONE") return oauthError("access_denied");

  const claims = {
    sub: auth.sub,
    aud: auth.aud,                 // ← 必ず単一ツール宛て
    sid: auth.sid,                 // デバイス単位のセッションID
    sid_gen: auth.sidGeneration,   // 失効世代
    ent_ver: auth.entitlementsVersion, // 権限バージョン
    roles: [role],
  };
  return json({
    access_token: signJwt(claims, ACCESS_TOKEN_EXPIRY_SEC),
    id_token: signJwt({ ...claims, nonce: auth.nonce }, ID_TOKEN_EXPIRY_SEC),
    token_type: "Bearer",
    expires_in: ACCESS_TOKEN_EXPIRY_SEC,
  });
}
```

クレームに `sid`（デバイス単位のセッションID）、`sid_gen`（失効世代）、`ent_ver`（権限バージョン）を載せているのが後段の伏線です。これらが「特定デバイスだけのログアウト」「権限変更の検知」を可能にします。

> **アルゴリズムの選択について**：本プロダクトは社内・同一信頼境界のサービス間で対称鍵（HMAC / HS256）を用いています。発行者と検証者が同じ運営下にあり、共有シークレットを Secret Manager で安全に配布できるためです。第三者にトークン検証を委ねる構成（例：外部パートナーのツール）に拡張するなら、非対称鍵（RS256 + JWKS公開）へ切り替えます。鍵方式は「誰が検証するか」で決まる、という原則です。

---

## 4. トークンの受け渡し：URLに載せない

地味ですが効くのがここです。OIDCの教科書的なフローは認可コードをクエリ文字列で返しますが、**最終的なトークンをツールへ渡すとき、URLに載せてはいけません。**URLはブラウザ履歴・Refererヘッダ・プロキシログ・アクセスログに残るからです。

そこで `tool-callback` は、トークンを**自動送信するHTMLフォーム（POST）**で渡します。さらに、リダイレクト先オリジンの改ざんを防ぐため、**オリジンのHMAC署名**を同梱します。

```ts
// app/api/auth/tool-callback/route.ts（匿名化・抜粋）
// redirectBase（戻り先オリジン）を署名し、改ざんを検知可能にする
const redirectBaseSig = createHmac("sha256", SHARED_SECRET)
  .update(redirectBase)
  .digest("hex");

return new Response(
  `<!doctype html><html><body onload="document.forms[0].submit()">
     <form method="POST" action="${escapeHtml(redirect)}">
       <input type="hidden" name="token" value="${escapeHtml(token)}">
       <input type="hidden" name="redirectBase" value="${escapeHtml(redirectBase)}">
       <input type="hidden" name="redirectBaseSig" value="${redirectBaseSig}">
       <input type="hidden" name="sid" value="${escapeHtml(sid)}">
     </form>
   </body></html>`,
  { headers: { "content-type": "text/html; charset=utf-8" } },
);
```

この形には副次的なメリットもあります。**URLの長さ制限を受けない**こと。権限情報がリッチになっても、フォームボディなら問題なく運べます。受け取り側のツールは `redirectBaseSig` を再計算して一致を確認し、署名が合わなければトークンを破棄します。

---

## 5. バックチャネルログアウト：疎結合のまま「全社で締め出す」

認証ハブの真価が問われるのがログアウトです。ユーザーがログアウトしたとき、あるいは管理者が権限を剥奪したとき、**すべてのツールのセッションを確実に無効化**しなければなりません。けれどツールは疎結合に保ちたい。この緊張関係を、署名付き・リトライ付き・重複排除付きの**バックチャネルイベント**で解きます。

```ts
// lib/backchannel/dispatch.ts（匿名化・抜粋）
async function dispatch(event: BackchannelEvent, tool: RegisteredTool) {
  const body = JSON.stringify(event); // 例: { type: "SID_REVOKED", sid, ts }
  const sig = createHmac("sha256", tool.sharedSecret)
    .update(`${event.ts}.${body}`)   // タイムスタンプ込みで署名（リプレイ対策）
    .digest("hex");

  // 一意制約 (eventId, toolId) で「同じイベントを二度処理しない」を保証
  const log = await backchannelLog.create({ eventId: event.id, toolId: tool.id });

  // 指数バックオフで最大3回（恒久失敗は打ち切ってアラート）
  for (const delayMs of [0, 1000, 5000, 30000]) {
    if (delayMs) await sleep(delayMs);
    const res = await fetch(tool.backchannelEventUrl, {
      method: "POST",
      headers: { "x-signature": sig },
      body,
    });
    if (res.ok) return backchannelLog.markDelivered(log.id);
  }
  await backchannelLog.markFailed(log.id); // 監視で検知 → 手動リカバリ
}
```

設計上のキモは3点です。

- **HMAC署名 + タイムスタンプ**：ツールは「本当にBFFが出したイベントか」を検証でき、リプレイも弾ける。
- **`(eventId, toolId)` の一意制約**：ネットワーク再送が来ても、ツール側は二重処理しない（冪等）。
- **DB追跡 + バックオフリトライ**：一時障害は自動で吸収し、恒久失敗だけをアラートに上げる。

ツール側は受け取った `SID_REVOKED` で該当セッションを `REVOKED` に落とすだけ。**ツールはBFFのユーザーモデルを知らないまま**、全社横断のログアウトに参加できます。

そして、トークンに載せた `sid_gen` / `ent_ver` がここで効きます。バックチャネルが届く前でも、ツールは検証時に「自分が持っている `ent_ver` が古くないか」をBFFに問い合わせる（あるいはキャッシュ済みの最新版と照合する）ことで、権限変更を早期に検知できます。**プッシュ（バックチャネル）とプル（バージョン照合）の二段構え**で、失効の取りこぼしを防ぎます。

---

## 6. ロール解決はキャッシュする——ただし「迂回口」を用意する

ユーザーがあるツールに対して持つ実効ロールの解決は、意外と重い処理です。Google Workspace のグループ所属や、外部ユーザーグループの権限テーブルを横断するからです。トークン発行のたびにこれを全部引くと、ログインが遅くなります。

そこで Redis に**30分TTLでロールをキャッシュ**します。ただし、権限変更の直後だけは古い値を返したくない。だから**キャッシュを意図的に迂回する経路**を必ず用意します。

```ts
// lib/auth/role-resolver.ts（匿名化・抜粋）
async function getRoleForAudience(userId: string, aud: string): Promise<Role> {
  const key = `tool-role:${userId}:${aud}`;
  const cached = await cache.get<Role>(key);
  if (cached) return cached;

  const role = await resolveFromGroups(userId, aud); // 重い：グループ横断
  await cache.set(key, role, 30 * 60);
  return role;
}

// 権限を変更した直後はこちらで「いま」の値を取り、キャッシュも更新する
async function getRoleForAudienceBypassCache(userId: string, aud: string) {
  const role = await resolveFromGroups(userId, aud);
  await cache.set(`tool-role:${userId}:${aud}`, role, 30 * 60);
  return role;
}
```

キャッシュは「速くする道具」であると同時に「古い権限が残るリスク」でもあります。**キャッシュを入れるなら、必ず無効化・迂回の経路をセットで設計する**——これは認可に限らず普遍的な原則です。

なお、Redisが落ちてもログインを止めないよう、キャッシュ層は**インメモリへ自動フォールバック**します。可用性を、依存ミドルウェアの可用性より上に置く判断です。

---

## 7. PIIは暗号化して持つ——「検索」と両立させる

放送事業者の内部統制では、ユーザーの個人情報（メール・電話・氏名）の扱いが厳しく問われます。素直に平文でDBに置くと、DBダンプが漏れた瞬間に全PIIが流出します。そこで**保存時に AES-256-GCM で暗号化**します。

問題は「暗号化すると検索できない」ことです。管理画面では「メールの一部で外部ユーザーを探す」といった部分一致検索が必要です。これを、**復号せずに HMAC トークンで引く**ことで解きます。

```ts
// lib/crypto/pii.ts（匿名化・抜粋）
// 保存：本体は AES-256-GCM、検索用に決定的HMACのトークンを別カラムへ
function sealEmail(email: string) {
  return {
    emailEncrypted: aesGcmEncrypt(email, KEY),            // 可逆・表示用（IV+tag付き）
    emailHash: hmacSha256(email.toLowerCase(), HMAC_KEY), // 完全一致検索用
    searchTokens: ngram(email, 3).map((g) => hmacSha256(g, HMAC_KEY)), // 部分一致用
  };
}

// 検索：入力もHMAC化して、ハッシュ列にインデックスを張って引く（復号ゼロ）
async function searchByEmailFragment(fragment: string) {
  const token = hmacSha256(fragment, HMAC_KEY);
  return db.externalUserSearchToken.findMany({ where: { token } });
}
```

ポイントは、**検索のためにDBを復号しない**こと。クエリ実行時に平文PIIがメモリに展開されないので、ログやコアダンプ経由の漏洩面も縮みます。秘密値どうしの比較（共有シークレットの照合など）は、長さリークやタイミング攻撃を避けるため、必ず `timingSafeEqual`（SHA-256にかけた上での定数時間比較）を使います。

さらに、セッションの作成・失効・更新といった機微操作は**追記専用の監査ログ**に残します。「誰が・いつ・どのデバイスのセッションを・なぜ失効させたか」を後から追跡できることが、内部統制の証跡になります。

---

## 8. エッジでの一次防御：ミドルウェアと WAF

最後に、アプリに到達する前の層です。BFFのミドルウェアは Edge Runtime で動き（Node.jsモジュールは使えない制約のもと）、**IPホワイトリスト + セッションCookie検証**を行います。未認証はAPIなら401、ページなら認証画面へ307。

```ts
// middleware.ts（匿名化・抜粋）
export function middleware(req: NextRequest) {
  if (isPublicPath(req.nextUrl.pathname)) return NextResponse.next();
  if (!isAllowedIp(req)) return new NextResponse(null, { status: 403 });

  const session = readSessionCookie(req);
  if (!session) {
    return req.nextUrl.pathname.startsWith("/api/")
      ? NextResponse.json({ error: "AUTH_REQUIRED" }, { status: 401 })
      : NextResponse.redirect(new URL("/login", req.url));
  }
  return NextResponse.next();
}
```

その手前には Cloud Armor（OWASP CRS 3.3 + 適応型DDoS防御 + レート制限）を置きます。ここで重要なのは運用の作法で、**ステージング環境ではWAFを「全面ブロック有効」で動かす**ことです。OAuthコールバックや認証Cookieは、WAFの正規表現ルールに誤検知されやすい。本番に出す前にstgで全ルールを実トラフィックに当て、誤検知を潰してからプロモートします。「本番でいきなり有効化して認証フローが死ぬ」事故を、運用フローで構造的に防ぎます。

---

## 9. まとめ：認証ハブは「機能」ではなく「契約」

この認証ハブを貫く思想は、**アイデンティティに関する責務を一点に集約し、ツールには最小限の契約だけを渡す**ことでした。

- ツールが知るのは「自分宛ての短命JWTの検証方法」と「バックチャネルイベントの受け取り方」だけ
- ログイン・ログアウト・権限変更・PII・監査は、すべてBFFに閉じる
- セキュリティの強度を、最弱のツールではなく**ハブの強度**で決める

この構造のいいところは、**ツールを増やすコストが下がる**ことです。新しいAIツールを足すとき、認証は「BFFにツールを登録し、短命JWTを検証し、バックチャネルを受ける」だけ。プラットフォームとして横展開できる。実際、このハブの上に音声合成・テロップ誤字検出・生成AI考査といった性質の異なるツールを載せ、単一のSSO体験として統合しています。

エンタープライズが外部の開発者に求めるのは、突き詰めると**「任せて事故が起きないか」**です。PKCEを必須にする、トークンを短命にする、URLにトークンを載せない、ログアウトを確実に伝播する、PIIを暗号化する、監査ログを残す——こうした一つひとつの判断の積み重ねが、その問いへの答えになります。

---

> 本記事のコードはすべて匿名化・再構成していますが、設計判断は実プロダクトのものです。同種の「複数ツールを束ねる社内プラットフォーム」「エンタープライズグレードの認証基盤」のご相談は、[サービスページ](/services)または[お問い合わせ](/contact)からどうぞ。
