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
@@ -6,7 +6,7 @@ import ConfirmDialog from './confirm-dialog.svelte';
interface ConfirmDialogState {
open: boolean;
title: string;
message: string | AnyFormattedMessage;
message: string | AnyFormattedMessage;
confirm: {
label: string;
destructive: boolean;
@@ -0,0 +1,55 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { m } from '$lib/paraglide/messages';
import { LucideMinus, LucidePlus } from '@lucide/svelte';
let {
urls = $bindable(),
error = null,
testIdPrefix = 'url',
disabled = false
}: {
urls: string[];
error?: string | null;
testIdPrefix?: string;
disabled?: boolean;
} = $props();
</script>
<div>
<div class="flex flex-col gap-y-2">
{#each urls as url, i (i)}
<div class="flex gap-x-2">
<Input
aria-invalid={!!error}
data-testid={`${testIdPrefix}-${i + 1}`}
type="text"
inputmode="url"
autocomplete="url"
bind:value={urls[i]}
{disabled}
/>
<Button
variant="outline"
size="sm"
aria-label={m.remove_url({ identifier: url || i + 1 })}
onclick={() => (urls = urls.filter((_, index) => index !== i))}
{disabled}
>
<LucideMinus class="size-4" />
</Button>
</div>
{/each}
</div>
<Button
class="mt-2"
variant="secondary"
size="sm"
onclick={() => (urls = [...urls, ''])}
{disabled}
>
<LucidePlus class="mr-1 size-4" />
{urls.length === 0 ? m.add() : m.add_another()}
</Button>
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
@@ -13,7 +13,7 @@
<p
bind:this={ref}
data-slot="card-description"
class={cn('text-muted-foreground text-sm mt-3', className)}
class={cn('text-muted-foreground text-sm mt-1', className)}
{...restProps}
>
{@render children?.()}
+3 -2
View File
@@ -6,6 +6,7 @@ import type {
ClientApiAccess
} from '$lib/types/api.type';
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import { encodeClientIdParam } from '$lib/utils/client-id-util';
import APIService from './api-service';
export default class ApisService extends APIService {
@@ -44,12 +45,12 @@ export default class ApisService extends APIService {
};
getClientAccess = async (clientId: string) => {
const res = await this.api.get(`/api-access/${clientId}`);
const res = await this.api.get(`/api-access/${encodeClientIdParam(clientId)}`);
return res.data as ClientApiAccess;
};
updateClientAccess = async (clientId: string, access: ClientApiAccess) => {
const res = await this.api.put(`/api-access/${clientId}`, access);
const res = await this.api.put(`/api-access/${encodeClientIdParam(clientId)}`, access);
return res.data as ClientApiAccess;
};
}
+27 -13
View File
@@ -14,6 +14,7 @@ import type {
} from '$lib/types/oidc.type';
import type { ScimServiceProvider } from '$lib/types/scim.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import { encodeClientIdParam } from '$lib/utils/client-id-util';
import APIService from './api-service';
class OidcService extends APIService {
@@ -41,17 +42,23 @@ class OidcService extends APIService {
(await this.api.post('/oidc/clients', client)).data as OidcClient;
removeClient = async (id: string) => {
await this.api.delete(`/oidc/clients/${id}`);
await this.api.delete(`/oidc/clients/${encodeClientIdParam(id)}`);
};
getClient = async (id: string) =>
(await this.api.get(`/oidc/clients/${id}`)).data as OidcClientWithAllowedUserGroups;
(await this.api.get(`/oidc/clients/${encodeClientIdParam(id)}`))
.data as OidcClientWithAllowedUserGroups;
getClientMetaData = async (id: string) =>
(await this.api.get(`/oidc/clients/${id}/meta`)).data as OidcClientMetaData;
(await this.api.get(`/oidc/clients/${encodeClientIdParam(id)}/meta`))
.data as OidcClientMetaData;
updateClient = async (id: string, client: OidcClientUpdate) =>
(await this.api.put(`/oidc/clients/${id}`, client)).data as OidcClient;
(await this.api.put(`/oidc/clients/${encodeClientIdParam(id)}`, client)).data as OidcClient;
refreshClient = async (id: string) =>
(await this.api.post(`/oidc/clients/${encodeClientIdParam(id)}/refresh`))
.data as OidcClientWithAllowedUserGroups;
updateClientLogo = async (client: OidcClient, image: File | null, light: boolean = true) => {
const hasLogo = light ? client.hasLogo : client.hasDarkLogo;
@@ -67,24 +74,26 @@ class OidcService extends APIService {
const formData = new FormData();
formData.append('file', image!);
await this.api.post(`/oidc/clients/${client.id}/logo`, formData, {
await this.api.post(`/oidc/clients/${encodeClientIdParam(client.id)}/logo`, formData, {
params: { light }
});
cachedOidcClientLogo.bustCache(client.id, light);
};
removeClientLogo = async (id: string, light: boolean = true) => {
await this.api.delete(`/oidc/clients/${id}/logo`, {
await this.api.delete(`/oidc/clients/${encodeClientIdParam(id)}/logo`, {
params: { light }
});
cachedOidcClientLogo.bustCache(id, light);
};
createClientSecret = async (id: string) =>
(await this.api.post(`/oidc/clients/${id}/secret`)).data.secret as string;
(await this.api.post(`/oidc/clients/${encodeClientIdParam(id)}/secret`)).data.secret as string;
updateAllowedUserGroups = async (id: string, userGroupIds: string[]) => {
const res = await this.api.put(`/oidc/clients/${id}/allowed-user-groups`, { userGroupIds });
const res = await this.api.put(`/oidc/clients/${encodeClientIdParam(id)}/allowed-user-groups`, {
userGroupIds
});
return res.data as OidcClientWithAllowedUserGroups;
};
@@ -98,9 +107,12 @@ class OidcService extends APIService {
};
getClientPreview = async (id: string, userId: string, scopes: string) => {
const response = await this.api.get(`/oidc/clients/${id}/preview/${userId}`, {
params: { scopes }
});
const response = await this.api.get(
`/oidc/clients/${encodeClientIdParam(id)}/preview/${userId}`,
{
params: { scopes }
}
);
return response.data;
};
@@ -110,11 +122,13 @@ class OidcService extends APIService {
};
revokeOwnAuthorizedClient = async (clientId: string) => {
await this.api.delete(`/oidc/users/me/authorized-clients/${clientId}`);
await this.api.delete(`/oidc/users/me/authorized-clients/${encodeClientIdParam(clientId)}`);
};
getScimResourceProvider = async (clientId: string) => {
const res = await this.api.get(`/oidc/clients/${clientId}/scim-service-provider`);
const res = await this.api.get(
`/oidc/clients/${encodeClientIdParam(clientId)}/scim-service-provider`
);
return res.data as ScimServiceProvider;
};
}
@@ -52,6 +52,8 @@ export type AllAppConfig = AppConfig & {
ldapAttributeGroupName: string;
ldapAdminGroupName: string;
ldapSoftDeleteUsers: boolean;
// OIDC
cimdUrlAllowlist: string[];
};
export type AppConfigRawResponse = {
+4 -1
View File
@@ -1,5 +1,7 @@
import type { UserGroup } from './user-group.type';
export type OidcClientType = 'standard' | 'cimd';
export type OidcClientMetaData = {
id: string;
name: string;
@@ -8,6 +10,7 @@ export type OidcClientMetaData = {
hasDarkLogo: boolean;
requiresReauthentication: boolean;
launchURL?: string;
clientType: OidcClientType;
};
export type OidcClientFederatedIdentity = {
@@ -55,7 +58,7 @@ export type OidcClientWithAllowedUserGroupsCount = OidcClient & {
export type OidcClientUpdate = Omit<
OidcClient,
'id' | 'logoURL' | 'hasLogo' | 'hasDarkLogo' | 'pkceSupported'
'id' | 'logoURL' | 'hasLogo' | 'hasDarkLogo' | 'pkceSupported' | 'clientType'
>;
export type OidcClientCreate = OidcClientUpdate & {
id?: string;
+10 -2
View File
@@ -1,3 +1,5 @@
import { encodeClientIdParam } from './client-id-util';
type SkipCacheUntil = {
[key: string]: number;
};
@@ -56,12 +58,18 @@ export const cachedProfilePicture: CachableImage = {
export const cachedOidcClientLogo: CachableImage = {
getUrl: (clientId: string, light = true) => {
const url = new URL(`/api/oidc/clients/${clientId}/logo`, window.location.origin);
const url = new URL(
`/api/oidc/clients/${encodeClientIdParam(clientId)}/logo`,
window.location.origin
);
if (!light) url.searchParams.set('light', 'false');
return getCachedImageUrl(url);
},
bustCache: (clientId: string, light = true) => {
const url = new URL(`/api/oidc/clients/${clientId}/logo`, window.location.origin);
const url = new URL(
`/api/oidc/clients/${encodeClientIdParam(clientId)}/logo`,
window.location.origin
);
if (!light) url.searchParams.set('light', 'false');
bustImageCache(url);
}
+49
View File
@@ -0,0 +1,49 @@
// Raw pocket-id client IDs match this pattern and need no encoding.
const RAW_CLIENT_ID = /^[a-zA-Z0-9._-]+$/;
/**
* Encodes a client ID for use as a path segment.
*
* CIMD client IDs are full https URLs containing slashes and colons, which
* cannot be carried in a single path segment. Such IDs are encoded as
* `~<base64url>`; the backend decodes them. Plain client IDs are unchanged.
*/
export function encodeClientIdParam(id: string): string {
if (RAW_CLIENT_ID.test(id)) {
return id;
}
const base64 = btoa(String.fromCharCode(...new TextEncoder().encode(id)));
const base64url = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return '~' + base64url;
}
/**
* Reverses {@link encodeClientIdParam}. Decodes a `~<base64url>` value back to the
* real client ID; returns plain values unchanged.
*/
export function decodeClientIdParam(param: string): string {
if (!param.startsWith('~')) {
return param;
}
try {
const base64 = param.slice(1).replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(base64);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
} catch {
return param;
}
}
/**
* Returns the host of a CIMD client's Client Identifier URL, or null for an administrator-configured client.
*
*/
export function getClientIDHost(client: { id: string; clientType?: string }): string | null {
if (client.clientType !== 'cimd') return null;
try {
return new URL(client.id).host;
} catch {
return null;
}
}
+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}