Speed Up Cypress Testing of NextAuth Secured Web Apps
Tags: webdev, testing, nextjs, security
Published: 2023-02-28 22:53:27

One of the biggest headaches around end-to-end testing is dealing with authentication/authorization. With the rapid adoption of [Next.js][nextjs_home] there has also been a corresponding increase in the use of [NextAuth][nextauth_home] which is the [preferred method][nextjs_auth_docs] for doing authentication/authorization with Next.js. 

The recommended approaches (e.g. [NextAuth][nextauth_test_cypress], [Cypress][cypress_test_nextauth]) for authenticating a [Cypress][cypress_home] test session usually involves going through a full login flow with a given authorization provider. The full login flow approach significantly increases the time to execute a test run and is still subject to failure when the underlying provider changes elements in the login flow interface.

However, if you're using NextAuth with the [JWT session strategy][jwt_session_strategy] you can skip the login flow completely for **ANY** provider by directly writing the `next-auth.session-token` cookie directly which will grant the session full authentication! This approach simplifies end-to-end testing with Cypress as well as reduces test time significantly regardless of the chosen authorization provider.

This article will cover how to configure Cypress to perform end-to-end testing on Next.js web app that has all pages and routes secured by NextAuth.

## Example Web App

The full source code of the examples shown in this article can be found [here][example_repo]. The example web app is based on the [react-note-taking-app][react-note-taking-app] built on top of the [T3 Stack][t3_stack]. The Cypress tests and configuration are based on the best practices documented in the [cypress-realworld-app][cypress-rwa].

This article is only going to cover a narrow portion of source code found in the [example repo][example_repo]. If you're looking for more detailed information about some of the source code in the [example repo][example_repo] then I suggest referring to the links above.

## Configure NextAuth

In the [`[...nextauth].ts`][nextauth_config] file enable the [JWT session strategy][jwt_session_strategy] for your chosen provider:

```typescript
import NextAuth, { type NextAuthOptions } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
import { PrismaAdapter } from "@next-auth/prisma-adapter";

import { env } from "../../../env/server.mjs";
import { prisma } from "../../../server/db";

export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),
  providers: [
    DiscordProvider({
      clientId: env.DISCORD_CLIENT_ID,
      clientSecret: env.DISCORD_CLIENT_SECRET,
    }),
  ],
  pages: {
    signIn: "/auth/login",
    signOut: "/",
    error: "/auth/unauthorized",
  },
  session: {
    strategy: "jwt",
  },
};

export default NextAuth(authOptions);
```

## Configure Cypress

In the [`cypress.config.ts`][cypress_config] file expose the `NEXTAUTH_SECRET` to the [Cypress.env][Cypress.env] command and disable [`chromeWebSecurity`][chromeWebSecurity] which will allow writing insecure cookies:

```typescript
import { defineConfig } from "cypress";
import { loadEnvConfig } from "@next/env";

loadEnvConfig(process.cwd());

export default defineConfig({
  env: {
    database_url: process.env.DATABASE_URL,
    nextauth_secret: process.env.NEXTAUTH_SECRET,
    mobileViewportWidthBreakpoint: 414,
  },
  e2e: {
    baseUrl: "http://localhost:3000",
    specPattern: "cypress/tests/**/*.spec.{js,jsx,ts,tsx}",
    supportFile: "cypress/support/e2e.ts",
    chromeWebSecurity: false,
    viewportHeight: 1000,
    viewportWidth: 1280,
  },
});
```

## Create a Custom Command for NextAuth Login

Create a [custom command][custom_command] that will write a properly encoded `next-auth.session-token` cookie using the provided `NEXTAUTH_SECRET`. ([`nextAuth.ts`][nextAuth.ts])

```typescript
import { v4 as uuidv4 } from "uuid";
import { encode } from "next-auth/jwt";
import type { JWT } from "next-auth/jwt";

// Custom command for automagically authenticating using next-auth cookies.
// Note: this function leaves you on a blank page, so you must call cy.visit()
// afterwards, before continuing with your test.
Cypress.Commands.add("loginNextAuth", ({ userId, name, email, provider }: loginNextAuthParams) => {
  Cypress.log({
    displayName: "NEXT-AUTH LOGIN",
    message: [`🔐 Authenticating | ${name}`],
  });

  const dateTimeNow = Math.floor(Date.now() / 1000);
  const expiry = dateTimeNow + 30 * 24 * 60 * 60; // 30 days
  const cookieName = "next-auth.session-token";
  const cookieValue: JWT = {
    sub: userId,
    name: name,
    email: email,
    provider: provider,
    picture: `https://via.placeholder.com/200/7732bb/c0392b.png?text=${name}`,
    tokenType: "Bearer",
    accessToken: "dummy",
    iat: dateTimeNow,
    exp: expiry,
    jti: uuidv4(),
  };

  // https://docs.cypress.io/api/utilities/promise#Waiting-for-Promises
  cy.wrap(null, { log: false }).then(() => {
    return new Cypress.Promise(async (resolve, reject) => {
      try {
        const encryptedCookieValue = await encode({ token: cookieValue, secret: Cypress.env("nextauth_secret") });

        cy.setCookie(cookieName, encryptedCookieValue, {
          log: false,
          httpOnly: true,
          path: "/",
          expiry: expiry,
        });

        resolve();
      } catch (err) {
        console.error(err);
        reject();
      }
    });
  });
});
```

## Use the NextAuth Login Command in a Test

Now we can use the custom command to login! ([`auth.spec.ts`][auth.spec.ts])

```typescript
import { testCreds } from "../support/testData";

describe("given an unauthenticated session", () => {
  before(() => {
    cy.resetDatabase();
  });

  beforeEach(() => {
    cy.loginNextAuth(testCreds);

    cy.visit("/");
  });

  describe("when generating a valid JWT and visiting the site", () => {
    it("should show the home page of an authenticated user", () => {
      cy.findByLabelText("logout-button").should("be.visible");
    });
  });
});
```

## Conclusion

This method of login is a great way to speed up Cypress tests that are currently using a full login flow. I don't think this method completely replaces the need for testing a full login flow, but for the vast majority of tests it would be best to save time using the method outlined in the article.

Check out the [example repo][example_repo] for this article and run the Cypress test suite yourself to get a feel for how fast this login technique can be for your project(s).

<!-- General Links -->

[nextjs_home]: https://nextjs.org/
[nextauth_home]: https://next-auth.js.org/
[nextjs_auth_docs]: https://nextjs.org/docs/authentication#bring-your-own-database
[nextauth_test_cypress]: https://next-auth.js.org/tutorials/testing-with-cypress
[cypress_test_nextauth]: https://docs.cypress.io/guides/end-to-end-testing/google-authentication
[cypress_home]: https://docs.cypress.io/guides/overview/why-cypress
[jwt_session_strategy]: https://next-auth.js.org/configuration/options#session
[react-note-taking-app]: https://www.youtube.com/watch?v=j898RGRw0b4&t=342s
[t3_stack]: https://create.t3.gg/
[cypress-rwa]: https://github.com/cypress-io/cypress-realworld-app
[Cypress.env]: https://docs.cypress.io/api/cypress-api/env
[chromeWebSecurity]: https://docs.cypress.io/guides/references/configuration#Browser
[custom_command]: https://docs.cypress.io/api/cypress-api/custom-commands

<!-- Example Repo Links -->
[example_repo]: https://github.com/cowofevil/next-auth-cypress-example
[nextauth_config]: https://github.com/cowofevil/next-auth-cypress-example/blob/main/src/pages/api/auth/%5B...nextauth%5D.ts
[cypress_config]: https://github.com/cowofevil/next-auth-cypress-example/blob/main/cypress.config.ts
[nextAuth.ts]: https://github.com/cowofevil/next-auth-cypress-example/blob/main/cypress/support/auth-provider-commands/nextAuth.ts
[auth.spec.ts]: https://github.com/cowofevil/next-auth-cypress-example/blob/main/cypress/tests/auth.spec.ts