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")]
28 pub token_key: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub tty: Option<String>,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
37pub struct SessionToken {
38 pub claims: SessionClaims,
39 pub signature: String,
41}
42
43const DEFAULT_TTL_HOURS: i64 = 24;
45
46pub fn current_tty() -> Option<String> {
53 if let Ok(wt) = std::env::var("WT_SESSION") {
55 if !wt.is_empty() {
56 return Some(format!("wt:{wt}"));
57 }
58 }
59
60 #[cfg(unix)]
61 {
62 let ptr = unsafe { libc::ttyname(0) };
65 if !ptr.is_null() {
66 let cstr = unsafe { std::ffi::CStr::from_ptr(ptr) };
67 if let Ok(s) = cstr.to_str() {
68 return Some(s.to_string());
69 }
70 }
71 }
72
73 None
74}
75
76pub fn create_session(
78 keypair: &IdentityKeypair,
79 member: &str,
80 project_id: &str,
81 ttl: Option<Duration>,
82) -> SessionToken {
83 create_session_with_token_key(keypair, member, project_id, ttl, None)
84}
85
86pub fn create_session_for_ai(
88 keypair: &IdentityKeypair,
89 member: &str,
90 project_id: &str,
91 ttl: Option<Duration>,
92 token_key: &str,
93) -> SessionToken {
94 create_session_with_token_key(
95 keypair,
96 member,
97 project_id,
98 ttl,
99 Some(token_key.to_string()),
100 )
101}
102
103fn create_session_with_token_key(
104 keypair: &IdentityKeypair,
105 member: &str,
106 project_id: &str,
107 ttl: Option<Duration>,
108 token_key: Option<String>,
109) -> SessionToken {
110 let now = Utc::now();
111 let ttl = ttl.unwrap_or_else(|| Duration::hours(DEFAULT_TTL_HOURS));
112 let tty = current_tty();
114 let claims = SessionClaims {
115 member: member.to_string(),
116 project_id: project_id.to_string(),
117 created: now,
118 expires: now + ttl,
119 token_key,
120 tty,
121 };
122 let claims_json = serde_json::to_string(&claims).expect("claims serialize");
123 let signature = keypair.sign(claims_json.as_bytes());
124 SessionToken {
125 claims,
126 signature: hex::encode(signature),
127 }
128}
129
130pub fn validate_session(
132 token: &SessionToken,
133 public_key: &PublicKey,
134 project_id: &str,
135) -> Result<SessionClaims, JoyError> {
136 if token.claims.project_id != project_id {
138 return Err(JoyError::AuthFailed(
139 "session belongs to a different project".into(),
140 ));
141 }
142
143 if Utc::now() > token.claims.expires {
145 return Err(JoyError::AuthFailed(
146 "session expired, run `joy auth` to re-authenticate".into(),
147 ));
148 }
149
150 let claims_json = serde_json::to_string(&token.claims).expect("claims serialize");
152 let signature =
153 hex::decode(&token.signature).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
154 public_key.verify(claims_json.as_bytes(), &signature)?;
155
156 Ok(token.claims.clone())
157}
158
159fn session_dir() -> Result<PathBuf, JoyError> {
161 let state_dir = dirs_state_dir()?;
162 Ok(state_dir.join("joy").join("sessions"))
163}
164
165fn session_filename(project_id: &str, member: &str) -> String {
168 format!("{}.json", session_id(project_id, member))
169}
170
171pub fn session_id(project_id: &str, member: &str) -> String {
174 use sha2::{Digest, Sha256};
175 let mut hasher = Sha256::new();
176 hasher.update(project_id.as_bytes());
177 hasher.update(b":");
178 hasher.update(member.as_bytes());
179 let hash = hasher.finalize();
180 hex::encode(&hash[..12])
181}
182
183pub fn save_session(project_id: &str, token: &SessionToken) -> Result<(), JoyError> {
185 let dir = session_dir()?;
186 std::fs::create_dir_all(&dir).map_err(|e| JoyError::CreateDir {
187 path: dir.clone(),
188 source: e,
189 })?;
190 let path = dir.join(session_filename(project_id, &token.claims.member));
191 let json = serde_json::to_string_pretty(token).expect("session serialize");
192 std::fs::write(&path, &json).map_err(|e| JoyError::WriteFile {
193 path: path.clone(),
194 source: e,
195 })?;
196 #[cfg(unix)]
198 {
199 use std::os::unix::fs::PermissionsExt;
200 let perms = std::fs::Permissions::from_mode(0o600);
201 std::fs::set_permissions(&path, perms).map_err(|e| JoyError::WriteFile {
202 path: path.clone(),
203 source: e,
204 })?;
205 }
206 Ok(())
207}
208
209pub fn load_session(project_id: &str, member: &str) -> Result<Option<SessionToken>, JoyError> {
211 let dir = session_dir()?;
212 let path = dir.join(session_filename(project_id, member));
213 if !path.exists() {
214 return Ok(None);
215 }
216 let json = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
217 path: path.clone(),
218 source: e,
219 })?;
220 let token: SessionToken =
221 serde_json::from_str(&json).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
222 Ok(Some(token))
223}
224
225pub fn load_session_by_id(id: &str) -> Result<Option<SessionToken>, JoyError> {
227 let dir = session_dir()?;
228 let path = dir.join(format!("{id}.json"));
229 if !path.exists() {
230 return Ok(None);
231 }
232 let json = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
233 path: path.clone(),
234 source: e,
235 })?;
236 let token: SessionToken =
237 serde_json::from_str(&json).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
238 Ok(Some(token))
239}
240
241pub fn remove_session(project_id: &str, member: &str) -> Result<(), JoyError> {
243 let dir = session_dir()?;
244 let path = dir.join(session_filename(project_id, member));
245 if path.exists() {
246 std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
247 }
248 Ok(())
249}
250
251pub fn project_id(root: &Path) -> Result<String, JoyError> {
253 let project = crate::store::load_project(root)?;
254 Ok(project
255 .acronym
256 .unwrap_or_else(|| project.name.to_lowercase().replace(' ', "-")))
257}
258
259fn dirs_state_dir() -> Result<PathBuf, JoyError> {
260 if let Ok(xdg) = std::env::var("XDG_STATE_HOME") {
262 return Ok(PathBuf::from(xdg));
263 }
264 if let Ok(home) = std::env::var("HOME") {
265 return Ok(PathBuf::from(home).join(".local").join("state"));
266 }
267 Err(JoyError::AuthFailed(
268 "cannot determine state directory".into(),
269 ))
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::auth::{derive, sign};
276 use tempfile::tempdir;
277
278 const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
279
280 fn test_keypair() -> (sign::IdentityKeypair, sign::PublicKey) {
281 let salt = derive::Salt::from_hex(
282 "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
283 )
284 .unwrap();
285 let key = derive::derive_key(TEST_PASSPHRASE, &salt).unwrap();
286 let kp = sign::IdentityKeypair::from_derived_key(&key);
287 let pk = kp.public_key();
288 (kp, pk)
289 }
290
291 #[test]
292 fn create_and_validate_session() {
293 let (kp, pk) = test_keypair();
294 let token = create_session(&kp, "test@example.com", "TST", None);
295 let claims = validate_session(&token, &pk, "TST").unwrap();
296 assert_eq!(claims.member, "test@example.com");
297 assert_eq!(claims.project_id, "TST");
298 }
299
300 #[test]
301 fn expired_session_rejected() {
302 let (kp, pk) = test_keypair();
303 let token = create_session(&kp, "test@example.com", "TST", Some(Duration::seconds(-1)));
304 assert!(validate_session(&token, &pk, "TST").is_err());
305 }
306
307 #[test]
308 fn wrong_project_rejected() {
309 let (kp, pk) = test_keypair();
310 let token = create_session(&kp, "test@example.com", "TST", None);
311 assert!(validate_session(&token, &pk, "OTHER").is_err());
312 }
313
314 #[test]
315 fn tampered_session_rejected() {
316 let (kp, pk) = test_keypair();
317 let mut token = create_session(&kp, "test@example.com", "TST", None);
318 token.claims.member = "attacker@evil.com".into();
319 assert!(validate_session(&token, &pk, "TST").is_err());
320 }
321
322 #[test]
323 fn save_load_roundtrip() {
324 let (kp, pk) = test_keypair();
325 let token = create_session(&kp, "test@example.com", "TST", None);
326
327 let dir = tempdir().unwrap();
328 unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
331
332 save_session("TST", &token).unwrap();
333 let loaded = load_session("TST", "test@example.com").unwrap().unwrap();
334 let claims = validate_session(&loaded, &pk, "TST").unwrap();
335 assert_eq!(claims.member, "test@example.com");
336
337 remove_session("TST", "test@example.com").unwrap();
338 assert!(load_session("TST", "test@example.com").unwrap().is_none());
339
340 unsafe { std::env::remove_var("XDG_STATE_HOME") };
342 }
343}