1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-04 11:36:46 +00:00

feat: add support for SCIM provisioning (#1182)

This commit is contained in:
Elias Schneider
2026-01-02 17:54:20 +01:00
committed by GitHub
parent e4a8ca476c
commit 579cfdc678
37 changed files with 1963 additions and 34 deletions

View File

@@ -56,6 +56,12 @@ export const oidcClients = {
},
accessCodes: ['federated']
},
scim: {
id: 'c46d2090-37a0-4f2b-8748-6aa53b0c1afa',
name: 'SCIM Client',
callbackUrl: 'http://scim.client/auth/callback',
secret: 'nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn'
},
pingvinShare: {
name: 'Pingvin Share',
callbackUrl: 'http://pingvin.share/auth/callback',

View File

@@ -1,6 +1,6 @@
{
"provider": "sqlite",
"version": 20251219000000,
"version": 20251229173100,
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
"tables": {
"api_keys": [
@@ -122,12 +122,36 @@
"pkce_enabled": false,
"requires_reauthentication": false,
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
},
{
"callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC9hdXRoL2NhbGxiYWNrIl0=",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
"dark_image_type": null,
"id": "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
"image_type": null,
"is_group_restricted": true,
"is_public": false,
"launch_url": null,
"logout_callback_urls": "bnVsbA==",
"name": "SCIM Client",
"pkce_enabled": false,
"requires_reauthentication": false,
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK"
}
],
"oidc_clients_allowed_user_groups": [
{
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
"user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211"
},
{
"oidc_client_id": "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
"user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211"
},
{
"oidc_client_id": "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
"user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368"
}
],
"oidc_refresh_tokens": [
@@ -230,6 +254,7 @@
"user_groups": [
{
"created_at": "2025-11-25T12:39:02Z",
"updated_at": null,
"friendly_name": "Developers",
"id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
"ldap_id": null,
@@ -237,6 +262,7 @@
},
{
"created_at": "2025-11-25T12:39:02Z",
"updated_at": null,
"friendly_name": "Designers",
"id": "adab18bf-f89d-4087-9ee1-70ff15b48211",
"ldap_id": null,
@@ -260,6 +286,7 @@
"users": [
{
"created_at": "2025-11-25T12:39:02Z",
"updated_at": null,
"disabled": false,
"display_name": "Tim Cook",
"email": "tim.cook@test.com",
@@ -273,6 +300,7 @@
},
{
"created_at": "2025-11-25T12:39:02Z",
"updated_at": null,
"disabled": false,
"display_name": "Craig Federighi",
"email": "craig.federighi@test.com",
@@ -283,6 +311,20 @@
"ldap_id": null,
"locale": null,
"username": "craig"
},
{
"created_at": "2025-11-25T12:39:02Z",
"updated_at": null,
"disabled": false,
"display_name": "Eddy Cue",
"email": "eddy.cue@test.com",
"first_name": "Eddy",
"id": "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
"is_admin": false,
"last_name": "Cue",
"ldap_id": null,
"locale": null,
"username": "eddy"
}
],
"webauthn_credentials": [

View File

@@ -4,6 +4,10 @@ services:
extends:
file: docker-compose.yml
service: lldap
scim-test-server:
extends:
file: docker-compose.yml
service: scim-test-server
postgres:
image: postgres:17
environment:

View File

@@ -3,7 +3,10 @@ services:
extends:
file: docker-compose.yml
service: lldap
scim-test-server:
extends:
file: docker-compose.yml
service: scim-test-server
localstack-s3:
image: localstack/localstack:s3-latest
healthcheck:
@@ -11,7 +14,6 @@ services:
interval: 1s
timeout: 3s
retries: 10
create-bucket:
image: amazon/aws-cli:latest
environment:
@@ -22,7 +24,6 @@ services:
localstack-s3:
condition: service_healthy
entrypoint: "aws --endpoint-url=http://localstack-s3:4566 s3 mb s3://pocket-id-test"
pocket-id:
extends:
file: docker-compose.yml

View File

@@ -8,6 +8,10 @@ services:
- LLDAP_JWT_SECRET=secret
- LLDAP_LDAP_USER_PASS=admin_password
- LLDAP_LDAP_BASE_DN=dc=pocket-id,dc=org
scim-test-server:
image: ghcr.io/pocket-id/scim-test-server:latest
ports:
- "18123:8080"
pocket-id:
image: pocket-id:test
ports:

View File

@@ -11,7 +11,7 @@ test('Dashboard shows all clients in the correct order', async ({ page }) => {
await page.goto('/settings/apps');
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(4);
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(5);
// Should be first
const card1 = page.getByTestId('authorized-oidc-client-card').first();
@@ -32,7 +32,7 @@ test.describe('Dashboard shows only clients where user has access', () => {
const cards = page.getByTestId('authorized-oidc-client-card');
await expect(cards).toHaveCount(3);
await expect(cards).toHaveCount(4);
const cardTexts = await cards.allTextContents();
expect(cardTexts.some((text) => text.includes(notVisibleClient.name))).toBe(false);
@@ -40,7 +40,7 @@ test.describe('Dashboard shows only clients where user has access', () => {
test('User can see all clients', async ({ page }) => {
await page.goto('/settings/apps');
const cards = page.getByTestId('authorized-oidc-client-card');
await expect(cards).toHaveCount(4);
await expect(cards).toHaveCount(5);
});
});

172
tests/specs/scim.spec.ts Normal file
View File

@@ -0,0 +1,172 @@
import test, { expect, type Page } from '@playwright/test';
import { cleanupBackend, cleanupScimServiceProvider } from 'utils/cleanup.util';
import { oidcClients, userGroups, users } from '../data';
async function configureOidcClient(page: Page) {
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
await page
.getByLabel('SCIM Endpoint')
.fill(process.env.SCIM_SERVICE_PROVIDER_URL_INTERNAL || 'http://scim.provider/api');
await page.getByRole('button', { name: 'Enable' }).click();
}
async function syncScimServiceProvider(page: Page) {
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
await page.getByRole('button', { name: 'Sync now' }).click();
await page.waitForSelector('[data-type="success"]');
}
test.beforeEach(async () => {
await cleanupBackend({ skipLdapSetup: true });
});
test.describe('SCIM Configuration', () => {
test('Enable SCIM for OIDC client', async ({ page }) => {
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
await page.getByLabel('SCIM Endpoint').fill('http://scim.provider/api');
await page.getByLabel('SCIM Token').fill('supersecrettoken');
await page.getByRole('button', { name: 'Enable' }).click();
await expect(page.locator('[data-type="success"]')).toHaveText('SCIM enabled successfully.');
await page.reload();
await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('http://scim.provider/api');
await expect(page.getByLabel('SCIM Token')).toHaveValue('supersecrettoken');
});
test('Update SCIM of OIDC client', async ({ page }) => {
await configureOidcClient(page);
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
await page.getByLabel('SCIM Endpoint').fill('http://new.scim.provider/api');
await page.getByLabel('SCIM Token').fill('evenmoresecrettoken');
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
'SCIM configuration updated successfully.'
);
await page.reload();
await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('http://new.scim.provider/api');
await expect(page.getByLabel('SCIM Token')).toHaveValue('evenmoresecrettoken');
});
test('Disable SCIM of OIDC client', async ({ page }) => {
await configureOidcClient(page);
await page.goto(`/settings/admin/oidc-clients/${oidcClients.scim.id}`);
await page.getByRole('button', { name: 'Disable' }).click();
await page.getByRole('button', { name: 'Disable' }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText('SCIM disabled successfully.');
await page.reload();
await expect(page.getByRole('button', { name: 'Enable' })).toBeVisible();
await expect(page.getByLabel('SCIM Endpoint')).toHaveValue('');
await expect(page.getByLabel('SCIM Token')).toHaveValue('');
});
});
test.describe('SCIM Sync', () => {
test.skip(
!process.env.SCIM_SERVICE_PROVIDER_URL || !process.env.SCIM_SERVICE_PROVIDER_URL_INTERNAL,
'Skipping SCIM Sync tests because SCIM_SERVICE_PROVIDER_URL or SCIM_SERVICE_PROVIDER_URL_INTERNAL is not set'
);
test.beforeEach(async ({ page }) => {
await Promise.all([configureOidcClient(page), cleanupScimServiceProvider()]);
});
test('Sync client', async ({ page }) => {
await syncScimServiceProvider(page);
const scimUsers = await getScimResources('Users');
await expect(scimUsers.length).toBe(2);
const groups = await getScimResources('Groups');
await expect(groups.length).toBe(2);
const timUser = scimUsers.find((u: any) => u.userName === 'tim');
await expect(timUser).toBeDefined();
await expect(timUser).toMatchObject({
externalId: users.tim.id,
emails: [
{
value: users.tim.email,
primary: true
}
],
name: {
givenName: users.tim.firstname,
familyName: users.tim.lastname
},
displayName: users.tim.displayName,
active: true
});
});
test('Remove allowed group and sync', async ({ page }) => {
await syncScimServiceProvider(page);
await page.getByRole('button', { name: 'Expand card' }).first().click();
await page
.getByRole('row', { name: userGroups.developers.name })
.getByRole('cell')
.first()
.click();
await page.getByRole('button', { name: 'Save' }).nth(1).click();
await syncScimServiceProvider(page);
const scimUsers = await getScimResources('Users');
await expect(scimUsers.length).toBe(1);
await expect(scimUsers.find((u: any) => u.userName === users.tim.username)).toBeDefined();
const scimGroups = await getScimResources('Groups');
await expect(scimGroups.length).toBe(1);
await expect(
scimGroups.find((g: any) => g.displayName === userGroups.designers.friendlyName)
).toBeDefined();
});
test('Remove group restrictions and sync', async ({ page }) => {
await syncScimServiceProvider(page);
await page.getByRole('button', { name: 'Expand card' }).first().click();
await page.getByRole('button', { name: 'Unrestrict' }).click();
await page.getByRole('button', { name: 'Unrestrict' }).nth(1).click();
await syncScimServiceProvider(page);
const scimUsers = await getScimResources('Users');
await expect(scimUsers.length).toBe(3);
const scimGroups = await getScimResources('Groups');
await expect(scimGroups.length).toBe(2);
});
});
async function getScimResources(resourceType: 'Users' | 'Groups') {
const response = await fetch(`${process.env.SCIM_SERVICE_PROVIDER_URL}/${resourceType}`).then(
(res) => res.json()
);
return response['Resources'];
}

View File

@@ -19,3 +19,8 @@ export async function cleanupBackend({ skipSeed = false, skipLdapSetup = false }
throw new Error(`Failed to reset backend: ${response.status} ${response.statusText}`);
}
}
export async function cleanupScimServiceProvider() {
if (!process.env.SCIM_SERVICE_PROVIDER_URL) return;
await fetch(`${process.env.SCIM_SERVICE_PROVIDER_URL}/reset`, { method: 'POST' });
}