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