Files
Oxicloud/src/interfaces/api/cookie_auth.rs
T
Claude c51af68432 perf: round 10 — auth alloc purge, parent-herd batching, query-shape pack, NC 304s
Benchmark-gated (benches/ROUND10.md; every change carries a BEFORE/AFTER
harness with equivalence/safety gates — two designs were rejected or
rewritten by their own benches before adoption):

- Auth hot path: TokenClaims/CurrentUser display fields to Arc<str>, role
  to inline SmolStr end-to-end (Bearer, cookie, Basic-auth cache) — 4→1
  allocs per authenticated request, 3→0 per warm DAV request; JWT
  Encoding/Decoding/Validation built once.
- Cold shared-album herd: leader-inline parent batching in PgAclEngine
  (+ cascade try_get_with single-flight) — 100→2 parent queries per
  100-thumb cold herd, herd wall 1.9x, sequential + warm paths unchanged,
  all ROUND8/9 safety gates plus new herd-equivalence gates.
- Query-shape pack: share download double-fetch 2→1 (2.18x), contact-group
  COUNT(*) 14.9x, save_faces UNNEST 3.9x, playlist reorder UNNEST 63.7x
  (now atomic), search files∥folders join! 1.45x, move drive-lookup join!
  2.14x, trash partial (drive_id, trashed_at) indexes, CalDAV event-gate
  narrow read, favorites/recents binary-decode port, dead count_files
  removed.
- NC surface: preview + avatar honour If-None-Match (e2e: 5 KB and 197 KB
  → 0 bytes per revalidation), avatar WebP→PNG transcode memoised,
  PROPFIND/trashbin integer+date emits on stack formatters, folder-header
  enrichment join!, chunk-PUT retry stat folded into create_new open.
- common::fmt integer rendering rewritten on the std 2-digit LUT after the
  round's own bench caught the div-loop losing to to_string (16.1 ns vs
  22.5; speeds every prior-round call site).
- Micro-pack: WebDAV scope probe borrow-only, ShareService base_url
  snapshot, cookie_secure OnceLock, Arc'd AES-GCM cipher, stack request-id,
  tantivy analyzer clone dropped.
- SPA: search stale-guard + AbortController (10→1 completed round-trips,
  stale-clobber gone), getFolder in-flight dedup, gridColumns matchMedia
  hoist (10k→0 style reads).

Backend: cargo fmt + clippy -D warnings clean, 524 tests green.
Frontend: npm run check clean, 301 vitest green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DdM7V7M3QPW7HEHg3gLov
2026-07-18 20:33:50 +00:00

235 lines
9.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! HttpOnly cookie helpers for secure token transport.
//!
//! Tokens are set as `HttpOnly; SameSite=Lax` cookies so that
//! browser-based JavaScript cannot read them (mitigates XSS token theft).
//! The `Secure` flag is controlled by the `OXICLOUD_COOKIE_SECURE` env var
//! (default: auto-detect from `OXICLOUD_BASE_URL`).
//!
//! A companion **non-HttpOnly** CSRF cookie (`oxicloud_csrf`) is set
//! alongside the auth cookies. The frontend must read it and echo its
//! value back as `X-CSRF-Token` on every state-changing request.
//! A middleware (`csrf_middleware`) validates the match.
//!
//! DAV clients continue to use `Authorization: Basic` with app passwords
//! and are completely unaffected by this mechanism.
use axum::http::header::SET_COOKIE;
use axum::http::{HeaderMap, HeaderValue};
/// Cookie name for the JWT access token.
pub const ACCESS_COOKIE: &str = "oxicloud_access";
/// Cookie name for the opaque refresh token.
pub const REFRESH_COOKIE: &str = "oxicloud_refresh";
/// Cookie name for the CSRF double-submit token (readable by JS).
pub const CSRF_COOKIE: &str = "oxicloud_csrf";
/// Header the frontend must send with the CSRF token value.
pub const CSRF_HEADER: &str = "x-csrf-token";
/// Per-request challenge cookie for browser-bound magic-link
/// redemption (PR 22). Set by `POST /api/auth/magic-link/send` on
/// the requesting browser; checked by `GET /magic/v1/{token}` against
/// 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";
/// Whether the `Secure` flag should be set on cookies.
///
/// Resolution order:
/// 1. `OXICLOUD_COOKIE_SECURE=true|false` — explicit override.
/// 2. `OXICLOUD_BASE_URL` starts with `https` → `true`.
/// 3. `OXICLOUD_BASE_URL` starts with `http` → `false`.
/// 4. **Default: `false`** for compatibility with HTTP deployments
/// (Docker, local development). Set `OXICLOUD_COOKIE_SECURE=true`
/// explicitly for production HTTPS environments.
pub fn is_cookie_secure() -> bool {
cookie_secure()
}
/// Memoised [`resolve_cookie_secure`]. The flag is a pure function of two
/// process-invariant env vars, yet a single login used to re-resolve it
/// ~4× (two auth cookies + the CSRF cookie + the handler's own probe) —
/// each call paying the env-lock syscalls and re-emitting the same
/// "⚠️ SECURITY" log line. Resolve once, log once.
fn cookie_secure() -> bool {
static COOKIE_SECURE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*COOKIE_SECURE.get_or_init(resolve_cookie_secure)
}
fn resolve_cookie_secure() -> bool {
if let Ok(v) = std::env::var("OXICLOUD_COOKIE_SECURE") {
let secure = v == "true" || v == "1";
if !secure {
tracing::warn!(
"⚠️ SECURITY: OXICLOUD_COOKIE_SECURE is explicitly disabled — \
cookies will be sent over plain HTTP. \
Do NOT use this in production."
);
}
return secure;
}
// Auto-detect from base URL, defaulting to insecure for compatibility
match std::env::var("OXICLOUD_BASE_URL") {
Ok(url) if url.starts_with("https") => true,
Ok(url) if url.starts_with("http://") => {
tracing::info!(
"⚠️ SECURITY: OXICLOUD_BASE_URL is HTTP — cookie Secure flag is OFF. \
Set OXICLOUD_COOKIE_SECURE=true to override if your proxy terminates TLS."
);
false
}
_ => {
// Default to false for compatibility with HTTP deployments
tracing::info!(
"⚠️ SECURITY: OXICLOUD_BASE_URL not set — defaulting to non-secure cookies \
for HTTP compatibility. Set OXICLOUD_COOKIE_SECURE=true for HTTPS deployments."
);
false
}
}
}
/// Build a `Set-Cookie` header value.
fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64, same_site: &str) -> String {
let secure = if cookie_secure() { "; Secure" } else { "" };
format!(
"{name}={value}; HttpOnly; SameSite={same_site}; Path={path}; Max-Age={max_age_secs}{secure}",
)
}
/// Append `Set-Cookie` headers for both access and refresh tokens.
///
/// The access cookie covers all paths (`/`) because the API lives under
/// `/api`, CalDAV under `/caldav`, WebDAV under `/webdav`, etc.
///
/// The refresh cookie is restricted to `/api/auth` so it is only sent
/// when the client explicitly calls the refresh or logout endpoints.
pub fn append_auth_cookies(
headers: &mut HeaderMap,
access_token: &str,
refresh_token: &str,
access_expiry_secs: i64,
refresh_expiry_secs: i64,
) {
if let Ok(val) = HeaderValue::from_str(&build_cookie(
ACCESS_COOKIE,
access_token,
"/",
access_expiry_secs,
"Lax", // Lax: cookie is sent on top-level navigations (links from other sites)
)) {
headers.append(SET_COOKIE, val);
}
if let Ok(val) = HeaderValue::from_str(&build_cookie(
REFRESH_COOKIE,
refresh_token,
"/api/auth",
refresh_expiry_secs,
"Strict", // Strict: refresh endpoint is never reached via cross-site navigation
)) {
headers.append(SET_COOKIE, val);
}
}
/// Append `Set-Cookie` headers that immediately expire both auth cookies,
/// effectively logging the user out on the browser side.
pub fn append_clear_cookies(headers: &mut HeaderMap) {
for (name, path) in [(ACCESS_COOKIE, "/"), (REFRESH_COOKIE, "/api/auth")] {
let secure = if cookie_secure() { "; Secure" } else { "" };
let val = format!("{name}=; HttpOnly; SameSite=Lax; Path={path}; Max-Age=0{secure}",);
if let Ok(hv) = HeaderValue::from_str(&val) {
headers.append(SET_COOKIE, hv);
}
}
}
/// Extract a named cookie value from the `Cookie` request header.
pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
let cookie_header = headers.get(axum::http::header::COOKIE)?;
let cookie_str = cookie_header.to_str().ok()?;
for pair in cookie_str.split(';') {
let pair = pair.trim();
if let Some(val) = pair.strip_prefix(name) {
let val = val.strip_prefix('=')?;
if !val.is_empty() {
return Some(val.to_string());
}
}
}
None
}
// ────────────────────────────────────────────────────────────
// CSRF double-submit cookie helpers
// ────────────────────────────────────────────────────────────
/// Generate a cryptographically random CSRF token (128-bit UUIDv4, hex-like).
pub fn generate_csrf_token() -> String {
uuid::Uuid::new_v4().to_string()
}
/// Build a **non-HttpOnly** CSRF cookie so that frontend JS can read it
/// via `document.cookie` and echo it back in the `X-CSRF-Token` header.
fn build_csrf_cookie(value: &str, max_age_secs: i64) -> String {
let secure = if cookie_secure() { "; Secure" } else { "" };
format!("{CSRF_COOKIE}={value}; SameSite=Lax; Path=/; Max-Age={max_age_secs}{secure}",)
}
/// Append a CSRF double-submit cookie alongside the auth cookies.
/// Should be called in every endpoint that also sets auth cookies.
pub fn append_csrf_cookie(headers: &mut HeaderMap, access_expiry_secs: i64) {
let token = generate_csrf_token();
if let Ok(val) = HeaderValue::from_str(&build_csrf_cookie(&token, access_expiry_secs)) {
headers.append(SET_COOKIE, val);
}
}
/// Generate a per-request challenge for the magic-link browser
/// binding (PR 22). 128-bit UUIDv4 — same shape as `generate_csrf_token`,
/// plenty of entropy to make brute-force matching infeasible during
/// the 10-minute login TTL. The value is set as a cookie on the
/// originating browser AND mirrored into the token row so the
/// redemption endpoint can compare them.
pub fn generate_magic_request_challenge() -> String {
uuid::Uuid::new_v4().to_string()
}
/// Append the `oxicloud_magic_request` cookie that binds a
/// login-via-email magic-link to the originating browser (PR 22).
/// HttpOnly + SameSite=Strict + Path=/magic — only sent back when
/// the user clicks the redemption link, never on cross-site
/// navigations. `value` is a random URL-safe string the handler
/// also mirrors into `auth.magic_link_tokens.request_challenge`.
pub fn append_magic_request_cookie(headers: &mut HeaderMap, value: &str, max_age_secs: i64) {
if let Ok(val) = HeaderValue::from_str(&build_cookie(
MAGIC_REQUEST_COOKIE,
value,
"/magic",
max_age_secs,
"Strict",
)) {
headers.append(SET_COOKIE, val);
}
}
/// Clear the `oxicloud_magic_request` cookie after redemption — the
/// challenge is single-use, so we don't want a stale cookie on the
/// browser confusing a later flow.
pub fn append_clear_magic_request_cookie(headers: &mut HeaderMap) {
let secure = if cookie_secure() { "; Secure" } else { "" };
let val = format!(
"{MAGIC_REQUEST_COOKIE}=; HttpOnly; SameSite=Strict; Path=/magic; Max-Age=0{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 { "" };
let val = format!("{CSRF_COOKIE}=; SameSite=Lax; Path=/; Max-Age=0{secure}",);
if let Ok(hv) = HeaderValue::from_str(&val) {
headers.append(SET_COOKIE, hv);
}
}