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

Cypress实现Azure AD登录时持续重定向出Cypress窗口的问题

Cypress 实现 Azure AD 自动化登录跳转问题解决方案

核心问题定位

当前实现存在4个关键错误,导致MSAL(微软身份验证库)无法正确读取缓存令牌,最终触发认证跳转:

  • 缓存键值规则不匹配:手动拼接的MSAL缓存键和实际MSAL版本的生成规则不一致,且错误将令牌同时写入sessionStorage和localStorage,MSAL默认仅从业务代码指定的单个存储位置读取缓存
  • 实体字段缺失/赋值错误:buildAccountEntity方法中clientInfo字段为占位假值,且错误将access_token同时当做id_token、refresh_token写入对应实体,ROPC流返回结果中id_token、refresh_token是独立字段,不能和access_token混用
  • 执行时序错误:injectTokens包含异步操作,未等待令牌写入存储完成就触发页面reload,导致MSAL初始化时读不到有效缓存
  • 跨域跳转未拦截:Cypress默认不会阻止顶级导航跳转到微软登录域名,未加拦截规则时测试会直接跳出Cypress运行上下文

分步修复方案

1. 修正Cypress配置,拦截外跳

在cypress.config.js中添加如下配置,禁用第三方域名的跳转阻塞:

const { defineConfig } = require("cypress");
module.exports = defineConfig({
  e2e: {
    // 保留原有其他配置
    experimentalModifyObstructiveThirdPartyCode: true,
    chromeWebSecurity: false,
    on('before:browser:launch', (browser, launchOptions) => {
      if (browser.family === 'chromium') {
        launchOptions.args.push('--disable-web-security')
      }
      return launchOptions
    })
  }
})

在测试支持文件cypress/support/e2e.js中添加导航拦截,避免页面跳出测试窗口:

cy.intercept('https://login.microsoftonline.com/**', (req) => {
  // 令牌POST请求放行,仅拦截HTML页面跳转类请求
  if (req.headers.accept?.includes('text/html')) {
    req.redirect('/')
  }
})

2. 修正令牌解析与缓存写入逻辑

首先确认业务项目使用的MSAL版本,MSAL v1和v2的缓存键规则完全不同,主流使用的MSAL v2.x对应修复点:

  • 不要手动拼接缓存键,优先对齐业务代码的MSAL配置,缓存位置和业务代码保持完全一致(业务用localStorage就全写localStorage,用sessionStorage就全写sessionStorage,不要双写)
  • 从ROPC流返回结果中分别取id_token、refresh_token、access_token字段赋值对应实体,不要混用access_token填充所有令牌字段
  • clientInfo字段需要从ROPC返回结果中做base64解码,不能用占位字符串
  • homeAccountId需要从解码后的clientInfo中取uid.utid拼接,不能用oid.tid手动拼接
    修正后的核心注入代码:
import { decode } from 'jsonwebtoken';
import authSettings from './authsettings.json';
const { authority, clientId, clientSecret, apiScopes, username, password, targetScopes } = authSettings;
const environment = 'login.windows.net';

// 解码base64编码的clientInfo
const decodeClientInfo = (rawClientInfo) => {
  if (!rawClientInfo) return {};
  const decoded = Buffer.from(rawClientInfo, 'base64').toString('utf8');
  return JSON.parse(decoded);
};

const injectTokens = (tokenResponse) => {
  const idToken = decode(tokenResponse.id_token);
  const clientInfo = decodeClientInfo(tokenResponse.client_info);
  const localAccountId = idToken.oid || idToken.sid;
  const realm = idToken.tid;
  // 按规则生成正确的homeAccountId
  const homeAccountId = `${clientInfo.uid}.${clientInfo.utid}`;
  const loginUsername = idToken.preferred_username || idToken.unique_name;
  const name = idToken.name;
  const now = Math.floor(Date.now() / 1000);

  // 清空原有存储
  window.localStorage.clear();
  window.sessionStorage.clear();
  const storage = window.localStorage; // 和业务代码缓存位置对齐

  // 写入账户实体
  const accountKey = `${homeAccountId}-${environment}-${realm}`;
  storage.setItem(accountKey, JSON.stringify({
    authorityType: 'MSSTS',
    clientInfo: tokenResponse.client_info,
    homeAccountId,
    environment,
    realm,
    localAccountId,
    username: loginUsername,
    name
  }));

  // 写入ID令牌实体
  const idTokenKey = `${homeAccountId}-${environment}-idtoken-${clientId}-${realm}---`;
  storage.setItem(idTokenKey, JSON.stringify({
    credentialType: 'IdToken',
    homeAccountId,
    environment,
    clientId,
    secret: tokenResponse.id_token,
    realm
  }));

  // 写入业务API访问令牌实体
  const accessTokenKey = `${homeAccountId}-${environment}-accesstoken-${clientId}-${realm}-${btoa(targetScopes)}--`;
  storage.setItem(accessTokenKey, JSON.stringify({
    homeAccountId,
    credentialType: 'AccessToken',
    secret: tokenResponse.access_token,
    cachedAt: now.toString(),
    expiresOn: (now + tokenResponse.expires_in).toString(),
    extendedExpiresOn: (now + tokenResponse.ext_expires_in).toString(),
    environment,
    clientId,
    realm,
    target: targetScopes,
    tokenType: 'Bearer'
  }));

  // 写入openid作用域访问令牌实体
  const oidcScopeKey = `${homeAccountId}-${environment}-accesstoken-${clientId}-${realm}-openid profile email`;
  storage.setItem(oidcScopeKey, JSON.stringify({
    homeAccountId,
    credentialType: 'AccessToken',
    secret: tokenResponse.access_token,
    cachedAt: now.toString(),
    expiresOn: (now + tokenResponse.expires_in).toString(),
    extendedExpiresOn: (now + tokenResponse.ext_expires_in).toString(),
    environment,
    clientId,
    realm,
    target: 'openid profile email',
    tokenType: 'Bearer'
  }));

  // 写入刷新令牌实体
  const refreshTokenKey = `${homeAccountId}-${environment}-refreshtoken-${clientId}--`;
  storage.setItem(refreshTokenKey, JSON.stringify({
    homeAccountId,
    credentialType: 'RefreshToken',
    secret: tokenResponse.refresh_token,
    environment,
    clientId
  }));
};

3. 修正登录方法的执行时序

必须等待存储写入完成后再执行页面重载,同时补充scope参数确保拿到完整令牌:

export const login = () => {
  return cy.request({
    url: authority + '/oauth2/v2.0/token',
    method: 'POST',
    body: {
      grant_type: 'password',
      client_id: clientId,
      client_secret: clientSecret,
      // 必须加offline_access才能拿到refresh_token,加openid/profile/email才能拿到id_token
      scope: apiScopes + ' openid profile email offline_access',
      username: username,
      password: password,
    },
    form: true,
  }).then((response) => {
    return cy.window().then(win => {
      injectTokens(response.body);
      cy.wait(500); // 等待缓存写入磁盘
      win.location.reload();
      return response.body.access_token;
    })
  })
};

前置校验项

  • 确认Azure AD应用注册中开启了ROPC密码授权流,且测试账号未开启MFA多因素认证(ROPC流不支持MFA账号)
  • 确认业务代码配置的MSAL缓存位置、clientId、authority参数和测试代码完全一致
  • 注入完成后可打开开发者工具,将测试写入的缓存条目和手动正常登录后的缓存条目做逐字段对比,完全匹配后MSAL将自动读取缓存完成登录,不会再跳转至微软认证页面

问题截图

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.01 04:45:48