azure-auth

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Azure Auth - Microsoft Entra ID for React + Cloudflare Workers

Azure 身份验证 - 适用于React + Cloudflare Workers的Microsoft Entra ID方案

Package Versions: @azure/msal-react@5.0.2, @azure/msal-browser@5.0.2, jose@6.1.3 Breaking Changes: MSAL v4→v5 migration (January 2026), Azure AD B2C sunset (May 2025 - new signups blocked, existing until 2030), ADAL retirement (Sept 2025 - complete) Last Updated: 2026-01-21

包版本:@azure/msal-react@5.0.2, @azure/msal-browser@5.0.2, jose@6.1.3 重大变更:MSAL v4→v5迁移(2026年1月),Azure AD B2C停用(2025年5月 - 停止新用户注册,现有用户支持至2030年),ADAL退役(2025年9月 - 完全停止支持) 最后更新:2026-01-21

Architecture Overview

架构概述

┌─────────────────────┐     ┌──────────────────────┐     ┌─────────────────────┐
│   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
Key Constraint: MSAL.js does NOT work in Cloudflare Workers (relies on browser/Node.js APIs). Use jose library for backend token validation.

┌─────────────────────┐     ┌──────────────────────┐     ┌─────────────────────┐
│   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库进行后端令牌验证。

Quick Start

快速开始

1. Install Dependencies

1. 安装依赖

bash
undefined
bash
undefined

Frontend (React SPA)

Frontend (React SPA)

npm install @azure/msal-react @azure/msal-browser
npm install @azure/msal-react @azure/msal-browser

Backend (Cloudflare Workers)

Backend (Cloudflare Workers)

npm install jose
undefined
npm install jose
undefined

2. Azure Portal Setup

2. Azure门户配置

  1. Go to Microsoft Entra IDApp registrationsNew registration
  2. Set Redirect URI to
    http://localhost:5173
    (SPA type)
  3. Note the Application (client) ID and Directory (tenant) ID
  4. Under Authentication:
    • Enable Access tokens and ID tokens
    • Add production redirect URI
  5. Under API permissions:
    • Add
      User.Read
      (Microsoft Graph)
    • Grant admin consent if required

  1. 前往Microsoft Entra ID应用注册新建注册
  2. 重定向URI设置为
    http://localhost:5173
    (SPA类型)
  3. 记录应用(客户端)ID目录(租户)ID
  4. 身份验证下:
    • 启用访问令牌ID令牌
    • 添加生产环境重定向URI
  5. API权限下:
    • 添加
      User.Read
      (Microsoft Graph)
    • 如有需要,授予管理员同意

Frontend: MSAL React Setup

前端:MSAL React配置

Configuration (src/auth/msal-config.ts)

配置文件(src/auth/msal-config.ts)

typescript
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`],
};
typescript
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 Setup (src/main.tsx)

MsalProvider配置(src/main.tsx)

typescript
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>
  );
});
typescript
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>
  );
});

Protected Route Component

受保护路由组件

typescript
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}</>;
}
typescript
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}</>;
}

Acquiring Tokens for API Calls

获取API调用令牌

typescript
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 };
}

typescript
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 };
}

Backend: Cloudflare Workers JWT Validation

后端:Cloudflare Workers JWT验证

Why jose Instead of MSAL

为什么使用jose而非MSAL

MSAL.js relies on browser APIs (localStorage, sessionStorage) and Node.js crypto modules that don't exist in Cloudflare Workers' V8 isolate runtime. The jose library is pure JavaScript and works perfectly in Workers.
MSAL.js依赖浏览器API(localStorage、sessionStorage)和Node.js加密模块,这些在Cloudflare Workers的V8隔离运行时中不存在。jose库是纯JavaScript实现,可完美在Workers中运行。

JWT Validation (src/auth/validate-token.ts)

JWT验证(src/auth/validate-token.ts)

typescript
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;
  }
}
typescript
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 Middleware Pattern

Worker中间件模式

typescript
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);
  },
};

typescript
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);
  },
};

Common Errors & Fixes

常见错误与修复

1. AADSTS50058 - Silent Sign-In Loop

1. AADSTS50058 - 静默登录循环

Error: "A silent sign-in request was sent but no user is signed in"
Cause:
acquireTokenSilent
called when no cached user exists.
Fix:
typescript
// 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;
}
错误信息:"A silent sign-in request was sent but no user is signed in"
原因:当不存在缓存用户时调用了
acquireTokenSilent
修复方案
typescript
// 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 - Refresh Token Expired

2. AADSTS700084 - 刷新令牌过期

Error: "The refresh token was issued to a single page app (SPA), and therefore has a fixed, limited lifetime of 1.00:00:00"
Cause: SPA refresh tokens expire after 24 hours. Cannot be extended.
Fix:
typescript
try {
  const response = await instance.acquireTokenSilent(request);
} catch (error) {
  if (error instanceof InteractionRequiredAuthError) {
    // Refresh token expired, need fresh login
    await instance.acquireTokenRedirect(request);
  }
}
错误信息:"The refresh token was issued to a single page app (SPA), and therefore has a fixed, limited lifetime of 1.00:00:00"
原因:SPA的刷新令牌24小时后过期,无法延长有效期。
修复方案
typescript
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 Redirect Loop

3. React Router v6重定向循环

Error: Infinite redirects between login page and app.
Cause: React Router v6 may strip the hash fragment containing auth response.
Fix: Use custom NavigationClient:
typescript
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]);
错误:登录页面与应用之间无限重定向。
原因:React Router v6可能会剥离包含身份验证响应的哈希片段。
修复方案:使用自定义NavigationClient:
typescript
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 Dynamic Route Error

4. NextJS动态路由错误

Error:
no_cached_authority_error
in dynamic routes.
Cause: MSAL instance not properly initialized before component renders.
Fix: Initialize MSAL in
_app.tsx
before any routing:
typescript
// 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>
  );
}
错误:动态路由中出现
no_cached_authority_error
原因:组件渲染前MSAL实例未正确初始化。
修复方案:在
_app.tsx
中初始化MSAL,再进行路由处理:
typescript
// 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 Issues

5. Safari/Edge Cookie问题

Error: Auth state lost, infinite loop on Safari or Edge. On iOS 18 Safari specifically, silent token refresh fails with AADSTS50058 even when third-party cookies are enabled.
Cause: These browsers have stricter cookie policies affecting session storage. iOS 18 Safari doesn't store the required session cookies for login.microsoftonline.com, even with third-party cookies explicitly allowed in settings.
Testing Note: Works in Chrome on iOS 18, but fails in Safari on iOS 18.
Fix: Enable cookie storage in MSAL config:
typescript
cache: {
  cacheLocation: "localStorage",
  storeAuthStateInCookie: true, // REQUIRED for Safari/Edge
}
iOS 18 Safari Limitation: If users still experience issues on iOS 18 Safari after enabling cookie storage, this is a known browser limitation with no current workaround. Recommend using Chrome on iOS or desktop browser.
错误:身份验证状态丢失,在Safari或Edge中出现无限循环。在iOS 18 Safari中,即使启用第三方Cookie,静默令牌刷新仍会失败并报AADSTS50058错误。
原因:这些浏览器的Cookie策略更严格,影响会话存储。iOS 18 Safari不会存储login.microsoftonline.com所需的会话Cookie,即使在设置中明确允许第三方Cookie。
测试说明:在iOS 18的Chrome中可正常工作,但在iOS 18的Safari中失败。
修复方案:在MSAL配置中启用Cookie存储:
typescript
cache: {
  cacheLocation: "localStorage",
  storeAuthStateInCookie: true, // REQUIRED for Safari/Edge
}
iOS 18 Safari限制:如果用户在启用Cookie存储后仍在iOS 18 Safari中遇到问题,这是已知的浏览器限制,目前没有解决方法。建议用户在iOS上使用Chrome或桌面浏览器。

6. JWKS URL Not Found (Workers)

6. JWKS URL未找到(Workers)

Error: Failed to fetch JWKS from
.well-known/jwks.json
.
Cause: Azure AD doesn't serve JWKS at the standard OpenID Connect path.
Fix: Fetch
openid-configuration
first, then use
jwks_uri
:
typescript
// 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));
错误:无法从
.well-known/jwks.json
获取JWKS。
原因:Azure AD不在标准OpenID Connect路径提供JWKS。
修复方案:先获取
openid-configuration
,再使用其中的
jwks_uri
typescript
// 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 Loader State Conflict

7. React Router Loader状态冲突

Error: React warning about updating state during render when using
acquireTokenSilent
in React Router loaders.
Cause: Using the same
PublicClientApplication
instance in both the router loader and
MsalProvider
causes state updates during rendering.
Fix: Call
initialize()
again in the loader:
typescript
const protectedLoader = async () => {
  await msalInstance.initialize(); // Prevents state conflict
  const response = await msalInstance.acquireTokenSilent(request);
  return { data };
};
错误:在React Router加载器中使用
acquireTokenSilent
时,出现渲染期间更新状态的React警告。
原因:在路由加载器和
MsalProvider
中使用同一个
PublicClientApplication
实例会导致渲染期间状态更新。
修复方案:在加载器中再次调用
initialize()
typescript
const protectedLoader = async () => {
  await msalInstance.initialize(); // Prevents state conflict
  const response = await msalInstance.acquireTokenSilent(request);
  return { data };
};

8. setActiveAccount Doesn't Trigger Re-render (Community-sourced)

8. setActiveAccount不触发重渲染(社区反馈)

Error: Components using
useMsal()
don't update after calling
setActiveAccount()
.
Verified: Multiple users confirmed in GitHub issue
Cause:
setActiveAccount()
updates the MSAL instance but doesn't notify React of the change.
Fix: Force re-render with state:
typescript
const [accountKey, setAccountKey] = useState(0);

const switchAccount = (newAccount) => {
  msalInstance.setActiveAccount(newAccount);
  setAccountKey(prev => prev + 1); // Force update
};

错误:调用
setActiveAccount()
后,使用
useMsal()
的组件未更新。
已验证:多个用户在GitHub issue中确认此问题
原因
setActiveAccount()
更新MSAL实例但未通知React状态变化。
修复方案:使用状态强制重渲染:
typescript
const [accountKey, setAccountKey] = useState(0);

const switchAccount = (newAccount) => {
  msalInstance.setActiveAccount(newAccount);
  setAccountKey(prev => prev + 1); // Force update
};

Multi-Tenant vs Single-Tenant

多租户与单租户

Single Tenant (Recommended for Enterprise)

单租户(企业推荐)

typescript
authority: `https://login.microsoftonline.com/${TENANT_ID}`,
  • Only users from your organization can sign in
  • Token issuer:
    https://login.microsoftonline.com/{tenant_id}/v2.0
typescript
authority: `https://login.microsoftonline.com/${TENANT_ID}`,
  • 仅允许组织内用户登录
  • 令牌颁发者:
    https://login.microsoftonline.com/{tenant_id}/v2.0

Multi-Tenant

多租户

typescript
authority: "https://login.microsoftonline.com/common",
// or for work/school accounts only:
authority: "https://login.microsoftonline.com/organizations",
  • Users from any Azure AD tenant can sign in
  • Token issuer varies by user's tenant
  • Backend validation must handle multiple issuers:
typescript
// 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");
}

typescript
authority: "https://login.microsoftonline.com/common",
// or for work/school accounts only:
authority: "https://login.microsoftonline.com/organizations",
  • 允许任何Azure AD租户的用户登录
  • 令牌颁发者因用户租户而异
  • 后端验证必须处理多个颁发者
typescript
// 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");
}

Environment Variables

环境变量

Frontend (.env)

前端(.env)

bash
VITE_AZURE_CLIENT_ID=your-client-id-guid
VITE_AZURE_TENANT_ID=your-tenant-id-guid
bash
VITE_AZURE_CLIENT_ID=your-client-id-guid
VITE_AZURE_TENANT_ID=your-tenant-id-guid

Backend (wrangler.jsonc)

后端(wrangler.jsonc)

jsonc
{
  "name": "my-api",
  "vars": {
    "AZURE_TENANT_ID": "your-tenant-id-guid",
    "AZURE_CLIENT_ID": "your-client-id-guid"
  }
}

jsonc
{
  "name": "my-api",
  "vars": {
    "AZURE_TENANT_ID": "your-tenant-id-guid",
    "AZURE_CLIENT_ID": "your-client-id-guid"
  }
}

Azure AD B2C Sunset

Azure AD B2C停用

Timeline:
  • May 1, 2025: Azure AD B2C no longer available for new customer signups
  • March 15, 2026: Azure AD B2C P2 discontinued for all customers
  • May 2030: Microsoft will continue supporting existing B2C customers with standard support
Existing B2C Customers: Can continue using B2C until 2030, but should plan migration to Entra External ID.
New Projects: Use Microsoft Entra External ID for consumer/customer identity scenarios.
Migration Status: As of January 2026, automated migration tools are in testing phase. Manual migration guidance available at Microsoft Learn.
Migration Path:
  • Different authority URL format (
    {tenant}.ciamlogin.com
    vs
    {tenant}.b2clogin.com
    )
  • Updated SDK support (same MSAL libraries)
  • New pricing model (consumption-based)
  • Self-Service Password Reset (SSPR) approach available for user migration
  • Seamless migration samples on GitHub (preview)

时间线
  • 2025年5月1日:Azure AD B2C不再接受新客户注册
  • 2026年3月15日:Azure AD B2C P2对所有客户停止服务
  • 2030年5月:Microsoft将继续为现有B2C客户提供标准支持
来源Microsoft Q&A
现有B2C客户:可继续使用B2C至2030年,但应规划迁移至Entra External ID。
新项目:请使用Microsoft Entra External ID处理消费者/客户身份场景。
迁移状态:截至2026年1月,自动化迁移工具处于测试阶段。Microsoft Learn提供手动迁移指南。
迁移路径
  • 不同的颁发者URL格式(
    {tenant}.ciamlogin.com
    vs
    {tenant}.b2clogin.com
  • 更新的SDK支持(使用相同的MSAL库)
  • 新的定价模式(基于消耗)
  • 提供用户迁移的自助密码重置(SSPR)方案
  • GitHub上提供无缝迁移示例(预览版)

ADAL Retirement (Complete)

ADAL退役(已完成)

Status: Azure AD Authentication Library (ADAL) was retired on September 30, 2025. Apps using ADAL no longer receive security updates.
If you're migrating from ADAL:
  1. ADAL → MSAL migration is required
  2. ADAL used v1.0 endpoints; MSAL uses v2.0 endpoints
  3. Token cache format differs - users must re-authenticate
  4. Scopes replace "resources" in token requests
Key Migration Changes:
typescript
// ADAL (deprecated) - resource-based
acquireToken({ resource: "https://graph.microsoft.com" })

// MSAL (current) - scope-based
acquireTokenSilent({ scopes: ["https://graph.microsoft.com/User.Read"] })

状态:Azure AD身份验证库(ADAL)已于2025年9月30日退役。使用ADAL的应用将不再收到安全更新。
如果您从ADAL迁移
  1. 必须从ADAL迁移至MSAL
  2. ADAL使用v1.0端点;MSAL使用v2.0端点
  3. 令牌缓存格式不同 - 用户必须重新身份验证
  4. 令牌请求中使用作用域替代“资源”
关键迁移变更
typescript
// ADAL (deprecated) - resource-based
acquireToken({ resource: "https://graph.microsoft.com" })

// MSAL (current) - scope-based
acquireTokenSilent({ scopes: ["https://graph.microsoft.com/User.Read"] })

Resources

参考资源