Files
pocket-id-pocket-id/tests/specs/oidc.spec.ts
2026-07-08 17:48:32 -07:00

1529 lines
49 KiB
TypeScript

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<unknown>,
expectedState = 'nXx-6Qr-owc1SHBa'
): Promise<URLSearchParams> {
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<URL> {
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<URL | null> {
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<unknown>
): Promise<URL> {
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: '<!doctype html><title>OIDC callback</title>'
});
});
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 page.getByRole('button', { name: /save/i }).click();
await page.reload();
await expect(page.getByRole('switch', { name: 'Skip Consent Screen' })).not.toBeChecked();
});
});