mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-06 17:59:03 +02:00
feat: add OIDC back-channel logout (#1734)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com> Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
co-authored by
Alessandro Segala
Elias Schneider
parent
23c4825abd
commit
2075de3234
@@ -30,6 +30,7 @@ export const oidcClients = {
|
||||
name: 'Nextcloud',
|
||||
callbackUrl: 'http://nextcloud.localhost/auth/callback',
|
||||
logoutCallbackUrl: 'http://nextcloud.localhost/auth/logout/callback',
|
||||
backchannelLogoutURL: 'http://host.docker.internal:18124/nextcloud',
|
||||
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY',
|
||||
launchURL: 'https://nextcloud.local'
|
||||
},
|
||||
@@ -44,6 +45,7 @@ export const oidcClients = {
|
||||
id: '7c21a609-96b5-4011-9900-272b8d31a9d1',
|
||||
name: 'Tailscale',
|
||||
callbackUrl: 'http://tailscale.localhost/auth/callback',
|
||||
backchannelLogoutURL: 'http://host.docker.internal:18124/tailscale',
|
||||
secret: 'n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo'
|
||||
},
|
||||
federated: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260814120000,
|
||||
"version": 20260923183637,
|
||||
"tableOrder": [
|
||||
"users",
|
||||
"user_groups",
|
||||
@@ -108,6 +108,7 @@
|
||||
"oidc_clients": [
|
||||
{
|
||||
"access_token_duration_minutes": 60,
|
||||
"backchannel_logout_url": "http://host.docker.internal:18124/nextcloud",
|
||||
"callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -133,6 +134,7 @@
|
||||
},
|
||||
{
|
||||
"access_token_duration_minutes": 60,
|
||||
"backchannel_logout_url": "",
|
||||
"callback_urls": "WyJodHRwOi8vaW1taWNoLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -158,6 +160,7 @@
|
||||
},
|
||||
{
|
||||
"access_token_duration_minutes": 60,
|
||||
"backchannel_logout_url": "http://host.docker.internal:18124/tailscale",
|
||||
"callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -183,6 +186,7 @@
|
||||
},
|
||||
{
|
||||
"access_token_duration_minutes": 60,
|
||||
"backchannel_logout_url": "",
|
||||
"callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -208,6 +212,7 @@
|
||||
},
|
||||
{
|
||||
"access_token_duration_minutes": 60,
|
||||
"backchannel_logout_url": "",
|
||||
"callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -233,6 +238,7 @@
|
||||
},
|
||||
{
|
||||
"access_token_duration_minutes": 60,
|
||||
"backchannel_logout_url": "",
|
||||
"callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -258,6 +264,7 @@
|
||||
},
|
||||
{
|
||||
"access_token_duration_minutes": 60,
|
||||
"backchannel_logout_url": "",
|
||||
"callback_urls": "WyJodHRwOi8vc2tpcC1jb25zZW50LmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
|
||||
"client_type": "standard",
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
|
||||
@@ -15,6 +15,8 @@ services:
|
||||
- "18123:8080"
|
||||
pocket-id:
|
||||
image: pocket-id:test
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "1411:1411"
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import base, { expect, type APIRequestContext } from '@playwright/test';
|
||||
import { createLocalJWKSet, jwtVerify } from 'jose';
|
||||
import { createServer } from 'node:http';
|
||||
import { oidcClients, userGroups, users } from '../data';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
|
||||
|
||||
type Delivery = {
|
||||
path: string;
|
||||
method: string;
|
||||
contentType: string;
|
||||
body: URLSearchParams;
|
||||
};
|
||||
|
||||
type LogoutReceiver = {
|
||||
url: string;
|
||||
deliveries: Delivery[];
|
||||
respond: (path: string, attempt: number) => number;
|
||||
};
|
||||
|
||||
const test = base.extend<{ receiver: LogoutReceiver }>({
|
||||
receiver: async ({}, use) => {
|
||||
const receiverURL = new URL(oidcClients.nextcloud.backchannelLogoutURL);
|
||||
const receiver: LogoutReceiver = {
|
||||
url: receiverURL.origin,
|
||||
deliveries: [],
|
||||
respond: () => 204
|
||||
};
|
||||
const server = createServer(async (request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
const path = request.url!;
|
||||
receiver.deliveries.push({
|
||||
path,
|
||||
method: request.method!,
|
||||
contentType: request.headers['content-type'] ?? '',
|
||||
body: new URLSearchParams(Buffer.concat(chunks).toString())
|
||||
});
|
||||
const attempt = receiver.deliveries.filter((delivery) => delivery.path === path).length;
|
||||
response.writeHead(receiver.respond(path, attempt)).end();
|
||||
});
|
||||
|
||||
// Docker reaches this real RP endpoint through the host gateway on both local machines and CI
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(Number(receiverURL.port), '0.0.0.0', resolve);
|
||||
});
|
||||
try {
|
||||
await use(receiver);
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => cleanupBackend({ skipLdapSetup: true }));
|
||||
|
||||
async function revoke(request: APIRequestContext, clientId: string) {
|
||||
const response = await request.delete(`/api/oidc/users/me/authorized-clients/${clientId}`);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function verifyLogoutToken(
|
||||
request: APIRequestContext,
|
||||
delivery: Delivery,
|
||||
clientId: string,
|
||||
userId: string
|
||||
) {
|
||||
expect(delivery.method).toBe('POST');
|
||||
expect(delivery.contentType).toBe('application/x-www-form-urlencoded');
|
||||
expect(delivery.body.getAll('logout_token')).toHaveLength(1);
|
||||
const discoveryResponse = await request.get('/.well-known/openid-configuration');
|
||||
expect(discoveryResponse.ok()).toBeTruthy();
|
||||
const discovery = await discoveryResponse.json();
|
||||
expect(discovery.backchannel_logout_supported).toBe(true);
|
||||
expect(discovery.backchannel_logout_session_supported).toBe(false);
|
||||
const jwksResponse = await request.get(discovery.jwks_uri);
|
||||
expect(jwksResponse.ok()).toBeTruthy();
|
||||
const { payload, protectedHeader } = await jwtVerify(
|
||||
delivery.body.get('logout_token')!,
|
||||
createLocalJWKSet(await jwksResponse.json()),
|
||||
{
|
||||
issuer: discovery.issuer,
|
||||
audience: clientId,
|
||||
algorithms: discovery.id_token_signing_alg_values_supported,
|
||||
typ: 'logout+jwt',
|
||||
maxTokenAge: '2m',
|
||||
requiredClaims: ['iss', 'sub', 'aud', 'iat', 'exp', 'jti', 'events']
|
||||
}
|
||||
);
|
||||
expect(protectedHeader.kid).toBeTruthy();
|
||||
expect(payload.sub).toBe(userId);
|
||||
expect(payload.aud).toEqual([clientId]);
|
||||
expect(payload.events).toEqual({ 'http://schemas.openid.net/event/backchannel-logout': {} });
|
||||
expect(payload.exp! - payload.iat!).toBe(120);
|
||||
expect(payload.jti).toBeTruthy();
|
||||
expect(payload).not.toHaveProperty('nonce');
|
||||
expect(payload).not.toHaveProperty('sid');
|
||||
return payload;
|
||||
}
|
||||
|
||||
test('Saving a logout URL and revoking an app sends a verifiable logout token', async ({
|
||||
page,
|
||||
receiver
|
||||
}) => {
|
||||
const client = oidcClients.nextcloud;
|
||||
const logoutURL = `${receiver.url}/logout?tenant=test`;
|
||||
await page.goto(`/settings/admin/oidc-clients/${client.id}`);
|
||||
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
|
||||
await page.getByLabel('Back-Channel Logout URL', { exact: true }).fill(logoutURL);
|
||||
await saveUnsavedChanges(page);
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
|
||||
await expect(page.getByLabel('Back-Channel Logout URL', { exact: true })).toHaveValue(logoutURL);
|
||||
|
||||
await page.goto('/settings/apps');
|
||||
await page
|
||||
.getByRole('article', { name: client.name })
|
||||
.getByRole('button', { name: 'Toggle menu' })
|
||||
.click();
|
||||
await page.getByRole('menuitem', { name: 'Revoke' }).click();
|
||||
await page.getByRole('alertdialog').getByRole('button', { name: 'Revoke' }).click();
|
||||
// Other specs can leave retries for the seeded URLs, so observe this client's updated endpoint
|
||||
const deliveries = () =>
|
||||
receiver.deliveries.filter((delivery) => delivery.path === '/logout?tenant=test');
|
||||
await expect.poll(() => deliveries().length).toBe(1);
|
||||
await verifyLogoutToken(page.request, deliveries()[0], client.id, users.tim.id);
|
||||
});
|
||||
|
||||
test('Disabling a user delivers logout to their authorized clients', async ({
|
||||
request,
|
||||
receiver
|
||||
}) => {
|
||||
const clients = [oidcClients.nextcloud, oidcClients.tailscale];
|
||||
const userResponse = await request.get(`/api/users/${users.tim.id}`);
|
||||
expect(userResponse.ok()).toBeTruthy();
|
||||
const disabled = await request.put(`/api/users/${users.tim.id}`, {
|
||||
data: { ...(await userResponse.json()), disabled: true }
|
||||
});
|
||||
expect(disabled.ok()).toBeTruthy();
|
||||
await expect.poll(() => receiver.deliveries.length).toBe(clients.length);
|
||||
expect(receiver.deliveries.map((delivery) => delivery.path).sort()).toEqual(
|
||||
clients.map((client) => new URL(client.backchannelLogoutURL).pathname).sort()
|
||||
);
|
||||
const tokens = await Promise.all(
|
||||
clients.map((client) =>
|
||||
verifyLogoutToken(
|
||||
request,
|
||||
receiver.deliveries.find(
|
||||
(delivery) => delivery.path === new URL(client.backchannelLogoutURL).pathname
|
||||
)!,
|
||||
client.id,
|
||||
users.tim.id
|
||||
)
|
||||
)
|
||||
);
|
||||
expect(new Set(tokens.map((token) => token.jti)).size).toBe(clients.length);
|
||||
});
|
||||
|
||||
test('Deleting a client still notifies its users after authorizations are deleted', async ({
|
||||
request,
|
||||
receiver
|
||||
}) => {
|
||||
const client = oidcClients.nextcloud;
|
||||
expect((await request.delete(`/api/oidc/clients/${client.id}`)).ok()).toBeTruthy();
|
||||
expect((await request.get(`/api/oidc/clients/${client.id}`)).status()).toBe(404);
|
||||
await expect.poll(() => receiver.deliveries.length).toBe(1);
|
||||
await verifyLogoutToken(request, receiver.deliveries[0], client.id, users.tim.id);
|
||||
});
|
||||
|
||||
test('Group access is retained through another allowed group and revoked after the last one', async ({
|
||||
request,
|
||||
receiver
|
||||
}) => {
|
||||
const client = oidcClients.tailscale;
|
||||
expect(
|
||||
(
|
||||
await request.put(`/api/oidc/clients/${client.id}/allowed-user-groups`, {
|
||||
data: { userGroupIds: [userGroups.developers.id, userGroups.designers.id] }
|
||||
})
|
||||
).ok()
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
await request.put(`/api/users/${users.tim.id}/user-groups`, {
|
||||
data: { userGroupIds: [userGroups.designers.id] }
|
||||
})
|
||||
).ok()
|
||||
).toBeTruthy();
|
||||
|
||||
// Observe the asynchronous delivery window before removing the user's remaining access
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
expect(receiver.deliveries).toHaveLength(0);
|
||||
expect(
|
||||
(
|
||||
await request.put(`/api/users/${users.tim.id}/user-groups`, {
|
||||
data: { userGroupIds: [] }
|
||||
})
|
||||
).ok()
|
||||
).toBeTruthy();
|
||||
await expect.poll(() => receiver.deliveries.length).toBe(1);
|
||||
await verifyLogoutToken(request, receiver.deliveries[0], client.id, users.tim.id);
|
||||
});
|
||||
|
||||
test('Deleting a user still notifies their clients after authorizations are deleted', async ({
|
||||
request,
|
||||
receiver
|
||||
}) => {
|
||||
const response = await request.delete(`/api/users/${users.tim.id}`);
|
||||
expect(response.status()).toBe(204);
|
||||
await expect.poll(() => receiver.deliveries.length).toBe(2);
|
||||
for (const client of [oidcClients.nextcloud, oidcClients.tailscale]) {
|
||||
const delivery = receiver.deliveries.find(
|
||||
(delivery) => delivery.path === new URL(client.backchannelLogoutURL).pathname
|
||||
);
|
||||
expect(delivery).toBeDefined();
|
||||
await verifyLogoutToken(request, delivery!, client.id, users.tim.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('Changing a client’s allowed groups logs out users only after their last allowed group is removed', async ({
|
||||
request,
|
||||
receiver
|
||||
}) => {
|
||||
const client = oidcClients.tailscale;
|
||||
const endpoint = `/api/oidc/clients/${client.id}/allowed-user-groups`;
|
||||
expect(
|
||||
(
|
||||
await request.put(endpoint, {
|
||||
data: { userGroupIds: [userGroups.developers.id, userGroups.designers.id] }
|
||||
})
|
||||
).ok()
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
await request.put(endpoint, {
|
||||
data: { userGroupIds: [userGroups.designers.id] }
|
||||
})
|
||||
).ok()
|
||||
).toBeTruthy();
|
||||
|
||||
// Retaining an allowed group must not schedule a logout for its members
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
expect(receiver.deliveries).toHaveLength(0);
|
||||
expect((await request.put(endpoint, { data: { userGroupIds: [] } })).ok()).toBeTruthy();
|
||||
await expect.poll(() => receiver.deliveries.length).toBe(1);
|
||||
expect(receiver.deliveries[0].path).toBe('/tailscale');
|
||||
await verifyLogoutToken(request, receiver.deliveries[0], client.id, users.tim.id);
|
||||
});
|
||||
Reference in New Issue
Block a user