Files
pocket-id-pocket-id/tests/specs/oidc.spec.ts
2025-06-27 23:33:26 +02:00

597 lines
18 KiB
TypeScript

import test, { expect } from '@playwright/test';
import { oidcClients, refreshTokens, users } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import { generateIdToken, generateOauthAccessToken } from '../utils/jwt.util';
import * as oidcUtil from '../utils/oidc.util';
import passkeyUtil from '../utils/passkey.util';
test.beforeEach(cleanupBackend);
test('Authorize existing client', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
await page.goto(`/authorize?${urlParams.toString()}`);
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
throw e;
}
});
});
test('Authorize existing client while not signed in', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
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();
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
throw e;
}
});
});
test('Authorize new client', async ({ page }) => {
const oidcClient = oidcClients.immich;
const urlParams = createUrlParams(oidcClient);
await page.goto(`/authorize?${urlParams.toString()}`);
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
await page.getByRole('button', { name: 'Sign in' }).click();
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
throw e;
}
});
});
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 expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
await page.getByRole('button', { name: 'Sign in' }).click();
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
throw e;
}
});
});
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 expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('paragraph').first()).toHaveText(
"You're not allowed to access this service."
);
});
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'
});
}
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');
});
test('End session with id token hint redirects to callback URL', async ({ page }) => {
const client = oidcClients.nextcloud;
const idToken = await generateIdToken(users.tim, client.id);
let redirectedCorrectly = false;
await page
.goto(
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}`
)
.catch((e) => {
if (e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
redirectedCorrectly = true;
} else {
throw e;
}
});
expect(redirectedCorrectly).toBeTruthy();
});
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.token_type).toBe('Bearer');
expect(tokenData.expires_in).toBe(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: 'bad-user'
}
})
.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 generateOauthAccessToken(users.tim, 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(400);
});
test('succeeds with client credentials', async ({ request, baseURL }) => {
const validAccessToken = await generateOauthAccessToken(users.tim, 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.token_type).toBe('access_token');
expect(introspectionBody.iss).toBe(baseURL);
expect(introspectionBody.sub).toBe(users.tim.id);
expect(introspectionBody.aud).toStrictEqual([oidcClients.nextcloud.id]);
});
test('succeeds with federated client credentials', async ({ page, request, baseURL }) => {
const validAccessToken = await generateOauthAccessToken(users.tim, 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: {
token: validAccessToken
}
});
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(true);
expect(introspectionBody.token_type).toBe('access_token');
expect(introspectionBody.iss).toBe(baseURL);
expect(introspectionBody.sub).toBe(users.tim.id);
expect(introspectionBody.aud).toStrictEqual([oidcClients.federated.id]);
});
test('fails with client credentials for wrong app', async ({ request }) => {
const validAccessToken = await generateOauthAccessToken(users.tim, 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
}
});
expect(introspectionResponse.status()).toBe(400);
});
test('fails with federated credentials for wrong app', async ({ page, request }) => {
const validAccessToken = await generateOauthAccessToken(users.tim, 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: {
token: validAccessToken
}
});
expect(introspectionResponse.status()).toBe(400);
});
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);
expect(introspectionBody.token_type).toBe('refresh_token');
});
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 generateOauthAccessToken(
users.tim,
oidcClients.nextcloud.id,
true
);
const introspectionResponse = await request.post('/api/oidc/introspect', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
form: {
token: expiredAccessToken
}
});
expect(introspectionResponse.status()).toBe(400);
});
});
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?code=${userCode}`);
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
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?code=${userCode}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Authorize' }).click();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
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?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?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('Authorize client with device authorization flow with invalid code', async ({ page }) => {
await page.goto('/device?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?code=${userCode}`);
await (await passkeyUtil.init(page)).addPasskey('craig');
await page.getByRole('button', { name: 'Authorize' }).click();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
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 assertion');
});
test('Authorize existing client with federated identity', async ({ page }) => {
const client = oidcClients.federated;
const clientAssertion = await oidcUtil.getClientAssertion(page, client.federatedJWT);
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: clientAssertion
});
expect(res.access_token).not.toBeNull;
expect(res.expires_in).not.toBeNull;
expect(res.token_type).toBe('Bearer');
});