feat: add support for "select_account" prompt (#1453)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Alessandro (Ale) Segala
2026-04-26 10:26:21 -07:00
committed by GitHub
parent e33a9b8c88
commit f4706cd6cc
9 changed files with 365 additions and 314 deletions

View File

@@ -14,7 +14,10 @@ test('Authorize existing client', async ({ page }) => {
// 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')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -31,7 +34,10 @@ test('Authorize existing client while not signed in', async ({ page }) => {
// 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')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -49,7 +55,10 @@ test('Authorize new client', async ({ page }) => {
// 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')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -71,7 +80,10 @@ test('Authorize new client while not signed in', async ({ page }) => {
// 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')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -125,7 +137,10 @@ test('End session with id token hint redirects to callback URL', async ({ page }
`/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')) {
if (
e.message.includes('net::ERR_NAME_NOT_RESOLVED') ||
e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
redirectedCorrectly = true;
} else {
throw e;
@@ -617,7 +632,10 @@ test('Forces reauthentication when client requires it', async ({ page, request }
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')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -678,10 +696,8 @@ test.describe('OIDC prompt parameter', () => {
urlParams.set('prompt', 'none');
// Should redirect to callback URL with error
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
page,
'/auth/callback',
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () =>
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('login_required');
@@ -696,10 +712,8 @@ test.describe('OIDC prompt parameter', () => {
urlParams.set('prompt', 'none');
// Should redirect to callback URL with error
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
page,
'/auth/callback',
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () =>
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('consent_required');
@@ -715,7 +729,10 @@ test.describe('OIDC prompt parameter', () => {
// 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')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -729,14 +746,19 @@ test.describe('OIDC prompt parameter', () => {
await page.goto(`/authorize?${urlParams.toString()}`);
// Should show consent UI even though client was already authorized
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
await expect(
page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })
).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')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -758,7 +780,10 @@ test.describe('OIDC prompt parameter', () => {
// 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')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -766,17 +791,56 @@ test.describe('OIDC prompt parameter', () => {
expect(reauthCalled).toBe(true);
});
test('prompt=select_account returns interaction_required error', async ({ page }) => {
test('prompt=select_account shows current user and continues on confirm', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
urlParams.set('prompt', 'select_account');
await page.goto(`/authorize?${urlParams.toString()}`);
// Should show error since account selection is not supported
await expect(
page.getByRole('paragraph').filter({ hasText: 'interaction_required' })
).toBeVisible();
// Account selection card with the signed-in user should appear
const selectionCard = page.getByTestId('account-selection');
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;
}
});
});
test('prompt=select_account account can be changed', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
urlParams.set('prompt', 'select_account');
await page.goto(`/authorize?${urlParams.toString()}`);
await page.getByRole('button', { name: 'Use a different account' }).click();
await expect(page.getByText('Do you want to sign in to Nextcloud')).toBeVisible();
(await passkeyUtil.init(page)).addPasskey('craig');
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;
}
});
});
test('prompt=none with prompt=consent returns interaction_required', async ({ page }) => {
@@ -785,10 +849,8 @@ test.describe('OIDC prompt parameter', () => {
urlParams.set('prompt', 'none consent');
// Should redirect with error since both can't be satisfied
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
page,
'/auth/callback',
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () =>
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
@@ -801,10 +863,8 @@ test.describe('OIDC prompt parameter', () => {
urlParams.set('prompt', 'none login');
// Should redirect with error since both can't be satisfied
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
page,
'/auth/callback',
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () =>
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
@@ -817,13 +877,11 @@ test.describe('OIDC prompt parameter', () => {
urlParams.set('prompt', 'none select_account');
// Should redirect with error since both can't be satisfied
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
page,
'/auth/callback',
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () =>
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
});
});
});