use base64::Engine;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
pub use super::operator_console::SessionSecret;
use super::operator_console::session::{sign, SessionError};
pub const COOKIE_NAME: &str = "rustango_tenant_session";
pub const SESSION_TTL_SECS: i64 = 7 * 24 * 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 exp: i64,
}
impl TenantSessionPayload {
#[must_use]
pub fn new(user_id: i64, slug: impl Into<String>, ttl_secs: i64) -> Self {
let exp = chrono::Utc::now().timestamp() + ttl_secs;
Self {
uid: user_id,
slug: slug.into(),
exp,
}
}
fn is_expired(&self) -> bool {
chrono::Utc::now().timestamp() >= self.exp
}
}
#[must_use]
pub fn encode(secret: &SessionSecret, payload: &TenantSessionPayload) -> 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}")
}
pub fn decode(
secret: &SessionSecret,
expected_slug: &str,
value: &str,
) -> Result<TenantSessionPayload, 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: TenantSessionPayload =
serde_json::from_slice(&payload_bytes).map_err(|_| SessionError::Malformed)?;
if payload.is_expired() {
return Err(SessionError::Expired);
}
if payload.slug != expected_slug {
return Err(SessionError::WrongTenant);
}
Ok(payload)
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> SessionSecret {
SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec())
}
#[test]
fn round_trip_valid_payload() {
let secret = key();
let payload = TenantSessionPayload::new(7, "acme", 3600);
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);
let cookie = encode(&secret, &payload);
let err = decode(&secret, "globex", &cookie).unwrap_err();
assert!(matches!(err, SessionError::WrongTenant));
}
#[test]
fn rejects_tampered_payload() {
let secret = key();
let payload = TenantSessionPayload::new(7, "acme", 3600);
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));
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));
let err = decode(&secret, "acme", &cookie).unwrap_err();
assert!(matches!(err, SessionError::Expired));
}
}