Files
Oxicloud/src/interfaces/api/cookie_auth.rs
T
Edouard Vanbelle b90fa6f619 security: prevent re-use of refresh token (reduce surface for any stolen token)
Security: session hardening

  Refresh token rotation with theft detection (family_id)
  - Added family_id column to auth.sessions (migration 20260507000000_session_family.sql) grouping all tokens issued from the same login into a family
  - On refresh, the new session inherits the parent's family_id
  - If a revoked token is replayed (indicates the token was stolen after rotation), the entire family is immediately invalidated and a warning is logged — forcing re-authentication on all devices

  SameSite=Strict on refresh cookie
  - Access cookie stays SameSite=Lax (needed for top-level navigation)
  - Refresh cookie upgraded to SameSite=Strict — it is only ever used for explicit POST to /api/auth/refresh, never via cross-site navigation

  Refresh token TTL: 30 days → 7 days
  - With rotation, active sessions auto-renew and effectively never expire
  - Inactive sessions expire after 7 days instead of 30, reducing the theft window
2026-05-07 13:57:10 +02:00

178 lines
7.1 KiB
Rust

//! 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";
/// 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()
}
fn 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);
}
}
/// 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);
}
}