I'm creating an Azure Devops Extension and I need to get an OAuth2 Token.
The authenthication flow works fine, but it always fails the first time you log in (or after some time without using the extension).
This is my MSAL code:
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);
}
As a redirect Uri I'm using a Function App.
The first time the Function App is invoked the url will look something like my-function.azure/auth/callback
and won't have any fragment parameters. I would get an error after that as I need the parameters to process some stuff I do during the authenthication flow.
Then, after refreshing the page, when initializing MSAL the url will look like my-function.azure/auth/callback#code=1q70hac87...&state=2882nnv726...
and everything would run smooth
The thing is that whenever I run both the extension and the function app at local host, I can't reproduce the error and it always works fine.
I'm guessing maybe is a Function App configuration?? Or probably some cache stuff? I don't know.
Tried messing around with cache configuration when I'm creating the PublicClientApplication object, but couldn't get any result.
Does anybody has any idea?
EDIT:
I'm adding the function code. What I'm doing here is parsing the fragment parameters as query parameters in order to get the state and then redirect the popUp to the origin of the authenthication flow.
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");
}
Found the solution.
The problem was that the Function App was using Azure AD as Authenthication, including the path api/auth/callback
So when the MSAL authenthication flow wanted to redirect to the function app, it first wanted to authenthicate with it'self, causing a prior redirection to https://{FunctionAppName}.azurewebsites.net/.auth/login/aad/callback
I excluded the both paths used in the authenthication flow from the Function App Authenthication Method and it worked correctly.