use base64::Engine;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use crate::session::sign;
pub use crate::session::SessionSecret as AdminSessionSecret;
tokio::task_local! {
pub(crate) static CURRENT_SESSION: AdminSession;
}
#[must_use]
pub fn current() -> Option<AdminSession> {
CURRENT_SESSION.try_with(|s| s.clone()).ok()
}
const DEFAULT_TTL_SECS: i64 = 8 * 60 * 60;
pub(crate) const SESSION_COOKIE: &str = "rustango_admin_session";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminSession {
pub user_id: i64,
pub username: String,
pub is_superuser: bool,
}
#[derive(Serialize, Deserialize)]
struct CookiePayload {
user_id: i64,
username: String,
is_superuser: bool,
exp: i64,
}
impl CookiePayload {
fn is_expired(&self) -> bool {
chrono::Utc::now().timestamp() >= self.exp
}
}
#[must_use]
pub(crate) fn encode(secret: &AdminSessionSecret, session: AdminSession) -> String {
let payload = CookiePayload {
user_id: session.user_id,
username: session.username,
is_superuser: session.is_superuser,
exp: chrono::Utc::now().timestamp() + DEFAULT_TTL_SECS,
};
let json = serde_json::to_vec(&payload).expect("payload serializes");
let body = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&json);
let sig = sign(secret, body.as_bytes());
let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig);
format!("{body}.{sig_b64}")
}
#[must_use]
pub(crate) fn decode(secret: &AdminSessionSecret, value: &str) -> Option<AdminSession> {
let (body, sig_b64) = value.split_once('.')?;
let expected = sign(secret, body.as_bytes());
let provided = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(sig_b64)
.ok()?;
if expected.ct_eq(&provided[..]).unwrap_u8() == 0 {
return None;
}
let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(body)
.ok()?;
let payload: CookiePayload = serde_json::from_slice(&json).ok()?;
if payload.is_expired() {
return None;
}
Some(AdminSession {
user_id: payload.user_id,
username: payload.username,
is_superuser: payload.is_superuser,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_recovers_session_fields() {
let secret = AdminSessionSecret::from_bytes(vec![42u8; 32]);
let cookie = encode(
&secret,
AdminSession {
user_id: 7,
username: "alice".into(),
is_superuser: true,
},
);
let session = decode(&secret, &cookie).expect("valid cookie verifies");
assert_eq!(session.user_id, 7);
assert!(session.is_superuser);
}
#[test]
fn tampered_signature_rejected() {
let secret = AdminSessionSecret::from_bytes(vec![1u8; 32]);
let cookie = encode(
&secret,
AdminSession {
user_id: 1,
username: "bob".into(),
is_superuser: false,
},
);
let (body, _sig) = cookie.split_once('.').unwrap();
let bad = format!("{body}.AAAA");
assert!(decode(&secret, &bad).is_none());
}
#[test]
fn wrong_secret_rejected() {
let secret_a = AdminSessionSecret::from_bytes(vec![1u8; 32]);
let secret_b = AdminSessionSecret::from_bytes(vec![2u8; 32]);
let cookie = encode(
&secret_a,
AdminSession {
user_id: 1,
username: "bob".into(),
is_superuser: false,
},
);
assert!(decode(&secret_b, &cookie).is_none());
}
}