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()); 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); 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('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('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 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'); });