Azure DevOps扩展首次登录时MSAL重定向URI无片段参数问题排查
Azure DevOps扩展OAuth2首次登录失败问题排查
问题描述
我正在开发Azure DevOps扩展,需要获取OAuth2 Token。认证流程整体正常,但首次登录(或长时间未使用后登录)总会失败。
我将Function App用作重定向URI:
- 首次调用Function App时,URL为
my-function.azure/auth/callback,无任何片段参数——后续流程依赖这些参数,因此触发错误。 - 刷新页面后,MSAL初始化时URL变为
my-function.azure/auth/callback#code=1q70hac87...&state=2882nnv726...,此时一切运行正常。
本地同时运行扩展和Function App时无法复现该问题,始终正常工作。我猜测可能是Function App配置或缓存问题,但不确定。尝试过调整MSAL的缓存配置,没有效果。
我的MSAL代码
return new PublicClientApplication({ auth: { clientId: Constants.Microsoft_AppID, authority: Constants.MsalAuthority + Constants.TenantID, redirectUri:Constants.Redirect_Uri, } }); await this.Instance.initialize(); console.log("MSAL Instance Initialized") if (!this.Instance) { console.error("MSAL instance is not initialized."); return; } const iframeUrl = window.location.href; console.log("Opening Msal LoginPopUp"); try { const authResult = await this.Instance.loginPopup({ scopes: ["User.Read", "offline_access"], state: JSON.stringify(iframeUrl), }); console.log("Authentication successful"); } catch (error) { console.error("Authentication error:", error); }
补充:Function App代码
我在这里将片段参数解析为查询参数,以便获取state,然后将弹窗重定向回认证流程的源地址:
app.http("Callback", { route: process.env.CALLBACK_PATH, methods: ['GET','OPTIONS'], authLevel: 'anonymous', handler: async (request, context) => { let Html = ""; try{ Html = `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> </head> <body> </body> <script> (function() { const protocol = window.location.host.includes("localhost") ? "http" : "https"; const port = window.location.host.includes("localhost") ? ":7071" : ""; const Target = protocol + "://" + window.location.hostname + port + "/api/` + process.env.REDIRECT_PATH + `" const fragment = window.location.hash.substring(1); // First time fragment is always empty as there are no parameters. console.log("Location", window.location); const queryString = "?redirected=true&"+fragment; const redirectUrl = Target+queryString; console.log("Target", Target); console.log("QueryString", queryString); setTimeout(()=>{ window.location.replace(redirectUrl); }, 3000) })(); </script> </html>`; }catch(error){ return{ body: JSON.stringify({ message: "Error", error: error.message || null, details: error.cause || null, stack: error.stack || null, externalResponse: error.externalResponse || null }), status: 500, headers: { 'Content-Type': 'application/json', 'access-control-allow-origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' } } } return { body: Html, status: 200, headers: { 'Content-Type': 'text/html' } }; } }) app.http("Redirect", { route: process.env.REDIRECT_PATH, methods: ['GET', 'OPTIONS'], authLevel: 'anonymous', handler: async (request, context) => { context.log(request); let UrlDestino = ""; try{ let queryAsFragment = GetQueryAsFragment(request); UrlDestino = GetUrlDestino(request, queryAsFragment); }catch (error){ return{ body: JSON.stringify({ message: "Error", error: error.message || null, details: error.cause || null, stack: error.stack || null, externalResponse: error.externalResponse || null }), status: 500, headers: { 'Content-Type': 'application/json' } } } return{ status : 302, headers: { "Location" : UrlDestino } } } }) function GetQueryAsFragment(request){ var fragmentBuilder = ""; for(const [key, value] of request.query) { if (fragmentBuilder.length > 0){ fragmentBuilder+="&"; } fragmentBuilder+= key + "=" + value.toString(); } return fragmentBuilder; } function GetUrlDestino(request, queryAsFragment){ let encodedState = request.query.get("state"); let encodedReplaced = encodedState.replaceAll("%257c","|") .replaceAll("%257C","|") .replaceAll("%7C","|") .replaceAll("%7c","|") .replaceAll("%2522",""") .replaceAll("%22",""") .replaceAll('"',""); let parts = encodedReplaced.split('|'); if (parts.Length < 2) { throw new Exception("State parameter is invalid or malformed. Original: " + encodedState + " \n - Modified: " + encodedReplaced); } return ( decodeURIComponent(parts[1] )) + "#" + queryAsFragment.replaceAll("|", "%7c"); }
问题分析与解决方案
核心原因
- Function App静态页面缓存:Azure Function App默认会缓存返回的HTML内容,首次登录时返回的是缓存的空片段页面,导致后续流程缺失必要参数;本地运行无缓存机制,因此不会触发问题。
- 重定向逻辑时序问题:原代码用
setTimeout(3000)延迟重定向,部分场景下浏览器还未正确获取哈希片段就执行重定向,导致片段丢失。 - 代码语法错误:
GetUrlDestino函数中使用了C#风格的parts.Length(大写L),JavaScript中应为parts.length,会导致首次登录时错误判断state参数无效。
修复步骤
1. 禁用Function App页面缓存
在Callback函数的响应头中添加缓存控制字段,强制浏览器不缓存该页面:
return { body: Html, status: 200, headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0' } };
2. 优化重定向逻辑
去掉setTimeout,改用DOMContentLoaded事件确保页面就绪后再处理哈希片段:
<script> document.addEventListener('DOMContentLoaded', function() { const protocol = window.location.host.includes("localhost") ? "http" : "https"; const port = window.location.host.includes("localhost") ? ":7071" : ""; const Target = protocol + "://" + window.location.hostname + port + "/api/` + process.env.REDIRECT_PATH + `" const fragment = window.location.hash.substring(1); console.log("Location", window.location); const queryString = fragment ? "?redirected=true&"+fragment : "?redirected=true"; const redirectUrl = Target+queryString; console.log("Target", Target); console.log("QueryString", queryString); window.location.replace(redirectUrl); }); </script>
3. 修复JavaScript语法错误
修改GetUrlDestino函数中的长度判断:
if (parts.length < 2) { throw new Error("State parameter is invalid or malformed. Original: " + encodedState + " \n - Modified: " + encodedReplaced); }
4. 验证Azure AD应用注册配置
确保Azure AD应用注册中已正确添加Function App的Callback路径作为重定向URI,且与MSAL配置的redirectUri完全一致。
内容的提问来源于stack exchange,提问作者Federico Crovetto
相关产品推荐
相关产品推荐

