mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-04 15:04:43 +00:00
feat: add support for SCIM provisioning (#1182)
This commit is contained in:
@@ -484,5 +484,19 @@
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"restricted": "Restricted",
|
||||
"scim_provisioning": "SCIM Provisioning",
|
||||
"scim_provisioning_description": "SCIM provisioning allows you to automatically provision and deprovision users and groups from your OIDC client. Learn more in the <link href='https://pocket-id.org/docs/configuration/scim'>docs</link>.",
|
||||
"scim_endpoint": "SCIM Endpoint",
|
||||
"scim_token": "SCIM Token",
|
||||
"last_successful_sync_at": "Last successful sync: {time}",
|
||||
"scim_configuration_updated_successfully": "SCIM configuration updated successfully.",
|
||||
"scim_enabled_successfully": "SCIM enabled successfully.",
|
||||
"scim_disabled_successfully": "SCIM disabled successfully.",
|
||||
"disable_scim_provisioning": "Disable SCIM Provisioning",
|
||||
"disable_scim_provisioning_confirm_description": "Are you sure you want to disable SCIM provisioning for <b>{clientName}</b>? This will stop all automatic user and group provisioning and deprovisioning.",
|
||||
"scim_sync_failed": "SCIM sync failed. Check the server logs for more information.",
|
||||
"scim_sync_successful": "The SCIM sync has been completed successfully.",
|
||||
"save_and_sync": "Save and Sync",
|
||||
"scim_save_changes_description": "You have to save the changes before starting a SCIM sync. Do you want to save now?",
|
||||
"scopes": "Scopes"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { LucideChevronDown, type Icon as IconType } from '@lucide/svelte';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import FormattedMessage from './formatted-message.svelte';
|
||||
import { Button } from './ui/button';
|
||||
import * as Card from './ui/card';
|
||||
|
||||
@@ -70,7 +71,7 @@
|
||||
{title}
|
||||
</Card.Title>
|
||||
{#if description}
|
||||
<Card.Description>{description}</Card.Description>
|
||||
<Card.Description><FormattedMessage m={description} /></Card.Description>
|
||||
{/if}
|
||||
</div>
|
||||
{#if button}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
OidcClientWithAllowedUserGroupsCount,
|
||||
OidcDeviceCodeInfo
|
||||
} from '$lib/types/oidc.type';
|
||||
import type { ScimServiceProvider } from '$lib/types/scim.type';
|
||||
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
|
||||
import APIService from './api-service';
|
||||
|
||||
@@ -127,6 +128,11 @@ class OidcService extends APIService {
|
||||
revokeOwnAuthorizedClient = async (clientId: string) => {
|
||||
await this.api.delete(`/oidc/users/me/authorized-clients/${clientId}`);
|
||||
};
|
||||
|
||||
getScimResourceProvider = async (clientId: string) => {
|
||||
const res = await this.api.get(`/oidc/clients/${clientId}/scim-service-provider`);
|
||||
return res.data as ScimServiceProvider;
|
||||
};
|
||||
}
|
||||
|
||||
export default OidcService;
|
||||
|
||||
27
frontend/src/lib/services/scim-service.ts
Normal file
27
frontend/src/lib/services/scim-service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ScimServiceProvider, ScimServiceProviderCreate } from '$lib/types/scim.type';
|
||||
import APIService from './api-service';
|
||||
|
||||
class ScimService extends APIService {
|
||||
syncServiceProvider = async (serviceProviderId: string) => {
|
||||
return await this.api.post(`/scim/service-provider/${serviceProviderId}/sync`);
|
||||
};
|
||||
|
||||
createServiceProvider = async (serviceProvider: ScimServiceProviderCreate) => {
|
||||
return (await this.api.post('/scim/service-provider', serviceProvider))
|
||||
.data as ScimServiceProvider;
|
||||
};
|
||||
|
||||
updateServiceProvider = async (
|
||||
serviceProviderId: string,
|
||||
serviceProvider: ScimServiceProviderCreate
|
||||
) => {
|
||||
return (await this.api.put(`/scim/service-provider/${serviceProviderId}`, serviceProvider))
|
||||
.data as ScimServiceProvider;
|
||||
};
|
||||
|
||||
deleteServiceProvider = async (serviceProviderId: string) => {
|
||||
await this.api.delete(`/scim/service-provider/${serviceProviderId}`);
|
||||
};
|
||||
}
|
||||
|
||||
export default ScimService;
|
||||
14
frontend/src/lib/types/scim.type.ts
Normal file
14
frontend/src/lib/types/scim.type.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { OidcClientMetaData } from './oidc.type';
|
||||
|
||||
export type ScimServiceProvider = {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
token?: string;
|
||||
lastSyncedAt?: string;
|
||||
createdAt: string;
|
||||
oidcClient: OidcClientMetaData;
|
||||
};
|
||||
|
||||
export type ScimServiceProviderCreate = Pick<ScimServiceProvider, 'endpoint' | 'token'> & {
|
||||
oidcClientId: string;
|
||||
};
|
||||
@@ -10,8 +10,10 @@
|
||||
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import OidcService from '$lib/services/oidc-service';
|
||||
import ScimService from '$lib/services/scim-service';
|
||||
import clientSecretStore from '$lib/stores/client-secret-store';
|
||||
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
||||
import type { ScimServiceProviderCreate } from '$lib/types/scim.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -19,16 +21,20 @@
|
||||
import { backNavigate } from '../../users/navigate-back-util';
|
||||
import OidcForm from '../oidc-client-form.svelte';
|
||||
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
|
||||
import ScimResourceProviderForm from './scim-resource-provider-form.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let client = $state({
|
||||
...data,
|
||||
allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id)
|
||||
...data.client,
|
||||
allowedUserGroupIds: data.client.allowedUserGroups.map((g) => g.id)
|
||||
});
|
||||
|
||||
let scimServiceProvider = $state(data.scimServiceProvider);
|
||||
let showAllDetails = $state(false);
|
||||
let showPreview = $state(false);
|
||||
|
||||
const oidcService = new OidcService();
|
||||
const scimService = new ScimService();
|
||||
const backNavigation = backNavigate('/settings/admin/oidc-clients');
|
||||
|
||||
const setupDetails = $state({
|
||||
@@ -149,6 +155,30 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function saveScimServiceProvider(provider: ScimServiceProviderCreate | null) {
|
||||
try {
|
||||
if (!provider) {
|
||||
await scimService.deleteServiceProvider(scimServiceProvider!.id);
|
||||
scimServiceProvider = undefined;
|
||||
toast.success(m.scim_disabled_successfully());
|
||||
return true;
|
||||
}
|
||||
let createdProvider;
|
||||
if (scimServiceProvider) {
|
||||
createdProvider = await scimService.updateServiceProvider(scimServiceProvider.id, provider);
|
||||
toast.success(m.scim_configuration_updated_successfully());
|
||||
} else {
|
||||
createdProvider = await scimService.createServiceProvider(provider);
|
||||
toast.success(m.scim_enabled_successfully());
|
||||
}
|
||||
scimServiceProvider = createdProvider;
|
||||
return true;
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
beforeNavigate(() => {
|
||||
clientSecretStore.clear();
|
||||
});
|
||||
@@ -251,9 +281,22 @@
|
||||
<div class="mt-5 flex justify-end gap-3">
|
||||
<Button onclick={disableGroupRestriction} variant="secondary">{m.unrestrict()}</Button>
|
||||
|
||||
<Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
|
||||
<Button usePromiseLoading onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}
|
||||
>{m.save()}</Button
|
||||
>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
<CollapsibleCard
|
||||
id="scim-provisioning"
|
||||
title={m.scim_provisioning()}
|
||||
description={m.scim_provisioning_description()}
|
||||
>
|
||||
<ScimResourceProviderForm
|
||||
oidcClientId={client.id}
|
||||
existingProvider={scimServiceProvider}
|
||||
onSave={saveScimServiceProvider}
|
||||
/>
|
||||
</CollapsibleCard>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
|
||||
|
||||
@@ -3,5 +3,14 @@ import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const oidcService = new OidcService();
|
||||
return await oidcService.getClient(params.id);
|
||||
|
||||
const client = await oidcService.getClient(params.id);
|
||||
const scimServiceProvider = await oidcService
|
||||
.getScimResourceProvider(params.id)
|
||||
.then((p) => p)
|
||||
.catch(() => undefined);
|
||||
return {
|
||||
client,
|
||||
scimServiceProvider
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import ScimService from '$lib/services/scim-service';
|
||||
import type { ScimServiceProvider, ScimServiceProviderCreate } from '$lib/types/scim.type';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { emptyToUndefined } from '$lib/utils/zod-util';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
let {
|
||||
onSave,
|
||||
existingProvider,
|
||||
oidcClientId
|
||||
}: {
|
||||
existingProvider?: ScimServiceProvider;
|
||||
onSave: (provider: ScimServiceProviderCreate | null) => Promise<boolean>;
|
||||
oidcClientId: string;
|
||||
} = $props();
|
||||
|
||||
const scimService = new ScimService();
|
||||
|
||||
let isSyncing = $state(false);
|
||||
|
||||
const serviceProvider = {
|
||||
endpoint: existingProvider?.endpoint || '',
|
||||
token: existingProvider?.token || ''
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
endpoint: z.url(),
|
||||
token: emptyToUndefined(z.string())
|
||||
});
|
||||
type FormSchema = typeof formSchema;
|
||||
|
||||
const { inputs, ...form } = createForm<FormSchema>(formSchema, serviceProvider);
|
||||
|
||||
async function onSubmit() {
|
||||
const data = form.validate();
|
||||
if (!data) return false;
|
||||
return await onSave({
|
||||
...data,
|
||||
oidcClientId
|
||||
});
|
||||
}
|
||||
|
||||
async function onDisable() {
|
||||
openConfirmDialog({
|
||||
title: m.disable_scim_provisioning(),
|
||||
message: m.disable_scim_provisioning_confirm_description({
|
||||
clientName: existingProvider!.oidcClient.name
|
||||
}),
|
||||
confirm: {
|
||||
label: m.disable(),
|
||||
destructive: true,
|
||||
action: async () => {
|
||||
await onSave(null);
|
||||
form.setValue('endpoint', '');
|
||||
form.setValue('token', '');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onSync() {
|
||||
const hasChanges = Object.keys($inputs).some(
|
||||
// @ts-ignore
|
||||
(key) => $inputs[key].value !== (existingProvider as any)[key]
|
||||
);
|
||||
|
||||
if (hasChanges) {
|
||||
openConfirmDialog({
|
||||
title: m.save_changes_question(),
|
||||
message: m.scim_save_changes_description(),
|
||||
confirm: {
|
||||
label: m.save_and_sync(),
|
||||
action: async () => {
|
||||
const saved = await onSubmit();
|
||||
if (saved) {
|
||||
syncProvider();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
syncProvider();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncProvider() {
|
||||
isSyncing = true;
|
||||
await scimService
|
||||
.syncServiceProvider(existingProvider!.id)
|
||||
.then(() => {
|
||||
existingProvider = {
|
||||
...existingProvider!,
|
||||
lastSyncedAt: new Date().toISOString()
|
||||
};
|
||||
toast.success(m.scim_sync_successful());
|
||||
})
|
||||
.catch(() => toast.error(m.scim_sync_failed()))
|
||||
.finally(() => (isSyncing = false));
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={preventDefault(onSubmit)}>
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<div class="w-full">
|
||||
<FormInput
|
||||
placeholder="https://scim.example.com/v2"
|
||||
label={m.scim_endpoint()}
|
||||
bind:input={$inputs.endpoint}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<FormInput label={m.scim_token()} bind:input={$inputs.token} type="password" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-5 flex items-end flex-col sm:flex-row {existingProvider
|
||||
? 'justify-between'
|
||||
: 'justify-end'} "
|
||||
>
|
||||
{#if existingProvider}
|
||||
<p class="text-muted-foreground text-xs self-start sm:self-auto">
|
||||
{m.last_successful_sync_at({
|
||||
time: existingProvider.lastSyncedAt
|
||||
? new Date(existingProvider.lastSyncedAt).toLocaleString()
|
||||
: m.never()
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="mt-5 flex justify-end gap-3">
|
||||
{#if existingProvider}
|
||||
<Button variant="destructive" onclick={onDisable}>{m.disable()}</Button>
|
||||
<Button variant="secondary" isLoading={isSyncing} onclick={onSync}>{m.sync_now()}</Button>
|
||||
{/if}
|
||||
<Button type="submit">{existingProvider ? m.save() : m.enable()}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
Reference in New Issue
Block a user