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:
@@ -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',
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
172
tests/specs/scim.spec.ts
Normal 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'];
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user