rustango 0.60.7

Batteries-included web framework for Rust: ORM + migrations + auto-admin + multi-tenancy + audit log + auth (sessions, JWT, OAuth2/OIDC, HMAC) + APIs (ViewSet, OpenAPI auto-derive, JSON:API) + jobs (in-mem + Postgres) + email + media (S3 / R2 / B2 / MinIO + presigned uploads + collections + tags) + production middleware (CSRF, CSP, rate-limiting, compression, idempotency, etc.).
Documentation
//! HMAC-SHA256 signed session cookies for the operator console.
//!
//! Stateless — the cookie carries the principal (`operator_id` + `exp`)
//! and a signature. Server validates the signature on every request;
//! no server-side session table for v1. Trade-offs:
//!
//! * **No revocation list** — a cookie is valid until `exp`, unless the
//!   operator is deactivated, changes password or logs out. Logout stamps
//!   `sessions_revoked_at`, which ends the operator's sessions on every device.
//! * **Secret rotation invalidates all cookies** — a server restart
//!   with auto-generated secret signs everyone out. With
//!   `RUSTANGO_SESSION_SECRET` set in env, sessions survive restarts.
//!
//! Cookie format:
//!
//! ```text
//! Cookie: rustango_op_session=<base64(payload)>.<base64(hmac_sha256)>
//! payload  = JSON {"oid": <i64>, "exp": <unix_seconds>, "iat": .., "pwf": ..}
//! signature = HMAC-SHA256(secret, payload_base64) [first 32 bytes]
//! ```

use base64::Engine;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;

// v0.45 (#253) — `SessionSecret` + `sign` were promoted to the
// crate-root `crate::session` module so the bare `admin` module can
// reuse the same HMAC primitives without compiling in `tenancy`.
// The re-exports below keep every pre-existing `crate::tenancy::session::SessionSecret`
// caller working.
pub use crate::session::{PasswordFingerprint, SessionSecret, SessionSecretError};
// `sign` re-exported at crate-internal visibility so existing
// callers (`tenant_console`, `impersonation_handoff`) keep working
// after the v0.45 move to `crate::session`.
pub(crate) use crate::session::{session_survives, sign};

/// Default cookie name. Visible in browser devtools — namespaced so
/// it doesn't collide with tenant cookies.
pub const COOKIE_NAME: &str = "rustango_op_session";

/// Default session lifetime (7 days). Configurable later; locked
/// for v1.
pub const SESSION_TTL_SECS: i64 = 7 * 24 * 60 * 60;

#[derive(Debug, thiserror::Error)]
pub enum SessionError {
    #[error("session cookie is malformed")]
    Malformed,
    #[error("session signature mismatch")]
    BadSignature,
    #[error("session expired")]
    Expired,
    /// Cookie's tenant slug doesn't match the resolved tenant — used
    /// by the tenant console to defend against cross-tenant cookie
    /// replay (cookie issued for `acme` should not authenticate at
    /// `globex`'s subdomain).
    #[error("session is bound to a different tenant")]
    WrongTenant,
}

/// Principal payload carried inside the cookie. Compact field names
/// to keep the cookie short (browsers truncate aggressively).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionPayload {
    /// Operator id (from `rustango_operators.id`).
    pub oid: i64,
    /// Expiry as Unix seconds.
    pub exp: i64,
    /// Issued-at as Unix seconds. A session issued before
    /// `rustango_operators.password_changed_at` is rejected. `0` for
    /// cookies minted on pre-0.28.4 servers.
    #[serde(default)]
    pub iat: i64,
    /// Fingerprint of the operator's `password_hash` at login. Any
    /// password change makes it stop matching (#1338).
    #[serde(default)]
    pub pwf: PasswordFingerprint,
}

impl SessionPayload {
    /// `pwf` is the [`PasswordFingerprint`] of the operator's current hash.
    #[must_use]
    pub fn new(operator_id: i64, ttl_secs: i64, pwf: PasswordFingerprint) -> Self {
        let now = chrono::Utc::now().timestamp();
        Self {
            oid: operator_id,
            exp: now + ttl_secs,
            iat: now,
            pwf,
        }
    }

    fn is_expired(&self) -> bool {
        chrono::Utc::now().timestamp() >= self.exp
    }
}

/// Serialize and sign a payload into a cookie value.
#[must_use]
pub fn encode(secret: &SessionSecret, payload: &SessionPayload) -> String {
    let json = serde_json::to_vec(payload).expect("payload serializes");
    let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json);
    let sig = sign(secret, payload_b64.as_bytes());
    let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig);
    format!("{payload_b64}.{sig_b64}")
}

/// Verify and deserialize a cookie value.
///
/// # Errors
/// Returns [`SessionError::Malformed`] for missing/bad-base64 parts,
/// [`SessionError::BadSignature`] when HMAC doesn't match (covers
/// secret rotation + tampering), [`SessionError::Expired`] when the
/// payload's `exp` is in the past.
pub fn decode(secret: &SessionSecret, value: &str) -> Result<SessionPayload, SessionError> {
    let (payload_b64, sig_b64) = value.split_once('.').ok_or(SessionError::Malformed)?;
    let expected = sign(secret, payload_b64.as_bytes());
    let provided = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(sig_b64)
        .map_err(|_| SessionError::Malformed)?;
    if expected.ct_eq(&provided[..]).unwrap_u8() == 0 {
        return Err(SessionError::BadSignature);
    }
    let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(payload_b64)
        .map_err(|_| SessionError::Malformed)?;
    let payload: SessionPayload =
        serde_json::from_slice(&payload_bytes).map_err(|_| SessionError::Malformed)?;
    if payload.is_expired() {
        return Err(SessionError::Expired);
    }
    Ok(payload)
}

// `sign` moved to `crate::session` in v0.45 (#253) — `use` above.

#[cfg(test)]
mod tests {
    use super::*;

    fn fp() -> PasswordFingerprint {
        PasswordFingerprint::default()
    }

    #[test]
    fn round_trip_valid_payload() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let payload = SessionPayload::new(42, 3600, PasswordFingerprint::of(&secret, "$h"));
        let cookie = encode(&secret, &payload);
        let back = decode(&secret, &cookie).unwrap();
        assert_eq!(back, payload);
    }

    #[test]
    fn rejects_tampered_payload() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let payload = SessionPayload::new(42, 3600, fp());
        let cookie = encode(&secret, &payload);
        // Replace payload but keep signature → BadSignature.
        let (_, sig) = cookie.split_once('.').unwrap();
        let evil_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
            .encode(br#"{"oid":999,"exp":9999999999}"#);
        let tampered = format!("{evil_payload}.{sig}");
        let err = decode(&secret, &tampered).unwrap_err();
        assert!(matches!(err, SessionError::BadSignature));
    }

    #[test]
    fn rejects_wrong_secret() {
        let s1 = SessionSecret::from_bytes(b"first-test-secret-thirty-2-bytes".to_vec());
        let s2 = SessionSecret::from_bytes(b"second-test-secret-thirty2-bytes".to_vec());
        let cookie = encode(&s1, &SessionPayload::new(1, 3600, fp()));
        let err = decode(&s2, &cookie).unwrap_err();
        assert!(matches!(err, SessionError::BadSignature));
    }

    #[test]
    fn rejects_expired() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let payload = SessionPayload::new(1, -10, fp());
        let cookie = encode(&secret, &payload);
        let err = decode(&secret, &cookie).unwrap_err();
        assert!(matches!(err, SessionError::Expired));
    }

    #[test]
    fn rejects_malformed_no_dot() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let err = decode(&secret, "not-a-cookie").unwrap_err();
        assert!(matches!(err, SessionError::Malformed));
    }

    /// A new hash ends the session even when `iat` equals the change second.
    #[test]
    fn a_new_password_hash_ends_a_same_second_session() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let now = chrono::Utc::now();
        let pwf = PasswordFingerprint::of(&secret, "$old");
        assert!(session_survives(
            &secret,
            &pwf,
            now.timestamp(),
            "$old",
            Some(now),
            None
        ));
        assert!(!session_survives(
            &secret,
            &pwf,
            now.timestamp(),
            "$new",
            Some(now),
            None
        ));
        // A cookie from before this field decodes empty and never matches.
        assert!(!session_survives(
            &secret,
            &fp(),
            now.timestamp(),
            "$old",
            None,
            None
        ));
    }

    /// With the hash unchanged, a `password_changed_at` bump after `iat` still ends it.
    #[test]
    fn a_later_password_changed_at_ends_a_session_with_the_same_hash() {
        let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
        let pwf = PasswordFingerprint::of(&secret, "$h");
        let iat = 1_700_000_000;
        let at = |secs| chrono::DateTime::from_timestamp(secs, 0);
        assert!(session_survives(&secret, &pwf, iat, "$h", None, None));
        assert!(session_survives(&secret, &pwf, iat, "$h", at(iat), None));
        assert!(!session_survives(
            &secret,
            &pwf,
            iat,
            "$h",
            at(iat + 1),
            None
        ));
    }
}