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
相关产品推荐
相关产品推荐

