diff --git a/Cargo.lock b/Cargo.lock index d42412c..d5572cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,56 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -332,7 +376,12 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -346,6 +395,23 @@ dependencies = [ "unsigned-varint", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "const-oid" version = "0.9.6" @@ -370,6 +436,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -386,6 +462,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -486,7 +568,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", ] [[package]] @@ -553,6 +655,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -939,6 +1047,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", +] + [[package]] name = "group" version = "0.13.0" @@ -1208,6 +1325,30 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1321,6 +1462,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipconfig" version = "0.3.4" @@ -1595,6 +1745,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1743,6 +1899,17 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.14.0" @@ -1758,6 +1925,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2577,12 +2750,19 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "starhaven" version = "0.1.0" dependencies = [ + "aes-gcm", "atproto-identity", "atproto-oauth", "axum", + "base64", + "chrono", + "cookie", "maud", + "rand 0.9.5", + "serde", "serde_json", "sqlx", + "thiserror 2.0.19", "tokio", ] @@ -2725,6 +2905,36 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2944,6 +3154,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsigned-varint" version = "0.8.0" @@ -3127,6 +3347,41 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 2114141..d9780b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,4 +14,11 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] } sqlx = { version = "0.8", features = [ "runtime-tokio" ] } atproto-identity = "0.14.5" atproto-oauth = "0.14.5" -serde_json = "1" \ No newline at end of file +serde = { version = "1", features = ["derive"] } +serde_json = "1" +aes-gcm = "0.11" +rand = "0.9" +cookie = "0.18" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +thiserror = "2" \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 6935ab1..8b855b0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,9 @@ //! Configuration loaded from environment variables. use atproto_identity::key::{generate_key, identify_key, to_public, KeyData, KeyType}; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine as _; +use rand::RngCore as _; /// Application configuration. pub struct Config { @@ -8,6 +11,8 @@ pub struct Config { pub external_base: String, /// OAuth private signing keys (P-256). First is the active signer. pub oauth_private_keys: Vec, + /// AES-256-GCM cookie secret (32 bytes). + pub cookie_secret: [u8; 32], } impl Config { @@ -34,9 +39,30 @@ impl Config { } }; + let cookie_secret = match std::env::var("COOKIE_SECRET") { + Ok(raw) => BASE64_STANDARD + .decode(raw.trim()) + .expect("invalid COOKIE_SECRET base64") + .try_into() + .expect("COOKIE_SECRET must decode to exactly 32 bytes"), + Err(_) => { + let mut secret = [0u8; 32]; + rand::rng().fill_bytes(&mut secret); + eprintln!("====================================================="); + eprintln!("WARNING: COOKIE_SECRET not set."); + eprintln!("Generating an EPHEMERAL cookie secret for this run only."); + eprintln!("Existing sessions will be invalidated on every restart."); + eprintln!("To pin it, set COOKIE_SECRET in your environment."); + eprintln!("Generated secret: {}", BASE64_STANDARD.encode(secret)); + eprintln!("====================================================="); + secret + } + }; + Self { external_base, oauth_private_keys, + cookie_secret, } } diff --git a/src/oauth/mod.rs b/src/oauth/mod.rs index 7a8360a..5a5a029 100644 --- a/src/oauth/mod.rs +++ b/src/oauth/mod.rs @@ -2,6 +2,7 @@ //! callback, session refresh and logout. Owns every OAuth-related route. pub mod metadata; +pub mod session; use axum::routing::get; use axum::Router; diff --git a/src/oauth/session.rs b/src/oauth/session.rs new file mode 100644 index 0000000..a652a97 --- /dev/null +++ b/src/oauth/session.rs @@ -0,0 +1,315 @@ +//! Session + identity cookie types and the AES-256-GCM cookie codec. +//! +//! The session cookie (encrypted, `HttpOnly`) carries the OAuth tokens and +//! the per-session DPoP key; the identity cookie (base64 JSON, JS-readable) +//! carries display info for the UI. + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use axum::http::{header, HeaderMap, HeaderValue}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64; +use base64::Engine as _; +use chrono::{DateTime, Duration, Utc}; +use cookie::{Cookie, SameSite}; +use rand::RngCore as _; +use serde::{Deserialize, Serialize}; + +/// Name of the encrypted session cookie. +pub const SESSION_COOKIE_NAME: &str = "session"; +/// Name of the readable identity cookie. +pub const IDENTITY_COOKIE_NAME: &str = "identity"; + +/// Encrypted session state (OAuth tokens + DPoP key). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionCookie { + /// The user's DID. + pub did: String, + /// The DPoP-bound OAuth access token. + pub access_token: String, + /// The OAuth refresh token, if any. + pub refresh_token: Option, + /// Access-token expiry. + pub expires_at: DateTime, + /// The per-session DPoP private key (`did:key:...`). + pub dpop_private_key: String, +} + +impl SessionCookie { + /// Whether the access token expires within `duration`. + pub fn expires_within(&self, duration: Duration) -> bool { + Utc::now() + duration >= self.expires_at + } +} + +/// Readable identity info for the UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdentityCookie { + /// The user's DID. + pub did: String, + /// The user's handle, if resolved. + pub handle: Option, + /// The user's PDS URL, if resolved. + pub pds_url: Option, +} + +/// Cookie codec/transport errors. +#[derive(Debug, thiserror::Error)] +pub enum CookieError { + /// Cipher initialization failed. + #[error("failed to initialize cipher")] + CipherInit, + /// Encryption failed. + #[error("encryption failed")] + Encryption, + /// Decryption failed. + #[error("decryption failed")] + Decryption, + /// Base64 decoding failed. + #[error("invalid base64 encoding")] + InvalidBase64, + /// The ciphertext was malformed. + #[error("invalid cookie format")] + InvalidFormat, + /// (De)serialization failed. + #[error("serialization failed")] + Serialization, + /// The cookie value was not a valid header value. + #[error("invalid cookie value")] + InvalidCookieValue, +} + +/// Encrypt `data` with AES-256-GCM, returning base64url(nonce || ciphertext). +fn encrypt_cookie(secret: &[u8; 32], data: &[u8]) -> Result { + let cipher = Aes256Gcm::new_from_slice(secret).map_err(|_| CookieError::CipherInit)?; + let mut nonce_bytes = [0u8; 12]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from(nonce_bytes); + let ciphertext = cipher + .encrypt(&nonce, data) + .map_err(|_| CookieError::Encryption)?; + let mut combined = Vec::with_capacity(12 + ciphertext.len()); + combined.extend_from_slice(&nonce_bytes); + combined.extend_from_slice(&ciphertext); + Ok(BASE64.encode(&combined)) +} + +/// Decrypt a value produced by `encrypt_cookie`. +fn decrypt_cookie(secret: &[u8; 32], encrypted: &str) -> Result, CookieError> { + let combined = BASE64 + .decode(encrypted) + .map_err(|_| CookieError::InvalidBase64)?; + if combined.len() < 12 { + return Err(CookieError::InvalidFormat); + } + let (nonce_bytes, ciphertext) = combined.split_at(12); + let nonce = Nonce::try_from(nonce_bytes).map_err(|_| CookieError::InvalidFormat)?; + let cipher = Aes256Gcm::new_from_slice(secret).map_err(|_| CookieError::CipherInit)?; + cipher + .decrypt(&nonce, ciphertext) + .map_err(|_| CookieError::Decryption) +} + +/// Serialize + encrypt a session cookie. +pub fn encode_session_cookie( + secret: &[u8; 32], + session: &SessionCookie, +) -> Result { + let json = serde_json::to_vec(session).map_err(|_| CookieError::Serialization)?; + encrypt_cookie(secret, &json) +} + +/// Decrypt + deserialize a session cookie. +pub fn decode_session_cookie( + secret: &[u8; 32], + encrypted: &str, +) -> Result { + let decrypted = decrypt_cookie(secret, encrypted)?; + serde_json::from_slice(&decrypted).map_err(|_| CookieError::Serialization) +} + +/// Encode an identity cookie (base64 JSON, readable by JS). +pub fn encode_identity_cookie(identity: &IdentityCookie) -> Result { + let json = serde_json::to_vec(identity).map_err(|_| CookieError::Serialization)?; + Ok(BASE64.encode(&json)) +} + +/// Decode an identity cookie. +pub fn decode_identity_cookie(encoded: &str) -> Result { + let json = BASE64 + .decode(encoded) + .map_err(|_| CookieError::InvalidBase64)?; + serde_json::from_slice(&json).map_err(|_| CookieError::Serialization) +} + +/// Build a `Set-Cookie` header for the session cookie (`HttpOnly`). +pub fn build_session_cookie_header( + external_base: &str, + value: &str, + max_age_secs: i64, +) -> Result { + build_cookie_header( + SESSION_COOKIE_NAME, + external_base, + value, + max_age_secs, + true, + ) +} + +/// Build a `Set-Cookie` header for the identity cookie (JS-readable). +pub fn build_identity_cookie_header( + external_base: &str, + value: &str, + max_age_secs: i64, +) -> Result { + build_cookie_header( + IDENTITY_COOKIE_NAME, + external_base, + value, + max_age_secs, + false, + ) +} + +/// Build a `Set-Cookie` header. `external_base` has a scheme (`http://` or +/// `https://`), which sets `Secure`; `Domain` is omitted for +/// `localhost`/IP hosts, which browsers reject as a `Domain` value. +fn build_cookie_header( + name: &str, + external_base: &str, + value: &str, + max_age_secs: i64, + http_only: bool, +) -> Result { + let secure = external_base.starts_with("https://"); + let host = external_base + .trim_start_matches("https://") + .trim_start_matches("http://") + .split(['/', ':']) + .next() + .unwrap_or(""); + + let mut builder = Cookie::build((name.to_string(), value.to_string())) + .path("/") + .secure(secure) + .http_only(http_only) + .same_site(SameSite::Lax) + .max_age(cookie::time::Duration::seconds(max_age_secs)); + + if host != "localhost" && host.parse::().is_err() { + builder = builder.domain(host.to_string()); + } + + let cookie = builder.build(); + HeaderValue::from_str(&cookie.to_string()).map_err(|_| CookieError::InvalidCookieValue) +} + +/// Build a `Set-Cookie` header that clears a cookie. +pub fn build_clear_cookie_header( + name: &str, + external_base: &str, + http_only: bool, +) -> Result { + build_cookie_header(name, external_base, "", 0, http_only) +} + +/// Extract a named cookie value from a `Cookie` header. +pub fn extract_cookie_value<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> { + for cookie_str in cookie_header.split(';') { + let cookie_str = cookie_str.trim(); + if let Some(value) = cookie_str.strip_prefix(name) { + if let Some(value) = value.strip_prefix('=') { + return Some(value); + } + } + } + None +} + +/// Extract and decrypt the session cookie from request headers. +pub fn get_session_from_headers(secret: &[u8; 32], headers: &HeaderMap) -> Option { + let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?; + let session_value = extract_cookie_value(cookie_header, SESSION_COOKIE_NAME)?; + decode_session_cookie(secret, session_value).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_cookie_round_trips() { + let secret = [7u8; 32]; + let session = SessionCookie { + did: "did:plc:example".to_string(), + access_token: "access-token".to_string(), + refresh_token: Some("refresh-token".to_string()), + expires_at: Utc::now() + Duration::minutes(10), + dpop_private_key: "did:key:example".to_string(), + }; + + let encoded = encode_session_cookie(&secret, &session).unwrap(); + let decoded = decode_session_cookie(&secret, &encoded).unwrap(); + + assert_eq!(decoded.did, session.did); + assert_eq!(decoded.access_token, session.access_token); + assert_eq!(decoded.refresh_token, session.refresh_token); + assert_eq!(decoded.dpop_private_key, session.dpop_private_key); + } + + #[test] + fn decoding_with_wrong_secret_fails() { + let secret = [1u8; 32]; + let wrong_secret = [2u8; 32]; + let session = SessionCookie { + did: "did:plc:example".to_string(), + access_token: "access-token".to_string(), + refresh_token: None, + expires_at: Utc::now(), + dpop_private_key: "did:key:example".to_string(), + }; + + let encoded = encode_session_cookie(&secret, &session).unwrap(); + assert!(decode_session_cookie(&wrong_secret, &encoded).is_err()); + } + + #[test] + fn identity_cookie_round_trips() { + let identity = IdentityCookie { + did: "did:plc:example".to_string(), + handle: Some("alice.test".to_string()), + pds_url: Some("https://pds.example".to_string()), + }; + + let encoded = encode_identity_cookie(&identity).unwrap(); + let decoded = decode_identity_cookie(&encoded).unwrap(); + + assert_eq!(decoded.did, identity.did); + assert_eq!(decoded.handle, identity.handle); + assert_eq!(decoded.pds_url, identity.pds_url); + } + + #[test] + fn extracts_named_cookie_from_header() { + let header = "foo=bar; session=abc123; identity=xyz789"; + assert_eq!(extract_cookie_value(header, "session"), Some("abc123")); + assert_eq!(extract_cookie_value(header, "identity"), Some("xyz789")); + assert_eq!(extract_cookie_value(header, "missing"), None); + } + + #[test] + fn local_http_cookie_is_not_secure_and_has_no_domain() { + let header = build_session_cookie_header("http://127.0.0.1:3000", "v", 60).unwrap(); + let cookie_str = header.to_str().unwrap(); + assert!(!cookie_str.contains("Secure"), "{cookie_str}"); + assert!(!cookie_str.contains("Domain="), "{cookie_str}"); + } + + #[test] + fn production_https_cookie_is_secure_with_domain() { + let header = build_session_cookie_header("https://starhaven.dev", "v", 60).unwrap(); + let cookie_str = header.to_str().unwrap(); + assert!(cookie_str.contains("Secure"), "{cookie_str}"); + assert!(cookie_str.contains("Domain=starhaven.dev"), "{cookie_str}"); + } +}