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 delegation_key this session was bound to at creation.
26    /// Rotating the delegation invalidates the session. Field name kept as
27    /// `token_key` for on-disk compatibility with already-written sessions.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub token_key: Option<String>,
30    /// For AI sessions (ADR-033): the ephemeral public key whose matching
31    /// private key lives only in the `JOY_SESSION` env var. Validation
32    /// requires the caller to possess that private key, binding the session
33    /// to the terminal environment it was created in.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub session_public_key: Option<String>,
36    /// Terminal device at session creation (e.g. "/dev/pts/1").
37    /// Human sessions are only valid from the same terminal.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub tty: Option<String>,
40}
41
42/// A session token: claims + Ed25519 signature.
43#[derive(Debug, Serialize, Deserialize)]
44pub struct SessionToken {
45    pub claims: SessionClaims,
46    /// Hex-encoded Ed25519 signature over the serialized claims.
47    pub signature: String,
48}
49
50/// Default session duration: 24 hours.
51const DEFAULT_TTL_HOURS: i64 = 24;
52
53/// Detect the current terminal device for session binding.
54///
55/// Returns a unique identifier for the terminal window/tab:
56/// - Unix: TTY device path (e.g. "/dev/pts/1") via libc::ttyname
57/// - Windows Terminal: WT_SESSION GUID (unique per tab/pane)
58/// - No terminal (CI, cron, etc.): None
59pub fn current_tty() -> Option<String> {
60    // Windows Terminal sets WT_SESSION to a unique GUID per tab/pane
61    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        // SAFETY: ttyname returns a pointer to a static buffer.
70        // We immediately copy it into a Rust String.
71        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
83/// Create a session token signed by the identity keypair.
84pub 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
93/// Create a session for an AI member with an ephemeral keypair (ADR-033).
94///
95/// The `ephemeral_keypair`'s public counterpart is recorded in the session
96/// claims; the matching private key must live in the `JOY_SESSION` env var
97/// of the caller. `delegation_key` is the hex-encoded public key of the
98/// stable ai_delegations entry; rotating that key invalidates the session.
99pub 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    // Human sessions remain TTY-bound (ADR-023); AI sessions use the
135    // ephemeral-keypair path above.
136    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
154/// Validate a session token against a public key and project ID.
155pub fn validate_session(
156    token: &SessionToken,
157    public_key: &PublicKey,
158    project_id: &str,
159) -> Result<SessionClaims, JoyError> {
160    // Check project match
161    if token.claims.project_id != project_id {
162        return Err(JoyError::AuthFailed(
163            "session belongs to a different project".into(),
164        ));
165    }
166
167    // Check expiry
168    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    // Verify signature
175    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
183/// Directory for session files: `~/.local/state/joy/sessions/`
184fn session_dir() -> Result<PathBuf, JoyError> {
185    let state_dir = dirs_state_dir()?;
186    Ok(state_dir.join("joy").join("sessions"))
187}
188
189/// Session filename: SHA-256 hash of project_id + member.
190/// Deterministic but not human-readable (privacy).
191fn session_filename(project_id: &str, member: &str) -> String {
192    format!("{}.json", session_id(project_id, member))
193}
194
195/// The session ID: a short, deterministic, opaque identifier for a session.
196/// Used as the filename stub for the session file and as part of the
197/// `JOY_SESSION` env var payload.
198pub 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
208/// Prefix for the `JOY_SESSION` env var value (ADR-033).
209pub const SESSION_ENV_PREFIX: &str = "joy_s_";
210const SESSION_ID_LEN: usize = 12;
211const SESSION_PRIVATE_LEN: usize = 32;
212
213/// Encode a `JOY_SESSION` env var value from the session id (hex) and the
214/// 32-byte ephemeral private key. Layout of the decoded payload:
215/// `[sid_raw 12 bytes][ephemeral_private 32 bytes]`, base64-encoded.
216pub 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
230/// Parse a `JOY_SESSION` env var value produced by `encode_session_env`.
231/// Returns `(sid_hex, ephemeral_private_bytes)` or None on malformed input.
232pub 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
245/// Save a session token to disk.
246pub 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    // Restrict to owner-only (session files contain signed claims)
259    #[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
271/// Load a session token from disk for a specific member, if it exists.
272pub 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
287/// Load a session by its opaque ID (the JOY_SESSION value).
288pub 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
303/// Remove a session token from disk for a specific member.
304pub 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
313/// Derive a stable project ID from project name and acronym.
314pub fn project_id(root: &Path) -> Result<String, JoyError> {
315    let project = crate::store::load_project(root)?;
316    Ok(project
317        .acronym
318        .unwrap_or_else(|| project.name.to_lowercase().replace(' ', "-")))
319}
320
321pub(super) fn dirs_state_dir() -> Result<PathBuf, JoyError> {
322    // Use XDG_STATE_HOME or ~/.local/state
323    if let Ok(xdg) = std::env::var("XDG_STATE_HOME") {
324        return Ok(PathBuf::from(xdg));
325    }
326    if let Ok(home) = std::env::var("HOME") {
327        return Ok(PathBuf::from(home).join(".local").join("state"));
328    }
329    Err(JoyError::AuthFailed(
330        "cannot determine state directory".into(),
331    ))
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::auth::{derive, sign};
338    use tempfile::tempdir;
339
340    const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
341
342    fn test_keypair() -> (sign::IdentityKeypair, sign::PublicKey) {
343        let salt = derive::Salt::from_hex(
344            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
345        )
346        .unwrap();
347        let key = derive::derive_key(TEST_PASSPHRASE, &salt).unwrap();
348        let kp = sign::IdentityKeypair::from_derived_key(&key);
349        let pk = kp.public_key();
350        (kp, pk)
351    }
352
353    #[test]
354    fn create_and_validate_session() {
355        let (kp, pk) = test_keypair();
356        let token = create_session(&kp, "test@example.com", "TST", None);
357        let claims = validate_session(&token, &pk, "TST").unwrap();
358        assert_eq!(claims.member, "test@example.com");
359        assert_eq!(claims.project_id, "TST");
360    }
361
362    #[test]
363    fn expired_session_rejected() {
364        let (kp, pk) = test_keypair();
365        let token = create_session(&kp, "test@example.com", "TST", Some(Duration::seconds(-1)));
366        assert!(validate_session(&token, &pk, "TST").is_err());
367    }
368
369    #[test]
370    fn wrong_project_rejected() {
371        let (kp, pk) = test_keypair();
372        let token = create_session(&kp, "test@example.com", "TST", None);
373        assert!(validate_session(&token, &pk, "OTHER").is_err());
374    }
375
376    #[test]
377    fn tampered_session_rejected() {
378        let (kp, pk) = test_keypair();
379        let mut token = create_session(&kp, "test@example.com", "TST", None);
380        token.claims.member = "attacker@evil.com".into();
381        assert!(validate_session(&token, &pk, "TST").is_err());
382    }
383
384    #[test]
385    fn session_env_roundtrip() {
386        let sid = "0123456789abcdef01234567";
387        let private = [7u8; 32];
388        let encoded = encode_session_env(sid, &private);
389        assert!(encoded.starts_with(SESSION_ENV_PREFIX));
390        let (decoded_sid, decoded_priv) = parse_session_env(&encoded).unwrap();
391        assert_eq!(decoded_sid, sid);
392        assert_eq!(decoded_priv, private);
393    }
394
395    #[test]
396    fn parse_session_env_rejects_bad_inputs() {
397        assert!(parse_session_env("no_prefix_value").is_none());
398        assert!(parse_session_env("joy_s_!!!").is_none());
399        // wrong length
400        use base64ct::{Base64, Encoding};
401        let short = format!("{SESSION_ENV_PREFIX}{}", Base64::encode_string(&[1u8; 10]));
402        assert!(parse_session_env(&short).is_none());
403    }
404
405    #[test]
406    fn ai_session_carries_ephemeral_public_key() {
407        let ephemeral = sign::IdentityKeypair::from_random();
408        let ephemeral_pk = ephemeral.public_key().to_hex();
409        let token = create_session_for_ai(&ephemeral, "ai:claude@joy", "TST", None, "dkey");
410        assert_eq!(
411            token.claims.session_public_key.as_deref(),
412            Some(ephemeral_pk.as_str())
413        );
414        assert_eq!(token.claims.token_key.as_deref(), Some("dkey"));
415        // Ensure the session signature validates against the ephemeral public key.
416        let pk = sign::PublicKey::from_hex(&ephemeral_pk).unwrap();
417        validate_session(&token, &pk, "TST").unwrap();
418    }
419
420    #[test]
421    fn save_load_roundtrip() {
422        let (kp, pk) = test_keypair();
423        let token = create_session(&kp, "test@example.com", "TST", None);
424
425        let dir = tempdir().unwrap();
426        // Override session dir via env
427        // SAFETY: test is single-threaded, setting env var for session dir override
428        unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
429
430        save_session("TST", &token).unwrap();
431        let loaded = load_session("TST", "test@example.com").unwrap().unwrap();
432        let claims = validate_session(&loaded, &pk, "TST").unwrap();
433        assert_eq!(claims.member, "test@example.com");
434
435        remove_session("TST", "test@example.com").unwrap();
436        assert!(load_session("TST", "test@example.com").unwrap().is_none());
437
438        // SAFETY: test cleanup
439        unsafe { std::env::remove_var("XDG_STATE_HOME") };
440    }
441}