use base64::Engine;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
pub use crate::session::{PasswordFingerprint, SessionSecret, SessionSecretError};
pub(crate) use crate::session::sign;
pub const COOKIE_NAME: &str = "rustango_op_session";
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,
#[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,
}
}
fn is_expired(&self) -> bool {
chrono::Utc::now().timestamp() >= self.exp
}
}
#[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}")
}
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)
}
#[must_use]
pub(crate) fn survives_password_change(
secret: &SessionSecret,
pwf: &PasswordFingerprint,
iat: i64,
password_hash: &str,
password_changed_at: Option<chrono::DateTime<chrono::Utc>>,
) -> bool {
pwf.matches(secret, password_hash) && password_changed_at.is_none_or(|ts| iat >= ts.timestamp())
}
#[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);
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));
}
#[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!(survives_password_change(
&secret,
&pwf,
now.timestamp(),
"$old",
Some(now)
));
assert!(!survives_password_change(
&secret,
&pwf,
now.timestamp(),
"$new",
Some(now)
));
assert!(!survives_password_change(
&secret,
&fp(),
now.timestamp(),
"$old",
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!(survives_password_change(&secret, &pwf, iat, "$h", None));
assert!(survives_password_change(&secret, &pwf, iat, "$h", at(iat)));
assert!(!survives_password_change(
&secret,
&pwf,
iat,
"$h",
at(iat + 1)
));
}
}