1use std::path::{Path, PathBuf};
11
12use chrono::{DateTime, Duration, Utc};
13use serde::{Deserialize, Serialize};
14
15use super::sign::{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(
100 ephemeral_keypair: &IdentityKeypair,
101 member: &str,
102 project_id: &str,
103 ttl: Option<Duration>,
104 delegation_key: &str,
105) -> SessionToken {
106 let now = Utc::now();
107 let ttl = ttl.unwrap_or_else(|| Duration::hours(DEFAULT_TTL_HOURS));
108 let claims = SessionClaims {
109 member: member.to_string(),
110 project_id: project_id.to_string(),
111 created: now,
112 expires: now + ttl,
113 token_key: Some(delegation_key.to_string()),
114 session_public_key: Some(ephemeral_keypair.public_key().to_hex()),
115 tty: None,
116 };
117 let claims_json = serde_json::to_string(&claims).expect("claims serialize");
118 let signature = ephemeral_keypair.sign(claims_json.as_bytes());
119 SessionToken {
120 claims,
121 signature: hex::encode(signature),
122 }
123}
124
125fn create_session_with_token_key(
126 keypair: &IdentityKeypair,
127 member: &str,
128 project_id: &str,
129 ttl: Option<Duration>,
130 token_key: Option<String>,
131) -> SessionToken {
132 let now = Utc::now();
133 let ttl = ttl.unwrap_or_else(|| Duration::hours(DEFAULT_TTL_HOURS));
134 let tty = current_tty();
137 let claims = SessionClaims {
138 member: member.to_string(),
139 project_id: project_id.to_string(),
140 created: now,
141 expires: now + ttl,
142 token_key,
143 session_public_key: None,
144 tty,
145 };
146 let claims_json = serde_json::to_string(&claims).expect("claims serialize");
147 let signature = keypair.sign(claims_json.as_bytes());
148 SessionToken {
149 claims,
150 signature: hex::encode(signature),
151 }
152}
153
154pub fn validate_session(
156 token: &SessionToken,
157 public_key: &PublicKey,
158 project_id: &str,
159) -> Result<SessionClaims, JoyError> {
160 if token.claims.project_id != project_id {
162 return Err(JoyError::AuthFailed(
163 "session belongs to a different project".into(),
164 ));
165 }
166
167 if Utc::now() > token.claims.expires {
169 return Err(JoyError::AuthFailed(
170 "session expired, run `joy auth` to re-authenticate".into(),
171 ));
172 }
173
174 let claims_json = serde_json::to_string(&token.claims).expect("claims serialize");
176 let signature =
177 hex::decode(&token.signature).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
178 public_key.verify(claims_json.as_bytes(), &signature)?;
179
180 Ok(token.claims.clone())
181}
182
183fn session_dir() -> Result<PathBuf, JoyError> {
185 let state_dir = dirs_state_dir()?;
186 Ok(state_dir.join("joy").join("sessions"))
187}
188
189fn session_filename(project_id: &str, member: &str) -> String {
192 format!("{}.json", session_id(project_id, member))
193}
194
195pub fn session_id(project_id: &str, member: &str) -> String {
199 use sha2::{Digest, Sha256};
200 let mut hasher = Sha256::new();
201 hasher.update(project_id.as_bytes());
202 hasher.update(b":");
203 hasher.update(member.as_bytes());
204 let hash = hasher.finalize();
205 hex::encode(&hash[..SESSION_ID_LEN])
206}
207
208pub const SESSION_ENV_PREFIX: &str = "joy_s_";
210const SESSION_ID_LEN: usize = 12;
211const SESSION_PRIVATE_LEN: usize = 32;
212
213pub fn encode_session_env(sid_hex: &str, ephemeral_private: &[u8; SESSION_PRIVATE_LEN]) -> String {
217 let sid_bytes = hex::decode(sid_hex).expect("session id must be valid hex");
218 assert_eq!(
219 sid_bytes.len(),
220 SESSION_ID_LEN,
221 "session id length mismatch"
222 );
223 let mut payload = Vec::with_capacity(SESSION_ID_LEN + SESSION_PRIVATE_LEN);
224 payload.extend_from_slice(&sid_bytes);
225 payload.extend_from_slice(ephemeral_private);
226 use base64ct::{Base64, Encoding};
227 format!("{SESSION_ENV_PREFIX}{}", Base64::encode_string(&payload))
228}
229
230pub fn parse_session_env(env_value: &str) -> Option<(String, [u8; SESSION_PRIVATE_LEN])> {
233 let encoded = env_value.strip_prefix(SESSION_ENV_PREFIX)?;
234 use base64ct::{Base64, Encoding};
235 let payload = Base64::decode_vec(encoded).ok()?;
236 if payload.len() != SESSION_ID_LEN + SESSION_PRIVATE_LEN {
237 return None;
238 }
239 let sid_hex = hex::encode(&payload[..SESSION_ID_LEN]);
240 let mut private = [0u8; SESSION_PRIVATE_LEN];
241 private.copy_from_slice(&payload[SESSION_ID_LEN..]);
242 Some((sid_hex, private))
243}
244
245pub fn save_session(project_id: &str, token: &SessionToken) -> Result<(), JoyError> {
247 let dir = session_dir()?;
248 std::fs::create_dir_all(&dir).map_err(|e| JoyError::CreateDir {
249 path: dir.clone(),
250 source: e,
251 })?;
252 let path = dir.join(session_filename(project_id, &token.claims.member));
253 let json = serde_json::to_string_pretty(token).expect("session serialize");
254 std::fs::write(&path, &json).map_err(|e| JoyError::WriteFile {
255 path: path.clone(),
256 source: e,
257 })?;
258 #[cfg(unix)]
260 {
261 use std::os::unix::fs::PermissionsExt;
262 let perms = std::fs::Permissions::from_mode(0o600);
263 std::fs::set_permissions(&path, perms).map_err(|e| JoyError::WriteFile {
264 path: path.clone(),
265 source: e,
266 })?;
267 }
268 Ok(())
269}
270
271pub fn load_session(project_id: &str, member: &str) -> Result<Option<SessionToken>, JoyError> {
273 let dir = session_dir()?;
274 let path = dir.join(session_filename(project_id, member));
275 if !path.exists() {
276 return Ok(None);
277 }
278 let json = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
279 path: path.clone(),
280 source: e,
281 })?;
282 let token: SessionToken =
283 serde_json::from_str(&json).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
284 Ok(Some(token))
285}
286
287pub fn load_session_by_id(id: &str) -> Result<Option<SessionToken>, JoyError> {
289 let dir = session_dir()?;
290 let path = dir.join(format!("{id}.json"));
291 if !path.exists() {
292 return Ok(None);
293 }
294 let json = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
295 path: path.clone(),
296 source: e,
297 })?;
298 let token: SessionToken =
299 serde_json::from_str(&json).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
300 Ok(Some(token))
301}
302
303pub fn remove_session(project_id: &str, member: &str) -> Result<(), JoyError> {
305 let dir = session_dir()?;
306 let path = dir.join(session_filename(project_id, member));
307 if path.exists() {
308 std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
309 }
310 Ok(())
311}
312
313pub fn project_id(root: &Path) -> Result<String, JoyError> {
315 let project = crate::store::load_project(root)?;
316 Ok(project_id_of(&project))
317}
318
319pub fn project_id_of(project: &crate::model::Project) -> String {
323 project
324 .acronym
325 .clone()
326 .unwrap_or_else(|| project.name.to_lowercase().replace(' ', "-"))
327}
328
329pub(super) fn dirs_state_dir() -> Result<PathBuf, JoyError> {
330 if let Ok(xdg) = std::env::var("XDG_STATE_HOME") {
332 return Ok(PathBuf::from(xdg));
333 }
334 if let Ok(home) = std::env::var("HOME") {
335 return Ok(PathBuf::from(home).join(".local").join("state"));
336 }
337 Err(JoyError::AuthFailed(
338 "cannot determine state directory".into(),
339 ))
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use crate::auth::{derive, sign};
346 use tempfile::tempdir;
347
348 const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
349
350 fn test_keypair() -> (sign::IdentityKeypair, sign::PublicKey) {
351 let salt = derive::Salt::from_hex(
352 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
353 )
354 .unwrap();
355 let key = derive::derive_key(TEST_PASSPHRASE, &salt).unwrap();
356 let kp = sign::IdentityKeypair::from_derived_key(&key);
357 let pk = kp.public_key();
358 (kp, pk)
359 }
360
361 #[test]
362 fn create_and_validate_session() {
363 let (kp, pk) = test_keypair();
364 let token = create_session(&kp, "test@example.com", "TST", None);
365 let claims = validate_session(&token, &pk, "TST").unwrap();
366 assert_eq!(claims.member, "test@example.com");
367 assert_eq!(claims.project_id, "TST");
368 }
369
370 #[test]
371 fn expired_session_rejected() {
372 let (kp, pk) = test_keypair();
373 let token = create_session(&kp, "test@example.com", "TST", Some(Duration::seconds(-1)));
374 assert!(validate_session(&token, &pk, "TST").is_err());
375 }
376
377 #[test]
378 fn wrong_project_rejected() {
379 let (kp, pk) = test_keypair();
380 let token = create_session(&kp, "test@example.com", "TST", None);
381 assert!(validate_session(&token, &pk, "OTHER").is_err());
382 }
383
384 #[test]
385 fn tampered_session_rejected() {
386 let (kp, pk) = test_keypair();
387 let mut token = create_session(&kp, "test@example.com", "TST", None);
388 token.claims.member = "attacker@evil.com".into();
389 assert!(validate_session(&token, &pk, "TST").is_err());
390 }
391
392 #[test]
393 fn session_env_roundtrip() {
394 let sid = "0123456789abcdef01234567";
395 let private = [7u8; 32];
396 let encoded = encode_session_env(sid, &private);
397 assert!(encoded.starts_with(SESSION_ENV_PREFIX));
398 let (decoded_sid, decoded_priv) = parse_session_env(&encoded).unwrap();
399 assert_eq!(decoded_sid, sid);
400 assert_eq!(decoded_priv, private);
401 }
402
403 #[test]
404 fn parse_session_env_rejects_bad_inputs() {
405 assert!(parse_session_env("no_prefix_value").is_none());
406 assert!(parse_session_env("joy_s_!!!").is_none());
407 use base64ct::{Base64, Encoding};
409 let short = format!("{SESSION_ENV_PREFIX}{}", Base64::encode_string(&[1u8; 10]));
410 assert!(parse_session_env(&short).is_none());
411 }
412
413 #[test]
414 fn ai_session_carries_ephemeral_public_key() {
415 let ephemeral = sign::IdentityKeypair::from_random();
416 let ephemeral_pk = ephemeral.public_key().to_hex();
417 let token = create_session_for_ai(&ephemeral, "ai:claude@joy", "TST", None, "dkey");
418 assert_eq!(
419 token.claims.session_public_key.as_deref(),
420 Some(ephemeral_pk.as_str())
421 );
422 assert_eq!(token.claims.token_key.as_deref(), Some("dkey"));
423 let pk = sign::PublicKey::from_hex(&ephemeral_pk).unwrap();
425 validate_session(&token, &pk, "TST").unwrap();
426 }
427
428 #[test]
429 fn save_load_roundtrip() {
430 let (kp, pk) = test_keypair();
431 let token = create_session(&kp, "test@example.com", "TST", None);
432
433 let dir = tempdir().unwrap();
434 unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
437
438 save_session("TST", &token).unwrap();
439 let loaded = load_session("TST", "test@example.com").unwrap().unwrap();
440 let claims = validate_session(&loaded, &pk, "TST").unwrap();
441 assert_eq!(claims.member, "test@example.com");
442
443 remove_session("TST", "test@example.com").unwrap();
444 assert!(load_session("TST", "test@example.com").unwrap().is_none());
445
446 unsafe { std::env::remove_var("XDG_STATE_HOME") };
448 }
449}