use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use base64::Engine as _;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use tokio::sync::Mutex;
type HmacSha256 = Hmac<Sha256>;
const TICKET_TTL: Duration = Duration::from_secs(300);
const BEAM_COOKIE_TTL: Duration = Duration::from_secs(86_400);
const USED_TICKET_TTL: Duration = Duration::from_secs(600);
pub const BEAM_COOKIE_NAME: &str = "beam_terminal_session";
pub const TICKET_QUERY_PARAM: &str = "beam_terminal_ticket";
fn ticket_secret_path() -> std::path::PathBuf {
let root = std::env::var("BEAM_HOME").unwrap_or_else(|_| {
std::env::var("HOME")
.map(|h| format!("{}/.beam", h))
.unwrap_or_default()
});
std::path::PathBuf::from(root).join("state/ticket-secret")
}
fn ticket_secret() -> &'static [u8] {
static SECRET: OnceLock<Vec<u8>> = OnceLock::new();
SECRET.get_or_init(|| {
let path = ticket_secret_path();
if let Ok(bytes) = std::fs::read(&path) {
if bytes.len() >= 16 {
return bytes;
}
}
let secret = uuid::Uuid::new_v4().as_bytes().to_vec();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, &secret);
secret
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminalPermission {
ReadOnly,
Write,
}
impl TerminalPermission {
pub fn as_str(&self) -> &'static str {
match self {
TerminalPermission::ReadOnly => "read_only",
TerminalPermission::Write => "write",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"read_only" => Some(TerminalPermission::ReadOnly),
"write" => Some(TerminalPermission::Write),
_ => None,
}
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct TerminalTicketPayload {
pub session_id: String,
pub permission: TerminalPermission,
pub created_at: u64,
pub nonce: String,
}
pub fn generate_terminal_ticket(session_id: &str, permission: TerminalPermission) -> String {
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let nonce = uuid::Uuid::new_v4().simple().to_string();
let payload = format!(
"{}:{}:{}:{}",
session_id,
permission.as_str(),
created_at,
nonce
);
let secret = ticket_secret();
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size");
mac.update(payload.as_bytes());
let signature = mac.finalize().into_bytes();
let sig_hex = hex_encode(&signature);
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload.as_bytes());
format!("{}.{}", payload_b64, sig_hex)
}
pub fn verify_terminal_ticket(
ticket: &str,
expected_session_id: &str,
used_tickets: &mut UsedTickets,
) -> Option<TerminalTicketPayload> {
let (payload_b64, sig_hex) = ticket.split_once('.')?;
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64.as_bytes())
.ok()?;
let payload = String::from_utf8(payload_bytes).ok()?;
let secret = ticket_secret();
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size");
mac.update(payload.as_bytes());
let expected_sig = hex_decode(sig_hex)?;
mac.verify_slice(&expected_sig).ok()?;
let parts: Vec<&str> = payload.splitn(4, ':').collect();
if parts.len() != 4 {
return None;
}
let session_id = parts[0].to_string();
let permission = TerminalPermission::from_str(parts[1])?;
let created_at: u64 = parts[2].parse().ok()?;
let nonce = parts[3].to_string();
if session_id != expected_session_id {
return None;
}
if permission == TerminalPermission::Write {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now.saturating_sub(created_at) > TICKET_TTL.as_secs() {
return None;
}
}
if !used_tickets.insert_and_check(&nonce) {
return None;
}
Some(TerminalTicketPayload {
session_id,
permission,
created_at,
nonce,
})
}
#[derive(Default)]
pub struct UsedTickets {
entries: Vec<(String, Instant)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UsedTicketsPersisted {
entries: Vec<(String, u64)>,
}
impl UsedTickets {
pub fn insert_and_check(&mut self, nonce: &str) -> bool {
let now = Instant::now();
self.entries
.retain(|(_, t)| now.saturating_duration_since(*t) < USED_TICKET_TTL);
if self.entries.iter().any(|(n, _)| n == nonce) {
return false; }
self.entries.push((nonce.to_string(), now));
true
}
pub fn load_from(&mut self, path: &std::path::Path) {
if let Ok(Some(persisted)) = beam_core::persist::read_json::<UsedTicketsPersisted>(path) {
let now = Instant::now();
let now_epoch = Self::epoch_now();
for (nonce, seen_at_epoch) in persisted.entries {
let elapsed_secs = now_epoch.saturating_sub(seen_at_epoch);
if elapsed_secs > USED_TICKET_TTL.as_secs() {
continue; }
let seen = now - Duration::from_secs(elapsed_secs);
self.entries.push((nonce, seen));
}
}
}
pub fn save_to(&self, path: &std::path::Path) {
let now_epoch = Self::epoch_now();
let entries: Vec<(String, u64)> = self
.entries
.iter()
.filter_map(|(nonce, instant)| {
let elapsed = instant.elapsed();
if elapsed < USED_TICKET_TTL {
let seen_at_epoch = now_epoch.saturating_sub(elapsed.as_secs());
Some((nonce.clone(), seen_at_epoch))
} else {
None
}
})
.collect();
if entries.is_empty() {
let _ = std::fs::remove_file(path);
return;
}
let persisted = UsedTicketsPersisted { entries };
let _ = beam_core::persist::atomic_write_json(path, &persisted);
}
fn epoch_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
}
#[derive(Debug, Clone)]
struct BeamCookieEntry {
zellij_cookie: String,
pub session_id: String,
pub permission: TerminalPermission,
pub created_at: Instant,
}
#[derive(Clone, Default)]
pub struct TerminalAuthState {
inner: Arc<Mutex<TerminalAuthInner>>,
}
#[derive(Default)]
struct TerminalAuthInner {
entries: HashMap<String, BeamCookieEntry>,
used_tickets: UsedTickets,
}
impl TerminalAuthState {
pub fn new() -> Self {
Self::default()
}
pub async fn load_used_tickets(&self, path: &std::path::Path) {
let path = path.to_path_buf();
let inner = self.inner.clone();
let _ = tokio::task::spawn_blocking(move || {
let mut guard = inner.blocking_lock();
guard.used_tickets.load_from(&path);
})
.await;
}
pub async fn save_used_tickets(&self, path: &std::path::Path) {
let path = path.to_path_buf();
let inner = self.inner.clone();
let _ = tokio::task::spawn_blocking(move || {
let guard = inner.blocking_lock();
guard.used_tickets.save_to(&path);
})
.await;
}
pub async fn insert(
&self,
zellij_cookie: String,
session_id: String,
permission: TerminalPermission,
) -> String {
let mut inner = self.inner.lock().await;
inner.prune();
let beam_cookie = uuid::Uuid::new_v4().simple().to_string();
inner.entries.insert(
beam_cookie.clone(),
BeamCookieEntry {
zellij_cookie,
session_id,
permission,
created_at: Instant::now(),
},
);
beam_cookie
}
pub async fn lookup(&self, beam_cookie: &str) -> Option<(String, String, TerminalPermission)> {
let mut inner = self.inner.lock().await;
inner.prune();
inner
.entries
.get(beam_cookie)
.map(|e| (e.zellij_cookie.clone(), e.session_id.clone(), e.permission))
}
pub async fn verify_and_consume_ticket(
&self,
ticket: &str,
expected_session_id: &str,
) -> Option<TerminalTicketPayload> {
let mut inner = self.inner.lock().await;
verify_terminal_ticket(ticket, expected_session_id, &mut inner.used_tickets)
}
}
impl TerminalAuthInner {
fn prune(&mut self) {
let now = Instant::now();
self.entries
.retain(|_, e| now.duration_since(e.created_at) < BEAM_COOKIE_TTL);
}
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
fn hex_decode(hex: &str) -> Option<Vec<u8>> {
if hex.len() % 2 != 0 {
return None;
}
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
.collect()
}
pub fn extract_beam_cookie(cookie_header: &str) -> Option<String> {
for part in cookie_header.split(';') {
let mut kv = part.trim().splitn(2, '=');
let key = kv.next().unwrap_or("").trim();
let value = kv.next().unwrap_or("").trim();
if key == BEAM_COOKIE_NAME && !value.is_empty() {
return Some(value.to_string());
}
}
None
}
pub fn extract_zellij_set_cookie(set_cookie_value: &str) -> Option<String> {
let cookie_part = set_cookie_value.split(';').next()?;
let (name, value) = cookie_part.split_once('=')?;
if name.trim().is_empty() || value.trim().is_empty() {
return None;
}
Some(cookie_part.trim().to_string())
}
pub fn is_zellij_root_path(path: &str) -> bool {
path == "session"
|| path == "info"
|| path.starts_with("info/")
|| path == "favicon.ico"
|| path.starts_with("command")
|| path.starts_with("api/")
|| path.starts_with("assets/")
|| path.starts_with("ws/terminal")
|| path == "ws/control"
|| path.starts_with("ws/control")
}
pub fn translate_root_ws_path(rest: &str, zellij_session: &str) -> String {
if rest == "terminal" || rest == "control" {
format!("ws/{rest}")
} else if rest.starts_with("terminal/") {
format!("ws/terminal/{zellij_session}")
} else {
rest.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ticket_generate_and_verify() {
let ticket = generate_terminal_ticket("session-abc", TerminalPermission::Write);
let mut used = UsedTickets::default();
let payload = verify_terminal_ticket(&ticket, "session-abc", &mut used);
assert!(payload.is_some());
let p = payload.unwrap();
assert_eq!(p.session_id, "session-abc");
assert_eq!(p.permission, TerminalPermission::Write);
}
#[test]
fn ticket_wrong_session_rejected() {
let ticket = generate_terminal_ticket("session-abc", TerminalPermission::ReadOnly);
let mut used = UsedTickets::default();
assert!(verify_terminal_ticket(&ticket, "session-xyz", &mut used).is_none());
}
#[test]
fn ticket_one_time_use() {
let ticket = generate_terminal_ticket("session-1", TerminalPermission::Write);
let mut used = UsedTickets::default();
assert!(verify_terminal_ticket(&ticket, "session-1", &mut used).is_some());
assert!(verify_terminal_ticket(&ticket, "session-1", &mut used).is_none());
}
#[test]
fn ticket_tampered_signature_rejected() {
let ticket = generate_terminal_ticket("session-abc", TerminalPermission::ReadOnly);
let parts: Vec<&str> = ticket.split('.').collect();
let sig_hex = parts[1];
let mut tampered_sig = String::new();
for (i, ch) in sig_hex.chars().enumerate() {
if i == 0 {
tampered_sig.push(if ch == 'a' { 'b' } else { 'a' });
} else {
tampered_sig.push(ch);
}
}
let tampered_ticket = format!("{}.{}", parts[0], tampered_sig);
let mut used = UsedTickets::default();
assert!(verify_terminal_ticket(&tampered_ticket, "session-abc", &mut used).is_none());
}
#[test]
fn ticket_expired_write_rejected() {
let old_payload = format!("session-x:write:0:{}", uuid::Uuid::new_v4().simple());
let secret = ticket_secret();
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
mac.update(old_payload.as_bytes());
let sig = hex_encode(&mac.finalize().into_bytes());
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(old_payload.as_bytes());
let ticket = format!("{}.{}", b64, sig);
let mut used = UsedTickets::default();
assert!(verify_terminal_ticket(&ticket, "session-x", &mut used).is_none());
}
#[test]
fn ticket_expired_read_only_accepted() {
let old_payload = format!("session-y:read_only:0:{}", uuid::Uuid::new_v4().simple());
let secret = ticket_secret();
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
mac.update(old_payload.as_bytes());
let sig = hex_encode(&mac.finalize().into_bytes());
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(old_payload.as_bytes());
let ticket = format!("{}.{}", b64, sig);
let mut used = UsedTickets::default();
let payload = verify_terminal_ticket(&ticket, "session-y", &mut used).unwrap();
assert_eq!(payload.permission, TerminalPermission::ReadOnly);
}
#[test]
fn ticket_session_id_must_match() {
let ticket = generate_terminal_ticket("correct-session", TerminalPermission::Write);
let mut used = UsedTickets::default();
assert!(verify_terminal_ticket(&ticket, "wrong-session", &mut used).is_none());
}
#[test]
fn ticket_read_only_permission_roundtrip() {
let ticket = generate_terminal_ticket("ro-session", TerminalPermission::ReadOnly);
let mut used = UsedTickets::default();
let payload = verify_terminal_ticket(&ticket, "ro-session", &mut used).unwrap();
assert_eq!(payload.permission, TerminalPermission::ReadOnly);
}
#[test]
fn ticket_write_permission_roundtrip() {
let ticket = generate_terminal_ticket("rw-session", TerminalPermission::Write);
let mut used = UsedTickets::default();
let payload = verify_terminal_ticket(&ticket, "rw-session", &mut used).unwrap();
assert_eq!(payload.permission, TerminalPermission::Write);
}
#[test]
fn ticket_format_has_dot_separator() {
let ticket = generate_terminal_ticket("s", TerminalPermission::Write);
assert!(ticket.contains('.'));
let parts: Vec<&str> = ticket.split('.').collect();
assert_eq!(parts.len(), 2);
assert!(!parts[0].is_empty());
assert!(!parts[1].is_empty());
assert!(parts[1].chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn ticket_with_wrong_secret_rejected() {
let ticket = generate_terminal_ticket("test-session", TerminalPermission::Write);
let mut used = UsedTickets::default();
assert!(verify_terminal_ticket(&ticket, "test-session", &mut used).is_some());
let ticket2 = generate_terminal_ticket("test-session", TerminalPermission::Write);
let mut used2 = UsedTickets::default();
assert!(verify_terminal_ticket(&ticket2, "test-session", &mut used2).is_some());
}
#[test]
fn hex_encode_decode_roundtrip() {
let original = b"hello world 12345";
let encoded = hex_encode(original);
let decoded = hex_decode(&encoded).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn hex_decode_invalid_length() {
assert!(hex_decode("abc").is_none());
}
#[test]
fn hex_decode_invalid_chars() {
assert!(hex_decode("zz").is_none());
}
#[test]
fn hex_decode_empty() {
assert_eq!(hex_decode("").unwrap(), Vec::<u8>::new());
}
#[test]
fn extract_beam_cookie_found() {
let result = extract_beam_cookie("beam_terminal_session=abc123def456; other=value");
assert_eq!(result, Some("abc123def456".to_string()));
}
#[test]
fn extract_beam_cookie_not_found() {
assert_eq!(extract_beam_cookie("other=value"), None);
}
#[test]
fn extract_beam_cookie_empty() {
assert_eq!(extract_beam_cookie(""), None);
}
#[test]
fn extract_zellij_set_cookie_standard() {
let result = extract_zellij_set_cookie("session=abc123; HttpOnly; SameSite=Strict; Path=/");
assert_eq!(result, Some("session=abc123".to_string()));
}
#[test]
fn extract_zellij_set_cookie_simple() {
let result = extract_zellij_set_cookie("session=abc123");
assert_eq!(result, Some("session=abc123".to_string()));
}
#[test]
fn extract_zellij_set_cookie_empty_value() {
assert_eq!(extract_zellij_set_cookie("session=; HttpOnly"), None);
}
#[test]
fn extract_zellij_set_cookie_malformed() {
assert_eq!(extract_zellij_set_cookie("; HttpOnly"), None);
}
#[tokio::test]
async fn auth_state_maps_external_beam_cookie_to_internal_zellij_cookie() {
let auth = TerminalAuthState::new();
let zellij_cookie = "zellij-session=secret-upstream-cookie".to_string();
let beam_cookie = auth
.insert(
zellij_cookie.clone(),
"beam-session-1".to_string(),
TerminalPermission::Write,
)
.await;
assert_ne!(beam_cookie, zellij_cookie);
let (stored_zellij_cookie, stored_session_id, stored_permission) = auth
.lookup(&beam_cookie)
.await
.expect("stored cookie entry");
assert_eq!(stored_zellij_cookie, zellij_cookie);
assert_eq!(stored_session_id, "beam-session-1");
assert_eq!(stored_permission, TerminalPermission::Write);
}
#[test]
fn used_tickets_dedupe() {
let mut used = UsedTickets::default();
assert!(used.insert_and_check("abc"));
assert!(!used.insert_and_check("abc"));
assert!(used.insert_and_check("def"));
}
#[test]
fn used_tickets_accepts_after_ttl() {
}
#[test]
fn used_tickets_save_and_load_preserves_dedupe() {
let tmp = std::env::temp_dir().join(format!(
"beam-used-tickets-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let _ = std::fs::remove_file(&tmp);
let mut used = UsedTickets::default();
assert!(used.insert_and_check("nonce-a"));
assert!(used.insert_and_check("nonce-b"));
used.save_to(&tmp);
let mut loaded = UsedTickets::default();
loaded.load_from(&tmp);
assert!(!loaded.insert_and_check("nonce-a"));
assert!(!loaded.insert_and_check("nonce-b"));
assert!(loaded.insert_and_check("nonce-c"));
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn used_tickets_load_preserves_ttl_approx() {
let tmp = std::env::temp_dir().join(format!(
"beam-used-tickets-ttl-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
let _ = std::fs::remove_file(&tmp);
let mut used = UsedTickets::default();
assert!(used.insert_and_check("nonce-x"));
used.save_to(&tmp);
let mut loaded = UsedTickets::default();
loaded.load_from(&tmp);
assert!(
!loaded.insert_and_check("nonce-x"),
"loaded entry should still block replay"
);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn root_paths_identified() {
assert!(is_zellij_root_path("session"));
assert!(is_zellij_root_path("info"));
assert!(is_zellij_root_path("info/version"));
assert!(is_zellij_root_path("command/login"));
assert!(is_zellij_root_path("command"));
assert!(is_zellij_root_path("api/stats"));
assert!(is_zellij_root_path("ws/terminal/mysession"));
assert!(is_zellij_root_path("ws/control"));
assert!(is_zellij_root_path("assets/style.css"));
assert!(is_zellij_root_path("assets/auth.js"));
assert!(is_zellij_root_path("favicon.ico"));
}
#[test]
fn session_paths_not_root() {
assert!(!is_zellij_root_path(""));
assert!(!is_zellij_root_path("ws")); assert!(!is_zellij_root_path("somefile.js"));
}
#[test]
fn translate_terminal_ws_replaces_session_name() {
let result = translate_root_ws_path("terminal/beam-session-id-123", "beam-beam-se");
assert_eq!(result, "ws/terminal/beam-beam-se");
}
#[test]
fn translate_terminal_without_ws_prefix() {
let result = translate_root_ws_path("terminal/beam-session-id-123", "beam-beam-se");
assert_eq!(result, "ws/terminal/beam-beam-se");
}
#[test]
fn translate_terminal_no_session() {
assert_eq!(
translate_root_ws_path("terminal", "beam-any"),
"ws/terminal"
);
assert_eq!(translate_root_ws_path("control", "beam-any"), "ws/control");
}
#[test]
fn translate_other_ws_passthrough() {
let result = translate_root_ws_path("ws/something-else", "any-session");
assert_eq!(result, "ws/something-else");
}
#[test]
fn ticket_secret_is_not_hardcoded() {
let secret = ticket_secret();
assert!(secret.len() >= 16);
assert_ne!(secret, b"beam-terminal-proxy-ticket-secret-v1");
}
}