Skip to main content

joy_core/auth/
consumed.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Single-use delegation token tracking (ADR-033).
5//!
6//! `joy auth --token` records each successfully redeemed token's id in
7//! `~/.local/state/joy/consumed-tokens.json` so that a second redemption
8//! attempt for the same token id is rejected as replay.
9//!
10//! Entries are garbage-collected lazily on every write: once the stored
11//! token expiry is in the past, the entry can be dropped because any
12//! replay attempt would be rejected by the expiry check anyway.
13
14use std::path::PathBuf;
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18
19use super::session::dirs_state_dir;
20use crate::error::JoyError;
21
22/// A single consumed token entry.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct ConsumedEntry {
25    pub token_id: String,
26    pub redeemed_at: DateTime<Utc>,
27    /// Token expiry captured from the DelegationClaims. Entries with
28    /// `expires_at` in the past can be GC'd because the expiry check
29    /// would reject the replay before the consumed check is reached.
30    /// For tokens issued without expiry, this is None and the entry is
31    /// kept indefinitely.
32    pub expires_at: Option<DateTime<Utc>>,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36struct ConsumedFile {
37    #[serde(default)]
38    entries: Vec<ConsumedEntry>,
39}
40
41fn consumed_path() -> Result<PathBuf, JoyError> {
42    Ok(dirs_state_dir()?.join("joy").join("consumed-tokens.json"))
43}
44
45fn load() -> Result<ConsumedFile, JoyError> {
46    let path = consumed_path()?;
47    if !path.exists() {
48        return Ok(ConsumedFile::default());
49    }
50    let data = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
51        path: path.clone(),
52        source: e,
53    })?;
54    // Tolerate a corrupt or empty file: start over rather than locking the
55    // user out of fresh auth because of an unrelated on-disk mishap.
56    Ok(serde_json::from_str(&data).unwrap_or_default())
57}
58
59fn save(file: &ConsumedFile) -> Result<(), JoyError> {
60    let path = consumed_path()?;
61    if let Some(parent) = path.parent() {
62        std::fs::create_dir_all(parent).map_err(|e| JoyError::CreateDir {
63            path: parent.to_path_buf(),
64            source: e,
65        })?;
66    }
67    let data = serde_json::to_string_pretty(file).expect("consumed tokens serialize");
68    std::fs::write(&path, data).map_err(|e| JoyError::WriteFile {
69        path: path.clone(),
70        source: e,
71    })?;
72    #[cfg(unix)]
73    {
74        use std::os::unix::fs::PermissionsExt;
75        let perms = std::fs::Permissions::from_mode(0o600);
76        let _ = std::fs::set_permissions(&path, perms);
77    }
78    Ok(())
79}
80
81fn gc(file: &mut ConsumedFile) {
82    let now = Utc::now();
83    file.entries
84        .retain(|e| e.expires_at.map(|exp| exp > now).unwrap_or(true));
85}
86
87/// Return the redemption timestamp if this token_id has already been consumed.
88pub fn is_consumed(token_id: &str) -> Result<Option<DateTime<Utc>>, JoyError> {
89    let file = load()?;
90    Ok(file
91        .entries
92        .iter()
93        .find(|e| e.token_id == token_id)
94        .map(|e| e.redeemed_at))
95}
96
97/// Record a successful redemption. Lazily garbage-collects entries whose
98/// token expiry has passed.
99pub fn mark_consumed(token_id: &str, expires_at: Option<DateTime<Utc>>) -> Result<(), JoyError> {
100    let mut file = load()?;
101    gc(&mut file);
102    if !file.entries.iter().any(|e| e.token_id == token_id) {
103        file.entries.push(ConsumedEntry {
104            token_id: token_id.to_string(),
105            redeemed_at: Utc::now(),
106            expires_at,
107        });
108    }
109    save(&file)
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use chrono::Duration;
116    use tempfile::tempdir;
117
118    fn with_state_dir<F: FnOnce()>(f: F) {
119        let _guard = super::super::STATE_ENV_LOCK
120            .lock()
121            .unwrap_or_else(|e| e.into_inner());
122        let dir = tempdir().unwrap();
123        // SAFETY: serialized via ENV_LOCK
124        unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
125        f();
126        // SAFETY: serialized via ENV_LOCK
127        unsafe { std::env::remove_var("XDG_STATE_HOME") };
128    }
129
130    #[test]
131    fn fresh_token_is_not_consumed() {
132        with_state_dir(|| {
133            assert!(is_consumed("unseen").unwrap().is_none());
134        });
135    }
136
137    #[test]
138    fn marking_consumed_is_observable() {
139        with_state_dir(|| {
140            mark_consumed("abc", Some(Utc::now() + Duration::hours(2))).unwrap();
141            assert!(is_consumed("abc").unwrap().is_some());
142        });
143    }
144
145    #[test]
146    fn remarking_same_id_is_idempotent() {
147        with_state_dir(|| {
148            let exp = Utc::now() + Duration::hours(2);
149            mark_consumed("abc", Some(exp)).unwrap();
150            mark_consumed("abc", Some(exp)).unwrap();
151            let file = load().unwrap();
152            let matches: Vec<&ConsumedEntry> = file
153                .entries
154                .iter()
155                .filter(|e| e.token_id == "abc")
156                .collect();
157            assert_eq!(matches.len(), 1);
158        });
159    }
160
161    #[test]
162    fn gc_drops_expired_entries_on_write() {
163        with_state_dir(|| {
164            let past = Utc::now() - Duration::hours(3);
165            let future = Utc::now() + Duration::hours(1);
166            // Seed the log manually so the "expired" entry has a timestamp
167            // that is unambiguously in the past when we next write.
168            let mut file = ConsumedFile::default();
169            file.entries.push(ConsumedEntry {
170                token_id: "old".into(),
171                redeemed_at: past - Duration::hours(1),
172                expires_at: Some(past),
173            });
174            file.entries.push(ConsumedEntry {
175                token_id: "new".into(),
176                redeemed_at: Utc::now(),
177                expires_at: Some(future),
178            });
179            save(&file).unwrap();
180
181            // Any new mark_consumed triggers GC of the expired entry.
182            mark_consumed("fresh", Some(future)).unwrap();
183            let file = load().unwrap();
184            let ids: Vec<&str> = file.entries.iter().map(|e| e.token_id.as_str()).collect();
185            assert!(!ids.contains(&"old"), "expired entry should be GC'd");
186            assert!(ids.contains(&"new"));
187            assert!(ids.contains(&"fresh"));
188        });
189    }
190
191    #[test]
192    fn entry_without_expiry_is_kept() {
193        with_state_dir(|| {
194            mark_consumed("no-expiry", None).unwrap();
195            mark_consumed("other", Some(Utc::now() + Duration::hours(1))).unwrap();
196            assert!(is_consumed("no-expiry").unwrap().is_some());
197        });
198    }
199}