An in-depth overview of writing Cypress end-to-end tests when using Auth0

SD

Sandrino Di Mattia / May 13, 2020

10 min read––– views

Introduction#

With a great developer experience, a good looking UI and a simply way to extend the tooling Cypress is slowly becoming the standard for end to end testing.

Cypress Web Interface

One of the biggest issues of the platform is the limitation when it comes to supporting multiple top level origins. The cypress#944 issue has been open since 2017 and doesn't provide a clear solution yet.

This makes it extra complicated to use redirect based authentication (OpenID Connect, SAML, ...) using products like Auth0. And while there are some workarounds available using chromeWebSecurity, these don't seem to work in a consistent way and they could also influence the results of your tests (eg: third party cookies & SameSite)

The official SSO example provides some guidance but is so generic that it's hard to translate this to an actual system you use.

So the goal of this blog post is to explain how you can write test where users can authenticate using Auth0. Since signing in to a regular web application is so different from signing in to a JAMstack application/SPA, we'll be covering the two use cases separately.

General Concepts#

Let's quickly cover some general concepts:

  1. The login transaction should always be initiated by the application and it will simply be based on redirects. We're not going to do anything crazy with the ROPG or have tokens fly around in all directions.
  2. The user will sign in to Auth0 where the IdP session will be created. We'll want to cache this across tests.
  3. The user will then be redirected back to the application, where a local application session will be created (in the form of a cookie or a token). We'll also want to cache this across tests.

By following these concepts we can make sure we also include the actual login experience (which could contain custom data, RBAC ...) as part of the application with minimal impact on the tests.

Login flow example

In order to overcome the cross-domain issues that exist in Cypress we will be using Puppeteer for the section highlighted in gray and load this part as a Cypress plugin. The only thing that needs to happen in this part is have the user be redirected to Auth0, have them enter their credentials and extract the Auth0 session cookie and the "redirect" to the application. Then we can take that information and use it in our Cypress tests to complete the login transaction.

Below you can find part of the Puppeteer logic which receives the login url (eg: https://acme.auth0.com/authorize?...) and where the user input will be provided. You'll need to modify this if you require additional user input.

const puppeteer = require('puppeteer');

module.exports = async function Login(options = {}) {
  const browser = await puppeteer.launch({
    headless: options.headless,
    args: options.args || ['--no-sandbox', '--disable-setuid-sandbox']
  });

  const page = await browser.newPage();
  try {
    await page.setViewport({ width: 1280, height: 800 });
    await page.setRequestInterception(true);
    page.on('request', preventApplicationRedirect(options.callbackUrl));

    await page.goto(options.loginUrl);

    // Enter credentials.
    await writeUsername({ page, options });
    await writePassword({ page, options });

    // The press login.
    const response = await clickLogin({ page, options });

    // The login failed.
    if (response.status() >= 400) {
      throw new Error(`'Login with user ${options.username} failed, error ${response.status()}`);
    }

    // Redirected to MFA/consent/... which is not implemented yet.
    const url = response.url();
    if (url.indexOf(options.callbackUrl) !== 0) {
      throw new Error(`User was redirected to unexpected location: ${url}`);
    }

    // Now let's fetch all cookies.
    const { cookies } = await page._client.send('Network.getAllCookies', {});
    return {
      callbackUrl: url,
      cookies
    };
  } finally {
    await page.close();
    await browser.close();
  }
};

...

Regular Web Applications#

Let's start by signing in to a regular web application which will create a session cookie when the user is signed in. We'll want to be able to write tests where we can sign in and then access both public and protected routes:

describe('My web application', () => {
  beforeEach(() => {
    const user = Cypress.env('DEFAULT_USER');
    cy.loginAuth0(user);
    cy.loadLoginState(user);
  });

  it('Visit the homepage without authentication', () => {
    cy.visit('http://localhost:8080');
  });

  it('Sign in and access a protected resource', () => {
    const user = Cypress.env('DEFAULT_USER');
    cy.visit('http://localhost:8080/protected');
    cy.contains('body', `Hello user: ${user}`, {
      timeout: 5000
    });
  });
});

We also have a sample web application which exposes several routes:

  • /login, the route which users hit to start the login transaction
  • /callback, the route where the user will be sent after they signed in using Auth0 and where the application session is created
  • /, the home page (not protected)
  • /protected a protected page

The package.json file comes with a test script that can start the server and run the tests in parallel: run-p --race start cy:run.

Signing in with Auth0#

To start with we first need to have the user sign in with Auth0. We'll have the user hit the /login endpoint first which will redirect the user to Auth0. We'll capture that redirect and send it to Puppeteer:

/**
 * Hit the local login endpoint in the application which will redirect to Auth0.
 */
function startLogin() {
  return cy.request({
    url: 'http://localhost:8080/login',
    followRedirect: false
  });
}

/**
 * Universal Login
 * @param {*} user
 * @param {*} loginUrl
 */
function followUniversalLogin(user, loginUrl) {
  return cy.task('LoginPuppeteer', {
    username: user.email,
    password: user.password,
    loginUrl,
    callbackUrl: 'http://localhost:8080/callback'
  });
}

Then when Puppeteer is done signing in the user on the Auth0 side, we'll capture the redirect url provided by Auth0 and visit that URL in the Cypress test.

startLogin().then((response) =>
  followUniversalLogin(user, response.headers.location).then(({ cookies, redirectUrl }) => {
    sessions.auth0 = cookies.map(createCookie);
    sessions.auth0.forEach((c) => cy.setCookie(c.name, c.value, c.options));

    cy.visit(redirectUrl);
    cy.getCookie('appSession').then((cookie) => {
      userSessions[user.email].app = [createCookie(cookie)];
    });
  })
);

When Auth0 tries to redirect the user back to the application you'll see that the user will be redirected to http://localhost:8080/callback?code=...&state=.... By simply visiting this URL in Cypress the application (or the SDK/middleware you are using to be more precise) will perform a code exchange, retrieve the user and create a session.

Note that this assumes that your application is using the Authorization Code grant. This example does not support the Implict grant (where the id_token is passed through the front channel)

In my example I then read the appSession cookie (the application session cookie), but you might need to use a different value here. This is highly dependant on the framework you are using.

The userSessions session object is a cache in which we store the Auth0 session and the application session. By doing that we can reuse those cookies to stay signed in for any Cypress tests that requires it whithout having to talk to Auth0 all the time.

Signing in to the application#

Now that we have the Auth0 session and the application session cached in Cypress, we can have a few additional commands to load the application session from the cache and store it in a cookie.

Cypress.Commands.add('loadLoginState', (email) => {
  return getUserSessions(email).then(({ user, sessions }) => {
    cy.log('Loading login state for' + user.email);

    if (!sessions.app) {
      throw new Error('Unable to find login state');
    }

    sessions.app.forEach((c) => {
      cy.log('Loading ' + c.name);
      cy.setCookie(c.name, c.value, c.options);
    });
  });
});

Testing#

And that's it. With those commands you can now write very simple test that allow you to sign in to your regular web application:

describe('My web application', () => {
  beforeEach(() => {
    const user = Cypress.env('DEFAULT_USER');
    cy.loginAuth0(user);
    cy.loadLoginState(user);
  });

  it('Visit the homepage without authentication', () => {
    cy.visit('http://localhost:8080');
  });

  it('Sign in and access a protected resource', () => {
    const user = Cypress.env('DEFAULT_USER');
    cy.visit('http://localhost:8080/protected');
    cy.contains('body', `Hello user: ${user}`, {
      timeout: 5000
    });
  });
});

A fully working sample is available here: https://github.com/sandrinodimattia/code-samples/tree/master/cypress-auth0-example

Web Application CLI

JAMstack and Single Page Applications#

For applications which receive the id_token and access_token on the client side it's slightly more challenging but not impossible. Before you continue it's important to call out that this method is not compatible with Auth0's old auth0.js SDK. It only works for auth0-spa-js or any wrappers using that SDK.

For this scenario to work we need to make a tiny change in the actual application. When the application is initialized and it's running inside a Cypress test, we'll want to use localstorage as a cache location. This will allow us to inject the necessary tokens in local storage to create the "application session".

if (window.Cypress) {
  this.auth0 = window.auth0 = new window.Auth0Client({
    domain: 'nextjs-auth0-demo.auth0.com',
    client_id: '3OnFkR1n5hy6z8rsYqxycE6RoLJx4xq5',
    cacheLocation: 'localstorage',
    redirect_uri: 'http://localhost:8080',
    audience: 'https://api/tv-shows'
  });
}

We also add the Auth0 client to the window, which allows our Cypress test to interact with it.

Signing in with Auth0#

The overall logic here remains the same with the only exception that there is no /login endpoint which we can hit. So instead of doing that we need to talk to an instance of the Auth0 SDK and request it to generate the authorize URL:

cy.window().should('have.property', 'auth0');
cy.window().then((win) => {
  win.auth0.buildAuthorizeUrl();
});

return cy
  .window()
  .then((win) => win.auth0.buildAuthorizeUrl({}))
  .then((authorizeUrl) => {
    return followUniversalLogin(user, authorizeUrl);
  })
  .then(({ cookies }) => {
    cy.clearCookies({ domain: Cypress.env('AUTH0_DOMAIN') });

    sessions.auth0 = cookies.map(createCookie);
    sessions.auth0.forEach((c) => cy.setCookie(c.name, c.value, c.options));
  });

This will reuse the same logic with Puppeteer after which the Auth0 cookies will be cached.

Signing in to the application#

Now that the Auth0 session has been established we can hit any protected route in the SPA which will then use getTokenSilently() to retrieve an access_token from Auth0. It does this by creating an hidden iframe and then performing a code exchange in the browser. Once that successfully succeeded the SDK will persist all of the necessary data in local storage (because we changed the cacheLocation settings), which is our opportunity to extract and cache it.

Cypress.Commands.add('saveLoginState', (email) => {
  return getUserSessions(email).then(({ user, sessions }) => {
    cy.log(`Saving login state for ${user.email}`);

    if (!sessions.app) {
      sessions.app = [];
    }

    Object.keys(localStorage).forEach((key) => {
      cy.log(`Persisting ${key}`);
      sessions.app.push({
        name: key,
        value: localStorage[key]
      });
    });
  });
});

Cypress.Commands.add('loadLoginState', (email) => {
  return getUserSessions(email).then(({ user, sessions }) => {
    cy.log(`Loading login state for ${user.email}`);

    if (!sessions.app) {
      throw new Error('Unable to find login state');
    }

    sessions.app.forEach((c) => {
      cy.log(`Loading ${c.name}`);
      localStorage.setItem(c.name, c.value);
    });
  });
});

Testing#

We can now go and write our test which will first sign in using Auth0 and then perform an action which calls getTokenSilently() after which the data from local storage is extracted and cached.

describe('Authenticated user', () => {
  beforeEach(() => {
    const user = Cypress.env('DEFAULT_USER');

    cy.visit('index.html');
    cy.loginAuth0(user);

    cy.hasLoginState(user).then((hasLoginState) => {
      if (!hasLoginState) {
        cy.log(`No application session found for ${user}`);
        cy.get('#getToken').click();
        cy.contains('div', 'Tokens:', {
          timeout: 5000
        });

        cy.saveLoginState(user);
      } else {
        cy.clearLocalStorage();
        cy.loadLoginState(user);
      }
    });
  });

  afterEach(() => {
    cy.clearStorage();
  });

  it('Call an API', () => {
    cy.server();
    cy.route('GET', Cypress.env('SHOWS_API')).as('shows');

    cy.visit('index.html');
    cy.get('#callApi').click();

    cy.wait('@shows').then((xhr) => {
      assert.isNotNull(xhr.response.body.shows, 'TV shows were returned by the API');
    });

    cy.contains('div', 'API Response:', {
      timeout: 5000
    });
  });
});

Handling Failures#

The example also comes with an other helper that can intercept messages from the iframe, allowing you to also capture login failures:

Cypress.Commands.add('validateSilentAuthentication', (validateFn) => {
  return cy.window().then((win) => {
    return new Cypress.Promise((resolve) => {
      const listener = (e) => {
        if (e.data.type === 'authorization_response') {
          win.removeEventListener('message', listener);
          const { response } = e.data;
          validateFn(response);
          resolve();
        }
      };
      win.addEventListener('message', listener);
    });
  });
});

With this you can now also test how your application behaves when the user is not signed in:

it('Call an API', () => {
  cy.server();

  cy.visit('index.html');
  cy.get('#callApi').click();

  cy.validateSilentAuthentication((response) => {
    expect(response.error, 'Login should be required').to.equal('login_required');
  });

  cy.contains('div', 'Error:', {
    timeout: 5000
  });
});

Example application#

A fully working sample is available here: https://github.com/sandrinodimattia/code-samples/tree/master/cypress-auth0-spa-sample

JAMstack example

Keep on testing

Testing these scenarios in Cypress are way harder than they should be, but with the examples above you should have everything you need to get started with your Auth0 end to end tests.

Discuss on Twitter