メインコンテンツへスキップ
友田 陽大
認証・認可
認証基盤
OIDC
Next.js
TypeScript
アーキテクチャ設計
セキュリティ

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

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

公開日
読了時間
14分
著者
友田 陽大
シェア

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

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

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

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


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

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

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

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

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

[社員ブラウザ]
   │  ① 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 を持たない攻撃者はトークンに交換できない」状態を、設定漏れの余地なく保証したいからです。

// 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 交換専用
// 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署名を同梱します。

// 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. バックチャネルログアウト:疎結合のまま「全社で締め出す」

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

// 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でロールをキャッシュします。ただし、権限変更の直後だけは古い値を返したくない。だからキャッシュを意図的に迂回する経路を必ず用意します。

// 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 トークンで引くことで解きます。

// 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。

// 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を暗号化する、監査ログを残す——こうした一つひとつの判断の積み重ねが、その問いへの答えになります。


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

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

国内大手放送事業者の番組制作を支援する社内AIプラットフォーム(マルチサービス基盤・認証ハブを構築)

ケーススタディを見る