mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-20 16:49:03 +02:00
feat: allow admins to auto grant APIs to CIMD clients (#1692)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260807120000,
|
||||
"version": 20260814120000,
|
||||
"tableOrder": [
|
||||
"users",
|
||||
"user_groups",
|
||||
@@ -9,6 +9,7 @@
|
||||
"signup_tokens",
|
||||
"apis",
|
||||
"api_permissions",
|
||||
"oidc_clients_allowed_apis",
|
||||
"oidc_clients_allowed_api_permissions"
|
||||
],
|
||||
"tables": {
|
||||
@@ -18,7 +19,8 @@
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"updated_at": null,
|
||||
"name": "Orders API",
|
||||
"audience": "https://api.orders.test"
|
||||
"audience": "https://api.orders.test",
|
||||
"allow_cimd_clients": false
|
||||
}
|
||||
],
|
||||
"api_permissions": [
|
||||
@@ -28,7 +30,8 @@
|
||||
"api_id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"key": "read:orders",
|
||||
"name": "Read orders",
|
||||
"description": "Read order data"
|
||||
"description": "Read order data",
|
||||
"allowed_for_cimd_clients": false
|
||||
},
|
||||
{
|
||||
"id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
|
||||
@@ -36,7 +39,20 @@
|
||||
"api_id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"key": "write:orders",
|
||||
"name": "Write orders",
|
||||
"description": "Create and modify orders"
|
||||
"description": "Create and modify orders",
|
||||
"allowed_for_cimd_clients": false
|
||||
}
|
||||
],
|
||||
"oidc_clients_allowed_apis": [
|
||||
{
|
||||
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
"api_id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"subject_type": "user"
|
||||
},
|
||||
{
|
||||
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
"api_id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"subject_type": "client"
|
||||
}
|
||||
],
|
||||
"oidc_clients_allowed_api_permissions": [
|
||||
|
||||
+136
-5
@@ -110,12 +110,13 @@ test('Grant a client user-delegated and client access to API permissions', async
|
||||
// Nextcloud has no API access granted by default
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
||||
|
||||
// Open the API access tab, then edit the Orders API row
|
||||
// Open the API access tab, where no API is listed yet, and add the Orders API
|
||||
await page.getByRole('tab', { name: 'API access' }).click();
|
||||
await page
|
||||
.getByRole('row', { name: apis.orders.name })
|
||||
.getByRole('button', { name: 'Edit' })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText('This client has not been granted access to any API yet.')
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Add API' }).click();
|
||||
await page.getByRole('row', { name: apis.orders.name }).click();
|
||||
|
||||
// Grant read:orders and write:orders on behalf of users, but only write:orders for the client itself
|
||||
const dialog = page.getByRole('dialog');
|
||||
@@ -137,10 +138,140 @@ test('Grant a client user-delegated and client access to API permissions', async
|
||||
await dialog.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
|
||||
// The dialogs carry rows with the same names, so the assertions below wait until they are gone
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
// Both subject types keep their own count: 2 / 2 user-delegated, 1 / 2 client access
|
||||
const row = page.getByRole('row', { name: apis.orders.name });
|
||||
await expect(row).toContainText('2 / 2');
|
||||
await expect(row).toContainText('1 / 2');
|
||||
|
||||
// The API is not offered a second time, because the selection is filtered server-side
|
||||
await page.getByRole('button', { name: 'Add API' }).click();
|
||||
await expect(page.getByRole('dialog').getByText('No items found')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Grant a client access from the API details page', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/apis/${apis.orders.id}`);
|
||||
|
||||
// Nextcloud has no API access granted by default, so it can be picked from the client selection
|
||||
await page.getByRole('button', { name: 'Add client' }).click();
|
||||
await page.getByRole('row', { name: oidcClients.nextcloud.name }).click();
|
||||
|
||||
await page
|
||||
.getByRole('checkbox', {
|
||||
name: `User-delegated access: ${apis.orders.permissions.readOrders.name}`
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('checkbox', {
|
||||
name: `Client access (M2M): ${apis.orders.permissions.writeOrders.name}`
|
||||
})
|
||||
.click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
|
||||
// The client selection dialog carries a row with the same name, so this waits until it is gone
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
const row = page.getByRole('row', { name: oidcClients.nextcloud.name });
|
||||
await expect(row).toContainText('1 / 2');
|
||||
|
||||
// Clients that already have access are filtered out of the selection, the others stay
|
||||
await page.getByRole('button', { name: 'Add client' }).click();
|
||||
const picker = page.getByRole('dialog');
|
||||
await expect(picker.getByRole('row', { name: oidcClients.tailscale.name })).toBeVisible();
|
||||
await expect(picker.getByRole('row', { name: oidcClients.nextcloud.name })).toHaveCount(0);
|
||||
await expect(picker.getByRole('row', { name: oidcClients.immich.name })).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// The same grant shows up on the client's side of the relation
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
||||
await page.getByRole('tab', { name: 'API access' }).click();
|
||||
await expect(page.getByRole('row', { name: apis.orders.name })).toContainText('1 / 2');
|
||||
});
|
||||
|
||||
test('Grant a client access to an API without any permission', async ({ page, baseURL }) => {
|
||||
const client = oidcClients.nextcloud;
|
||||
const api = apis.orders;
|
||||
|
||||
// The client is added with user-delegated access and no permission at all
|
||||
await page.goto(`/settings/admin/apis/${api.id}`);
|
||||
await page.getByRole('button', { name: 'Add client' }).click();
|
||||
await page.getByRole('row', { name: client.name }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
|
||||
// The client selection dialog carries a row with the same name, so this waits until it is gone
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await expect(page.getByRole('row', { name: client.name })).toContainText('0 / 2');
|
||||
|
||||
// A resource request without any scope now succeeds, which is what MCP clients send
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
response_type: 'code',
|
||||
resource: api.resource,
|
||||
redirect_uri: client.callbackUrl,
|
||||
state: 'nXx-6Qr-owc1SHBa'
|
||||
});
|
||||
|
||||
const callbackUrl = await oidcUtil.interceptCallbackRedirect(
|
||||
page,
|
||||
new URL(client.callbackUrl).pathname,
|
||||
async () => {
|
||||
await page.goto(`/authorize?${params.toString()}`);
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
}
|
||||
);
|
||||
const code = callbackUrl.searchParams.get('code');
|
||||
expect(code).toBeTruthy();
|
||||
|
||||
const res = await oidcUtil.exchangeCode(page, {
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: client.callbackUrl,
|
||||
code: code!,
|
||||
client_id: client.id,
|
||||
client_secret: client.secret
|
||||
});
|
||||
expect(res.access_token).toBeTruthy();
|
||||
|
||||
// The token is audienced to the API and carries no scope
|
||||
const claims = jose.decodeJwt(res.access_token!);
|
||||
expect(tokenAudiences(claims)).toContain(api.resource);
|
||||
expect(tokenAudiences(claims)).not.toContain(baseURL);
|
||||
expect(tokenScopes(claims)).toEqual([]);
|
||||
});
|
||||
|
||||
test('Revoke a client from the API details page', async ({ page }) => {
|
||||
// Immich is seeded with grants on the Orders API
|
||||
await page.goto(`/settings/admin/apis/${apis.orders.id}`);
|
||||
|
||||
const row = page.getByRole('row', { name: oidcClients.immich.name });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole('button', { name: 'Revoke' }).click();
|
||||
await page.getByRole('button', { name: 'Revoke' }).last().click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
|
||||
await expect(page.getByRole('row', { name: oidcClients.immich.name })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Allow all metadata document clients for an API', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/apis/${apis.orders.id}`);
|
||||
|
||||
await page.getByRole('tab', { name: 'Metadata document clients' }).click();
|
||||
await page.getByLabel('Allow all metadata document clients').click();
|
||||
await page.getByLabel(apis.orders.permissions.readOrders.name, { exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).nth(2).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
|
||||
|
||||
await page.reload();
|
||||
await page.getByRole('tab', { name: 'Metadata document clients' }).click();
|
||||
await expect(page.getByLabel('Allow all metadata document clients')).toBeChecked();
|
||||
await expect(
|
||||
page.getByLabel(apis.orders.permissions.readOrders.name, { exact: true })
|
||||
).toBeChecked();
|
||||
await expect(
|
||||
page.getByLabel(apis.orders.permissions.writeOrders.name, { exact: true })
|
||||
).not.toBeChecked();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user