关于 azure-auth
面向使用 React 和 Cloudflare Workers 的开发者,介绍 Microsoft Entra ID 身份验证的实现方式。内容覆盖 MSAL 登录、令牌获取,以及在 Workers 中验证 JWT。
主要功能
- 01配置 React MSAL 登录
- 02使用授权码与 PKCE
- 03获取访问令牌
- 04在 Workers 中验证 JWT
- 05处理多租户令牌发行者
适用场景
- 01为 React SPA 添加 Entra ID 登录
- 02为 API 请求获取访问令牌
- 03在 Cloudflare Workers 保护 API
- 04排查 MSAL 登录与令牌错误
安装
复制下面的命令,在终端中运行。
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
Package Versions:@azure/[email protected]、@azure/[email protected]、[email protected] Breaking Changes:MSAL v4→v5 迁移(2026 年 1 月)、Azure AD B2C 停用(2025 年 5 月起禁止新注册,现有服务持续至 2030 年)、ADAL 退役(2025 年 9 月完成) Last Updated: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 验证
为什么使用 jose 而非 MSAL
MSAL.js 依赖浏览器 API(localStorage、sessionStorage)和 Node.js crypto 模块,而这些在 Cloudflare Workers 的 V8 隔离运行时中并不存在。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 策略较严格,会影响会话存储。即使在设置中明确允许第三方 Cookie,iOS 18 Safari 也不会存储 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 警告渲染期间更新了状态。
原因:在路由器加载器和 MsalProvider 中使用同一个 PublicClientApplication 实例,会导致渲染期间更新状态。
修复方法:在加载器中再次调用 initialize():
const protectedLoader = async () => {
await msalInstance.initialize(); // Prevents state conflict
const response = await msalInstance.acquireTokenSilent(request);
return { data };
};
8. setActiveAccount 不会触发重新渲染(社区反馈)
错误:调用 setActiveAccount() 后,使用 useMsal() 的组件不会更新。
已确认:多个用户在 GitHub issue 中确认了此问题
原因: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 客户提供标准支持
现有 B2C 客户:可以继续使用 B2C 至 2030 年,但应规划迁移到 Entra External ID。
新项目:对于消费者/客户身份场景,请使用 Microsoft Entra External ID。
迁移状态:截至 2026 年 1 月,自动迁移工具仍处于测试阶段。Microsoft Learn 上提供手动迁移指南。
迁移路径:
- Authority URL 格式不同(
{tenant}.ciamlogin.com与{tenant}.b2clogin.com) - SDK 支持已更新(使用相同的 MSAL 库)
- 新的定价模式(按用量计费)
- 提供 Self-Service Password Reset(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 端点
- 令牌缓存格式不同,因此用户必须重新进行身份验证
- 令牌请求中使用作用域替代“资源”
主要迁移变更:
// 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