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

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");
}

问题分析与解决方案

核心原因

  1. Function App静态页面缓存:Azure Function App默认会缓存返回的HTML内容,首次登录时返回的是缓存的空片段页面,导致后续流程缺失必要参数;本地运行无缓存机制,因此不会触发问题。
  2. 重定向逻辑时序问题:原代码用setTimeout(3000)延迟重定向,部分场景下浏览器还未正确获取哈希片段就执行重定向,导致片段丢失。
  3. 代码语法错误: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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.15 08:53:15