MSAL.js で React 製 SPA に「主ユーザーは常時ログインしたまま、副次ユーザーにだけ 1 回だけ API を呼ばせたい」という要件は珍しくありません。しかし MSAL 単体では “single‑use token” を発行できません。本記事では Microsoft Entra ID(旧 Azure AD)と BFF/OBO を組み合わせ、短寿命・一回使い捨ての権限を安全に実装する具体手順とコードを提示します。
結論:MSAL だけで「一度きりトークン」は作れない。実現はバックエンドで
先に要点を整理します。フロントで MSAL.js を使うのは「本人 (副次ユーザー) を正しく認証する」ため。「1 回だけ」制約はバックエンド(BFF)で発行・消費する短寿命 JWT と jti 消込みで実現します。これによりページリロードや多タブでも安全に振る舞い、主ユーザーのセッションと衝突しません。
| ポイント | 解説・推奨策 |
|---|---|
| MSAL に「一度きりトークン」は無い | MSAL.js が取得できるのは通常のアクセストークン/ID トークン/(必要に応じて)リフレッシュトークンのみ。 クライアント側だけで使用回数を制御する仕組みは提供されていません。 |
| 副次ユーザーも正規フローで認証 | OAuth 2.0 / OIDC の整合性を保つため、副次ユーザーも Authorization Code Flow with PKCE で対話ログイン(ポップアップ/リダイレクト)させてトークンを取得させます。 |
| 複数アカウントの同居 | msal-browser の PublicClientApplication は複数アカウントを同居可能。getAllAccounts() で列挙し、setActiveAccount() とローカルストレージの homeAccountId を使って「主」「副」を区別します。 |
| 「1 回だけ」の制限は BFF で | BFF で副次ユーザーの AAD アクセストークンを検証 → 短寿命・限定スコープの One‑Time JWT を発行(例:5分/1 エンドポイント専用)。 API 側で jti(トークン ID)を 一意消費 し、再利用を拒否します。 |
| Silent Flow の条件 | UI を出さずにトークンを再取得できるのは「そのアカウントで既にサインイン済み」の場合のみ。 副次ユーザーが未ログインならポップアップ/リダイレクトが必要です。 |
| リロード・多タブ対策 | MSAL の既定(localStorage)を利用し、homeAccountId を「主」「副」に割当てて保存。起動時に再解決して activeAccount を都度セットします。 |
| より安全に:BFF パターン | フロントからの API 呼び出しをすべて自サーバー経由にし、アクセストークンをブラウザに露出しない。 「一度きり」やスコープ制限をサーバーで集中管理できます。 |
| OBO(On‑Behalf‑Of)で権限を絞る | BFF が受け取った AAD アクセストークンを使い、下流 API 用の限定トークンを Azure 側から取得(必要に応じて)。 それでも「一度きり」自体は jti 消込みで担保します。 |
全体アーキテクチャとデータフロー
構成は「React(MSAL.js)+BFF(Node/Express 例)+下流 API」。BFF が「One‑Time JWT」を発行・検証し、下流 API はそれを信頼して一回限りの実行を受け付けます。
- 主ユーザーで通常ログイン(既存)。
- 副次ユーザーを ポップアップ で認証(PKCE)。
loginPopup()→acquireTokenSilent()。 - 副次ユーザーの AAD アクセストークンを BFF に送る(
Authorization: Bearer <AAD AT>)。 - BFF は AAD トークンを検証し、短寿命 One‑Time JWT を発行(
jti付与、Redis などに TTL 付きで 未使用 として保存)。 - フロントは One‑Time JWT を使って目的の API をコール(
Authorization: Bearer <OTT>)。 - API は JWT を検証し、Redis の
jtiキーを 原子的に消費(GETDEL など)して処理。二度目以降は 401/409 で拒否。
| トークン | 誰が発行 | 用途 | 寿命 | 備考 |
|---|---|---|---|---|
| AAD アクセストークン | Microsoft Entra ID | 副次ユーザーの本人性を BFF に証明 | 短〜中 | ブラウザから BFF へだけ送る(下流 API へは送らない) |
| One‑Time JWT(OTT) | BFF | 下流 API への単回呼び出し権限 | 極短(例:5 分) | aud 固定、scope 限定、jti 必須 |
MSAL.js で「主」「副」を同居させる実装
msal-browser は 1 つの PublicClientApplication で複数アカウントを保持できます。homeAccountId を使って主/副をラベリングし、起動時に再解決するだけでリロード・多タブでも識別できます。
設定例(msal-browser + msal-react)
// msal.ts
import { PublicClientApplication, EventType, AccountInfo, AuthenticationResult } from "@azure/msal-browser";
export const msalInstance = new PublicClientApplication({
auth: {
clientId: "YOUR_CLIENT_ID",
authority: "[https://login.microsoftonline.com/YOUR_TENANT_ID](https://login.microsoftonline.com/YOUR_TENANT_ID)",
redirectUri: "/",
postLogoutRedirectUri: "/"
},
cache: {
cacheLocation: "localStorage", // 再読み込み・多タブを考慮
storeAuthStateInCookie: false
},
system: {
loggerOptions: { loggerCallback: (level, message) => console.debug("[msal]", message) }
}
});
const KEY_PRIMARY = "app:primaryAccountId";
const KEY_SECONDARY = "app:secondaryAccountId";
export function rehydrateAccounts() {
const accounts = msalInstance.getAllAccounts();
const findById = (id?: string | null) => accounts.find(a => a.homeAccountId === id);
const primary = findById(localStorage.getItem(KEY_PRIMARY));
const secondary = findById(localStorage.getItem(KEY_SECONDARY));
// 起動時の activeAccount は主を優先。必要に応じて副を明示セットする。
if (primary) msalInstance.setActiveAccount(primary);
return { primary, secondary, accounts };
}
// ログイン成功時に「主」「副」を割り振る(シンプルな一例)
msalInstance.addEventCallback((event) => {
if (event.eventType === EventType.LOGIN_SUCCESS) {
const result = event.payload as AuthenticationResult;
const acc = result.account as AccountInfo;
const primaryId = localStorage.getItem(KEY_PRIMARY);
if (!primaryId) {
localStorage.setItem(KEY_PRIMARY, acc.homeAccountId);
} else {
localStorage.setItem(KEY_SECONDARY, acc.homeAccountId);
}
msalInstance.setActiveAccount(acc);
}
});
副次ユーザーのログインとトークン交換(フロントエンド)
// secondary-login.ts
import { msalInstance } from "./msal";
import type { AccountInfo, SilentRequest, PopupRequest } from "@azure/msal-browser";
const baseScopes = ["openid", "profile", "email"];
const apiScopes = ["api://YOUR_API_CLIENT_ID/Secondary.Invoke"]; // 必要最小
export async function ensureSecondarySignedIn(): Promise {
const accounts = msalInstance.getAllAccounts();
// 既に副がいるならそれを返す
const secondaryId = localStorage.getItem("app:secondaryAccountId");
const secondary = accounts.find(a => a.homeAccountId === secondaryId);
if (secondary) return secondary;
// いなければポップアップでサインイン(別アカウント選択を促す)
const loginRequest: PopupRequest = {
scopes: baseScopes,
prompt: "select_account"
};
const result = await msalInstance.loginPopup(loginRequest);
return result.account!;
}
export async function getSecondaryAadAccessToken(account: AccountInfo) {
const request: SilentRequest = {
account,
scopes: [...baseScopes, ...apiScopes]
};
try {
const silent = await msalInstance.acquireTokenSilent(request);
return silent.accessToken;
} catch {
const popup = await msalInstance.acquireTokenPopup(request);
return popup.accessToken;
}
}
export async function getOneTimeTokenFromBff(aadAccessToken: string): Promise<{ token: string; expiresIn: number }> {
const res = await fetch("/bff/one-time-token", {
method: "POST",
headers: { "Authorization": `Bearer ${aadAccessToken}` }
});
if (!res.ok) throw new Error("BFF exchange failed");
return res.json();
}
export async function callSecondaryApiOnce(oneTimeToken: string) {
const res = await fetch("/api/secondary-action", {
method: "POST",
headers: { "Authorization": `Bearer ${oneTimeToken}` }
});
if (!res.ok) throw new Error("Secondary API failed");
return res.json();
}
ポイントは「副次ユーザーの AAD アクセストークンは BFF との間だけで使用し、下流 API には渡さない」ことです。OTT(One‑Time Token)しか下流に渡らないため、漏えい時の被害を極小化できます。
BFF 実装:短寿命 One‑Time JWT の発行と「一回消費」
ここでは Node.js/Express + Redis(任意の KV でも可)で実装例を示します。署名には JOSE を使い、aud 固定・exp 極短・jti 一意・scope 限定を基本にします。
One‑Time JWT 発行(/bff/one-time-token)
// bff.ts
import express from "express";
import crypto from "crypto";
import { SignJWT, jwtVerify, importPKCS8, JWTPayload } from "jose";
import { createClient } from "redis";
const app = express();
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const ISS = "[https://bff.example.local](https://bff.example.local)";
const AUD = "secondary-api";
const PRIVATE_PEM = process.env.PRIVATE_PEM!; // ES256 など
const privateKey = await importPKCS8(PRIVATE_PEM, "ES256");
// AAD アクセストークンの検証(JWKS/issuer/audience を必ず検証する)
// 簡略化のため詳細は省略:
async function validateAadAccessToken(bearer: string) {
// 実装では issuer / audience / signature / exp / nbf を厳密検証する
// ここではペイロードの一部(sub, tid 等)を返すダミー
return { sub: "aad-subject", tid: "tenant-id", upn: "[[email protected]](mailto:[email protected])" };
}
app.post("/bff/one-time-token", async (req, res) => {
try {
const auth = req.headers.authorization || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (!token) return res.status(401).end();
const aad = await validateAadAccessToken(token);
const jti = crypto.randomUUID();
const now = Math.floor(Date.now() / 1000);
const exp = now + 5 * 60; // 5 分
// Redis に「未使用」の印を TTL 付きで保存
await redis.set(`ott:${jti}`, JSON.stringify({
status: "unused",
sub: aad.sub,
scope: "secondary:invoke"
}), { EX: 5 * 60 });
const ott = await new SignJWT({
one_time: true,
scope: "secondary:invoke"
} as JWTPayload)
.setProtectedHeader({ alg: "ES256" })
.setIssuer(ISS)
.setAudience(AUD)
.setSubject(aad.sub)
.setJti(jti)
.setIssuedAt(now)
.setExpirationTime(exp)
.sign(privateKey);
res.json({ token: ott, expiresIn: exp - now });
} catch (e) {
console.error(e);
res.status(500).end();
}
});
API 受け側:GETDEL で原子的に「使い捨て」
// api.ts
import express from "express";
import { jwtVerify, importSPKI } from "jose";
import { createClient } from "redis";
const api = express();
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const ISS = "[https://bff.example.local](https://bff.example.local)";
const AUD = "secondary-api";
const PUBLIC_SPKI = process.env.PUBLIC_SPKI!;
const publicKey = await importSPKI(PUBLIC_SPKI, "ES256");
async function verifyOneTimeJwt(bearer: string) {
const token = bearer.startsWith("Bearer ") ? bearer.slice(7) : "";
if (!token) throw new Error("no token");
const { payload } = await jwtVerify(token, publicKey, {
issuer: ISS,
audience: AUD
});
if (!payload.jti || !payload.one_time || payload.scope !== "secondary:invoke") {
throw new Error("invalid claim");
}
return payload;
}
// 一回だけのエンドポイント
api.post("/api/secondary-action", async (req, res) => {
try {
const auth = String(req.headers.authorization || "");
const payload = await verifyOneTimeJwt(auth);
// jti を原子的に消費(Redis 6.2+)
const key = `ott:${payload.jti}`;
const existed = await redis.GETDEL(key);
if (!existed) {
// 期限切れ or 既に使用済み
return res.status(409).json({ error: "token_already_used_or_expired" });
}
// ---- ここに本来の処理 ----
// 例:副次ユーザーに関連づく一度限りの購入・権限移譲・招待確定 等
// --------------------------
return res.json({ ok: true });
} catch (e) {
console.error(e);
return res.status(401).json({ error: "unauthorized" });
}
});
なぜ GETDEL か? 複数リクエスト(リトライ・競合)が同時到達しても、最初の 1 件だけがキーを取得し、残りはキーが消えているため失敗します。まさに「一度きり」をレースフリーで実現できます。
セキュリティ設計の要点(チェックリスト)
| 項目 | 推奨 | 理由 |
|---|---|---|
| OTT の寿命 | 5 分以内(業務要件に応じて短く) | 奪取・貼り付け攻撃の時間窓を最小化 |
| OTT の対象 | aud を固定、scope を 1 エンドポイント専用に | 用途外利用の封じ込め |
| 再利用防止 | jti を Redis で 未使用 として保存 → API で GETDEL 消費 | リプレイ耐性とレースの解消 |
| クライアント保護 | フロントは AAD アクセストークンを BFF にのみ 送る | ブラウザの XSS リスク低減、下流 API の露出防止 |
| Cookie 設計(BFF) | SameSite=Lax/Secure/HttpOnly、CSRF トークン併用 | CSRF・セッション固定化への対策 |
| ログと監査 | jti/sub/client_id/aud/exp を監査ログに | 追跡性と異常検知 |
| DPoP(任意) | 可能なら PoP/DPoP でトークンをクライアント鍵にバインド | 不正コピーによる再送リスクを更に低減 |
よくある誤解とアンチパターン
- 誤解:「MSAL で使用回数を 1 回に制限できる」→ できません。制限はバックエンドで。
- 誤解:「副次ユーザーはメールアドレス入力だけで擬似認証」→ 不可。必ず Auth Code + PKCE の正規フロー。
- 誤解:「副次ユーザーのトークンをローカルストレージに保存」→ 非推奨。トークンはできるだけメモリ/BFF 側に。
- 誤解:「副次ユーザーのセッション破棄=主ユーザーも巻き添え」→ msal-browser はアカウント別に操作可能。
logoutPopup({ account })等で副だけサインアウト可能。
ページリロード・多タブでも 2 人分を区別する実践
最低限の状態(homeAccountId のみ)だけを localStorage に持ち、トークン類は MSAL のキャッシュ(同一オリジン内)に任せます。再読み込み時は次の順で復元します。
getAllAccounts()で列挙。- 保存してある
primaryAccountId/secondaryAccountIdと突き合わせ。 - 見つかれば
setActiveAccount()で現在の主体をセット。 - API 呼び出し直前に
acquireTokenSilent({ account })で確実に当該ユーザーのトークンを取得。
// rehydrate-on-start.ts
import { msalInstance, rehydrateAccounts } from "./msal";
const { primary, secondary } = rehydrateAccounts();
if (primary) {
msalInstance.setActiveAccount(primary);
}
// 呼び出し直前の安全策
export async function withSecondaryToken(fn: (token: string) => Promise) {
const sid = localStorage.getItem("app:secondaryAccountId");
const acc = msalInstance.getAllAccounts().find(a => a.homeAccountId === sid);
if (!acc) throw new Error("secondary not signed in");
const token = (await msalInstance.acquireTokenSilent({ account: acc, scopes: ["openid","profile","email","api://.../Secondary.Invoke"] })).accessToken;
return fn(token);
}
OBO(On‑Behalf‑Of)を併用する場合の考え方
副次ユーザーが AAD の下流 API(Graph や自社 API)に そのまま 触れるのが不都合なケースでは、BFF が受け取った AAD アクセストークンで OBO を実施し、必要最小限のスコープに絞った下流用トークンを取得します。とはいえ「一回だけ」自体は OBO では表現できないため、最終的な単発制御は jti 消費で行うのが王道です。
運用ポイント:テレメトリとエラー設計
- 監査ログ:すべての OTT 発行 と 消費 に
jti/sub/aud/exp/client_idを記録。 - メトリクス:発行数・消費成功数・重複消費(409)の割合・期限切れ率。
- エラー応答:期限切れ →
401 expired、既使用 →409 conflictなど、ユーザー向けに再発行を促す UI を用意。 - 障害時:Redis ダウンを想定し、「一回だけ」の保証が外れるリスクを考慮したフェイルクローズ(= 受け付けない)を選択。
実装まとめ(コピペ用クイックガイド)
- msal-browser + msal-react を導入し、主/副の homeAccountId を
localStorageに保存。 - 副次ユーザーは
loginPopup()+acquireTokenSilent()で AAD アクセストークン取得。 - BFF の
/bff/one-time-tokenに AAD トークンを渡し、5 分以内の One‑Time JWT を受け取る。 - 下流 API は JWT を検証し、Redis GETDEL で
jtiを消費。成功 1 回限り。 - ログ・メトリクス・再発行 UI を整備し、運用での見える化を徹底。
補足:設計判断の比較表
| 選択肢 | メリット | デメリット | 適用場面 |
|---|---|---|---|
| クライアントのみで「一度きり」実装 | 実装が軽い | 不可/脆弱(再送・改ざんを防げない) | 非推奨 |
| BFF + One‑Time JWT | 強い再利用防止、短寿命、監査容易 | サーバー/KV が必要 | 推奨の標準解 |
| OBO 併用 | 下流権限の更なる最小化 | 構成が複雑化 | 下流 API のスコープ制御が厳密に必要 |
| DPoP 併用 | トークンのコピー悪用に強い | クライアント鍵管理が必要 | 高度な対策が必要な領域 |
締め:MSAL は認証、単発制御はサーバー
MSAL.js は「誰がログインしているか」を安全に示すためのライブラリであり、「一度だけ使えるトークン」を作る道具ではありません。認証は MSAL、単発制御は BFF の One‑Time JWT という役割分担こそが、安全性・拡張性・運用性のバランスが取れた現実解です。ここまでの手順とコードをそのままベースにすれば、主ユーザーの体験を壊さず、副次ユーザーにだけ一回限りの権限をきめ細かく付与できます。

コメント