Skip to content

Commit 153e901

Browse files
committed
WIP: feat: backchannel logout in tests/app
- TODO: get the SSE sending to the frontend so we get a notification of the backend state change. - TODO: look into the CSRF issue. Are we breaking a major CSRF rule here or is sveltkit overly defensive. add backchannel logout support to our tests/app idp and rp to facilitate manual end to end testing.
1 parent 68b9ce9 commit 153e901

File tree

9 files changed

+215
-3
lines changed

9 files changed

+215
-3
lines changed

tests/app/idp/idp/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@
207207
"openid": "OpenID Connect scope",
208208
},
209209
"ALLOWED_SCHEMES": env("OAUTH2_PROVIDER_ALLOWED_SCHEMES"),
210+
"OIDC_BACKCHANNEL_LOGOUT_ENABLED": True,
211+
"OIDC_ISS_ENDPOINT": "http://localhost:8000",
210212
}
211213
# needs to be set to allow cors requests from the test app, along with ALLOWED_SCHEMES=["http"]
212214
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = env("OAUTHLIB_INSECURE_TRANSPORT")
@@ -238,3 +240,5 @@
238240
# },
239241
},
240242
}
243+
244+

tests/app/rp/package-lock.json

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/app/rp/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
},
2626
"type": "module",
2727
"dependencies": {
28-
"@dopry/svelte-oidc": "^1.1.0"
28+
"@dopry/svelte-oidc": "^1.1.0",
29+
"jose": "^6.1.0"
2930
}
3031
}

tests/app/rp/src/routes/+page.svelte

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script>
22
import { browser } from '$app/environment';
3+
import { onMount } from 'svelte';
34
import {
45
OidcContext,
56
LoginButton,
@@ -16,6 +17,23 @@
1617
} from '@dopry/svelte-oidc';
1718
1819
const metadata = {};
20+
21+
// Listen for SSE logout events if authenticated and sid is available
22+
23+
let eventSource;
24+
onMount(() => {
25+
if (browser && $isAuthenticated && $userInfo?.sid) {
26+
eventSource = new EventSource(`/api/logout-events?sid=${$userInfo.sid}`);
27+
eventSource.addEventListener('logout', (event) => {
28+
// Optionally show a message or redirect
29+
logout();
30+
alert('You have been logged out by the OP (backchannel logout).');
31+
});
32+
}
33+
return () => {
34+
if (eventSource) eventSource.close();
35+
};
36+
});
1937
</script>
2038
2139
{#if browser}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// OIDC Backchannel Logout Endpoint for SvelteKit RP
2+
// Receives POST requests from the OP with a logout_token (JWT)
3+
4+
import { validateLogoutToken } from '../validateLogoutToken.js';
5+
import { sendLogoutEvent } from '../sseClients.js';
6+
7+
const corsHeaders = {
8+
'Access-Control-Allow-Origin': '*',
9+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
10+
'Access-Control-Allow-Headers': 'Content-Type',
11+
'Accept': '*'
12+
};
13+
14+
/**
15+
* POST /api/backchannel-logout
16+
* Receives a logout_token from the OP and invalidates the user session.
17+
*/
18+
export async function POST({ request, locals }) {
19+
try {
20+
const headers = {
21+
'Content-Type': 'application/json',
22+
...corsHeaders
23+
};
24+
let logout_token;
25+
let data;
26+
const contentType = request.headers.get('content-type') || '';
27+
if (contentType.includes('application/json')) {
28+
data = await request.json();
29+
logout_token = data.logout_token;
30+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
31+
const form = await request.formData();
32+
logout_token = form.get('logout_token');
33+
data = { logout_token };
34+
} else {
35+
return new Response(JSON.stringify({ error: 'Unsupported content type' }), {
36+
status: 415,
37+
headers
38+
});
39+
}
40+
console.log('Received backchannel logout request', { data });
41+
if (!logout_token) {
42+
return new Response(JSON.stringify({ error: 'Missing logout_token' }), {
43+
status: 400,
44+
headers
45+
});
46+
}
47+
48+
// Validate the logout_token (JWT) according to OIDC spec
49+
let payload;
50+
try {
51+
payload = await validateLogoutToken(logout_token);
52+
} catch (e) {
53+
return new Response(
54+
JSON.stringify({ error: 'Invalid logout_token', details: e.message }),
55+
{
56+
status: 400,
57+
headers
58+
}
59+
);
60+
}
61+
62+
// Notify frontend via SSE if sid is present
63+
if (payload.sid) {
64+
sendLogoutEvent(payload.sid, { sub: payload.sub, sid: payload.sid, event: 'logout' });
65+
}
66+
return new Response(
67+
JSON.stringify({ status: 'logout processed', sub: payload?.sub, sid: payload?.sid }),
68+
{
69+
status: 200,
70+
headers
71+
}
72+
);
73+
} catch (err) {
74+
return new Response(JSON.stringify({ error: 'Invalid request', details: err.message }), {
75+
status: 400,
76+
headers
77+
});
78+
}
79+
}
80+
81+
// Handle preflight OPTIONS requests for CORS
82+
export async function OPTIONS() {
83+
return new Response(null, {
84+
status: 204,
85+
headers: corsHeaders
86+
});
87+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// SSE endpoint for logout events
2+
import { addClient, removeClient } from '../sseClients.js';
3+
4+
export async function GET({ url }) {
5+
// Get sid from query param (e.g., /api/logout-events?sid=123)
6+
const sid = url.searchParams.get('sid');
7+
if (!sid) {
8+
return new Response('Missing sid', { status: 400 });
9+
}
10+
11+
const stream = new ReadableStream({
12+
start(controller) {
13+
const encoder = new TextEncoder();
14+
// Create a fake response object for our in-memory store
15+
const res = {
16+
write: (data) => controller.enqueue(encoder.encode(data)),
17+
close: () => controller.close()
18+
};
19+
addClient(sid, res);
20+
// Remove client on stream close
21+
controller.signal?.addEventListener('abort', () => {
22+
removeClient(sid, res);
23+
});
24+
}
25+
});
26+
27+
return new Response(stream, {
28+
headers: {
29+
'Content-Type': 'text/event-stream',
30+
'Cache-Control': 'no-cache',
31+
'Connection': 'keep-alive'
32+
}
33+
});
34+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// In-memory store for SSE clients keyed by sid (session id)
2+
const clients = new Map();
3+
4+
export function addClient(sid, res) {
5+
if (!clients.has(sid)) {
6+
clients.set(sid, new Set());
7+
}
8+
clients.get(sid).add(res);
9+
}
10+
11+
export function removeClient(sid, res) {
12+
if (clients.has(sid)) {
13+
clients.get(sid).delete(res);
14+
if (clients.get(sid).size === 0) {
15+
clients.delete(sid);
16+
}
17+
}
18+
}
19+
20+
export function sendLogoutEvent(sid, data) {
21+
if (clients.has(sid)) {
22+
for (const res of clients.get(sid)) {
23+
res.write(`event: logout\ndata: ${JSON.stringify(data)}\n\n`);
24+
}
25+
}
26+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Utility for OIDC logout token validation using jose
2+
import { createRemoteJWKSet, jwtVerify } from 'jose';
3+
4+
// Set your OP's JWKS endpoint here
5+
const JWKS_URI = 'http://localhost:8000/o/.well-known/jwks.json'; // Adjust as needed
6+
7+
const JWKS = createRemoteJWKSet(new URL(JWKS_URI));
8+
9+
export async function validateLogoutToken(token) {
10+
try {
11+
// Accept only ID Token or Logout Token types
12+
const { payload } = await jwtVerify(token, JWKS, {
13+
algorithms: ['RS256'],
14+
// audience, issuer, etc. can be checked here if needed
15+
});
16+
// OIDC Backchannel Logout Token must have events.logout
17+
if (!payload.events || !payload.events['http://schemas.openid.net/event/backchannel-logout']) {
18+
throw new Error('Missing backchannel-logout event');
19+
}
20+
// sub or sid must be present
21+
if (!payload.sub && !payload.sid) {
22+
throw new Error('Logout token missing sub and sid');
23+
}
24+
return payload;
25+
} catch (e) {
26+
throw new Error('Logout token validation failed: ' + e.message);
27+
}
28+
}

tests/app/rp/svelte.config.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ const config = {
99

1010
kit: {
1111
// build to run in containerized node.js environment
12-
adapter: adapter()
12+
adapter: adapter(),
13+
// added for backchannel logout testing, the idp does a
14+
// form POST to the api/backchannel-logout endpoint
15+
// which freaks out CSRF protection unless we disable it here
16+
csrf: { checkOrigin: false }
1317
}
1418
};
1519

0 commit comments

Comments
 (0)