DEV Community

Cover image for Testing MSAL protected single-page applications in Playwright
Yahya Alshwaily
Yahya Alshwaily

Posted on

Testing MSAL protected single-page applications in Playwright

In this article we are going to look at how to handle authenticating with playwright end-to-end automated tests using the microsoft authentication library


First lets look at a couple of popular shortcuts to bypass msal auth

1. Disabling Authentication for E2E tests
This is a bad practice for a couple of reasons. first you have to make changes in the application settings to have authentication disabled in test environment and implement switching in production code plus you cannot test some auth guarded functionality if your application has tiered users

2. Using the popup window to enter credentials
This approach is not advisable for two reasons, first, if there are UI changes made by microsoft to the popup window. this will break your tests since it is a component that is not in your control. Second, you will have to login for every test if you use a fresh context for each test and not properly storing the auth tokens in the local storage, thus losing flexibility and longer testing times


what we need to do instead is directly acquire the tokens via a REST API call using the ROPC (resource owner password credentials) approach for a seamless experience and more robust testing.


1️⃣ Setting up Azure

A. Create the test user
If you have admin rights for Azure do the following:
Azure Portal ▶️ Azure AD ▶️ Users ▶️ New User

  • The test user cannot be a guest user
  • Login through the application for the first time using the test user credentials
  • Disable Multi-factor authentication (MFA)

B. Add a new client secret
The ROPC flow needs a trusted client so in addition to the password and username we also need a client secret, and for that you need to do the following:

Azure Portal ▶️ Microsoft entra ID ▶️ App Registrations ▶️ Your Application ▶️ Certificates & secrets

AI generated Playwright and Azure logos with a scenic backdrop

❗ Save the secret upon creation it can only be viewed once

C. Login test
Use the the following on windows powershell and fill in:

  • tenant-id
  • client-id
  • client-secret
  • api-scope
  • username
  • password
$headers = @{
    "Content-Type" = "application/x-www-form-urlencoded"
}

$body = @{
    "grant_type"    = "password"
    "client_id"     = "<client-id>"
    "client_secret" = "<client-secret>"
    "scope"         = "openid profile email <api-scope>"
    "username"      = "<username>"
    "password"      = "<password>"
}

$response = Invoke-WebRequest -Uri "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" -Method Post -Headers $headers -Body $body
$response.Content
Enter fullscreen mode Exit fullscreen mode

the response should be something like this

{
"token_type":"Bearer",
"scope":"<api-scope>",
"expires_in":4330,
"ext_expires_in":4330,
"access_token":"<access-token>",
"id_token":"<id-token>"
}
Enter fullscreen mode Exit fullscreen mode

You can see from the response that the token expiry is 4330 seconds which means that if the tests take longer than 72 minutes the test will fail, we'll look at how to handle that later

2️⃣ Building the token factory
Now we can see that we can acquire the token successfully, lets set up the rest of the functionality that will get this token and inject it into the local storage before all the tests are run.

first we need to capture the token with an api call, then build the tokenBuilder, the token builder takes the token and construct the account entity, the token identity and the access token entity
that afterwards need to be injected into the local storage

A. getToken

export const getToken = async () => {
  let tokenResponse = null;
  const response = await axios.post(
    `${authority}/oauth2/v2.0/token`,
    new URLSearchParams({
      grant_type: 'password',
      client_id: clientId,
      client_secret: clientSecret,
      scope: ['openid profile email', apiScopes].join(' '),
      username: username,
      password: password,
    }),
    { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
  );

  tokenResponse = response.data;
  return tokenResponse;
};
Enter fullscreen mode Exit fullscreen mode

B. setAccountEntity

const setAccountEntity = (
  homeAccountId: string,
  realm: string,
  localAccountId: string,
  username: string,
  name: string,
) => {
  return {
    authorityType: 'MSSTS',
    clientInfo: '',
    homeAccountId,
    environment,
    realm,
    localAccountId,
    username,
    name,
  };
};
Enter fullscreen mode Exit fullscreen mode

C. setIdTokenEntity

const setIdTokenEntity = (
  homeAccountId: string,
  idToken: string,
  realm: string,
) => {
  return {
    credentialType: 'IdToken',
    homeAccountId,
    environment,
    clientId,
    secret: idToken,
    realm,
  };
};
Enter fullscreen mode Exit fullscreen mode

D. setAccessTokenEntity

const setAccessTokenEntity = (
  homeAccountId: string,
  accessToken: string,
  expiresIn: number,
  extExpiresIn: number,
  realm: string,
  scopes: string,
) => {
  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.toLowerCase(),
  };
};
Enter fullscreen mode Exit fullscreen mode

E. tokenBuilder

export const tokenBuilder = (tokenResponse) => {
  const idToken: JwtPayload = decode(tokenResponse.id_token) as JwtPayload;
  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 = setAccountEntity(
    homeAccountId,
    realm,
    localAccountId,
    username,
    name,
  );

  const idTokenKey = `${homeAccountId}-${environment}-idtoken-${clientId}-${realm}-`;
  const idTokenEntity = setIdTokenEntity(
    homeAccountId,
    tokenResponse.id_token,
    realm,
  );

  const accessTokenKey = `${homeAccountId}-${environment}-accesstoken-${clientId}-${realm}-${apiScopes}`;

  const accessTokenEntity = setAccessTokenEntity(
    homeAccountId,
    tokenResponse.access_token,
    tokenResponse.expires_in,
    tokenResponse.ext_expires_in,
    realm,
    apiScopes,
  );

  return [
    accountKey,
    accountEntity,
    idTokenKey,
    idTokenEntity,
    accessTokenKey,
    accessTokenEntity,
  ];
};

Enter fullscreen mode Exit fullscreen mode

And finally,
F. injectToken

export const injectTokens = async (page) => {
  let globalTokenResponse = null;
  let tokenBuilderResponse: any = null;

  globalTokenResponse = await getToken();
  tokenBuilderResponse = await tokenBuilder(globalTokenResponse);

  await page.goto('/');
  await page.evaluate(
    (tokenBuilderResponse) => {
      window.localStorage.setItem(
        tokenBuilderResponse[0],
        JSON.stringify(tokenBuilderResponse[1]),
      );
      window.localStorage.setItem(
        tokenBuilderResponse[2],
        JSON.stringify(tokenBuilderResponse[3]),
      );
      window.localStorage.setItem(
        tokenBuilderResponse[4],
        JSON.stringify(tokenBuilderResponse[5]),
      );
    },
    tokenBuilderResponse,
  );
  await page.goto('/');
};
Enter fullscreen mode Exit fullscreen mode

3️⃣ Putting it all together

Now that we have our test user setup and the token builder logic, lets put it all together in an authentication.ts file

first we need the imports. we'll get our credentials from environment variables which will be taken care of by another config file

import axios from 'axios';
import { decode, JwtPayload } from 'jsonwebtoken';
import { config } from '../playwright.env.config';
Enter fullscreen mode Exit fullscreen mode

Then we pull and assign the necessary data for tokenBuilder

const tenantId = config.tenantId;
const clientId = config.clientId;
const clientSecret = config.clientSecret;
const apiScopes = config.apiScopes;
const username = config.username;
const password = config.password;

const authority = `https://login.microsoftonline.com/${tenantId}`;
const environment = 'login.windows.net';
Enter fullscreen mode Exit fullscreen mode

And the rest of the code.

import axios from 'axios';
import { decode, JwtPayload } from 'jsonwebtoken';
import { config } from '../playwright.env.config';

const tenantId = config.tenantId;
const clientId = config.clientId;
const clientSecret = config.clientSecret;
const apiScopes = config.apiScopes;
const username = config.username;
const password = config.password;

const authority = `https://login.microsoftonline.com/${tenantId}`;
const environment = 'login.windows.net';

const setAccountEntity = (
  homeAccountId: string,
  realm: string,
  localAccountId: string,
  username: string,
  name: string,
) => {
  return {
    authorityType: 'MSSTS',
    clientInfo: '',
    homeAccountId,
    environment,
    realm,
    localAccountId,
    username,
    name,
  };
};

const setIdTokenEntity = (
  homeAccountId: string,
  idToken: string,
  realm: string,
) => {
  return {
    credentialType: 'IdToken',
    homeAccountId,
    environment,
    clientId,
    secret: idToken,
    realm,
  };
};

const setAccessTokenEntity = (
  homeAccountId: string,
  accessToken: string,
  expiresIn: number,
  extExpiresIn: number,
  realm: string,
  scopes: string,
) => {
  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.toLowerCase(),
  };
};

export const getToken = async () => {
  let tokenResponse = null;
  const response = await axios.post(
    `${authority}/oauth2/v2.0/token`,
    new URLSearchParams({
      grant_type: 'password',
      client_id: clientId,
      client_secret: clientSecret,
      scope: ['openid profile email', apiScopes].join(' '),
      username: username,
      password: password,
    }),
    { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
  );

  tokenResponse = response.data;
  return tokenResponse;
};

export const tokenBuilder = (tokenResponse) => {
  const idToken: JwtPayload = decode(tokenResponse.id_token) as JwtPayload;
  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 = setAccountEntity(
    homeAccountId,
    realm,
    localAccountId,
    username,
    name,
  );

  const idTokenKey = `${homeAccountId}-${environment}-idtoken-${clientId}-${realm}-`;
  const idTokenEntity = setIdTokenEntity(
    homeAccountId,
    tokenResponse.id_token,
    realm,
  );

  const accessTokenKey = `${homeAccountId}-${environment}-accesstoken-${clientId}-${realm}-${apiScopes}`;

  const accessTokenEntity = setAccessTokenEntity(
    homeAccountId,
    tokenResponse.access_token,
    tokenResponse.expires_in,
    tokenResponse.ext_expires_in,
    realm,
    apiScopes,
  );

  return [
    accountKey,
    accountEntity,
    idTokenKey,
    idTokenEntity,
    accessTokenKey,
    accessTokenEntity,
  ];
};

export const injectTokens = async (page) => {
  let globalTokenResponse = null;
  let tokenBuilderResponse: any = null;

  globalTokenResponse = await getToken();
  tokenBuilderResponse = await tokenBuilder(globalTokenResponse);

  await page.goto('/');
  await page.evaluate((tokenBuilderResponse) => {
    window.localStorage.setItem(
      tokenBuilderResponse[0],
      JSON.stringify(tokenBuilderResponse[1]),
    );
    window.localStorage.setItem(
      tokenBuilderResponse[2],
      JSON.stringify(tokenBuilderResponse[3]),
    );
    window.localStorage.setItem(
      tokenBuilderResponse[4],
      JSON.stringify(tokenBuilderResponse[5]),
    );
  }, tokenBuilderResponse);
  await page.goto('/');
};

Enter fullscreen mode Exit fullscreen mode

4️⃣ Calling injectTokens from the test file

Now that we have our auth logic we can start using it from the test, however, it has to be called in a certain way for it to work properly.

First, we do these imports.

import { test, chromium } from '@playwright/test';
import { injectTokens } from '../auth/auth';
Enter fullscreen mode Exit fullscreen mode

Second, we declare the following in order to provide inject tokens with the page object.

let browser;
let context;
let page;
Enter fullscreen mode Exit fullscreen mode

And finally, we set up a beforeAll hook to make sure the auth runs before any of the test and provide the auth token in local storage for the tests to run smoothly

test.beforeAll(async () => {
  browser = await chromium.launch();
  context = await browser.newContext({
    ignoreHTTPSErrors: true,
    acceptDownloads: true,
  });
  page = await context.newPage();
  await injectTokens(page);
});
Enter fullscreen mode Exit fullscreen mode

And now we can begin testing, try a simple visit protected view test to check if it all works together.

test('visit profile page', async () => {
  await page.goto('https://example.com/profile');
  await expect(page.locator('body')).toHaveText('secret info');
});
Enter fullscreen mode Exit fullscreen mode

Notice how the page object is not provided as an argument in the test because it is already declared at the top.

5️⃣ Special note

As we have seen earlier the token expires within 72 minutes, so general advice here is either figure our a caching mechanism where the token auto renews or keep the test files brief taking note of tests run time and keeping each file under one hour run time.

Enjoy :)

Top comments (0)