feat(oidc): RP initiator logout

request token invalidation to IdP (OIDC) on logout
This commit is contained in:
Edouard Vanbelle
2026-08-03 01:17:16 +02:00
parent 5ebe2d3bae
commit 166b8c4891
12 changed files with 219 additions and 21 deletions
+21 -2
View File
@@ -258,11 +258,30 @@ export async function sendMagicLink(email: string): Promise<MagicLinkResult> {
return 'sent';
}
export async function logout(): Promise<void> {
await apiFetch('/api/auth/logout', {
export interface LogoutResult {
/**
* RP-initiated OIDC logout URL, present only when the session was minted
* through OIDC AND the IdP advertises an `end_session_endpoint`. The
* caller MUST navigate there via `window.location` (not `goto()`) so the
* browser leaves the SPA and hits the IdP; the IdP kills its SSO cookie
* and redirects back to `/login`. Without this hop the IdP session stays
* alive and the next `/login` visit would silently re-authenticate.
*/
postLogoutUrl?: string;
}
export async function logout(): Promise<LogoutResult> {
const res = await apiFetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: '{}'
});
if (!res.ok) return {};
try {
const body = (await res.json()) as { post_logout_url?: unknown };
return typeof body?.post_logout_url === 'string' ? { postLogoutUrl: body.post_logout_url } : {};
} catch {
return {};
}
}
+16 -1
View File
@@ -488,11 +488,26 @@
}
async function onLogout() {
let postLogoutUrl: string | undefined;
try {
await logout();
({ postLogoutUrl } = await logout());
} catch {
/* clear locally regardless */
}
if (postLogoutUrl) {
// Full-page navigation to the IdP end-session endpoint. Do NOT
// touch local session state first: `session.reset()` fires the
// layout $effect guard which races us with a competing
// `goto('/login?redirect=...')`, and any ambient in-flight
// fetch that 401s trips the sessionExpiredHandler with yet
// another navigation to `/login?source=session_expired`. Two
// or three concurrent navigations cancel each other and the
// browser stalls on the current page. The IdP round-trip lands
// us back on `/login` where the SPA reboots fresh from scratch —
// no local cleanup needed here.
window.location.replace(postLogoutUrl);
return;
}
session.reset();
await goto(resolve('/login'));
}
@@ -165,11 +165,18 @@
icon: 'sign-out-alt',
run: async () => {
close();
let postLogoutUrl: string | undefined;
try {
await logout();
({ postLogoutUrl } = await logout());
} catch {
/* clear locally regardless */
}
if (postLogoutUrl) {
// See AppShell::onLogout — `session.reset()` before this
// races the layout $effect guard and the 401 handler.
window.location.replace(postLogoutUrl);
return;
}
session.reset();
await goto(resolve('/login'));
}