Understanding the problem

Cypress is unique in that it runs as a native application inside the browser while your web app runs in an iframe. However, it also means that Cypress is subject to the same-origin policy, which prevents it from interacting with or verifying cross-origin content without browser restrictions. 

This post details two ways to authenticate a user against a provider on a different domain, specifically for Microsoft Azure Active Directory:

  1. Using cy.origin().
  2. Programmatic login to sidestep UX login mechanism (Leveraging MSAL).

Cypress can only work with the domain of the first URL that is loaded via cy.visit(). To log in a user to the app using a third-party provider it needs to be redirected to a login page hosted on the provider's domain. Earlier, if a web app used Microsoft Azure Active Directory, Cypress could not test the login process because the AAD login page is hosted on a different domain. This is because Cypress could not interact with the login page due to the same-origin policy.

Login with cy.origin()

Recently, Cypress introduced a new feature called cy.origin(), which allows it to interact with cross-origin content. This means that one can now test web apps that use third-party services or that allow users to log in with third-party accounts.

Cypress version: 13.2.0 and beyond

Lets create a custom function called loginToMSAAD to login to Azure Active Directory. This function will use cy.origin().

But first, a few pre-requisite settings:

//cypress.config.ts

import { defineConfig } from 'cypress'

export default defineConfig({
  e2e: {
    "experimentalModifyObstructiveThirdPartyCode": true,
    "chromeWebSecurity": true
  }
})

experimentalModifyObstructiveThirdPartyCode needs to be set to true in e2e configuration. If not, the AAD login form will not render.

chromeWebSecurity needs to be explicitly set to true. This is to avoid the error: AADSTS9002326: Cross-origin token redemption is permitted only for the 'Single-Page Application' client-type.

The custom command:

//cypress/support/index.d.ts
declare namespace Cypress {
 interface Chainable {
   loginToMSAAD(username: string, password: string): void;
 }
}

 

//cypress/support/commands.ts
namespace Cypress {
 interface Chainable {
   loginToMSAAD(username: string, password: string): void;
 }
}

Cypress.Commands.add('loginToMSAAD', (username: string, password: string) => {

 cy.origin(
   'login.microsoftonline.com',
   {
     args: {
       username,
     },
   },
   ({ username }) => {
     cy.get('input[type="email"]').type(username, {
       log: false,
     })
     cy.get('input[type="submit"]').click()
   }
 );
 cy.origin(
   'login.microsoftonline.com', //This could be different, for eg, live.microsoft.com
   {
     args: {
       password,
     },
   },
   ({ password }) => {
     cy.get('input[type="password"]').type(password, {
       log: false,
     })
     cy.get('input[type="submit"]').click();
     cy.get('input[type="submit"]').click();
   }
 );

 // Ensure AAD has redirected us back to our app. Verify that the user is logged in
 cy.url().should('contains', Cypress.config('baseUrl'));
 cy.get('#span.avatar').should(
   'contain',
   `Hi, ${Cypress.env('testuser_username')}!`
 )
});

Usage in a spec file:

describe('Authenticated User Tests', () => {
 beforeEach(() => {
   //Visit your application, click login
   cy.visit('/');
   cy.get('button#login-pop').click();
   cy.get('[data-testid="log-in-aad"]').first().click({ force: true });

   // Log into MS Azure Active Directory using the custom function
   cy.loginToMSAAD(Cypress.env('testuser_username'), Cypress.env('testuser_password'));
 })

 it('verifies the logged in user can see his profile details', () => {
   cy.get('#user-pop').should(
     'contain',
     'My Profile'
   );
 })
 })

The user is now successfully authenticated. However, our current setup involves logging in before each test. We can optimize our login command by utilizing cy.session() to store the authentication tokens and/or cookies of our logged-in user. 

This optimization eliminates the need for reauthentication before every test, streamlining our testing process and mitigating concerns related to API rate limiting.

 Cypress.Commands.add('loginToMSAAD', (username: string, password: string) => {
 const args = { username, password };
 cy.session(
   args,
   () => {
     cy.visit('/');
     cy.get('button#login-pop').click();
     cy.get('[data-testid="log-in-aad"]').first().click({ force: true });
     cy.origin(
       'login.microsoftonline.com',
       {
         args: {
           username,
         },
       },
       ({ username }) => {
         cy.get('input[type="email"]').type(username, {
           log: false,
         })
         cy.get('input[type="submit"]').click()
       }
     );
     cy.origin(
       'login.microsoftonline.com', //This could be different, for eg, live.microsoft.com
       {
         args: {
           password,
         },
       },
       ({ password }) => {
         cy.get('input[type="password"]').type(password, {
           log: false,
         })
         cy.get('input[type="submit"]').click();
         cy.get('input[type="submit"]').click();
       }
     );

     // Ensure AAD has redirected us back to our app. Verify that the user is logged in
     cy.url().should('contains', Cypress.config('baseUrl'));
     cy.get('#span.avatar').should(
       'contain',
       `Hi, ${Cypress.env('testuser_username')}!`
     );
   });
});

cy.session() will cache the results and the tests will run faster.

Programmatic authentication

An alternative is to write Cypress code to programmatically log in to the application without having to interact with the UI.

Cypress version: 9.2.0 and beyond

Applications that use Microsoft Azure Active Directory for authentication typically use MSAL.js for communication with AAD. When a user has been authenticated, the following tokens are injected into the browser session storage:

IdToken

AccessToken

RefreshToken

AccountInfo

Programmatic login leverages the above mechanism to trick the browser into believing that the user has been authenticated. These tokens are generated using the ROPC flow.

Solution overview

Use a POST call to the endpoint /oauth2/v2.0/token to generate id, access and refresh tokens. Inject these tokens in the browser session storage in a format expected by MSAL.js. MSAL finds the tokens in the session storage cache. It concludes that it has already acquired the tokens and doesn't try to acquire them again.

Acquire and Inject Tokens

 

 

 

 

 

 

 

 

 

Use cy.request() to create a POST request to the endpoint /oauth2/v2.0/token. The grant_type to be specified is password for the ROPC flow. scope specifies what kind of access tokens we need; to get the ID token, the openid scope should be mentioned. 

Cypress.Commands.add('login', (username, password) => {
    let sequence = cy.visit('/');
  
    //POST call to token endpoint
    sequence = sequence.request({
    url: authority + "/oauth2/v2.0/token",
    method: "POST",
    body: {
        grant_type: "password",
        client_id: clientId,
        client_secret: clientSecret,
        scope: ["openid profile offline_access"].concat(apiScopes).join(" "),
        username: username,
        password: password,
    },
    form: true,
    });
  
    sequence
      .then((response) => {
        insertSessionStorageTokens(response.body);
      })
      .reload(); //Reload the page for MSAL to know that the tokens are there and the user is already authenticated
  });

Build the token keys and values from the response. These entities should adhere to the format expected by MSAL.js. For example, the accessToken key and its value can be built as follows:

  const accessTokenKey = `${homeAccountId}-${environment}-accesstoken-${clientId}-${realm}-${apiScopes}`;
  const accessTokenEntity = buildAccessTokenEntity(
    homeAccountId,
    tokenResponse.access_token,
    tokenResponse.expires_in,
    tokenResponse.ext_expires_in,
    realm,
    apiScopes
  );


  const buildAccessTokenEntity = (
    homeAccountId,
    accessToken,
    expiresIn,
    extExpiresIn,
    realm
  ) => {
    const now = Math.floor(Date.now() / 1000);
    return {
      homeAccountId,
      credentialType: "AccessToken",
      secret: accessToken,
      cachedAt: now.toString(),
      expiresOn: (now + expiresIn).toString(),
      extendedExpiresOn: (now + extExpiresIn).toString(),
      environment,
      clientId,
      realm,
      target: apiScopes    
    };
  };

Similarly, build the idToken, refreshToken and AccountInfo entities.

Inject these keys and their values in the browser session storage. Reload the page for MSAL to know that the tokens are there and the user is already authenticated.

  const insertSessionStorageTokens = (tokenResponse) => {
    const homeAccountId = `${localAccountId}.${realm}`;
    const accountKey = `${homeAccountId}-${environment}-${realm}`;
    const accountEntity = buildAccountEntity(
       homeAccountId,
       realm,
       localAccountId,
       username,
       name
     );
   
     const idTokenKey = `${homeAccountId}-${environment}-idtoken-${clientId}-${realm}-`;
     const idTokenEntity = buildIdTokenEntity(
       homeAccountId,
       tokenResponse.id_token,
       realm
     );
   
     const refreshTokenKey = `${homeAccountId}-${environment}-refreshtoken-${clientId}-`;
     const refreshTokenEntity = buildRefreshTokenEntity(
       homeAccountId,
       tokenResponse.refresh_token
     );
   
     const accessTokenKey = `${homeAccountId}-${environment}-accesstoken-${clientId}-${realm}-${apiScopes}`;
     const accessTokenEntity = buildAccessTokenEntity(
       homeAccountId,
       tokenResponse.access_token,
       tokenResponse.expires_in,
       tokenResponse.ext_expires_in,
       realm,
       apiScopes
     );
   
     //Inject into browser sessionStorage
     sessionStorage.setItem(accountKey, JSON.stringify(accountEntity));
     sessionStorage.setItem(idTokenKey, JSON.stringify(idTokenEntity));
     sessionStorage.setItem(accessTokenKey, JSON.stringify(accessTokenEntity));
     sessionStorage.setItem(refreshTokenKey, JSON.stringify(refreshTokenEntity));
   };

The values of various parameters used above are being fetched from an environment JSON file:

    {
      "authority": "https://login.microsoftonline.com/your-aad-tenant-id",
      "clientId": "app-client-id",
      "clientSecret": "app-client-secret",
      "apiScopes": "api://api-client-id/adminuiapi.access",
      "username": "user@yourcompany.onmicrosoft.com",
      "password": "password",
      "environment":"login.microsoftonline.com",
      "authorityType":"MSSTS",
      "localAccountId": "current-user's-user-id",
      "name":"name-of-the-user-without-the-AADdomain",
      "realm":"your-aad-tenant-id"    
    }

This file should be gitignored and shouldn't be added to the repository.

Example usage in a test file

Since the login function has been defined in commands file, it can be used as cy.login(). In the following snippet, login is called before each test. Depending on the expiration window for the tokens, login may be called for each test specification instead to improve the performance.

describe('Authenticated User Tests', ()=>{
    beforeEach(() => {        
        cy.login();
    })

   it('verifies the logged in user can see his profile details', () => {
   cy.get('#user-pop').should(
     'contain',
     'My Profile'
   );
 })
})

I hope that the solutions outlined in this article will assist you in effectively establishing user authentication against AAD using Cypress.