Automated Testing of a Website with Azure-AD and Two-Factor Authentication using Ping Identity

Andreas Berger
6 min readMay 8, 2023

--

Automated testing of web applications has become an essential part of software development and quality assurance. However, testing web applications with additional security measures like two-factor authentication (2FA) can be challenging. In this blog post, I will discuss the problems I encountered while automating the testing of a web application with Azure-AD and Ping Identity, and the solutions I implemented to overcome them.

Image by rawpixel.com on Freepik

Problem 1: Endless Requests with new iframe-request-id

Due to the way Cypress executes tests, the Azure-AD client library generates new requests, with each request containing an additional parameter iframe-request-id. This leads to a URI that becomes too long to be processed by the web application.

Solution:

I solved this problem by performing the login process not directly through Cypress but through a Node-JS task that connects to the browser using chrome-remote-interface. I used Puppeteer to automate the login process (Kudos to TonyHernandezAtMS, who provided a Gist with this solution) within a cy.session block. This ensures that the session cookies generated during login are reused for all subsequent tests.

Before(() => {
cy.session(
'azureLogin',
() => {
cy.task('azureLogin', {
domain: Cypress.env('LOGIN_DOMAIN'),
user: Cypress.env('LOGIN_USER'),
password: Cypress.env('LOGIN_PASSWORD'),
clientId: Cypress.env('SECURITY_KEY_CLIENT_ID'),
privateKey: Cypress.env('SECURITY_KEY_PRIVATE_KEY'),
appUrl: Cypress.config().baseUrl,
});
},
{
cacheAcrossSpecs: true,
// @ts-ignore
validate: async () => {
cy.task('checkAzureLogin', {
appUrl: Cypress.config().baseUrl,
});
},
},
);
});

The cypress-config:

import { defineConfig } from 'cypress';
import {
azureLogin,
checkAzureLogin,
setDebuggingPort,
} from './cypress/support/azureLogin';

async function setupNodeEvents(
on: Cypress.PluginEvents,
config: Cypress.PluginConfigOptions,
): Promise<Cypress.PluginConfigOptions> {

on('before:browser:launch', (browser, launchOptions) => {
const remotePort = getOrSetRemoteDebuggingPort(launchOptions.args);
setDebuggingPort(remotePort);
if (browser.family === 'chromium' && browser.name !== 'electron') {
// since cypress uses its own CA, we need webauthn to ignore the cert error
launchOptions.args.push(
'--disable-features=DisableWebAuthnWithBrokenCerts',
);
}
return launchOptions;
});

on('task', {
checkAzureLogin,
azureLogin,
});

// Make sure to return the config object as it might have been modified by the plugin.
return config;
}

function getOrSetRemoteDebuggingPort(args) {
const existing = args.find(
arg => arg.slice(0, 23) === '--remote-debugging-port',
);

if (existing) {
return Number(existing.split('=')[1]);
}

const port = 40000 + Math.round(Math.random() * 25000);
args.push(`--remote-debugging-port=${port}`);
return port;
}

export default defineConfig({
fixturesFolder: false,
e2e: {
setupNodeEvents,
},
});

Implement 2 Tasks,

  1. checkAzureLogin to check if a login is required
  2. azureLogin to click through the login process
import puppeteer from 'puppeteer-core';
import CDP, { Client } from 'chrome-remote-interface';

let debuggingPort: number = 0;
let criClient: Client;

export const setDebuggingPort = (port: number) => {
debuggingPort = port;
};

async function initPage(appUrl: string) {
const browser = await puppeteer.connect({
browserURL: `http://localhost:${debuggingPort}`,
});
const page = await browser.newPage();
await page.goto(appUrl);
await page.waitForNavigation();
return page;
}

export const checkAzureLogin = async ({ appUrl }: { appUrl: string }) => {
const page = await initPage(appUrl);

if (page.url().startsWith(appUrl)) {
// already logged in
await page.close();
return true;
}
// we are not logged in
await page.close();
throw new Error('Not logged in');
};

export const azureLogin = async ({
appUrl,
domain,
user,
password,
clientId,
privateKey,
}: {
appUrl: string;
domain: string;
user: string;
password: string;
clientId: string;
privateKey: string;
}) => {
const page = await initPage(appUrl);
if (page.url().startsWith(appUrl)) {
// already logged in
await page.close();
return true;
}

// insert code for solution of problem 2

await page.waitForSelector("input[name='loginfmt']", { visible: true });
await page.type("input[name='loginfmt']", `${user}@${domain}`);
await page.keyboard.press('Enter');
await page.waitForNavigation();

await page.waitForSelector('.ping-signin', { visible: true });
await page.click("input[type='button']");
await page.waitForSelector('#username', { visible: true });
await page.type('#username', user);
await page.type('#password', password);
await page.keyboard.press('Enter');
await page.waitForNavigation();
await page.waitForSelector('#idSIButton9', { visible: true });
await page.click('#idSIButton9');
await page.close();
return true;
};

Problem 2: Using an Alternative 2FA to Execute Tests Despite Ping Identity

The web application I wanted to test was secured with Azure-AD and Ping Identity, and the company required users to have 2FA enabled. However, Google Authenticator for TOTP was not enabled in Ping Identity.

Solution:

I overcame this problem by selecting a security key as the 2FA option in Ping Identity. I then used Chrome browser’s virtual authenticator feature to automate the login process. Here is how I did it:

  1. Activate devTools and virtual authenticator environment in chrome:

2. Add an new virtual authenticator:

3. Register an virtual security key in Ping Identity:

In the screen asking to do the 2FA go to the setting page:

Add a new device

Authenticate with your old 2FA

Now Add an Security Key (the little Button at the bottom of the modal window)

The key will now automatically be added

Remember the ID of the created credential (red block in screenshot above) and export the private key to a locale file.

For the test to run automatically, make the newly created security key the primary one:

4. Extract the private key from the saved file, by just concatenating all the lines of the key, without the 1st and last line:

-----BEGIN PRIVATE KEY-----
ThEpRiVaTeKeyThEpRiVaTeKeyThEpRiVaTeKey
ThEpRiVaTeKeyThEpRiVaTeKeyThEpRiVaTeKey
-----END PRIVATE KEY-----

`ThEpRiVaTeKeyThEpRiVaTeKeyThEpRiVaTeKeyThEpRiVaTeKeyThEpRiVaTeKeyThEpRiVaTeKey` in the example above

5. Provide your private key and client ID (rembered from step 3) as env variables to your tests

Now you can adjust the azureLogin code to restore your virtual security key via the Chrome DevTools Protocol with this code (inserted at the placeholder comment):

const { authenticatorId } = await criClient.WebAuthn.addVirtualAuthenticator({
options: {
protocol: 'ctap2',
transport: 'usb',
hasResidentKey: false,
hasUserVerification: false,
isUserVerified: false,
},
});
await criClient.WebAuthn.addCredential({
authenticatorId,
credential: {
credentialId: clientId,
isResidentCredential: false,
privateKey: privateKey,
// for each signature request, signCount is incremented by 1. To ensure that the signCount is always greater
// than the signCount of the last run, we initialize it with the numbers of passed 5 seconds since the
// implementation of this feature
signCount: Math.round(
(new Date().getTime() - timeOfImplementation) / 1000 / 5,
),
rpId: 'pingone.eu',
},
});

Since cypress uses its own CA, we need WebAuthn to ignore the cert error, by starting the chrome browser with DisableWebAuthnWithBrokenCerts

on('before:browser:launch', (browser, launchOptions) => {
const remotePort = getOrSetRemoteDebuggingPort(launchOptions.args);
setDebuggingPort(remotePort);
if (browser.family === 'chromium' && browser.name !== 'electron') {
// since cypress uses its own CA, we need webauthn to ignore the cert error
launchOptions.args.push(
'--disable-features=DisableWebAuthnWithBrokenCerts',
);
}
return launchOptions;
});

Conclusion

Automating the testing of web applications with additional security measures can be challenging, but it is essential to ensure the quality of the software. I encountered problems while testing a web application with Azure-AD and Ping Identity, but I overcame them by automating the login process through a Node-JS task and using a security key as the 2FA option. I also used Chrome browser’s virtual authenticator feature to automate the entire process. By sharing my experience, I hope to help other developers who encounter similar problems in their automated testing efforts.

--

--