From 10d831b204b6315fb2dfe422a6034b7c40e6a809 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 05:00:30 +0200 Subject: [PATCH] feat(session): ensure dpop even with OIDC --- frontend/src/lib/api/types.ts | 5 ++ frontend/src/lib/stores/session.svelte.ts | 16 +++++-- .../src/routes/admin/[[tab]]/+page.svelte | 47 +++++++++++++++---- frontend/src/routes/login/+page.svelte | 9 ++++ src/application/dtos/session_dto.rs | 23 +++++++++ .../services/auth_application_service.rs | 8 +++- src/interfaces/api/handlers/admin_handler.rs | 9 ++++ 7 files changed, 103 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 51ee665e..37f8ca14 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -754,6 +754,11 @@ export interface SessionSummary { is_revoked: boolean; is_active: boolean; oidc_sid: string | null; + /** `true` when this row IS the admin's currently-active session — + * compared server-side by `dpop_jkt`. Panel uses this to warn + * before revoking ("this will log you out"). Always `false` when + * the admin's own session is unbound. */ + is_current: boolean; } /** Wire response of `GET /api/admin/sessions`. */ diff --git a/frontend/src/lib/stores/session.svelte.ts b/frontend/src/lib/stores/session.svelte.ts index 8a78e6cc..86148641 100644 --- a/frontend/src/lib/stores/session.svelte.ts +++ b/frontend/src/lib/stores/session.svelte.ts @@ -6,7 +6,7 @@ * routing: externals (magic-link / OIDC-only / OCM recipients) have no home * folder and land on the shared-with-me view. */ -import { fetchMe, tryRefresh } from '$lib/api/endpoints/auth'; +import { bindDpopIfPossible, fetchMe, tryRefresh } from '$lib/api/endpoints/auth'; import { drives } from '$lib/stores/drives.svelte'; import type { User } from '$lib/api/types'; import { ensureActiveUser } from '$lib/utils/localStoragePrefs'; @@ -45,8 +45,18 @@ class SessionStore { if (!me && (await tryRefresh())) { me = await fetchMe(); } - if (me) this.setUser(me); - else this.user = null; + if (me) { + this.setUser(me); + // Post-redirect DPoP bind — catches OIDC / magic-link + // flows whose server-side callback creates the session + // UNBOUND (no way for the redirect to carry the JKT in + // the callback body). One-shot per SPA lifetime because + // `this.loaded` guard makes `load()` a singleton; + // server returns 409 if the session is already bound + // (harmless — result is swallowed). Fire-and-forget so + // a slow IndexedDB open doesn't stall the app boot. + void bindDpopIfPossible(); + } else this.user = null; } catch { this.user = null; } diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 081979c6..5621f386 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -879,16 +879,21 @@ } } - async function onRevokeSession(id: string) { - if ( - !confirm( - t( + async function onRevokeSession(id: string, isCurrent: boolean) { + // Escalated warning for the caller's own session — revoking it + // bricks the tab (all subsequent requests 401 → nav-guard bounces + // to /login). A plain "are you sure" was too easy to click + // through by muscle memory on a table of revoke buttons. + const message = isCurrent + ? t( + 'admin.sessions.revoke_self_confirm', + "⚠️ This is YOUR current session. Revoking it will log YOU out immediately and you'll have to sign back in. Continue?" + ) + : t( 'admin.sessions.revoke_confirm', 'Revoke this session? The next request from that browser will 401.' - ) - ) - ) - return; + ); + if (!confirm(message)) return; sessionRevokingId = id; try { await revokeAdminSession(id); @@ -3024,8 +3029,22 @@ - {s.user_id.slice(0, 8)}… + + {s.user_id.slice(0, 8)}… + {#if s.is_current} + + {t('admin.sessions.current', 'you')} + + {/if} + {new Date(s.created_at).toLocaleString()} {new Date(s.expires_at).toLocaleString()} {s.ip_address ?? '—'} @@ -3065,7 +3084,7 @@