Test MSAL based SPAs with Cypress
Tags: msal, cypress, azuread, oauth2
Published: 2021-10-30 18:30:08

This post is about how to properly handle authentication of [microsoft authentication library (msal)](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-browser) based single page applications in [cypress](https://www.cypress.io/) 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](https://docs.cypress.io/plugins/directory#Authentication) 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](https://www.npmjs.com/package/@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](https://auth0.com/docs/authorization/flows/which-oauth-2-0-flow-should-i-use#is-the-client-a-single-page-app-) 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](https://auth0.com/docs/authorization/flows/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](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app#add-credentials)): 
Open Azure Portal > Azure AD > App Registrations > your app reg > Certificates & Secrets
![Create a client secret](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/20nrgg055wzunqsltmnp.png)
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](https://docs.microsoft.com/bs-latn-ba/azure/active-directory/fundamentals/add-users-azure-active-directory)): 
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](https://docs.microsoft.com/en-us/graph/overview) to dynamically add and remove app role assignments before and after e2e test execution


#### Test the Login
Now we can finally try the login:
```bash 
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>"
```

If the request is sucessfull, you will see a response like this:
```json
{
    "token_type": "Bearer",
    "scope": "openid profile email <api-scope>",
    "expires_in": 3599,
    "ext_expires_in": 3599,
    "access_token": "<access-token>",
    "id_token": "<id-token>"
}
```
If you like you can verify the content of the base64-encoded JWTs with [JWT.io](https://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:

```typescript
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
});
```


### 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f4q0qoxpqfmv1ibedx73.png)

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](https://www.youtube.com/watch?v=OZh5RmCztrU), where everything is explained in detail. Of course you can also directly jump to the [end result](https://github.com/kauppfbi/kauppfbi-blogs), which I published on [GitHub](https://github.com/kauppfbi/kauppfbi-blogs).


#### Recapture - our starting point

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

```typescript
// 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;
};
```
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: 
```typescript
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));
  ...
};
```
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:  
```typescript
const accessTokenKey = `${homeAccountId}-${environment}-accesstoken-${clientId}-${realm}-${apiScopes.join(
    ' '
  )}`;
```
Maybe you noticed, that we did not yet come across `homeAccountId` and the `realm`. Those attributes can be parsed from the `idToken`:

```typescript
// 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}`;
  ...
```

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

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

Now put it all together: 
```typescript
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));
};
```
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](https://github.com/kauppfbi/kauppfbi-blogs/blob/main/apps/angular-msal-example-e2e/src/support/auth.ts). 

#### 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:
```typescript
import { login } from './auth';

Cypress.Commands.add('login', () => {
  return login().then((tokenResponse) => {
    // ...
  });
});
```
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: 

```typescript
// 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;
  });
});
```
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](https://github.com/kauppfbi/kauppfbi-blogs) of MS Graph API: 
![Example App](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u81tttuykew715pwgx5a.png)

This is how we can test the page and its content:
```typescript
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}`)
      );
  });
});
```
The expected user data is saved as [fixture](https://docs.cypress.io/api/commands/fixture) in `/fixtures/users.json`

Last but not least, let's see the tests turning green 🥇: 
![Cypress Tests](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/66wjfu2slz4n8sub02y4.png)
 
## 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](https://www.npmjs.com/package/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](https://github.com/kauppfbi/kauppfbi-blogs). Please note, that I used a [Nx workspace](https://nx.dev/) for it.
You can find the angular-msal-example-app [here](https://github.com/kauppfbi/kauppfbi-blogs/tree/main/apps/angular-msal-example) and the corresponding e2e-app / cypress tests [here](https://github.com/kauppfbi/kauppfbi-blogs/tree/main/apps/angular-msal-example-e2e).

To run the e2e tests, use these commands:
```bash
npm install

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

```

### Credits 🤝 

I would like to thank my buddy [Philip Riecks](https://rieckpil.de) for proofreading my first ever blogpost. Make sure to checkout his content as well!

Here are some further links: 
*  [UI testing Azure AD protected single page applications with Cypress](https://www.youtube.com/watch?v=OZh5RmCztrU) by Joonas Westlin
*  [Azure AD Authentication in Cypress Tests](https://mechanicalrock.github.io/2020/05/05/azure-ad-authentication-cypress.html) by Tim Veletta (MSAL v1)