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
+16 -2
View File
@@ -8,6 +8,8 @@ import type {
OidcClient,
OidcClientCreate,
OidcClientMetaData,
OidcClientSecret,
OidcClientSecretCreated,
OidcClientUpdate,
OidcClientWithAllowedUserGroups,
OidcClientWithAllowedUserGroupsCount,
@@ -88,8 +90,20 @@ class OidcService extends APIService {
cachedOidcClientLogo.bustCache(id, light);
};
createClientSecret = async (id: string) =>
(await this.api.post(`/oidc/clients/${encodeClientIdParam(id)}/secret`)).data.secret as string;
listClientSecrets = async (id: string) =>
(await this.api.get(`/oidc/clients/${encodeClientIdParam(id)}/secrets`))
.data as OidcClientSecret[];
createClientSecret = async (id: string, expiresAt: Date | null) =>
(
await this.api.post(`/oidc/clients/${encodeClientIdParam(id)}/secrets`, {
expiresAt: expiresAt?.toISOString() ?? null
})
).data as OidcClientSecretCreated;
deleteClientSecret = async (id: string, secretId: string) => {
await this.api.delete(`/oidc/clients/${encodeClientIdParam(id)}/secrets/${secretId}`);
};
updateAllowedUserGroups = async (id: string, userGroupIds: string[]) => {
const res = await this.api.put(`/oidc/clients/${encodeClientIdParam(id)}/allowed-user-groups`, {
+15 -4
View File
@@ -1,17 +1,28 @@
import { writable } from 'svelte/store';
const clientSecretStore = writable<string | null>(null);
// Holds the clear-text value of the client secrets created during the current page visit, keyed by secret ID.
// The server never returns those values again, so they are shown until the user navigates away and then forgotten.
const clientSecretStore = writable<Record<string, string>>({});
const set = (user: string) => {
clientSecretStore.set(user);
const set = (secretId: string, secret: string) => {
clientSecretStore.update((secrets) => ({ ...secrets, [secretId]: secret }));
};
const remove = (secretId: string) => {
clientSecretStore.update((secrets) => {
const remaining = { ...secrets };
delete remaining[secretId];
return remaining;
});
};
const clear = () => {
clientSecretStore.set(null);
clientSecretStore.set({});
};
export default {
subscribe: clientSecretStore.subscribe,
set,
remove,
clear
};
+15
View File
@@ -21,8 +21,23 @@ export type OidcClientFederatedIdentity = {
replayProtection: boolean;
};
export type OidcClientSecret = {
id: string;
// The first characters of the secret, empty for secrets created before Pocket ID supported multiple secrets
prefix: string;
createdAt: string;
expiresAt: string | null;
isActive: boolean;
};
// The clear-text value of a secret is only returned when it is created and cannot be retrieved afterwards
export type OidcClientSecretCreated = OidcClientSecret & {
secret: string;
};
export type OidcClientCredentials = {
federatedIdentities: OidcClientFederatedIdentity[];
secrets: OidcClientSecret[];
};
export type OidcDiscoveryConfiguration = {
@@ -32,10 +32,7 @@
: Promise.resolve();
await Promise.all([logoPromise, darkLogoPromise]);
if (!createdClient.isPublic) {
const clientSecret = await oidcService.createClientSecret(createdClient.id);
clientSecretStore.set(clientSecret);
}
// A new client starts without any secret: the admin creates the ones they need from the credentials tab
goto(`/settings/admin/oidc-clients/${encodeClientIdParam(createdClient.id)}`);
toast.success(m.oidc_client_created_successfully());
return true;
@@ -16,17 +16,14 @@
import type {
OidcClientCreateWithLogo,
OidcClientCredentials,
OidcClientFederatedIdentity,
OidcClientSecret,
OidcClientTokenLifetimes
} from '$lib/types/oidc.type';
import type { ScimServiceProviderCreate } from '$lib/types/scim.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import {
LucideChevronLeft,
LucideInfo,
LucideRefreshCcw,
LucideTriangleAlert
} from '@lucide/svelte';
import { LucideChevronLeft, LucideInfo, LucideTriangleAlert } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import { backNavigate } from '../../users/navigate-back-util';
@@ -34,6 +31,7 @@
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
import ApiAccessCard from './api-access-card.svelte';
import OidcClientFederatedCredentialsCard from './oidc-client-federated-credentials-card.svelte';
import OidcClientSecretsCard from './oidc-client-secrets-card.svelte';
import OidcClientTokenLifetimesCard from './oidc-client-token-lifetimes-card.svelte';
import ScimResourceProviderForm from './scim-resource-provider-form.svelte';
@@ -43,6 +41,9 @@
allowedUserGroupIds: data.client.allowedUserGroups.map((g) => g.id)
});
// Secrets are managed by their own endpoints, so they are kept out of the client object that the forms below submit
let clientSecrets = $state<OidcClientSecret[]>(data.client.credentials?.secrets ?? []);
let scimServiceProvider = $state(data.scimServiceProvider);
let showAllDetails = $state(false);
let showPreview = $state(false);
@@ -125,7 +126,9 @@
return success;
}
async function updateFederatedCredentials(credentials: OidcClientCredentials) {
async function updateFederatedCredentials(federatedIdentities: OidcClientFederatedIdentity[]) {
// Secrets are read-only in this request, but they are carried over so the client object keeps matching what the server has
const credentials: OidcClientCredentials = { federatedIdentities, secrets: clientSecrets };
const success = await updateClient({ ...client, credentials });
if (success) {
client.credentials = credentials;
@@ -174,26 +177,6 @@
});
}
async function createClientSecret() {
openConfirmDialog({
title: m.create_new_client_secret(),
message: m.are_you_sure_you_want_to_create_a_new_client_secret(),
confirm: {
label: m.generate(),
destructive: true,
action: async () => {
try {
const clientSecret = await oidcService.createClientSecret(client.id);
clientSecretStore.set(clientSecret);
toast.success(m.new_client_secret_created_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
async function updateUserGroupClients(allowedGroups: string[]) {
await oidcService
.updateAllowedUserGroups(client.id, allowedGroups)
@@ -281,6 +264,7 @@
<LucideTriangleAlert class="ml-0.5 size-4 text-yellow-600 dark:text-yellow-400" />
{/if}</Tabs.Trigger
>
<Tabs.Trigger value="credentials">{m.credentials()}</Tabs.Trigger>
<Tabs.Trigger value="api-access">{m.api_access()}</Tabs.Trigger>
<Tabs.Trigger value="scim">{m.scim_provisioning()}</Tabs.Trigger>
<Tabs.Trigger value="preview">{m.oidc_data_preview()}</Tabs.Trigger>
@@ -302,32 +286,6 @@
</span>
</CopyToClipboard>
</div>
{#if !client.isPublic}
<div class="mt-1 mb-2 flex flex-col sm:flex-row sm:items-center">
<Field.Label class="w-52">{m.client_secret()}</Field.Label>
{#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret">
{$clientSecretStore}
</span>
</CopyToClipboard>
{:else}
<div>
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>••••••••••••••••••••••••••••••••</span
>
<Button
class="ml-2"
onclick={createClientSecret}
size="sm"
variant="ghost"
aria-label="Create new client secret"
><LucideRefreshCcw class="size-3" /></Button
>
</div>
{/if}
</div>
{/if}
{#if showAllDetails}
<div transition:slide>
{#each Object.entries(setupDetails) as [key, value] (key)}
@@ -359,6 +317,10 @@
</Card.Root>
<OidcClientTokenLifetimesCard {client} callback={updateTokenLifetimes} />
</Tabs.Content>
<Tabs.Content value="credentials" id="credentials" class="flex flex-col gap-4">
<OidcClientSecretsCard {client} bind:secrets={clientSecrets} />
<OidcClientFederatedCredentialsCard {client} callback={updateFederatedCredentials} />
</Tabs.Content>
@@ -2,7 +2,7 @@
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import type { OidcClient, OidcClientCredentials } from '$lib/types/oidc.type';
import type { OidcClient, OidcClientFederatedIdentity } from '$lib/types/oidc.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { slide } from 'svelte/transition';
@@ -14,7 +14,7 @@
callback
}: {
client: OidcClient;
callback: (credentials: OidcClientCredentials) => Promise<boolean>;
callback: (federatedIdentities: OidcClientFederatedIdentity[]) => Promise<boolean>;
} = $props();
let isLoading = $state(false);
@@ -72,7 +72,7 @@
if (!data) return;
isLoading = true;
await callback(data.credentials).finally(() => (isLoading = false));
await callback(data.credentials.federatedIdentities).finally(() => (isLoading = false));
}
</script>
@@ -0,0 +1,195 @@
<script lang="ts">
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button, buttonVariants } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Select from '$lib/components/ui/select';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import OidcService from '$lib/services/oidc-service';
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClient, OidcClientSecret } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideEllipsis, LucidePlus, LucideTrash2 } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
let {
client,
secrets = $bindable()
}: {
client: OidcClient;
secrets: OidcClientSecret[];
} = $props();
const oidcService = new OidcService();
// Mirrors model.MaxOidcClientSecrets on the backend, which rejects any secret above this count
const maxSecrets = 20;
// A secret can only be given an expiration when it is created, so this is not bound to any existing secret
const expirationOptions = [
{ value: 'never', label: m.no_expiration() },
{ value: '30', label: m.days_count({ count: '30' }) },
{ value: '90', label: m.days_count({ count: '90' }) },
{ value: '180', label: m.days_count({ count: '180' }) },
{ value: '360', label: m.days_count({ count: '360' }) },
{ value: '720', label: m.days_count({ count: '720' }) }
];
let expiration = $state('never');
let isCreating = $state(false);
const selectedExpirationLabel = $derived(
expirationOptions.find((option) => option.value === expiration)!.label
);
const limitReached = $derived(secrets.length >= maxSecrets);
function formatDate(date: string | null) {
if (!date) return m.never();
return new Date(date).toLocaleString();
}
function expirationDate() {
if (expiration === 'never') return null;
const millisecondsPerDay = 24 * 60 * 60 * 1000;
return new Date(Date.now() + Number(expiration) * millisecondsPerDay);
}
async function createSecret() {
isCreating = true;
try {
const created = await oidcService.createClientSecret(client.id, expirationDate());
// The value is kept in the store, so it survives switching between tabs but not leaving the page
clientSecretStore.set(created.id, created.secret);
secrets = [...secrets, created];
expiration = 'never';
toast.success(m.new_client_secret_created_successfully());
} catch (e) {
axiosErrorToast(e);
} finally {
isCreating = false;
}
}
function deleteSecret(secret: OidcClientSecret) {
openConfirmDialog({
title: m.delete_client_secret(),
message: m.are_you_sure_you_want_to_delete_this_client_secret(),
confirm: {
label: m.delete(),
destructive: true,
action: async () => {
try {
await oidcService.deleteClientSecret(client.id, secret.id);
clientSecretStore.remove(secret.id);
secrets = secrets.filter((s) => s.id !== secret.id);
toast.success(m.client_secret_deleted_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
}
});
}
</script>
<Card.Root data-testid="client-secrets-card">
<Card.Header>
<Card.Title>{m.client_secrets()}</Card.Title>
<Card.Description>{m.client_secrets_description()}</Card.Description>
</Card.Header>
<Card.Content>
{#if client.isPublic}
<p class="text-muted-foreground text-sm">{m.public_clients_cannot_have_secrets()}</p>
{:else}
{#if secrets.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{m.client_secret()}</Table.Head>
<Table.Head>{m.created()}</Table.Head>
<Table.Head>{m.expires_at()}</Table.Head>
<Table.Head class="w-12"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each secrets as secret (secret.id)}
{@const revealed = $clientSecretStore[secret.id]}
<Table.Row data-testid="client-secret-row">
<Table.Cell>
{#if revealed}
<CopyToClipboard value={revealed}>
<span class="font-mono text-sm break-all" data-testid="client-secret">
{revealed}
</span>
</CopyToClipboard>
{:else}
<span
class="text-muted-foreground font-mono text-sm"
data-testid="client-secret">{secret.prefix}{'•'.repeat(8)}</span
>
{/if}
{#if !secret.isActive}
<Badge class="ml-2" variant="destructive">{m.expired()}</Badge>
{/if}
</Table.Cell>
<Table.Cell class="text-muted-foreground text-sm"
>{formatDate(secret.createdAt)}</Table.Cell
>
<Table.Cell class="text-muted-foreground text-sm"
>{formatDate(secret.expiresAt)}</Table.Cell
>
<Table.Cell class="text-right">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
>
<LucideEllipsis class="size-4" />
<span class="sr-only">{m.toggle_menu()}</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item class="text-red-500!" onclick={() => deleteSecret(secret)}>
<LucideTrash2 class="mr-2 size-4" />
{m.delete()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else}
<p class="text-muted-foreground text-sm">{m.no_client_secrets_yet()}</p>
{/if}
<div class="mt-5 flex flex-col justify-end gap-3 sm:flex-row sm:items-center">
<Select.Root type="single" bind:value={expiration}>
<Select.Trigger
class="w-full sm:w-48"
aria-label={m.expiration()}
placeholder={m.expiration()}
>
{selectedExpirationLabel}
</Select.Trigger>
<Select.Content>
{#each expirationOptions as option (option.value)}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Button onclick={createSecret} disabled={limitReached || isCreating}>
<LucidePlus class="mr-2 size-4" />
{m.add_client_secret()}
</Button>
</div>
{#if limitReached}
<p class="text-muted-foreground mt-2 text-end text-xs">
{m.client_secrets_limit_reached()}
</p>
{/if}
{/if}
</Card.Content>
</Card.Root>
@@ -109,7 +109,7 @@
const success = await callback({
...data,
credentials: existingClient?.credentials ?? { federatedIdentities: [] },
credentials: existingClient?.credentials ?? { federatedIdentities: [], secrets: [] },
logo: $inputs.logoUrl?.value ? undefined : logo,
logoUrl: $inputs.logoUrl?.value,
darkLogo: $inputs.darkLogoUrl?.value ? undefined : darkLogo,