社内向けの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つです。
client_id(= audience)はDB登録制。野良ツールがトークンを要求できない。redirect_uriは登録ホワイトリストと完全一致。オープンリダイレクタを潰す。- 認可コードは単回使用・短寿命で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を暗号化する、監査ログを残す——こうした一つひとつの判断の積み重ねが、その問いへの答えになります。
本記事のコードはすべて匿名化・再構成していますが、設計判断は実プロダクトのものです。同種の「複数ツールを束ねる社内プラットフォーム」「エンタープライズグレードの認証基盤」のご相談は、サービスページまたはお問い合わせからどうぞ。