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
This commit is contained in:
@@ -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<void> {
|
||||
setSessionExpiredHandler(() => {
|
||||
@@ -15,5 +16,13 @@ export async function init(): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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=<current path>` 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;
|
||||
|
||||
|
||||
@@ -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 = <this value>` 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 { "" };
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -582,6 +582,17 @@ fn build_success_response(state: &Arc<AppState>, 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());
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user