use serde::{Deserialize, Serialize};
pub use crate::session::{PasswordFingerprint, SessionSecret, SessionSecretError};
const OPERATOR_DOMAIN_TAG: &[u8] = b"rustango-operator-session-v1";
pub(crate) use crate::session::session_survives;
pub const COOKIE_NAME: &str = "rustango_op_session";
pub const SESSION_TTL_SECS: i64 = 12 * 60 * 60;
pub const SESSION_IDLE_SECS: i64 = 30 * 60;
const RENEW_AFTER_SECS: i64 = 60;
pub(crate) fn tenant_secret_from_env() -> Result<SessionSecret, super::TenancyError> {
use crate::session::{is_prod_tier, tier_from_env};
if is_prod_tier(&tier_from_env()) {
SessionSecret::require_from_env()
.map_err(|e| super::TenancyError::Validation(e.to_string()))
} else {
Ok(SessionSecret::from_env_or_disk(std::path::Path::new(
crate::session::TENANT_SESSION_KEY_PATH,
)))
}
}
pub(crate) struct ServerKeys {
pub(crate) tenant: SessionSecret,
pub(crate) operator: SessionSecret,
}
impl ServerKeys {
pub(crate) fn load() -> Self {
let tier = crate::session::tier_from_env();
let env = std::env::var(crate::session::SESSION_SECRET_ENV).ok();
if let Some(msg) = per_pod_key_warning(&tier, env.as_deref()) {
tracing::warn!(target: "rustango::server", "{msg}");
}
Self::from_files(|p| crate::session::load_session_secret_for_tier(&tier, p))
}
fn from_files(load: impl Fn(&std::path::Path) -> SessionSecret) -> Self {
use crate::session::{OPERATOR_SESSION_KEY_PATH, TENANT_SESSION_KEY_PATH};
Self {
tenant: load(std::path::Path::new(TENANT_SESSION_KEY_PATH)),
operator: load(std::path::Path::new(OPERATOR_SESSION_KEY_PATH)),
}
}
#[cfg(test)]
fn dev_at(env: Option<&str>, root: &std::path::Path) -> Self {
Self::from_files(|p| SessionSecret::from_raw_or_disk(env, &root.join(p)))
}
}
fn per_pod_key_warning(tier: &str, env: Option<&str>) -> Option<&'static str> {
let usable = env.is_some_and(|raw| SessionSecret::from_b64(raw).is_ok());
(crate::env::canonical_tier(tier) == "staging" && !usable).then_some(
"RUSTANGO_SESSION_SECRET is not set (or unusable) on staging: session keys come \
from ./var/ files on this pod, so sessions are not shared across replicas",
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum KeySource {
Env,
DevFile,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DevKeyFile {
LoadOnly,
LoadOrCreate,
}
pub(crate) fn existing_tenant_secret(
dev_key: DevKeyFile,
) -> Result<(SessionSecret, KeySource), super::TenancyError> {
use crate::session::{is_prod_tier, tier_from_env};
existing_tenant_secret_at(
std::env::var(crate::session::SESSION_SECRET_ENV)
.ok()
.as_deref(),
is_prod_tier(&tier_from_env()),
std::path::Path::new(crate::session::TENANT_SESSION_KEY_PATH),
dev_key,
)
}
fn existing_tenant_secret_at(
env: Option<&str>,
prod: bool,
dev_file: &std::path::Path,
dev_key: DevKeyFile,
) -> Result<(SessionSecret, KeySource), super::TenancyError> {
let invalid =
|e: crate::session::SessionSecretError| super::TenancyError::Validation(e.to_string());
if prod {
return env
.ok_or(crate::session::SessionSecretError::Missing)
.and_then(SessionSecret::from_b64)
.map(|s| (s, KeySource::Env))
.map_err(invalid);
}
if let Some(raw) = env {
match SessionSecret::from_b64(raw) {
Ok(s) => return Ok((s, KeySource::Env)),
Err(e) => crate::session::warn_unusable_secret(&e.to_string()),
}
}
let missing = |fix: String| {
super::TenancyError::Validation(format!(
"no tenant session key: set RUSTANGO_SESSION_SECRET, or {fix}"
))
};
match dev_key {
DevKeyFile::LoadOnly => match std::fs::read(dev_file) {
Ok(bytes) if bytes.len() >= 32 => {
Ok((SessionSecret::from_bytes(bytes), KeySource::DevFile))
}
_ => Err(missing(format!(
"start the server once so it writes {}",
dev_file.display()
))),
},
DevKeyFile::LoadOrCreate => crate::session::load_or_create_key_file(
dev_file,
"session secret",
crate::session::SESSION_SECRET_ENV,
)
.map(|bytes| (SessionSecret::from_bytes(bytes), KeySource::DevFile))
.map_err(|e| {
missing(format!(
"fix the dev key file: cannot read or write {} ({e})",
dev_file.display()
))
}),
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SessionError {
#[error("session cookie is malformed")]
Malformed,
#[error("session signature mismatch")]
BadSignature,
#[error("session expired")]
Expired,
#[error("session is bound to a different tenant")]
WrongTenant,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionPayload {
pub oid: i64,
pub exp: i64,
#[serde(default)]
pub iat: i64,
#[serde(default)]
pub pwf: PasswordFingerprint,
}
impl SessionPayload {
#[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,
}
}
#[must_use]
pub fn renewed(&self) -> Option<Self> {
let now = chrono::Utc::now().timestamp();
let exp = (now + SESSION_IDLE_SECS).min(self.iat.saturating_add(SESSION_TTL_SECS));
(exp < self.exp || exp - self.exp >= RENEW_AFTER_SECS).then(|| Self {
exp,
..self.clone()
})
}
}
impl crate::session::Expiring for SessionPayload {
fn is_expired_at(&self, now: i64) -> bool {
now >= self.exp || now >= self.iat.saturating_add(SESSION_TTL_SECS)
}
}
impl From<crate::session::SealError> for SessionError {
fn from(e: crate::session::SealError) -> Self {
match e {
crate::session::SealError::BadSignature => Self::BadSignature,
crate::session::SealError::Expired => Self::Expired,
_ => Self::Malformed,
}
}
}
#[must_use]
pub fn encode(secret: &SessionSecret, payload: &SessionPayload) -> String {
crate::session::SealedToken::seal(secret.key(), OPERATOR_DOMAIN_TAG, payload)
}
pub fn decode(secret: &SessionSecret, value: &str) -> Result<SessionPayload, SessionError> {
Ok(crate::session::SealedToken::open(
secret.key(),
OPERATOR_DOMAIN_TAG,
value,
)?)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
fn fp() -> PasswordFingerprint {
PasswordFingerprint::default()
}
#[test]
fn a_token_of_another_kind_is_refused() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
let payload = SessionPayload::new(7, 3600, fp());
assert!(decode(&secret, &encode(&secret, &payload)).is_ok());
let b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
let tags: [&[u8]; 3] = [
b"rustango-tenant-session-v1",
b"rustango-impersonation-handoff-v1",
b"rustango-member-session-v1",
];
let sigs = tags
.iter()
.map(|t| crate::session::sign_tagged(&secret, t, b64.as_bytes()))
.chain([crate::session::sign(&secret, b64.as_bytes())]);
for sig in sigs {
let cookie = format!("{b64}.{}", URL_SAFE_NO_PAD.encode(sig));
assert!(matches!(
decode(&secret, &cookie),
Err(SessionError::BadSignature)
));
}
}
#[test]
fn staging_on_dev_key_files_warns_once() {
let good = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
assert!(per_pod_key_warning("staging", None).is_some());
assert!(per_pod_key_warning(" STAGING ", Some("short")).is_some());
assert!(per_pod_key_warning("staging", Some(good)).is_none());
assert!(per_pod_key_warning("dev", None).is_none());
assert!(per_pod_key_warning("prod", None).is_none());
}
#[test]
fn off_prod_the_dev_key_file_is_created_once() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("var").join("tenant.key");
assert!(existing_tenant_secret_at(None, true, &path, DevKeyFile::LoadOrCreate).is_err());
assert!(!path.exists(), "prod wrote a key file");
let (first, source) =
existing_tenant_secret_at(None, false, &path, DevKeyFile::LoadOrCreate).unwrap();
assert_eq!(source, KeySource::DevFile);
assert_eq!(std::fs::read(&path).unwrap(), first.key());
let server = SessionSecret::from_raw_or_disk(None, &path);
assert_eq!(
server.key(),
first.key(),
"the server must load the same key"
);
let (again, _) =
existing_tenant_secret_at(None, false, &path, DevKeyFile::LoadOrCreate).unwrap();
assert_eq!(again.key(), first.key());
}
#[test]
fn a_cli_link_verifies_under_the_servers_tenant_key() {
let root = tempfile::tempdir().unwrap();
let file = root.path().join(crate::session::TENANT_SESSION_KEY_PATH);
let (cli, _) =
existing_tenant_secret_at(None, false, &file, DevKeyFile::LoadOrCreate).unwrap();
let server = ServerKeys::dev_at(None, root.path());
let scope = crate::auth_flows::LinkScope::from(&crate::tenancy::Org {
id: crate::sql::Auto::Set(1),
..crate::testkit::org()
});
let link = crate::auth_flows::PasswordReset::issue(
&scope,
"/change-password",
42,
&super::super::first_admin::link_key(&cli),
std::time::Duration::from_secs(60),
);
let key = super::super::first_admin::link_key(&server.tenant);
assert_eq!(
crate::auth_flows::PasswordReset::verify(&scope, &link, &key),
Ok(42)
);
assert_ne!(
server.operator.key(),
server.tenant.key(),
"one key per console"
);
}
#[test]
fn an_unusable_env_secret_uses_the_servers_dev_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tenant.key");
std::fs::write(&path, [7u8; 32]).unwrap();
let (_, source) =
existing_tenant_secret_at(Some("not-base64!"), false, &path, DevKeyFile::LoadOnly)
.unwrap();
assert_eq!(source, KeySource::DevFile);
assert!(
existing_tenant_secret_at(Some("not-base64!"), true, &path, DevKeyFile::LoadOnly)
.is_err()
);
let good = "A".repeat(44);
let (_, source) =
existing_tenant_secret_at(Some(&good), false, &path, DevKeyFile::LoadOnly).unwrap();
assert_eq!(source, KeySource::Env);
}
#[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);
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 renewal_slides_exp_up_to_the_cap() {
let now = chrono::Utc::now().timestamp();
let renewed = SessionPayload::new(1, 30, fp()).renewed().expect("stale");
assert!(renewed.exp >= now + SESSION_IDLE_SECS);
assert!(renewed.renewed().is_none(), "fresh needs no new cookie");
let mut near_cap = SessionPayload::new(1, 30, fp());
near_cap.iat = now - SESSION_TTL_SECS + 10;
assert!(near_cap.renewed().unwrap().exp <= now + 10);
let old = SessionPayload::new(1, 7 * 24 * 3600, fp());
let cut = old.renewed().expect("a far exp is pulled in");
assert!(cut.exp <= chrono::Utc::now().timestamp() + SESSION_IDLE_SECS);
}
#[test]
fn rejects_a_session_past_the_absolute_cap() {
let secret = SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec());
let mut payload = SessionPayload::new(1, 3600, fp());
payload.iat -= SESSION_TTL_SECS;
let err = decode(&secret, &encode(&secret, &payload)).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));
}
#[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
));
assert!(!session_survives(
&secret,
&fp(),
now.timestamp(),
"$old",
None,
None
));
}
#[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
));
}
}