Skip to main content

dsp_cli/actions/auth/
token.rs

1//! Action for `dsp auth token`.
2//!
3//! Prints the resolved bearer token **verbatim to stdout**, bare — no
4//! envelope, no `--format`, no `Renderer`. This is the **read-out**
5//! counterpart to `dsp auth set-token` (which reads a JWT from stdin into the
6//! cache): today `set-token` writes a token in, and this command is the only
7//! way to read one back out for use in shell pipelines (`export
8//! DSP_TOKEN=$(dsp auth token -s dev)`).
9//!
10//! No server round-trip is made — this is a pure cache/env inspection, like
11//! `dsp auth status` (see `status.rs`), from which the resolution and expiry
12//! logic below is lifted. Before printing, a **local, best-effort** expiry
13//! check is done: if the resolved token is locally detected as expired, or no
14//! token is cached for the server, the command returns
15//! [`Diagnostic::AuthRequired`] (exit `3`, dsp-cli/ADR-0012) instead of printing
16//! anything. This check is advisory only — the JWT signature is not verified
17//! (see `crate::client::jwt`) — so exit `0` means only "the token looks
18//! unexpired locally," not "the server will accept it." See dsp-cli/ADR-0007 for the
19//! token-resolution/precedence model this command reuses unchanged.
20
21use std::io::Write;
22use std::path::Path;
23
24use chrono::Utc;
25
26use crate::config::{AuthCache, Config, TokenOrigin, resolve_token};
27use crate::diagnostic::Diagnostic;
28
29/// Print the resolved bearer token for `cfg.server` to stdout.
30///
31/// Reads `DSP_TOKEN` from the environment, resolves the effective token via
32/// [`resolve_token`] (env wins over cache), and — unless the token is
33/// locally detected as expired or absent — writes it followed by a single
34/// `\n` to stdout. Returns [`Diagnostic::AuthRequired`] when no token is
35/// cached for the server or the resolved token is expired; never logs,
36/// formats, or otherwise displays the token value except via the final
37/// `writeln!`.
38pub fn run(cfg: &Config) -> Result<(), Diagnostic> {
39    let env_token = std::env::var("DSP_TOKEN").ok();
40    // Wrapped in `BrokenPipeWriter` so `dsp auth token | head` exits 0
41    // silently instead of surfacing a broken pipe as `Diagnostic::Internal`.
42    let mut out = crate::util::BrokenPipeWriter::new(std::io::stdout().lock());
43    run_impl(cfg, &mut out, None, env_token)
44}
45
46fn run_impl(
47    cfg: &Config,
48    out: &mut dyn Write,
49    cache_path: Option<&Path>,
50    env_token: Option<String>,
51) -> Result<(), Diagnostic> {
52    // dsp-cli/ADR-0007 says a non-blank `DSP_TOKEN` wins regardless of cache state.
53    // A corrupt or unreadable `auth.toml` therefore must not mask the env
54    // token: treat a cache-load failure as an empty cache when the env token
55    // would resolve. (Matches the trim-and-empty rule in `resolve_token`, and
56    // the identical fallthrough in `status.rs`.)
57    let env_token_would_win = env_token.as_deref().map(str::trim).map(|s| !s.is_empty()).unwrap_or(false);
58
59    let cache_result = match cache_path {
60        Some(p) => AuthCache::load_from(p),
61        None => AuthCache::load(),
62    };
63    let cache = match cache_result {
64        Ok(c) => c,
65        Err(e) if env_token_would_win => {
66            crate::util::warn_auth_cache_load_failed(&e, "DSP_TOKEN is set, falling through to env token");
67            AuthCache::default()
68        }
69        Err(e) => return Err(e),
70    };
71
72    match resolve_token(env_token, &cache, &cfg.server) {
73        None => Err(Diagnostic::AuthRequired(format!(
74            "no token for {}; run `dsp auth login`, pipe one to `dsp auth set-token`, or set DSP_TOKEN",
75            cfg.server
76        ))),
77        Some(resolved) => {
78            // Exhaustive match, no wildcard: a future TokenOrigin variant must
79            // fail to compile here rather than silently folding into the
80            // cache case.
81            let expires_at = match resolved.origin {
82                TokenOrigin::Env => crate::client::jwt::extract_exp(&resolved.token),
83                TokenOrigin::Cache => cache.expires_at(&cfg.server),
84            };
85            let expired = expires_at.map(|t| t < Utc::now()).unwrap_or(false);
86            if expired {
87                // The remedy differs by origin. A *cached* token is refreshed by
88                // `login`. But a `DSP_TOKEN` unconditionally overrides the cache
89                // (dsp-cli/ADR-0007), so `login` would NOT fix an expired env token — the
90                // freshly-cached token stays shadowed and the command keeps
91                // exiting 3; the caller must refresh or unset `DSP_TOKEN` instead.
92                let msg = match resolved.origin {
93                    TokenOrigin::Env => format!(
94                        "the DSP_TOKEN for {} has expired; export a fresh token or unset DSP_TOKEN",
95                        cfg.server
96                    ),
97                    TokenOrigin::Cache => format!(
98                        "the cached token for {} has expired; run `dsp auth login` to refresh",
99                        cfg.server
100                    ),
101                };
102                return Err(Diagnostic::AuthRequired(msg));
103            }
104            // Bare `?`: a stdout write failure (e.g. `dsp auth token | head`
105            // closing the pipe early) routes through the blanket
106            // `From<std::io::Error> for Diagnostic` to `Internal` (exit 1),
107            // exactly like `docs.rs`. Never hand-roll `Diagnostic::Io` here —
108            // that variant is reserved for explicit user-requested file
109            // writes, not injected-writer plumbing I/O.
110            writeln!(out, "{}", resolved.token)?;
111            Ok(())
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use std::path::Path;
119
120    use chrono::{TimeZone, Utc};
121    use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
122    use tempfile::TempDir;
123
124    use super::run_impl;
125    use crate::config::auth_cache::ServerEntry;
126    use crate::config::{AuthCache, Config};
127    use crate::diagnostic::Diagnostic;
128
129    // ── helpers ───────────────────────────────────────────────────────────────
130
131    fn make_cfg(server: &str) -> Config {
132        Config { server: server.to_string() }
133    }
134
135    fn fixed_future() -> chrono::DateTime<Utc> {
136        Utc.with_ymd_and_hms(2099, 1, 1, 0, 0, 0).unwrap()
137    }
138
139    fn fixed_past() -> chrono::DateTime<Utc> {
140        Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()
141    }
142
143    /// Produce a minimal JWT with the given JSON payload.
144    /// The secret is arbitrary — `extract_exp` disables signature validation.
145    fn make_jwt(payload: &serde_json::Value) -> String {
146        encode(&Header::new(Algorithm::HS256), payload, &EncodingKey::from_secret(b"unused"))
147            .expect("test JWT encoding should not fail")
148    }
149
150    fn make_jwt_with_exp(exp_ts: i64) -> String {
151        make_jwt(&serde_json::json!({ "exp": exp_ts }))
152    }
153
154    fn run_buf(cfg: &Config, cache_path: &Path, env_token: Option<String>) -> (Result<(), Diagnostic>, String) {
155        let mut buf: Vec<u8> = Vec::new();
156        let result = run_impl(cfg, &mut buf, Some(cache_path), env_token);
157        let out = String::from_utf8(buf).unwrap();
158        (result, out)
159    }
160
161    // ── (a) this command's unique behaviour — print vs. exit 3 ────────────────
162
163    #[test]
164    fn cached_unexpired_prints_token() {
165        let dir = TempDir::new().unwrap();
166        let cache_path = dir.path().join("auth.toml");
167        let cfg = make_cfg("https://api.test.dasch.swiss");
168
169        let mut cache = AuthCache::default();
170        cache.set_entry(
171            "https://api.test.dasch.swiss",
172            ServerEntry {
173                token: "tok-123".to_string(),
174                user: Some("u@x.test".to_string()),
175                acquired_at: None,
176                expires_at: Some(fixed_future()),
177            },
178        );
179        cache.save_to(&cache_path).unwrap();
180
181        let (result, out) = run_buf(&cfg, &cache_path, None);
182        assert!(result.is_ok(), "expected Ok, got {result:?}");
183        assert_eq!(out, "tok-123\n");
184    }
185
186    #[test]
187    fn cached_expired_produces_auth_required() {
188        let dir = TempDir::new().unwrap();
189        let cache_path = dir.path().join("auth.toml");
190        let cfg = make_cfg("https://api.test.dasch.swiss");
191
192        let mut cache = AuthCache::default();
193        cache.set_entry(
194            "https://api.test.dasch.swiss",
195            ServerEntry {
196                token: "old-tok".to_string(),
197                user: Some("u@x.test".to_string()),
198                acquired_at: None,
199                expires_at: Some(fixed_past()),
200            },
201        );
202        cache.save_to(&cache_path).unwrap();
203
204        let (result, out) = run_buf(&cfg, &cache_path, None);
205        let err = result.expect_err("expected AuthRequired for expired cached token");
206        let Diagnostic::AuthRequired(msg) = &err else {
207            panic!("expected AuthRequired, got {err:?}");
208        };
209        // Cache-origin remedy: log in to refresh the cached token.
210        assert!(
211            msg.contains("cached") && msg.contains("login"),
212            "cache-expired message should name the cached token and the login remedy: {msg}"
213        );
214        assert!(out.is_empty(), "nothing should be printed on error");
215    }
216
217    #[test]
218    fn no_cache_entry_produces_auth_required() {
219        let dir = TempDir::new().unwrap();
220        let cache_path = dir.path().join("auth.toml");
221        let cfg = make_cfg("https://api.test.dasch.swiss");
222
223        let (result, out) = run_buf(&cfg, &cache_path, None);
224        let err = result.expect_err("expected AuthRequired when no token is cached");
225        assert!(matches!(err, Diagnostic::AuthRequired(_)), "expected AuthRequired, got {err:?}");
226        assert!(out.is_empty(), "nothing should be printed on error");
227    }
228
229    #[test]
230    fn env_jwt_past_exp_produces_auth_required() {
231        let dir = TempDir::new().unwrap();
232        let cache_path = dir.path().join("auth.toml");
233        let cfg = make_cfg("https://api.test.dasch.swiss");
234
235        let token = make_jwt_with_exp(fixed_past().timestamp());
236        let (result, out) = run_buf(&cfg, &cache_path, Some(token));
237        let err = result.expect_err("expected AuthRequired for expired env token");
238        let Diagnostic::AuthRequired(msg) = &err else {
239            panic!("expected AuthRequired, got {err:?}");
240        };
241        // Env-origin remedy must reference DSP_TOKEN — NOT (only) `dsp auth
242        // login`, which cannot fix an expired env token (DSP_TOKEN overrides the
243        // cache, dsp-cli/ADR-0007). Guards against the origin-agnostic message regression.
244        assert!(
245            msg.contains("DSP_TOKEN"),
246            "env-expired message must reference DSP_TOKEN, not just the cache/login remedy: {msg}"
247        );
248        assert!(out.is_empty(), "nothing should be printed on error");
249    }
250
251    #[test]
252    fn env_non_jwt_prints_token() {
253        // D5: an undecodable/non-JWT env token has expires_at == None, so it
254        // is not treated as expired — it is printed.
255        let dir = TempDir::new().unwrap();
256        let cache_path = dir.path().join("auth.toml");
257        let cfg = make_cfg("https://api.test.dasch.swiss");
258
259        let (result, out) = run_buf(&cfg, &cache_path, Some("not-a-jwt".to_string()));
260        assert!(result.is_ok(), "expected Ok, got {result:?}");
261        assert_eq!(out, "not-a-jwt\n");
262    }
263
264    #[test]
265    fn cached_token_with_no_expiry_prints_token() {
266        // D5: a cached entry with expires_at == None is not treated as
267        // expired — it is printed.
268        let dir = TempDir::new().unwrap();
269        let cache_path = dir.path().join("auth.toml");
270        let cfg = make_cfg("https://api.test.dasch.swiss");
271
272        let mut cache = AuthCache::default();
273        cache.set_entry(
274            "https://api.test.dasch.swiss",
275            ServerEntry {
276                token: "opaque-tok".to_string(),
277                user: None,
278                acquired_at: None,
279                expires_at: None,
280            },
281        );
282        cache.save_to(&cache_path).unwrap();
283
284        let (result, out) = run_buf(&cfg, &cache_path, None);
285        assert!(result.is_ok(), "expected Ok, got {result:?}");
286        assert_eq!(out, "opaque-tok\n");
287    }
288
289    // ── (b) shared resolution logic (mirrors resolve_token/status) ────────────
290
291    #[test]
292    fn env_future_exp_wins_over_cache_prints_env_token() {
293        let dir = TempDir::new().unwrap();
294        let cache_path = dir.path().join("auth.toml");
295        let cfg = make_cfg("https://api.test.dasch.swiss");
296
297        let mut cache = AuthCache::default();
298        cache.set_entry(
299            "https://api.test.dasch.swiss",
300            ServerEntry {
301                token: "cache-tok".to_string(),
302                user: Some("u@cache.test".to_string()),
303                acquired_at: None,
304                expires_at: Some(fixed_future()),
305            },
306        );
307        cache.save_to(&cache_path).unwrap();
308
309        let env_token = make_jwt_with_exp(fixed_future().timestamp());
310        let (result, out) = run_buf(&cfg, &cache_path, Some(env_token.clone()));
311        assert!(result.is_ok(), "expected Ok, got {result:?}");
312        assert_eq!(out, format!("{env_token}\n"));
313    }
314
315    #[test]
316    fn whitespace_env_falls_through_to_valid_cache() {
317        let dir = TempDir::new().unwrap();
318        let cache_path = dir.path().join("auth.toml");
319        let cfg = make_cfg("https://api.test.dasch.swiss");
320
321        let mut cache = AuthCache::default();
322        cache.set_entry(
323            "https://api.test.dasch.swiss",
324            ServerEntry {
325                token: "cache-tok".to_string(),
326                user: Some("u@x.test".to_string()),
327                acquired_at: None,
328                expires_at: Some(fixed_future()),
329            },
330        );
331        cache.save_to(&cache_path).unwrap();
332
333        let (result, out) = run_buf(&cfg, &cache_path, Some("  ".to_string()));
334        assert!(result.is_ok(), "expected Ok, got {result:?}");
335        assert_eq!(out, "cache-tok\n");
336    }
337
338    #[test]
339    fn whitespace_env_with_expired_cache_produces_auth_required() {
340        let dir = TempDir::new().unwrap();
341        let cache_path = dir.path().join("auth.toml");
342        let cfg = make_cfg("https://api.test.dasch.swiss");
343
344        let mut cache = AuthCache::default();
345        cache.set_entry(
346            "https://api.test.dasch.swiss",
347            ServerEntry {
348                token: "old-tok".to_string(),
349                user: Some("u@x.test".to_string()),
350                acquired_at: None,
351                expires_at: Some(fixed_past()),
352            },
353        );
354        cache.save_to(&cache_path).unwrap();
355
356        let (result, out) = run_buf(&cfg, &cache_path, Some("  ".to_string()));
357        let err = result.expect_err("expected AuthRequired for expired cache fallthrough");
358        assert!(matches!(err, Diagnostic::AuthRequired(_)), "expected AuthRequired, got {err:?}");
359        assert!(out.is_empty(), "nothing should be printed on error");
360    }
361
362    #[test]
363    fn corrupt_cache_with_valid_env_token_prints_env_token() {
364        // dsp-cli/ADR-0007: DSP_TOKEN wins regardless of cache state. A corrupt
365        // auth.toml must not mask the env token.
366        let dir = TempDir::new().unwrap();
367        let cache_path = dir.path().join("auth.toml");
368        std::fs::write(&cache_path, b"not valid toml [[[").unwrap();
369        let cfg = make_cfg("https://api.test.dasch.swiss");
370
371        let env_token = make_jwt_with_exp(fixed_future().timestamp());
372        let (result, out) = run_buf(&cfg, &cache_path, Some(env_token.clone()));
373        assert!(result.is_ok(), "expected Ok, got {result:?}");
374        assert_eq!(out, format!("{env_token}\n"));
375    }
376
377    #[test]
378    fn corrupt_cache_without_env_token_propagates_error() {
379        // Without an env token, a corrupt cache must propagate the load error
380        // rather than being swallowed into AuthRequired — the user needs to
381        // learn the cache is broken (contrast case: cached_expired above,
382        // which is AuthRequired, not a propagated load error).
383        let dir = TempDir::new().unwrap();
384        let cache_path = dir.path().join("auth.toml");
385        std::fs::write(&cache_path, b"not valid toml [[[").unwrap();
386        let cfg = make_cfg("https://api.test.dasch.swiss");
387
388        let (result, out) = run_buf(&cfg, &cache_path, None);
389        let err = result.expect_err("expected the load error to propagate");
390        assert!(
391            !matches!(err, Diagnostic::AuthRequired(_)),
392            "corrupt-cache-without-env error must NOT be AuthRequired, got {err:?}"
393        );
394        assert!(out.is_empty(), "nothing should be printed on error");
395    }
396
397    // ── (c) secret hygiene ──────────────────────────────────────────────────
398
399    #[test]
400    fn printed_bytes_equal_token_exactly() {
401        let dir = TempDir::new().unwrap();
402        let cache_path = dir.path().join("auth.toml");
403        let cfg = make_cfg("https://api.test.dasch.swiss");
404
405        const SECRET: &str = "super-secret-bearer-token-xyz";
406        let mut cache = AuthCache::default();
407        cache.set_entry(
408            "https://api.test.dasch.swiss",
409            ServerEntry {
410                token: SECRET.to_string(),
411                user: None,
412                acquired_at: None,
413                expires_at: Some(fixed_future()),
414            },
415        );
416        cache.save_to(&cache_path).unwrap();
417
418        let (result, out) = run_buf(&cfg, &cache_path, None);
419        assert!(result.is_ok(), "expected Ok, got {result:?}");
420        assert_eq!(out, format!("{SECRET}\n"), "output must be exactly the token plus one newline");
421    }
422
423    #[test]
424    fn exit_3_error_messages_never_contain_the_token() {
425        const SECRET: &str = "super-secret-bearer-token-abc";
426
427        // expired cache path
428        let dir = TempDir::new().unwrap();
429        let cache_path = dir.path().join("auth.toml");
430        let cfg = make_cfg("https://api.test.dasch.swiss");
431        let mut cache = AuthCache::default();
432        cache.set_entry(
433            "https://api.test.dasch.swiss",
434            ServerEntry {
435                token: SECRET.to_string(),
436                user: None,
437                acquired_at: None,
438                expires_at: Some(fixed_past()),
439            },
440        );
441        cache.save_to(&cache_path).unwrap();
442        let (result, out) = run_buf(&cfg, &cache_path, None);
443        let err = result.expect_err("expected AuthRequired");
444        assert!(matches!(err, Diagnostic::AuthRequired(_)));
445        assert!(out.is_empty(), "nothing must be written on the exit-3 path");
446        assert!(
447            !err.to_string().contains(SECRET),
448            "expired cache-token error message must not contain the token: {err}"
449        );
450
451        // Expired env-token (DSP_TOKEN) path — the JWT string is itself the
452        // credential, so a leak would surface it verbatim in the message. This
453        // exercises the *env* origin's exit-3 path directly (case 4), rather
454        // than leaning on the shared error-construction code with the cache case.
455        let env_jwt = make_jwt_with_exp(fixed_past().timestamp());
456        let dir2 = TempDir::new().unwrap();
457        let cache_path2 = dir2.path().join("auth.toml"); // empty cache → env wins
458        let (result2, out2) = run_buf(&cfg, &cache_path2, Some(env_jwt.clone()));
459        let err2 = result2.expect_err("expected AuthRequired for expired env token");
460        assert!(matches!(err2, Diagnostic::AuthRequired(_)));
461        assert!(out2.is_empty(), "nothing must be written on the exit-3 path");
462        assert!(
463            !err2.to_string().contains(&env_jwt),
464            "expired env-token error message must not contain the token: {err2}"
465        );
466    }
467}