Auth0-react and cypress

Hey there,

I’m trying to get cypress working with auth0-react. I’ve been banging my head on this for a while so I can’t even find where I found the below code from.

I have this in my cypress commands.js

Cypress.Commands.add(
  'login',
  (username, password, appState = { targetUrl: '/' }) => {
    cy.log(`Logging in as ${username}`);
    const options = {
      method: 'POST',
      url: Cypress.env('auth_url'),
      body: {
        grant_type: 'password',
        username: Cypress.env('auth_username'),
        password: Cypress.env('auth_password'),
        audience: Cypress.env('auth_audience'),
        scope: 'openid profile email',
        client_id: Cypress.env('auth_client_id'),
        client_secret: Cypress.env('auth_client_secret'),
      },
    };
    cy.request(options).then(({ body }) => {
      const { access_token, expires_in, id_token } = body;

      cy.server();

      // intercept Auth0 request for token and return what we have
      cy.route({
        url: 'oauth/token',
        method: 'POST',
        response: {
          access_token: access_token,
          id_token: id_token,
          scope: 'openid profile email',
          expires_in: expires_in,
          token_type: 'Bearer',
        },
      });

      // Auth0 SPA SDK will check for value in cookie to get appState
      // and validate nonce (which has been removed for simplicity)
      const stateId = 'test';
      cy.setCookie('auth0.is.authenticated', 'true');
      cy.setCookie(
        `a0.spajs.txs.${stateId}`,
        encodeURIComponent(
          JSON.stringify({
            appState: appState,
            scope: 'openid profile email',
            audience: Cypress.env('auth_audience'),
            redirect_uri: 'http://localhost:3000',
          }),
        ),
      ).then(() => {
        cy.visit(`/?code=test-code&state=${stateId}`, {
          onBeforeLoad(win) {
            delete win.fetch;
          },
        });
      });
    });
  },
);
Cypress.Commands.overwrite('visit', (originalFn, url, options) => {
  const opts = Object.assign({}, options, {
    onBeforeLoad: (window, ...args) => {
      window.fetch = null;
      if (options.onBeforeLoad) {
        return options.onBeforeLoad(window, ...args);
      }
    },
  });
  return originalFn(url, opts);
});
Cypress.on('window:before:load', (win) => {
  delete win.fetch;
});

I have cypress.json:

{
  "experimentalFetchPolyfill": true,
  "baseUrl": "http://localhost:3000"
}

The problem seems to be that @auth0/auth0-react is using fetch which isn’t caught by cy.route() above, even if I frantically try to remove window.fetch + run experimentalFetchPolyfill. I’ve also tried to do window.fetch = null in the top of my index.js.

Have anyone else gotten auth0-react to play nicely with cypress? Could you guide me through it?

Okay, I’ve now managed a horrible hack that seems to work

1. Make sure my <Auth0Provider uses cacheLocation="localstorage"

Full code:

 <Auth0Provider
                domain={env.REACT_APP_AUTH0_DOMAIN}
                clientId={env.REACT_APP_AUTH0_CLIENT_ID}
                redirectUri={window.location.origin}
                onRedirectCallback={onRedirectCallback}
                useRefreshTokens={true}
                cacheLocation="localstorage"
              >

2. Update my commands.js


Cypress.Commands.add('login', (username, password) => {
  cy.log(`Logging in as ${username}`);
  const client_id = Cypress.env('auth_client_id');
  const client_secret = Cypress.env('auth_client_secret');
  const audience = Cypress.env('auth_audience');
  const scope = 'openid profile email offline_access';

  const options = {
    method: 'POST',
    url: Cypress.env('auth_url'),
    body: {
      grant_type: 'password',
      username,
      password,
      audience,
      scope,
      client_id,
      client_secret,
    },
  };
  cy.request(options).then(({ body }) => {
    const { access_token, expires_in, id_token } = body;
    const key = `@@auth0spajs@@::${client_id}::default::${scope}`;
    const auth0Cache = {
      body: {
        client_id,
        access_token,
        id_token,
        scope,
        expires_in,
        decodedToken: {
          user: jwt_decode(id_token),
        },
      },
      expiresAt: Math.floor(Date.now() / 1000) + expires_in,
    };
    window.localStorage.setItem(key, JSON.stringify(auth0Cache));
    window.localStorage.setItem('__cypress', JSON.stringify(auth0Cache)); 
  });
});


let LOCAL_STORAGE_MEMORY = {};

Cypress.Commands.add('saveLocalStorageCache', () => {
  Object.keys(localStorage).forEach((key) => {
    LOCAL_STORAGE_MEMORY[key] = localStorage[key];
  });
});

Cypress.Commands.add('restoreLocalStorageCache', () => {
  Object.keys(LOCAL_STORAGE_MEMORY).forEach((key) => {
    localStorage.setItem(key, LOCAL_STORAGE_MEMORY[key]);
  });
});

3. In my app, make sure we read __cypress when initializing auth0

(I’m using Hasura)

// src/useAuth.tsx
import { useAuth0 } from '@auth0/auth0-react';
import { useCallback, useEffect, useState } from 'react';

export interface HttpsHasuraIoJwtClaims {
  'x-hasura-default-role': string;
  'x-hasura-allowed-roles': string[];
  'x-hasura-user-id': string;
}

export interface Auth0User {
  'https://hasura.io/jwt/claims': HttpsHasuraIoJwtClaims;
  nickname: string;
  name: string;
  picture: string;
  updated_at: string;
  email: string;
  email_verified: boolean;
  sub: string;
}


type AuthState =
  | {
      state: 'in';
      user: Auth0User;
      token: string;
    }
  | {
      state: 'out';
    }
  | {
      state: 'loading';
    }
  | {
      state: 'error';
      error: Error;
    };

type AuthContext = {
  login(): void;
  logout(): void;
} & AuthState;
export let authToken: string | null = null;
export function useAuth(): AuthContext {
  const auth0 = useAuth0();
  const [state, setState] = useState<AuthState>({
    state: 'loading',
  });

  const login = useCallback(() => {
    auth0.loginWithRedirect({
      appState: {
        targetUrl: window.location.href.substr(window.location.origin.length),
      },
    });
  }, [auth0]);
  const logout = auth0.logout;

  // get access token
  useEffect(() => {
    const { user, isAuthenticated } = auth0;

    async function fetchToken() {
      const tokenClaims = await auth0.getIdTokenClaims();

      let token = tokenClaims?.__raw;
      if (!token && user) {
        // ⚠️ horrible hack to get cypress in
        const item = window.localStorage.getItem('__cypress');
        if (item) {
          const parsed = JSON.parse(item);
          token = parsed.body.id_token;
        }
      }
      if (!token) {
        throw new Error('No token in tokenClaims');
      }

      authToken = token;
      setState({
        state: 'in',
        token,
        user,
      });
    }

    if (user && isAuthenticated) {
      fetchToken().catch((error) => {
        setState({
          state: 'error',
          error,
        });
      });
    }
  }, [auth0]);

  useEffect(() => {
    if (auth0.isLoading) {
      setState({ state: 'loading' });
      return;
    }
    if (auth0.error) {
      setState({
        state: 'error',
        error: auth0.error,
      });
      return;
    }
    if (!auth0.isAuthenticated) {
      setState({ state: 'out' });
      return;
    }
  }, [auth0.error, auth0.isAuthenticated, auth0.isLoading, auth0.user]);

  return {
    ...state,
    login,
    logout,
  };
}

I can then use it in a basic cypress test like this:

/// <reference types="Cypress" />

describe('login', () => {
  before(() => {
    cy.login('test@example.com', 'test');
    cy.saveLocalStorageCache();
  });
  beforeEach(() => {
    cy.restoreLocalStorageCache();
  });
  it('visit /', () => {
    cy.visit('/');
    cy.contains('Apple');
  });
});

/cypress.env.json looks something like this

{
  "auth_audience": "https://MYAPP.eu.auth0.com/api/v2/",
  "auth_url": "https://MYAPP.eu.auth0.com/oauth/token",
  "auth_client_id": "CLIENT_ID",
  "auth_client_secret": "CLIENT_SECRET",
}

I still want improvements -

  • I’d prefer to have a solution that works w/o local storage
  • How to get rid of the horrible __cypress hack - I needed it as auth0.getAccessTokenSilently() didn’t return a token that included the user that Hasura needs
  • There’s a mismatch on the localstorage key in auth_audience in the commands.js and that I store it as default (because of the application uses default), but I didn’t get why/how.

Anyway, happy I found a workaround. Maybe it helps someone else.