Search code examples
azureoauth-2.0automated-testscypresspkce

Is there a way to programatically login to using AzureAD with Cypress on PKCE flow?


I want to athenticate myself (React application) using cypress.js (https://www.cypress.io/). Is there a way to do it programatically with PKCE? As i was reading and looking into all examples - all of them are using implicit flow

I was trying to use solutions like https://www.npmjs.com/package/react-adal but with no success as it needs an implicit flow to be turned on I was trying this as well: https://xebia.com/blog/how-to-use-azure-ad-single-sign-on-with-cypress/ with no success

I expected to programatically signin inside cypress and save user info and access_token to sessionStorage to be able to perform another api calls


Solution

  • I have not found a way to do this programmatically with PKCE per se, but with the MSAL 2.0 library (@azure/msal-browser on npm) I was able to fill in the account cache ahead of time so it thought it was already logged in. The process looks like this:

    1. With a cy.task, send a request to Azure AD using the ROPC flow to get the tokens.
      const scopes = [
          'openid',
          'profile',
          'user.read',
          'email',
          'offline_access' // needed to get a refresh token
      ];
      const formdata = new URLSearchParams({
          'grant_type': 'password',
          'scope': scopes.join(' '),
          'client_info': 1, // returns an extra token that MSAL needs
          'client_id': aadClientId,
          'client_secret': aadClientSecret,
          'username': aadUsername,
          'password': aadPassword,
      });
      const response = await fetch(`https://login.microsoft.com/${aadTenantId}/oauth2/v2.0/token`, {
          method: 'POST',
          headers: {
              'Content-Type': 'application/x-www-form-urlencoded',
          },
          body: formdata.toString(),
      });
      const tokens = await response.json();
      
    2. Transform the tokens into the cache entries that MSAL wants (based on observing it in a real browser)
      // The token tells us how many seconds until expiration;
      // MSAL wants to know the timestamp of expiration.
      const cachedAt = Math.round(new Date().getTime()/1000);
      const expiresOn = cachedAt + tokens.expires_in;
      const extendedExpiresOn = cachedAt + tokens.ext_expires_in;
      
      // We can pull the rest of the data we need off of the ID token body
      const id_token = JSON.parse(Buffer.from(tokens.id_token.split('.')[1], 'base64').toString('utf-8'));
      
      const clientId = id_token.aud;
      const tenantId = id_token.tid;
      const userId = id_token.oid;
      const name = id_token.name;
      const username = id_token.preferred_username;
      
      const environment = 'login.windows.net'; // 🤷‍♂️
      const homeAccountId = `${userId}.${tenantId}`;
      
      const cacheEntries = {};
      
      // client info
      cacheEntries[`${homeAccountId}-${environment}-${tenantId}`] = JSON.stringify({
        authorityType: 'MSSTS',
        clientInfo: tokens.client_info,
        environment,
        homeAccountId,
        localAccountId: userId,
        name,
        realm: tenantId,
        username,
      });
      
      // access token
      cacheEntries[`${homeAccountId}-${environment}-accesstoken-${clientId}-${tenantId}-${token.scope}`] = JSON.stringify({
        cachedAt: cachedAt.toString(),
        clientId,
        credentialType: "AccessToken",
        environment,
        expiresOn: expiresOn.toString(),
        extendedExpiresOn: extendedExpiresOn.toString(),
        homeAccountId,
        realm: tenantId,
        secret: tokens.access_token,
        target: tokens.scope,
      });
      
      // id token
      cacheEntries[`${homeAccountId}-${environment}-idtoken-${clientId}-${tenantId}-`] = JSON.stringify({
        clientId,
        credentialType: "IdToken",
        environment,
        homeAccountId,
        realm: tenantId,
        secret: tokens.id_token,
      });
      
      // refresh token
      cacheEntries[`${homeAccountId}-${environment}-refreshtoken-${clientId}--`] = JSON.stringify({
        clientId,
        credentialType: "RefreshToken",
        environment,
        homeAccountId,
        secret: tokens.refresh_token,
      });
      
    3. Use cy.window to store those in sessionStorage or localStorage, depending on how you have MSAL configured.
      cy.task('login').then(cacheEntries => {
          cy.window().then(window => {
              for (let entry in cacheEntries) {
                  window.sessionStorage.setItem(entry, cacheEntries[entry]);
              }
          });
      });
      

    It's super fragile and not very pretty, but it works! The user that Cypress logs in as needs MFA to be disabled, of course.