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
❗ 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
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>"
}
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;
};
B. setAccountEntity
const setAccountEntity = (
homeAccountId: string,
realm: string,
localAccountId: string,
username: string,
name: string,
) => {
return {
authorityType: 'MSSTS',
clientInfo: '',
homeAccountId,
environment,
realm,
localAccountId,
username,
name,
};
};
C. setIdTokenEntity
const setIdTokenEntity = (
homeAccountId: string,
idToken: string,
realm: string,
) => {
return {
credentialType: 'IdToken',
homeAccountId,
environment,
clientId,
secret: idToken,
realm,
};
};
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(),
};
};
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,
];
};
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('/');
};
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';
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';
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('/');
};
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';
Second, we declare the following in order to provide inject tokens with the page object.
let browser;
let context;
let page;
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);
});
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');
});
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)