mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-16 04:03:47 +03:00
1529 lines
49 KiB
TypeScript
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();
|
|
});
|
|
});
|