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}`); // Open the API access tab, then edit the Orders API row await page.getByRole('tab', { name: 'API access' }).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'); });