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 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub members_zone_key: Option<String>,
56}
57
58const DEFAULT_TTL_HOURS: i64 = 24;
60
61pub fn current_tty() -> Option<String> {
68 if let Ok(wt) = std::env::var("WT_SESSION") {
70 if !wt.is_empty() {
71 return Some(format!("wt:{wt}"));
72 }
73 }
74
75 #[cfg(unix)]
76 {
77 let ptr = unsafe { libc::ttyname(0) };
80 if !ptr.is_null() {
81 let cstr = unsafe { std::ffi::CStr::from_ptr(ptr) };
82 if let Ok(s) = cstr.to_str() {
83 return Some(s.to_string());
84 }
85 }
86 }
87
88 None
89}
90
91pub fn create_session(
93 keypair: &IdentityKeypair,
94 member: &str,
95 project_id: &str,
96 ttl: Option<Duration>,
97) -> SessionToken {
98 create_session_with_token_key(keypair, member, project_id, ttl, None)
99}
100
101pub fn create_session_for_ai(
113 ephemeral_keypair: &IdentityKeypair,
114 member: &str,
115 project_id: &str,
116 ttl: Option<Duration>,
117 delegation_key: &str,
118 token_expires: Option<DateTime<Utc>>,
119) -> SessionToken {
120 let now = Utc::now();
121 let ttl = ttl.unwrap_or_else(|| Duration::hours(DEFAULT_TTL_HOURS));
122 let session_expiry = now + ttl;
123 let expires = match token_expires {
124 Some(token_exp) if token_exp < session_expiry => token_exp,
125 _ => session_expiry,
126 };
127 let claims = SessionClaims {
128 member: member.to_string(),
129 project_id: project_id.to_string(),
130 created: now,
131 expires,
132 token_key: Some(delegation_key.to_string()),
133 session_public_key: Some(ephemeral_keypair.public_key().to_hex()),
134 tty: None,
135 };
136 let claims_json = serde_json::to_string(&claims).expect("claims serialize");
137 let signature = ephemeral_keypair.sign(claims_json.as_bytes());
138 SessionToken {
139 claims,
140 signature: hex::encode(signature),
141 members_zone_key: None,
142 }
143}
144
145fn create_session_with_token_key(
146 keypair: &IdentityKeypair,
147 member: &str,
148 project_id: &str,
149 ttl: Option<Duration>,
150 token_key: Option<String>,
151) -> SessionToken {
152 let now = Utc::now();
153 let ttl = ttl.unwrap_or_else(|| Duration::hours(DEFAULT_TTL_HOURS));
154 let tty = current_tty();
157 let claims = SessionClaims {
158 member: member.to_string(),
159 project_id: project_id.to_string(),
160 created: now,
161 expires: now + ttl,
162 token_key,
163 session_public_key: None,
164 tty,
165 };
166 let claims_json = serde_json::to_string(&claims).expect("claims serialize");
167 let signature = keypair.sign(claims_json.as_bytes());
168 SessionToken {
169 claims,
170 signature: hex::encode(signature),
171 members_zone_key: None,
172 }
173}
174
175pub fn validate_session(
177 token: &SessionToken,
178 public_key: &PublicKey,
179 project_id: &str,
180) -> Result<SessionClaims, JoyError> {
181 if token.claims.project_id != project_id {
183 return Err(JoyError::AuthFailed(
184 "session belongs to a different project".into(),
185 ));
186 }
187
188 if Utc::now() > token.claims.expires {
190 return Err(JoyError::AuthFailed(
191 "session expired, run `joy auth` to re-authenticate".into(),
192 ));
193 }
194
195 let claims_json = serde_json::to_string(&token.claims).expect("claims serialize");
197 let signature =
198 hex::decode(&token.signature).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
199 public_key.verify(claims_json.as_bytes(), &signature)?;
200
201 Ok(token.claims.clone())
202}
203
204fn session_dir() -> Result<PathBuf, JoyError> {
206 let state_dir = dirs_state_dir()?;
207 Ok(state_dir.join("joy").join("sessions"))
208}
209
210fn session_filename(project_id: &str, member: &str) -> String {
213 format!("{}.json", session_id(project_id, member))
214}
215
216pub fn session_id(project_id: &str, member: &str) -> String {
220 use sha2::{Digest, Sha256};
221 let mut hasher = Sha256::new();
222 hasher.update(project_id.as_bytes());
223 hasher.update(b":");
224 hasher.update(member.as_bytes());
225 let hash = hasher.finalize();
226 hex::encode(&hash[..SESSION_ID_LEN])
227}
228
229pub const SESSION_ENV_PREFIX: &str = "joy_s_";
231const SESSION_ID_LEN: usize = 12;
232const SESSION_PRIVATE_LEN: usize = 32;
233const DELEGATION_PRIVATE_LEN: usize = 32;
234
235pub fn encode_session_env(sid_hex: &str, ephemeral_private: &[u8; SESSION_PRIVATE_LEN]) -> String {
248 encode_session_env_full(sid_hex, ephemeral_private, None)
249}
250
251pub fn encode_session_env_full(
254 sid_hex: &str,
255 ephemeral_private: &[u8; SESSION_PRIVATE_LEN],
256 delegation_private: Option<&[u8; DELEGATION_PRIVATE_LEN]>,
257) -> String {
258 let sid_bytes = hex::decode(sid_hex).expect("session id must be valid hex");
259 assert_eq!(
260 sid_bytes.len(),
261 SESSION_ID_LEN,
262 "session id length mismatch"
263 );
264 let total_len = SESSION_ID_LEN
265 + SESSION_PRIVATE_LEN
266 + if delegation_private.is_some() {
267 DELEGATION_PRIVATE_LEN
268 } else {
269 0
270 };
271 let mut payload = Vec::with_capacity(total_len);
272 payload.extend_from_slice(&sid_bytes);
273 payload.extend_from_slice(ephemeral_private);
274 if let Some(dpk) = delegation_private {
275 payload.extend_from_slice(dpk);
276 }
277 use base64ct::{Base64, Encoding};
278 format!("{SESSION_ENV_PREFIX}{}", Base64::encode_string(&payload))
279}
280
281pub fn parse_session_env(env_value: &str) -> Option<(String, [u8; SESSION_PRIVATE_LEN])> {
287 let (sid, session_priv, _) = parse_session_env_full(env_value)?;
288 Some((sid, session_priv))
289}
290
291pub fn parse_session_env_full(
296 env_value: &str,
297) -> Option<(
298 String,
299 [u8; SESSION_PRIVATE_LEN],
300 Option<[u8; DELEGATION_PRIVATE_LEN]>,
301)> {
302 let encoded = env_value.strip_prefix(SESSION_ENV_PREFIX)?;
303 use base64ct::{Base64, Encoding};
304 let payload = Base64::decode_vec(encoded).ok()?;
305 let auth_only_len = SESSION_ID_LEN + SESSION_PRIVATE_LEN;
306 let with_crypt_len = auth_only_len + DELEGATION_PRIVATE_LEN;
307 if payload.len() != auth_only_len && payload.len() != with_crypt_len {
308 return None;
309 }
310 let sid_hex = hex::encode(&payload[..SESSION_ID_LEN]);
311 let mut session_priv = [0u8; SESSION_PRIVATE_LEN];
312 session_priv.copy_from_slice(&payload[SESSION_ID_LEN..auth_only_len]);
313 let delegation_priv = if payload.len() == with_crypt_len {
314 let mut dpk = [0u8; DELEGATION_PRIVATE_LEN];
315 dpk.copy_from_slice(&payload[auth_only_len..]);
316 Some(dpk)
317 } else {
318 None
319 };
320 Some((sid_hex, session_priv, delegation_priv))
321}
322
323pub fn save_session(project_id: &str, token: &SessionToken) -> Result<(), JoyError> {
325 let dir = session_dir()?;
326 std::fs::create_dir_all(&dir).map_err(|e| JoyError::CreateDir {
327 path: dir.clone(),
328 source: e,
329 })?;
330 let path = dir.join(session_filename(project_id, &token.claims.member));
331 let json = serde_json::to_string_pretty(token).expect("session serialize");
332 std::fs::write(&path, &json).map_err(|e| JoyError::WriteFile {
333 path: path.clone(),
334 source: e,
335 })?;
336 #[cfg(unix)]
338 {
339 use std::os::unix::fs::PermissionsExt;
340 let perms = std::fs::Permissions::from_mode(0o600);
341 std::fs::set_permissions(&path, perms).map_err(|e| JoyError::WriteFile {
342 path: path.clone(),
343 source: e,
344 })?;
345 }
346 Ok(())
347}
348
349pub fn load_session(project_id: &str, member: &str) -> Result<Option<SessionToken>, JoyError> {
351 let dir = session_dir()?;
352 let path = dir.join(session_filename(project_id, member));
353 if !path.exists() {
354 return Ok(None);
355 }
356 let json = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
357 path: path.clone(),
358 source: e,
359 })?;
360 let token: SessionToken =
361 serde_json::from_str(&json).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
362 Ok(Some(token))
363}
364
365pub fn load_session_by_id(id: &str) -> Result<Option<SessionToken>, JoyError> {
367 let dir = session_dir()?;
368 let path = dir.join(format!("{id}.json"));
369 if !path.exists() {
370 return Ok(None);
371 }
372 let json = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
373 path: path.clone(),
374 source: e,
375 })?;
376 let token: SessionToken =
377 serde_json::from_str(&json).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
378 Ok(Some(token))
379}
380
381pub fn remove_session(project_id: &str, member: &str) -> Result<(), JoyError> {
383 let dir = session_dir()?;
384 let path = dir.join(session_filename(project_id, member));
385 if path.exists() {
386 std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
387 }
388 Ok(())
389}
390
391pub fn project_id(root: &Path) -> Result<String, JoyError> {
393 let project = crate::store::load_project(root)?;
394 Ok(project_id_of(&project))
395}
396
397pub fn project_id_of(project: &crate::model::Project) -> String {
401 project
402 .acronym
403 .clone()
404 .unwrap_or_else(|| project.name.to_lowercase().replace(' ', "-"))
405}
406
407pub(super) fn dirs_state_dir() -> Result<PathBuf, JoyError> {
408 crate::store::resolve_base_dir(
412 std::env::var("XDG_STATE_HOME").ok(),
413 std::env::var("LOCALAPPDATA").ok(),
414 std::env::var("HOME").ok(),
415 std::env::var("USERPROFILE").ok(),
416 cfg!(windows),
417 "Local",
418 ".local/state",
419 )
420 .ok_or_else(|| JoyError::AuthFailed("cannot determine state directory".into()))
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use crate::auth::{derive_key, IdentityKeypair, PublicKey, Salt};
427 use tempfile::tempdir;
428
429 const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
430
431 fn test_keypair() -> (IdentityKeypair, PublicKey) {
432 let salt =
433 Salt::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
434 .unwrap();
435 let key = derive_key(TEST_PASSPHRASE, &salt).unwrap();
436 let kp = IdentityKeypair::from_derived_key(&key);
437 let pk = kp.public_key();
438 (kp, pk)
439 }
440
441 #[test]
442 fn create_and_validate_session() {
443 let (kp, pk) = test_keypair();
444 let token = create_session(&kp, "test@example.com", "TST", None);
445 let claims = validate_session(&token, &pk, "TST").unwrap();
446 assert_eq!(claims.member, "test@example.com");
447 assert_eq!(claims.project_id, "TST");
448 }
449
450 #[test]
451 fn expired_session_rejected() {
452 let (kp, pk) = test_keypair();
453 let token = create_session(&kp, "test@example.com", "TST", Some(Duration::seconds(-1)));
454 assert!(validate_session(&token, &pk, "TST").is_err());
455 }
456
457 #[test]
458 fn wrong_project_rejected() {
459 let (kp, pk) = test_keypair();
460 let token = create_session(&kp, "test@example.com", "TST", None);
461 assert!(validate_session(&token, &pk, "OTHER").is_err());
462 }
463
464 #[test]
465 fn tampered_session_rejected() {
466 let (kp, pk) = test_keypair();
467 let mut token = create_session(&kp, "test@example.com", "TST", None);
468 token.claims.member = "attacker@evil.com".into();
469 assert!(validate_session(&token, &pk, "TST").is_err());
470 }
471
472 #[test]
473 fn session_env_roundtrip() {
474 let sid = "0123456789abcdef01234567";
475 let private = [7u8; 32];
476 let encoded = encode_session_env(sid, &private);
477 assert!(encoded.starts_with(SESSION_ENV_PREFIX));
478 let (decoded_sid, decoded_priv) = parse_session_env(&encoded).unwrap();
479 assert_eq!(decoded_sid, sid);
480 assert_eq!(decoded_priv, private);
481 }
482
483 #[test]
484 fn parse_session_env_rejects_bad_inputs() {
485 assert!(parse_session_env("no_prefix_value").is_none());
486 assert!(parse_session_env("joy_s_!!!").is_none());
487 use base64ct::{Base64, Encoding};
489 let short = format!("{SESSION_ENV_PREFIX}{}", Base64::encode_string(&[1u8; 10]));
490 assert!(parse_session_env(&short).is_none());
491 }
492
493 #[test]
494 fn ai_session_carries_ephemeral_public_key() {
495 let ephemeral = IdentityKeypair::from_random();
496 let ephemeral_pk = ephemeral.public_key().to_hex();
497 let token = create_session_for_ai(&ephemeral, "ai:claude@joy", "TST", None, "dkey", None);
498 assert_eq!(
499 token.claims.session_public_key.as_deref(),
500 Some(ephemeral_pk.as_str())
501 );
502 assert_eq!(token.claims.token_key.as_deref(), Some("dkey"));
503 let pk = PublicKey::from_hex(&ephemeral_pk).unwrap();
505 validate_session(&token, &pk, "TST").unwrap();
506 }
507
508 #[test]
509 fn ai_session_clamped_to_token_expiry() {
510 let ephemeral = IdentityKeypair::from_random();
512 let token_expires = Utc::now() + Duration::minutes(30);
513 let token = create_session_for_ai(
514 &ephemeral,
515 "ai:claude@joy",
516 "TST",
517 None,
518 "dkey",
519 Some(token_expires),
520 );
521 let delta = (token.claims.expires - token_expires).num_seconds().abs();
523 assert!(delta < 2, "session expiry should match token expiry");
524 }
525
526 #[test]
527 fn ai_session_uses_session_ttl_when_token_lives_longer() {
528 let ephemeral = IdentityKeypair::from_random();
529 let token_expires = Utc::now() + Duration::days(7);
530 let token = create_session_for_ai(
531 &ephemeral,
532 "ai:claude@joy",
533 "TST",
534 Some(Duration::hours(1)),
535 "dkey",
536 Some(token_expires),
537 );
538 let session_ttl = token.claims.expires - token.claims.created;
540 assert!(
541 session_ttl <= Duration::hours(1),
542 "session must respect its own TTL when token lives longer"
543 );
544 }
545
546 #[test]
547 fn session_env_full_roundtrip_with_delegation() {
548 let sid = "0123456789abcdef01234567";
549 let session_priv = [7u8; 32];
550 let delegation_priv = [9u8; 32];
551 let encoded = encode_session_env_full(sid, &session_priv, Some(&delegation_priv));
552 let (decoded_sid, decoded_session, decoded_delegation) =
553 parse_session_env_full(&encoded).unwrap();
554 assert_eq!(decoded_sid, sid);
555 assert_eq!(decoded_session, session_priv);
556 assert_eq!(decoded_delegation, Some(delegation_priv));
557 }
558
559 #[test]
560 fn session_env_legacy_auth_only_still_parses() {
561 let sid = "0123456789abcdef01234567";
562 let session_priv = [7u8; 32];
563 let encoded = encode_session_env(sid, &session_priv);
564 let (decoded_sid, decoded_session, decoded_delegation) =
565 parse_session_env_full(&encoded).unwrap();
566 assert_eq!(decoded_sid, sid);
567 assert_eq!(decoded_session, session_priv);
568 assert!(decoded_delegation.is_none());
569 }
570
571 #[test]
572 fn save_load_roundtrip() {
573 let (kp, pk) = test_keypair();
574 let token = create_session(&kp, "test@example.com", "TST", None);
575
576 let dir = tempdir().unwrap();
577 unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
580
581 save_session("TST", &token).unwrap();
582 let loaded = load_session("TST", "test@example.com").unwrap().unwrap();
583 let claims = validate_session(&loaded, &pk, "TST").unwrap();
584 assert_eq!(claims.member, "test@example.com");
585
586 remove_session("TST", "test@example.com").unwrap();
587 assert!(load_session("TST", "test@example.com").unwrap().is_none());
588
589 unsafe { std::env::remove_var("XDG_STATE_HOME") };
591 }
592}