Search code examples
javascriptmethodscypressazure-ad-msalcypress-login

How to edit the MSAL login commands to make it work for multiple users?


I have the following code working and I'm able to login with username and password. I am working with Cypress to login to a webapp with MSAL.

In the e2e Testfile:

describe('Login with MSAL as xxUsername', () => {
beforeEach(() => {
    cy.LoginWithMsal()
})

Command.js:

    import { login } from "./auth";
    let cachedTokenExpiryTime = new Date().getTime();
    let cachedTokenResponse = null;
    
    Cypress.Commands.add("LoginWithMsal", () => {
    if (cachedTokenExpiryTime <= new Date().getTime()) {
        cachedTokenResponse = null;
    }
    return login(cachedTokenResponse).then((tokenResponse) => {
        cachedTokenResponse = tokenResponse;
        cachedTokenExpiryTime = new Date().getTime() + 50 * 60 * 1000;
    });
});

Imported auth.js

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

import { decode } from "jsonwebtoken";
import authSettings from "./authsettings.json";

const {
    authority,
    clientId,
    clientSecret,
    apiScopes,
    username,
    password,
} = authSettings;
const environment = "login.windows.net";

const buildAccountEntity = (
    homeAccountId,
    realm,
    localAccountId,
    username,
    name
) => {
    return {
        authorityType: "MSSTS",
        // This could be filled in but it involves a bit of custom base64 encoding
        // and would make this sample more complicated.
        // This value does not seem to get used, so we can leave it out.
        clientInfo: "",
        homeAccountId,
        environment,
        realm,
        localAccountId,
        username,
        name,
    };
};

const buildIdTokenEntity = (homeAccountId, idToken, realm) => {
    return {
        credentialType: "IdToken",
        homeAccountId,
        environment,
        clientId,
        secret: idToken,
        realm,
    };
};

const buildAccessTokenEntity = (
    homeAccountId,
    accessToken,
    expiresIn,
    extExpiresIn,
    realm,
    scopes
) => {
    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: scopes.map((s) => s.toLowerCase()).join(" "),
        // Scopes _must_ be lowercase or the token won't be found
    };
};

const injectTokens = (tokenResponse) => {
    const idToken = decode(tokenResponse.id_token);
    const localAccountId = idToken.oid || idToken.sid;
    const realm = idToken.tid;
    const homeAccountId = `${localAccountId}.${realm}`;
    const username = idToken.preferred_username;
    const name = idToken.name;

    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 accessTokenKey = `${homeAccountId}-${environment}-accesstoken-${clientId}-${realm}-${apiScopes.join(
        " "
    )}`;
    const accessTokenEntity = buildAccessTokenEntity(
        homeAccountId,
        tokenResponse.access_token,
        tokenResponse.expires_in,
        tokenResponse.ext_expires_in,
        realm,
        apiScopes
    );

    localStorage.setItem(accountKey, JSON.stringify(accountEntity));
    localStorage.setItem(idTokenKey, JSON.stringify(idTokenEntity));
    localStorage.setItem(accessTokenKey, JSON.stringify(accessTokenEntity));
};

export const login = (cachedTokenResponse) => {
    let tokenResponse = null;
    let chainable = cy.visit("https://xxxxxxxxxxxxx.nl/");

    if (!cachedTokenResponse) {
        chainable = chainable.request({
            url: authority + "/oauth2/v2.0/token",
            method: "POST",
            body: {
                grant_type: "password",
                client_id: clientId,
                client_secret: clientSecret,
                scope: ["openid profile"].concat(apiScopes).join(" "),
                username: username,
                password: password,
            },
            form: true,
        });
    } else {
        chainable = chainable.then(() => {
            return {
                body: cachedTokenResponse,
            };
        });
    }

    chainable
        .then((response) => {
            injectTokens(response.body);
            tokenResponse = response.body;
        })
        .reload()
        .then(() => {
            return tokenResponse;
        });

    return chainable;
};

Got credentials in authSettings.json

{
  "authority": "https://login.microsoftonline.com/x",
  "clientId": "x",
  "clientSecret": "x",
  "apiScopes": [ "x" ],
  "username": "xxUsername",
  "password": "xxPassword"
}

As you can see I am able to login with the credentials which were saved as variable in the authSettings.json file. This is restricting me to use just 1 user to authenticate in my tests. What is the best practice to get logged in with any other usercredential?


Solution

  • Add users to the fixture keyed by an id

    authsettings.json

    {
      "user1": {
        "username": "xxUsername",
        "password": "xxPassword"
        ...
      },
      "user2": {
        "username": "xxUsername",
        "password": "xxPassword"
        ...
      },
      ...
    }
    

    In auth.js is gets a bit tricky since you have some closures on the initial import, for example

    const buildIdTokenEntity = (homeAccountId, idToken, realm) => {
        return {
            credentialType: "IdToken",
            homeAccountId,
            environment,
            clientId,                    // closure from above (not a parameter)
            secret: idToken,
            realm,
        };
    };
    

    You could set the desired userid in an environment variable, so the top of auth.js becomes

    import authSettings from "./authsettings.json";
    
    const userId = Cypress.env('userId');
    const {
        authority,
        clientId,
        clientSecret,
        apiScopes,
        username,
        password,
    } = authSettings[userId];
    

    In the tests,

    it('tests user1', () => {
      Cypress.env('userId', 'user1')
      ...
    })
    

    Also use a default in Cypress configuration

    // cypress.config.js
    
    const { defineConfig } = require('cypress')
    
    module.exports = defineConfig({
      e2e: {
        baseUrl: 'http://localhost:1234'
      },
      env: {
        userId: 'user3'
      }
    })
    

    Timing

    The above is your smallest change, but I suspect it won't work since Command.js is imported in cypress/support/e2e.js and executes the auth.js import before the test runs.

    If that's the case, you will need to pass userId into the login

    test

    describe('Login with MSAL as xxUsername', () => {
    beforeEach(() => {
        cy.LoginWithMsal('user2')
    })
    

    Commands.js

    Cypress.Commands.add("LoginWithMsal", (userId) => {           // receive here
        if (cachedTokenExpiryTime <= new Date().getTime()) {
            cachedTokenResponse = null;
        }
        return login(cachedTokenResponse, userId)                 // pass here
          .then((tokenResponse) => {
            cachedTokenResponse = tokenResponse;
            cachedTokenExpiryTime = new Date().getTime() + 50 * 60 * 1000;
          });
    

    auth.js

    import authSettings from "./authsettings.json";
    
    let                                        // const -> let to allow change
        authority,
        clientId,
        clientSecret,
        apiScopes,
        username,
        password;
    
    ...
    
    export const login = (cachedTokenResponse, userId) => {
    
      authority = authSettings[userId].authority;
      clientId = authSettings[userId].clientId;
      clientSecret = authSettings[userId].clientSecret;
      apiScopes = authSettings[userId].apiScopes;
      username = authSettings[userId].username;
      password = authSettings[userId].password;
    
      ...
    

    You could reduce that down if some of the credentials are common to all users.