mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-06 17:59:03 +02:00
793 lines
32 KiB
TypeScript
793 lines
32 KiB
TypeScript
import test, { expect, Page } from '@playwright/test';
|
|
import * as jose from 'jose';
|
|
import { oidcClients, userGroups } from '../data';
|
|
import { cleanupBackend } from '../utils/cleanup.util';
|
|
import * as oidcUtil from '../utils/oidc.util';
|
|
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
|
|
|
|
test.beforeEach(async () => await cleanupBackend());
|
|
|
|
// ICON_LIBRARY_URL is fixed when the backend starts, so other states are simulated in the public configuration
|
|
async function stubIconLibrary(page: Page, iconLibrary: 'custom' | 'disabled') {
|
|
await page.route('**/api/application-configuration', async (route) => {
|
|
const response = await route.fetch();
|
|
const config = (await response.json()) as { key: string; value: string }[];
|
|
await route.fulfill({
|
|
response,
|
|
json: config.map((entry) =>
|
|
entry.key === 'iconLibrary' ? { ...entry, value: iconLibrary } : entry
|
|
)
|
|
});
|
|
});
|
|
}
|
|
|
|
test.describe('Create OIDC client', () => {
|
|
async function createClientTest(page: Page, clientId?: string) {
|
|
const oidcClient = oidcClients.pingvinShare;
|
|
await page.goto('/settings/admin/oidc-clients');
|
|
await page.getByRole('button', { name: 'Add OIDC Client' }).click();
|
|
|
|
await page.getByLabel('Name').fill(oidcClient.name);
|
|
|
|
await page.getByRole('button', { name: 'Add callback URL' }).click();
|
|
await page.getByTestId('callback-url-1').fill(oidcClient.callbackUrl);
|
|
|
|
await page.getByRole('button', { name: 'Add callback URL' }).click();
|
|
await page.getByTestId('callback-url-2').fill(oidcClient.secondCallbackUrl);
|
|
|
|
if (clientId) {
|
|
await page.getByRole('button', { name: 'Set custom client ID' }).click();
|
|
await page.getByLabel('Client ID').fill(clientId);
|
|
}
|
|
|
|
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
|
|
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
|
'OIDC client created successfully'
|
|
);
|
|
|
|
const resolvedClientId = (await page.getByTestId('client-id').innerText()).trim();
|
|
const createdSecret = (await page.getByTestId('created-client-secret').innerText()).trim();
|
|
expect(createdSecret).toMatch(/^\w{32}$/);
|
|
|
|
if (clientId) {
|
|
expect(resolvedClientId).toBe(clientId);
|
|
} else {
|
|
expect(resolvedClientId).toMatch(/^[\w-]{36}$/);
|
|
}
|
|
|
|
await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name);
|
|
await expect(page.getByTestId('callback-url-1')).toHaveValue(oidcClient.callbackUrl);
|
|
await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl);
|
|
|
|
// The generated value is available on the creation page and is forgotten after a reload
|
|
await page.reload();
|
|
await expect(page.getByText(createdSecret, { exact: true })).toHaveCount(0);
|
|
await page.getByRole('tab', { name: 'Credentials' }).click();
|
|
await expect(page.getByTestId('client-secret-row')).toHaveCount(1);
|
|
await expect(page.getByTestId('client-secret')).toHaveText(
|
|
`${createdSecret.slice(0, 4)}••••••••`
|
|
);
|
|
}
|
|
|
|
test('with auto-generated client ID', async ({ page }) => {
|
|
await createClientTest(page);
|
|
});
|
|
|
|
test('with custom client ID', async ({ page }) => {
|
|
await createClientTest(page, '123e4567-e89b-12d3-a456-426614174000');
|
|
});
|
|
});
|
|
|
|
test('Edit OIDC client', async ({ page }) => {
|
|
const oidcClient = oidcClients.nextcloud;
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
|
|
|
await page.getByLabel('Name').fill('Nextcloud updated');
|
|
await page.getByLabel('Description').fill('Updated description');
|
|
await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
|
|
await page.setInputFiles('#oidc-client-logo-light', 'resources/images/cloud-logo.png');
|
|
await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png');
|
|
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
|
|
await saveUnsavedChanges(page);
|
|
await expect(page.getByRole('img', { name: 'Nextcloud updated logo' }).first()).toBeVisible();
|
|
await page.request
|
|
.get(`/api/oidc/clients/${oidcClient.id}/logo`)
|
|
.then((res) => expect.soft(res.status()).toBe(200));
|
|
});
|
|
|
|
test('Upload OIDC client logo from the logo picker', async ({ page }) => {
|
|
const oidcClient = oidcClients.nextcloud;
|
|
await page.route('**/api/oidc/logo-presets*', (route) => route.fulfill({ json: [] }));
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
|
|
|
const uploadLogo = async (variant: 'light' | 'dark') => {
|
|
const fileChooser = page.waitForEvent('filechooser');
|
|
await page.getByRole('button', { name: `Upload ${variant} logo` }).click();
|
|
await (await fileChooser).setFiles('resources/images/cloud-logo.png');
|
|
};
|
|
|
|
// The picker stays open and pre-selects the current logo tile, which shows each upload in its theme
|
|
await page.getByRole('button', { name: 'Choose logo' }).click();
|
|
await uploadLogo('dark');
|
|
const customTile = page.getByRole('option', { name: 'Current logo' });
|
|
const customTileImages = customTile.locator('img');
|
|
await expect(customTile).toHaveAttribute('aria-selected', 'true');
|
|
await expect(customTileImages.nth(1)).toHaveAttribute('src', /^blob:/);
|
|
await expect(customTileImages.nth(0)).not.toHaveAttribute('src', /^blob:/);
|
|
|
|
await uploadLogo('light');
|
|
await expect(customTileImages.nth(0)).toHaveAttribute('src', /^blob:/);
|
|
|
|
// The form only previews the logo of the current theme
|
|
await page.keyboard.press('Escape');
|
|
const preview = page.getByRole('img', { name: 'Nextcloud logo' });
|
|
await expect(preview).toHaveCount(1);
|
|
await expect(preview).toHaveAttribute('src', /^blob:/);
|
|
|
|
await saveUnsavedChanges(page);
|
|
for (const light of [true, false]) {
|
|
await page.request
|
|
.get(`/api/oidc/clients/${oidcClient.id}/logo?light=${light}`)
|
|
.then((res) => expect.soft(res.status()).toBe(200));
|
|
}
|
|
});
|
|
|
|
test('A single OIDC client logo shows in both themes', async ({ page }) => {
|
|
const oidcClient = oidcClients.immich;
|
|
await page.route('**/api/oidc/logo-presets*', (route) => route.fulfill({ json: [] }));
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
|
|
|
// Upload only a dark logo while the page is in light mode
|
|
await page.getByRole('button', { name: 'Choose logo' }).click();
|
|
const fileChooser = page.waitForEvent('filechooser');
|
|
await page.getByRole('button', { name: 'Upload dark logo' }).click();
|
|
await (await fileChooser).setFiles('resources/images/cloud-logo.png');
|
|
|
|
// The light half of the current logo tile and the light preview fall back to the dark logo
|
|
const currentTileImages = page.getByRole('option', { name: 'Current logo' }).locator('img');
|
|
await expect(currentTileImages.nth(0)).toHaveAttribute('src', /^blob:/);
|
|
await expect(currentTileImages.nth(1)).toHaveAttribute('src', /^blob:/);
|
|
await page.keyboard.press('Escape');
|
|
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toHaveAttribute(
|
|
'src',
|
|
/^blob:/
|
|
);
|
|
|
|
// After saving, the light logo URL serves the dark logo and the client list shows it
|
|
await saveUnsavedChanges(page);
|
|
const response = await page.request.get(`/api/oidc/clients/${oidcClient.id}/logo?light=true`);
|
|
expect(response.status()).toBe(200);
|
|
|
|
await page.goto('/settings/admin/oidc-clients');
|
|
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toBeVisible();
|
|
});
|
|
|
|
test('Pick OIDC client logo from the icon library', async ({ page }) => {
|
|
const oidcClient = oidcClients.nextcloud;
|
|
const cdnBaseUrl = 'https://cdn.jsdelivr.net/gh/selfhst/icons@main';
|
|
const nextcloud = {
|
|
name: 'Nextcloud',
|
|
reference: 'nextcloud',
|
|
logoUrl: `${cdnBaseUrl}/svg/nextcloud.svg`,
|
|
darkLogoUrl: `${cdnBaseUrl}/svg/nextcloud-light.svg`
|
|
};
|
|
const otherApp = {
|
|
name: '2FAuth',
|
|
reference: '2fauth',
|
|
logoUrl: `${cdnBaseUrl}/svg/2fauth.svg`,
|
|
darkLogoUrl: null
|
|
};
|
|
|
|
// Stub the icon index and the CDN so the test doesn't depend on jsDelivr
|
|
// Nextcloud is only found by searching for it, like most icons that aren't at the start of the alphabet
|
|
const searchTerms: (string | null)[] = [];
|
|
await page.route('**/api/oidc/logo-presets*', async (route) => {
|
|
const search = new URL(route.request().url()).searchParams.get('search');
|
|
searchTerms.push(search);
|
|
await route.fulfill({ json: search?.startsWith('next') ? [nextcloud] : [otherApp] });
|
|
});
|
|
await page.route(`${cdnBaseUrl}/**`, (route) =>
|
|
route.fulfill({
|
|
contentType: 'image/svg+xml',
|
|
body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>'
|
|
})
|
|
);
|
|
|
|
// Answer the save with the stored client so the backend doesn't download the icons
|
|
await page.route(`**/api/oidc/clients/${oidcClient.id}`, async (route) => {
|
|
if (route.request().method() !== 'PUT') return route.fallback();
|
|
await route.fulfill({ response: await route.fetch({ method: 'GET' }) });
|
|
});
|
|
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
|
await page.getByRole('button', { name: 'Choose logo' }).click();
|
|
|
|
// The search starts empty and lists the icon library
|
|
const searchInput = page.getByRole('combobox', { name: 'Search icons or paste an image URL' });
|
|
await expect(searchInput).toHaveValue('');
|
|
await expect(page.getByRole('option', { name: otherApp.name })).toBeVisible();
|
|
expect(searchTerms).toEqual(['']);
|
|
await expect(page.getByText('Icons by selfh.st')).toBeVisible();
|
|
|
|
await searchInput.fill('nextcloud');
|
|
await page.getByRole('option', { name: nextcloud.name, exact: true }).click();
|
|
await expect(page.getByRole('img', { name: 'Nextcloud logo' })).toHaveAttribute(
|
|
'src',
|
|
nextcloud.logoUrl
|
|
);
|
|
|
|
// Before saving, the reopened picker shows the picked icon as the selected current logo even though the search no longer finds it
|
|
await page.getByRole('button', { name: 'Choose logo' }).click();
|
|
await expect(page.getByRole('option', { name: otherApp.name })).toBeVisible();
|
|
const currentTile = page.getByRole('option', { name: 'Current logo' });
|
|
await expect(currentTile).toHaveAttribute('aria-selected', 'true');
|
|
await expect(currentTile.locator('img').nth(0)).toHaveAttribute('src', nextcloud.logoUrl);
|
|
await expect(currentTile.locator('img').nth(1)).toHaveAttribute('src', nextcloud.darkLogoUrl);
|
|
await page.keyboard.press('Escape');
|
|
|
|
const updateRequest = page.waitForRequest(
|
|
(req) => req.method() === 'PUT' && req.url().endsWith(`/api/oidc/clients/${oidcClient.id}`)
|
|
);
|
|
await saveUnsavedChanges(page);
|
|
|
|
const body = (await updateRequest).postDataJSON();
|
|
expect(body.logoUrl).toBe(nextcloud.logoUrl);
|
|
expect(body.darkLogoUrl).toBe(nextcloud.darkLogoUrl);
|
|
});
|
|
|
|
test('Paste an image URL into the OIDC client logo picker', async ({ page }) => {
|
|
const oidcClient = oidcClients.nextcloud;
|
|
const darkLogoUrl = 'https://example.com/nextcloud-dark.svg';
|
|
|
|
// Fail the test if a pasted URL is ever sent as an icon search
|
|
const searchTerms: (string | null)[] = [];
|
|
await page.route('**/api/oidc/logo-presets*', async (route) => {
|
|
searchTerms.push(new URL(route.request().url()).searchParams.get('search'));
|
|
await route.fulfill({ json: [] });
|
|
});
|
|
await page.route(darkLogoUrl, (route) =>
|
|
route.fulfill({
|
|
contentType: 'image/svg+xml',
|
|
body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>'
|
|
})
|
|
);
|
|
|
|
// Answer the save with the stored client so the backend doesn't download the image
|
|
await page.route(`**/api/oidc/clients/${oidcClient.id}`, async (route) => {
|
|
if (route.request().method() !== 'PUT') return route.fallback();
|
|
await route.fulfill({ response: await route.fetch({ method: 'GET' }) });
|
|
});
|
|
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
|
|
|
// Each half of the URL tile replaces only its own variant
|
|
await page.getByRole('button', { name: 'Choose logo' }).click();
|
|
await page
|
|
.getByRole('combobox', { name: 'Search icons or paste an image URL' })
|
|
.fill(darkLogoUrl);
|
|
await page.getByRole('button', { name: 'Use as dark logo' }).click();
|
|
|
|
// The search resets and the current logo tile shows the URL in the dark half
|
|
await expect(
|
|
page.getByRole('combobox', { name: 'Search icons or paste an image URL' })
|
|
).toHaveValue('');
|
|
await expect(
|
|
page.getByRole('option', { name: 'Current logo' }).locator('img').nth(1)
|
|
).toHaveAttribute('src', darkLogoUrl);
|
|
await page.keyboard.press('Escape');
|
|
|
|
const updateRequest = page.waitForRequest(
|
|
(req) => req.method() === 'PUT' && req.url().endsWith(`/api/oidc/clients/${oidcClient.id}`)
|
|
);
|
|
await saveUnsavedChanges(page);
|
|
|
|
const body = (await updateRequest).postDataJSON();
|
|
expect(body.darkLogoUrl).toBe(darkLogoUrl);
|
|
expect(body.logoUrl).toBeFalsy();
|
|
expect(searchTerms).not.toContain(darkLogoUrl);
|
|
});
|
|
|
|
test('OIDC client logo picker stays fully visible when reopened', async ({ page }) => {
|
|
const presets = Array.from({ length: 30 }, (_, i) => ({
|
|
name: `App ${i}`,
|
|
reference: `app-${i}`,
|
|
logoUrl: `https://example.com/app-${i}.svg`,
|
|
darkLogoUrl: null
|
|
}));
|
|
await page.route('**/api/oidc/logo-presets*', (route) => route.fulfill({ json: presets }));
|
|
|
|
// A short viewport leaves too little room below the button for the picker
|
|
const viewportHeight = 760;
|
|
await page.setViewportSize({ width: 1100, height: viewportHeight });
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
|
|
|
const button = page.getByRole('button', { name: 'Choose logo' });
|
|
const picker = page.locator('[data-slot="popover-content"]');
|
|
const expectPickerInViewport = async () => {
|
|
await expect(page.getByRole('option', { name: 'App 0' })).toBeVisible();
|
|
await expect(async () => {
|
|
const box = await picker.boundingBox();
|
|
expect(box!.y).toBeGreaterThanOrEqual(0);
|
|
expect(box!.y + box!.height).toBeLessThanOrEqual(viewportHeight);
|
|
}).toPass();
|
|
};
|
|
|
|
await button.click();
|
|
await expectPickerInViewport();
|
|
|
|
// Reopening with results still in state used to scroll the page towards the unpositioned popover
|
|
await page.getByRole('combobox', { name: 'Search icons or paste an image URL' }).fill('');
|
|
await page.keyboard.press('Escape');
|
|
await expect(picker).toHaveCount(0);
|
|
await button.click();
|
|
await expectPickerInViewport();
|
|
});
|
|
|
|
test('Custom icon library hides the selfh.st credit', async ({ page }) => {
|
|
await stubIconLibrary(page, 'custom');
|
|
|
|
// The credit is only shown next to icons, so the custom library has to return one
|
|
const preset = {
|
|
name: 'Custom App',
|
|
reference: 'custom-app',
|
|
logoUrl: 'https://example.com/custom-app.svg',
|
|
darkLogoUrl: null
|
|
};
|
|
await page.route('**/api/oidc/logo-presets*', (route) => route.fulfill({ json: [preset] }));
|
|
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
|
await page.getByRole('button', { name: 'Choose logo' }).click();
|
|
|
|
await expect(page.getByRole('option', { name: preset.name })).toBeVisible();
|
|
await expect(page.getByText('Icons by selfh.st')).toHaveCount(0);
|
|
});
|
|
|
|
test('Disabled icon library only accepts image URLs', async ({ page }) => {
|
|
await stubIconLibrary(page, 'disabled');
|
|
|
|
// Fail the test if the picker searches the disabled icon library
|
|
let searched = false;
|
|
await page.route('**/api/oidc/logo-presets*', async (route) => {
|
|
searched = true;
|
|
await route.fulfill({ json: [] });
|
|
});
|
|
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
|
await page.getByRole('button', { name: 'Choose logo' }).click();
|
|
await page
|
|
.getByRole('combobox', { name: 'Paste an image URL' })
|
|
.fill('https://example.com/logo.svg');
|
|
await expect(page.getByRole('button', { name: 'Use as light logo' })).toBeVisible();
|
|
await expect(page.getByText('Icons by selfh.st')).toHaveCount(0);
|
|
expect(searched).toBe(false);
|
|
});
|
|
|
|
test('Displays OIDC client endpoints from discovery configuration', async ({ page }) => {
|
|
const oidcConfiguration = {
|
|
issuer: 'https://id.example.com',
|
|
authorization_endpoint: 'https://id.example.com/authorize',
|
|
token_endpoint: 'http://pocket-id:1411/api/oidc/token',
|
|
userinfo_endpoint: 'http://pocket-id:1411/api/oidc/userinfo',
|
|
end_session_endpoint: 'https://id.example.com/api/oidc/end-session',
|
|
jwks_uri: 'http://pocket-id:1411/.well-known/jwks.json'
|
|
};
|
|
|
|
await page.route('**/.well-known/openid-configuration', async (route) => {
|
|
await route.fulfill({ json: oidcConfiguration });
|
|
});
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
|
await page.getByRole('button', { name: 'Show more details' }).click();
|
|
|
|
await expect(page.getByText(oidcConfiguration.token_endpoint, { exact: true })).toBeVisible();
|
|
await expect(page.getByText(oidcConfiguration.userinfo_endpoint, { exact: true })).toBeVisible();
|
|
await expect(page.getByText(oidcConfiguration.jwks_uri, { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test('Update OIDC client token lifetimes', async ({ page }) => {
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
|
|
|
const card = page.getByTestId('token-lifetimes-card');
|
|
const accessLifetime = card.getByLabel('Access token lifetime', { exact: true });
|
|
const accessUnit = card.getByLabel('Access token lifetime unit');
|
|
const refreshLifetime = card.getByLabel('Refresh token inactivity timeout', { exact: true });
|
|
const refreshUnit = card.getByLabel('Refresh token inactivity timeout unit');
|
|
|
|
await expect(accessLifetime).toHaveValue('1');
|
|
await expect(accessUnit).toHaveText('Hours');
|
|
await expect(refreshLifetime).toHaveValue('30');
|
|
await expect(refreshUnit).toHaveText('Days');
|
|
|
|
await accessUnit.click();
|
|
await page.getByRole('option', { name: 'Minutes' }).click();
|
|
await expect(accessLifetime).toHaveValue('60');
|
|
await accessLifetime.fill('90');
|
|
|
|
await refreshUnit.click();
|
|
await page.getByRole('option', { name: 'Hours' }).click();
|
|
await expect(refreshLifetime).toHaveValue('720');
|
|
await refreshLifetime.fill('336');
|
|
|
|
await saveUnsavedChanges(page);
|
|
|
|
await page.reload();
|
|
await expect(card.getByLabel('Access token lifetime', { exact: true })).toHaveValue('90');
|
|
await expect(card.getByLabel('Access token lifetime unit')).toHaveText('Minutes');
|
|
await expect(card.getByLabel('Refresh token inactivity timeout', { exact: true })).toHaveValue(
|
|
'14'
|
|
);
|
|
await expect(card.getByLabel('Refresh token inactivity timeout unit')).toHaveText('Days');
|
|
|
|
await card.getByLabel('Access token lifetime', { exact: true }).fill('0');
|
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
|
await expect(card.getByText('Token lifetime must be at least 1 minute.')).toBeVisible();
|
|
|
|
await card.getByLabel('Access token lifetime', { exact: true }).fill('525601');
|
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
|
await expect(card.getByText('Token lifetime cannot exceed 365 days.')).toBeVisible();
|
|
|
|
await card.getByLabel('Access token lifetime', { exact: true }).fill('1.5');
|
|
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
|
await expect(card.getByText('Token lifetime must use whole-minute increments.')).toBeVisible();
|
|
|
|
await card.getByLabel('Access token lifetime', { exact: true }).fill('60');
|
|
await card.getByLabel('Refresh token inactivity timeout', { exact: true }).fill('30');
|
|
await saveUnsavedChanges(page);
|
|
});
|
|
|
|
test('Save OIDC client details and token lifetimes together', async ({ page }) => {
|
|
const client = oidcClients.nextcloud;
|
|
await page.goto(`/settings/admin/oidc-clients/${client.id}`);
|
|
|
|
const name = page.getByLabel('Name');
|
|
const accessLifetime = page
|
|
.getByTestId('token-lifetimes-card')
|
|
.getByLabel('Access token lifetime', { exact: true });
|
|
await name.fill('Nextcloud with custom lifetime');
|
|
await accessLifetime.fill('2');
|
|
await page.getByRole('button', { name: 'Discard', exact: true }).click();
|
|
await expect(name).toHaveValue(client.name);
|
|
await expect(accessLifetime).toHaveValue('1');
|
|
|
|
await name.fill('Nextcloud with custom lifetime');
|
|
await accessLifetime.fill('2');
|
|
|
|
await saveUnsavedChanges(page);
|
|
|
|
await page.reload();
|
|
await expect(name).toHaveValue('Nextcloud with custom lifetime');
|
|
await expect(accessLifetime).toHaveValue('2');
|
|
});
|
|
|
|
test('Update OIDC client federated credentials', async ({ page }) => {
|
|
const client = oidcClients.nextcloud;
|
|
await page.goto(`/settings/admin/oidc-clients/${client.id}#credentials`);
|
|
|
|
const card = page.getByTestId('federated-credentials-card');
|
|
await card.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await card.getByLabel('Issuer').fill('https://issuer.example.com');
|
|
await card.getByLabel('Subject').fill('workload-client');
|
|
await card.getByLabel('Audience').fill('https://pocket-id.example.com');
|
|
|
|
const cardUpdate = page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === 'PUT' &&
|
|
response.url().endsWith(`/api/oidc/clients/${client.id}`)
|
|
);
|
|
await saveUnsavedChanges(page);
|
|
expect((await cardUpdate).ok()).toBeTruthy();
|
|
|
|
await page.reload();
|
|
await expect(card.getByLabel('Issuer')).toHaveValue('https://issuer.example.com');
|
|
await expect(card.getByLabel('Subject')).toHaveValue('workload-client');
|
|
await expect(card.getByLabel('Audience')).toHaveValue('https://pocket-id.example.com');
|
|
|
|
await card.getByRole('radio', { name: 'Public keys' }).click();
|
|
await card.getByRole('button', { name: 'Add another federated client credential' }).click();
|
|
await expect(card.getByLabel('Issuer')).toHaveCount(2);
|
|
await page.getByRole('button', { name: 'Discard', exact: true }).click();
|
|
await expect(card.getByLabel('Issuer')).toHaveCount(1);
|
|
await expect(card.getByRole('radio', { name: 'JWKS URL' })).toBeChecked();
|
|
|
|
// Saving the main client form must preserve credentials managed by the separate card
|
|
await page.locator('[role="tab"][data-value="general"]').click();
|
|
const description = page.getByLabel('Description');
|
|
await description.fill('Updated without replacing federated credentials');
|
|
const formUpdate = page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === 'PUT' &&
|
|
response.url().endsWith(`/api/oidc/clients/${client.id}`)
|
|
);
|
|
await saveUnsavedChanges(page);
|
|
expect((await formUpdate).ok()).toBeTruthy();
|
|
|
|
await page.goto(`/settings/admin/oidc-clients/${client.id}#credentials`);
|
|
await expect(card.getByLabel('Issuer')).toHaveValue('https://issuer.example.com');
|
|
});
|
|
|
|
test('Update OIDC client federated credentials with public keys', async ({ page }) => {
|
|
const client = oidcClients.nextcloud;
|
|
const issuer = 'https://agent.example.com';
|
|
const audience = 'api://agent-test';
|
|
|
|
async function generatePublicJwk(kid: string) {
|
|
const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { extractable: true });
|
|
return { privateKey, jwk: { ...(await jose.exportJWK(publicKey)), kid, alg: 'ES256' } };
|
|
}
|
|
|
|
const first = await generatePublicJwk('agent-key-1');
|
|
const second = await generatePublicJwk('agent-key-2');
|
|
const third = await generatePublicJwk('agent-key-3');
|
|
|
|
await page.goto(`/settings/admin/oidc-clients/${client.id}#credentials`);
|
|
|
|
const card = page.getByTestId('federated-credentials-card');
|
|
await card.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await card.getByLabel('Issuer').fill(issuer);
|
|
await card.getByLabel('Audience').fill(audience);
|
|
await card.getByRole('radio', { name: 'Public keys' }).click();
|
|
|
|
const pasteInput = card.getByLabel('Public key', { exact: true });
|
|
const addKeyButton = card.getByRole('button', { name: 'Add public key' });
|
|
const publicKeys = card.getByTestId('federated-identity-public-key');
|
|
const saveButton = page.getByRole('button', { name: 'Save', exact: true });
|
|
const waitForClientUpdate = () =>
|
|
page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === 'PUT' &&
|
|
response.url().endsWith(`/api/oidc/clients/${client.id}`)
|
|
);
|
|
|
|
// A single JWK is imported as one key
|
|
await pasteInput.fill(JSON.stringify(first.jwk));
|
|
await addKeyButton.click();
|
|
await expect(publicKeys).toHaveCount(1);
|
|
await expect(publicKeys.first()).toContainText('agent-key-1');
|
|
|
|
// A JWKS is imported as one key per entry
|
|
await pasteInput.fill(JSON.stringify({ keys: [second.jwk, third.jwk] }));
|
|
await addKeyButton.click();
|
|
await expect(publicKeys).toHaveCount(3);
|
|
|
|
// Private keys pass the JSON-only importer but are rejected by the backend
|
|
await pasteInput.fill(JSON.stringify(await jose.exportJWK(first.privateKey)));
|
|
await addKeyButton.click();
|
|
await expect(publicKeys).toHaveCount(4);
|
|
const privateKeyUpdate = waitForClientUpdate();
|
|
await saveButton.click();
|
|
expect((await privateKeyUpdate).status()).toBe(400);
|
|
await expect(page.getByText(/private key material/)).toBeVisible();
|
|
await publicKeys.last().getByRole('button').click();
|
|
await expect(publicKeys).toHaveCount(3);
|
|
|
|
// Keys without a key ID pass the JSON-only importer but are rejected by the backend
|
|
const { kid, ...withoutKeyId } = third.jwk;
|
|
await pasteInput.fill(JSON.stringify(withoutKeyId));
|
|
await addKeyButton.click();
|
|
await expect(publicKeys).toHaveCount(4);
|
|
const missingKeyIdUpdate = waitForClientUpdate();
|
|
await saveButton.click();
|
|
expect((await missingKeyIdUpdate).status()).toBe(400);
|
|
await expect(page.getByText(/missing the "kid" property/)).toBeVisible();
|
|
await publicKeys.last().getByRole('button').click();
|
|
await expect(publicKeys).toHaveCount(3);
|
|
|
|
await card.getByRole('button', { name: `Remove public key ${third.jwk.kid}` }).click();
|
|
await expect(publicKeys).toHaveCount(2);
|
|
|
|
const cardUpdate = waitForClientUpdate();
|
|
await saveUnsavedChanges(page);
|
|
expect((await cardUpdate).ok()).toBeTruthy();
|
|
|
|
await page.reload();
|
|
await expect(card.getByRole('radio', { name: 'Public keys' })).toBeChecked();
|
|
await expect(publicKeys).toHaveCount(2);
|
|
await expect(publicKeys.first()).toContainText('agent-key-1');
|
|
|
|
// The stored keys authenticate a client assertion signed with the matching private key
|
|
async function authenticateWithAssertion(key: CryptoKey, keyId: string, jti: string) {
|
|
const assertion = await new jose.SignJWT({})
|
|
.setProtectedHeader({ alg: 'ES256', kid: keyId })
|
|
.setIssuer(issuer)
|
|
.setSubject(client.id)
|
|
.setAudience(audience)
|
|
.setJti(jti)
|
|
.setIssuedAt()
|
|
.setExpirationTime('5m')
|
|
.sign(key);
|
|
|
|
return oidcUtil.exchangeCode(page, {
|
|
grant_type: 'client_credentials',
|
|
client_id: client.id,
|
|
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
client_assertion: assertion
|
|
});
|
|
}
|
|
|
|
for (const key of [first, second]) {
|
|
const res = await authenticateWithAssertion(
|
|
key.privateKey,
|
|
key.jwk.kid,
|
|
`assertion-${key.jwk.kid}`
|
|
);
|
|
expect(res.access_token).toBeTruthy();
|
|
}
|
|
|
|
// The key that was removed can no longer authenticate the client
|
|
const res = await authenticateWithAssertion(third.privateKey, third.jwk.kid, 'assertion-removed');
|
|
expect(res.access_token).toBeFalsy();
|
|
});
|
|
|
|
test('Create and delete OIDC client secrets', async ({ page }) => {
|
|
const oidcClient = oidcClients.nextcloud;
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}#credentials`);
|
|
|
|
const card = page.getByTestId('client-secrets-card');
|
|
// The seeded client already has the secret the other tests authenticate with
|
|
await expect(card.getByTestId('client-secret-row')).toHaveCount(1);
|
|
|
|
await card.getByRole('button', { name: 'Add client secret' }).click();
|
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
|
'New client secret created successfully'
|
|
);
|
|
|
|
// The new secret is the only one whose value is shown in full, and only until the page is left
|
|
await expect(card.getByTestId('client-secret-row')).toHaveCount(2);
|
|
const createdSecret = (await card.getByTestId('client-secret').nth(1).innerText()).trim();
|
|
expect(createdSecret).toMatch(/^\w{32}$/);
|
|
|
|
// Both secrets authenticate the client, so it can be rotated without downtime
|
|
for (const secret of [oidcClient.secret, createdSecret]) {
|
|
const res = await oidcUtil.exchangeCode(page, {
|
|
grant_type: 'client_credentials',
|
|
client_id: oidcClient.id,
|
|
client_secret: secret
|
|
});
|
|
expect(res.access_token).toBeTruthy();
|
|
}
|
|
|
|
// After a reload only the stored prefix is left
|
|
await page.reload();
|
|
await expect(card.getByTestId('client-secret').nth(1)).toHaveText(
|
|
`${createdSecret.substring(0, 4)}••••••••`
|
|
);
|
|
|
|
await card
|
|
.getByTestId('client-secret-row')
|
|
.nth(1)
|
|
.getByRole('button', { name: 'Toggle menu' })
|
|
.click();
|
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
|
await page.getByRole('button', { name: 'Delete' }).click();
|
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
|
'Client secret deleted successfully'
|
|
);
|
|
await expect(card.getByTestId('client-secret-row')).toHaveCount(1);
|
|
|
|
// The deleted secret can no longer authenticate the client
|
|
const res = await oidcUtil.exchangeCode(page, {
|
|
grant_type: 'client_credentials',
|
|
client_id: oidcClient.id,
|
|
client_secret: createdSecret
|
|
});
|
|
expect(res.access_token).toBeFalsy();
|
|
});
|
|
|
|
test('Client secrets can be created with an expiration', async ({ page }) => {
|
|
const oidcClient = oidcClients.nextcloud;
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}#credentials`);
|
|
|
|
const card = page.getByTestId('client-secrets-card');
|
|
await card.getByRole('button', { name: 'Expiration' }).click();
|
|
await page.getByRole('option', { name: '90 days' }).click();
|
|
await card.getByRole('button', { name: 'Add client secret' }).click();
|
|
|
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
|
'New client secret created successfully'
|
|
);
|
|
|
|
const secrets = await page.request
|
|
.get(`/api/oidc/clients/${oidcClient.id}/secrets`)
|
|
.then((r) => r.json());
|
|
expect(secrets).toHaveLength(2);
|
|
|
|
const expiresAt = new Date(secrets[1].expiresAt).getTime();
|
|
const expected = Date.now() + 90 * 24 * 60 * 60 * 1000;
|
|
expect(Math.abs(expiresAt - expected)).toBeLessThan(5 * 60 * 1000);
|
|
expect(secrets[1].isActive).toBe(true);
|
|
});
|
|
|
|
test('Delete OIDC client', async ({ page }) => {
|
|
const oidcClient = oidcClients.nextcloud;
|
|
await page.goto('/settings/admin/oidc-clients');
|
|
|
|
await page
|
|
.getByRole('row', { name: oidcClient.name })
|
|
.getByRole('button', { name: 'Toggle menu' })
|
|
.click();
|
|
|
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
|
|
|
await page.getByRole('button', { name: 'Delete' }).click();
|
|
|
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
|
'OIDC client deleted successfully'
|
|
);
|
|
await expect(page.getByRole('row', { name: oidcClient.name })).not.toBeVisible();
|
|
});
|
|
|
|
test('Delete OIDC client from the details page', async ({ page }) => {
|
|
const oidcClient = oidcClients.nextcloud;
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
|
|
|
await page.getByRole('button', { name: 'Actions' }).click();
|
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
|
await page.getByRole('button', { name: 'Delete' }).click();
|
|
|
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
|
'OIDC client deleted successfully'
|
|
);
|
|
await page.waitForURL((url) => url.pathname === '/settings/admin/oidc-clients');
|
|
await expect(page.getByRole('row', { name: oidcClient.name })).not.toBeVisible();
|
|
});
|
|
|
|
test('Filter OIDC clients by PAR requirement', async ({ page, request }) => {
|
|
const parClient = oidcClients.parClient;
|
|
|
|
// Enable PAR on the PAR test client
|
|
await request.put(`/api/oidc/clients/${parClient.id}`, {
|
|
data: {
|
|
name: parClient.name,
|
|
callbackURLs: [parClient.callbackUrl],
|
|
logoutCallbackURLs: [],
|
|
isPublic: false,
|
|
pkceEnabled: false,
|
|
pkceSupported: false,
|
|
requiresReauthentication: false,
|
|
requiresPushedAuthorizationRequests: true,
|
|
credentials: { federatedIdentities: [] },
|
|
isGroupRestricted: false
|
|
}
|
|
});
|
|
|
|
await page.goto('/settings/admin/oidc-clients');
|
|
|
|
// Open PAR filter and select "Yes"
|
|
await page.getByTestId('facet-par-trigger').click();
|
|
await page.getByTestId('facet-par-option-true').click();
|
|
|
|
// Only the PAR client should be visible
|
|
await expect(page.getByRole('row', { name: parClient.name })).toBeVisible();
|
|
await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).not.toBeVisible();
|
|
|
|
// Deselect "Yes" and select "No" to invert the filter
|
|
await page.getByTestId('facet-par-option-true').click();
|
|
await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).toBeVisible();
|
|
await page.getByTestId('facet-par-option-false').click();
|
|
|
|
// PAR client should be hidden, others visible
|
|
await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).toBeVisible();
|
|
await expect(page.getByRole('row', { name: parClient.name })).not.toBeVisible();
|
|
});
|
|
|
|
test('Update OIDC client allowed user groups', async ({ page }) => {
|
|
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
|
await page.getByRole('tab', { name: 'Access', exact: true }).click();
|
|
|
|
await page.getByRole('radio', { name: 'Selected user groups' }).click();
|
|
|
|
await page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox').click();
|
|
await page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox').click();
|
|
|
|
await saveUnsavedChanges(page);
|
|
|
|
await page.reload();
|
|
|
|
await expect(
|
|
page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox')
|
|
).toHaveAttribute('data-state', 'checked');
|
|
await expect(
|
|
page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox')
|
|
).toHaveAttribute('data-state', 'checked');
|
|
});
|