Next-auth/Auth0 e2e with Cypress test not working on host, but redirecting to universal flow

Currently facing a complex issue where i want to authenticate my end-to-end test in Cypress using Auth0 and Next-auth. Already took me quite some effort using various different tutorials like;

On localhost I’ve got the whole flow working now. But when I run the test via CI in a Github workflow using a custom domain/host I run into issues where instead of being logged in, I’m redirected to the Universal Login page of Auth0. You can see a compared flow of the Cypress tests in the following screenshot:

Here I’m facing the issue that as soon as I set the next-auth.session-token on my test server and redirect to the platform, the app is redirected to the Auth0 Universal Login page instead. For logging into Auth0 I use a custom Cypress command creating tokens via the Auth0 passwordGrant:

// cypress/support/commands.ts

import hkdf from '@panva/hkdf';
import { EncryptJWT, JWTPayload, decodeJwt } from 'jose';

// Function from https://github.com/nextauthjs/next-auth/blob/5c1826a8d1f8d8c2d26959d12375704b0a693bfc/packages/next-auth/src/jwt/index.ts#L113-L121
async function getDerivedEncryptionKey(secret: string) {
  return await hkdf('sha256', secret, '', 'NextAuth.js Generated Encryption Key', 32);
}

// Function from https://github.com/nextauthjs/next-auth/blob/5c1826a8d1f8d8c2d26959d12375704b0a693bfc/packages/next-auth/src/jwt/index.ts#L16-L25
export async function encode(token: JWTPayload, secret: string): Promise<string> {
  const maxAge = 30 * 24 * 60 * 60; // 30 days
  const encryptionSecret = await getDerivedEncryptionKey(secret);
  return await new EncryptJWT(token)
    .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
    .setIssuedAt()
    .setExpirationTime(Math.round(Date.now() / 1000 + maxAge))
    .setJti('test')
    .encrypt(encryptionSecret);
}

Cypress.Commands.add('loginByAuth0Api', (username: string, password: string) => {
  const log = Cypress.log({
    displayName: 'AUTH0 LOGIN',
    message: [`🔐 Authenticating | ${username}`],
    autoEnd: false,
  });

  log.snapshot(`Logging in as ${username}`);

  const client_id = Cypress.env('auth0_client_id');
  const client_secret = Cypress.env('auth0_client_secret');
  const audience = Cypress.env('auth0_audience');
  const scope = Cypress.env('auth0_scope');

  log.snapshot('before');

  cy.request({
    method: 'POST',
    url: `https://${Cypress.env('auth0_domain')}/oauth/token`,
    body: {
      grant_type: 'password',
      username,
      password,
      audience,
      scope,
      client_id,
      client_secret,
    },
  }).then(({ body }) => {
    const user: any = decodeJwt(body.id_token);
    const userObj = {
      user: {
        accessToken: body.access_token,
        idToken: body.id_token,
        sub: user.sub,
        nickname: user.nickname,
        picture: user.name,
        email: user.email,
      },
    };

    // Generate and set a valid cookie that next-auth can decrypt
    cy.wrap(null)
      .then(() => {
        return encode(userObj.user, Cypress.env('NEXTAUTH_SECRET'));
      })
      .then((encryptedToken) => {
        cy.setCookie('next-auth.session-token', encryptedToken);

        // Gives error:
        // cy.setCookie() had an unexpected error setting the requested cookie in Chrome.
        // > Error: Sanitizing cookie failed
        // cy.setCookie('next-auth.session-token', encryptedToken, {
        //   domain: `https://${Cypress.env('auth0_domain')}`,
        //   expiry: body.expires_in,
        //   secure: true,
        //   httpOnly: true,
        //   path: '/',
        // });

        Cypress.Cookies.defaults({
          preserve: 'next-auth.session-token',
        });
      });

    log.snapshot('after');
    log.end();
  });
});

As you can see I’m fetching the user on the supplied Auth0 issuer and fetching tokens. I can verify via jwt.io that the tokens fetched are valid on both localhost and the test server.

Furthermore the code is fairly simple. I use a next-auth middleware file in src/middleware.ts to redirect to user when unauthenticated and using a straight forwards next-auth config as well.

// src/middleware.ts
export { default } from 'next-auth/middleware';

export const config = { matcher: ['/dashboard/:path*'] };
// pages/api/auth/[...nextauth].ts

import NextAuth from 'next-auth';
import type { NextAuthOptions } from 'next-auth';
import Auth0Provider from 'next-auth/providers/auth0';

const authOptions: NextAuthOptions = {
  pages: {
    signIn: '/auth/signin',
    signOut: '/auth/signout',
  },
  session: {
    maxAge: 900, // 15 min
  },
  providers: [
    Auth0Provider({
      clientId: process.env.AUTH0_CLIENT_ID!,
      issuer: process.env.AUTH0_ISSUER!,
      clientSecret: process.env.AUTH0_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    async jwt({ token, account }) {
      if (account) {
        token.accessToken = account.access_token;
        token.idToken = account.id_token;
      }

      return token;
    },
    async session({ session, token }) {
      session.userId = token.sub;
      return session;
    },
  },
};

export default NextAuth(authOptions);

Also maybe helpful is the Cypress config:

// cypress.config.ts

import { defineConfig } from 'cypress';

// Populate process.env with values from .env file
require('dotenv').config({
  path: '.env.development',
});

const env = process.env.NODE_ENV === 'production' ? 'PROD' : 'DEV';
const baseUrl = process.env[`${env}_CYPRESS_BASE_URL`] || 'http://localhost:3000';

export default defineConfig({
  e2e: {
    baseUrl,
    chromeWebSecurity: false,
  },
  env: {
    NEXTAUTH_URL: process.env[`${env}_NEXTAUTH_URL`] || process.env.NEXTAUTH_URL,
    NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
    auth0_username: '',
    auth0_password: '',
    auth0_domain: (process.env?.[`${env}_AUTH0_ISSUER`] || process.env?.AUTH0_ISSUER!).replace(
      'https://',
      '',
    ),
    auth0_audience: 'main-audience',
    auth0_scope: 'openid profile email offline_access',
    auth0_client_id: process.env[`${env}_AUTH0_CLIENT_ID`] || process.env.AUTH0_CLIENT_ID,
    auth0_client_secret:
      process.env[`${env}_AUTH0_CLIENT_SECRET`] || process.env.AUTH0_CLIENT_SECRET,
  },
});

As seen in the Next Auth - Testing with Cypress tutorial I would expect that setting the next-auth.session-token cookie would be enough to login via Auth0 with next-auth. I also would expect the same behaviour on localhost as well as the test server.

Only the settings of the cookie is not as in the tutorial:

// tutorial code
cy.setCookie(cookie.name, cookie.value, {
  domain: cookie.domain,
  expiry: cookie.expires,
  httpOnly: cookie.httpOnly,
  path: cookie.path,
  secure: cookie.secure,
})

// my used code
cy.setCookie('next-auth.session-token', encryptedToken);

// also tried
cy.setCookie('next-auth.session-token', encryptedToken, {
  domain: `https://${Cypress.env('auth0_domain')}`,
  expiry: body.expires_in,
  secure: true,
  httpOnly: true,
  path: '/',
});

But the latter results in a Cypress error: > Error: Sanitizing cookie failed

Although I’ve come a long way, I’m not sure what I’m missing or misunderstanding to fix the final step; so all the help is welcome. Thanks in advance.