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:
Alec Rubin
2026-09-23 21:34:38 +02:00
committed by GitHub
co-authored by Alessandro Segala Elias Schneider
parent 23c4825abd
commit 2075de3234
33 changed files with 1493 additions and 96 deletions
+2
View File
@@ -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: {
+8 -1
View File
@@ -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",
+2
View File
@@ -15,6 +15,8 @@ services:
- "18123:8080"
pocket-id:
image: pocket-id:test
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "1411:1411"
environment:
+252
View File
@@ -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);
});