feat(oidc): RP initiator logout
request token invalidation to IdP (OIDC) on logout
This commit is contained in:
@@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -198,14 +198,23 @@ it('renders an SSO sign-in link when an OIDC provider is configured', async () =
|
||||
expect(sso.getAttribute('href')).toBe('https://idp.test/auth');
|
||||
});
|
||||
|
||||
it('auto-redirects to the IdP when OIDC is the only login method', async () => {
|
||||
// Auto-redirect on standalone OIDC is enforced server-side via the
|
||||
// `auto_redirect_if_standalone_oidc` policy (see
|
||||
// src/interfaces/web/mod.rs::oidc_standalone_login_redirect). The SPA no
|
||||
// longer contains a client-side copy — a duplicate would override the admin's
|
||||
// policy choice. We keep this test asserting the *negative* to lock in
|
||||
// "SPA renders the click-to-continue button, no window.location.replace".
|
||||
it('does not client-side auto-redirect when OIDC is the only login method', async () => {
|
||||
m(auth.getOidcProviders).mockResolvedValue({
|
||||
enabled: true,
|
||||
password_login_enabled: false,
|
||||
authorize_endpoint: '/api/auth/oidc/authorize'
|
||||
});
|
||||
render(LoginPage);
|
||||
await waitFor(() => expect(replaceSpy).toHaveBeenCalledWith('/api/auth/oidc/authorize'));
|
||||
// Give onMount time to finish its probes; the SSO button must appear
|
||||
// and `window.location.replace` must NOT have been called.
|
||||
await screen.findByTestId('login-oidc-btn');
|
||||
expect(replaceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not auto-redirect when password login is also enabled', async () => {
|
||||
|
||||
Reference in New Issue
Block a user