mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-04 08:49:03 +02:00
feat: add OAuth APIs with scoped permissions (#1542)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
co-authored by
Alessandro Segala
parent
0b2706a488
commit
09d196f7c5
@@ -82,6 +82,26 @@ export const oidcClients = {
|
||||
}
|
||||
};
|
||||
|
||||
export const apis = {
|
||||
orders: {
|
||||
id: 'f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d',
|
||||
name: 'Orders API',
|
||||
resource: 'https://api.orders.test',
|
||||
permissions: {
|
||||
readOrders: {
|
||||
id: '1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
key: 'read:orders',
|
||||
name: 'Read orders'
|
||||
},
|
||||
writeOrders: {
|
||||
id: '2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e',
|
||||
key: 'write:orders',
|
||||
name: 'Write orders'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const userGroups = {
|
||||
developers: {
|
||||
id: 'c7ae7c01-28a3-4f3c-9572-1ee734ea8368',
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260726153900,
|
||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
|
||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_api_permissions"],
|
||||
"tables": {
|
||||
"apis": [
|
||||
{
|
||||
"id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"updated_at": null,
|
||||
"name": "Orders API",
|
||||
"audience": "https://api.orders.test"
|
||||
}
|
||||
],
|
||||
"api_permissions": [
|
||||
{
|
||||
"id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"api_id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"key": "read:orders",
|
||||
"name": "Read orders",
|
||||
"description": "Read order data"
|
||||
},
|
||||
{
|
||||
"id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"api_id": "f6a8b3c1-2d4e-4a6b-8c9d-0e1f2a3b4c5d",
|
||||
"key": "write:orders",
|
||||
"name": "Write orders",
|
||||
"description": "Create and modify orders"
|
||||
}
|
||||
],
|
||||
"oidc_clients_allowed_api_permissions": [
|
||||
{
|
||||
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
"api_permission_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
|
||||
"subject_type": "user"
|
||||
},
|
||||
{
|
||||
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
|
||||
"api_permission_id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
|
||||
"subject_type": "client"
|
||||
}
|
||||
],
|
||||
"api_keys": [
|
||||
{
|
||||
"created_at": "2025-12-21T19:12:03Z",
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import test, { expect } from '@playwright/test';
|
||||
import * as jose from 'jose';
|
||||
import { apis, oidcClients } from '../data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
import * as oidcUtil from '../utils/oidc.util';
|
||||
|
||||
test.beforeEach(async () => await cleanupBackend());
|
||||
|
||||
function tokenScopes(claims: jose.JWTPayload): string[] {
|
||||
if (Array.isArray((claims as Record<string, unknown>).scp)) {
|
||||
return (claims as Record<string, unknown>).scp as string[];
|
||||
}
|
||||
if (typeof claims.scope === 'string') {
|
||||
return claims.scope.split(' ');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function tokenAudiences(claims: jose.JWTPayload): string[] {
|
||||
if (Array.isArray(claims.aud)) return claims.aud;
|
||||
if (typeof claims.aud === 'string') return [claims.aud];
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Lists the preseeded API', async ({ page }) => {
|
||||
await page.goto('/settings/admin/apis');
|
||||
|
||||
const row = page.getByRole('row', { name: apis.orders.name });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText(apis.orders.resource);
|
||||
});
|
||||
|
||||
test('Create API', async ({ page }) => {
|
||||
await page.goto('/settings/admin/apis');
|
||||
|
||||
await page.getByRole('button', { name: 'Add API' }).click();
|
||||
await page.getByLabel('Name', { exact: true }).fill('Billing API');
|
||||
await page.getByLabel('Resource').fill('https://api.billing.test');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API created successfully');
|
||||
await page.waitForURL('/settings/admin/apis/*');
|
||||
|
||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Billing API');
|
||||
await expect(page.getByLabel('Resource')).toHaveValue('https://api.billing.test');
|
||||
});
|
||||
|
||||
test('Cannot create an API with the issuer as resource', async ({ page }) => {
|
||||
const { issuer } = await page.request
|
||||
.get('/.well-known/openid-configuration')
|
||||
.then((r) => r.json());
|
||||
|
||||
await page.goto('/settings/admin/apis');
|
||||
await page.getByRole('button', { name: 'Add API' }).click();
|
||||
await page.getByLabel('Name', { exact: true }).fill('Reserved API');
|
||||
await page.getByLabel('Resource').fill(issuer);
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="error"]')).toContainText('reserved');
|
||||
});
|
||||
|
||||
test('Edit the name of an API', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/apis/${apis.orders.id}`);
|
||||
|
||||
await page.getByLabel('Name', { exact: true }).fill('Orders API renamed');
|
||||
await page.getByRole('button', { name: 'Save' }).nth(0).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API updated successfully');
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Orders API renamed');
|
||||
});
|
||||
|
||||
test('Add a permission to an API', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/apis/${apis.orders.id}`);
|
||||
|
||||
// The seeded API already has permissions, so the button reads "Add another"
|
||||
await page.getByRole('button', { name: 'Add another' }).click();
|
||||
await page.getByPlaceholder('Permission', { exact: true }).last().fill('ship:orders');
|
||||
await page.getByPlaceholder('Name', { exact: true }).last().fill('Ship orders');
|
||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText(
|
||||
'Permissions updated successfully'
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
// The two seeded permissions plus the newly added one
|
||||
await expect(page.getByPlaceholder('Permission', { exact: true })).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('Delete an API', async ({ page }) => {
|
||||
await page.goto('/settings/admin/apis');
|
||||
|
||||
await page.getByRole('row', { name: apis.orders.name }).getByRole('button').click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API deleted successfully');
|
||||
await expect(page.getByRole('row', { name: apis.orders.name })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Grant a client user-delegated and client access to API permissions', async ({ page }) => {
|
||||
// Nextcloud has no API access granted by default
|
||||
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);
|
||||
|
||||
// Expand the API access card, then edit the Orders API row
|
||||
await page.getByText('API access', { exact: true }).click();
|
||||
await page
|
||||
.getByRole('row', { name: apis.orders.name })
|
||||
.getByRole('button', { name: 'Edit' })
|
||||
.click();
|
||||
|
||||
// Grant read:orders and write:orders on behalf of users, but only write:orders for the client itself
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog
|
||||
.getByRole('checkbox', {
|
||||
name: `User-delegated access: ${apis.orders.permissions.readOrders.name}`
|
||||
})
|
||||
.click();
|
||||
await dialog
|
||||
.getByRole('checkbox', {
|
||||
name: `User-delegated access: ${apis.orders.permissions.writeOrders.name}`
|
||||
})
|
||||
.click();
|
||||
await dialog
|
||||
.getByRole('checkbox', {
|
||||
name: `Client access (M2M): ${apis.orders.permissions.writeOrders.name}`
|
||||
})
|
||||
.click();
|
||||
await dialog.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('API access updated successfully');
|
||||
// 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');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authorization flow with the RFC 8707 resource parameter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Authorization with a resource parameter issues a token audienced to that API', async ({
|
||||
page,
|
||||
baseURL
|
||||
}) => {
|
||||
const client = oidcClients.immich;
|
||||
const api = apis.orders;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
response_type: 'code',
|
||||
scope: 'openid email read:orders',
|
||||
resource: api.resource,
|
||||
redirect_uri: client.callbackUrl,
|
||||
state: 'nXx-6Qr-owc1SHBa',
|
||||
nonce: 'P1gN3PtpKHJgKUVcLpLjm'
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
const claims = jose.decodeJwt(res.access_token!);
|
||||
expect(tokenAudiences(claims)).toContain(api.resource);
|
||||
// Because openid was requested alongside the resource, the token also carries the issuer audience so it can still reach /userinfo
|
||||
expect(tokenAudiences(claims)).toContain(baseURL);
|
||||
expect(tokenScopes(claims)).toContain(api.permissions.readOrders.key);
|
||||
|
||||
// The same token can be presented at userinfo, by the client's explicit opt-in of requesting openid
|
||||
const userinfo = await page.request.get('/api/oidc/userinfo', {
|
||||
headers: { Authorization: 'Bearer ' + res.access_token }
|
||||
});
|
||||
expect(userinfo.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('Consent screen shows the friendly permission name for a resource request', async ({
|
||||
page
|
||||
}) => {
|
||||
const client = oidcClients.immich;
|
||||
const api = apis.orders;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
response_type: 'code',
|
||||
scope: 'openid read:orders',
|
||||
resource: api.resource,
|
||||
redirect_uri: client.callbackUrl,
|
||||
state: 'nXx-6Qr-owc1SHBa'
|
||||
});
|
||||
await page.goto(`/authorize?${params.toString()}`);
|
||||
|
||||
const scopeList = page.getByTestId('scopes');
|
||||
await expect(scopeList).toBeVisible();
|
||||
// The permission's friendly name is shown, not the raw scope key
|
||||
await expect(scopeList.getByText(api.permissions.readOrders.name, { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Requesting a custom scope without its resource is rejected with invalid_scope', async ({
|
||||
page
|
||||
}) => {
|
||||
const client = oidcClients.immich;
|
||||
|
||||
// The client is allowed read:orders, but it is requested without the resource parameter
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
response_type: 'code',
|
||||
scope: 'openid read:orders',
|
||||
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()}`);
|
||||
}
|
||||
);
|
||||
|
||||
expect(callbackUrl.searchParams.get('error')).toBe('invalid_scope');
|
||||
expect(callbackUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Separation of user-delegated and client (machine-to-machine) access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Client credentials issues a token for a client-granted permission', async ({ page }) => {
|
||||
const client = oidcClients.immich;
|
||||
const api = apis.orders;
|
||||
|
||||
// write:orders is granted to Immich for client access
|
||||
const res = await oidcUtil.exchangeCode(page, {
|
||||
grant_type: 'client_credentials',
|
||||
client_id: client.id,
|
||||
client_secret: client.secret,
|
||||
scope: api.permissions.writeOrders.key,
|
||||
resource: api.resource
|
||||
});
|
||||
expect(res.access_token).toBeTruthy();
|
||||
|
||||
const claims = jose.decodeJwt(res.access_token!);
|
||||
expect(tokenAudiences(claims)).toContain(api.resource);
|
||||
expect(tokenScopes(claims)).toContain(api.permissions.writeOrders.key);
|
||||
});
|
||||
|
||||
test('Client credentials cannot mint a permission that is only user-delegated', async ({
|
||||
page
|
||||
}) => {
|
||||
const client = oidcClients.immich;
|
||||
const api = apis.orders;
|
||||
|
||||
// read:orders is only granted for user-delegated access
|
||||
const res = await oidcUtil.exchangeCode(page, {
|
||||
grant_type: 'client_credentials',
|
||||
client_id: client.id,
|
||||
client_secret: client.secret,
|
||||
scope: api.permissions.readOrders.key,
|
||||
resource: api.resource
|
||||
});
|
||||
|
||||
expect(res.access_token).toBeFalsy();
|
||||
expect(res.error).toBe('invalid_scope');
|
||||
});
|
||||
|
||||
test('Authorization on behalf of a user cannot request a client-only permission', async ({
|
||||
page
|
||||
}) => {
|
||||
const client = oidcClients.immich;
|
||||
const api = apis.orders;
|
||||
|
||||
// write:orders is only granted for client access, so users cannot be asked to delegate it
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.id,
|
||||
response_type: 'code',
|
||||
scope: `openid ${api.permissions.writeOrders.key}`,
|
||||
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()}`);
|
||||
}
|
||||
);
|
||||
|
||||
// The authorize endpoint collapses every resource-targeted scope/resource failure into a generic invalid_request
|
||||
expect(callbackUrl.searchParams.get('error')).toBe('invalid_request');
|
||||
expect(callbackUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
});
|
||||
@@ -398,7 +398,8 @@ test.describe('Introspection endpoint', () => {
|
||||
expect(introspectionBody.active).toBe(true);
|
||||
expect(introspectionBody.iss).toBe(baseURL);
|
||||
expect(introspectionBody.sub).toBe(users.tim.id);
|
||||
expect(introspectionBody.aud).toStrictEqual([oidcClients.nextcloud.id]);
|
||||
// An identity access token is audienced to the client and additionally to the issuer, so it can be presented at /userinfo
|
||||
expect(introspectionBody.aud).toStrictEqual([oidcClients.nextcloud.id, baseURL]);
|
||||
});
|
||||
|
||||
test('succeeds with federated client credentials', async ({ page, request, baseURL }) => {
|
||||
@@ -427,7 +428,8 @@ test.describe('Introspection endpoint', () => {
|
||||
expect(introspectionBody.active).toBe(true);
|
||||
expect(introspectionBody.iss).toBe(baseURL);
|
||||
expect(introspectionBody.sub).toBe(users.tim.id);
|
||||
expect(introspectionBody.aud).toStrictEqual([oidcClients.federated.id]);
|
||||
// An identity access token is audienced to the client and additionally to the issuer, so it can be presented at /userinfo
|
||||
expect(introspectionBody.aud).toStrictEqual([oidcClients.federated.id, baseURL]);
|
||||
});
|
||||
|
||||
test('fails with client credentials for wrong app', async ({ request }) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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.getByText('SCIM Provisioning', { exact: true }).click();
|
||||
|
||||
await page
|
||||
.getByLabel('SCIM Endpoint')
|
||||
@@ -29,7 +29,7 @@ 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.getByText('SCIM Provisioning', { exact: true }).click();
|
||||
|
||||
await page.getByLabel('SCIM Endpoint').fill('http://scim.provider/api');
|
||||
await page.getByLabel('SCIM Token').fill('supersecrettoken');
|
||||
|
||||
Reference in New Issue
Block a user