Skip to main content

joy_core/auth/
session.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Session management for authenticated Joy operations.
5//!
6//! Sessions are time-limited tokens stored locally in `~/.config/joy/sessions/`.
7//! They prove that the user has entered their passphrase and derived the correct
8//! identity key within the configured time window.
9
10use 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/// Claims encoded in a session token.
19#[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    /// For AI sessions: the token_key that was used to create this session.
26    /// Used to invalidate sessions when the token is revoked/replaced.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub token_key: Option<String>,
29    /// Terminal device at session creation (e.g. "/dev/pts/1").
30    /// Human sessions are only valid from the same terminal.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub tty: Option<String>,
33}
34
35/// A session token: claims + Ed25519 signature.
36#[derive(Debug, Serialize, Deserialize)]
37pub struct SessionToken {
38    pub claims: SessionClaims,
39    /// Hex-encoded Ed25519 signature over the serialized claims.
40    pub signature: String,
41}
42
43/// Default session duration: 24 hours.
44const DEFAULT_TTL_HOURS: i64 = 24;
45
46/// Detect the current terminal device for session binding.
47///
48/// Returns a unique identifier for the terminal window/tab:
49/// - Unix: TTY device path (e.g. "/dev/pts/1") via libc::ttyname
50/// - Windows Terminal: WT_SESSION GUID (unique per tab/pane)
51/// - No terminal (CI, cron, etc.): None
52pub fn current_tty() -> Option<String> {
53    // Windows Terminal sets WT_SESSION to a unique GUID per tab/pane
54    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        // SAFETY: ttyname returns a pointer to a static buffer.
63        // We immediately copy it into a Rust String.
64        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
76/// Create a session token signed by the identity keypair.
77pub 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
86/// Create a session for an AI member, binding it to a specific token_key.
87pub 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    // Capture TTY for terminal-bound sessions (both human and AI).
113    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
130/// Validate a session token against a public key and project ID.
131pub fn validate_session(
132    token: &SessionToken,
133    public_key: &PublicKey,
134    project_id: &str,
135) -> Result<SessionClaims, JoyError> {
136    // Check project match
137    if token.claims.project_id != project_id {
138        return Err(JoyError::AuthFailed(
139            "session belongs to a different project".into(),
140        ));
141    }
142
143    // Check expiry
144    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    // Verify signature
151    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
159/// Directory for session files: `~/.local/state/joy/sessions/`
160fn session_dir() -> Result<PathBuf, JoyError> {
161    let state_dir = dirs_state_dir()?;
162    Ok(state_dir.join("joy").join("sessions"))
163}
164
165/// Session filename: SHA-256 hash of project_id + member.
166/// Deterministic but not human-readable (privacy).
167fn session_filename(project_id: &str, member: &str) -> String {
168    format!("{}.json", session_id(project_id, member))
169}
170
171/// The session ID: a short, deterministic, opaque identifier for a session.
172/// Used as `JOY_SESSION` environment variable (SSH-agent pattern).
173pub 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
183/// Save a session token to disk.
184pub 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    // Restrict to owner-only (session files contain signed claims)
197    #[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
209/// Load a session token from disk for a specific member, if it exists.
210pub 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
225/// Load a session by its opaque ID (the JOY_SESSION value).
226pub 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
241/// Remove a session token from disk for a specific member.
242pub 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
251/// Derive a stable project ID from project name and acronym.
252pub 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    // Use XDG_STATE_HOME or ~/.local/state
261    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        // Override session dir via env
329        // SAFETY: test is single-threaded, setting env var for session dir override
330        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        // SAFETY: test cleanup
341        unsafe { std::env::remove_var("XDG_STATE_HOME") };
342    }
343}