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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user