Search code examples
testingoauthnestjscypressauth0

Testing authentication with Auth0 in a full stack application with Cypress


I’m working on a full-stack NestJS application, integrating with Auth0 using the express-openid-connect library. I’m using Cypress for e2e tests, and I’m trying to find a way of testing my login using Cypress.

I found this article - https://auth0.com/blog/end-to-end-testing-with-cypress-and-auth0/, but it seems to be very much tied to a React application. I’m calling the /oauth/token API endpoint, and I get a response, but I’m unsure how to build out my callback URL to log me in to the application. Here’s what I have so far:

Cypress.Commands.add('login', () => {
  cy.session('logged in user', () => {
    const options = {
      method: 'POST',
      url: `${Cypress.env('OAUTH_DOMAIN')}/oauth/token`,
      body: {
        grant_type: 'password',
        username: Cypress.env('AUTH_USERNAME'),
        password: Cypress.env('AUTH_PASSWORD'),
        scope: 'openid profile email',
        audience: `${Cypress.env('OAUTH_DOMAIN')}/api/v2/`,
        client_id: Cypress.env('OAUTH_CLIENT_ID'),
        client_secret: Cypress.env('OAUTH_CLIENT_SECRET'),
      },
    };
    cy.request(options).then((response) => {
      // What do I do here?
    });
  });
});

Any pointers would be gratefully recieved!


Solution

  • I ended up sorting this out by using Puppeteer to handle my login, stopping at the point of redirection to the callback URL and returning the cookies and callback URL to Cypress, as detailed in this article:

    https://sandrino.dev/blog/writing-cypress-e2e-tests-with-auth0

    Things have changed a bit since then, and with the introduction of Cypress's experimentalSessionSupport it's a bit simpler. I ended up whittling the solution down to having the following in my Cypress setup:

    // cypress/plugins/auth0.js
    
    const puppeteer = require('puppeteer');
    
    const preventApplicationRedirect = function (callbackUrl) {
      return (request) => {
        const url = request.url();
        if (request.isNavigationRequest() && url.indexOf(callbackUrl) === 0)
          request.respond({ body: url, status: 200 });
        else request.continue();
      };
    };
    
    const writeUsername = async function writeUsername({ page, options } = {}) {
      await page.waitForSelector('#username');
      await page.type('#username', options.username);
    };
    
    const writePassword = async function writeUsername({ page, options } = {}) {
      await page.waitForSelector('#password', { visible: true });
      await page.type('#password', options.password);
    };
    
    const clickLogin = async function ({ page } = {}) {
      await page.waitForSelector('button[type="submit"]', {
        visible: true,
        timeout: 5000,
      });
    
      const [response] = await Promise.all([
        page.waitForNavigation({ waitUntil: 'networkidle2' }),
        page.click('button[type="submit"]'),
      ]);
      return response;
    };
    
    exports.Login = async function (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);
    
        await writeUsername({ page, options });
        await writePassword({ page, options });
    
        const response = await clickLogin({ page, options });
    
        if (response.status() >= 400) {
          throw new Error(
            `'Login with user ${
              options.username
            } failed, error ${response.status()}`,
          );
        }
    
        const url = response.url();
        if (url.indexOf(options.callbackUrl) !== 0) {
          throw new Error(`User was redirected to unexpected location: ${url}`);
        }
    
        const { cookies } = await page._client.send('Network.getAllCookies', {});
        return {
          callbackUrl: url,
          cookies,
        };
      } finally {
        await page.close();
        await browser.close();
      }
    };
    
    
    // cypress/plugins/index.js
    
    const auth0 = require('./auth0');
    
    module.exports = (on, config) => {
      require('dotenv').config({ path: '.env.test' });
    
      config.env.AUTH0_DOMAIN = process.env.AUTH0_DOMAIN;
      config.env.AUTH_USERNAME = process.env.AUTH_USERNAME;
      config.env.AUTH_PASSWORD = process.env.AUTH_PASSWORD;
    
      on('task', {
        LoginPuppeteer(options) {
          return auth0.Login(options);
        },
      });
    
      return config;
    };
    
    // cypress/support/commands.js
    
    const { getUnixTime } = require('date-fns');
    
    /*
     * Create the cookie expiration.
     */
    function getFutureTime(minutesInFuture) {
      const time = new Date(new Date().getTime() + minutesInFuture * 60000);
      return getUnixTime(time);
    }
    
    /**
     * Create a cookie object.
     * @param {*} cookie
     */
    function createCookie(cookie) {
      return {
        name: cookie.name,
        value: cookie.value,
        options: {
          domain: `${cookie.domain.trimLeft('.')}`,
          expiry: getFutureTime(15),
          httpOnly: cookie.httpOnly,
          path: cookie.path,
          sameSite: cookie.sameSite,
          secure: cookie.secure,
          session: cookie.session,
        },
      };
    }
    
    /**
     * Login via puppeteer and return the redirect url and cookies.
     */
    function login() {
      return cy.task('LoginPuppeteer', {
        username: Cypress.env('AUTH_USERNAME'),
        password: Cypress.env('AUTH_PASSWORD'),
        loginUrl: 'http://localhost:3000/login',
        callbackUrl: 'http://localhost:3000/callback',
      });
    }
    
    /**
     * Login with Auth0.
     */
    Cypress.Commands.add('loginAuth0', () => {
      cy.session('logged in user', () => {
        login().then(({ cookies, callbackUrl }) => {
          console.log(cookies);
          cookies
            .map(createCookie)
            .forEach((c) => cy.setCookie(c.name, c.value, c.options));
    
          cy.visit(callbackUrl);
        });
      });
    });
    

    You can then use cy.loginAuth0() in your app to login with a real Auth0 instance. Make sure you have "experimentalSessionSupport": true in your cypress.json. That way you'll only have to perform this (admittedly long winded) task only once in your test suite!