- [x] Bug report
msal js, version: 1.0.0
Please see attached word document with screen shots, console log etc.
msalProbs.docx
Our SPAs rely on hash urls to support deep linking without server roundtrips.
msal uses hash-fragments, too, e.g. for returning error "interaction_required" etc.
This breaks hash-based SPA-routing (unless you tell the router to explicitly ignore those hashes).
In our "real" SPA, we had several, not easily reproducible problems, inspite of using the exact same mechanism:
Uncaugt ClientAuthError: Error occured in token received callback function. TypeError: this.authResponseCallback is not a function.
It happens here:
UserAgentApplication.prototype.handleAuthenticationResponse = function (hash) {
if (this.parentIsMsal()) {
tokenResponseCallback =
window.parent.callbackMappedToRenewStates[stateInfo.state];
// Debugger: tokenResponseCallback == null
}
UserAgentApplication.prototype.processCallBack = function (hash, stateInfo, parentCallback) {
// Debugger:
// stateInfo.state = „RENEW_TOKEN“
// stateInfo.state: "15c80c88-b9b5-45d6-bf8f-51a0130f019d"
this.logger.info("Processing the callback from redirect response");
// get the state info from the hash
if (!stateInfo) {
stateInfo = this.getResponseState(hash);
}
var response;
var authErr;
// Save the token info from the hash
try {
response = this.saveTokenFromHash(hash, stateInfo);
}
catch (err) {
authErr = err;
}
Calling a Web API as a user authenticated with Msal.js app
Test Authentication with Azure AD B2C
Not logged in
Azure AD B2C Authentication
Logout
@thomas-mindruptive Thanks for the detailed description of the issues you are facing integrating msal JS into your SPA.
Since MSAL JS is a facilitator library that communicates with the service (endpoint in this case which also gives the tokens) through http calls, it always handles a 302 redirect as the method of retrieving the token from the service. Our app is designed to parse the hash returned from the service. This is the basic design principle today behind msal js functionality.
We behave in two different ways for popup/silent calls:
We are brainstorming on how to make this work with as little impact on the developer as possible but today as it stands, the refresh can only be contained in a popup or iframe but cannot be completely avoided.
Now regarding your questions:
Can you please help me understand if I am missing anything here?
@sameerag Thanks for the quick response!
I investigated further:
Thanks a lot
Tom
@sameerag I've ammended my sample: If I add the code between the comments "login silently", the app works without roundtrips. So it reallys seems to be a local cache problem?
I would kindly suggest to add this information to the docs: rountrips, popup and iframe. What happens exactly, what are the consequences? And especially how to prevent a full re- instantiation of the app (i.e. check hash and if it's an auth-redirect, create empty page with client app) etc. It took me many hours of re-engineering, debugging and trial and error.
My "real" SPA still doesnt work. We will probably eliminate msal and azure ADB2C and use a different product.
Calling a Web API as a user authenticated with Msal.js app
Test Authentication with Azure AD B2C
Not logged in
Azure AD B2C Authentication
Logout
<script>
console.info(`Start script: history.length: %s`, history.length);
if (window.location.hash) {
console.info(`Got hash for auth-redirect, leaving page: ${window.location.hash}`);
}
const b2bScopesAuthTest = ["https://tgwdsb2c.onmicrosoft.com/xxx/demo.read"];
const userFlowPolicy = "B2C_1_Test";
const clientIDTGWAuthTest = "***************************************";
// azure B2C config.
let applicationConfig = {
clientID: clientIDTGWAuthTest,
authority: `https://tgwdsb2c.b2clogin.com/tgwdsb2c.onmicrosoft.com/${userFlowPolicy}`,
// OLD!!! authority: "https://login.microsoftonline.com/tfp/tgwdsb2c.onmicrosoft.com/B2C_1_Test",
b2cScopes: b2bScopesAuthTest,
webApi: 'http://localhost:5000/api/test',
loginApi: 'http://localhost:5000/login',
logoutApi: 'http://localhost:5000/api/logout',
};
let curAuthStrategy;
let authenticated = false;
let clientApplication;
let AuthStrategy = {};
AuthStrategy[AuthStrategy.PassportToken = 1] = "PassportToken";
AuthStrategy[AuthStrategy.Credentials = 2] = "Credentials";
let mockUser = {
email: "[email protected]",
password: "password"
};
let loggedInUser = undefined;
clientApplication = new Msal.UserAgentApplication(
{
auth: {
clientId: applicationConfig.clientID,
authority: applicationConfig.authority,
validateAuthority: false
},
cache: {
cacheLocation: "localStorage",
storeAuthStateInCookie: true
}
}
);
if (!window.location.hash) window.onload = doIt;
/**
* Start test.
*/
async function doIt() {
updateUI();
/*
* Login silently --------------------------------------------------------------------------
*/
let tokenRequest = { scopes: applicationConfig.b2cScopes };
clientApplication.acquireTokenSilent(tokenRequest).then(function (authResponse) {
logMessage(`getTokenSilently: got token silently: ${authResponse.accessToken}`);
console.info(`App:init, after getTokenSilently: history.length: %s`, history.length);
if (authResponse.accessToken) {
curAuthStrategy = AuthStrategy.PassportToken;
authenticated = true;
console.info(`App:init, got valid token silently without login`);
updateUI();
}
});
/*
* Login silently --------------------------------------------------------------------------
*/
console.info(`doIt: appConfig: %O, history.length: %s`, applicationConfig, history.length);
/**
* Try to get access token silently without interaction.
* => Try to automatically login when app starts.
*/
// let account = clientApplication.getAccount();
// if (account) {
// console.info("******* Found account in client app.");
// try {
// let tokenRequest = { scopes: applicationConfig.b2cScopes };
// let accessToken = await clientApplication.acquireTokenSilent(tokenRequest);
// authenticated = true;
// curAuthStrategy = AuthStrategy.PassportToken;
// updateUI()
// } catch (e) {
// logMessage("Error acquiring the token silently:\n" + e);
// }
// }
}
/* azure AD B2C.
----------------------------------------------------------------------------------------*/
/**
* Login to azure AD.
*/
function login() {
let loginRequest = { scopes: applicationConfig.b2cScopes };
clientApplication.loginPopup(loginRequest)
.then(function (authResponse) {
console.log(`After loginPopup, authResponse == %O`, authResponse);
console.info(`login: history.length: %s`, history.length);
if (authResponse.accessToken) {
curAuthStrategy = AuthStrategy.PassportToken;
authenticated = true;
console.info(`login: history.length: %s`, history.length);
updateUI();
} else {
let tokenRequest = { scopes: applicationConfig.b2cScopes };
clientApplication.acquireTokenSilent(tokenRequest)
.then(function (accessToken) {
console.info("After login & acquireTokenSilent, token == %O", accessToken);
console.info(`login: history.length: %s`, history.length);
curAuthStrategy = AuthStrategy.PassportToken;
authenticated = true;
updateUI();
}, function (error) {
clientApplication.acquireTokenPopup(tokenRequest)
.then(function (accessToken) {
console.info("After login & acquireTokenPopup, token == %O", accessToken);
console.info(`login: history.length: %s`, history.length);
curAuthStrategy = AuthStrategy.PassportToken;
authenticated = true;
updateUI();
}, function (error) {
logMessage("Error acquiring the token silently:\n" + error);
});
})
}
}, function (error) {
logMessage("Error during loginPopup:\n" + error);
});
}
/**
* Update the UI according to the state of login etc.
*/
function updateUI() {
console.info("Update UI");
if (authenticated) {
let userName = clientApplication && clientApplication.getAccount() ? clientApplication.getAccount().name : loggedInUser ? loggedInUser.email : "no logged in user";
logMessage("User '" + userName + "' logged-in");
let authButton = document.getElementById('auth');
authButton.setAttribute("class", "hidden");
// Not used: let loginWithCredButton = document.getElementById('loginWithCredentialsButton');
// Not used: loginWithCredButton.setAttribute("class", "hidden");
let logoutButton = document.getElementById('logoutButton');
logoutButton.setAttribute("class", "visible");
let label = document.getElementById('label');
label.innerText = "Hello " + userName;
if (curAuthStrategy === AuthStrategy.PassportToken) {
let callWebApiButton = document.getElementById('callApiButton');
callWebApiButton.setAttribute('class', 'visible');
// Not used: let callApiWithSessionButton = document.getElementById('callApiWithSessionButton');
// Not used: callApiWithSessionButton.setAttribute('class', 'hidden');
} else {
let callWebApiButton = document.getElementById('callApiButton');
callWebApiButton.setAttribute('class', 'hidden');
// Not used: let callApiWithSessionButton = document.getElementById('callApiWithSessionButton');
// Not used: callApiWithSessionButton.setAttribute('class', 'visible');
}
} else {
let logoutButton = document.getElementById('logoutButton');
logoutButton.setAttribute("class", "hidden");
let authButton = document.getElementById('auth');
authButton.setAttribute("class", "visible");
// Not used: let loginWithCredButton = document.getElementById('loginWithCredentialsButton');
// Not used: authButton.setAttribute("class", "visible");
}
}
/**
* Test getting a token silently.
*/
function getTokenSilently() {
let tokenRequest = { scopes: applicationConfig.b2cScopes };
clientApplication.acquireTokenSilent(tokenRequest).then(function (authResponse) {
logMessage(`getTokenSilently: got token silently: ${authResponse.accessToken}`);
console.info(`getTokenSilently: history.length: %s`, history.length);
}, function (error) {
clientApplication.acquireTokenPopup(tokenRequest).then(function (authResponse) {
logMessage(`getTokenSilently: got token from popup: ${authResponse.accessToken}`);
console.info(`getTokenSilently: history.length: %s`, history.length);
}, function (error) {
logMessage("Error acquiring the access token to call the Web api:\n" + error);
});
})
}
/**
* Call the API.
*/
function callApi() {
let tokenRequest = { scopes: applicationConfig.b2cScopes };
clientApplication.acquireTokenSilent(tokenRequest).then(function (accessToken) {
callApiWithAccessToken(accessToken);
}, function (error) {
clientApplication.acquireTokenPopup(tokenRequest).then(function (accessToken) {
callApiWithAccessToken(accessToken);
}, function (error) {
logMessage("Error acquiring the access token to call the Web api:\n" + error);
});
})
}
/**
* Call the API with the access token.
*/
function callApiWithAccessToken(accessToken) {
// Call the Web API with the AccessToken
$.ajax({
type: "GET",
url: applicationConfig.webApi,
headers: {
'Authorization': 'Bearer ' + accessToken
},
}).done(function (data) {
logMessage("Web API returned: " + JSON.stringify(data));
})
.fail(function (jqXHR, textStatus) {
logMessage("Error calling the web api: " + textStatus);
})
}
function logMessage(s) {
document.body.querySelector('.response').appendChild(document.createTextNode('\n' + s));
}
/* Login with user/PW credentials. Not used at the moment.
----------------------------------------------------------------------------------------*/
/**
* Login with "ordinary" credentials => Will create a session on server.
*/
function loginWithCredentials(userName, password) {
// Call the Web API with the AccessToken
$.ajax({
type: "post",
url: applicationConfig.loginApi,
data: mockUser,
xhrFields: {
withCredentials: true
}
}).done(function (data) {
logMessage("Login API returned:\n" + JSON.stringify(data));
loggedInUser = mockUser;
curAuthStrategy = AuthStrategy.Credentials;
authenticated = true;
updateUI();
})
.fail(function (jqXHR, textStatus) {
logMessage("Error calling the login api:\n" + textStatus);
})
}
/**
* Call the API with the current session. (withCredentials)
* "loginWithCredentials()" must have been called before.
*/
function callApiWithLoggedInSession(accessToken) {
// Call the Web API with the AccessToken
$.ajax({
type: "GET",
url: applicationConfig.webApi,
xhrFields: {
withCredentials: true
}
}).done(function (data) {
logMessage("Web API returned: " + JSON.stringify(data));
})
.fail(function (jqXHR, textStatus) {
logMessage("Error calling the Web api: " + textStatus);
})
}
/**
* Logout: Either from access-token based clientApp or from session.
*/
function logout() {
// Removes all sessions, need to call AAD endpoint to do full logout
if (curAuthStrategy = AuthStrategy.PassportToken) {
clientApplication.logout();
} else {
loggedInUser = undefined;
$.ajax({
type: "post",
url: applicationConfig.logoutApi,
xhrFields: {
withCredentials: true
}
}).done(function (data) {
logMessage("Logout API returned:\n" + JSON.stringify(data));
updateUI();
})
.fail(function (jqXHR, textStatus) {
logMessage("Error calling the logout api:\n" + textStatus);
})
}
}
</script>
I am seeing exactly the same behavior as @thomas-mindruptive and I completely agree with him that reloading the whole SPA into an iframe isn't a viable solution.
It might work fine with your small example, but not so much with an "enterprise" SPA.
@chansen-p44 that is really good feedback, I think that is something we need to take to heart in future iterations. We are in the early stages of talking about supporting the auth-code flow client side which should help us get around a lot of these issues
@thomas-mindruptive I am really sorry to hear that our product is not up to your current use case. I think you bring a lot of valid feedback, this is something we will make sure to consider moving forward. I will create work on our end to document the things you suggested better. Please do follow us and our roadmap, I think we will have more elegant solutions moving forward.
@DarylThayil Thanks a lot for taking the time. In the meantime, I created following work-around, a poor man's token approach. I'm fully aware that it isn't as secure as the OpenID-Connect-flow, because users enter the credentials in the app's UI, not a trusted identity-provider. But it's simple, fast and better than "session-based".
Best
Thomas
Just wanted to offer my 2cents here since I seem to have created something similar to what MSAL does already.
My solution is using a backend server (asp net) which returns a challenge to the front end, but the redirect afterwards goes to a very small minimal page which passes back the extracted access token to my angular app, then closes the window:
@model MyProject.Models.TokenAuth.AuthCompleteModel
<head>
<title></title>
</head>
<body>
<script type="text/javascript">
console.log("@Model.ReturnOrigin")
window.opener.postMessage(@Html.Raw(Model.ExternalAuthenticateResultModelJson),
"@Model.ReturnOrigin"
);
window.close()
</script>
</body>
This is picked up by a listener which is started in the original app upon clicking to connect.
From here my front end Angular app can do whatever it wants with the access token, whether the user is logged in or not.
I wrote a very massive writeup of how I did this here:
https://github.com/aspnetboilerplate/aspnetboilerplate/issues/3342#issuecomment-400878536
But the point is, this is going to be real hard without some dumb redirect location somewhere to catch and send back the fragment.
How about msal just has a static asset which is some HTML+JS, and you instruct the user to add that asset to their project by referencing the node_module/..., and then that asset should automatically have a simple route set up which will avoid any app and just run that raw js (in a window)
I must chime in on this sentiment unfortunately. Given that it's the official MS library I expected it to be rather plugin-and-play with AD.
Been using it for about 1.5 years but it still confuses me a lot compared to other OIDC experiences. I really need to get into the details to get it to work and sometimes the documentation is not in sync - so I have to go into the source code.
Although I really appreciate you guys putting it out there! It's just that the level of quality and user experience needs to be bumped up a few notches.
@ShieldPad that is really helpful feedback, we definitely want to make it easier to use.
Do you have any suggestions on what a simple, plug and play api could look like?
@ShieldPad that is really helpful feedback, we definitely want to make it easier to use.
Do you have any suggestions on what a simple, plug and play api could look like?
I am _not_ using any framework, just vanilla JavaScript - for me plug and play would be plug in the required settings (authority, clientId etc..) then be able to call a method like getAccessToken() and it return a useable token.
Right now, you are asking me to check if the instance has an account, if not, log in, then ask for a token etc... The login popup is provided by Microsoft, so I don't really understand why the above is pushed onto the msal js consumer - can you not just handle this for me and "Give me a token"?
Could we not just have a "getAccessToken" and then let msal js worry about if it can retreive from cache, refresh or show the login-in popup?
I have been working on this now for days (ADFS with SAML was honestly less painful); there is out of date documentation all over the internet and it's really, really painful. Although I can get a token, the code feels hacky and it's not clear what the current, right way is - some serious open bugs are also muddying the waters here.
it makes me want to ask, who is this library actually for?
0101010101010101010 (sp?) as a whole we are thinking about higher level apis vs lower level apis. Right now, I agree, we have lower level apis, and they require some patterns that are not extremely obvious.
The goal is the flesh out higher level apis accross msal libraries, that make sense and have the right level of abstraction. Hoping that we have something to share in the near future
Putting in Triage, to determine the set of work to come out of this
0101010101010101010 (sp?) as a whole we are thinking about higher level apis vs lower level apis. Right now, I agree, we have lower level apis, and they require some patterns that are not extremely obvious.
The goal is the flesh out higher level apis accross msal libraries, that make sense and have the right level of abstraction. Hoping that we have something to share in the near future
Just wanted to reply after the 1.1.2 release - this has cleared up all my issues and it seems to be working really well now from a vanilla JS point of view, everything appears to be working as intended.
Great to hear!