1use std::path::{Path, PathBuf};
11
12use chrono::{DateTime, Duration, Utc};
13use serde::{Deserialize, Serialize};
14
15use super::{IdentityKeypair, PublicKey};
16use crate::error::JoyError;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SessionClaims {
21 pub member: String,
22 pub project_id: String,
23 pub created: DateTime<Utc>,
24 pub expires: DateTime<Utc>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub token_key: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub session_public_key: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub tty: Option<String>,
40}
41
42#[derive(Debug, Serialize, Deserialize)]
44pub struct SessionToken {
45 pub claims: SessionClaims,
46 pub signature: String,
48}
49
50const DEFAULT_TTL_HOURS: i64 = 24;
52
53pub fn current_tty() -> Option<String> {
60 if let Ok(wt) = std::env::var("WT_SESSION") {
62 if !wt.is_empty() {
63 return Some(format!("wt:{wt}"));
64 }
65 }
66
67 #[cfg(unix)]
68 {
69 let ptr = unsafe { libc::ttyname(0) };
72 if !ptr.is_null() {
73 let cstr = unsafe { std::ffi::CStr::from_ptr(ptr) };
74 if let Ok(s) = cstr.to_str() {
75 return Some(s.to_string());
76 }
77 }
78 }
79
80 None
81}
82
83pub fn create_session(
85 keypair: &IdentityKeypair,
86 member: &str,
87 project_id: &str,
88 ttl: Option<Duration>,
89) -> SessionToken {
90 create_session_with_token_key(keypair, member, project_id, ttl, None)
91}
92
93pub fn create_session_for_ai(
105 ephemeral_keypair: &IdentityKeypair,
106 member: &str,
107 project_id: &str,
108 ttl: Option<Duration>,
109 delegation_key: &str,
110 token_expires: Option<DateTime<Utc>>,
111) -> SessionToken {
112 let now = Utc::now();
113 let ttl = ttl.unwrap_or_else(|| Duration::hours(DEFAULT_TTL_HOURS));
114 let session_expiry = now + ttl;
115 let expires = match token_expires {
116 Some(token_exp) if token_exp < session_expiry => token_exp,
117 _ => session_expiry,
118 };
119 let claims = SessionClaims {
120 member: member.to_string(),
121 project_id: project_id.to_string(),
122 created: now,
123 expires,
124 token_key: Some(delegation_key.to_string()),
125 session_public_key: Some(ephemeral_keypair.public_key().to_hex()),
126 tty: None,
127 };
128 let claims_json = serde_json::to_string(&claims).expect("claims serialize");
129 let signature = ephemeral_keypair.sign(claims_json.as_bytes());
130 SessionToken {
131 claims,
132 signature: hex::encode(signature),
133 }
134}
135
136fn create_session_with_token_key(
137 keypair: &IdentityKeypair,
138 member: &str,
139 project_id: &str,
140 ttl: Option<Duration>,
141 token_key: Option<String>,
142) -> SessionToken {
143 let now = Utc::now();
144 let ttl = ttl.unwrap_or_else(|| Duration::hours(DEFAULT_TTL_HOURS));
145 let tty = current_tty();
148 let claims = SessionClaims {
149 member: member.to_string(),
150 project_id: project_id.to_string(),
151 created: now,
152 expires: now + ttl,
153 token_key,
154 session_public_key: None,
155 tty,
156 };
157 let claims_json = serde_json::to_string(&claims).expect("claims serialize");
158 let signature = keypair.sign(claims_json.as_bytes());
159 SessionToken {
160 claims,
161 signature: hex::encode(signature),
162 }
163}
164
165pub fn validate_session(
167 token: &SessionToken,
168 public_key: &PublicKey,
169 project_id: &str,
170) -> Result<SessionClaims, JoyError> {
171 if token.claims.project_id != project_id {
173 return Err(JoyError::AuthFailed(
174 "session belongs to a different project".into(),
175 ));
176 }
177
178 if Utc::now() > token.claims.expires {
180 return Err(JoyError::AuthFailed(
181 "session expired, run `joy auth` to re-authenticate".into(),
182 ));
183 }
184
185 let claims_json = serde_json::to_string(&token.claims).expect("claims serialize");
187 let signature =
188 hex::decode(&token.signature).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
189 public_key.verify(claims_json.as_bytes(), &signature)?;
190
191 Ok(token.claims.clone())
192}
193
194fn session_dir() -> Result<PathBuf, JoyError> {
196 let state_dir = dirs_state_dir()?;
197 Ok(state_dir.join("joy").join("sessions"))
198}
199
200fn session_filename(project_id: &str, member: &str) -> String {
203 format!("{}.json", session_id(project_id, member))
204}
205
206pub fn session_id(project_id: &str, member: &str) -> String {
210 use sha2::{Digest, Sha256};
211 let mut hasher = Sha256::new();
212 hasher.update(project_id.as_bytes());
213 hasher.update(b":");
214 hasher.update(member.as_bytes());
215 let hash = hasher.finalize();
216 hex::encode(&hash[..SESSION_ID_LEN])
217}
218
219pub const SESSION_ENV_PREFIX: &str = "joy_s_";
221const SESSION_ID_LEN: usize = 12;
222const SESSION_PRIVATE_LEN: usize = 32;
223const DELEGATION_PRIVATE_LEN: usize = 32;
224
225pub fn encode_session_env(sid_hex: &str, ephemeral_private: &[u8; SESSION_PRIVATE_LEN]) -> String {
238 encode_session_env_full(sid_hex, ephemeral_private, None)
239}
240
241pub fn encode_session_env_full(
244 sid_hex: &str,
245 ephemeral_private: &[u8; SESSION_PRIVATE_LEN],
246 delegation_private: Option<&[u8; DELEGATION_PRIVATE_LEN]>,
247) -> String {
248 let sid_bytes = hex::decode(sid_hex).expect("session id must be valid hex");
249 assert_eq!(
250 sid_bytes.len(),
251 SESSION_ID_LEN,
252 "session id length mismatch"
253 );
254 let total_len = SESSION_ID_LEN
255 + SESSION_PRIVATE_LEN
256 + if delegation_private.is_some() {
257 DELEGATION_PRIVATE_LEN
258 } else {
259 0
260 };
261 let mut payload = Vec::with_capacity(total_len);
262 payload.extend_from_slice(&sid_bytes);
263 payload.extend_from_slice(ephemeral_private);
264 if let Some(dpk) = delegation_private {
265 payload.extend_from_slice(dpk);
266 }
267 use base64ct::{Base64, Encoding};
268 format!("{SESSION_ENV_PREFIX}{}", Base64::encode_string(&payload))
269}
270
271pub fn parse_session_env(env_value: &str) -> Option<(String, [u8; SESSION_PRIVATE_LEN])> {
277 let (sid, session_priv, _) = parse_session_env_full(env_value)?;
278 Some((sid, session_priv))
279}
280
281pub fn parse_session_env_full(
286 env_value: &str,
287) -> Option<(
288 String,
289 [u8; SESSION_PRIVATE_LEN],
290 Option<[u8; DELEGATION_PRIVATE_LEN]>,
291)> {
292 let encoded = env_value.strip_prefix(SESSION_ENV_PREFIX)?;
293 use base64ct::{Base64, Encoding};
294 let payload = Base64::decode_vec(encoded).ok()?;
295 let auth_only_len = SESSION_ID_LEN + SESSION_PRIVATE_LEN;
296 let with_crypt_len = auth_only_len + DELEGATION_PRIVATE_LEN;
297 if payload.len() != auth_only_len && payload.len() != with_crypt_len {
298 return None;
299 }
300 let sid_hex = hex::encode(&payload[..SESSION_ID_LEN]);
301 let mut session_priv = [0u8; SESSION_PRIVATE_LEN];
302 session_priv.copy_from_slice(&payload[SESSION_ID_LEN..auth_only_len]);
303 let delegation_priv = if payload.len() == with_crypt_len {
304 let mut dpk = [0u8; DELEGATION_PRIVATE_LEN];
305 dpk.copy_from_slice(&payload[auth_only_len..]);
306 Some(dpk)
307 } else {
308 None
309 };
310 Some((sid_hex, session_priv, delegation_priv))
311}
312
313pub fn save_session(project_id: &str, token: &SessionToken) -> Result<(), JoyError> {
315 let dir = session_dir()?;
316 std::fs::create_dir_all(&dir).map_err(|e| JoyError::CreateDir {
317 path: dir.clone(),
318 source: e,
319 })?;
320 let path = dir.join(session_filename(project_id, &token.claims.member));
321 let json = serde_json::to_string_pretty(token).expect("session serialize");
322 std::fs::write(&path, &json).map_err(|e| JoyError::WriteFile {
323 path: path.clone(),
324 source: e,
325 })?;
326 #[cfg(unix)]
328 {
329 use std::os::unix::fs::PermissionsExt;
330 let perms = std::fs::Permissions::from_mode(0o600);
331 std::fs::set_permissions(&path, perms).map_err(|e| JoyError::WriteFile {
332 path: path.clone(),
333 source: e,
334 })?;
335 }
336 Ok(())
337}
338
339pub fn load_session(project_id: &str, member: &str) -> Result<Option<SessionToken>, JoyError> {
341 let dir = session_dir()?;
342 let path = dir.join(session_filename(project_id, member));
343 if !path.exists() {
344 return Ok(None);
345 }
346 let json = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
347 path: path.clone(),
348 source: e,
349 })?;
350 let token: SessionToken =
351 serde_json::from_str(&json).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
352 Ok(Some(token))
353}
354
355pub fn load_session_by_id(id: &str) -> Result<Option<SessionToken>, JoyError> {
357 let dir = session_dir()?;
358 let path = dir.join(format!("{id}.json"));
359 if !path.exists() {
360 return Ok(None);
361 }
362 let json = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
363 path: path.clone(),
364 source: e,
365 })?;
366 let token: SessionToken =
367 serde_json::from_str(&json).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
368 Ok(Some(token))
369}
370
371pub fn remove_session(project_id: &str, member: &str) -> Result<(), JoyError> {
373 let dir = session_dir()?;
374 let path = dir.join(session_filename(project_id, member));
375 if path.exists() {
376 std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
377 }
378 Ok(())
379}
380
381pub fn project_id(root: &Path) -> Result<String, JoyError> {
383 let project = crate::store::load_project(root)?;
384 Ok(project_id_of(&project))
385}
386
387pub fn project_id_of(project: &crate::model::Project) -> String {
391 project
392 .acronym
393 .clone()
394 .unwrap_or_else(|| project.name.to_lowercase().replace(' ', "-"))
395}
396
397pub(super) fn dirs_state_dir() -> Result<PathBuf, JoyError> {
398 if let Ok(xdg) = std::env::var("XDG_STATE_HOME") {
400 return Ok(PathBuf::from(xdg));
401 }
402 if let Ok(home) = std::env::var("HOME") {
403 return Ok(PathBuf::from(home).join(".local").join("state"));
404 }
405 Err(JoyError::AuthFailed(
406 "cannot determine state directory".into(),
407 ))
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::auth::{derive_key, IdentityKeypair, PublicKey, Salt};
414 use tempfile::tempdir;
415
416 const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
417
418 fn test_keypair() -> (IdentityKeypair, PublicKey) {
419 let salt =
420 Salt::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
421 .unwrap();
422 let key = derive_key(TEST_PASSPHRASE, &salt).unwrap();
423 let kp = IdentityKeypair::from_derived_key(&key);
424 let pk = kp.public_key();
425 (kp, pk)
426 }
427
428 #[test]
429 fn create_and_validate_session() {
430 let (kp, pk) = test_keypair();
431 let token = create_session(&kp, "test@example.com", "TST", None);
432 let claims = validate_session(&token, &pk, "TST").unwrap();
433 assert_eq!(claims.member, "test@example.com");
434 assert_eq!(claims.project_id, "TST");
435 }
436
437 #[test]
438 fn expired_session_rejected() {
439 let (kp, pk) = test_keypair();
440 let token = create_session(&kp, "test@example.com", "TST", Some(Duration::seconds(-1)));
441 assert!(validate_session(&token, &pk, "TST").is_err());
442 }
443
444 #[test]
445 fn wrong_project_rejected() {
446 let (kp, pk) = test_keypair();
447 let token = create_session(&kp, "test@example.com", "TST", None);
448 assert!(validate_session(&token, &pk, "OTHER").is_err());
449 }
450
451 #[test]
452 fn tampered_session_rejected() {
453 let (kp, pk) = test_keypair();
454 let mut token = create_session(&kp, "test@example.com", "TST", None);
455 token.claims.member = "attacker@evil.com".into();
456 assert!(validate_session(&token, &pk, "TST").is_err());
457 }
458
459 #[test]
460 fn session_env_roundtrip() {
461 let sid = "0123456789abcdef01234567";
462 let private = [7u8; 32];
463 let encoded = encode_session_env(sid, &private);
464 assert!(encoded.starts_with(SESSION_ENV_PREFIX));
465 let (decoded_sid, decoded_priv) = parse_session_env(&encoded).unwrap();
466 assert_eq!(decoded_sid, sid);
467 assert_eq!(decoded_priv, private);
468 }
469
470 #[test]
471 fn parse_session_env_rejects_bad_inputs() {
472 assert!(parse_session_env("no_prefix_value").is_none());
473 assert!(parse_session_env("joy_s_!!!").is_none());
474 use base64ct::{Base64, Encoding};
476 let short = format!("{SESSION_ENV_PREFIX}{}", Base64::encode_string(&[1u8; 10]));
477 assert!(parse_session_env(&short).is_none());
478 }
479
480 #[test]
481 fn ai_session_carries_ephemeral_public_key() {
482 let ephemeral = IdentityKeypair::from_random();
483 let ephemeral_pk = ephemeral.public_key().to_hex();
484 let token = create_session_for_ai(&ephemeral, "ai:claude@joy", "TST", None, "dkey", None);
485 assert_eq!(
486 token.claims.session_public_key.as_deref(),
487 Some(ephemeral_pk.as_str())
488 );
489 assert_eq!(token.claims.token_key.as_deref(), Some("dkey"));
490 let pk = PublicKey::from_hex(&ephemeral_pk).unwrap();
492 validate_session(&token, &pk, "TST").unwrap();
493 }
494
495 #[test]
496 fn ai_session_clamped_to_token_expiry() {
497 let ephemeral = IdentityKeypair::from_random();
499 let token_expires = Utc::now() + Duration::minutes(30);
500 let token = create_session_for_ai(
501 &ephemeral,
502 "ai:claude@joy",
503 "TST",
504 None,
505 "dkey",
506 Some(token_expires),
507 );
508 let delta = (token.claims.expires - token_expires).num_seconds().abs();
510 assert!(delta < 2, "session expiry should match token expiry");
511 }
512
513 #[test]
514 fn ai_session_uses_session_ttl_when_token_lives_longer() {
515 let ephemeral = IdentityKeypair::from_random();
516 let token_expires = Utc::now() + Duration::days(7);
517 let token = create_session_for_ai(
518 &ephemeral,
519 "ai:claude@joy",
520 "TST",
521 Some(Duration::hours(1)),
522 "dkey",
523 Some(token_expires),
524 );
525 let session_ttl = token.claims.expires - token.claims.created;
527 assert!(
528 session_ttl <= Duration::hours(1),
529 "session must respect its own TTL when token lives longer"
530 );
531 }
532
533 #[test]
534 fn session_env_full_roundtrip_with_delegation() {
535 let sid = "0123456789abcdef01234567";
536 let session_priv = [7u8; 32];
537 let delegation_priv = [9u8; 32];
538 let encoded = encode_session_env_full(sid, &session_priv, Some(&delegation_priv));
539 let (decoded_sid, decoded_session, decoded_delegation) =
540 parse_session_env_full(&encoded).unwrap();
541 assert_eq!(decoded_sid, sid);
542 assert_eq!(decoded_session, session_priv);
543 assert_eq!(decoded_delegation, Some(delegation_priv));
544 }
545
546 #[test]
547 fn session_env_legacy_auth_only_still_parses() {
548 let sid = "0123456789abcdef01234567";
549 let session_priv = [7u8; 32];
550 let encoded = encode_session_env(sid, &session_priv);
551 let (decoded_sid, decoded_session, decoded_delegation) =
552 parse_session_env_full(&encoded).unwrap();
553 assert_eq!(decoded_sid, sid);
554 assert_eq!(decoded_session, session_priv);
555 assert!(decoded_delegation.is_none());
556 }
557
558 #[test]
559 fn save_load_roundtrip() {
560 let (kp, pk) = test_keypair();
561 let token = create_session(&kp, "test@example.com", "TST", None);
562
563 let dir = tempdir().unwrap();
564 unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
567
568 save_session("TST", &token).unwrap();
569 let loaded = load_session("TST", "test@example.com").unwrap().unwrap();
570 let claims = validate_session(&loaded, &pk, "TST").unwrap();
571 assert_eq!(claims.member, "test@example.com");
572
573 remove_session("TST", "test@example.com").unwrap();
574 assert!(load_session("TST", "test@example.com").unwrap().is_none());
575
576 unsafe { std::env::remove_var("XDG_STATE_HOME") };
578 }
579}