mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-26 21:09:04 +02:00
feat: support multiple client secrets per OIDC client (#1679)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260802120000,
|
||||
"version": 20260807120000,
|
||||
"tableOrder": [
|
||||
"users",
|
||||
"user_groups",
|
||||
@@ -92,7 +92,7 @@
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
"credentials": "e30=",
|
||||
"credentials": "eyJzZWNyZXRzIjpbeyJpZCI6IjJmMWI4ZjFhLTFkM2UtNGYwYy05YzFhLTAwMDAwMDAwMDAwMSIsImFsZyI6InNoYTI1NiIsImhhc2giOiJkZjMzMWMxOGRhMTM0NDMwOTRkYzJjODQ5NzFkNWNlZmIzNDY0ZTk3OTk5YzM5YmMwMzg0NWVhY2NjNjE3MGEyIiwicHJlZml4IjoidzJtVSIsImNyZWF0ZWRBdCI6IjIwMjUtMTEtMjVUMTI6Mzk6MDJaIn1dfQ==",
|
||||
"dark_image_type": null,
|
||||
"id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
"image_type": "png",
|
||||
@@ -109,7 +109,6 @@
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"refresh_token_duration_minutes": 43200,
|
||||
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
|
||||
"skip_consent": false
|
||||
},
|
||||
{
|
||||
@@ -118,7 +117,7 @@
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||
"credentials": "e30=",
|
||||
"credentials": "eyJzZWNyZXRzIjpbeyJpZCI6IjJmMWI4ZjFhLTFkM2UtNGYwYy05YzFhLTAwMDAwMDAwMDAwMiIsImFsZyI6InNoYTI1NiIsImhhc2giOiI4ODNiNzFjZjk1MWZlODk3ZTEwMjAxMTdiMTZjOGE2ZTY0MjllMWI5NGViZWY3ZGQ5NjM0OGJjZjc4ZThhMjI0IiwicHJlZml4IjoiUFlqciIsImNyZWF0ZWRBdCI6IjIwMjUtMTEtMjVUMTI6Mzk6MDJaIn1dfQ==",
|
||||
"dark_image_type": null,
|
||||
"id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
"image_type": null,
|
||||
@@ -135,7 +134,6 @@
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"refresh_token_duration_minutes": 43200,
|
||||
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe",
|
||||
"skip_consent": false
|
||||
},
|
||||
{
|
||||
@@ -144,7 +142,7 @@
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
"credentials": "e30=",
|
||||
"credentials": "eyJzZWNyZXRzIjpbeyJpZCI6IjJmMWI4ZjFhLTFkM2UtNGYwYy05YzFhLTAwMDAwMDAwMDAwMyIsImFsZyI6InNoYTI1NiIsImhhc2giOiIzZWY5MzU5YWVlNmRiYjNlNGNhOTYxMTQxZGY3ZjZiYTA2ZGJiOTE5MDBiMGM4MWZhNjk5NjZlNDU4ZjA3MGQ2IiwicHJlZml4IjoibjRWZiIsImNyZWF0ZWRBdCI6IjIwMjUtMTEtMjVUMTI6Mzk6MDJaIn1dfQ==",
|
||||
"dark_image_type": null,
|
||||
"id": "7c21a609-96b5-4011-9900-272b8d31a9d1",
|
||||
"image_type": null,
|
||||
@@ -161,7 +159,6 @@
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"refresh_token_duration_minutes": 43200,
|
||||
"secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a",
|
||||
"skip_consent": false
|
||||
},
|
||||
{
|
||||
@@ -170,7 +167,7 @@
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||
"credentials": "eyJmZWRlcmF0ZWRJZGVudGl0aWVzIjpbeyJpc3N1ZXIiOiJodHRwczovL2V4dGVybmFsLWlkcC5sb2NhbCIsInN1YmplY3QiOiJjNDgyMzJmZi1mZjY1LTQ1ZWQtYWU5Ni03YWZhOGE5YjQ0M2IiLCJhdWRpZW5jZSI6ImFwaTovL1BvY2tldElEIiwiandrcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6MTQxMS9hcGkvZXh0ZXJuYWxpZHAvandrcy5qc29uIn1dfQ==",
|
||||
"credentials": "eyJmZWRlcmF0ZWRJZGVudGl0aWVzIjpbeyJpc3N1ZXIiOiJodHRwczovL2V4dGVybmFsLWlkcC5sb2NhbCIsInN1YmplY3QiOiJjNDgyMzJmZi1mZjY1LTQ1ZWQtYWU5Ni03YWZhOGE5YjQ0M2IiLCJhdWRpZW5jZSI6ImFwaTovL1BvY2tldElEIiwiandrcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6MTQxMS9hcGkvZXh0ZXJuYWxpZHAvandrcy5qc29uIn1dLCJzZWNyZXRzIjpbeyJpZCI6IjJmMWI4ZjFhLTFkM2UtNGYwYy05YzFhLTAwMDAwMDAwMDAwNCIsImFsZyI6InNoYTI1NiIsImhhc2giOiI4ODNiNzFjZjk1MWZlODk3ZTEwMjAxMTdiMTZjOGE2ZTY0MjllMWI5NGViZWY3ZGQ5NjM0OGJjZjc4ZThhMjI0IiwicHJlZml4IjoiUFlqciIsImNyZWF0ZWRBdCI6IjIwMjUtMTEtMjVUMTI6Mzk6MDJaIn1dfQ==",
|
||||
"dark_image_type": null,
|
||||
"id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||
"image_type": null,
|
||||
@@ -187,7 +184,6 @@
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"refresh_token_duration_minutes": 43200,
|
||||
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe",
|
||||
"skip_consent": false
|
||||
},
|
||||
{
|
||||
@@ -212,8 +208,8 @@
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"refresh_token_duration_minutes": 43200,
|
||||
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK",
|
||||
"skip_consent": false
|
||||
"skip_consent": false,
|
||||
"credentials": "eyJzZWNyZXRzIjpbeyJpZCI6IjJmMWI4ZjFhLTFkM2UtNGYwYy05YzFhLTAwMDAwMDAwMDAwNSIsImFsZyI6InNoYTI1NiIsImhhc2giOiJiZWM3ZjA4YTI4MDhlZmNkYmU2ZmIwYjFmM2EwZjFhMDFjNjYwMGQxMTc1MjYyOTNjMTlhOWRlZDNkOGVjMGZiIiwicHJlZml4IjoiblFiaSIsImNyZWF0ZWRBdCI6IjIwMjUtMTEtMjVUMTI6Mzk6MDJaIn1dfQ=="
|
||||
},
|
||||
{
|
||||
"access_token_duration_minutes": 60,
|
||||
@@ -221,7 +217,7 @@
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
"credentials": "e30=",
|
||||
"credentials": "eyJzZWNyZXRzIjpbeyJpZCI6IjJmMWI4ZjFhLTFkM2UtNGYwYy05YzFhLTAwMDAwMDAwMDAwNiIsImFsZyI6InNoYTI1NiIsImhhc2giOiJkZjMzMWMxOGRhMTM0NDMwOTRkYzJjODQ5NzFkNWNlZmIzNDY0ZTk3OTk5YzM5YmMwMzg0NWVhY2NjNjE3MGEyIiwicHJlZml4IjoidzJtVSIsImNyZWF0ZWRBdCI6IjIwMjUtMTEtMjVUMTI6Mzk6MDJaIn1dfQ==",
|
||||
"dark_image_type": null,
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
|
||||
"image_type": null,
|
||||
@@ -238,7 +234,6 @@
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"refresh_token_duration_minutes": 43200,
|
||||
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
|
||||
"skip_consent": false
|
||||
},
|
||||
{
|
||||
@@ -247,7 +242,7 @@
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
"credentials": "e30=",
|
||||
"credentials": "eyJzZWNyZXRzIjpbeyJpZCI6IjJmMWI4ZjFhLTFkM2UtNGYwYy05YzFhLTAwMDAwMDAwMDAwNyIsImFsZyI6InNoYTI1NiIsImhhc2giOiJkZjMzMWMxOGRhMTM0NDMwOTRkYzJjODQ5NzFkNWNlZmIzNDY0ZTk3OTk5YzM5YmMwMzg0NWVhY2NjNjE3MGEyIiwicHJlZml4IjoidzJtVSIsImNyZWF0ZWRBdCI6IjIwMjUtMTEtMjVUMTI6Mzk6MDJaIn1dfQ==",
|
||||
"dark_image_type": null,
|
||||
"id": "e1f2a3b4-c5d6-7890-abcd-ef0000000002",
|
||||
"image_type": null,
|
||||
@@ -264,7 +259,6 @@
|
||||
"requires_pushed_authorization_requests": false,
|
||||
"requires_reauthentication": false,
|
||||
"refresh_token_duration_minutes": 43200,
|
||||
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
|
||||
"skip_consent": true
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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());
|
||||
|
||||
@@ -37,7 +38,6 @@ test.describe('Create OIDC client', () => {
|
||||
);
|
||||
|
||||
const resolvedClientId = (await page.getByTestId('client-id').innerText()).trim();
|
||||
const clientSecret = (await page.getByTestId('client-secret').innerText()).trim();
|
||||
|
||||
if (clientId) {
|
||||
expect(resolvedClientId).toBe(clientId);
|
||||
@@ -45,8 +45,6 @@ test.describe('Create OIDC client', () => {
|
||||
expect(resolvedClientId).toMatch(/^[\w-]{36}$/);
|
||||
}
|
||||
|
||||
expect(clientSecret).toMatch(/^\w{32}$/);
|
||||
|
||||
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);
|
||||
@@ -166,7 +164,7 @@ test('Update OIDC client token lifetimes', async ({ page }) => {
|
||||
|
||||
test('Update OIDC client federated credentials', async ({ page }) => {
|
||||
const client = oidcClients.nextcloud;
|
||||
await page.goto(`/settings/admin/oidc-clients/${client.id}`);
|
||||
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();
|
||||
@@ -188,6 +186,7 @@ test('Update OIDC client federated credentials', async ({ page }) => {
|
||||
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');
|
||||
@@ -199,21 +198,87 @@ test('Update OIDC client federated credentials', async ({ page }) => {
|
||||
await clientForm.getByRole('button', { name: 'Save' }).click();
|
||||
expect((await formUpdate).ok()).toBeTruthy();
|
||||
|
||||
await page.reload();
|
||||
await page.goto(`/settings/admin/oidc-clients/${client.id}#credentials`);
|
||||
await expect(card.getByLabel('Issuer')).toHaveValue('https://issuer.example.com');
|
||||
});
|
||||
|
||||
test('Create new OIDC client secret', async ({ page }) => {
|
||||
test('Create and delete OIDC client secrets', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}#credentials`);
|
||||
|
||||
await page.getByLabel('Create new client secret').click();
|
||||
await page.getByRole('button', { name: 'Generate' }).click();
|
||||
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'
|
||||
);
|
||||
expect((await page.getByTestId('client-secret').textContent())?.length).toBe(32);
|
||||
|
||||
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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user