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::{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.
99///
100/// Per ADR-041 §6, the `token_expires` is an upper bound on the session's
101/// own expiry: `session.expires = min(session_ttl, token_expires)`. When
102/// the AI redeems a 30-minute Crypt token, the session must die after 30
103/// minutes too, regardless of the configured session TTL.
104pub fn create_session_for_ai(
105    ephemeral_keypair: &IdentityKeypair,
106    member: &str,
107    project_id: &str,
108    ttl: Option<Duration>,
109    delegation_key: &str,
110    token_expires: Option<DateTime<Utc>>,
111) -> SessionToken {
112    let now = Utc::now();
113    let ttl = ttl.unwrap_or_else(|| Duration::hours(DEFAULT_TTL_HOURS));
114    let session_expiry = now + ttl;
115    let expires = match token_expires {
116        Some(token_exp) if token_exp < session_expiry => token_exp,
117        _ => session_expiry,
118    };
119    let claims = SessionClaims {
120        member: member.to_string(),
121        project_id: project_id.to_string(),
122        created: now,
123        expires,
124        token_key: Some(delegation_key.to_string()),
125        session_public_key: Some(ephemeral_keypair.public_key().to_hex()),
126        tty: None,
127    };
128    let claims_json = serde_json::to_string(&claims).expect("claims serialize");
129    let signature = ephemeral_keypair.sign(claims_json.as_bytes());
130    SessionToken {
131        claims,
132        signature: hex::encode(signature),
133    }
134}
135
136fn create_session_with_token_key(
137    keypair: &IdentityKeypair,
138    member: &str,
139    project_id: &str,
140    ttl: Option<Duration>,
141    token_key: Option<String>,
142) -> SessionToken {
143    let now = Utc::now();
144    let ttl = ttl.unwrap_or_else(|| Duration::hours(DEFAULT_TTL_HOURS));
145    // Human sessions remain TTY-bound (ADR-023); AI sessions use the
146    // ephemeral-keypair path above.
147    let tty = current_tty();
148    let claims = SessionClaims {
149        member: member.to_string(),
150        project_id: project_id.to_string(),
151        created: now,
152        expires: now + ttl,
153        token_key,
154        session_public_key: None,
155        tty,
156    };
157    let claims_json = serde_json::to_string(&claims).expect("claims serialize");
158    let signature = keypair.sign(claims_json.as_bytes());
159    SessionToken {
160        claims,
161        signature: hex::encode(signature),
162    }
163}
164
165/// Validate a session token against a public key and project ID.
166pub fn validate_session(
167    token: &SessionToken,
168    public_key: &PublicKey,
169    project_id: &str,
170) -> Result<SessionClaims, JoyError> {
171    // Check project match
172    if token.claims.project_id != project_id {
173        return Err(JoyError::AuthFailed(
174            "session belongs to a different project".into(),
175        ));
176    }
177
178    // Check expiry
179    if Utc::now() > token.claims.expires {
180        return Err(JoyError::AuthFailed(
181            "session expired, run `joy auth` to re-authenticate".into(),
182        ));
183    }
184
185    // Verify signature
186    let claims_json = serde_json::to_string(&token.claims).expect("claims serialize");
187    let signature =
188        hex::decode(&token.signature).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
189    public_key.verify(claims_json.as_bytes(), &signature)?;
190
191    Ok(token.claims.clone())
192}
193
194/// Directory for session files: `~/.local/state/joy/sessions/`
195fn session_dir() -> Result<PathBuf, JoyError> {
196    let state_dir = dirs_state_dir()?;
197    Ok(state_dir.join("joy").join("sessions"))
198}
199
200/// Session filename: SHA-256 hash of project_id + member.
201/// Deterministic but not human-readable (privacy).
202fn session_filename(project_id: &str, member: &str) -> String {
203    format!("{}.json", session_id(project_id, member))
204}
205
206/// The session ID: a short, deterministic, opaque identifier for a session.
207/// Used as the filename stub for the session file and as part of the
208/// `JOY_SESSION` env var payload.
209pub fn session_id(project_id: &str, member: &str) -> String {
210    use sha2::{Digest, Sha256};
211    let mut hasher = Sha256::new();
212    hasher.update(project_id.as_bytes());
213    hasher.update(b":");
214    hasher.update(member.as_bytes());
215    let hash = hasher.finalize();
216    hex::encode(&hash[..SESSION_ID_LEN])
217}
218
219/// Prefix for the `JOY_SESSION` env var value (ADR-033).
220pub const SESSION_ENV_PREFIX: &str = "joy_s_";
221const SESSION_ID_LEN: usize = 12;
222const SESSION_PRIVATE_LEN: usize = 32;
223const DELEGATION_PRIVATE_LEN: usize = 32;
224
225/// Encode a `JOY_SESSION` env var value. Backward-compatible with sessions
226/// that carry only the ephemeral session key; pass `delegation_private =
227/// None` for that case.
228///
229/// Layout (base64-encoded):
230/// - Auth-only:    `[sid 12][session_priv 32]` (44 bytes)
231/// - Auth + Crypt: `[sid 12][session_priv 32][delegation_priv 32]` (76 bytes)
232///
233/// Per ADR-041 §5, the delegation private key is included exactly when the
234/// originating token had `crypt` scope. The AI's joy commands read the
235/// delegation key from this env var to unwrap zone keys; it never lives on
236/// disk.
237pub fn encode_session_env(sid_hex: &str, ephemeral_private: &[u8; SESSION_PRIVATE_LEN]) -> String {
238    encode_session_env_full(sid_hex, ephemeral_private, None)
239}
240
241/// Encode a `JOY_SESSION` env var value with an optional embedded
242/// delegation private key (ADR-041 §5).
243pub fn encode_session_env_full(
244    sid_hex: &str,
245    ephemeral_private: &[u8; SESSION_PRIVATE_LEN],
246    delegation_private: Option<&[u8; DELEGATION_PRIVATE_LEN]>,
247) -> String {
248    let sid_bytes = hex::decode(sid_hex).expect("session id must be valid hex");
249    assert_eq!(
250        sid_bytes.len(),
251        SESSION_ID_LEN,
252        "session id length mismatch"
253    );
254    let total_len = SESSION_ID_LEN
255        + SESSION_PRIVATE_LEN
256        + if delegation_private.is_some() {
257            DELEGATION_PRIVATE_LEN
258        } else {
259            0
260        };
261    let mut payload = Vec::with_capacity(total_len);
262    payload.extend_from_slice(&sid_bytes);
263    payload.extend_from_slice(ephemeral_private);
264    if let Some(dpk) = delegation_private {
265        payload.extend_from_slice(dpk);
266    }
267    use base64ct::{Base64, Encoding};
268    format!("{SESSION_ENV_PREFIX}{}", Base64::encode_string(&payload))
269}
270
271/// Parse a `JOY_SESSION` env var value produced by `encode_session_env`.
272/// Returns `(sid_hex, ephemeral_private_bytes)` or None on malformed input.
273/// Sessions with an embedded delegation private key (Crypt scope) parse
274/// successfully here too; use `parse_session_env_full` to access the
275/// delegation key.
276pub fn parse_session_env(env_value: &str) -> Option<(String, [u8; SESSION_PRIVATE_LEN])> {
277    let (sid, session_priv, _) = parse_session_env_full(env_value)?;
278    Some((sid, session_priv))
279}
280
281/// Parse a `JOY_SESSION` env var value, returning the session id, the
282/// ephemeral session private key, and (if the originating token was
283/// Crypt-scoped) the delegation private key embedded for zone-key unwrap
284/// (ADR-041 §5).
285pub fn parse_session_env_full(
286    env_value: &str,
287) -> Option<(
288    String,
289    [u8; SESSION_PRIVATE_LEN],
290    Option<[u8; DELEGATION_PRIVATE_LEN]>,
291)> {
292    let encoded = env_value.strip_prefix(SESSION_ENV_PREFIX)?;
293    use base64ct::{Base64, Encoding};
294    let payload = Base64::decode_vec(encoded).ok()?;
295    let auth_only_len = SESSION_ID_LEN + SESSION_PRIVATE_LEN;
296    let with_crypt_len = auth_only_len + DELEGATION_PRIVATE_LEN;
297    if payload.len() != auth_only_len && payload.len() != with_crypt_len {
298        return None;
299    }
300    let sid_hex = hex::encode(&payload[..SESSION_ID_LEN]);
301    let mut session_priv = [0u8; SESSION_PRIVATE_LEN];
302    session_priv.copy_from_slice(&payload[SESSION_ID_LEN..auth_only_len]);
303    let delegation_priv = if payload.len() == with_crypt_len {
304        let mut dpk = [0u8; DELEGATION_PRIVATE_LEN];
305        dpk.copy_from_slice(&payload[auth_only_len..]);
306        Some(dpk)
307    } else {
308        None
309    };
310    Some((sid_hex, session_priv, delegation_priv))
311}
312
313/// Save a session token to disk.
314pub fn save_session(project_id: &str, token: &SessionToken) -> Result<(), JoyError> {
315    let dir = session_dir()?;
316    std::fs::create_dir_all(&dir).map_err(|e| JoyError::CreateDir {
317        path: dir.clone(),
318        source: e,
319    })?;
320    let path = dir.join(session_filename(project_id, &token.claims.member));
321    let json = serde_json::to_string_pretty(token).expect("session serialize");
322    std::fs::write(&path, &json).map_err(|e| JoyError::WriteFile {
323        path: path.clone(),
324        source: e,
325    })?;
326    // Restrict to owner-only (session files contain signed claims)
327    #[cfg(unix)]
328    {
329        use std::os::unix::fs::PermissionsExt;
330        let perms = std::fs::Permissions::from_mode(0o600);
331        std::fs::set_permissions(&path, perms).map_err(|e| JoyError::WriteFile {
332            path: path.clone(),
333            source: e,
334        })?;
335    }
336    Ok(())
337}
338
339/// Load a session token from disk for a specific member, if it exists.
340pub fn load_session(project_id: &str, member: &str) -> Result<Option<SessionToken>, JoyError> {
341    let dir = session_dir()?;
342    let path = dir.join(session_filename(project_id, member));
343    if !path.exists() {
344        return Ok(None);
345    }
346    let json = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
347        path: path.clone(),
348        source: e,
349    })?;
350    let token: SessionToken =
351        serde_json::from_str(&json).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
352    Ok(Some(token))
353}
354
355/// Load a session by its opaque ID (the JOY_SESSION value).
356pub fn load_session_by_id(id: &str) -> Result<Option<SessionToken>, JoyError> {
357    let dir = session_dir()?;
358    let path = dir.join(format!("{id}.json"));
359    if !path.exists() {
360        return Ok(None);
361    }
362    let json = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
363        path: path.clone(),
364        source: e,
365    })?;
366    let token: SessionToken =
367        serde_json::from_str(&json).map_err(|e| JoyError::AuthFailed(format!("{e}")))?;
368    Ok(Some(token))
369}
370
371/// Remove a session token from disk for a specific member.
372pub fn remove_session(project_id: &str, member: &str) -> Result<(), JoyError> {
373    let dir = session_dir()?;
374    let path = dir.join(session_filename(project_id, member));
375    if path.exists() {
376        std::fs::remove_file(&path).map_err(|e| JoyError::WriteFile { path, source: e })?;
377    }
378    Ok(())
379}
380
381/// Derive a stable project ID from project name and acronym.
382pub fn project_id(root: &Path) -> Result<String, JoyError> {
383    let project = crate::store::load_project(root)?;
384    Ok(project_id_of(&project))
385}
386
387/// Same as `project_id` but operates on an in-memory `Project`. Useful when
388/// the caller needs both the pre- and post-mutation id (e.g. acronym
389/// rename migrations) without re-reading the yaml.
390pub fn project_id_of(project: &crate::model::Project) -> String {
391    project
392        .acronym
393        .clone()
394        .unwrap_or_else(|| project.name.to_lowercase().replace(' ', "-"))
395}
396
397pub(super) fn dirs_state_dir() -> Result<PathBuf, JoyError> {
398    // Use XDG_STATE_HOME or ~/.local/state
399    if let Ok(xdg) = std::env::var("XDG_STATE_HOME") {
400        return Ok(PathBuf::from(xdg));
401    }
402    if let Ok(home) = std::env::var("HOME") {
403        return Ok(PathBuf::from(home).join(".local").join("state"));
404    }
405    Err(JoyError::AuthFailed(
406        "cannot determine state directory".into(),
407    ))
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::auth::{derive_key, IdentityKeypair, PublicKey, Salt};
414    use tempfile::tempdir;
415
416    const TEST_PASSPHRASE: &str = "correct horse battery staple extra words";
417
418    fn test_keypair() -> (IdentityKeypair, PublicKey) {
419        let salt =
420            Salt::from_hex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
421                .unwrap();
422        let key = derive_key(TEST_PASSPHRASE, &salt).unwrap();
423        let kp = IdentityKeypair::from_derived_key(&key);
424        let pk = kp.public_key();
425        (kp, pk)
426    }
427
428    #[test]
429    fn create_and_validate_session() {
430        let (kp, pk) = test_keypair();
431        let token = create_session(&kp, "test@example.com", "TST", None);
432        let claims = validate_session(&token, &pk, "TST").unwrap();
433        assert_eq!(claims.member, "test@example.com");
434        assert_eq!(claims.project_id, "TST");
435    }
436
437    #[test]
438    fn expired_session_rejected() {
439        let (kp, pk) = test_keypair();
440        let token = create_session(&kp, "test@example.com", "TST", Some(Duration::seconds(-1)));
441        assert!(validate_session(&token, &pk, "TST").is_err());
442    }
443
444    #[test]
445    fn wrong_project_rejected() {
446        let (kp, pk) = test_keypair();
447        let token = create_session(&kp, "test@example.com", "TST", None);
448        assert!(validate_session(&token, &pk, "OTHER").is_err());
449    }
450
451    #[test]
452    fn tampered_session_rejected() {
453        let (kp, pk) = test_keypair();
454        let mut token = create_session(&kp, "test@example.com", "TST", None);
455        token.claims.member = "attacker@evil.com".into();
456        assert!(validate_session(&token, &pk, "TST").is_err());
457    }
458
459    #[test]
460    fn session_env_roundtrip() {
461        let sid = "0123456789abcdef01234567";
462        let private = [7u8; 32];
463        let encoded = encode_session_env(sid, &private);
464        assert!(encoded.starts_with(SESSION_ENV_PREFIX));
465        let (decoded_sid, decoded_priv) = parse_session_env(&encoded).unwrap();
466        assert_eq!(decoded_sid, sid);
467        assert_eq!(decoded_priv, private);
468    }
469
470    #[test]
471    fn parse_session_env_rejects_bad_inputs() {
472        assert!(parse_session_env("no_prefix_value").is_none());
473        assert!(parse_session_env("joy_s_!!!").is_none());
474        // wrong length
475        use base64ct::{Base64, Encoding};
476        let short = format!("{SESSION_ENV_PREFIX}{}", Base64::encode_string(&[1u8; 10]));
477        assert!(parse_session_env(&short).is_none());
478    }
479
480    #[test]
481    fn ai_session_carries_ephemeral_public_key() {
482        let ephemeral = IdentityKeypair::from_random();
483        let ephemeral_pk = ephemeral.public_key().to_hex();
484        let token = create_session_for_ai(&ephemeral, "ai:claude@joy", "TST", None, "dkey", None);
485        assert_eq!(
486            token.claims.session_public_key.as_deref(),
487            Some(ephemeral_pk.as_str())
488        );
489        assert_eq!(token.claims.token_key.as_deref(), Some("dkey"));
490        // Ensure the session signature validates against the ephemeral public key.
491        let pk = PublicKey::from_hex(&ephemeral_pk).unwrap();
492        validate_session(&token, &pk, "TST").unwrap();
493    }
494
495    #[test]
496    fn ai_session_clamped_to_token_expiry() {
497        // ADR-041 §6: a 30-minute token must not produce a 24h session.
498        let ephemeral = IdentityKeypair::from_random();
499        let token_expires = Utc::now() + Duration::minutes(30);
500        let token = create_session_for_ai(
501            &ephemeral,
502            "ai:claude@joy",
503            "TST",
504            None,
505            "dkey",
506            Some(token_expires),
507        );
508        // Session expiry should equal token_expires (within a tiny window).
509        let delta = (token.claims.expires - token_expires).num_seconds().abs();
510        assert!(delta < 2, "session expiry should match token expiry");
511    }
512
513    #[test]
514    fn ai_session_uses_session_ttl_when_token_lives_longer() {
515        let ephemeral = IdentityKeypair::from_random();
516        let token_expires = Utc::now() + Duration::days(7);
517        let token = create_session_for_ai(
518            &ephemeral,
519            "ai:claude@joy",
520            "TST",
521            Some(Duration::hours(1)),
522            "dkey",
523            Some(token_expires),
524        );
525        // Session expiry should be ~1h, not 7 days.
526        let session_ttl = token.claims.expires - token.claims.created;
527        assert!(
528            session_ttl <= Duration::hours(1),
529            "session must respect its own TTL when token lives longer"
530        );
531    }
532
533    #[test]
534    fn session_env_full_roundtrip_with_delegation() {
535        let sid = "0123456789abcdef01234567";
536        let session_priv = [7u8; 32];
537        let delegation_priv = [9u8; 32];
538        let encoded = encode_session_env_full(sid, &session_priv, Some(&delegation_priv));
539        let (decoded_sid, decoded_session, decoded_delegation) =
540            parse_session_env_full(&encoded).unwrap();
541        assert_eq!(decoded_sid, sid);
542        assert_eq!(decoded_session, session_priv);
543        assert_eq!(decoded_delegation, Some(delegation_priv));
544    }
545
546    #[test]
547    fn session_env_legacy_auth_only_still_parses() {
548        let sid = "0123456789abcdef01234567";
549        let session_priv = [7u8; 32];
550        let encoded = encode_session_env(sid, &session_priv);
551        let (decoded_sid, decoded_session, decoded_delegation) =
552            parse_session_env_full(&encoded).unwrap();
553        assert_eq!(decoded_sid, sid);
554        assert_eq!(decoded_session, session_priv);
555        assert!(decoded_delegation.is_none());
556    }
557
558    #[test]
559    fn save_load_roundtrip() {
560        let (kp, pk) = test_keypair();
561        let token = create_session(&kp, "test@example.com", "TST", None);
562
563        let dir = tempdir().unwrap();
564        // Override session dir via env
565        // SAFETY: test is single-threaded, setting env var for session dir override
566        unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
567
568        save_session("TST", &token).unwrap();
569        let loaded = load_session("TST", "test@example.com").unwrap().unwrap();
570        let claims = validate_session(&loaded, &pk, "TST").unwrap();
571        assert_eq!(claims.member, "test@example.com");
572
573        remove_session("TST", "test@example.com").unwrap();
574        assert!(load_session("TST", "test@example.com").unwrap().is_none());
575
576        // SAFETY: test cleanup
577        unsafe { std::env::remove_var("XDG_STATE_HOME") };
578    }
579}