@@ -91,6 +93,7 @@
bind:value={input.value}
{disabled}
oninput={(e) => onInput?.(e)}
+ {readonly}
/>
{/if}
{/if}
diff --git a/frontend/src/lib/components/scope-list.svelte b/frontend/src/lib/components/scope-list.svelte
index fadeb6f3..83eb038d 100644
--- a/frontend/src/lib/components/scope-list.svelte
+++ b/frontend/src/lib/components/scope-list.svelte
@@ -1,13 +1,19 @@
-
+
{#if scopes.includes('email')}
{/if}
@@ -25,4 +31,11 @@
description={m.view_the_groups_you_are_a_member_of()}
/>
{/if}
+ {#each customScopes as scope}
+
+ {/each}
diff --git a/frontend/src/lib/components/ui/card/card-description.svelte b/frontend/src/lib/components/ui/card/card-description.svelte
index 54805ad3..293d8250 100644
--- a/frontend/src/lib/components/ui/card/card-description.svelte
+++ b/frontend/src/lib/components/ui/card/card-description.svelte
@@ -13,7 +13,7 @@
{@render children?.()}
diff --git a/frontend/src/lib/services/apis-service.ts b/frontend/src/lib/services/apis-service.ts
new file mode 100644
index 00000000..29789722
--- /dev/null
+++ b/frontend/src/lib/services/apis-service.ts
@@ -0,0 +1,56 @@
+import type {
+ Api,
+ ApiCreate,
+ ApiListItem,
+ ApiPermissionInput,
+ ApiUpdate,
+ ClientApiAccess
+} from '$lib/types/api.type';
+import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
+import APIService from './api-service';
+
+export default class ApisService extends APIService {
+ list = async (options?: ListRequestOptions) => {
+ const res = await this.api.get('/apis', { params: options });
+ return res.data as Paginated;
+ };
+
+ listAll = async () => {
+ const res = await this.api.get('/apis', { params: { pagination: { page: 1, limit: 1000 } } });
+ return (res.data as Paginated).data;
+ };
+
+ get = async (id: string) => {
+ const res = await this.api.get(`/apis/${id}`);
+ return res.data as Api;
+ };
+
+ create = async (api: ApiCreate) => {
+ const res = await this.api.post('/apis', api);
+ return res.data as Api;
+ };
+
+ update = async (id: string, api: ApiUpdate) => {
+ const res = await this.api.put(`/apis/${id}`, api);
+ return res.data as Api;
+ };
+
+ remove = async (id: string) => {
+ await this.api.delete(`/apis/${id}`);
+ };
+
+ updatePermissions = async (id: string, permissions: ApiPermissionInput[]) => {
+ const res = await this.api.put(`/apis/${id}/permissions`, { permissions });
+ return res.data as Api;
+ };
+
+ getClientAccess = async (clientId: string) => {
+ const res = await this.api.get(`/api-access/${clientId}`);
+ return res.data as ClientApiAccess;
+ };
+
+ updateClientAccess = async (clientId: string, access: ClientApiAccess) => {
+ const res = await this.api.put(`/api-access/${clientId}`, access);
+ return res.data as ClientApiAccess;
+ };
+}
diff --git a/frontend/src/lib/types/api.type.ts b/frontend/src/lib/types/api.type.ts
new file mode 100644
index 00000000..7f03a29e
--- /dev/null
+++ b/frontend/src/lib/types/api.type.ts
@@ -0,0 +1,38 @@
+export type ApiPermission = {
+ id: string;
+ key: string;
+ name: string;
+ description?: string;
+};
+
+export type Api = {
+ id: string;
+ name: string;
+ resource: string;
+ createdAt: string;
+ permissions: ApiPermission[];
+};
+
+export type ApiListItem = Omit & {
+ permissionCount: number;
+};
+
+export type ApiCreate = {
+ name: string;
+ resource: string;
+};
+
+export type ApiUpdate = {
+ name: string;
+};
+
+export type ApiPermissionInput = {
+ key: string;
+ name: string;
+ description: string;
+};
+
+export type ClientApiAccess = {
+ userDelegatedPermissionIds: string[];
+ clientPermissionIds: string[];
+};
diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts
index 729d4788..d78f3a23 100644
--- a/frontend/src/lib/types/oidc.type.ts
+++ b/frontend/src/lib/types/oidc.type.ts
@@ -43,7 +43,10 @@ export type OidcClientWithAllowedUserGroupsCount = OidcClient & {
allowedUserGroupsCount: number;
};
-export type OidcClientUpdate = Omit;
+export type OidcClientUpdate = Omit<
+ OidcClient,
+ 'id' | 'logoURL' | 'hasLogo' | 'hasDarkLogo' | 'pkceSupported'
+>;
export type OidcClientCreate = OidcClientUpdate & {
id?: string;
};
@@ -61,6 +64,7 @@ export type OidcClientCreateWithLogo = OidcClientCreate & {
export type OidcDeviceCodeInfo = {
scope: string[];
+ scopeInfo: InteractionScopeInfo[];
authorizationRequired: boolean;
reauthenticationRequired: boolean;
client: OidcClientMetaData;
@@ -72,9 +76,16 @@ export type AccessibleOidcClient = OidcClientMetaData & {
export type InteractionStep = 'authenticate' | 'select_account' | 'reauthenticate' | 'consent';
+export type InteractionScopeInfo = {
+ key: string;
+ name: string;
+ description?: string;
+};
+
export type InteractionSession = {
id: string;
scopes: string[];
+ scopeInfo: InteractionScopeInfo[];
client: OidcClientMetaData;
currentStep?: InteractionStep;
requiredSteps: InteractionStep[];
diff --git a/frontend/src/routes/device/+page.svelte b/frontend/src/routes/device/+page.svelte
index c8763a18..e4d8c046 100644
--- a/frontend/src/routes/device/+page.svelte
+++ b/frontend/src/routes/device/+page.svelte
@@ -121,8 +121,8 @@
{:else if authorizationRequired}
diff --git a/frontend/src/routes/interaction/+page.svelte b/frontend/src/routes/interaction/+page.svelte
index b0371d95..24734ebe 100644
--- a/frontend/src/routes/interaction/+page.svelte
+++ b/frontend/src/routes/interaction/+page.svelte
@@ -170,7 +170,7 @@
{:else if currentStep === 'consent'}
diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte
index 7076e2d8..1e20120e 100644
--- a/frontend/src/routes/settings/+layout.svelte
+++ b/frontend/src/routes/settings/+layout.svelte
@@ -34,6 +34,7 @@
{ href: '/settings/admin/users', label: m.users() },
{ href: '/settings/admin/user-groups', label: m.user_groups() },
{ href: '/settings/admin/oidc-clients', label: m.oidc_clients() },
+ { href: '/settings/admin/apis', label: m.apis() },
{ href: '/settings/admin/api-keys', label: m.api_keys() },
{ href: '/settings/admin/application-configuration', label: m.application_configuration() }
];
diff --git a/frontend/src/routes/settings/admin/apis/+page.svelte b/frontend/src/routes/settings/admin/apis/+page.svelte
new file mode 100644
index 00000000..67fb1c30
--- /dev/null
+++ b/frontend/src/routes/settings/admin/apis/+page.svelte
@@ -0,0 +1,83 @@
+
+
+
+ {m.apis()}
+
+
+
+
+
+
+
+
+
+ {m.create_api()}
+
+ {m.create_a_new_api_description()}
+
+ {#if !expandAddApi}
+
+ {:else}
+
+ {/if}
+
+
+ {#if expandAddApi}
+
+ {/if}
+
+
+
+
+
+
+
+
+ {m.manage_apis()}
+
+
+
+
+
+
+
diff --git a/frontend/src/routes/settings/admin/apis/[id]/+page.svelte b/frontend/src/routes/settings/admin/apis/[id]/+page.svelte
new file mode 100644
index 00000000..a99f2e0d
--- /dev/null
+++ b/frontend/src/routes/settings/admin/apis/[id]/+page.svelte
@@ -0,0 +1,88 @@
+
+
+
+ {api.name}
+
+
+
+
+
+
+
+
+ {m.general()}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/routes/settings/admin/apis/[id]/+page.ts b/frontend/src/routes/settings/admin/apis/[id]/+page.ts
new file mode 100644
index 00000000..c9e97c54
--- /dev/null
+++ b/frontend/src/routes/settings/admin/apis/[id]/+page.ts
@@ -0,0 +1,7 @@
+import ApisService from '$lib/services/apis-service';
+import type { PageLoad } from './$types';
+
+export const load: PageLoad = async ({ params }) => {
+ const api = await new ApisService().get(params.id);
+ return { api };
+};
diff --git a/frontend/src/routes/settings/admin/apis/[id]/api-permissions-input.svelte b/frontend/src/routes/settings/admin/apis/[id]/api-permissions-input.svelte
new file mode 100644
index 00000000..1530885a
--- /dev/null
+++ b/frontend/src/routes/settings/admin/apis/[id]/api-permissions-input.svelte
@@ -0,0 +1,44 @@
+
+
+
+{#if permissions.length < limit}
+
+{/if}
diff --git a/frontend/src/routes/settings/admin/apis/api-form.svelte b/frontend/src/routes/settings/admin/apis/api-form.svelte
new file mode 100644
index 00000000..94b5cfae
--- /dev/null
+++ b/frontend/src/routes/settings/admin/apis/api-form.svelte
@@ -0,0 +1,65 @@
+
+
+
diff --git a/frontend/src/routes/settings/admin/apis/api-list.svelte b/frontend/src/routes/settings/admin/apis/api-list.svelte
new file mode 100644
index 00000000..2f2310ac
--- /dev/null
+++ b/frontend/src/routes/settings/admin/apis/api-list.svelte
@@ -0,0 +1,74 @@
+
+
+
diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte
index 4270bcf1..39197c8a 100644
--- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte
+++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte
@@ -23,6 +23,7 @@
import { backNavigate } from '../../users/navigate-back-util';
import OidcForm from '../oidc-client-form.svelte';
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
+ import ApiAccessCard from './api-access-card.svelte';
import ScimResourceProviderForm from './scim-resource-provider-form.svelte';
let { data } = $props();
@@ -315,6 +316,9 @@
>
+
+
+
+ import { goto } from '$app/navigation';
+ import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
+ import { Button } from '$lib/components/ui/button';
+ import { Spinner } from '$lib/components/ui/spinner';
+ import * as Table from '$lib/components/ui/table';
+ import { m } from '$lib/paraglide/messages';
+ import ApisService from '$lib/services/apis-service';
+ import type { Api } from '$lib/types/api.type';
+ import { axiosErrorToast } from '$lib/utils/error-util';
+ import { onMount } from 'svelte';
+ import { toast } from 'svelte-sonner';
+ import ApiPermissionsModal from './api-permissions-modal.svelte';
+
+ let { clientId, isPublicClient }: { clientId: string; isPublicClient: boolean } = $props();
+
+ const apisService = new ApisService();
+
+ let apis = $state([]);
+ let userSelected = $state>(new Set());
+ let clientSelected = $state>(new Set());
+ let loading = $state(true);
+
+ let editingApi = $state(null);
+ let modalOpen = $state(false);
+
+ onMount(async () => {
+ try {
+ const [list, access] = await Promise.all([
+ apisService.listAll(),
+ apisService.getClientAccess(clientId)
+ ]);
+ apis = await Promise.all(list.map((a) => apisService.get(a.id)));
+ userSelected = new Set(access.userDelegatedPermissionIds);
+ clientSelected = new Set(access.clientPermissionIds);
+ } catch (e) {
+ axiosErrorToast(e);
+ } finally {
+ loading = false;
+ }
+ });
+
+ function grantedCount(api: Api, selected: Set) {
+ return api.permissions.filter((p) => selected.has(p.id)).length;
+ }
+
+ function openEdit(api: Api) {
+ editingApi = api;
+ modalOpen = true;
+ }
+
+ function allowedIdsFor(api: Api, selected: Set) {
+ return api.permissions.filter((p) => selected.has(p.id)).map((p) => p.id);
+ }
+
+ async function saveApi(api: Api, userIds: string[], clientIds: string[]) {
+ // Grants of other APIs stay untouched, and for public clients the (never editable) client grants are sent back unchanged
+ const otherUser = [...userSelected].filter((id) => !api.permissions.some((p) => p.id === id));
+ const otherClient = [...clientSelected].filter(
+ (id) => !api.permissions.some((p) => p.id === id)
+ );
+ const res = await apisService.updateClientAccess(clientId, {
+ userDelegatedPermissionIds: [...otherUser, ...userIds],
+ clientPermissionIds: isPublicClient ? [...clientSelected] : [...otherClient, ...clientIds]
+ });
+ userSelected = new Set(res.userDelegatedPermissionIds);
+ clientSelected = new Set(res.clientPermissionIds);
+ toast.success(m.api_access_updated_successfully());
+ }
+
+
+{#if loading}
+
+
+
+{:else if apis.length === 0}
+
+
{m.no_apis_defined_yet()}
+
+
+{:else}
+
+
+
+ {m.api_name()}
+ {m.user_delegated_access()}
+ {#if !isPublicClient}
+ {m.client_access()}
+ {/if}
+
+
+
+
+ {#each apis as api}
+
+
+
+
{api.name}
+
+
+ {api.resource}
+
+
+
+
+
+ {m.permissions_granted_count({
+ granted: String(grantedCount(api, userSelected)),
+ total: String(api.permissions.length)
+ })}
+
+ {#if !isPublicClient}
+
+ {m.permissions_granted_count({
+ granted: String(grantedCount(api, clientSelected)),
+ total: String(api.permissions.length)
+ })}
+
+ {/if}
+
+
+
+
+ {/each}
+
+
+{/if}
+
+{#if editingApi}
+ saveApi(editingApi!, userIds, clientIds)}
+ />
+{/if}
diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/api-permissions-modal.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/api-permissions-modal.svelte
new file mode 100644
index 00000000..d2e88842
--- /dev/null
+++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/api-permissions-modal.svelte
@@ -0,0 +1,153 @@
+
+
+{#snippet KeyCell({ item }: { item: ApiPermission })}
+ {item.key}
+{/snippet}
+
+{#snippet UserDelegatedCell({ item }: { item: ApiPermission })}
+ (workingUser = toggle(workingUser, item.id, checked))}
+ />
+{/snippet}
+
+{#snippet ClientAccessCell({ item }: { item: ApiPermission })}
+
+ (workingClient = toggle(workingClient, item.id, checked))}
+ />
+{/snippet}
+
+
+
+
+ {api.name}
+
+ {m.select_the_permissions_this_client_may_request()}
+ {#if !showClientAccess}
+ {m.client_access_unavailable_for_public_clients()}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/data.ts b/tests/data.ts
index e7357925..5eb0e5a0 100644
--- a/tests/data.ts
+++ b/tests/data.ts
@@ -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',
diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json
index 0fcbfb4b..5cd27203 100644
--- a/tests/resources/export/database.json
+++ b/tests/resources/export/database.json
@@ -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",
diff --git a/tests/specs/api.spec.ts b/tests/specs/api.spec.ts
new file mode 100644
index 00000000..dd5a1aef
--- /dev/null
+++ b/tests/specs/api.spec.ts
@@ -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).scp)) {
+ return (claims as Record).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');
+});
diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts
index b3461b87..4ca4d382 100644
--- a/tests/specs/oidc.spec.ts
+++ b/tests/specs/oidc.spec.ts
@@ -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 }) => {
diff --git a/tests/specs/scim.spec.ts b/tests/specs/scim.spec.ts
index c4711c23..2b7ccafe 100644
--- a/tests/specs/scim.spec.ts
+++ b/tests/specs/scim.spec.ts
@@ -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');