mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-19 11:26:25 +00:00
364 lines
15 KiB
TypeScript
364 lines
15 KiB
TypeScript
import test, { expect, Page } from '@playwright/test';
|
|
import { oidcClients, userGroups } from '../data';
|
|
import { cleanupBackend } from '../utils/cleanup.util';
|
|
import * as oidcUtil from '../utils/oidc.util';
|
|
|
|
test.beforeEach(async () => await cleanupBackend());
|
|
|
|
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.getByLabel('Description').fill(oidcClient.description);
|
|
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
|
|
|
|
await page.getByRole('button', { name: 'Add' }).first().click();
|
|
await page.getByTestId('callback-url-1').fill(oidcClient.callbackUrl);
|
|
|
|
await page.getByRole('button', { name: 'Add another' }).click();
|
|
await page.getByTestId('callback-url-2').fill(oidcClient.secondCallbackUrl);
|
|
|
|
await page.locator('[role="tab"][data-value="light-logo"]').first().click();
|
|
await page.setInputFiles('#oidc-client-logo-light', 'resources/images/pingvin-share-logo.png');
|
|
await page.locator('[role="tab"][data-value="dark-logo"]').first().click();
|
|
await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/pingvin-share-logo.png');
|
|
|
|
if (clientId) {
|
|
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
|
|
await page.getByLabel('Client ID').fill(clientId);
|
|
}
|
|
|
|
await page.getByRole('button', { name: 'Save' }).click();
|
|
|
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
|
'OIDC client created successfully'
|
|
);
|
|
|
|
const resolvedClientId = (await page.getByTestId('client-id').innerText()).trim();
|
|
|
|
if (clientId) {
|
|
expect(resolvedClientId).toBe(clientId);
|
|
} else {
|
|
expect(resolvedClientId).toMatch(/^[\w-]{36}$/);
|
|
}
|
|
|
|
await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name);
|
|
await expect(page.getByLabel('Description')).toHaveValue(oidcClient.description);
|
|
await expect(page.getByTestId('callback-url-1')).toHaveValue(oidcClient.callbackUrl);
|
|
await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl);
|
|
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` }).first()).toBeVisible();
|
|
|
|
const res = await page.request.get(`/api/oidc/clients/${resolvedClientId}/logo`);
|
|
expect(res.ok()).toBeTruthy();
|
|
}
|
|
|
|
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.locator('[role="tab"][data-value="light-logo"]').first().click();
|
|
await page.setInputFiles('#oidc-client-logo-light', 'resources/images/cloud-logo.png');
|
|
await page.locator('[role="tab"][data-value="dark-logo"]').first().click();
|
|
await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png');
|
|
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
|
|
const clientForm = page.getByLabel('Name').locator('xpath=ancestor::form');
|
|
await clientForm.getByRole('button', { name: 'Save' }).click();
|
|
|
|
await expect(page.locator('[data-type="success"]')).toHaveText(
|
|
'OIDC client updated successfully'
|
|
);
|
|
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('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 card.getByRole('button', { name: 'Save' }).click();
|
|
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
|
|
|
|
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 card.getByRole('button', { name: 'Save' }).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 card.getByRole('button', { name: 'Save' }).click();
|
|
await expect(card.getByText('Token lifetime cannot exceed 365 days.')).toBeVisible();
|
|
|
|
await card.getByLabel('Access token lifetime', { exact: true }).fill('1.5');
|
|
await card.getByRole('button', { name: 'Save' }).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 card.getByRole('button', { name: 'Save' }).click();
|
|
await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible();
|
|
});
|
|
|
|
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 card.getByRole('button', { name: 'Save' }).click();
|
|
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');
|
|
|
|
// 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 clientForm = description.locator('xpath=ancestor::form');
|
|
const formUpdate = page.waitForResponse(
|
|
(response) =>
|
|
response.request().method() === 'PUT' &&
|
|
response.url().endsWith(`/api/oidc/clients/${client.id}`)
|
|
);
|
|
await clientForm.getByRole('button', { name: 'Save' }).click();
|
|
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('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('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: 'Allowed user groups' }).click();
|
|
|
|
await page.getByRole('button', { name: 'Restrict' }).click();
|
|
|
|
await page.getByRole('row', { name: userGroups.designers.name }).getByRole('checkbox').click();
|
|
await page.getByRole('row', { name: userGroups.developers.name }).getByRole('checkbox').click();
|
|
|
|
await page.getByRole('button', { name: 'Save' }).click();
|
|
|
|
await expect(page.getByText('Allowed user groups updated successfully')).toBeVisible();
|
|
|
|
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');
|
|
});
|