feat: implement OAuth Client ID Metadata Document (#1525) (#1526)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Jean-François Roy
2026-08-02 15:05:39 +00:00
committed by GitHub
co-authored by Elias Schneider
parent 7c55bdf115
commit 1934efa84c
67 changed files with 2311 additions and 217 deletions
+3 -4
View File
@@ -14,6 +14,7 @@
import userStore from '$lib/stores/user-store';
import type { DeviceLoginVerificationInfo } from '$lib/types/device-login.type';
import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type';
import { getClientIDHost } from '$lib/utils/client-id-util';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { startAuthentication } from '@simplewebauthn/browser';
@@ -209,7 +210,7 @@
<FormattedMessage
message={m.do_you_want_to_sign_in_to_client_with_your_app_name_account}
inputs={{
client: deviceInfo.client.name,
client: getClientIDHost(deviceInfo!.client) ?? deviceInfo!.client.name,
appName: $appConfigStore.appName
}}
/>
@@ -221,9 +222,7 @@
<Card.Description class="text-start">
<FormattedMessage
message={m.client_wants_to_access_the_following_information}
inputs={{
client: deviceInfo!.client.name
}}
inputs={{ client: getClientIDHost(deviceInfo!.client) ?? deviceInfo!.client.name }}
/>
</Card.Description>
</Card.Header>
+6 -3
View File
@@ -13,6 +13,7 @@
import userStore from '$lib/stores/user-store';
import type { InteractionStep } from '$lib/types/oidc.type';
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { getClientIDHost } from '$lib/utils/client-id-util';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
import { slide } from 'svelte/transition';
@@ -121,13 +122,15 @@
{:else if currentStep == 'select_account' && $userStore}
<FormattedMessage
message={m.account_selection_signin_confirmation}
inputs={{ name: interactionSession.client.name }}
inputs={{
name: getClientIDHost(interactionSession.client) ?? interactionSession.client.name
}}
/>
{:else}
<FormattedMessage
message={m.do_you_want_to_sign_in_to_client_with_your_app_name_account}
inputs={{
client: interactionSession.client.name,
client: getClientIDHost(interactionSession.client) ?? interactionSession.client.name,
appName: $appConfigStore.appName
}}
/>
@@ -178,7 +181,7 @@
<FormattedMessage
message={m.client_wants_to_access_the_following_information}
inputs={{
client: interactionSession.client.name
client: getClientIDHost(interactionSession.client) ?? interactionSession.client.name
}}
/>
</p>
@@ -24,7 +24,7 @@
title: m.login_code(),
description: m.enter_a_login_code_to_sign_in(),
href: '/login/alternative/code'
},
}
];
if ($appConfigStore.emailOneTimeAccessAsUnauthenticatedEnabled) {
@@ -9,6 +9,7 @@
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideInfo } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import AppConfigDynamicClientsForm from './forms/app-config-dynamic-clients-form.svelte';
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import AppConfigLdapForm from './forms/app-config-ldap-form.svelte';
@@ -110,6 +111,9 @@
<Tabs.Trigger value="ldap">
{m.ldap()}
</Tabs.Trigger>
<Tabs.Trigger value="oidc">
{m.oidc()}
</Tabs.Trigger>
<Tabs.Trigger value="images">
{m.images()}
</Tabs.Trigger>
@@ -165,6 +169,18 @@
</Card.Root>
</Tabs.Content>
<Tabs.Content value="oidc" id="application-configuration-oidc">
<Card.Root>
<Card.Header>
<Card.Title>{m.client_id_metadata_documents()}</Card.Title>
<Card.Description>{m.client_id_metadata_documents_description()}</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigDynamicClientsForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="images" id="application-configuration-images">
<Card.Root>
<Card.Header>
@@ -0,0 +1,44 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import UrlListInput from '$lib/components/form/url-list-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration.type';
import { preventDefault } from '$lib/utils/event-util';
import { toast } from 'svelte-sonner';
let {
appConfig,
callback
}: {
appConfig: AllAppConfig;
callback: (updatedConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let cimdUrlAllowlist: string[] = $derived(appConfig.cimdUrlAllowlist || []);
let isLoading = $state(false);
async function onSubmit() {
isLoading = true;
const update: Partial<AllAppConfig> = {
cimdUrlAllowlist: cimdUrlAllowlist.filter((u) => u.trim() !== '')
};
await callback(update).finally(() => (isLoading = false));
toast.success(m.application_configuration_updated_successfully());
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
<fieldset class="flex flex-col gap-5" disabled={$appConfigStore.uiConfigDisabled}>
<FormInput label={m.cimd_url_allowlist()} description={m.cimd_url_allowlist_description()}>
<UrlListInput bind:urls={cimdUrlAllowlist} testIdPrefix="cimd-url-allowlist" />
</FormInput>
<div class="flex justify-end pt-2">
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</fieldset>
</form>
@@ -7,6 +7,7 @@
import appConfigStore from '$lib/stores/application-configuration-store';
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { encodeClientIdParam } from '$lib/utils/client-id-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus, ShieldCheck, ShieldPlus } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
@@ -20,6 +21,7 @@
async function createOIDCClient(client: OidcClientCreateWithLogo) {
try {
clientSecretStore.clear();
const createdClient = await oidcService.createClient(client);
const logoPromise = client.logo
@@ -30,9 +32,11 @@
: Promise.resolve();
await Promise.all([logoPromise, darkLogoPromise]);
const clientSecret = await oidcService.createClientSecret(createdClient.id);
clientSecretStore.set(clientSecret);
goto(`/settings/admin/oidc-clients/${createdClient.id}`);
if (!createdClient.isPublic) {
const clientSecret = await oidcService.createClientSecret(createdClient.id);
clientSecretStore.set(clientSecret);
}
goto(`/settings/admin/oidc-clients/${encodeClientIdParam(createdClient.id)}`);
toast.success(m.oidc_client_created_successfully());
return true;
} catch (e) {
@@ -232,6 +232,16 @@
</Alert.Root>
{/if}
{#if client.clientType === 'cimd'}
<Alert.Root variant="info">
<LucideInfo class="size-4" />
<Alert.Title>{m.cimd_client_managed_fields_title()}</Alert.Title>
<Alert.Description>
{m.cimd_client_managed_fields_description()}
</Alert.Description>
</Alert.Root>
{/if}
<div>
<button type="button" class="text-muted-foreground flex text-sm" onclick={backNavigation.go}
><LucideChevronLeft class="size-5" /> {m.back()}</button
@@ -1,13 +1,15 @@
import OidcService from '$lib/services/oidc-service';
import type { OidcDiscoveryConfiguration } from '$lib/types/oidc.type';
import { decodeClientIdParam } from '$lib/utils/client-id-util';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ fetch, params }) => {
const oidcService = new OidcService();
const id = decodeClientIdParam(params.id);
const clientPromise = oidcService.getClient(params.id);
const clientPromise = oidcService.getClient(id);
const scimServiceProviderPromise = oidcService
.getScimResourceProvider(params.id)
.getScimResourceProvider(id)
.then((p) => p)
.catch(() => undefined);
const oidcConfigurationPromise = fetch('/.well-known/openid-configuration').then(
@@ -14,10 +14,12 @@
let {
federatedIdentities = $bindable([]),
errors,
disabled = false,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
federatedIdentities: OidcClientFederatedIdentity[];
errors?: z.core.$ZodIssue[];
disabled?: boolean;
children?: Snippet;
} = $props();
@@ -62,6 +64,7 @@
label={m.federated_client_credentials()}
description={m.federated_client_credentials_description()}
docsLink="https://pocket-id.org/docs/guides/oidc-client-authentication"
{disabled}
>
<div class="space-y-4">
{#each federatedIdentities as identity, i (identity)}
@@ -74,6 +77,7 @@
size="sm"
onclick={() => removeFederatedIdentity(i)}
aria-label="Remove federated identity"
{disabled}
>
<LucideMinus class="size-4" />
</Button>
@@ -89,6 +93,7 @@
value={identity.issuer}
oninput={(e) => updateFederatedIdentity(i, 'issuer', e.currentTarget.value)}
aria-invalid={!!getFieldError(i, 'issuer')}
{disabled}
/>
{#if getFieldError(i, 'issuer')}
<Field.Error>{getFieldError(i, 'issuer')}</Field.Error>
@@ -103,6 +108,7 @@
value={identity.subject || ''}
oninput={(e) => updateFederatedIdentity(i, 'subject', e.currentTarget.value)}
aria-invalid={!!getFieldError(i, 'subject')}
{disabled}
/>
{#if getFieldError(i, 'subject')}
<Field.Error>{getFieldError(i, 'subject')}</Field.Error>
@@ -117,6 +123,7 @@
value={identity.audience || ''}
oninput={(e) => updateFederatedIdentity(i, 'audience', e.currentTarget.value)}
aria-invalid={!!getFieldError(i, 'audience')}
{disabled}
/>
{#if getFieldError(i, 'audience')}
<Field.Error>{getFieldError(i, 'audience')}</Field.Error>
@@ -131,6 +138,7 @@
value={identity.jwks || ''}
oninput={(e) => updateFederatedIdentity(i, 'jwks', e.currentTarget.value)}
aria-invalid={!!getFieldError(i, 'jwks')}
{disabled}
/>
{#if getFieldError(i, 'jwks')}
<Field.Error>{getFieldError(i, 'jwks')}</Field.Error>
@@ -142,6 +150,7 @@
description={m.replay_protection_description()}
checked={identity.replayProtection}
onCheckedChange={(checked) => updateFederatedIdentity(i, 'replayProtection', checked)}
{disabled}
/>
</div>
</div>
@@ -149,7 +158,14 @@
</div>
</FormInput>
<Button class="mt-3" variant="secondary" size="sm" onclick={addFederatedIdentity} type="button">
<Button
class="mt-3"
variant="secondary"
size="sm"
onclick={addFederatedIdentity}
type="button"
{disabled}
>
<LucidePlus class="mr-1 size-4" />
{federatedIdentities.length === 0
? m.add_federated_client_credential()
@@ -1,10 +1,7 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import UrlListInput from '$lib/components/form/url-list-input.svelte';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
import { m } from '$lib/paraglide/messages';
import { LucideMinus, LucidePlus } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
@@ -13,50 +10,23 @@
description,
callbackURLs = $bindable(),
error = $bindable(null),
disabled = false,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
label: string;
description: string | Snippet;
callbackURLs: string[];
error?: string | null;
disabled?: boolean;
children?: Snippet;
} = $props();
</script>
<div {...restProps}>
<FormInput {label} {description}>
<div class="flex flex-col gap-y-2">
{#each callbackURLs.keys() as i (i)}
<div class="flex gap-x-2">
<Input
aria-invalid={!!error}
data-testid={`callback-url-${i + 1}`}
type="text"
inputmode="url"
autocomplete="url"
bind:value={callbackURLs[i]}
/>
<Button
variant="outline"
size="sm"
onclick={() => (callbackURLs = callbackURLs.filter((_, index) => index !== i))}
>
<LucideMinus class="size-4" />
</Button>
</div>
{/each}
</div>
<FormInput {label} {description} {disabled}>
<UrlListInput bind:urls={callbackURLs} {error} {disabled} testIdPrefix="callback-url" />
</FormInput>
{#if error}
<Field.Error>{error}</Field.Error>
{/if}
<Button
class="mt-2"
variant="secondary"
size="sm"
onclick={() => (callbackURLs = [...callbackURLs, ''])}
>
<LucidePlus class="mr-1 size-4" />
{callbackURLs.length === 0 ? m.add() : m.add_another()}
</Button>
</div>
@@ -41,6 +41,7 @@
let darkLogoDataURL: string | null = $state(
existingClient?.hasDarkLogo ? cachedOidcClientLogo.getUrl(existingClient!.id, false) : null
);
const isCIMDClient = $derived(existingClient?.clientType === 'cimd');
const client = {
id: '',
@@ -202,6 +203,7 @@
class="w-full"
description={m.client_name_description()}
bind:input={$inputs.name}
disabled={isCIMDClient}
/>
<FormInput
label={m.client_description()}
@@ -222,6 +224,7 @@
class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error}
disabled={isCIMDClient}
/>
<OidcCallbackUrlInput
label={m.logout_callback_urls()}
@@ -229,6 +232,7 @@
class="w-full"
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
bind:error={$inputs.logoutCallbackURLs.error}
disabled={isCIMDClient}
/>
<div>
<SwitchWithLabel
@@ -241,6 +245,7 @@
}
}}
bind:checked={$inputs.isPublic.value}
disabled={isCIMDClient}
/>
</div>
<div
@@ -252,7 +257,7 @@
id="pkce"
label={m.pkce()}
description={m.proof_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}
disabled={$inputs.isPublic.value}
disabled={isCIMDClient || $inputs.isPublic.value}
bind:checked={$inputs.pkceEnabled.value}
/>
</div>
@@ -334,6 +339,7 @@
<FederatedIdentitiesInput
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
errors={getFederatedIdentityErrors($errors)}
disabled={isCIMDClient}
/>
</div>
{/if}
@@ -11,8 +11,9 @@
} from '$lib/types/advanced-table.type';
import type { OidcClient, OidcClientWithAllowedUserGroupsCount } from '$lib/types/oidc.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { encodeClientIdParam } from '$lib/utils/client-id-util';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucidePencil, LucideTrash } from '@lucide/svelte';
import { LucidePencil, LucideRefreshCcw, LucideTrash } from '@lucide/svelte';
import { mode } from 'mode-watcher';
import { toast } from 'svelte-sonner';
@@ -30,6 +31,11 @@
{ label: m.no(), value: false }
];
const clientTypeFilterValues = [
{ label: m.client_type_standard(), value: 'standard' },
{ label: m.client_type_metadata_document(), value: 'cimd' }
];
const columns: AdvancedTableColumn<OidcClientWithAllowedUserGroupsCount>[] = [
{ label: 'ID', column: 'id', hidden: true },
{ label: m.logo(), key: 'logo', cell: LogoCell },
@@ -46,6 +52,14 @@
sortable: true,
filterableValues: booleanFilterValues
},
{
label: m.client_type(),
column: 'clientType',
sortable: true,
filterableValues: clientTypeFilterValues,
value: (item) =>
item.clientType === 'cimd' ? m.client_type_metadata_document() : m.client_type_standard()
},
{
label: m.pkce(),
column: 'pkceEnabled',
@@ -79,12 +93,18 @@
}
];
const actions: CreateAdvancedTableActions<OidcClientWithAllowedUserGroupsCount> = () => [
const actions: CreateAdvancedTableActions<OidcClientWithAllowedUserGroupsCount> = (client) => [
{
label: m.edit(),
primary: true,
icon: LucidePencil,
onClick: (client) => goto(`/settings/admin/oidc-clients/${client.id}`)
onClick: (client) => goto(`/settings/admin/oidc-clients/${encodeClientIdParam(client.id)}`)
},
{
label: m.refresh(),
icon: LucideRefreshCcw,
hidden: client.clientType !== 'cimd',
onClick: (client) => refreshClient(client)
},
{
label: m.delete(),
@@ -94,6 +114,16 @@
}
];
async function refreshClient(client: OidcClient) {
try {
await oidcService.refreshClient(client.id);
await refresh();
toast.success(m.oidc_client_metadata_refreshed_successfully());
} catch (e) {
axiosErrorToast(e);
}
}
async function deleteClient(client: OidcClient) {
openConfirmDialog({
title: m.delete_name({ name: client.name }),
@@ -9,6 +9,7 @@
import userStore from '$lib/stores/user-store';
import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type';
import { cachedApplicationLogo, cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { encodeClientIdParam } from '$lib/utils/client-id-util';
import {
LucideBan,
LucideEllipsisVertical,
@@ -79,7 +80,8 @@
<DropdownMenu.Content align="end">
{#if $userStore?.isAdmin}
<DropdownMenu.Item
onclick={() => goto(`/settings/admin/oidc-clients/${client.id}`)}
onclick={() =>
goto(`/settings/admin/oidc-clients/${encodeClientIdParam(client.id)}`)}
><LucidePencil class="mr-2 size-4" /> {m.edit()}</DropdownMenu.Item
>
{/if}