Tuesday, October 27, 2020

Cypress.io testing with Auth0 and Angular 10

I read the documentation on both the Auth0 site and the Cypress.io site about using the two technologies together with the Angular framework, but I simply couldn't get it to work.

For example, the tutorial on the Auth0 Blog called The Complete Guide to Angular User Authentication with Auth0 got me most of the way there and the same was true for the Cypress.io "real-world app" code and documentation on GitHub (which is written for React BTW).

These two resources (plus some community posts) allowed me to set up all the underpinnings of a working test system but didn't resolve all my issues. In the past, my Angular project exclusively used Google Authentication, so my Cypress tests required me manually login first, and then run all the tests that needed an authenticated user. It wasn't perfect, but it was workable. However, his manual login option did not work with Auth0 (and Angular 10). Google Authentication would not allow the login window to open in an iFrame when running in the context of a Cypress test (i.e., Cypress was automating/controlling the browser).

- Auth0 authenticated tests running successfully in Cypress.io and Angular 10


This is what I ultimately did to get it working:

1.  Switch my Node.js API from Google tokens to Auth0 authentication tokens by following the Auth0 quickstart for Node.js (Express) backends.

2. Set up a test SPA Application in Auth0 with lower security than my production app. This allowed me to enable username/password logins and turn on the Password Grant Types (under Application > Advanced Settings).

3. Follow the Cypress real-world app section called "Cypress Setup for Testing Auth0) to add a Login function to my Cypress.io setup.

Under Cypress > support > commands.ts, I now have the code below. You can also use plain JavaScript of course. The configuration for the variables is found under src > cypress.env.json.

/// <reference types="cypress" />

/// <reference types="jwt-decode" />

import jwt_decode from 'jwt-decode';


Cypress.Commands.add('login', (overrides = {}) => {

  const username = Cypress.env('auth0_username');

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

    cy.request({

      method: "POST",

      url: Cypress.env('auth0_url'),

      body: {

        grant_type: 'password',

        username: Cypress.env('auth0_username'),

        password: Cypress.env('auth0_password'),

        audience: Cypress.env('auth0_audience'),

        scope: Cypress.env('auth0_scope'),

        client_id: Cypress.env('auth0_client_id'),

        client_secret: Cypress.env('auth0_client_secret'),  

      },

    }).then(({ body }) => {

      const claims: any = jwt_decode(body.id_token);

      const { nickname, name, picture, updated_at, email, email_verified, sub, exp } = claims;


      const item = {

        body: {

          ...body,

          decodedToken: {

            claims,

            user: {

              nickname,

              name,

              picture,

              updated_at,

              email,

              email_verified,

              sub,

            },

            audience: '',

            client_id: '',

          },

        },

        expiresAt: exp,

      };


    window.localStorage.setItem('auth0Cypress', JSON.stringify(item));

    return body;

  });

});


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]);

  });

});


4. With this command added to Cypress.io, I can now login programmatically to Auth0 using the following code in my first test. You'll note that the authenticated user details are being written in local storage (but only during testing), and the Auth0 authenticated token is being stored as a cookie--this is the token that I use with my backend API.


describe('Login', () => {

  beforeEach(() => {

    cy.restoreLocalStorageCache();

  });

  

  it('Should successfully login', () => {

    cy.login2()

      .then((resp) => {

        return resp;

      })

      .then((body) => {

        const {access_token, expires_in, id_token} = body;

        const auth0State = {

          nonce: '',

          state: 'some-random-state'

        };


        // write access token to user-token cookie

        cy.setCookie('user-token', access_token);


        const callbackUrl = `/callback#access_token=${access_token}&scope=openid&id_token=${id_token}&expires_in=${expires_in}&token_type=Bearer&state=${auth0State.state}`;

        cy.visit(callbackUrl, {

          onBeforeLoad(win) {

            win.document.cookie = 'com.auth0.auth.some-random-state=' + JSON.stringify(auth0State);

          }

        });

      })

  });

  afterEach(() => {

    cy.saveLocalStorageCache();

  });

});


6. This all seemed to work great, however, Auth0 still would not recognize that the user is authenticated. The Auth0 client would always return false for isAuthenticated. To get around this issue, I had to hack my AuthGuard.

This is what I had before adding Cypress.io:

return this.auth.isAuthenticated$.pipe(

  tap(loggedIn => {

    if (!loggedIn) {

       this.auth.login(state.url);

    }

 })

);


To get around the issue, I simple added another option. If the code is being run by Cypress, I check for the stored user credential and token. I even added a check that it's the right authenticated user, but that's really not needed.


    // @ts-ignore

    if (window.Cypress) {

      const auth0credentials = JSON.parse(localStorage.getItem("auth0Cypress")!);

      const user = auth0credentials.body.decodedToken.user;

      const access_token = auth0credentials.body.access_token;


      if(user.name === 'youtestuser@yourdomain.com' && access_token) {

        return true;

      } else {

        return this.auth.isAuthenticated$.pipe(

          tap(loggedIn => {

            if (!loggedIn) {

              } else {

                this.auth.login(state.url);

              }

            })

          );

      };

    } else {

      return this.auth.isAuthenticated$.pipe(

        tap(loggedIn => {

          if (!loggedIn) {

            this.auth.login(state.url);

          }

        })

      );

    }


Well, I think that's everything. I hope there is a better answer coming as this hack around the AuthGuard is not a prefect solution, but it does let me move forward and that's promising.


No comments: