use std::sync::{Arc, OnceLock};
use base64::Engine;
use serde::{Deserialize, Serialize};
use super::session::SessionError;
pub use super::session::{PasswordFingerprint, SessionSecret};
const HANDOFF_DOMAIN_TAG: &[u8] = b"rustango-impersonation-handoff-v1";
pub const HANDOFF_TTL_SECS: i64 = 60;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum HandoffError {
#[error("handoff token malformed")]
Malformed,
#[error("handoff token signature invalid")]
BadSignature,
#[error("handoff token expired")]
Expired,
#[error("handoff token bound to a different tenant")]
WrongTenant,
#[error("handoff token already used")]
AlreadyUsed,
}
impl From<SessionError> for HandoffError {
fn from(e: SessionError) -> Self {
match e {
SessionError::Malformed => Self::Malformed,
SessionError::BadSignature => Self::BadSignature,
SessionError::Expired => Self::Expired,
SessionError::WrongTenant => Self::WrongTenant,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HandoffPayload {
pub op: i64,
pub slug: String,
pub org: i64,
pub exp: i64,
pub jti: String,
pub pwf: PasswordFingerprint,
#[serde(default)]
pub iat: i64,
}
impl HandoffPayload {
#[must_use]
pub fn new(
op_id: i64,
tenant: &crate::tenancy::TenantSlug,
ttl_secs: i64,
pwf: PasswordFingerprint,
) -> Self {
let now = chrono::Utc::now().timestamp();
use rand::{rngs::OsRng, RngCore};
let mut bytes = [0u8; 16];
OsRng.fill_bytes(&mut bytes[..]);
let jti = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
Self {
op: op_id,
slug: tenant.slug.clone(),
org: tenant.org_id,
exp: now + ttl_secs,
jti,
pwf,
iat: now,
}
}
}
impl crate::session::Expiring for HandoffPayload {
fn is_expired_at(&self, now: i64) -> bool {
now >= self.exp
}
}
#[must_use]
pub fn mint(secret: &SessionSecret, payload: &HandoffPayload) -> String {
crate::session::SealedToken::seal(secret.key(), HANDOFF_DOMAIN_TAG, payload)
}
pub fn decode(
secret: &SessionSecret,
tenant: &crate::tenancy::TenantSlug,
value: &str,
) -> Result<HandoffPayload, HandoffError> {
let payload: HandoffPayload =
crate::session::SealedToken::open(secret.key(), HANDOFF_DOMAIN_TAG, value)
.map_err(SessionError::from)?;
if !tenant.binds(&payload.slug, payload.org) {
return Err(HandoffError::WrongTenant);
}
Ok(payload)
}
pub struct JtiBlacklist {
store: Arc<dyn crate::jti_store::JtiStore>,
}
impl JtiBlacklist {
fn new() -> Self {
Self {
store: Arc::new(crate::jti_store::InMemoryJtiStore::new()),
}
}
#[must_use]
pub fn with_store(store: Arc<dyn crate::jti_store::JtiStore>) -> Self {
Self { store }
}
pub fn shared() -> &'static Self {
static INSTANCE: OnceLock<JtiBlacklist> = OnceLock::new();
INSTANCE.get_or_init(Self::new)
}
pub async fn is_used(&self, jti: &str) -> bool {
self.store.is_used(jti).await
}
pub async fn mark_used(&self, jti: &str, exp: i64) -> Result<(), HandoffError> {
if self.store.mark_used(jti, exp).await {
Ok(())
} else {
Err(HandoffError::AlreadyUsed)
}
}
pub async fn end_session(&self, sid: &str, exp: i64) {
self.store.mark_used(&Self::ended_key(sid), exp).await;
}
pub async fn session_ended(&self, sid: &str) -> bool {
self.store.is_used(&Self::ended_key(sid)).await
}
fn ended_key(sid: &str) -> String {
format!("ended:{sid}")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> SessionSecret {
SessionSecret::from_bytes(b"a-test-secret-thirty-two-bytes-x".to_vec())
}
fn acme() -> crate::tenancy::TenantSlug {
crate::tenancy::TenantSlug::new(1, "acme")
}
#[test]
fn rejects_token_minted_for_a_purged_tenant_with_the_slug() {
let secret = key();
let token = mint(&secret, &payload(60));
let heir = crate::tenancy::TenantSlug::new(2, "acme");
assert_eq!(
decode(&secret, &heir, &token).unwrap_err(),
HandoffError::WrongTenant,
);
}
#[test]
fn rejects_token_without_an_org() {
let secret = key();
let mut json = serde_json::to_value(payload(60)).unwrap();
json.as_object_mut().unwrap().remove("org");
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&json).unwrap());
let sig = crate::session::sign_tagged(&secret, HANDOFF_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");
assert_eq!(
decode(&secret, &zero, &format!("{b64}.{sig_b64}")).unwrap_err(),
HandoffError::Malformed,
);
}
fn payload(ttl: i64) -> HandoffPayload {
HandoffPayload::new(7, &acme(), ttl, PasswordFingerprint::of(&key(), "$h"))
}
#[test]
fn round_trip_valid_payload() {
let secret = key();
let payload = payload(60);
let token = mint(&secret, &payload);
let back = decode(&secret, &acme(), &token).unwrap();
assert_eq!(back, payload);
}
#[test]
fn rejects_token_minted_for_a_different_tenant() {
let secret = key();
let payload = payload(60);
let token = mint(&secret, &payload);
assert_eq!(
decode(
&secret,
&crate::tenancy::TenantSlug::new(2, "globex"),
&token
)
.unwrap_err(),
HandoffError::WrongTenant,
);
}
#[test]
fn rejects_tampered_signature() {
let secret = key();
let payload = payload(60);
let token = mint(&secret, &payload);
let mut bytes = token.into_bytes();
let dot = bytes
.iter()
.rposition(|&b| b == b'.')
.expect("token has `payload.sig` shape");
let mid_sig = dot + 1 + (bytes.len() - dot - 1) / 2;
bytes[mid_sig] = if bytes[mid_sig] == b'A' { b'B' } else { b'A' };
let tampered = String::from_utf8(bytes).unwrap();
assert_eq!(
decode(&secret, &acme(), &tampered).unwrap_err(),
HandoffError::BadSignature,
);
}
#[test]
fn rejects_token_signed_with_a_different_secret() {
let s1 = key();
let s2 = SessionSecret::from_bytes(b"b-other-secret-thirty-two-bytes-x".to_vec());
let token = mint(&s1, &payload(60));
assert_eq!(
decode(&s2, &acme(), &token).unwrap_err(),
HandoffError::BadSignature,
);
}
#[test]
fn rejects_expired_token() {
let secret = key();
let token = mint(&secret, &payload(-10));
assert_eq!(
decode(&secret, &acme(), &token).unwrap_err(),
HandoffError::Expired,
);
}
#[test]
fn rejects_malformed_token() {
let secret = key();
assert_eq!(
decode(&secret, &acme(), "not-a-token").unwrap_err(),
HandoffError::Malformed,
);
assert_eq!(
decode(&secret, &acme(), "abc.!!!").unwrap_err(),
HandoffError::Malformed,
);
}
#[test]
fn jtis_are_unique_across_mints() {
let p1 = payload(60);
let p2 = payload(60);
assert_ne!(p1.jti, p2.jti, "random jti collision is unacceptable");
}
#[tokio::test]
async fn jti_blacklist_first_use_succeeds_second_fails() {
let bl = JtiBlacklist::new();
let jti = "abc123";
let exp = chrono::Utc::now().timestamp() + 60;
bl.mark_used(jti, exp).await.unwrap();
assert!(bl.is_used(jti).await);
assert_eq!(
bl.mark_used(jti, exp).await.unwrap_err(),
HandoffError::AlreadyUsed
);
}
#[tokio::test]
async fn jti_blacklist_with_store_delegates_to_swapped_backend() {
use crate::jti_store::{InMemoryJtiStore, JtiStore};
use std::sync::Arc;
let shared: Arc<dyn JtiStore> = Arc::new(InMemoryJtiStore::new());
let bl_a = JtiBlacklist::with_store(Arc::clone(&shared));
let bl_b = JtiBlacklist::with_store(Arc::clone(&shared));
let jti = "shared-token";
let exp = chrono::Utc::now().timestamp() + 60;
bl_a.mark_used(jti, exp).await.unwrap();
assert!(
bl_b.is_used(jti).await,
"second handle on the shared store must see the mark"
);
assert_eq!(
bl_b.mark_used(jti, exp).await.unwrap_err(),
HandoffError::AlreadyUsed,
"single-use guard must hold across handles on the shared store"
);
}
#[test]
fn handoff_ttl_default_is_one_minute() {
assert_eq!(HANDOFF_TTL_SECS, 60);
}
}