Microsoft-authentication-library-for-js: msal js not suited for "real" single page apps? reload and history state issues

Created on 8 May 2019  Â·  15Comments  Â·  Source: AzureAD/microsoft-authentication-library-for-js

I'm submitting a...


- [x] Bug report  

Browser:

  • [x] Chrome 74.0.3729.131, 64 bit

Library version

msal js, version: 1.0.0

Attachments

Please see attached word document with screen shots, console log etc.
msalProbs.docx

Current behavior

  • "loginPopup()" and "aquireTokenSiltent()" cause our SPA to be reloaded in iframe.
    This is bad :-) Below, I've added a modified sample from the msal js github. IT is very small and simple and it doe not matter if it reloads. But in real apps with > 25 webpack bundles it does.
  • aquireTokenSiltent() causes reloads even after a successful login. But if you refresh the app and "aquireTokenSiltent()" once, all subsequent calls will be immediate and without reload. The token is taken from memory and/or local storage. Why redirects if the token is already there. The expiration-date can be checkt without roundtrips (or can it not?)
  • This behaviour breaks most SPAs or makes them unpredictable.
  • It's no solution to create an almost empty app-stub just for msal. The app should log in automatically/silently on startup, go to a main page which loads data etc.
  • From a UX perspective in general, the slow login popup workflow is not good.
  • With all due respect, the default ADB2C user flow policy windows (login, forgot password), are ugly. To customize the forms, one needs to create a blob storage account and upload files there (!?).
  • I have worked with several identity providers, but msal + ADB2C drove me almost crazy.

Expected behavior

  • No/fewer reloads

Hash url fragments

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).

Other issues

In our "real" SPA, we had several, not easily reproducible problems, inspite of using the exact same mechanism:

  • History state contained too many states after login and "aquireTokenSilent()"
  • The app was loaded in the login popup window
  • We sometimes saw following error:

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

Minimal reproduction of the problem with instructions

  • Register app ind ADB2C portal

    • Add redirect URL (in our test case http://localhost:8082)

    • Use app id as "Msal.UserAgentApplication.auth.clientId"

  • Use the sample code below to reproduce problem
  • Watch console output and Chrome debugger to identify reloads in iframe JS-context

Sample-code




    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


All 15 comments

@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:

  • popup opens a new popup window -> we handle the redirect within the popup window -> separate the hash, process it and return to the main window -> which is your SPA
  • silent essentially does the same but in a hidden iframe.

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:

  • Is the iframe refresh causing performance issues for you as your actual "SPA" remains unrefreshed in a popup/silent case?
  • The first roundtrip to the service cannot be avoided for acquireToken as the loginPopup only retrieves idToken. Once an accessToken is granted from the service we cache it and until expiry do not make any roundtrips. No extra calls are actually made to the service unless the claims changed, user lost their session with the service or the token is expired. Hence I am confused regarding the "refresh" on success cases.

Can you please help me understand if I am missing anything here?

@sameerag Thanks for the quick response!
I investigated further:

  • One could catch the refresh by checking the hashes. => If certain hashes are present, don't load the app, only a minimal "empty page"
  • But: One must at least create the Msal.UserAgentApplication in the "empty page"
  • But: The problem with the roundtrips still exists. In my sample, each time I call "aquireTokenSilently", the "empty page" is loaded in the iframe (claims have not changed). Accoring to the docs and your explanation, this should not happen.

    • Strange enough, after I refresh the page, it works as expected: No roundtrips

    • => Problem with the local caching (local storage and/or cookie)?

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".

  • The client sends its credentials to my ID-server via xhr/HTTPS
  • The ID-server creates a token, stores the clients claims and scopes in it and signs the token with its private key
  • The client (s) can do whatever he needs with the token. The token will be sent as authorization token with each request to an API-backend.
  • The API backends can easily validate the token with the ID-server's public key.

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!

Was this page helpful?
0 / 5 - 0 ratings