use std::path::{Path, PathBuf};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use super::{IdentityKeypair, PublicKey};
use crate::error::JoyError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionClaims {
pub member: String,
pub project_id: String,
pub created: DateTime<Utc>,
pub expires: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tty: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionToken {
pub claims: SessionClaims,
pub signature: String,
}
const DEFAULT_TTL_HOURS: i64 = 24;
pub fn current_tty() -> Option<String> {
if let Ok(wt) = std::env::var("WT_SESSION") {
if !wt.is_empty() {
return Some(format!("wt:{wt}"));
}
}
#[cfg(unix)]
{
let ptr = unsafe { libc::ttyname(0) };
if !ptr.is_null() {
let cstr = unsafe { std::ffi::CStr::from_ptr(ptr) };
if let Ok(s) = cstr.to_str() {
return Some(s.to_string());
}
}
}
None
}
pub fn create_session(
keypair: &IdentityKeypair,
member: &str,
project_id: &str,
ttl: Option<Duration>,
) -> SessionToken {
create_session_with_token_key(keypair, member, project_id, ttl, None)
}
pub fn create_session_for_ai(
ephemeral_keypair: &IdentityKeypair,
member: &str,
project_id: &str,
ttl: Option<Duration>,
delegation_key: &str,
token_expires: Option<DateTime<Utc>>,
) -> SessionToken {
let now = Utc::now();
let ttl = ttl.unwrap_or_else(|| Duration::hours(DEFAULT_TTL_HOURS));
let session_expiry = now + ttl;
let expires = match token_expires {
Some(token_exp) if token_exp < session_expiry => token_exp,
_ => session_expiry,
};
let claims = SessionClaims {
member: member.to_string(),
project_id: project_id.to_string(),
created: now,
expires,
token_key: Some(delegation_key.to_string()),
session_public_key: Some(ephemeral_keypair.public_key().to_hex()),
tty: None,
};
let claims_json = serde_json::to_string(&claims).expect("claims serialize");
let signature = ephemeral_keypair.sign(claims_json.as_bytes());
SessionToken {
claims,
signature: hex::encode(signature),
}
}
fn create_session_with_token_key(
keypair: &IdentityKeypair,
member: &str,
project_id: &str,
ttl: Option<Duration>,
token_key: Option<String>,
) -> SessionToken {
let now = Utc::now();
let ttl = ttl.unwrap_or_else(|| Duration::hours(DEFAULT_TTL_HOURS));
let tty = current_tty();
let claims = SessionClaims {
member: member.to_string(),
project_id: project_id.to_string(),
created: now,
expires: now + ttl,
token_key,
session_public_key: None,
tty,
};
let claims_json = serde_json::to_string(&claims).expect("claims serialize");
let signature = keypair.sign(claims_json.as_bytes());
SessionToken {
claims,
signature: hex::encode(signature),
}
}
pub fn validate_session(
token: &SessionToken,
public_key: &PublicKey,
project_id: &str,
) -> Result<SessionClaims, JoyError> {
if token.claims.project_id != project_id {
return Err(JoyError::AuthFailed(
"session belongs to a different project".into(),
));
}
if Utc::now() > token.claims.expires {
return Err(JoyError::AuthFailed(
"session expired, run `joy auth` to re-authenticate".into(),
));
}
let claims_json = serde_json::to_string(&token.claims).expect("claims serialize");
let signature =
hex::decode(&token.signature).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
public_key.verify(claims_json.as_bytes(), &signature)?;
Ok(token.claims.clone())
}
fn session_dir() -> Result<PathBuf, JoyError> {
let state_dir = dirs_state_dir()?;
Ok(state_dir.join("joy").join("sessions"))
}
fn session_filename(project_id: &str, member: &str) -> String {
format!("{}.json", session_id(project_id, member))
}
pub fn session_id(project_id: &str, member: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(project_id.as_bytes());
hasher.update(b":");
hasher.update(member.as_bytes());
let hash = hasher.finalize();
hex::encode(&hash[..SESSION_ID_LEN])
}
pub const SESSION_ENV_PREFIX: &str = "joy_s_";
const SESSION_ID_LEN: usize = 12;
const SESSION_PRIVATE_LEN: usize = 32;
const DELEGATION_PRIVATE_LEN: usize = 32;
pub fn encode_session_env(sid_hex: &str, ephemeral_private: &[u8; SESSION_PRIVATE_LEN]) -> String {
encode_session_env_full(sid_hex, ephemeral_private, None)
}
pub fn encode_session_env_full(
sid_hex: &str,
ephemeral_private: &[u8; SESSION_PRIVATE_LEN],
delegation_private: Option<&[u8; DELEGATION_PRIVATE_LEN]>,
) -> String {
let sid_bytes = hex::decode(sid_hex).expect("session id must be valid hex");
assert_eq!(
sid_bytes.len(),
SESSION_ID_LEN,
"session id length mismatch"
);
let total_len = SESSION_ID_LEN
+ SESSION_PRIVATE_LEN
+ if delegation_private.is_some() {
DELEGATION_PRIVATE_LEN
} else {
0
};
let mut payload = Vec::with_capacity(total_len);
payload.extend_from_slice(&sid_bytes);
payload.extend_from_slice(ephemeral_private);
if let Some(dpk) = delegation_private {
payload.extend_from_slice(dpk);
}
use base64ct::{Base64, Encoding};
format!("{SESSION_ENV_PREFIX}{}", Base64::encode_string(&payload))
}
pub fn parse_session_env(env_value: &str) -> Option<(String, [u8; SESSION_PRIVATE_LEN])> {
let (sid, session_priv, _) = parse_session_env_full(env_value)?;
Some((sid, session_priv))
}
pub fn parse_session_env_full(
env_value: &str,
) -> Option<(
String,
[u8; SESSION_PRIVATE_LEN],
Option<[u8; DELEGATION_PRIVATE_LEN]>,
)> {
let encoded = env_value.strip_prefix(SESSION_ENV_PREFIX)?;
use base64ct::{Base64, Encoding};
let payload = Base64::decode_vec(encoded).ok()?;
let auth_only_len = SESSION_ID_LEN + SESSION_PRIVATE_LEN;
let with_crypt_len = auth_only_len + DELEGATION_PRIVATE_LEN;
if payload.len() != auth_only_len && payload.len() != with_crypt_len {
return None;
}
let sid_hex = hex::encode(&payload[..SESSION_ID_LEN]);
let mut session_priv = [0u8; SESSION_PRIVATE_LEN];
session_priv.copy_from_slice(&payload[SESSION_ID_LEN..auth_only_len]);
let delegation_priv = if payload.len() == with_crypt_len {
let mut dpk = [0u8; DELEGATION_PRIVATE_LEN];
dpk.copy_from_slice(&payload[auth_only_len..]);
Some(dpk)
} else {
None
};
Some((sid_hex, session_priv, delegation_priv))
}
pub fn save_session(project_id: &str, token: &SessionToken) -> Result<(), JoyError> {
let dir = session_dir()?;
std::fs::create_dir_all(&dir).map_err(|e| JoyError::CreateDir {
path: dir.clone(),
source: e,
})?;
let path = dir.join(session_filename(project_id, &token.claims.member));
let json = serde_json::to_string_pretty(token).expect("session serialize");
std::fs::write(&path, &json).map_err(|e| JoyError::WriteFile {
path: path.clone(),
source: e,
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&path, perms).map_err(|e| JoyError::WriteFile {
path: path.clone(),
source: e,
})?;
}
Ok(())
}
pub fn load_session(project_id: &str, member: &str) -> Result<Option<SessionToken>, JoyError> {
let dir = session_dir()?;
let path = dir.join(session_filename(project_id, member));
if !path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
path: path.clone(),
source: e,
})?;
let token: SessionToken =
serde_json::from_str(&json).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
Ok(Some(token))
}
pub fn load_session_by_id(id: &str) -> Result<Option<SessionToken>, JoyError> {
let dir = session_dir()?;
let path = dir.join(format!("{id}.json"));
if !path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
path: path.clone(),
source: e,
})?;
let token: SessionToken =
serde_json::from_str(&json).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
Ok(Some(token))
}
pub fn remove_session(project_id: &str, member: &str) -> Result<(), JoyError> {
let dir = session_dir()?;
let path = dir.join(session_filename(project_id, member));
if path.exists() {
std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
}
Ok(())
}
pub fn project_id(root: &Path) -> Result<String, JoyError> {
let project = crate::store::load_project(root)?;
Ok(project_id_of(&project))
}
pub fn project_id_of(project: &crate::model::Project) -> String {
project
.acronym
.clone()
.unwrap_or_else(|| project.name.to_lowercase().replace(' ', "-"))
}
pub(super) fn dirs_state_dir() -> Result<PathBuf, JoyError> {
if let Ok(xdg) = std::env::var("XDG_STATE_HOME") {
return Ok(PathBuf::from(xdg));
}
if let Ok(home) = std::env::var("HOME") {
return Ok(PathBuf::from(home).join(".local").join("state"));
}
Err(JoyError::AuthFailed(
"cannot determine state directory".into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{derive_key, IdentityKeypair, PublicKey, Salt};
use tempfile::tempdir;
const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
fn test_keypair() -> (IdentityKeypair, PublicKey) {
let salt =
Salt::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
.unwrap();
let key = derive_key(TEST_PASSPHRASE, &salt).unwrap();
let kp = IdentityKeypair::from_derived_key(&key);
let pk = kp.public_key();
(kp, pk)
}
#[test]
fn create_and_validate_session() {
let (kp, pk) = test_keypair();
let token = create_session(&kp, "test@example.com", "TST", None);
let claims = validate_session(&token, &pk, "TST").unwrap();
assert_eq!(claims.member, "test@example.com");
assert_eq!(claims.project_id, "TST");
}
#[test]
fn expired_session_rejected() {
let (kp, pk) = test_keypair();
let token = create_session(&kp, "test@example.com", "TST", Some(Duration::seconds(-1)));
assert!(validate_session(&token, &pk, "TST").is_err());
}
#[test]
fn wrong_project_rejected() {
let (kp, pk) = test_keypair();
let token = create_session(&kp, "test@example.com", "TST", None);
assert!(validate_session(&token, &pk, "OTHER").is_err());
}
#[test]
fn tampered_session_rejected() {
let (kp, pk) = test_keypair();
let mut token = create_session(&kp, "test@example.com", "TST", None);
token.claims.member = "attacker@evil.com".into();
assert!(validate_session(&token, &pk, "TST").is_err());
}
#[test]
fn session_env_roundtrip() {
let sid = "0123456789abcdef01234567";
let private = [7u8; 32];
let encoded = encode_session_env(sid, &private);
assert!(encoded.starts_with(SESSION_ENV_PREFIX));
let (decoded_sid, decoded_priv) = parse_session_env(&encoded).unwrap();
assert_eq!(decoded_sid, sid);
assert_eq!(decoded_priv, private);
}
#[test]
fn parse_session_env_rejects_bad_inputs() {
assert!(parse_session_env("no_prefix_value").is_none());
assert!(parse_session_env("joy_s_!!!").is_none());
use base64ct::{Base64, Encoding};
let short = format!("{SESSION_ENV_PREFIX}{}", Base64::encode_string(&[1u8; 10]));
assert!(parse_session_env(&short).is_none());
}
#[test]
fn ai_session_carries_ephemeral_public_key() {
let ephemeral = IdentityKeypair::from_random();
let ephemeral_pk = ephemeral.public_key().to_hex();
let token = create_session_for_ai(&ephemeral, "ai:claude@joy", "TST", None, "dkey", None);
assert_eq!(
token.claims.session_public_key.as_deref(),
Some(ephemeral_pk.as_str())
);
assert_eq!(token.claims.token_key.as_deref(), Some("dkey"));
let pk = PublicKey::from_hex(&ephemeral_pk).unwrap();
validate_session(&token, &pk, "TST").unwrap();
}
#[test]
fn ai_session_clamped_to_token_expiry() {
let ephemeral = IdentityKeypair::from_random();
let token_expires = Utc::now() + Duration::minutes(30);
let token = create_session_for_ai(
&ephemeral,
"ai:claude@joy",
"TST",
None,
"dkey",
Some(token_expires),
);
let delta = (token.claims.expires - token_expires).num_seconds().abs();
assert!(delta < 2, "session expiry should match token expiry");
}
#[test]
fn ai_session_uses_session_ttl_when_token_lives_longer() {
let ephemeral = IdentityKeypair::from_random();
let token_expires = Utc::now() + Duration::days(7);
let token = create_session_for_ai(
&ephemeral,
"ai:claude@joy",
"TST",
Some(Duration::hours(1)),
"dkey",
Some(token_expires),
);
let session_ttl = token.claims.expires - token.claims.created;
assert!(
session_ttl <= Duration::hours(1),
"session must respect its own TTL when token lives longer"
);
}
#[test]
fn session_env_full_roundtrip_with_delegation() {
let sid = "0123456789abcdef01234567";
let session_priv = [7u8; 32];
let delegation_priv = [9u8; 32];
let encoded = encode_session_env_full(sid, &session_priv, Some(&delegation_priv));
let (decoded_sid, decoded_session, decoded_delegation) =
parse_session_env_full(&encoded).unwrap();
assert_eq!(decoded_sid, sid);
assert_eq!(decoded_session, session_priv);
assert_eq!(decoded_delegation, Some(delegation_priv));
}
#[test]
fn session_env_legacy_auth_only_still_parses() {
let sid = "0123456789abcdef01234567";
let session_priv = [7u8; 32];
let encoded = encode_session_env(sid, &session_priv);
let (decoded_sid, decoded_session, decoded_delegation) =
parse_session_env_full(&encoded).unwrap();
assert_eq!(decoded_sid, sid);
assert_eq!(decoded_session, session_priv);
assert!(decoded_delegation.is_none());
}
#[test]
fn save_load_roundtrip() {
let (kp, pk) = test_keypair();
let token = create_session(&kp, "test@example.com", "TST", None);
let dir = tempdir().unwrap();
unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
save_session("TST", &token).unwrap();
let loaded = load_session("TST", "test@example.com").unwrap().unwrap();
let claims = validate_session(&loaded, &pk, "TST").unwrap();
assert_eq!(claims.member, "test@example.com");
remove_session("TST", "test@example.com").unwrap();
assert!(load_session("TST", "test@example.com").unwrap().is_none());
unsafe { std::env::remove_var("XDG_STATE_HOME") };
}
}