Subsections of Tester guide

Sign-in a test user in a Playwright test

This requires OpenId Connect protocol and it isn’t supported in Starter Edition.

The Challange

We strongly recommend that a user should sign-in with their existing accounts, in many cases an Entra Id account, and it is possible to limit the available authentication methods on a tenant to only allow for example Entra Ids. If you want to build integrations test with (for example) Playwright for your applications this kind of secure setup could make it impossible, since the Entra account often requires verification in Microsoft Authenticator.

One possibility would be to create test users with passwords but that weakens the security and to build a full suite of tests for many different permission sets. More users increases the attack vector and therefor weakens the security event futher.

The Solution

Authway support for forcing a single-sign-on with Login hint tokens can be used to make it possible for Playwright to run as different users withouth having actual user credentials.

Warning: Since Login hint tokens is a very powerful tool it will instead be very important to protect the configured Playwright client id and secret that combination together with the uique identity of a user makes it possible to sign-in that user wherever the user has access. Protect the client id and secret very carefully (as it always should be done).

Below is a guide that will go through all steps to make this possible.

Configure a Playwright client

  1. Start by creating a new Client and choose Web Application (Server Side) as type. Give the client id (in this example we’ll use playwright_sso_test), a client name and optionally a description. Add a temporary https://temp redirect uri (which will be removed in next steps).
  2. Click Edit on the Basic tab.
  3. Add a new request uri playwright://sso (used in this example but could be anything, but do not use http/https protocol).
  4. Remove the https://temp redirect uri added in step 1.
  5. Enable Allow passing login hint token.
  6. Remove all scopes except openid.
  7. Click Save.
  8. Create a secret from the Actions menu.
  9. Save the client id and secret temporarly (should be used later in this guide). When doing this for real make sure to keep these values safe according to your standards.

Creat a first test user

  1. Navigate to Persons in Authway and click New.
  2. Give the user a suitable name and typically an e-mail. Choose fake names and use a standard that makes it easy for you to identify your test users seperately from ordinary users.
  3. Click Add.
  4. Add the Groups your test user needs for accessing the application that you want to test with Palywright.
  5. Copy the user id from the url and save it for later use in this guide. It is the last part of the path and it is a globaly unique value (GUID/UUID).

Extend your Playwright tests

In this example we have used Typescript for the Playwright code.

  1. Add a function for creating a Login hint token (helpers/login_hint_token.ts).
import { createHash, createHmac } from 'node:crypto';
import { Buffer } from 'node:buffer';

export interface LoginHintTokenOptions {
  clientId: string;
  clientSecret: string;
  authority: string;
  tenantId?: string;
}

export function createLoginHintToken(
  userId: string,
  { clientId, clientSecret, authority, tenantId }: LoginHintTokenOptions,
): string {
  const requiredValues = { userId, clientId, clientSecret, authority };
  for (const [name, value] of Object.entries(requiredValues)) {
    if (typeof value !== 'string' || value.trim().length === 0) {
      throw new Error(`${name} must be a nonempty string`);
    }
  }
  if (tenantId !== undefined && (typeof tenantId !== 'string' || tenantId.trim().length === 0)) {
    throw new Error('tenantId must be a nonempty string when provided');
  }

  const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
  const payload = Buffer.from(JSON.stringify({
    sub: userId,
    iss: clientId,
    aud: authority,
    iat: Math.floor(Date.now() / 1000),
    ...(tenantId !== undefined ? { tid: tenantId } : {}),
  })).toString('base64url');

  // Authway uses the Base64 text as the HMAC key, not the raw SHA-256 digest.
  const key = createHash('sha256').update(clientSecret, 'utf8').digest('base64');
  const signingInput = `${header}.${payload}`;
  const signature = createHmac('sha256', key).update(signingInput).digest('base64url');
  return `${signingInput}.${signature}`;
}
  1. Add settings for Authway where values are read from Environment variables (or for test purpose an .env file) (helpers/authway_settings.ts):
import type { LoginHintTokenOptions } from './login-hint-token';

export function getAuthwaySettings(
  env: NodeJS.ProcessEnv = process.env,
): LoginHintTokenOptions {
  function required(name: string): string {
    const value = env[name];
    if (!value || value.trim().length === 0) {
      throw new Error(`Missing required Authway setting: ${name}`);
    }
    return value;
  }

  const authority = required('AUTHWAY_BASE_URL');
  let url: URL;
  try {
    url = new URL(authority);
  } catch {
    throw new Error('AUTHWAY_BASE_URL must be an absolute HTTPS URL');
  }
  if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
    throw new Error('AUTHWAY_BASE_URL must be an HTTPS URL without credentials, query, or fragment');
  }

  return {
    authority,
    clientId: required('AUTHWAY_CLIENT_ID'),
    clientSecret: required('AUTHWAY_CLIENT_SECRET'),
  };
}
  1. Add a function for establishing a Authway user session (helpers/authway_sso.ts)
import { createHash, randomBytes } from 'node:crypto';
import type { Page, Route } from '@playwright/test';
import { createLoginHintToken, type LoginHintTokenOptions } from './login-hint-token';

export const authwayRedirectUri = 'playwright://sso'; //Same as registered in "Configure a Playwright client" above

export async function establishAuthwaySession(
  page: Page,
  userId: string,
  settings: LoginHintTokenOptions,
): Promise<void> {
  const token = createLoginHintToken(userId, settings);
  const state = randomBytes(32).toString('base64url');
  const codeVerifier = randomBytes(32).toString('base64url');
  const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
  const authorizeUrl = new URL(`${settings.authority.replace(/\/$/, '')}/connect/authorize`);
  authorizeUrl.search = new URLSearchParams({
    client_id: settings.clientId,
    response_type: 'code',
    scope: 'openid',
    redirect_uri: authwayRedirectUri,
    login_hint_token: token,
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  }).toString();

  let callback: URL | undefined;
  let navigationFailure = 'Authway SSO navigation failed. Check IdP connectivity and client configuration.';
  const intercept = async (route: Route): Promise<void> => {
    if (!route.request().isNavigationRequest() || route.request().frame() !== page.mainFrame()) {
      await route.continue();
      return;
    }

    try {
      // Fetch one response at a time so Chromium never opens the custom URI scheme.
      // route.fetch shares the browser context's cookie jar, including Set-Cookie.
      let response = await route.fetch({ maxRedirects: 0 });
      let method = route.request().method();
      for (let redirects = 0; redirects < 20; redirects++) {
        const location = response.headers().location;
        if (![301, 302, 303, 307, 308].includes(response.status()) || !location) {
          await route.fulfill({ response });
          return;
        }
        const target = new URL(location, response.url());
        const redirect = new URL(authwayRedirectUri);
        if (target.protocol === redirect.protocol && target.host === redirect.host
          && target.pathname === redirect.pathname && !target.username && !target.password) {
          callback = target;
          await route.fulfill({
            status: 200,
            contentType: 'text/html',
            body: '<!doctype html><title>IdP session established</title>',
          });
          return;
        }
        if (target.protocol !== 'https:' && target.protocol !== 'http:') {
          await route.abort('failed');
          return;
        }
        if (response.status() === 303
          || ((response.status() === 301 || response.status() === 302) && method === 'POST')) {
          method = 'GET';
        }
        // Browser routing does not run again for HTTP redirects. Follow them
        // explicitly, keeping the shared cookie jar and stopping at our callback.
        response = await page.context().request.fetch(target.toString(), {
          method,
          data: method === 'GET' || method === 'HEAD'
            ? undefined : route.request().postDataBuffer() ?? undefined,
          maxRedirects: 0,
        });
      }
      await route.abort('failed');
    } catch (error) {
      await route.abort('failed');
    }
  };

  await page.route('**/*', intercept);
  try {
    try {
      await page.goto(authorizeUrl.toString(), { waitUntil: 'load', timeout: 30_000 });
    } catch {
      throw new Error(navigationFailure);
    }
    if (!callback) {
      throw new Error('Authway did not return to the SSO callback. Check client permissions and user ID.');
    }
    if (callback.searchParams.get('state') !== state) {
      throw new Error('Authway SSO callback state did not match the authorization request.');
    }
    if (callback.searchParams.has('error')) {
      throw new Error('Authway rejected SSO authorization. Check client permissions and redirect URI registration.');
    }
    if (!callback.searchParams.get('code')) {
      throw new Error('Authway SSO callback did not contain an authorization code.');
    }
    if ((await page.context().cookies(settings.authority)).length === 0) {
      throw new Error('Authway returned an authorization code but did not establish any IdP cookies.');
    }
    await page.goto('about:blank');
  } finally {
    await page.unroute('**/*', intercept);
  }
}
  1. Create an authentication fixture (fixtures/authenticated-test.ts)
import { test as base, expect } from '@playwright/test';
import { getAuthwaySettings } from '../helpers/authway-settings';
import { establishAuthwaySession } from '../helpers/authway-sso';
import type { LoginHintTokenOptions } from '../helpers/login-hint-token';

type AuthwayFixtures = {
  authwayUserId: string;
  authwaySettings: LoginHintTokenOptions;
};

export const test = base.extend<AuthwayFixtures>({
  // Authorization URLs and session cookies must not be captured in artifacts.
  trace: 'off',
  screenshot: 'off',
  video: 'off',
  authwayUserId: async ({}, use) => {
    const userId = process.env.AUTHWAY_USER_ID;
    if (!userId || !userId.trim()) {
      throw new Error('Missing required Authway setting: AUTHWAY_USER_ID');
    }
    await use(userId);
  },
  authwaySettings: async ({}, use) => {
    await use(getAuthwaySettings());
  },
  page: async ({ page, authwayUserId, authwaySettings }, use) => {
    await establishAuthwaySession(page, authwayUserId, authwaySettings);
    await use(page);
  },
});

export { expect };
  1. Use the authentication fixture in a test that requires an authenticated user:
import { test, expect } from '../fixtures/authenticated-test';

test('opens application through SSO without entering credentials', async ({ page, baseURL }) => {
  test.setTimeout(60_000);
  await page.goto('/'); //Adjust to your application
  await page.getByRole('button', { name: 'Log in', exact: true }).click(); //Adjust to your application

  // Check a boolean so failures cannot print an authorization URL or code.
  await expect.poll(() => {
    const url = new URL(page.url());
    return url.origin === new URL(baseURL!).origin && url.pathname === '/my_expected_url'; //Adjust to your application
  }, { message: 'SSO should open application', timeout: 30_000 }).toBe(true);
});
  1. Set the environment variables and run your test
AUTHWAY_BASE_URL=https://tenant.irmciam.se
AUTHWAY_CLIENT_ID=playwright_sso_test
AUTHWAY_CLIENT_SECRET=THE_VERY_SECRET_VALUE
AUTHWAY_USER_ID=THE_UNIQUE_IDENTITY_OF_A_USER_ALLOWED_TO_RUN_APPLICATION