mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-05-20 20:09:53 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user