From f13424720b7f5da3978988f0f9169cf303971a13 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 29 May 2026 09:44:37 +0200 Subject: [PATCH] tests(e2e): use custom Playwright route for callback URL checks --- tests/specs/oidc.spec.ts | 249 ++++++++++++++++----------------------- 1 file changed, 100 insertions(+), 149 deletions(-) diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts index 16900d6f..3a4c125d 100644 --- a/tests/specs/oidc.spec.ts +++ b/tests/specs/oidc.spec.ts @@ -10,36 +10,21 @@ test.beforeEach(async () => await cleanupBackend()); test('Authorize existing client', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); - await page.goto(`/authorize?${urlParams.toString()}`); - - // Ignore DNS resolution error as the callback URL is not reachable - await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if ( - !e.message.includes('net::ERR_NAME_NOT_RESOLVED') && - !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') - ) { - throw e; - } - }); + await expectCallbackRedirect(page, oidcClient.callbackUrl, () => + page.goto(`/authorize?${urlParams.toString()}`) + ); }); test('Authorize existing client while not signed in', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); await page.context().clearCookies(); - await page.goto(`/authorize?${urlParams.toString()}`); - await (await passkeyUtil.init(page)).addPasskey(); - await page.getByRole('button', { name: 'Sign in' }).click(); + await expectCallbackRedirect(page, oidcClient.callbackUrl, async () => { + await page.goto(`/authorize?${urlParams.toString()}`); - // Ignore DNS resolution error as the callback URL is not reachable - await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if ( - !e.message.includes('net::ERR_NAME_NOT_RESOLVED') && - !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') - ) { - throw e; - } + await (await passkeyUtil.init(page)).addPasskey(); + await page.getByRole('button', { name: 'Sign in' }).click(); }); }); @@ -51,17 +36,9 @@ test('Authorize new client', async ({ page }) => { await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible(); await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible(); - await page.getByRole('button', { name: 'Sign in' }).click(); - - // Ignore DNS resolution error as the callback URL is not reachable - await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if ( - !e.message.includes('net::ERR_NAME_NOT_RESOLVED') && - !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') - ) { - throw e; - } - }); + await expectCallbackRedirect(page, oidcClient.callbackUrl, () => + page.getByRole('button', { name: 'Sign in' }).click() + ); }); test('Authorize new client while not signed in', async ({ page }) => { @@ -76,17 +53,9 @@ test('Authorize new client while not signed in', async ({ page }) => { await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible(); await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible(); - await page.getByRole('button', { name: 'Sign in' }).click(); - - // Ignore DNS resolution error as the callback URL is not reachable - await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if ( - !e.message.includes('net::ERR_NAME_NOT_RESOLVED') && - !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') - ) { - throw e; - } - }); + await expectCallbackRedirect(page, oidcClient.callbackUrl, () => + page.getByRole('button', { name: 'Sign in' }).click() + ); }); test('Authorize new client fails with user group not allowed', async ({ page }) => { @@ -130,24 +99,16 @@ test('End session without id token hint shows confirmation page', async ({ page test('End session with id token hint redirects to callback URL', async ({ page }) => { const client = oidcClients.nextcloud; - const idToken = await generateIdToken("fe81c12a-7336-4aee-bebc-d901a873bf48", users.tim, client.id); - let redirectedCorrectly = false; - await page - .goto( + const idToken = await generateIdToken( + 'fe81c12a-7336-4aee-bebc-d901a873bf48', + users.tim, + client.id + ); + await expectCallbackRedirect(page, client.logoutCallbackUrl, () => + page.goto( `/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}` ) - .catch((e) => { - if ( - e.message.includes('net::ERR_NAME_NOT_RESOLVED') || - e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') - ) { - redirectedCorrectly = true; - } else { - throw e; - } - }); - - expect(redirectedCorrectly).toBeTruthy(); + ); }); test('Successfully refresh tokens with valid refresh token', async ({ request }) => { @@ -627,17 +588,9 @@ test('Forces reauthentication when client requires it', async ({ page, request } await (await passkeyUtil.init(page)).addPasskey(); const urlParams = createUrlParams(oidcClients.nextcloud); - await page.goto(`/authorize?${urlParams.toString()}`); - - await expect(page.getByTestId('scopes')).not.toBeVisible(); - - await page.waitForURL(oidcClients.nextcloud.callbackUrl).catch((e) => { - if ( - !e.message.includes('net::ERR_NAME_NOT_RESOLVED') && - !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') - ) { - throw e; - } + await expectCallbackRedirect(page, oidcClients.nextcloud.callbackUrl, async () => { + await page.goto(`/authorize?${urlParams.toString()}`); + await expect(page.getByTestId('scopes')).not.toBeVisible(); }); expect(webauthnStartCalled).toBe(true); @@ -676,9 +629,9 @@ test('Authorize existing client with response_mode=fragment', async ({ page }) = const urlParams = createUrlParams(oidcClient); urlParams.set('response_mode', 'fragment'); - await page.goto(`/authorize?${urlParams.toString()}`); - - const redirectUrl = await waitForCallbackURL(page, oidcClient.callbackUrl); + const redirectUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () => + page.goto(`/authorize?${urlParams.toString()}`) + ); expect(redirectUrl.search).toBe(''); const fragmentParams = new URLSearchParams(redirectUrl.hash.slice(1)); @@ -693,23 +646,6 @@ function waitForFormPostRequest(page: Page, callbackUrl: string): Promise { - const expectedUrl = new URL(callbackUrl); - - await page - .waitForURL((url) => url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname) - .catch((e) => { - if ( - !e.message.includes('net::ERR_NAME_NOT_RESOLVED') && - !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') - ) { - throw e; - } - }); - - return new URL(page.url()); -} - async function expectFormPostRequest(formPostRequestPromise: Promise) { const request = await formPostRequestPromise; const formData = new URLSearchParams(request.postData() ?? ''); @@ -744,9 +680,9 @@ test.describe('OIDC prompt parameter', () => { urlParams.set('prompt', 'none'); urlParams.set('response_mode', 'fragment'); - await page.goto(`/authorize?${urlParams.toString()}`).then(() => {}); - - const redirectUrl = await waitForCallbackURL(page, oidcClient.callbackUrl); + const redirectUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () => + page.goto(`/authorize?${urlParams.toString()}`) + ); expect(redirectUrl.search).toBe(''); const fragmentParams = new URLSearchParams(redirectUrl.hash.slice(1)); @@ -775,17 +711,9 @@ test.describe('OIDC prompt parameter', () => { const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'none'); - await page.goto(`/authorize?${urlParams.toString()}`); - - // Should redirect successfully to callback URL with code - await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if ( - !e.message.includes('net::ERR_NAME_NOT_RESOLVED') && - !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') - ) { - throw e; - } - }); + await expectCallbackRedirect(page, oidcClient.callbackUrl, () => + page.goto(`/authorize?${urlParams.toString()}`) + ); }); test('prompt=consent forces consent display even for authorized client', async ({ page }) => { @@ -801,17 +729,9 @@ test.describe('OIDC prompt parameter', () => { ).toBeVisible(); await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible(); - await page.getByRole('button', { name: 'Sign in' }).click(); - - // Should redirect successfully after consent - await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if ( - !e.message.includes('net::ERR_NAME_NOT_RESOLVED') && - !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') - ) { - throw e; - } - }); + await expectCallbackRedirect(page, oidcClient.callbackUrl, () => + page.getByRole('button', { name: 'Sign in' }).click() + ); }); test('prompt=login forces reauthentication', async ({ page }) => { @@ -826,17 +746,9 @@ test.describe('OIDC prompt parameter', () => { }); await (await passkeyUtil.init(page)).addPasskey(); - await page.goto(`/authorize?${urlParams.toString()}`); - - // Should require reauthentication even though user is signed in - await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if ( - !e.message.includes('net::ERR_NAME_NOT_RESOLVED') && - !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') - ) { - throw e; - } - }); + await expectCallbackRedirect(page, oidcClient.callbackUrl, () => + page.goto(`/authorize?${urlParams.toString()}`) + ); expect(reauthCalled).toBe(true); }); @@ -853,17 +765,9 @@ test.describe('OIDC prompt parameter', () => { await expect(selectionCard).toBeVisible(); await expect(selectionCard).toContainText('Tim Cook'); - await page.getByRole('button', { name: 'Sign In' }).click(); - - // Should redirect successfully to callback URL with code - await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if ( - !e.message.includes('net::ERR_NAME_NOT_RESOLVED') && - !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') - ) { - throw e; - } - }); + await expectCallbackRedirect(page, oidcClient.callbackUrl, () => + page.getByRole('button', { name: 'Sign In' }).click() + ); }); test('prompt=select_account account can be changed', async ({ page }) => { @@ -880,17 +784,9 @@ test.describe('OIDC prompt parameter', () => { await page.getByRole('button', { name: 'Sign In' }).click(); - await page.getByRole('button', { name: 'Sign In' }).click(); - - // Should redirect successfully to callback URL with code - await page.waitForURL(oidcClient.callbackUrl).catch((e) => { - if ( - !e.message.includes('net::ERR_NAME_NOT_RESOLVED') && - !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID') - ) { - throw e; - } - }); + await expectCallbackRedirect(page, oidcClient.callbackUrl, () => + page.getByRole('button', { name: 'Sign In' }).click() + ); }); test('prompt=none with prompt=consent returns interaction_required', async ({ page }) => { @@ -935,3 +831,58 @@ test.describe('OIDC prompt parameter', () => { expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); }); }); + +async function waitForCallbackURL(page: Page, callbackUrl: string): Promise { + const expectedUrl = new URL(callbackUrl); + const isCallbackURL = (url: URL) => + url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname; + + const callbackRequest = await page.waitForRequest((request) => + isCallbackURL(new URL(request.url())) + ); + await page.waitForURL(isCallbackURL, { waitUntil: 'commit' }).catch(() => {}); + + const currentURL = new URL(page.url()); + if (isCallbackURL(currentURL)) { + return currentURL; + } + + return new URL(callbackRequest.url()); +} + +async function expectCallbackRedirect( + page: Page, + callbackUrl: string, + action: () => Promise +): Promise { + const callbackRouteMatcher = await routeCallbackPage(page, callbackUrl); + + try { + const callbackURLPromise = waitForCallbackURL(page, callbackUrl); + const actionPromise = action().then( + () => undefined, + (error) => error + ); + const callbackURL = await callbackURLPromise; + await actionPromise; + return callbackURL; + } finally { + await page.unroute(callbackRouteMatcher); + } +} + +async function routeCallbackPage(page: Page, callbackUrl: string): Promise<(url: URL) => boolean> { + const expectedUrl = new URL(callbackUrl); + const callbackRouteMatcher = (url: URL) => + url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname; + + await page.route(callbackRouteMatcher, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: 'OIDC callback' + }); + }); + + return callbackRouteMatcher; +}