Skip to main content

dsp_cli/config/
token.rs

1//! Token resolution — resolves the effective bearer token from env or cache.
2//!
3//! See dsp-cli/ADR-0007 for the full resolution order (flag → env → `.env` → cache).
4//! In v1 there is no `--token` flag, so the effective order is **env → cache**.
5
6use std::fmt;
7
8use super::AuthCache;
9
10/// Where the effective bearer token came from.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum TokenOrigin {
13    /// Supplied via the `DSP_TOKEN` environment variable.
14    Env,
15    /// Read from the on-disk auth cache (`auth.toml`).
16    Cache,
17}
18
19/// The bearer token a command should use for a server, plus its origin.
20///
21/// Holds a secret — `Debug` is manual and redacts the token value (mirrors
22/// the `ServerEntry` pattern in `auth_cache.rs`).
23pub struct ResolvedToken {
24    /// The raw bearer token string.
25    ///
26    /// Do NOT log or display this field. Use it only to build the
27    /// `Authorization` header or to read the `exp` claim for display.
28    /// `pub(crate)` enforces that contract structurally — the secret cannot be
29    /// read by any out-of-crate consumer, only by in-crate callers (the status
30    /// action, the `dsp auth token` action — which prints it verbatim to
31    /// stdout, its one sanctioned purpose — and the bearer-header plumbing for
32    /// data commands such as `dump` and `list`).
33    pub(crate) token: String,
34    /// Where the token came from.
35    pub origin: TokenOrigin,
36}
37
38impl fmt::Debug for ResolvedToken {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.debug_struct("ResolvedToken")
41            .field("token", &"[REDACTED]")
42            .field("origin", &self.origin)
43            .finish()
44    }
45}
46
47/// Resolve the effective bearer token for `server`.
48///
49/// Precedence per dsp-cli/ADR-0007: a non-blank `DSP_TOKEN` env token wins over any
50/// cached token; a blank or unset `DSP_TOKEN` falls through to the cache.
51/// Returns `None` when neither source has a token.
52///
53/// The env token is server-agnostic and wins regardless of the cache contents
54/// for any server. Surrounding whitespace in the env value is trimmed: a JWT
55/// never contains whitespace, so `DSP_TOKEN="   "` is treated as absent.
56pub fn resolve_token(env_token: Option<String>, cache: &AuthCache, server: &str) -> Option<ResolvedToken> {
57    let t = env_token.as_deref().map(str::trim).unwrap_or("");
58    if !t.is_empty() {
59        return Some(ResolvedToken { token: t.to_owned(), origin: TokenOrigin::Env });
60    }
61    if let Some(c) = cache.token(server) {
62        return Some(ResolvedToken { token: c.to_owned(), origin: TokenOrigin::Cache });
63    }
64    None
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    const SERVER: &str = "https://api.dasch.swiss";
72    const OTHER_SERVER: &str = "https://api.stage.dasch.swiss";
73    const ENV_TOKEN: &str = "env-token-abc";
74    const CACHE_TOKEN: &str = "cache-token-xyz";
75
76    fn cache_with_token(server: &str, token: &str) -> AuthCache {
77        let mut cache = AuthCache::default();
78        cache.set_token(server.to_string(), token.to_string());
79        cache
80    }
81
82    /// 1. env non-empty wins over a populated cache for the same server.
83    #[test]
84    fn env_nonempty_wins_over_cache() {
85        let cache = cache_with_token(SERVER, CACHE_TOKEN);
86        let result = resolve_token(Some(ENV_TOKEN.to_string()), &cache, SERVER);
87        let rt = result.unwrap();
88        assert_eq!(rt.origin, TokenOrigin::Env);
89        assert_eq!(rt.token, ENV_TOKEN);
90    }
91
92    /// 2a. env whitespace-only falls through; cache present → origin Cache.
93    #[test]
94    fn env_whitespace_only_falls_through_to_cache() {
95        let cache = cache_with_token(SERVER, CACHE_TOKEN);
96        let result = resolve_token(Some("   ".to_string()), &cache, SERVER);
97        let rt = result.unwrap();
98        assert_eq!(rt.origin, TokenOrigin::Cache);
99        assert_eq!(rt.token, CACHE_TOKEN);
100    }
101
102    /// 2b. env whitespace-only with no cache → None.
103    #[test]
104    fn env_whitespace_only_no_cache_returns_none() {
105        let cache = AuthCache::default();
106        let result = resolve_token(Some("   ".to_string()), &cache, SERVER);
107        assert!(result.is_none());
108    }
109
110    /// 3a. env empty string ("") falls through; cache present → origin Cache.
111    #[test]
112    fn env_empty_string_falls_through_to_cache() {
113        let cache = cache_with_token(SERVER, CACHE_TOKEN);
114        let result = resolve_token(Some(String::new()), &cache, SERVER);
115        let rt = result.unwrap();
116        assert_eq!(rt.origin, TokenOrigin::Cache);
117        assert_eq!(rt.token, CACHE_TOKEN);
118    }
119
120    /// 3b. env empty string with no cache → None.
121    #[test]
122    fn env_empty_string_no_cache_returns_none() {
123        let cache = AuthCache::default();
124        let result = resolve_token(Some(String::new()), &cache, SERVER);
125        assert!(result.is_none());
126    }
127
128    /// 4. env None + cache has token → origin Cache.
129    #[test]
130    fn env_none_uses_cache() {
131        let cache = cache_with_token(SERVER, CACHE_TOKEN);
132        let result = resolve_token(None, &cache, SERVER);
133        let rt = result.unwrap();
134        assert_eq!(rt.origin, TokenOrigin::Cache);
135        assert_eq!(rt.token, CACHE_TOKEN);
136    }
137
138    /// 5. env set + cache has a token for a DIFFERENT server → still origin Env (server-agnostic).
139    #[test]
140    fn env_set_cache_for_different_server_still_env() {
141        let cache = cache_with_token(OTHER_SERVER, CACHE_TOKEN);
142        let result = resolve_token(Some(ENV_TOKEN.to_string()), &cache, SERVER);
143        let rt = result.unwrap();
144        assert_eq!(rt.origin, TokenOrigin::Env);
145        assert_eq!(rt.token, ENV_TOKEN);
146    }
147
148    /// 6. env set + empty cache → origin Env.
149    #[test]
150    fn env_set_empty_cache_returns_env() {
151        let cache = AuthCache::default();
152        let result = resolve_token(Some(ENV_TOKEN.to_string()), &cache, SERVER);
153        let rt = result.unwrap();
154        assert_eq!(rt.origin, TokenOrigin::Env);
155        assert_eq!(rt.token, ENV_TOKEN);
156    }
157
158    /// 7. both absent (env None + empty cache) → None.
159    #[test]
160    fn both_absent_returns_none() {
161        let cache = AuthCache::default();
162        let result = resolve_token(None, &cache, SERVER);
163        assert!(result.is_none());
164    }
165
166    /// 8. Debug-redaction: the token value must not appear in the Debug output.
167    #[test]
168    fn debug_redacts_token() {
169        let secret = "super-secret-bearer-token";
170        let rt = ResolvedToken { token: secret.to_string(), origin: TokenOrigin::Env };
171        let rendered = format!("{rt:?}");
172        assert!(!rendered.contains(secret), "Debug impl leaked the token: {rendered}");
173        assert!(
174            rendered.contains("REDACTED"),
175            "expected redaction marker in Debug output, got: {rendered}"
176        );
177    }
178}