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;
}
}