Skip to content

Commit feb0b4a

Browse files
committed
feat: backchannel logout in tests/app
add backchannel logout support to our tests/app idp and rp to facilitate manual end to end testing.
1 parent 74172d3 commit feb0b4a

File tree

9 files changed

+313
-62
lines changed

9 files changed

+313
-62
lines changed

tests/app/idp/fixtures/seed.json

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,39 @@
1-
[
2-
{
3-
"model": "auth.user",
4-
"fields": {
5-
"password": "pbkdf2_sha256$390000$29LoVHfFRlvEOJ9clv73Wx$fx5ejfUJ+nYsnBXFf21jZvDsq4o3p5io3TrAGKAVTq4=",
6-
"last_login": "2023-11-11T17:24:19.359Z",
7-
"is_superuser": true,
8-
"username": "superuser",
9-
"first_name": "",
10-
"last_name": "",
11-
"email": "",
12-
"is_staff": true,
13-
"is_active": true,
14-
"date_joined": "2023-05-01T19:53:59.622Z",
15-
"groups": [],
16-
"user_permissions": []
17-
}
18-
},
19-
{
20-
"model": "oauth2_provider.application",
21-
"fields": {
22-
"client_id": "2EIxgjlyy5VgCp2fjhEpKLyRtSMMPK0hZ0gBpNdm",
23-
"user": null,
24-
"redirect_uris": "http://localhost:5173\r\nhttp://127.0.0.1:5173",
25-
"post_logout_redirect_uris": "http://localhost:5173\r\nhttp://127.0.0.1:5173",
26-
"client_type": "public",
27-
"authorization_grant_type": "authorization-code",
28-
"client_secret": "pbkdf2_sha256$600000$HEYByn6WXiQUI1D6ezTnAf$qPLekt0t3ZssnzEOvQkeOSfxx7tbs/gcC3O0CthtP2A=",
29-
"hash_client_secret": true,
30-
"name": "OIDC - Authorization Code",
31-
"skip_authorization": true,
32-
"created": "2023-05-01T20:27:46.167Z",
33-
"updated": "2023-11-11T17:23:44.643Z",
34-
"algorithm": "RS256",
35-
"allowed_origins": "http://localhost:5173\r\nhttp://127.0.0.1:5173"
36-
}
37-
}
38-
]
1+
[
2+
{
3+
"model": "auth.user",
4+
"fields": {
5+
"password": "pbkdf2_sha256$600000$8lMa9lh0apkrhQShFyKKBI$MQ4tij3eQhzA3yJF+GPCKCUSIZ7meMJHSxh+ccDF/UI=",
6+
"last_login": "2025-11-01T17:57:15.021Z",
7+
"is_superuser": true,
8+
"username": "superuser",
9+
"first_name": "",
10+
"last_name": "",
11+
"email": "",
12+
"is_staff": true,
13+
"is_active": true,
14+
"date_joined": "2023-05-01T19:53:59.622Z",
15+
"groups": [],
16+
"user_permissions": []
17+
}
18+
},
19+
{
20+
"model": "oauth2_provider.application",
21+
"fields": {
22+
"client_id": "2EIxgjlyy5VgCp2fjhEpKLyRtSMMPK0hZ0gBpNdm",
23+
"user": null,
24+
"redirect_uris": "http://localhost:5173\r\nhttp://127.0.0.1:5173",
25+
"post_logout_redirect_uris": "http://localhost:5173\r\nhttp://127.0.0.1:5173",
26+
"client_type": "public",
27+
"authorization_grant_type": "authorization-code",
28+
"client_secret": "pbkdf2_sha256$600000$HEYByn6WXiQUI1D6ezTnAf$qPLekt0t3ZssnzEOvQkeOSfxx7tbs/gcC3O0CthtP2A=",
29+
"hash_client_secret": true,
30+
"name": "OIDC - Authorization Code",
31+
"skip_authorization": true,
32+
"created": "2023-05-01T20:27:46.167Z",
33+
"updated": "2025-11-01T17:57:59.850Z",
34+
"algorithm": "RS256",
35+
"allowed_origins": "http://localhost:5173\r\nhttp://127.0.0.1:5173",
36+
"backchannel_logout_uri": "http://localhost:5173/api/backchannel-logout"
37+
}
38+
}
39+
]

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/src/app.html

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<!doctype html>
22
<html lang="en">
3-
<head>
4-
<meta charset="utf-8" />
5-
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
3+
<head>
4+
<meta charset="utf-8" />
5+
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
66
<link rel="stylesheet" href="%sveltekit.assets%/materialize.min.css" />
77
<meta name="viewport" content="width=device-width, initial-scale=1" />
88
<title>Django OAuth Toolkit RP Demo</title>
@@ -38,10 +38,10 @@
3838
}
3939
}
4040
</style>
41-
%sveltekit.head%
42-
</head>
41+
%sveltekit.head%
42+
</head>
4343

44-
<body data-sveltekit-preload-data="hover">
44+
<body data-sveltekit-preload-data="hover">
4545
<div class="container">
4646
<h2>Django OAuth Toolkit Test RP</h2>
4747
<a
@@ -77,5 +77,5 @@ <h2>Django OAuth Toolkit Test RP</h2>
7777
></a>
7878
<div>%sveltekit.body%</div>
7979
</div>
80-
</body>
81-
</html>
80+
</body>
81+
</html>

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

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script>
22
import { browser } from '$app/environment';
3+
import { onDestroy } from 'svelte';
34
import {
45
EventLog,
56
LoginButton,
@@ -15,20 +16,49 @@
1516
} from '@dopry/svelte-oidc';
1617
1718
const metadata = {};
19+
20+
let eventSource = null;
21+
22+
$: sid = $userInfo?.sid || $userInfo?.sub;
23+
24+
$: {
25+
// Reactively manage SSE connection when sid or authentication changes
26+
if (browser && $isAuthenticated && sid) {
27+
if (eventSource) {
28+
eventSource.close();
29+
}
30+
eventSource = new EventSource(`/api/logout-events?sid=${sid}`);
31+
eventSource.addEventListener('logout', (event) => {
32+
console.log('You have been logged out by the OP (backchannel logout).', event);
33+
});
34+
} else {
35+
if (eventSource) {
36+
eventSource.close();
37+
eventSource = null;
38+
}
39+
}
40+
}
41+
42+
onDestroy(() => {
43+
if (eventSource) {
44+
eventSource.close();
45+
eventSource = null;
46+
}
47+
});
1848
</script>
1949
20-
{#if browser}
21-
<OidcContext
22-
issuer="http://localhost:8000/o"
23-
client_id="2EIxgjlyy5VgCp2fjhEpKLyRtSMMPK0hZ0gBpNdm"
24-
redirect_uri="http://localhost:5173"
25-
post_logout_redirect_uri="http://localhost:5173"
26-
{metadata}
27-
scope="openid"
28-
extraOptions={{
29-
mergeClaims: true
30-
}}
31-
>
50+
{#if browser}
51+
<OidcContext
52+
issuer="http://localhost:8000/o"
53+
client_id="2EIxgjlyy5VgCp2fjhEpKLyRtSMMPK0hZ0gBpNdm"
54+
redirect_uri="http://localhost:5173"
55+
post_logout_redirect_uri="http://localhost:5173"
56+
{metadata}
57+
scope="openid"
58+
extraOptions={{
59+
mergeClaims: true
60+
}}
61+
>
3262
<div class="row">
3363
<div class="col s12">
3464
<LoginButton>Login</LoginButton>
@@ -80,5 +110,5 @@
80110
<EventLog />
81111
</div>
82112
</div>
83-
</OidcContext>
84-
{/if}
113+
</OidcContext>
114+
{/if}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
const headers = {
20+
'Content-Type': 'application/json',
21+
...corsHeaders
22+
};
23+
try {
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+
console.log('Logout token validation error', e);
54+
return new Response(
55+
JSON.stringify({ error: 'Invalid logout_token', details: e.message }),
56+
{
57+
status: 400,
58+
headers
59+
}
60+
);
61+
}
62+
63+
// Notify frontend via SSE if sid is present
64+
try {
65+
if (payload.sid) {
66+
console.log('Sending logout event for sid', payload.sid);
67+
sendLogoutEvent(payload.sub, {
68+
sub: payload.sub,
69+
sid: payload.sid,
70+
event: 'logout'
71+
});
72+
}
73+
// Notify frontend via SSE if sub is present
74+
// dot doesn't support sid claim currently, so use sub claim for testing
75+
if (payload.sub) {
76+
console.log('Sending logout event for sub', payload.sub);
77+
sendLogoutEvent(payload.sub, {
78+
sub: payload.sub,
79+
sid: payload.sid,
80+
event: 'logout'
81+
});
82+
}
83+
} catch (e) {
84+
console.log('Error sending logout sse events to frontend', e);
85+
}
86+
console.log('Processed backchannel logout for', { sub: payload.sub, sid: payload.sid });
87+
return new Response(
88+
JSON.stringify({ status: 'logout processed', sub: payload?.sub, sid: payload?.sid }),
89+
{
90+
status: 200,
91+
headers
92+
}
93+
);
94+
} catch (err) {
95+
console.log('Error processing backchannel logout request', err);
96+
return new Response(JSON.stringify({ error: 'Invalid request', details: err.message }), {
97+
status: 400,
98+
headers
99+
});
100+
}
101+
}
102+
103+
// Handle preflight OPTIONS requests for CORS
104+
export async function OPTIONS() {
105+
return new Response(null, {
106+
status: 204,
107+
headers: corsHeaders
108+
});
109+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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) => {
17+
try {
18+
controller.enqueue(encoder.encode(data));
19+
} catch (err) {
20+
// Remove client if writing fails
21+
removeClient(sid, res);
22+
throw err;
23+
}
24+
},
25+
close: () => controller.close()
26+
};
27+
addClient(sid, res);
28+
// Remove client on stream close
29+
controller.signal?.addEventListener('abort', () => {
30+
removeClient(sid, res);
31+
});
32+
}
33+
});
34+
35+
return new Response(stream, {
36+
headers: {
37+
'Content-Type': 'text/event-stream',
38+
'Cache-Control': 'no-cache',
39+
'Connection': 'keep-alive'
40+
}
41+
});
42+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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 Array.from(clients.get(sid))) {
23+
try {
24+
res.write(`event: logout\ndata: ${JSON.stringify(data)}\n\n`);
25+
} catch (err) {
26+
// Remove client if writing fails (stream closed)
27+
removeClient(sid, res);
28+
console.error('Error sending logout sse events to frontend', err);
29+
}
30+
}
31+
}
32+
}

0 commit comments

Comments
 (0)