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

Viewed 99

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?

1 Answers

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.

Related