feat: support multiple client secrets per OIDC client (#1679)

This commit is contained in:
Alessandro (Ale) Segala
2026-08-11 00:54:52 +00:00
committed by GitHub
parent 03498e2f51
commit 155a1fcba0
29 changed files with 1353 additions and 165 deletions
+9 -15
View File
@@ -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
}
],
+75 -10
View File
@@ -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 }) => {