import test, { expect, type APIRequestContext, type Page, type Request } from '@playwright/test'; import { oidcClients, refreshTokens, users } from '../data'; import { cleanupBackend } from '../utils/cleanup.util'; import { generateIdToken } from '../utils/jwt.util'; import * as oidcUtil from '../utils/oidc.util'; import passkeyUtil from '../utils/passkey.util'; test.beforeEach(async () => await cleanupBackend()); async function generateSeededOauthAccessToken( request: APIRequestContext, userId: string, clientId: string, expired = false ) { return request .post('/api/test/accesstoken', { data: { client: clientId, expired, user: userId } }) .then((r) => r.text()); } test('Authorize existing client', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.goto(`/authorize?${urlParams.toString()}`) ); }); test('Authorize existing client while not signed in', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); await page.context().clearCookies(); await expectCallbackRedirect(page, oidcClient.callbackUrl, async () => { await page.goto(`/authorize?${urlParams.toString()}`); await (await passkeyUtil.init(page)).addPasskey(); await page.getByRole('button', { name: 'Sign in' }).click(); }); }); test('Authorize new client', async ({ page }) => { const oidcClient = oidcClients.immich; const urlParams = createUrlParams(oidcClient); await page.goto(`/authorize?${urlParams.toString()}`); await expectScopes(page, ['Email', 'Profile']); await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() ); }); test('Authorize client requesting offline_access scope', async ({ page }) => { const oidcClient = oidcClients.immich; const urlParams = createUrlParams(oidcClient); urlParams.set('scope', 'openid profile email offline_access'); await page.goto(`/authorize?${urlParams.toString()}`); // offline_access is a valid OIDC scope: the flow must reach the consent screen rather than // being rejected with invalid_scope (offline_access itself has no displayable scope item) await expectScopes(page, ['Email', 'Profile']); const callbackUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() ); expect(callbackUrl.searchParams.get('code')).toBeTruthy(); expect(callbackUrl.searchParams.get('error')).toBeNull(); }); test('Authorize existing client without scopes', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = new URLSearchParams({ client_id: oidcClient.id, response_type: 'code', redirect_uri: oidcClient.callbackUrl, state: 'no-scope-state', nonce: 'no-scope-nonce' }); await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.goto(`/authorize?${urlParams.toString()}`) ); }); test('Authorize new client without scopes', async ({ page }) => { const oidcClient = oidcClients.immich; const urlParams = new URLSearchParams({ client_id: oidcClient.id, response_type: 'code', redirect_uri: oidcClient.callbackUrl, state: 'no-scope-new-client', nonce: 'no-scope-new-nonce' }); await page.goto(`/authorize?${urlParams.toString()}`); // With no scopes there is nothing to display, but the page should still allow sign-in await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); const callbackUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() ); expect(callbackUrl.searchParams.get('code')).toBeTruthy(); expect(callbackUrl.searchParams.get('error')).toBeNull(); expect(callbackUrl.searchParams.get('state')).toBe('no-scope-new-client'); }); test('Authorize new client while not signed in', async ({ page }) => { const oidcClient = oidcClients.immich; const urlParams = createUrlParams(oidcClient); await page.context().clearCookies(); await page.goto(`/authorize?${urlParams.toString()}`); await (await passkeyUtil.init(page)).addPasskey(); await page.getByRole('button', { name: 'Sign in' }).click(); await expectScopes(page, ['Email', 'Profile']); await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() ); }); test('Authorize new client fails with user group not allowed', async ({ page }) => { const oidcClient = oidcClients.immich; const urlParams = createUrlParams(oidcClient); await page.context().clearCookies(); await page.goto(`/authorize?${urlParams.toString()}`); await (await passkeyUtil.init(page)).addPasskey('craig'); await page.getByRole('button', { name: 'Sign in' }).click(); await expectScopes(page, ['Email', 'Profile']); const callbackUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() ); expect(callbackUrl.searchParams.get('error')).toBe('access_denied'); expect(callbackUrl.searchParams.get('error_description')).toContain( 'You are not allowed to access this service.' ); expect(callbackUrl.searchParams.get('state')).toBe(urlParams.get('state')); }); function createUrlParams(oidcClient: { id: string; callbackUrl: string }) { return new URLSearchParams({ client_id: oidcClient.id, response_type: 'code', scope: 'openid profile email', redirect_uri: oidcClient.callbackUrl, state: 'nXx-6Qr-owc1SHBa', nonce: 'P1gN3PtpKHJgKUVcLpLjm' }); } async function expectScopes(page: Page, scopes: string[]) { const scopeList = page.getByTestId('scopes').filter({ hasText: scopes[0] }).last(); await expect(scopeList).toBeVisible(); for (const scope of scopes) { await expect(scopeList.getByText(scope, { exact: true })).toBeVisible(); } } test('End session without id token hint shows confirmation page', async ({ page }) => { await page.goto('/api/oidc/end-session'); await expect(page).toHaveURL('/logout'); await page.getByRole('button', { name: 'Sign out' }).click(); await expect(page).toHaveURL('/login?redirect=%2F'); }); test('End session with id token hint redirects to callback URL', async ({ page }) => { const client = oidcClients.nextcloud; const idToken = await generateIdToken( 'fe81c12a-7336-4aee-bebc-d901a873bf48', users.tim, client.id ); await expectCallbackRedirect(page, client.logoutCallbackUrl, () => page.goto( `/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}` ) ); }); test('End session with id token hint redirects to callback URL without UI session', async ({ page }) => { const client = oidcClients.nextcloud; const idToken = await generateIdToken( 'fe81c12a-7336-4aee-bebc-d901a873bf48', users.tim, client.id ); await page.context().clearCookies(); const callbackUrl = await expectCallbackRedirect(page, client.logoutCallbackUrl, () => page.goto( `/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}&state=logout-state` ) ); expect(callbackUrl.searchParams.get('state')).toBe('logout-state'); }); test('End session ignores an unregistered post_logout_redirect_uri', async ({ page }) => { // A post_logout_redirect_uri the client never registered must not be honored, otherwise // RP-initiated logout becomes an open redirect. The handler falls back to the in-app // logout confirmation instead of bouncing the user to the attacker URL. const client = oidcClients.nextcloud; const idToken = await generateIdToken( 'fe81c12a-7336-4aee-bebc-d901a873bf48', users.tim, client.id ); await page.goto( `/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=http://evil.localhost/steal` ); await expect(page).toHaveURL('/logout'); }); test('End session rejects an id token hint with a mismatched client_id', async ({ page }) => { const client = oidcClients.nextcloud; const idToken = await generateIdToken( 'fe81c12a-7336-4aee-bebc-d901a873bf48', users.tim, client.id ); // The explicit client_id contradicts the audience baked into the id_token_hint, so the // logout must not be auto-confirmed against another client's callback. await page.goto( `/api/oidc/end-session?id_token_hint=${idToken}&client_id=${oidcClients.immich.id}` ); await expect(page).toHaveURL('/logout'); }); test('Successfully refresh tokens with valid refresh token', async ({ request }) => { const { token, clientId, userId } = refreshTokens.filter((token) => !token.expired)[0]; const clientSecret = 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'; // Sign the refresh token const refreshToken = await request .post('/api/test/refreshtoken', { data: { rt: token, client: clientId, user: userId } }) .then((r) => r.text()); // Perform the exchange const refreshResponse = await request.post('/api/oidc/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, form: { grant_type: 'refresh_token', client_id: clientId, refresh_token: refreshToken, client_secret: clientSecret } }); // Verify we got new tokens const tokenData = await refreshResponse.json(); expect(tokenData.access_token).toBeDefined(); expect(tokenData.refresh_token).toBeDefined(); expect(tokenData.id_token).toBeDefined(); expect(tokenData.token_type).toBe('bearer'); expect(tokenData.expires_in).toBeGreaterThanOrEqual(3598); expect(tokenData.expires_in).toBeLessThanOrEqual(3600); // The new refresh token should be different from the old one expect(tokenData.refresh_token).not.toBe(token); }); test('Refresh token fails when used for the wrong client', async ({ request }) => { const { token, clientId, userId } = refreshTokens.filter((token) => !token.expired)[0]; const clientSecret = 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'; // Sign the refresh token const refreshToken = await request .post('/api/test/refreshtoken', { data: { rt: token, client: 'bad-client', user: userId } }) .then((r) => r.text()); // Perform the exchange const refreshResponse = await request.post('/api/oidc/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, form: { grant_type: 'refresh_token', client_id: clientId, refresh_token: refreshToken, client_secret: clientSecret } }); expect(refreshResponse.status()).toBe(400); }); test('Refresh token fails when used for the wrong user', async ({ request }) => { const { token, clientId } = refreshTokens.filter((token) => !token.expired)[0]; const clientSecret = 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'; // Sign the refresh token const refreshToken = await request .post('/api/test/refreshtoken', { data: { rt: token, client: clientId, user: '44cb5d71-db31-4555-9a1b-5484650f6002' } }) .then((r) => r.text()); // Perform the exchange const refreshResponse = await request.post('/api/oidc/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, form: { grant_type: 'refresh_token', client_id: clientId, refresh_token: refreshToken, client_secret: clientSecret } }); expect(refreshResponse.status()).toBe(400); }); test('Using refresh token invalidates it for future use', async ({ request }) => { const { token, clientId, userId } = refreshTokens.filter((token) => !token.expired)[0]; const clientSecret = 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'; // Sign the refresh token const refreshToken = await request .post('/api/test/refreshtoken', { data: { rt: token, client: clientId, user: userId } }) .then((r) => r.text()); // Perform the exchange await request.post('/api/oidc/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, form: { grant_type: 'refresh_token', client_id: clientId, refresh_token: refreshToken, client_secret: clientSecret } }); // Try again const refreshResponse = await request.post('/api/oidc/token', { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, form: { grant_type: 'refresh_token', client_id: clientId, refresh_token: refreshToken, client_secret: clientSecret } }); expect(refreshResponse.status()).toBe(400); }); test.describe('Introspection endpoint', () => { test('fails without client credentials', async ({ request }) => { const validAccessToken = await generateSeededOauthAccessToken( request, users.tim.id, oidcClients.nextcloud.id ); const introspectionResponse = await request.post('/api/oidc/introspect', { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, form: { token: validAccessToken } }); expect(introspectionResponse.status()).toBe(401); }); test('succeeds with client credentials', async ({ request, baseURL }) => { const validAccessToken = await generateSeededOauthAccessToken( request, users.tim.id, oidcClients.nextcloud.id ); const introspectionResponse = await request.post('/api/oidc/introspect', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${oidcClients.nextcloud.id}:${oidcClients.nextcloud.secret}`).toString( 'base64' ) }, form: { token: validAccessToken } }); expect(introspectionResponse.status()).toBe(200); const introspectionBody = await introspectionResponse.json(); expect(introspectionBody.active).toBe(true); expect(introspectionBody.iss).toBe(baseURL); expect(introspectionBody.sub).toBe(users.tim.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 }) => { const validAccessToken = await generateSeededOauthAccessToken( request, users.tim.id, oidcClients.federated.id ); const clientAssertion = await oidcUtil.getClientAssertion( page, oidcClients.federated.federatedJWT ); const introspectionResponse = await request.post('/api/oidc/introspect', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Bearer ' + clientAssertion }, form: { client_id: oidcClients.federated.id, token: validAccessToken } }); expect(introspectionResponse.status()).toBe(200); const introspectionBody = await introspectionResponse.json(); expect(introspectionBody.active).toBe(true); expect(introspectionBody.iss).toBe(baseURL); expect(introspectionBody.sub).toBe(users.tim.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 }) => { const validAccessToken = await generateSeededOauthAccessToken( request, users.tim.id, oidcClients.nextcloud.id ); const introspectionResponse = await request.post('/api/oidc/introspect', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${oidcClients.immich.id}:${oidcClients.immich.secret}`).toString('base64') }, form: { token: validAccessToken } }); const introspectionBody = await introspectionResponse.json(); expect(introspectionBody.active).toBe(false); }); test('fails with federated credentials for wrong app', async ({ page, request }) => { const validAccessToken = await generateSeededOauthAccessToken( request, users.tim.id, oidcClients.nextcloud.id ); const clientAssertion = await oidcUtil.getClientAssertion( page, oidcClients.federated.federatedJWT ); const introspectionResponse = await request.post('/api/oidc/introspect', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Bearer ' + clientAssertion }, form: { client_id: oidcClients.federated.id, token: validAccessToken } }); expect(introspectionResponse.status()).toBe(200); const introspectionBody = await introspectionResponse.json(); expect(introspectionBody.active).toBe(false); }); test('non-expired refresh_token can be verified', async ({ request }) => { const { token, clientId, userId } = refreshTokens.filter((token) => !token.expired)[0]; // Sign the refresh token const refreshToken = await request .post('/api/test/refreshtoken', { data: { rt: token, client: clientId, user: userId } }) .then((r) => r.text()); const introspectionResponse = await request.post('/api/oidc/introspect', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${oidcClients.nextcloud.id}:${oidcClients.nextcloud.secret}`).toString( 'base64' ) }, form: { token: refreshToken } }); expect(introspectionResponse.status()).toBe(200); const introspectionBody = await introspectionResponse.json(); expect(introspectionBody.active).toBe(true); }); test('expired refresh_token can be verified', async ({ request }) => { const { token, clientId, userId } = refreshTokens.filter((token) => token.expired)[0]; // Sign the refresh token const refreshToken = await request .post('/api/test/refreshtoken', { data: { rt: token, client: clientId, user: userId } }) .then((r) => r.text()); const introspectionResponse = await request.post('/api/oidc/introspect', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${oidcClients.nextcloud.id}:${oidcClients.nextcloud.secret}`).toString( 'base64' ) }, form: { token: refreshToken } }); expect(introspectionResponse.status()).toBe(200); const introspectionBody = await introspectionResponse.json(); expect(introspectionBody.active).toBe(false); }); test("expired access_token can't be verified", async ({ request }) => { const expiredAccessToken = await generateSeededOauthAccessToken( request, users.tim.id, oidcClients.nextcloud.id, true ); const introspectionResponse = await request.post('/api/oidc/introspect', { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${oidcClients.nextcloud.id}:${oidcClients.nextcloud.secret}`).toString( 'base64' ) }, form: { token: expiredAccessToken } }); expect(introspectionResponse.status()).toBe(200); const introspectionBody = await introspectionResponse.json(); expect(introspectionBody.active).toBe(false); }); }); test.describe('Userinfo endpoint', () => { test('returns the claims for the granted scopes', async ({ request, baseURL }) => { const accessToken = await generateSeededOauthAccessToken( request, users.tim.id, oidcClients.nextcloud.id ); const res = await request.get('/api/oidc/userinfo', { headers: { Authorization: 'Bearer ' + accessToken } }); expect(res.status()).toBe(200); const body = await res.json(); expect(body.sub).toBe(users.tim.id); expect(body.email).toBe(users.tim.email); expect(body.given_name).toBe(users.tim.firstname); expect(body.family_name).toBe(users.tim.lastname); expect(body.preferred_username).toBe(users.tim.username); // The picture claim must point at this instance, not leak an arbitrary host. expect(body.picture).toBe(`${baseURL}/api/users/${users.tim.id}/profile-picture.png`); }); test('fails without an access token', async ({ request }) => { const res = await request.get('/api/oidc/userinfo'); expect(res.status()).toBe(401); }); test('fails with an invalid access token', async ({ request }) => { const res = await request.get('/api/oidc/userinfo', { headers: { Authorization: 'Bearer not-a-real-token' } }); expect(res.status()).not.toBe(200); }); }); test('Authorize new client with device authorization flow', async ({ page }) => { const client = oidcClients.immich; const userCode = await oidcUtil.getUserCode(page, client.id, client.secret); await page.goto(`/device?user_code=${userCode}`); await expectScopes(page, ['Email', 'Profile']); await page.getByRole('button', { name: 'Authorize' }).click(); await expect( page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' }) ).toBeVisible(); }); test('Authorize new client with device authorization flow while not signed in', async ({ page }) => { await page.context().clearCookies(); const client = oidcClients.immich; const userCode = await oidcUtil.getUserCode(page, client.id, client.secret); await page.goto(`/device?user_code=${userCode}`); await (await passkeyUtil.init(page)).addPasskey(); await page.getByRole('button', { name: 'Authorize' }).click(); await expectScopes(page, ['Email', 'Profile']); await page.getByRole('button', { name: 'Authorize' }).click(); await expect( page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' }) ).toBeVisible(); }); test('Authorize existing client with device authorization flow', async ({ page }) => { const client = oidcClients.nextcloud; const userCode = await oidcUtil.getUserCode(page, client.id, client.secret); await page.goto(`/device?user_code=${userCode}`); await expect( page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' }) ).toBeVisible(); }); test('Authorize existing client with device authorization flow while not signed in', async ({ page }) => { await page.context().clearCookies(); const client = oidcClients.nextcloud; const userCode = await oidcUtil.getUserCode(page, client.id, client.secret); await page.goto(`/device?user_code=${userCode}`); await (await passkeyUtil.init(page)).addPasskey(); await page.getByRole('button', { name: 'Authorize' }).click(); await expect( page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' }) ).toBeVisible(); }); test('Device authorization flow forces reauthentication when client requires it', async ({ page, request }) => { const client = oidcClients.nextcloud; await request.put(`/api/oidc/clients/${client.id}`, { data: { name: client.name, callbackURLs: [client.callbackUrl], logoutCallbackURLs: [client.logoutCallbackUrl], isPublic: false, pkceEnabled: false, pkceSupported: false, requiresReauthentication: true, requiresPushedAuthorizationRequests: false, credentials: { federatedIdentities: [] }, isGroupRestricted: false } }); const userCode = await oidcUtil.getUserCode(page, client.id, client.secret); const directVerify = await page.request.post(`/api/oidc/device/verify?code=${userCode}`); expect(directVerify.status()).toBe(401); let reauthCalled = false; await page.route('/api/webauthn/login/start', async (route) => { reauthCalled = true; await route.continue(); }); await page.route('/api/webauthn/reauthenticate', async (route) => { reauthCalled = true; await route.continue(); }); await (await passkeyUtil.init(page)).addPasskey(); await page.goto(`/device?user_code=${userCode}`); await expect(page.getByText('Do you want to sign in to Nextcloud')).toBeVisible(); await page.getByRole('button', { name: 'Authorize' }).click(); await expect( page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' }) ).toBeVisible(); expect(reauthCalled).toBe(true); }); test('Authorize client with device authorization flow with invalid code', async ({ page }) => { await page.goto('/device?user_code=invalid-code'); await expect( page.getByRole('paragraph').filter({ hasText: 'Invalid device code.' }) ).toBeVisible(); }); test('Authorize new client with device authorization with user group not allowed', async ({ page }) => { await page.context().clearCookies(); const client = oidcClients.immich; const userCode = await oidcUtil.getUserCode(page, client.id, client.secret); await page.goto(`/device?user_code=${userCode}`); await (await passkeyUtil.init(page)).addPasskey('craig'); await page.getByRole('button', { name: 'Authorize' }).click(); await expectScopes(page, ['Email', 'Profile']); await page.getByRole('button', { name: 'Authorize' }).click(); await expect( page.getByRole('paragraph').filter({ hasText: "You're not allowed to access this service." }) ).toBeVisible(); }); test('Federated identity fails with invalid client assertion', async ({ page }) => { const client = oidcClients.federated; const res = await oidcUtil.exchangeCode(page, { client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', grant_type: 'authorization_code', redirect_uri: client.callbackUrl, code: client.accessCodes[0], client_id: client.id, client_assertion: 'not-an-assertion' }); expect(res?.error).toBe('invalid_client'); }); test('Authorize existing client with federated identity', async ({ page }) => { const client = oidcClients.federated; const clientAssertion = await oidcUtil.getClientAssertion(page, client.federatedJWT); const urlParams = createUrlParams(client); const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, async () => { await page.goto(`/authorize?${urlParams.toString()}`); const signInButton = page.getByRole('button', { name: /sign in/i }); await expect(signInButton).toBeVisible(); await signInButton.click(); }); const code = callbackUrl.searchParams.get('code'); expect(code).toBeTruthy(); const res = await oidcUtil.exchangeCode(page, { client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', grant_type: 'authorization_code', redirect_uri: client.callbackUrl, code: code!, client_id: client.id, client_assertion: clientAssertion }); expect(res.access_token).not.toBeNull(); expect(res.expires_in).not.toBeNull(); expect(res.token_type).toBe('bearer'); }); test('Forces reauthentication when client requires it', async ({ page, request }) => { let reauthCalled = false; await page.route('/api/webauthn/login/start', async (route) => { reauthCalled = true; await route.continue(); }); await page.route('/api/webauthn/reauthenticate', async (route) => { reauthCalled = true; await route.continue(); }); await request.put(`/api/oidc/clients/${oidcClients.nextcloud.id}`, { data: { name: oidcClients.nextcloud.name, callbackURLs: [oidcClients.nextcloud.callbackUrl], logoutCallbackURLs: [oidcClients.nextcloud.logoutCallbackUrl], isPublic: false, pkceEnabled: false, pkceSupported: false, requiresReauthentication: true, requiresPushedAuthorizationRequests: false, credentials: { federatedIdentities: [] }, isGroupRestricted: false } }); await (await passkeyUtil.init(page)).addPasskey(); const urlParams = createUrlParams(oidcClients.nextcloud); await expectCallbackRedirect(page, oidcClients.nextcloud.callbackUrl, async () => { await page.goto(`/authorize?${urlParams.toString()}`); await expect(page.getByTestId('scopes')).not.toBeVisible(); await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible(); await page.getByRole('button', { name: /sign in/i }).click(); }); expect(reauthCalled).toBe(true); }); test('Authorize existing client while not signed in with response_mode=form_post', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('response_mode', 'form_post'); await page.context().clearCookies(); await page.goto(`/authorize?${urlParams.toString()}`); await (await passkeyUtil.init(page)).addPasskey(); await expectFormPostCallback(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() ); }); test('Authorize existing client with response_mode=form_post', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('response_mode', 'form_post'); await expectFormPostCallback(page, oidcClient.callbackUrl, () => page.goto(`/authorize?${urlParams.toString()}`) ); }); test('Authorize existing client with response_mode=fragment', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('response_mode', 'fragment'); const redirectUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.goto(`/authorize?${urlParams.toString()}`) ); expect(redirectUrl.search).toBe(''); const fragmentParams = new URLSearchParams(redirectUrl.hash.slice(1)); expect(fragmentParams.get('code')).toBeTruthy(); expect(fragmentParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); expect(fragmentParams.get('iss')).toBeTruthy(); }); async function expectFormPostCallback( page: Page, callbackUrl: string, action: () => Promise, expectedState = 'nXx-6Qr-owc1SHBa' ): Promise { const isCallbackURL = callbackURLMatcher(callbackUrl); const callbackRouteMatcher = await routeCallbackPage(page, callbackUrl); try { const requestPromise = page.waitForRequest( (request) => request.method() === 'POST' && isCallbackURL(new URL(request.url())), { timeout: 5000 } ); const actionPromise = action().then( () => undefined, (error) => error ); const request = await requestPromise; await actionPromise; const formData = new URLSearchParams(request.postData() ?? ''); expect(formData.get('code')).toBeTruthy(); expect(formData.get('state')).toBe(expectedState); expect(formData.get('iss')).toBeTruthy(); return formData; } finally { if (!page.isClosed()) { await page.unroute(callbackRouteMatcher).catch(() => {}); } } } test.describe('OIDC prompt parameter', () => { test('prompt=none redirects with login_required when user not authenticated', async ({ page }) => { await page.context().clearCookies(); const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'none'); // Should redirect to callback URL with error const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () => page.goto(`/authorize?${urlParams.toString()}`).then(() => {}) ); expect(redirectUrl.searchParams.get('error')).toBe('login_required'); expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); }); test('prompt=none does not redirect login_required to an unregistered redirect_uri', async ({ page }) => { await page.context().clearCookies(); const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'none'); urlParams.set('redirect_uri', 'https://attacker.example/collect'); let attackerRedirected = false; await page.route('https://attacker.example/**', async (route) => { attackerRedirected = true; await route.fulfill({ status: 200, body: 'attacker' }); }); await page.goto(`/authorize?${urlParams.toString()}`); await expect(page.locator('body')).toContainText('is not registered for this client.'); expect(attackerRedirected).toBe(false); }); test('prompt=none redirects errors with response_mode=fragment', async ({ page }) => { await page.context().clearCookies(); const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'none'); urlParams.set('response_mode', 'fragment'); const redirectUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.goto(`/authorize?${urlParams.toString()}`) ); expect(redirectUrl.search).toBe(''); const fragmentParams = new URLSearchParams(redirectUrl.hash.slice(1)); expect(fragmentParams.get('error')).toBe('login_required'); expect(fragmentParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); }); test('prompt=none redirects with consent_required when authorization needed', async ({ page }) => { const oidcClient = oidcClients.immich; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'none'); // Should redirect to callback URL with error const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () => page.goto(`/authorize?${urlParams.toString()}`).then(() => {}) ); expect(redirectUrl.searchParams.get('error')).toBe('consent_required'); expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); }); test('prompt=none does not redirect consent_required to an unregistered redirect_uri', async ({ page }) => { const oidcClient = oidcClients.immich; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'none'); urlParams.set('redirect_uri', 'https://attacker.example/collect'); let attackerRedirected = false; await page.route('https://attacker.example/**', async (route) => { attackerRedirected = true; await route.fulfill({ status: 200, body: 'attacker' }); }); await page.goto(`/authorize?${urlParams.toString()}`); await expect(page.locator('body')).toContainText(''); expect(attackerRedirected).toBe(false); }); test('prompt=none succeeds when user is authenticated and authorized', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'none'); await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.goto(`/authorize?${urlParams.toString()}`) ); }); test('prompt=consent forces consent display even for authorized client', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'consent'); await page.goto(`/authorize?${urlParams.toString()}`); // Should show consent UI even though client was already authorized await expectScopes(page, ['Profile', 'Email']); await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() ); }); test('prompt=login forces reauthentication', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'login'); let reauthCalled = false; await page.route('/api/webauthn/login/start', async (route) => { reauthCalled = true; await route.continue(); }); await page.route('/api/webauthn/reauthenticate', async (route) => { reauthCalled = true; await route.continue(); }); await (await passkeyUtil.init(page)).addPasskey(); await expectCallbackRedirect(page, oidcClient.callbackUrl, async () => { await page.goto(`/authorize?${urlParams.toString()}`); await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible(); await page.getByRole('button', { name: /sign in/i }).click(); }); expect(reauthCalled).toBe(true); }); test('prompt=select_account shows current user and continues on confirm', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'select_account'); await page.goto(`/authorize?${urlParams.toString()}`); // Account selection card with the signed-in user should appear const selectionCard = page.getByTestId('account-selection'); await expect(selectionCard).toBeVisible(); await expect(selectionCard).toContainText('Tim Cook'); await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: 'Sign In' }).click() ); }); test('prompt=select_account account can be changed', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'select_account'); await page.goto(`/authorize?${urlParams.toString()}`); await page.getByRole('button', { name: 'Use a different account' }).click(); await expect(page.getByText('Do you want to sign in to Nextcloud')).toBeVisible(); (await passkeyUtil.init(page)).addPasskey('craig'); await page.getByRole('button', { name: 'Sign In' }).click(); await expect(page.getByText('Do you want to sign in to Nextcloud')).toBeVisible(); await expectCallbackRedirect(page, oidcClient.callbackUrl, () => page.getByRole('button', { name: /sign in/i }).click() ); }); test('prompt=none with prompt=consent returns invalid_request', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'none consent'); // Should redirect with error since both can't be satisfied const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () => page.goto(`/authorize?${urlParams.toString()}`).then(() => {}) ); expect(redirectUrl.searchParams.get('error')).toBe('invalid_request'); expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); }); test('prompt=none with prompt=login returns invalid_request', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'none login'); // Should redirect with error since both can't be satisfied const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () => page.goto(`/authorize?${urlParams.toString()}`).then(() => {}) ); expect(redirectUrl.searchParams.get('error')).toBe('invalid_request'); expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); }); test('prompt=none with prompt=select_account returns invalid_request', async ({ page }) => { const oidcClient = oidcClients.nextcloud; const urlParams = createUrlParams(oidcClient); urlParams.set('prompt', 'none select_account'); // Should redirect with error since both can't be satisfied const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () => page.goto(`/authorize?${urlParams.toString()}`).then(() => {}) ); expect(redirectUrl.searchParams.get('error')).toBe('invalid_request'); expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa'); }); }); async function waitForCallbackURL(page: Page, callbackUrl: string): Promise { const isCallbackURL = callbackURLMatcher(callbackUrl); const callbackRequest = await page.waitForRequest((request) => isCallbackURL(new URL(request.url())) ); const redirectURL = await callbackRedirectURL(callbackRequest, isCallbackURL); if (redirectURL) { return redirectURL; } await page.waitForURL(isCallbackURL, { waitUntil: 'commit', timeout: 5000 }).catch(() => {}); const currentURL = new URL(page.url()); if (isCallbackURL(currentURL)) { return currentURL; } return new URL(callbackRequest.url()); } function callbackURLMatcher(callbackUrl: string): (url: URL) => boolean { const expectedUrl = new URL(callbackUrl); return (url) => url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname; } async function callbackRedirectURL( request: Request, isCallbackURL: (url: URL) => boolean ): Promise { const redirectResponse = await request.redirectedFrom()?.response(); if (!redirectResponse) { return null; } const redirectLocation = redirectResponse.headers().location; if (!redirectLocation) { return null; } const redirectURL = new URL(redirectLocation, redirectResponse.url()); return isCallbackURL(redirectURL) ? redirectURL : null; } async function expectCallbackRedirect( page: Page, callbackUrl: string, action: () => Promise ): Promise { const callbackRouteMatcher = await routeCallbackPage(page, callbackUrl); try { const callbackURLPromise = waitForCallbackURL(page, callbackUrl); const actionPromise = action().then( () => undefined, (error) => error ); const callbackURL = await callbackURLPromise; await actionPromise; return callbackURL; } finally { if (!page.isClosed()) { await page.unroute(callbackRouteMatcher).catch(() => {}); } } } async function routeCallbackPage(page: Page, callbackUrl: string): Promise<(url: URL) => boolean> { const callbackRouteMatcher = callbackURLMatcher(callbackUrl); await page.route(callbackRouteMatcher, async (route) => { await route.fulfill({ status: 200, contentType: 'text/html', body: 'OIDC callback' }); }); return callbackRouteMatcher; } // ─── PAR (Pushed Authorization Requests - RFC 9126) ────────────────────────── test.describe('Pushed Authorization Requests (PAR)', () => { const client = oidcClients.parClient; test('PAR endpoint returns request_uri for valid confidential client', async ({ page }) => { const result = await oidcUtil.pushAuthorizationRequest(page, { clientId: client.id, clientSecret: client.secret, redirectUri: client.callbackUrl }); expect(result.request_uri).toMatch(/^urn:ietf:params:oauth:request_uri:/); expect(result.expires_in).toBe(90); expect(result.error).toBeUndefined(); }); test('PAR full flow: push then authorize then exchange tokens', async ({ page }) => { // Step 1: Push authorization parameters const parResult = await oidcUtil.pushAuthorizationRequest(page, { clientId: client.id, clientSecret: client.secret, redirectUri: client.callbackUrl, nonce: 'par-nonce-123' }); expect(parResult.request_uri).toBeDefined(); expect(parResult.error).toBeUndefined(); // Step 2: Navigate to /authorize using the request_uri const urlParams = new URLSearchParams({ client_id: client.id, request_uri: parResult.request_uri! }); const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () => page.goto(`/authorize?${urlParams.toString()}`) ); const code = callbackUrl.searchParams.get('code'); expect(code).toBeTruthy(); // Step 3: Exchange the authorization code for tokens const tokenResult = await oidcUtil.exchangeCode(page, { grant_type: 'authorization_code', code: code!, client_id: client.id, client_secret: client.secret, redirect_uri: client.callbackUrl }); expect(tokenResult.access_token).toBeTruthy(); expect(tokenResult.token_type).toBe('bearer'); expect(tokenResult.error).toBeUndefined(); }); test('PAR full flow carries the resolved state into the callback redirect', async ({ page }) => { const state = 'par-flow-state-7b21'; const parResult = await oidcUtil.pushAuthorizationRequest(page, { clientId: client.id, clientSecret: client.secret, redirectUri: client.callbackUrl, state, responseMode: 'form_post' }); expect(parResult.request_uri).toBeDefined(); const urlParams = new URLSearchParams({ client_id: client.id, request_uri: parResult.request_uri! }); const formData = await expectFormPostCallback( page, client.callbackUrl, () => page.goto(`/authorize?${urlParams.toString()}`), state ); expect(formData.get('code')).toBeTruthy(); expect(formData.get('state')).toBe(state); }); test('PAR full flow shows consent screen when authorization is required', async ({ page }) => { // The parClient is pre-authorized for "openid profile email"; pushing a different // scope means consent is required and the consent screen must be shown rather than // silently authorizing. const parResult = await oidcUtil.pushAuthorizationRequest(page, { clientId: client.id, clientSecret: client.secret, redirectUri: client.callbackUrl, scope: 'openid profile groups' }); expect(parResult.request_uri).toBeDefined(); const urlParams = new URLSearchParams({ client_id: client.id, request_uri: parResult.request_uri! }); await page.goto(`/authorize?${urlParams.toString()}`); // Consent screen with the requested scope (resolved from the PAR) must be shown await expectScopes(page, ['Groups']); // Confirming proceeds with the authorization await expectCallbackRedirect(page, client.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() ); }); test('PAR request_uri is single-use', async ({ page }) => { // Push two requests — use the first via the browser, then try to reuse it const parResult = await oidcUtil.pushAuthorizationRequest(page, { clientId: client.id, clientSecret: client.secret, redirectUri: client.callbackUrl }); expect(parResult.request_uri).toBeDefined(); // First use — navigate to /authorize (must succeed and consume the request_uri) const urlParams = new URLSearchParams({ client_id: client.id, request_uri: parResult.request_uri! }); const firstCallbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () => page.goto(`/authorize?${urlParams.toString()}`) ); expect(firstCallbackUrl.searchParams.get('code')).toBeTruthy(); await page.goto(`/authorize?${urlParams.toString()}`); await expect(page.locator('body')).toContainText('Invalid PAR session'); }); test('PAR endpoint rejects confidential client request without client credentials', async ({ page }) => { const result = await oidcUtil.pushAuthorizationRequest(page, { clientId: client.id, // no clientSecret redirectUri: client.callbackUrl }); expect(result.error).toBeDefined(); expect(result.request_uri).toBeUndefined(); }); test('PAR endpoint accepts public client with PKCE', async ({ page }) => { // The PAR test client is confidential by default, so make it public first. await page.request.put(`/api/oidc/clients/${client.id}`, { headers: { 'Content-Type': 'application/json' }, data: { name: client.name, callbackURLs: [client.callbackUrl], logoutCallbackURLs: [], isPublic: true, pkceEnabled: true, pkceSupported: false, requiresReauthentication: false, requiresPushedAuthorizationRequests: false, credentials: { federatedIdentities: [] }, isGroupRestricted: false } }); const result = await oidcUtil.pushAuthorizationRequest(page, { clientId: client.id, redirectUri: client.callbackUrl, codeChallenge: 'K2-ltc83acc4h0c9w6ESC_rEMTJ3bww-uCHaoeK1t8U', codeChallengeMethod: 'S256' }); expect(result.error).toBeUndefined(); expect(result.request_uri).toBeDefined(); }); test('PAR endpoint rejects invalid redirect_uri at push time', async ({ page }) => { const result = await oidcUtil.pushAuthorizationRequest(page, { clientId: client.id, clientSecret: client.secret, redirectUri: 'http://evil.example.com/steal' }); expect(result.error).toBeDefined(); expect(result.request_uri).toBeUndefined(); }); test('Client with requiresPushedAuthorizationRequests rejects direct /authorize', async ({ page, request }) => { // Enable the PAR requirement on the client await request.put(`/api/oidc/clients/${client.id}`, { headers: { 'Content-Type': 'application/json' }, data: { name: client.name, callbackURLs: [client.callbackUrl], logoutCallbackURLs: [], isPublic: false, pkceEnabled: false, pkceSupported: false, requiresReauthentication: false, requiresPushedAuthorizationRequests: true, credentials: { federatedIdentities: [] }, isGroupRestricted: false } }); const urlParams = createUrlParams(client); urlParams.set('scope', 'openid profile'); const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () => page.goto(`/authorize?${urlParams.toString()}`) ); expect(callbackUrl.searchParams.get('error')).toBeTruthy(); }); test('Admin UI: PAR toggle persists after save', async ({ page }) => { await page.goto(`/settings/admin/oidc-clients/${client.id}`); await page.getByRole('button', { name: 'Show Advanced Options' }).click(); // Enable the PAR toggle const parToggle = page.getByRole('switch', { name: 'Requires Pushed Authorization' }); if (!(await parToggle.isChecked())) { await parToggle.click(); } await page.getByRole('button', { name: /save/i }).click(); await page.reload(); await page.getByRole('button', { name: 'Show Advanced Options' }).click(); const savedToggle = page.getByRole('switch', { name: 'Requires Pushed Authorization' }); await expect(savedToggle).toBeChecked(); }); }); test.describe('OIDC skip consent', () => { // This seeded client has skip-consent enabled and is not pre-authorized for the signed-in // user, so the consent screen would normally be shown on first authorization. const client = oidcClients.skipConsent; test('skips the consent screen on first authorization', async ({ page }) => { // The flow must go straight through to the callback instead of stopping at the consent screen const urlParams = createUrlParams(client); const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () => page.goto(`/authorize?${urlParams.toString()}`) ); expect(callbackUrl.searchParams.get('code')).toBeTruthy(); expect(callbackUrl.searchParams.get('error')).toBeNull(); }); test('still shows the consent screen when prompt=consent is requested', async ({ page }) => { const urlParams = createUrlParams(client); urlParams.set('prompt', 'consent'); await page.goto(`/authorize?${urlParams.toString()}`); // prompt=consent overrides the client's skip-consent setting, so the scopes must still be shown await expectScopes(page, ['Email', 'Profile']); const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () => page.getByRole('button', { name: 'Sign in' }).click() ); expect(callbackUrl.searchParams.get('code')).toBeTruthy(); }); test('Admin UI: skip consent toggle reflects the seeded value and persists', async ({ page }) => { await page.goto(`/settings/admin/oidc-clients/${client.id}`); // The seeded client has skip-consent enabled, so the toggle loads checked const toggle = page.getByRole('switch', { name: 'Skip Consent Screen' }); await expect(toggle).toBeChecked(); // Disabling it and saving must persist across a reload await toggle.click(); await expect(toggle).not.toBeChecked(); await page.getByRole('button', { name: /save/i }).click(); await expect(page.getByText('OIDC client updated successfully', { exact: true })).toBeVisible(); await page.reload(); await expect(page.getByRole('switch', { name: 'Skip Consent Screen' })).not.toBeChecked(); }); });