azure-auth について
React と Cloudflare Workers で Microsoft Entra ID 認証を実装する開発者向けのガイドです。MSAL によるサインインとトークン取得、Workers での JWT 検証を扱います。
主な機能
- 01React MSAL サインインの設定
- 02認可コードと PKCE の利用
- 03アクセストークンの取得
- 04Workers での JWT 検証
- 05マルチテナントの発行者への対応
活用シーン
- 01React SPA に Entra ID サインインを追加
- 02API リクエスト用のトークンを取得
- 03Cloudflare Workers 上の API を保護
- 04MSAL のサインインやトークンのエラー調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ jezweb/ claude-skills --skill azure-authインストール後、Claude Code で/azure-authを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
README.md.claude-plugin/plugin.jsonreferences/aadsts-error-codes.mdrules/azure-auth.mdtemplates/msal-config.tsmsal-provider.tsxprotected-route.tsxuse-api-token.tsworkers-jwt-validation.ts詳細
Azure Auth - React + Cloudflare Workers 向け Microsoft Entra ID
パッケージのバージョン: @azure/[email protected]、@azure/[email protected]、[email protected] 破壊的変更: MSAL v4→v5 の移行(2026 年 1 月)、Azure AD B2C の提供終了(2025 年 5 月 — 新規登録を停止、既存ユーザーは 2030 年まで利用可能)、ADAL の提供終了(2025 年 9 月 — 完了) 最終更新日: 2026-01-21
アーキテクチャの概要
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ React SPA │────▶│ Microsoft Entra ID │────▶│ Cloudflare Worker │
│ @azure/msal-react │ │ (login.microsoft) │ │ jose JWT validation│
└─────────────────────┘ └──────────────────────┘ └─────────────────────┘
│ │
│ Authorization Code + PKCE │
│ (access_token, id_token) │
└──────────────────────────────────────────────────────────┘
Bearer token in Authorization header
主な制約: MSAL.js は Cloudflare Workers では動作しません(ブラウザー/Node.js の API に依存します)。バックエンドでのトークン検証には jose ライブラリを使用してください。
クイックスタート
1. 依存関係のインストール
# Frontend (React SPA)
npm install @azure/msal-react @azure/msal-browser
# Backend (Cloudflare Workers)
npm install jose
2. Azure ポータルの設定
- Microsoft Entra ID → アプリの登録 → 新規登録に移動します
- リダイレクト URI に
http://localhost:5173を設定します(SPA タイプ) - アプリケーション(クライアント)ID と ディレクトリ(テナント)ID を控えます
- 認証で次の操作を行います:
- アクセス トークンとID トークンを有効にします
- 本番環境のリダイレクト URI を追加します
- API のアクセス許可で次の操作を行います:
User.Read(Microsoft Graph)を追加します- 必要に応じて管理者の同意を付与します
フロントエンド:MSAL React の設定
設定(src/auth/msal-config.ts)
import { Configuration, LogLevel } from "@azure/msal-browser";
export const msalConfig: Configuration = {
auth: {
clientId: import.meta.env.VITE_AZURE_CLIENT_ID,
authority: `https://login.microsoftonline.com/${import.meta.env.VITE_AZURE_TENANT_ID}`,
redirectUri: window.location.origin,
postLogoutRedirectUri: window.location.origin,
navigateToLoginRequestUrl: true,
},
cache: {
cacheLocation: "localStorage", // or "sessionStorage"
storeAuthStateInCookie: true, // Required for Safari/Edge issues
},
system: {
loggerOptions: {
logLevel: LogLevel.Warning,
loggerCallback: (level, message) => {
if (level === LogLevel.Error) console.error(message);
},
},
},
};
// Scopes for token requests
export const loginRequest = {
scopes: ["User.Read", "openid", "profile", "email"],
};
// Scopes for API calls (add your API scope here)
export const apiRequest = {
scopes: [`api://${import.meta.env.VITE_AZURE_CLIENT_ID}/access_as_user`],
};
MsalProvider の設定(src/main.tsx)
import React from "react";
import ReactDOM from "react-dom/client";
import { PublicClientApplication, EventType } from "@azure/msal-browser";
import { MsalProvider } from "@azure/msal-react";
import { msalConfig } from "./auth/msal-config";
import App from "./App";
// CRITICAL: Initialize MSAL outside component tree to prevent re-instantiation
const msalInstance = new PublicClientApplication(msalConfig);
// Handle redirect promise on page load
msalInstance.initialize().then(() => {
// Set active account after redirect
// IMPORTANT: Use getAllAccounts() (returns array), NOT getActiveAccount() (returns single account or null)
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 0) {
msalInstance.setActiveAccount(accounts[0]);
}
// Listen for sign-in events
msalInstance.addEventCallback((event) => {
if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) {
const account = (event.payload as { account: any }).account;
msalInstance.setActiveAccount(account);
}
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<MsalProvider instance={msalInstance}>
<App />
</MsalProvider>
</React.StrictMode>
);
});
保護されたルートコンポーネント
import { useMsal, useIsAuthenticated } from "@azure/msal-react";
import { InteractionStatus } from "@azure/msal-browser";
import { loginRequest } from "./msal-config";
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { instance, inProgress } = useMsal();
const isAuthenticated = useIsAuthenticated();
// Wait for MSAL to finish any in-progress operations
if (inProgress !== InteractionStatus.None) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
// Trigger login redirect
instance.loginRedirect(loginRequest);
return <div>Redirecting to login...</div>;
}
return <>{children}</>;
}
API 呼び出し用のトークン取得
import { useMsal } from "@azure/msal-react";
import { InteractionRequiredAuthError } from "@azure/msal-browser";
import { apiRequest } from "./msal-config";
export function useApiToken() {
const { instance, accounts } = useMsal();
async function getAccessToken(): Promise<string | null> {
if (accounts.length === 0) return null;
const request = {
...apiRequest,
account: accounts[0],
};
try {
// Try silent token acquisition first
const response = await instance.acquireTokenSilent(request);
return response.accessToken;
} catch (error) {
if (error instanceof InteractionRequiredAuthError) {
// Silent acquisition failed, need interactive login
// This handles expired refresh tokens (AADSTS700084)
await instance.acquireTokenRedirect(request);
return null;
}
throw error;
}
}
return { getAccessToken };
}
バックエンド:Cloudflare Workers での JWT 検証
MSAL ではなく jose を使う理由
MSAL.js は、Cloudflare Workers の V8 分離ランタイムには存在しないブラウザー API(localStorage、sessionStorage)と Node.js の暗号モジュールに依存します。jose ライブラリは純粋な JavaScript で実装されており、Workers で問題なく動作します。
JWT 検証(src/auth/validate-token.ts)
import * as jose from "jose";
interface EntraTokenPayload {
aud: string; // Audience (your client ID or API URI)
iss: string; // Issuer (https://login.microsoftonline.com/{tenant}/v2.0)
sub: string; // Subject (user's unique ID)
oid: string; // Object ID (user's Azure AD object ID)
preferred_username: string;
name: string;
email?: string;
roles?: string[]; // App roles if configured
scp?: string; // Scopes (space-separated)
}
// Cache JWKS to avoid fetching on every request
let jwksCache: jose.JWTVerifyGetKey | null = null;
let jwksCacheTime = 0;
const JWKS_CACHE_DURATION = 3600000; // 1 hour
async function getJWKS(tenantId: string): Promise<jose.JWTVerifyGetKey> {
const now = Date.now();
if (jwksCache && now - jwksCacheTime < JWKS_CACHE_DURATION) {
return jwksCache;
}
// CRITICAL: Azure AD JWKS is NOT at .well-known/jwks.json
// Must fetch from openid-configuration first
const configUrl = `https://login.microsoftonline.com/${tenantId}/v2.0/.well-known/openid-configuration`;
const configResponse = await fetch(configUrl);
const config = await configResponse.json() as { jwks_uri: string };
// Now fetch JWKS from the correct URL
jwksCache = jose.createRemoteJWKSet(new URL(config.jwks_uri));
jwksCacheTime = now;
return jwksCache;
}
export async function validateEntraToken(
token: string,
env: {
AZURE_TENANT_ID: string;
AZURE_CLIENT_ID: string;
}
): Promise<EntraTokenPayload | null> {
try {
const jwks = await getJWKS(env.AZURE_TENANT_ID);
const { payload } = await jose.jwtVerify(token, jwks, {
issuer: `https://login.microsoftonline.com/${env.AZURE_TENANT_ID}/v2.0`,
audience: env.AZURE_CLIENT_ID, // or your API URI: api://{client_id}
});
return payload as unknown as EntraTokenPayload;
} catch (error) {
console.error("Token validation failed:", error);
return null;
}
}
Worker のミドルウェアパターン
import { validateEntraToken } from "./auth/validate-token";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Skip auth for public routes
const url = new URL(request.url);
if (url.pathname === "/" || url.pathname.startsWith("/public")) {
return handlePublicRoute(request, env);
}
// Extract Bearer token
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return new Response(JSON.stringify({ error: "Missing authorization" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const token = authHeader.slice(7);
const user = await validateEntraToken(token, env);
if (!user) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
// Add user to request context
const requestWithUser = new Request(request);
// Pass user info downstream (e.g., via headers or context)
return handleProtectedRoute(request, env, user);
},
};
よくあるエラーと解決方法
1. AADSTS50058 - サイレントサインインのループ
エラー: 「サイレントサインイン要求が送信されましたが、サインインしているユーザーがいません」
原因: キャッシュされたユーザーがいない状態で acquireTokenSilent が呼び出されています。
解決方法:
// Always check for accounts before silent acquisition
const accounts = instance.getAllAccounts();
if (accounts.length === 0) {
// No cached user, trigger interactive login
await instance.loginRedirect(loginRequest);
return;
}
2. AADSTS700084 - リフレッシュトークンの期限切れ
エラー: 「リフレッシュトークンはシングルページアプリ(SPA)に対して発行されたため、有効期間は固定され、1.00:00:00 に制限されています」
原因: SPA のリフレッシュトークンは 24 時間後に期限切れになります。延長することはできません。
解決方法:
try {
const response = await instance.acquireTokenSilent(request);
} catch (error) {
if (error instanceof InteractionRequiredAuthError) {
// Refresh token expired, need fresh login
await instance.acquireTokenRedirect(request);
}
}
3. React Router v6 のリダイレクトループ
エラー: ログインページとアプリの間でリダイレクトが無限に繰り返されます。
原因: React Router v6 によって、認証応答を含むハッシュフラグメントが削除される場合があります。
解決方法: カスタム NavigationClient を使用します:
import { NavigationClient } from "@azure/msal-browser";
import { useNavigate } from "react-router-dom";
class CustomNavigationClient extends NavigationClient {
private navigate: ReturnType<typeof useNavigate>;
constructor(navigate: ReturnType<typeof useNavigate>) {
super();
this.navigate = navigate;
}
async navigateInternal(url: string, options: { noHistory: boolean }) {
const relativePath = url.replace(window.location.origin, "");
if (options.noHistory) {
this.navigate(relativePath, { replace: true });
} else {
this.navigate(relativePath);
}
return false; // Prevent MSAL from doing its own navigation
}
}
// In your App component:
const navigate = useNavigate();
useEffect(() => {
const navigationClient = new CustomNavigationClient(navigate);
instance.setNavigationClient(navigationClient);
}, [instance, navigate]);
4. NextJS の動的ルートエラー
エラー: 動的ルートで no_cached_authority_error が発生します。
原因: コンポーネントのレンダリング前に MSAL インスタンスが正しく初期化されていません。
解決方法: ルーティングを開始する前に _app.tsx で MSAL を初期化します:
// pages/_app.tsx
import { PublicClientApplication } from "@azure/msal-browser";
import { MsalProvider } from "@azure/msal-react";
import { msalConfig } from "../auth/msal-config";
// Initialize outside component
const msalInstance = new PublicClientApplication(msalConfig);
// Ensure initialization completes before render
export default function App({ Component, pageProps }) {
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
msalInstance.initialize().then(() => setIsInitialized(true));
}, []);
if (!isInitialized) return <div>Loading...</div>;
return (
<MsalProvider instance={msalInstance}>
<Component {...pageProps} />
</MsalProvider>
);
}
5. Safari/Edge の Cookie の問題
エラー: Safari または Edge で認証状態が失われ、ループが無限に発生します。特に iOS 18 の Safari では、サードパーティ Cookie が有効でも、サイレントトークンの更新が AADSTS50058 で失敗します。
原因: これらのブラウザーでは Cookie ポリシーがより厳しく、セッションストレージに影響します。iOS 18 の Safari では、設定でサードパーティ Cookie を明示的に許可していても、login.microsoftonline.com に必要なセッション Cookie が保存されません。
テストに関する注意: iOS 18 の Chrome では動作しますが、iOS 18 の Safari では失敗します。
解決方法: MSAL の設定で Cookie ストレージを有効にします:
cache: {
cacheLocation: "localStorage",
storeAuthStateInCookie: true, // REQUIRED for Safari/Edge
}
iOS 18 の Safari の制限事項: Cookie ストレージを有効にしても iOS 18 の Safari で問題が発生する場合、これは既知のブラウザーの制限であり、現時点で回避策はありません。iOS では Chrome を、またはデスクトップブラウザーを使用することを推奨します。
6. JWKS URL が見つからない(Workers)
エラー: .well-known/jwks.json からの JWKS の取得に失敗します。
原因: Azure AD は標準の OpenID Connect パスで JWKS を提供していません。
解決方法: まず openid-configuration を取得し、その後 jwks_uri を使用します:
// WRONG - Azure AD doesn't use this path
const jwks = createRemoteJWKSet(
new URL(`https://login.microsoftonline.com/${tenantId}/.well-known/jwks.json`)
);
// CORRECT - Fetch config first
const config = await fetch(
`https://login.microsoftonline.com/${tenantId}/v2.0/.well-known/openid-configuration`
).then(r => r.json());
const jwks = createRemoteJWKSet(new URL(config.jwks_uri));
7. React Router ローダーの状態競合
エラー: React Router のローダーで acquireTokenSilent を使用すると、レンダー中に状態を更新しているという React の警告が表示されます。
ソース: GitHub Issue #7068
原因: ルーターのローダーと MsalProvider の両方で同じ PublicClientApplication インスタンスを使用すると、レンダリング中に状態が更新されます。
修正方法: ローダー内でもう一度 initialize() を呼び出します。
const protectedLoader = async () => {
await msalInstance.initialize(); // Prevents state conflict
const response = await msalInstance.acquireTokenSilent(request);
return { data };
};
8. setActiveAccount を呼び出しても再レンダリングされない(コミュニティ提供)
エラー: useMsal() を使用するコンポーネントが、setActiveAccount() の呼び出し後に更新されません。
ソース: GitHub Issue #6989
確認状況: GitHub のイシューで複数のユーザーが確認しています。
原因: setActiveAccount() は MSAL インスタンスを更新しますが、変更を React に通知しません。
修正方法: 状態を使って強制的に再レンダリングします。
const [accountKey, setAccountKey] = useState(0);
const switchAccount = (newAccount) => {
msalInstance.setActiveAccount(newAccount);
setAccountKey(prev => prev + 1); // Force update
};
マルチテナントとシングルテナント
シングルテナント(エンタープライズ向けに推奨)
authority: `https://login.microsoftonline.com/${TENANT_ID}`,
- 組織内のユーザーのみがサインインできます
- トークン発行者:
https://login.microsoftonline.com/{tenant_id}/v2.0
マルチテナント
authority: "https://login.microsoftonline.com/common",
// or for work/school accounts only:
authority: "https://login.microsoftonline.com/organizations",
- 任意の Azure AD テナントのユーザーがサインインできます
- トークン発行者はユーザーのテナントによって異なります
- バックエンドの検証では複数の発行者に対応する必要があります。
// Multi-tenant issuer validation
const tenantId = payload.tid; // Tenant ID from token
const expectedIssuer = `https://login.microsoftonline.com/${tenantId}/v2.0`;
if (payload.iss !== expectedIssuer) {
throw new Error("Invalid issuer");
}
環境変数
フロントエンド(.env)
VITE_AZURE_CLIENT_ID=your-client-id-guid
VITE_AZURE_TENANT_ID=your-tenant-id-guid
バックエンド(wrangler.jsonc)
{
"name": "my-api",
"vars": {
"AZURE_TENANT_ID": "your-tenant-id-guid",
"AZURE_CLIENT_ID": "your-client-id-guid"
}
}
Azure AD B2C の提供終了
タイムライン:
- 2025 年 5 月 1 日: Azure AD B2C では新規顧客登録を受け付けなくなります
- 2026 年 3 月 15 日: Azure AD B2C P2 がすべての顧客向けに終了します
- 2030 年 5 月: Microsoft は既存の B2C 顧客に標準サポートを引き続き提供します
ソース: Microsoft Q&A
既存の B2C 顧客: 2030 年まで B2C を引き続き利用できますが、Entra External ID への移行を計画してください。
新規プロジェクト: 消費者/顧客の ID 管理には Microsoft Entra External ID を使用してください。
移行状況: 2026 年 1 月時点で、自動移行ツールはテスト段階です。手動での移行ガイダンスは Microsoft Learn で確認できます。
移行パス:
- オーソリティ URL の形式が異なります(
{tenant}.ciamlogin.comと{tenant}.b2clogin.com) - SDK のサポートが更新されます(同じ MSAL ライブラリを使用)
- 新しい価格モデル(従量課金制)
- ユーザー移行にはセルフサービス パスワード リセット(SSPR)方式を利用できます
- シームレスな移行サンプルを GitHub で公開(プレビュー)
参照: https://learn.microsoft.com/en-us/entra/external-id/ 移行ガイド: https://learn.microsoft.com/en-us/entra/external-id/customers/how-to-migrate-users
ADAL の廃止(完了)
状況: Azure AD Authentication Library(ADAL)は 2025 年 9 月 30 日に廃止されました。ADAL を使用するアプリは、セキュリティ更新プログラムを受け取れなくなります。
ADAL から移行する場合:
- ADAL から MSAL への移行が必要です
- ADAL は v1.0 エンドポイントを使用していましたが、MSAL は v2.0 エンドポイントを使用します
- トークンキャッシュの形式が異なるため、ユーザーは再認証する必要があります
- トークン要求では「resources」の代わりにスコープを使用します
主な移行時の変更点:
// ADAL (deprecated) - resource-based
acquireToken({ resource: "https://graph.microsoft.com" })
// MSAL (current) - scope-based
acquireTokenSilent({ scopes: ["https://graph.microsoft.com/User.Read"] })
参照: https://learn.microsoft.com/en-us/entra/msal/javascript/migration/msal-net-migration