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