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:
@@ -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