feat: add OpenID Connect prompt Parameter Handling (#1299)

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Robert Jaakke
2026-04-19 18:03:08 +02:00
committed by GitHub
parent 4f09de2cfc
commit 59fe481af9
12 changed files with 447 additions and 28 deletions

View File

@@ -14,7 +14,7 @@ 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')) {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
throw e;
}
});
@@ -31,7 +31,7 @@ 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')) {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
throw e;
}
});
@@ -49,7 +49,7 @@ 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')) {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
throw e;
}
});
@@ -71,7 +71,7 @@ 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')) {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
throw e;
}
});
@@ -125,7 +125,7 @@ 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')) {
if (e.message.includes('net::ERR_NAME_NOT_RESOLVED') || e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
redirectedCorrectly = true;
} else {
throw e;
@@ -617,10 +617,170 @@ 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')) {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
throw e;
}
});
expect(webauthnStartCalled).toBe(true);
});
test.describe('OIDC prompt parameter', () => {
test('prompt=none redirects with login_required when user not authenticated', async ({
page
}) => {
await page.context().clearCookies();
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
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(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('login_required');
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
});
test('prompt=none redirects with consent_required when authorization needed', async ({
page
}) => {
const oidcClient = oidcClients.immich;
const urlParams = createUrlParams(oidcClient);
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(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('consent_required');
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
});
test('prompt=none succeeds when user is authenticated and authorized', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
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;
}
});
});
test('prompt=consent forces consent display even for authorized client', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
urlParams.set('prompt', 'consent');
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: '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;
}
});
});
test('prompt=login forces reauthentication', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
urlParams.set('prompt', 'login');
let reauthCalled = false;
await page.route('/api/webauthn/login/start', async (route) => {
reauthCalled = true;
await route.continue();
});
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;
}
});
expect(reauthCalled).toBe(true);
});
test('prompt=select_account returns interaction_required error', 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();
});
test('prompt=none with prompt=consent returns interaction_required', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
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(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
});
test('prompt=none with prompt=login returns interaction_required', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
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(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
});
test('prompt=none with prompt=select_account returns interaction_required', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
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(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
});
});

View File

@@ -1,5 +1,34 @@
import type { Page } from '@playwright/test';
export async function interceptCallbackRedirect(
page: Page,
callbackPath: string,
action: () => Promise<void>,
timeoutMs: number = 10000
): Promise<URL> {
const routeMatcher = (url: URL) => url.pathname === callbackPath;
const callbackPromise = new Promise<URL>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Timed out waiting for redirect to ${callbackPath}`)),
timeoutMs
);
page.route(routeMatcher, async (route) => {
clearTimeout(timer);
resolve(new URL(route.request().url()));
await route.abort();
});
});
try {
await action();
return await callbackPromise;
} finally {
await page.unroute(routeMatcher);
}
}
export async function getUserCode(
page: Page,
clientId: string,