DEV Community 👩‍💻👨‍💻

Cover image for Test MSAL based SPAs with Cypress
kauppfbi
kauppfbi

Posted on

Test MSAL based SPAs with Cypress

This post is about how to properly handle authentication of microsoft authentication library (msal) based single page applications in cypress e2e tests.

Why is it important?

In many cases modern single page applications are protected via authentication & authorization mechanisms like OAuth. Of course we want to test our applications properly to develop and deploy with the best possible confidence. What is better than writing some e2e tests (with Cypress)?

But wait... How do we handle authentication within the tests? Isn't there a redirect to the login page of the identity provider (IDP) triggered by the app? How do we pass user credentials? What about multi-factor-authentication?

A lot of questions need to be clarified, before we can actually start writing functional tests. Altough Cypress provides some good recipes for most common authentication protocols and providers and there are even some packages out there, which abstract the login logic, authentication against the Azure AD can be very tricky.

This is, why the post will focus on MSAL based apps, which use the @azure/msal-browser under the hood and authenticate against the Azure AD. But before you think, this is not of interest for you, you can still follow the steps and adapt the approach to your scenario, no matter if you are using a different IDP or underlying auth package.

Let's get started 🛫

How not to do it 🚨

Let's first talk about two approaches, you should not follow:

  1. Login via UI:
    Please don't! One of the most important rules in e2e testing - or testing in general - is to not test components, which are not under your control. You may be happy and your code will work for a while, but your tests will break sooner or later when Microsoft decides to change its login page.

  2. Disable Auth in E2E Tests:
    Another popular approach is to disable authentication via environment variables and mock missing parts in e2e tests. I personally really dislike the approach und would also not recommend it to anyone. The problem is that you have to touch your production code and implement some switches here and there to make it work (auth guards, route protection, http interceptor, ...).

While I think it's never worth changing your productive code implementation just for test purposes, the main problem is, that those switches increase the complexity and so also the risk for configuration errors. We are just humans and make silly mistakes, isn't it? Imagine deploying your app to production without correct settings for AuthN and AuthZ. Also, the significance of your e2e tests decreases.

How to do it then? ✅

To make it short, the approach is the following. We will

  1. programatically request a access and id token
  2. transform the response and save it to the localstorage
  3. run some e2e tests

1. Acquire ID and Access Token

Usually modern SPAs are using OAuth either with Implicit or Authorization Code Flow (recommended in OAuth 2.1). If you are not yet familiar with the different flows, Auth0 comes with a great documentation.
However, both flows include some ping pong and redirect between SPA and IDP, so those are not fitting for our purpose.

Instead, we will use the Resource Owner Password Flow, because it is much simpler as you only need to make one REST call to receive the token.

Please consider the following:

[...] the Resource Owner Password Flow should only be used when redirect-based flows (like the Authorization Code Flow) cannot be used.
(https://auth0.com/docs/authorization/flows/resource-owner-password-flow)

Generate a client secret

However, the flow expects the client to be trusted. Because a SPA is usually considered a public and therefore not a trusted client, we need to pass not only the user credentials (username, password), but also a additional client secret. So let's create one in the Azure Portal (official docs):
Open Azure Portal > Azure AD > App Registrations > your app reg > Certificates & Secrets
Create a client secret
Please note, that you can only see the secret once, when you create it. Afterwards you won't be able to reveal the entire secret again. Ideally, you directly save the secret in a Key Vault and consume it from there if you need it.

Create a technical user

Before we can continue with the login, we need to have a technical user account.
Please don't use your own user account, especially not in CI environment.
If you don't have one yet, you can simply create one in the Azure Portal (official docs):
Open Azure Portal > Azure AD > Users > New User

If this button is disabled for you, you may need to request a test user from your Azure AD Admin.

Here are some important points on test users:

  • MFA must be disabled for the test user
  • The test user must not be a guest user
  • You must login once via UI to grant consent for the requested scopes
  • Test users should not be shared among different apps or even test types
  • Do not check in user password and client secret into git
  • Pro Tip: Use the Azure Graph API to dynamically add and remove app role assignments before and after e2e test execution

Test the Login

Now we can finally try the login:

curl -X POST "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=password" \
--data-urlencode "client_id=<client-id>" \
--data-urlencode "client_secret=<client-secret>" \
--data-urlencode "scope=openid profile email <api-scope>" \
--data-urlencode "username=<username>" \
--data-urlencode "password=<password>"
Enter fullscreen mode Exit fullscreen mode

If the request is sucessfull, you will see a response like this:

{
    "token_type": "Bearer",
    "scope": "openid profile email <api-scope>",
    "expires_in": 3599,
    "ext_expires_in": 3599,
    "access_token": "<access-token>",
    "id_token": "<id-token>"
}
Enter fullscreen mode Exit fullscreen mode

If you like you can verify the content of the base64-encoded JWTs with JWT.io. Just paste the encoded token there.

Request Login via cy.request()

Of course we don't use curl within the e2e test. Instead we can use cy.request() to perform the login via the REST API:

cy.request({
    url: authority + '/oauth2/v2.0/token',
    method: 'POST',
    body: {
        grant_type: 'password',
        client_id: clientId,
        client_secret: clientSecret,
        scope: ['openid profile email'].concat(apiScopes).join(' '),
        username: username,
        password: password,
    },
    form: true,
}).then(response => {
    // work with response.body
});
Enter fullscreen mode Exit fullscreen mode

2. Prepare the localstorage

We already did important groundwork so that we can acquire a JWT with Cypress. In a next step, we need to transform the api response properly and set it in the localstorage.

Let's analyze the target structure first. This is a localstorage snapshot of my example app:
Structure of Localstorage

We can see, that there are a few entries in the localstorage. If those are present and their entries are valid (e.g. token is not expired), the SPA won't trigger a redirect to the IDP, when it is opened (by Cypress) and we can directly run some tests in a authenticated context 🎉.

To not blow up the blogpost endlessly, we will just focus on the entry for access token. If you are interested in the whole story, I can recommend you the Video of Joonas Westlin, where everything is explained in detail. Of course you can also directly jump to the end result, which I published on GitHub.

Recapture - our starting point

We already prepared the request to the API, which is now part of the login function:

// Get all necessary credentials out of Cypress environment
// Vriables can easily be replaced depending on the stage
const {
  tenantId,
  clientId,
  clientSecret,
  apiScopes,
  username,
  password,
} = Cypress.env();

const authority = `https://login.microsoftonline.com/${tenantId}`;
const environment = 'login.windows.net';

export const login = () => {
  let chainable: Cypress.Chainable = cy.visit('/');

  chainable = chainable.request({
    url: authority + '/oauth2/v2.0/token',
    method: 'POST',
    body: {
      grant_type: 'password',
      client_id: clientId,
      client_secret: clientSecret,
      scope: ['openid profile email'].concat(apiScopes).join(' '),
      username: username,
      password: password,
    },
    form: true,
  });


  chainable
    .then((response) => {
      // transforming the response and set localstorage
      injectTokens(response.body);
    })
    .reload()
    .then(() => {
      return tokenResponse;
    });

  return chainable;
};
Enter fullscreen mode Exit fullscreen mode

As you can see, we are working with Cypress.Chainable. This is in preparation for later, when we wrap the login in a custom command.

Implement InjectTokens

At the end we need a function like this:

const injectTokens = (tokenResponse: any) => {
  // some logic in between
  // ...  

  localStorage.setItem(accountKey, JSON.stringify(accountEntity));
  localStorage.setItem(idTokenKey, JSON.stringify(idTokenEntity));
  localStorage.setItem(accessTokenKey, JSON.stringify(accessTokenEntity));
  ...
};
Enter fullscreen mode Exit fullscreen mode

As mentioned, we will focus on the accessToken only. But to accomplish this, we already cover a lot.

Let's begin with the accessTokenKey. As we can see from the localstorage snapshot (Link), the key consists of several concatenated attributes:

const accessTokenKey = `${homeAccountId}-${environment}-accesstoken-${clientId}-${realm}-${apiScopes.join(
    ' '
  )}`;
Enter fullscreen mode Exit fullscreen mode

Maybe you noticed, that we did not yet come across homeAccountId and the realm. Those attributes can be parsed from the idToken:

// npm i jsonwebtoken -D 
import { decode, JwtPayload } from 'jsonwebtoken';

  ...
  const idToken: JwtPayload = decode(tokenResponse.id_token) as JwtPayload;
  const localAccountId = idToken.oid || idToken.sid;
  const realm = idToken.tid;
  const homeAccountId = `${localAccountId}.${realm}`;
  ...
Enter fullscreen mode Exit fullscreen mode

Ok, well. Now let's fill the value for the accessTokenKey:

const buildAccessTokenEntity = (
  homeAccountId: string,
  accessToken: string,
  expiresIn: number,
  extExpiresIn: number,
  realm: string,
  scopes: string[]
) => {
  const now = Math.floor(Date.now() / 1000);
  return {
    homeAccountId,
    credentialType: 'AccessToken',
    secret: accessToken,
    cachedAt: now.toString(),
    expiresOn: (now + expiresIn).toString(),
    extendedExpiresOn: (now + extExpiresIn).toString(),
    environment,
    clientId,
    realm,
    target: scopes.map((s: string) => s.toLowerCase()).join(' '),
    // Scopes _must_ be lowercase or the token won't be found
  };
};
Enter fullscreen mode Exit fullscreen mode

Now put it all together:

const injectTokens = (tokenResponse: any) => {
  const idToken: JwtPayload = decode(tokenResponse.id_token) as JwtPayload;
  const localAccountId = idToken.oid || idToken.sid;
  const realm = idToken.tid;
  const homeAccountId = `${localAccountId}.${realm}`;

  const accessTokenKey = `${homeAccountId}-${environment}-accesstoken-${clientId}-${realm}-${apiScopes.join(
    ' '
  )}`;
  const accessTokenEntity = buildAccessTokenEntity(
    homeAccountId,
    tokenResponse.access_token,
    tokenResponse.expires_in,
    tokenResponse.ext_expires_in,
    realm,
    apiScopes
  );

  localStorage.setItem(accessTokenKey, JSON.stringify(accessTokenEntity));
};
Enter fullscreen mode Exit fullscreen mode

This is already the whole concept. We are receiving the tokenResponse as input and we need to put all the things together as expected and save it to the localstorage.
We now showcased the concept with the accessToken. To complete the login, we need to repeat the exercise with idToken and the accountKey as well. You can find the end result here.

Wrap the login in a cypress custom command

As we need to run the login in every test, this is a perfect fit for a Cypress custom command. A custom command extends the existing API of Cypress and comes with the same capabilities as usual cy commands (cy.get(), ...) in terms of debuggability and retryability.

Register the custom command in /support/commands.ts file:

import { login } from './auth';

Cypress.Commands.add('login', () => {
  return login().then((tokenResponse) => {
    // ...
  });
});
Enter fullscreen mode Exit fullscreen mode

We could already use the login now, but as it is, the actual login request is performed every single test, which means one API call per test. On the other site, a JWT is usually valid for 1h.
So we can add a simple caching mechanism as a last step:

// auth.ts
export const login = (cachedTokenResponse: any) => {
  let tokenResponse: any = null;
  let chainable: Cypress.Chainable = cy.visit('/');

  if (!cachedTokenResponse) {
    chainable = chainable.request({
      url: authority + '/oauth2/v2.0/token',
      method: 'POST',
      body: {
        grant_type: 'password',
        client_id: clientId,
        client_secret: clientSecret,
        scope: ['openid profile email'].concat(apiScopes).join(' '),
        username: username,
        password: password,
      },
      form: true,
    });
  } else {
    chainable = chainable.then(() => {
      return {
        body: cachedTokenResponse,
      };
    });
  }

  chainable
    .then((response) => {
      injectTokens(response.body);
      tokenResponse = response.body;
    })
    .reload()
    .then(() => {
      return tokenResponse;
    });

  return chainable;
};

// commands.ts
let cachedTokenExpiryTime = new Date().getTime();
let cachedTokenResponse: any = null;

Cypress.Commands.add('login', () => {
  // Clear our cache if tokens are expired
  if (cachedTokenExpiryTime <= new Date().getTime()) {
    cachedTokenResponse = null;
  }

  return login(cachedTokenResponse).then((tokenResponse) => {
    cachedTokenResponse = tokenResponse;
    // Set expiry time to 50 minutes from now
    cachedTokenExpiryTime = new Date().getTime() + 50 * 60 * 1000;
  });
});
Enter fullscreen mode Exit fullscreen mode

With the last adaption, we added a inline caching mechanism. At the end, the token is only requested initially and is reused for 50 minutes, which should be enough for all e2e tests. If not, a new token is acquired automatically.

3. Write some tests

Finally we can write some tests and try out our new cy.login() command. In our scenario, we have an angular app, which has a profile page. This page displays some data from the /me endpoint of MS Graph API:
Example App

This is how we can test the page and its content:

describe('angular-msal-example', () => {
  beforeEach(() => {
    cy.login();
  });

  it('should display user data', () => {
    cy.visit('/#/profile')
      .fixture('user.json')
      .then((expectedUser) =>
        cy
          .get('[data-cy="firstName"]')
          .should('have.text', `First Name: ${expectedUser.firstName}`)
          .get('[data-cy="lastName"]')
          .should('have.text', `Last Name: ${expectedUser.lastName}`)
          .get('[data-cy="email"]')
          .should('have.text', `Email: ${expectedUser.email}`)
          .get('[data-cy="id"]')
          .should('have.text', `Id: ${expectedUser.id}`)
      );
  });
});
Enter fullscreen mode Exit fullscreen mode

The expected user data is saved as fixture in /fixtures/users.json

Last but not least, let's see the tests turning green 🥇:
Cypress Tests

Summary

In this post, we learned several aspects of authentication handling in e2e tests with cypress:

  • How not to implement authentication in e2e tests
  • How to acquire id- and access-token via Resource Owner Password Flow
  • How msal works under the hood
  • How to fake the localstorage for msal based SPAs
  • How to wrap the login in a cypress custom command
  • How to reuse the tokenResponse for multiple tests

Next steps 🚵

As always, software is never ready and this is just the beginning.
I can imagine the following activities:

  • Integrate the tests in your CI-Pipeline.
  • Make use of the cypress-localstorage-commands package to preserve the state between tests. Use this package with caution. It is intended by design to always clear the state after a single test.
  • If you are working with a Nx monorepo, you can extract the custom command into a shared library and can reuse the function in different e2e-apps with ease.
  • Adapt the code to your needs.

Happy Coding & happy testing 😊

GitHub Repository

I added my code in this GitHub Repository. Please note, that I used a Nx workspace for it.
You can find the angular-msal-example-app here and the corresponding e2e-app / cypress tests here.

To run the e2e tests, use these commands:

npm install

npx nx e2e angular-msal-example-e2e --watch

Enter fullscreen mode Exit fullscreen mode

Credits 🤝

I would like to thank my buddy Philip Riecks for proofreading my first ever blogpost. Make sure to checkout his content as well!

Here are some further links:

Top comments (3)

Collapse
 
garaxer profile image
Gary Bagnall

Very Nice, thank you very much

Collapse
 
prathammantri profile image
Prathamesh Mantri

That's such a great explanation! Thank you for writing the article!
It helped a lot

Collapse
 
kauppfbi_96 profile image
kauppfbi Author

This is my first blog post ever. So, when you have feedback for, I am more than happy 😊

🌙 Dark Mode?!

Turn it on in Settings