8fc9a50681
magic-link as now 2 modes:
- invitation: long TTL (24), no challenge
- passwordless login: short TTL (10min), cookie challenge to ensure that
user goes back to same browser (no man in the middle capturing email)
225 lines
9.1 KiB
Rust
225 lines
9.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";
|
|
/// 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()
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|