mcpField
A
azure-auth

azure-auth

github.com/jezweb安全1.1k327 次安装3 周前更新
代码仓库

关于 azure-auth

面向使用 React 和 Cloudflare Workers 的开发者,介绍 Microsoft Entra ID 身份验证的实现方式。内容覆盖 MSAL 登录、令牌获取,以及在 Workers 中验证 JWT。

主要功能

  1. 01配置 React MSAL 登录
  2. 02使用授权码与 PKCE
  3. 03获取访问令牌
  4. 04在 Workers 中验证 JWT
  5. 05处理多租户令牌发行者

适用场景

  1. 01为 React SPA 添加 Entra ID 登录
  2. 02为 API 请求获取访问令牌
  3. 03在 Cloudflare Workers 保护 API
  4. 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.json
references/
aadsts-error-codes.md
rules/
azure-auth.md
templates/
msal-config.ts
msal-provider.tsx
protected-route.tsx
use-api-token.ts
workers-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 门户设置
  1. 转到 Microsoft Entra ID → 应用注册 → 新注册
  2. 将 重定向 URI 设置为 http://localhost:5173(SPA 类型)
  3. 记下 应用程序(客户端)ID 和 目录(租户)ID
  4. 在 身份验证下:
    • 启用 访问令牌和 ID 令牌
    • 添加生产环境重定向 URI
  5. 在 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 失败。

来源:GitHub Issue #7384

原因:这些浏览器的 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 警告渲染期间更新了状态。

来源: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 不会触发重新渲染(社区反馈)

错误:调用 setActiveAccount() 后,使用 useMsal() 的组件不会更新。

来源:GitHub Issue #6989

已确认:多个用户在 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 客户提供标准支持

来源:Microsoft Q&A

现有 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 迁移:

  1. 必须从 ADAL 迁移到 MSAL
  2. ADAL 使用 v1.0 端点;MSAL 使用 v2.0 端点
  3. 令牌缓存格式不同,因此用户必须重新进行身份验证
  4. 令牌请求中使用作用域替代“资源”

主要迁移变更:

// 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


资源

评论

支持 Markdown