You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用Nested App Authentication(NAA)时弹窗自动快速关闭问题咨询

问题描述

我正尝试通过实现Nested App Authentication(NAA),将Outlook插件从legacy exchange tokens迁移出来。目前已能成功获取有效的Graph API令牌,但浏览器端存在糟糕的用户体验问题:

  • Chrome浏览器中,用户登出后重新登录,再次使用插件时无法静默获取令牌,会弹出一个快速自动关闭的弹窗,之后才能获取令牌;后续操作可正常静默获取。
  • Firefox中问题更严重,会出现2个弹窗,有时弹窗还会冻结空白。
  • 若浏览器阻止弹窗,会导致无法获取令牌。
  • Mac版Outlook桌面客户端无此问题,一切正常。

疑问:这种弹窗行为是否正常?用户已授权所需权限,理应可以静默获取令牌。我们正考虑改用非NAA方案规避问题。

相关认证代码

MSAL配置代码

export const getMsalConfig = (instance: string): Configuration => {
  let config =  {
    auth: {
      clientId: getClientId(instance),
      authority: "https://login.microsoftonline.com/common",
      redirectUri: "https://localhost:3000/auth.html",
      postLogoutRedirectUri: "https://localhost:3000/auth.html",
    },
    cache: {
      cacheLocation: "localStorage",
    },
    system: {},
  };
}

根组件代码

export default function App({ isOfficeInitialized }: Props) {

  const [msalInstance, setMsalInstance] = React.useState<IPublicClientApplication>();

  useEffect(() => {
    createNestablePublicClientApplication(getMsalConfig(instance))
      .then(res => {
        setMsalInstance(res);
      }).catch(error => {
      console.error(error);
    });

  }, [instance])

  return (
    <>
    {msalInstance &&
    <MsalProvider instance={msalInstance}>
    <AppErrorBoundary>
      <GlobalStyle />
        <Root>
          ... <DoThing>
        </Root>
    </AppErrorBoundary>
    </MsalProvider>
}</>
  );
}

业务组件代码

export default function DoThing({}: Props) {

  const accountIdentifiers = {
    username: "foo@example.com" // TODO genericify
  }
  const request = {
    loginHint: "foo@example.com",
    scopes: ["User.Read", "Mail.ReadWrite.Shared"]
  }

  const useMsalResult = useMsal();
  const msalInstance = useMsalResult['instance'];
  // @ts-ignore
  const inProgress = useMsalResult['inProgress'];
  // @ts-ignore
  const isAuthenticated = useIsAuthenticated();

  // @ts-ignore
  const {login, error } = useMsalAuthentication(InteractionType.Silent, request, accountIdentifiers);


  const doAction = async () => {
    // Code here gets an ewsToken that we currently use

    // Try to get a graph API token
    let graphApiToken = null;
    let graphApiError = null;
    try {
      const account = msalInstance.getActiveAccount();
      if (!account) {
        throw Error("No active account found");
      }

      const tokenRequest = {
        scopes: ["User.Read", "Mail.ReadWrite.Shared"],
        account: account
      };
      const tokenResponse = await msalInstance.acquireTokenSilent(tokenRequest);
      graphApiToken = tokenResponse.accessToken;
    } catch ($error) {
      graphApiError = error ?? new Error("Failed to obtain a graph API token silently");
      console.error(graphApiError.message);
    }

    // If we didn't silently get a graph api token and we didn't get a ewsToken, we'll try to more noisily
    // acquire a graph api token
    try {
      if (!restToken && !graphApiToken) {
        if (inProgress == InteractionStatus.None) {
          try {
            await login(InteractionType.Popup, request);
          } catch (popupError) {
            if (popupError.errorCode === "popup_window_error") {
              throw new Error("Popup blocked by browser. Please enable popups and try again.");
            }
          }
        } else {
          console.error("interaction already in progress");
        }

        try {
          const account = msalInstance.getActiveAccount();
          if (!account) {
            throw Error("No active account found");
          }

          const tokenRequest = {
            scopes: ["User.Read", "Mail.ReadWrite.Shared"],
            account: account
          };
          const tokenResponse = await msalInstance.acquireTokenSilent(tokenRequest);
          graphApiToken = tokenResponse.accessToken;
        } catch ($error) {
          graphApiError = error;
          console.error(graphApiError.message);
          throw graphApiError;
        }
      }

    } catch (error) {
      graphApiError = error ?? new Error("Failed to obtain a graph API token");
    }

    if (!ewsToken && !graphApiToken) {
      onError({
        context: 'Unable to process this message.',
        error:
          ewsError ??
          graphApiError ??
          new Error(
            'There is no API support to obtain a callback token in this environment.'
          ),
      });
      return;
    }
  }

  useEffect(() => {
    if (!!msalInstance) {
      doAction();
    }
  }, [msalInstance]); 

  return <Root>{// some components here}</Root>;
}

解答

这种弹窗行为不正常,核心问题出在NAA模式下浏览器的缓存状态、MSAL实例初始化逻辑以及静默令牌获取的时机上。

问题原因分析

  1. 登出后缓存未完全清理:使用localStorage作为缓存位置时,登出操作可能未彻底清除MSAL的账户缓存和令牌缓存,导致重新登录后MSAL无法识别有效会话,触发弹窗验证。
  2. 静默获取时机过早:组件初始化时立即调用doAction尝试静默获取令牌,但此时MSAL实例可能尚未完成账户加载和会话同步,直接进入错误分支触发弹窗。
  3. 浏览器第三方上下文限制:Chrome和Firefox对Outlook网页版插件这类第三方上下文的Cookie/存储限制更严格,导致MSAL无法读取缓存的会话信息,被迫发起弹窗验证。桌面客户端无此限制,因此正常。

修复建议

1. 优化缓存清理与会话同步

登出时调用MSAL的logout方法并主动清理缓存:

// 示例登出逻辑
await msalInstance.logout({
  account: msalInstance.getActiveAccount(),
  postLogoutRedirectUri: "https://localhost:3000/auth.html"
});
// 清除localStorage中MSAL相关缓存项
localStorage.removeItem(`msal.${getClientId(instance)}.common`);

2. 调整静默获取逻辑与时机

在尝试静默获取前,先确保MSAL已加载有效账户:

const doAction = async () => {
  // 先加载并设置活跃账户
  const accounts = msalInstance.getAllAccounts();
  if (accounts.length > 0) {
    msalInstance.setActiveAccount(accounts[0]);
  }

  let graphApiToken = null;
  let graphApiError = null;
  try {
    const account = msalInstance.getActiveAccount();
    if (!account) {
      // 先尝试静默SSO而非直接弹窗
      await msalInstance.ssoSilent(request);
    }
    const tokenRequest = {
      scopes: ["User.Read", "Mail.ReadWrite.Shared"],
      account: msalInstance.getActiveAccount()
    };
    const tokenResponse = await msalInstance.acquireTokenSilent(tokenRequest);
    graphApiToken = tokenResponse.accessToken;
  } catch ($error) {
    // 仅当静默方式完全失败时,再触发弹窗
    if ($error instanceof InteractionRequiredAuthError) {
      try {
        await msalInstance.acquireTokenPopup(request);
        const tokenResponse = await msalInstance.acquireTokenSilent({
          scopes: ["User.Read", "Mail.ReadWrite.Shared"],
          account: msalInstance.getActiveAccount()
        });
        graphApiToken = tokenResponse.accessToken;
      } catch (popupError) {
        graphApiError = popupError;
        console.error(graphApiError.message);
      }
    } else {
      graphApiError = $error;
      console.error(graphApiError.message);
    }
  }

  // 后续业务逻辑...
}

3. 调整MSAL配置适配浏览器限制

改用sessionStorage减少跨会话缓存残留,并开启Cookie存储适配第三方上下文:

export const getMsalConfig = (instance: string): Configuration => {
  return {
    auth: {
      clientId: getClientId(instance),
      authority: "https://login.microsoftonline.com/common",
      redirectUri: "https://localhost:3000/auth.html",
      postLogoutRedirectUri: "https://localhost:3000/auth.html",
    },
    cache: {
      cacheLocation: "sessionStorage",
      storeAuthStateInCookie: true, // 针对浏览器第三方上下文开启Cookie存储
    },
    system: {
      loggerOptions: {
        loggerCallback: (level, message, containsPii) => {
          if (containsPii) return;
          switch (level) {
            case LogLevel.Error:
              console.error(message);
              return;
            case LogLevel.Info:
              console.info(message);
              return;
            case LogLevel.Verbose:
              console.debug(message);
              return;
            case LogLevel.Warning:
              console.warn(message);
              return;
          }
        }
      }
    },
  };
}

4. 避免重复触发认证逻辑

移除useMsalAuthentication的重复使用,直接通过MSAL实例调用方法,减少不必要的交互触发:

// 删除该行代码
// const {login, error } = useMsalAuthentication(InteractionType.Silent, request, accountIdentifiers);

是否需要改用非NAA方案?

如果上述优化后仍无法解决浏览器端的弹窗问题,可以考虑改用**Office.js的Single Sign-On (SSO)**方案,该方案针对Outlook插件场景做了专门优化,依赖Office的身份上下文,避免第三方弹窗问题。但需要注意SSO的权限范围限制,部分Graph API权限可能需要额外的管理员审批。


内容的提问来源于stack exchange,提问作者user1938392

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 10:34:55