DEV Community

Cover image for Using Playwright to test Azure Active Directory secured APIs
Ira Rainey
Ira Rainey

Posted on • Updated on

Using Playwright to test Azure Active Directory secured APIs

So you've built yourself a great API, and because you're smart you've secured it using JSON Web Tokens that you need to get from Azure Active Directory. Nice. As it's a user-focussed API you've implemented an authorisation model based on the user scopes defined in the access token. Smart.

However, now you want to perform end-to-end testing of your API endpoints, but you've suddenly realised that you need a user to authenticate themselves with Azure Active Directory to generate the access token you need.

If you were using the Client Credentials OAuth2 grant type you wouldn't have this problem, but that would only allow you to use application permissions and not user delegated scopes as you need. So using the Authorization Code OAuth2 grant type you're now presented with the challenge of automating that authentication process.

One way of doing this would be to use the Resource Owner Password Credentials (ROPC) OAuth2 grant type, which allows you to simply pass the username and password with the token request and be given back an access token. But this grant type is considered insecure and should be avoided if possible. It is also proposed to be omitted from the OAuth2.1 standard.

A better way to achieve this would be to use the open source browser automation framework Playwright, which offers the ability to test applications in Chromium, Firefox and WebKit with a single API, using .NET, Python, Java, or Node.js.

While Playwright is a fully-fledged automation framework for testing browser-based applications, in our scenario we're only interested in automating the Azure Active Directory authentication process. This will enable us to obtain an access token with user-scoped claims to allow us to test our API authorisation model.

This example uses a .NET 6.0 console application (a Node version can be found in the comments) with Playwright and the Microsoft Authentication Library (MSAL). The token acquisition process uses the Authorization Code grant type which following successful authentication of a user will return an authorization code to the specified redirect URI. This is the part that will be automated using Playwright. The returned authorization code is then exchanged for an access token using MSAL.

Authorization Code Grant Type

A client Application Registration is required to be registered in your Azure Active Directory tenant, with all required user permissions consented, and a redirect URI configured to receive the authorization code. This example uses https://oidcdebugger.com/ which is then accessed by Playwright to retrieve the code. This could however just as easily be your own service.

Your tenant id, client id, and scope are required for the sample to function. In the snippet below they are shown as placeholders. In the GitHub repository these values are set from either an appsettings.json or usersecrets.json file. This is only designed as an example. If you are running this anywhere other than locally it's recommended to store these values in Azure Key Vault secrets and access them using Managed Identity.

// Get client app related related settings
string tenant_id = "< AAD TENANT ID >";
string client_id = "< AAD CLIENT ID >";
string scope = "< API SCOPE >";

// The redirect uri being used here could be any service that you can use to access the auth code
// after it has been redirected from Azure Active Directory.
// This is using https://oidcdebugger.com which of course determines how you extract the auth code
// from page at the step lower down to use to exchange for an access token
string redirect_uri = "https://oidcdebugger.com/debug";

// Define authority and login uri
string authority = $"https://login.microsoftonline.com/{tenant_id}";
string login_uri = $"{authority}/oauth2/v2.0/authorize?client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}&response_type=code&prompt=login";

// Create a Playwright instance
Console.WriteLine("Creating Playwright instance");
using var playwright = await Playwright.CreateAsync();

// Launch an instance of Chrome
Console.WriteLine("Launching Chrome");
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions()
{
    Headless = true
});

// Create a browser page
var page = await browser.NewPageAsync();

// Navigate to the login screen
Console.WriteLine($"Navigating to {login_uri}");
await page.GotoAsync(login_uri);

// Enter username
Console.WriteLine("Entering username");
await page.FillAsync("input[name='loginfmt']", "< USERNAME >");
await page.ClickAsync("input[type=submit]");

// Wait until page has changed and is loaded
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

// Enter password
Console.WriteLine("Entering password");
await page.FillAsync("input[name='passwd']", "< PASSWORD >");
await page.ClickAsync("input[type=submit]");

// Wait until page has changed and is loaded
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);

// Extract the auth code from the page we've redirected it to
Console.WriteLine("Extract auth code");
var authCode = await page.InnerTextAsync("#debug-view-component > div.debug__callback-header > div:nth-child(4) > p");

// Close the browser
await browser.CloseAsync();

// Build an MSAL confidential client
var app = ConfidentialClientApplicationBuilder.Create(client_id)
    .WithAuthority(authority)
    .WithRedirectUri(redirect_uri)
    .WithClientSecret("< CLIENT SECRET >")
    .Build();

// Get access token with code exchange
Console.WriteLine("Request access token with MSAL");
AuthenticationResult result = await app.AcquireTokenByAuthorizationCode(new string[] { scope }, authCode)
    .ExecuteAsync();

// Display the access token from the response
Console.WriteLine("Access token retrieved:\n");
Console.WriteLine(result.AccessToken);

Enter fullscreen mode Exit fullscreen mode

Once you have exchanged your authorization code for an access token, you are free to use that token to call your API endpoints passing the token in the Authorization header as usual.

It is worth noting that this example will not work if multi-factor authentication is enabled for the user to be authenticated. It is recommended to create test users in your tenant that are excluded from MFA using a Conditional Access Policy.

If that is not possible, then you could configure MFA to use an SMS number and use a service such as Twilio to trigger a webhook from incoming SMS messages, which could then be included into the automated token acquisition process. This is not included in the scope of this article.

Full Code: https://github.com/irarainey/PlaywrightTokenAcquisition

Top comments (10)

Collapse
 
daniavander profile image
daniavander

hi thanks the article
but i have a question
how to write this part with typescript:// Build an MSAL confidential client
var app = ConfidentialClientApplicationBuilder.Create(client_id)
.WithAuthority(authority)
.WithRedirectUri(redirect_uri)
.WithClientSecret("< CLIENT SECRET >")
.Build();

Collapse
 
irarainey profile image
Ira Rainey • Edited

You would just need to use the msal-node package and create the client like this:

import { ConfidentialClientApplication } from "@azure/msal-node";

const app = new ConfidentialClientApplication({
    auth: {
        clientId: "<CLIENT ID>",
        authority: "<AUTHORITY URL>",
        clientSecret: "<CLIENT SECRET>"
    }
});

const tokenRequest = {
    code: authCode,
    redirectUri: redirect_uri,
    scopes: [ scope ],
};

await app.acquireTokenByCode(tokenRequest).then((response) => {
    console.log("\nResponse: \n:", response);
}).catch((error) => {
    console.log(error);
});
Enter fullscreen mode Exit fullscreen mode
Collapse
 
daniavander profile image
daniavander

thanks a lot, but i get an error:
for redirectUri row:
ype '{ clientId: string; authority: string; redirectUri: string; clientSecret: string; }' is not assignable to type 'NodeAuthOptions'.
Object literal may only specify known properties, and 'redirectUri' does not exist in type 'NodeAuthOptions'.ts(2322)
Configuration.d.ts(80, 5): The expected type comes from property 'auth' which is declared here on type 'Configuration'

what is mean this?

Thread Thread
 
irarainey profile image
Ira Rainey

Sorry, I got the confidential client mixed up with public client constructor. I've updated it now.

Thread Thread
 
irarainey profile image
Ira Rainey • Edited

Here's the same example, but for Node:

import { test, chromium } from '@playwright/test';
import { ConfidentialClientApplication } from "@azure/msal-node";

// Get client app related related settings
const tenant_id: string = "< AAD TENANT ID >";
const client_id: string = "< AAD CLIENT ID >";
const scope: string = "< API SCOPE >";

// The redirect uri being used here could be any service that you can use to access the auth code
// after it has been redirected from Azure Active Directory.
// This is using https://oidcdebugger.com which of course determines how you extract the auth code
// from page at the step lower down to use to exchange for an access token
const redirect_uri: string = "https://oidcdebugger.com/debug";

// Define authority and login uri
const authority: string = `https://login.microsoftonline.com/${tenant_id}`;
const login_uri: string =  `${authority}/oauth2/v2.0/authorize?client_id=${client_id}&redirect_uri=${redirect_uri}&scope=${scope}&response_type=code&prompt=login`;

test('Get Access Token', async () => {
    // Launch an instance of Chrome
    const browser = await chromium.launch({ headless: true });

    // Create a browser page
    const page = await browser.newPage();

    // Navigate to the login screen
    await page.goto(login_uri);

    // Enter username
    await page.fill("input[name='loginfmt']", "< USERNAME >");
    await page.click("input[type=submit]");

    // Wait until page has changed and is loaded
    await page.waitForLoadState("networkidle");

    // Enter password
    await page.fill("input[name='passwd']", "< PASSWORD >");
    await page.click("input[type=submit]");

    // Wait until page has changed and is loaded
    await page.waitForLoadState("networkidle");

    // Extract the auth code from the page we've redirected it to
    const authCode: string = await page.innerText("#debug-view-component > div.debug__callback-header > div:nth-child(4) > p");

    // Close the browser
    await browser.close();

    // Build an MSAL confidential client
    const app = new ConfidentialClientApplication({
        auth: {
            clientId: client_id,
            authority: authority,
            clientSecret: "< CLIENT SECRET >"
        }
    });

    // Build the token request
    const tokenRequest = {
        code: authCode,
        redirectUri: redirect_uri,
        scopes: [ scope ],
    };

    // Get access token with code exchange
    const response = await app.acquireTokenByCode(tokenRequest);

    // Display the access token from the response
    console.log(response.accessToken);
});
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
daniavander profile image
daniavander

thanks a lot i understand the steps, just one question

you get the code: const authCode: string = await page.innerText()

I do not understand why we have this code and where i could get it, because i add an email and password after that auth automatically

here is the sample video:
streamable.com/1p0qqe

Thread Thread
 
irarainey profile image
Ira Rainey

That line is there to extract the authorization code from the website, oidcdebugger.com that we are using as a redirect. That particular line of code is referencing the HTML element that the auth code is added to after the callback, hence how we can extract it. I would suggest reading up on the OAuth2 Auth Code flow to understand what's going on here.

OAuth2 Auth Code

Thread Thread
 
daniavander profile image
daniavander

i see i will studying, but there is not any html object which i should use for innertext()
because i add a usermail and pwd after that ligin the webapp

Thread Thread
 
daniavander profile image
daniavander

I can login via this method, but when i how can i found the authCode ?

Thread Thread
 
daniavander profile image
daniavander

how can i send a request.post( in correct form?