mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-22 10:59:05 +02:00
feat: support multiple client secrets per OIDC client (#1679)
This commit is contained in:
@@ -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`, {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user