mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-04 08:49:03 +02:00
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
co-authored by
Elias Schneider
parent
7c55bdf115
commit
1934efa84c
@@ -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?.()}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user