From 950c8c0f38c14c908baaa3ad78250274fdc494c4 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 14:40:10 +0200 Subject: [PATCH] feat(dpop): provide nonce on immediate login provide the DPoP nonce via cookie on login, this reduce the amount of API call and prevent having any first call returning in 401 --- frontend/src/hooks.client.ts | 9 ++++ frontend/src/lib/api/client.ts | 7 +++ frontend/src/lib/auth/dpop-proof.ts | 27 ++++++++++++ frontend/src/lib/stores/session.svelte.ts | 8 ++++ frontend/src/routes/+layout.svelte | 22 +++++++++- src/interfaces/api/cookie_auth.rs | 44 +++++++++++++++++++ src/interfaces/api/handlers/auth_handler.rs | 20 +++++++++ .../api/handlers/magic_link_handler.rs | 11 +++++ .../api/handlers/opaque_auth_handler.rs | 8 ++++ 9 files changed, 155 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index fb1de1a3..38c815c6 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -6,6 +6,7 @@ import { setSessionExpiredHandler } from '$lib/api/client'; import { initI18n } from '$lib/i18n/index.svelte'; import { session } from '$lib/stores/session.svelte'; +import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; export async function init(): Promise { setSessionExpiredHandler(() => { @@ -15,5 +16,13 @@ export async function init(): Promise { } }); + // Consume the one-shot `oxicloud_dpop_nonce` cookie the backend + // stamps on every login-success response — critical for redirect- + // flow logins (OIDC callback, magic-link finish) where the browser + // lands here BEFORE any client-side login handler has run. Without + // this, the layout's `session.load()` fetchMe would be the first + // bound request and eat a `use_dpop_nonce` 401 → retry cycle. + seedNonceFromCookie(); + await initI18n(); } diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 445e1ccf..8f3f287a 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -301,6 +301,13 @@ export function setLogoutInProgress(value: boolean): void { logoutInProgress = value; } +/** Read-only view of the gate — used by cross-tab handlers to distinguish + * OUR logout (already handled by AppShell.onLogout with source=logged_out) + * from ANOTHER tab's logout (which needs a bare redirect). */ +export function isLogoutInProgress(): boolean { + return logoutInProgress; +} + // Same shape as `sessionExpiredHandler` — mutable so the app can install // the real behaviour post-mount, and a fallback for the (rare) case // where no handler is wired yet (bootstrap, tests). The fallback does diff --git a/frontend/src/lib/auth/dpop-proof.ts b/frontend/src/lib/auth/dpop-proof.ts index 2c74a5cb..c55dda5a 100644 --- a/frontend/src/lib/auth/dpop-proof.ts +++ b/frontend/src/lib/auth/dpop-proof.ts @@ -55,6 +55,33 @@ export function updateNonceFromHeader(fresh: string | null): void { } } +/** + * Read the one-shot `oxicloud_dpop_nonce` cookie the backend stamps on + * every login-success response (POST OPAQUE/legacy AND 302 OIDC/magic- + * link), seed the local nonce cache with it, then clear the cookie so a + * later flow can't reuse a stale value. + * + * Call at SPA boot AND from any client-side login-success path + * (`session.setUser()`). The cookie is set by the server unconditionally + * on login when `dpop_mode != off`; if the client lacks DPoP support this + * call is a harmless no-op (the seeded nonce is never used). + * + * SameSite=Strict + non-HttpOnly on the server side — see + * `cookie_auth::maybe_append_dpop_nonce_cookie` in the backend. + */ +export function seedNonceFromCookie(): void { + if (typeof document === 'undefined') return; + const match = document.cookie.split('; ').find((row) => row.startsWith('oxicloud_dpop_nonce=')); + if (!match) return; + const value = match.split('=')[1] ?? ''; + if (value) updateNonceFromHeader(value); + // Single-shot: expire the cookie so a stale value can't confuse a + // later flow (or, worse, land in the DPoP proof after the nonce has + // rotated server-side past its pool TTL). Path + SameSite must match + // the set-cookie for the browser to accept the deletion. + document.cookie = 'oxicloud_dpop_nonce=; SameSite=Strict; Path=/; Max-Age=0'; +} + /** Wipe the current nonce — called on logout so a new session bootstraps fresh. */ export function clearNonce(): void { currentNonce = null; diff --git a/frontend/src/lib/stores/session.svelte.ts b/frontend/src/lib/stores/session.svelte.ts index 6b523301..bc2022c1 100644 --- a/frontend/src/lib/stores/session.svelte.ts +++ b/frontend/src/lib/stores/session.svelte.ts @@ -9,6 +9,7 @@ import { bindDpopIfPossible, fetchMe, tryRefresh } from '$lib/api/endpoints/auth'; import { setLogoutInProgress } from '$lib/api/client'; import { hasSessionHint } from '$lib/api/csrf'; +import { seedNonceFromCookie } from '$lib/auth/dpop-proof'; import { drives } from '$lib/stores/drives.svelte'; import type { User } from '$lib/api/types'; import { ensureActiveUser } from '$lib/utils/localStoragePrefs'; @@ -95,6 +96,13 @@ class SessionStore { // `AUTH_PRIMITIVES`, but the /me + /drives + … fetches the app // fires post-login would all abort with "Session terminated". setLogoutInProgress(false); + // Consume the one-shot `oxicloud_dpop_nonce` cookie the login + // response set. For POST logins (OPAQUE, legacy, magic-link + // SPA-side, OIDC exchange) this is where the seed lands — the + // hooks.client boot pass fires too early (before any login). + // Redirect-flow logins are seeded at boot; both paths are safe + // to double-run (idempotent, cookie is single-shot). + seedNonceFromCookie(); } /** diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index e8d39ab5..e0f6f83a 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -8,7 +8,7 @@ import AppShell from '$lib/components/AppShell.svelte'; import DialogHost from '$lib/components/DialogHost.svelte'; import Toaster from '$lib/components/Toaster.svelte'; - import { setPasswordChangeRequiredHandler } from '$lib/api/client'; + import { isLogoutInProgress, setPasswordChangeRequiredHandler } from '$lib/api/client'; import { onSessionCleared } from '$lib/auth/session-broadcast'; import { session } from '$lib/stores/session.svelte'; import { ui } from '$lib/stores/ui.svelte'; @@ -74,6 +74,17 @@ // there is far cheaper than the risk of missing an // invalidation event during teardown. onSessionCleared(() => { + // A BroadcastChannel dispatches to every OTHER instance + // on the same channel — including OTHER instances in the + // SAME tab (the API only skips the exact sender instance, + // not the whole tab). So `broadcastSessionCleared()` fired + // from `logout()` on this tab re-enters here. When THIS + // tab initiated the logout, `AppShell.onLogout` has already + // navigated to `/login?source=logged_out`; running the bare + // `/login` goto below would clobber the query string (Ed's + // missing "Successfully signed out" banner). Only handle + // broadcasts from OTHER tabs. + if (isLogoutInProgress()) return; session.reset(); // `replaceState: true` so the back button doesn't return // the user to the now-dead protected page they were on. @@ -163,6 +174,15 @@ // protected routes. Runs client-side only (ssr=false). $effect(() => { if (!ready) return; + // During an explicit logout `AppShell.onLogout` has already picked + // the destination (`/login?source=logged_out`) and issued the + // navigation. `session.reset()` inside that flow flips + // `session.isAuthenticated` to false, which fires THIS effect + // reactively — if we don't bail, we race the pending goto with a + // `/login?redirect=` nav and last-write-wins clobbers + // the "signed out" banner (Ed's report: URL landed as + // `?redirect=%2Ffiles%2F…` instead of `?source=logged_out`). + if (isLogoutInProgress()) return; const path = page.url.pathname; if (session.isAuthenticated || isPublic(path)) return; diff --git a/src/interfaces/api/cookie_auth.rs b/src/interfaces/api/cookie_auth.rs index fb3ca5dc..751cb8f5 100644 --- a/src/interfaces/api/cookie_auth.rs +++ b/src/interfaces/api/cookie_auth.rs @@ -30,6 +30,20 @@ pub const CSRF_HEADER: &str = "x-csrf-token"; /// the token row's `request_challenge` column. Limited to `/magic` /// so it only travels back on the redemption endpoint. pub const MAGIC_REQUEST_COOKIE: &str = "oxicloud_magic_request"; +/// One-shot **non-HttpOnly** cookie carrying a fresh `DPoP-Nonce` value +/// on login-success responses (POST OPAQUE/legacy and 302 OIDC/magic-link +/// redirects alike). The SPA reads it on mount and seeds the client-side +/// nonce cache, so the very first bound request under `DPOP=required` +/// doesn't have to eat a 401 `use_dpop_nonce` challenge before its retry +/// succeeds. Short TTL: nonces rotate server-side every ~30 s, and this +/// cookie is single-shot (cleared by the SPA after reading). +pub const DPOP_NONCE_COOKIE: &str = "oxicloud_dpop_nonce"; +/// TTL for the DPoP-nonce hand-off cookie. 60 s is well within the +/// server-side pool TTL, and if the SPA doesn't read it within a +/// minute the client either lacks DPoP support (harmless waste) or +/// is broken (a stale cookie doesn't hurt — the middleware challenge- +/// retry still kicks in on first use). +const DPOP_NONCE_COOKIE_MAX_AGE_SECS: i64 = 60; /// Whether the `Secure` flag should be set on cookies. /// @@ -231,6 +245,36 @@ pub fn append_clear_magic_request_cookie(headers: &mut HeaderMap) { } } +/// Stamp the DPoP-nonce hand-off cookie alongside the auth cookies on +/// any login-success response — but only when DPoP is actually enforced +/// server-side, otherwise the cookie is dead weight the client would +/// read and discard. Uses `state.dpop_nonce_service.current_or_rotate()` +/// under the hood — the SAME nonce the middleware would stamp on any +/// authenticated response, so a subsequent bound request presenting a +/// proof with `nonce = ` validates against the live pool +/// without ever touching a `use_dpop_nonce` retry. +/// +/// SameSite=Strict + Path=/: cookie only travels back to our origin, on +/// any route the SPA might land on after login. Survives the 302 follow +/// on OIDC / magic-link flows (Set-Cookie IS applied across redirects). +pub fn maybe_append_dpop_nonce_cookie( + headers: &mut HeaderMap, + nonce_service: &crate::infrastructure::services::dpop_nonce_service::DpopNonceService, + dpop_mode: crate::common::config::DpopMode, +) { + if matches!(dpop_mode, crate::common::config::DpopMode::Off) { + return; + } + let value = nonce_service.current_or_rotate(); + let secure = if cookie_secure() { "; Secure" } else { "" }; + let val = format!( + "{DPOP_NONCE_COOKIE}={value}; SameSite=Strict; Path=/; Max-Age={DPOP_NONCE_COOKIE_MAX_AGE_SECS}{secure}", + ); + if let Ok(hv) = HeaderValue::from_str(&val) { + headers.append(SET_COOKIE, hv); + } +} + /// Clear the CSRF cookie (on logout). pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) { let secure = if cookie_secure() { "; Secure" } else { "" }; diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 9750417e..877de522 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -439,6 +439,14 @@ pub async fn login( state.core.config.auth.refresh_token_expiry_secs, ); cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in); + // Seed the SPA's DPoP-nonce cache so the first bound request + // after login doesn't eat a `use_dpop_nonce` challenge → retry. + // No-op when `dpop_mode = off`. + cookie_auth::maybe_append_dpop_nonce_cookie( + response.headers_mut(), + &state.dpop_nonce_service, + state.core.config.auth.dpop_mode, + ); // Diagnostic: warn when Secure cookies are set but the request // arrived over plain HTTP, the browser will reject them (#241). @@ -604,6 +612,12 @@ pub async fn refresh_token( state.core.config.auth.refresh_token_expiry_secs, ); cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in); + // Seed the SPA's DPoP-nonce cache — see the login handler above. + cookie_auth::maybe_append_dpop_nonce_cookie( + response.headers_mut(), + &state.dpop_nonce_service, + state.core.config.auth.dpop_mode, + ); Ok(response) } @@ -1797,6 +1811,12 @@ pub async fn oidc_exchange( state.core.config.auth.refresh_token_expiry_secs, ); cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in); + // Seed the SPA's DPoP-nonce cache — see the login handler above. + cookie_auth::maybe_append_dpop_nonce_cookie( + response.headers_mut(), + &state.dpop_nonce_service, + state.core.config.auth.dpop_mode, + ); Ok(response) } diff --git a/src/interfaces/api/handlers/magic_link_handler.rs b/src/interfaces/api/handlers/magic_link_handler.rs index 89dbf60d..aa2c07bf 100644 --- a/src/interfaces/api/handlers/magic_link_handler.rs +++ b/src/interfaces/api/handlers/magic_link_handler.rs @@ -582,6 +582,17 @@ fn build_success_response(state: &Arc, redemption: MagicLinkRedemption state.core.config.auth.refresh_token_expiry_secs, ); cookie_auth::append_csrf_cookie(response.headers_mut(), redemption.auth.expires_in); + // Seed the SPA's DPoP-nonce cache so the first bound request after + // the redirect (typically the `bindDpopIfPossible` POST or the layout's + // `session.load()` probe) has a valid nonce and doesn't eat a + // `use_dpop_nonce` challenge → retry cycle. Cookie survives the 302 + // follow (Set-Cookie is applied by the browser across redirects, + // unlike other response headers). No-op when `dpop_mode = off`. + cookie_auth::maybe_append_dpop_nonce_cookie( + response.headers_mut(), + &state.dpop_nonce_service, + state.core.config.auth.dpop_mode, + ); // Clear the request-challenge cookie — it's single-use and we don't // want a stale value on the browser confusing a later flow. cookie_auth::append_clear_magic_request_cookie(response.headers_mut()); diff --git a/src/interfaces/api/handlers/opaque_auth_handler.rs b/src/interfaces/api/handlers/opaque_auth_handler.rs index 988c7abc..c3c2180d 100644 --- a/src/interfaces/api/handlers/opaque_auth_handler.rs +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -829,6 +829,14 @@ pub async fn login_ke3( state.core.config.auth.refresh_token_expiry_secs, ); cookie_auth::append_csrf_cookie(response.headers_mut(), session.expires_in); + // Seed the SPA's DPoP-nonce cache so the first bound request after + // login doesn't eat a `use_dpop_nonce` challenge → retry cycle. + // No-op when `dpop_mode = off`. + cookie_auth::maybe_append_dpop_nonce_cookie( + response.headers_mut(), + &state.dpop_nonce_service, + state.core.config.auth.dpop_mode, + ); Ok(response) }