use serde::{Deserialize, Serialize};
use super::session::SessionError;
pub use super::session::{PasswordFingerprint, SessionSecret};
const TENANT_DOMAIN_TAG: &[u8] = b"rustango-tenant-session-v1";
pub const COOKIE_NAME: &str = "rustango_tenant_session";
pub const SESSION_TTL_SECS: i64 = 7 * 24 * 60 * 60;
pub const IMPERSONATION_TTL_SECS: i64 = 60 * 60;
pub(crate) const RUSTANGO_PNG: &[u8] = include_bytes!("static/rustango.png");
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TenantSessionPayload {
pub uid: i64,
pub slug: String,
pub org: i64,
pub exp: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub imp: Option<i64>,
#[serde(default)]
pub iat: i64,
#[serde(default)]
pub pwf: PasswordFingerprint,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sid: Option<String>,
}
impl TenantSessionPayload {
#[must_use]
pub fn new(
user_id: i64,
tenant: &crate::tenancy::TenantSlug,
ttl_secs: i64,
pwf: PasswordFingerprint,
) -> Self {
let now = chrono::Utc::now().timestamp();
Self {
uid: user_id,
slug: tenant.slug.clone(),
org: tenant.org_id,
exp: now + ttl_secs,
imp: None,
iat: now,
pwf,
sid: None,
}
}
#[must_use]
pub fn impersonation(
operator_id: i64,
tenant: &crate::tenancy::TenantSlug,
ttl_secs: i64,
pwf: PasswordFingerprint,
sid: impl Into<String>,
) -> Self {
let now = chrono::Utc::now().timestamp();
Self {
uid: 0,
slug: tenant.slug.clone(),
org: tenant.org_id,
exp: now + ttl_secs,
imp: Some(operator_id),
iat: now,
pwf,
sid: Some(sid.into()),
}
}
#[must_use]
pub fn is_impersonation(&self) -> bool {
self.imp.is_some()
}
}
impl crate::session::Expiring for TenantSessionPayload {
fn is_expired_at(&self, now: i64) -> bool {
now >= self.exp
}
}
#[must_use]
pub fn encode(secret: &SessionSecret, payload: &TenantSessionPayload) -> String {
crate::session::SealedToken::seal(secret.key(), TENANT_DOMAIN_TAG, payload)
}
pub fn decode(
secret: &SessionSecret,
tenant: &crate::tenancy::TenantSlug,
value: &str,
) -> Result<TenantSessionPayload, SessionError> {
let payload: TenantSessionPayload =
crate::session::SealedToken::open(secret.key(), TENANT_DOMAIN_TAG, value)?;
if !tenant.binds(&payload.slug, payload.org) {
return Err(SessionError::WrongTenant);
}
Ok(payload)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine as _;
fn key() -> SessionSecret {
SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec())
}
#[test]
fn a_token_of_another_kind_is_refused() {
use base64::Engine;
let secret = key();
let json = br#"{"uid":7,"slug":"acme","exp":99999999999}"#;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json);
let tags: [&[u8]; 2] = [
b"rustango-operator-session-v1",
b"rustango-impersonation-handoff-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 sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig);
assert!(matches!(
decode(&secret, &acme(), &format!("{b64}.{sig_b64}")),
Err(SessionError::BadSignature)
));
}
}
fn acme() -> crate::tenancy::TenantSlug {
crate::tenancy::TenantSlug::new(1, "acme")
}
#[test]
fn rejects_cookie_minted_for_a_purged_tenant_with_the_slug() {
let secret = key();
let heir = crate::tenancy::TenantSlug::new(2, "acme");
for p in [
TenantSessionPayload::new(7, &acme(), 3600, fp()),
TenantSessionPayload::impersonation(9, &acme(), 3600, fp(), "s1"),
] {
let err = decode(&secret, &heir, &encode(&secret, &p)).unwrap_err();
assert!(matches!(err, SessionError::WrongTenant));
}
let json = br#"{"uid":7,"slug":"acme","exp":99999999999}"#;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json);
let sig = crate::session::sign_tagged(&secret, TENANT_DOMAIN_TAG, b64.as_bytes());
let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig);
let zero = crate::tenancy::TenantSlug::new(0, "acme");
let err = decode(&secret, &zero, &format!("{b64}.{sig_b64}")).unwrap_err();
assert!(matches!(err, SessionError::Malformed));
}
fn fp() -> PasswordFingerprint {
PasswordFingerprint::of(&key(), "$argon2id$test")
}
#[test]
fn round_trip_valid_payload() {
let secret = key();
let payload = TenantSessionPayload::new(7, &acme(), 3600, fp());
let cookie = encode(&secret, &payload);
let back = decode(&secret, &acme(), &cookie).unwrap();
assert_eq!(back, payload);
}
#[test]
fn rejects_cookie_minted_for_a_different_tenant() {
let secret = key();
let payload = TenantSessionPayload::new(7, &acme(), 3600, fp());
let cookie = encode(&secret, &payload);
let err = decode(
&secret,
&crate::tenancy::TenantSlug::new(2, "globex"),
&cookie,
)
.unwrap_err();
assert!(matches!(err, SessionError::WrongTenant));
}
#[test]
fn impersonation_payload_has_imp_set() {
let p = TenantSessionPayload::impersonation(42, &acme(), 3600, fp(), "s1");
assert_eq!(p.uid, 0);
assert_eq!(p.slug, "acme");
assert_eq!(p.imp, Some(42));
assert!(p.is_impersonation());
}
#[test]
fn regular_payload_has_no_imp_field() {
let p = TenantSessionPayload::new(7, &acme(), 3600, fp());
assert_eq!(p.imp, None);
assert!(!p.is_impersonation());
}
#[test]
fn impersonation_round_trip_through_cookie() {
let secret = key();
let p = TenantSessionPayload::impersonation(99, &acme(), 3600, fp(), "s1");
let cookie = encode(&secret, &p);
let back = decode(&secret, &acme(), &cookie).unwrap();
assert_eq!(back, p);
assert_eq!(back.imp, Some(99));
}
#[test]
fn impersonation_cookie_still_pinned_to_slug() {
let secret = key();
let p = TenantSessionPayload::impersonation(99, &acme(), 3600, fp(), "s1");
let cookie = encode(&secret, &p);
let err = decode(
&secret,
&crate::tenancy::TenantSlug::new(2, "globex"),
&cookie,
)
.unwrap_err();
assert!(matches!(err, SessionError::WrongTenant));
}
#[test]
fn pre_0_27_8_cookie_without_imp_still_decodes() {
let secret = key();
let json = br#"{"uid":7,"slug":"acme","org":1,"exp":99999999999}"#;
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json);
let sig = crate::session::sign_tagged(&secret, TENANT_DOMAIN_TAG, payload_b64.as_bytes());
let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig);
let cookie = format!("{payload_b64}.{sig_b64}");
let back = decode(&secret, &acme(), &cookie).unwrap();
assert_eq!(back.uid, 7);
assert_eq!(back.imp, None);
}
#[test]
fn rejects_tampered_payload() {
let secret = key();
let payload = TenantSessionPayload::new(7, &acme(), 3600, fp());
let cookie = encode(&secret, &payload);
let (_, sig) = cookie.split_once('.').unwrap();
let evil = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(br#"{"uid":999,"slug":"acme","exp":9999999999}"#);
let tampered = format!("{evil}.{sig}");
let err = decode(&secret, &acme(), &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, &TenantSessionPayload::new(1, &acme(), 3600, fp()));
let err = decode(&s2, &acme(), &cookie).unwrap_err();
assert!(matches!(err, SessionError::BadSignature));
}
#[test]
fn rejects_expired() {
let secret = key();
let cookie = encode(&secret, &TenantSessionPayload::new(1, &acme(), -10, fp()));
let err = decode(&secret, &acme(), &cookie).unwrap_err();
assert!(matches!(err, SessionError::Expired));
}
#[test]
fn new_payload_stamps_iat_at_construction_time() {
let p = TenantSessionPayload::new(7, &acme(), 3600, fp());
let now = chrono::Utc::now().timestamp();
assert!((p.iat - now).abs() < 2, "iat = {} but now = {now}", p.iat);
assert!(p.iat > 0);
assert_eq!(p.exp - p.iat, 3600);
}
#[test]
fn legacy_pre_v0_28_4_cookie_decodes_with_iat_zero() {
use base64::Engine;
let secret = key();
let json = br#"{"uid":7,"slug":"acme","org":1,"exp":99999999999}"#;
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json);
let sig = crate::session::sign_tagged(&secret, TENANT_DOMAIN_TAG, payload_b64.as_bytes());
let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig);
let cookie = format!("{payload_b64}.{sig_b64}");
let p = decode(&secret, &acme(), &cookie).expect("legacy cookie still parses");
assert_eq!(p.uid, 7);
assert_eq!(p.iat, 0, "missing iat should default to 0");
assert_eq!(p.pwf, PasswordFingerprint::default());
}
}