use std::time::{SystemTime, UNIX_EPOCH};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use thiserror::Error;
type HmacSha256 = Hmac<Sha256>;
pub const SESSION_KEY_LABEL: &[u8] = b"ados-dashboard-session-v1";
pub const DEFAULT_TTL_SECONDS: i64 = 86_400;
pub const MAX_TTL_SECONDS: i64 = 604_800;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum SessionError {
#[error("malformed dashboard session token")]
Malformed,
#[error("dashboard session timestamp is not an integer")]
BadTimestamp,
#[error("dashboard session signature is not valid hex")]
BadSignature,
#[error("dashboard session HMAC mismatch")]
HmacMismatch,
#[error("dashboard session expired")]
Expired,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DashboardSession {
pub token: String,
pub expires_at: i64,
}
#[derive(Clone)]
pub struct DashboardSessionIssuer {
key: Vec<u8>,
}
impl DashboardSessionIssuer {
pub fn from_api_key_and_salt(api_key: &str, salt: &[u8]) -> Self {
let mut mac =
HmacSha256::new_from_slice(api_key.as_bytes()).expect("HMAC accepts any key length");
mac.update(SESSION_KEY_LABEL);
mac.update(salt);
let key = mac.finalize().into_bytes().to_vec();
Self { key }
}
pub fn mint(&self, ttl_seconds: i64) -> DashboardSession {
self.mint_at(ttl_seconds, now_unix())
}
pub fn mint_at(&self, ttl_seconds: i64, now: i64) -> DashboardSession {
let expires_at = now.saturating_add(ttl_seconds);
let payload = sign_payload(now, expires_at);
let signature = self.sign(&payload);
DashboardSession {
token: format!("{payload}|{signature}"),
expires_at,
}
}
pub fn verify(&self, token: &str, now: i64) -> Result<(), SessionError> {
let parts: Vec<&str> = token.split('|').collect();
if parts.len() != 4 || parts[0] != "v1" {
return Err(SessionError::Malformed);
}
let _issued: i64 = parts[1].parse().map_err(|_| SessionError::BadTimestamp)?;
let expires_at: i64 = parts[2].parse().map_err(|_| SessionError::BadTimestamp)?;
let sig = hex::decode(parts[3]).map_err(|_| SessionError::BadSignature)?;
let payload = parts[..3].join("|");
let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
mac.update(payload.as_bytes());
mac.verify_slice(&sig)
.map_err(|_| SessionError::HmacMismatch)?;
if now >= expires_at {
return Err(SessionError::Expired);
}
Ok(())
}
fn sign(&self, payload: &str) -> String {
let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
mac.update(payload.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
}
fn sign_payload(issued_at: i64, expires_at: i64) -> String {
format!("v1|{issued_at}|{expires_at}")
}
pub fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
const SALT: &[u8] = b"a-random-salt-16";
#[test]
fn mint_round_trips_and_verifies() {
let issuer = DashboardSessionIssuer::from_api_key_and_salt("ados_secret", SALT);
let s = issuer.mint_at(86_400, 1000);
assert_eq!(s.expires_at, 87_400);
assert!(s.token.starts_with("v1|1000|87400|"));
assert!(issuer.verify(&s.token, 1000).is_ok());
assert!(issuer.verify(&s.token, 87_399).is_ok());
}
#[test]
fn rejects_expired_at_the_boundary() {
let issuer = DashboardSessionIssuer::from_api_key_and_salt("k", SALT);
let s = issuer.mint_at(30, 1000);
assert_eq!(issuer.verify(&s.token, 1030), Err(SessionError::Expired));
}
#[test]
fn rejects_wrong_key() {
let a = DashboardSessionIssuer::from_api_key_and_salt("key-a", SALT);
let b = DashboardSessionIssuer::from_api_key_and_salt("key-b", SALT);
let s = a.mint_at(30, 1000);
assert_eq!(b.verify(&s.token, 1000), Err(SessionError::HmacMismatch));
}
#[test]
fn a_fresh_salt_revokes_prior_sessions() {
let old = DashboardSessionIssuer::from_api_key_and_salt("ados_secret", b"old-salt");
let new = DashboardSessionIssuer::from_api_key_and_salt("ados_secret", b"new-salt");
let s = old.mint_at(86_400, 1000);
assert!(
old.verify(&s.token, 1000).is_ok(),
"the old salt still verifies its own token"
);
assert_eq!(
new.verify(&s.token, 1000),
Err(SessionError::HmacMismatch),
"a reset (new salt) must revoke the old session"
);
}
#[test]
fn rejects_tampered_expiry() {
let issuer = DashboardSessionIssuer::from_api_key_and_salt("k", SALT);
let s = issuer.mint_at(30, 1000);
let parts: Vec<&str> = s.token.split('|').collect();
let forged = format!("v1|{}|99999|{}", parts[1], parts[3]);
assert_eq!(
issuer.verify(&forged, 1000),
Err(SessionError::HmacMismatch)
);
}
#[test]
fn rejects_malformed() {
let issuer = DashboardSessionIssuer::from_api_key_and_salt("k", SALT);
assert_eq!(issuer.verify("", 0), Err(SessionError::Malformed));
assert_eq!(
issuer.verify("v1|s|1|2|ff", 0),
Err(SessionError::Malformed)
);
assert_eq!(issuer.verify("v2|1|2|ff", 0), Err(SessionError::Malformed));
assert_eq!(
issuer.verify("v1|notanint|2|ff", 0),
Err(SessionError::BadTimestamp)
);
assert_eq!(
issuer.verify("v1|1|2|nothex", 0),
Err(SessionError::BadSignature)
);
}
#[test]
fn ttl_saturates_rather_than_overflows() {
let issuer = DashboardSessionIssuer::from_api_key_and_salt("k", SALT);
let s = issuer.mint_at(i64::MAX, i64::MAX - 1);
assert_eq!(s.expires_at, i64::MAX);
}
}