Skip to main content

dsp_cli/config/
token.rs

1//! Token resolution — resolves the effective bearer token from env or cache.
2//!
3//! See 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 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(
57    env_token: Option<String>,
58    cache: &AuthCache,
59    server: &str,
60) -> Option<ResolvedToken> {
61    let t = env_token.as_deref().map(str::trim).unwrap_or("");
62    if !t.is_empty() {
63        return Some(ResolvedToken {
64            token: t.to_owned(),
65            origin: TokenOrigin::Env,
66        });
67    }
68    if let Some(c) = cache.token(server) {
69        return Some(ResolvedToken {
70            token: c.to_owned(),
71            origin: TokenOrigin::Cache,
72        });
73    }
74    None
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    const SERVER: &str = "https://api.dasch.swiss";
82    const OTHER_SERVER: &str = "https://api.stage.dasch.swiss";
83    const ENV_TOKEN: &str = "env-token-abc";
84    const CACHE_TOKEN: &str = "cache-token-xyz";
85
86    fn cache_with_token(server: &str, token: &str) -> AuthCache {
87        let mut cache = AuthCache::default();
88        cache.set_token(server.to_string(), token.to_string());
89        cache
90    }
91
92    /// 1. env non-empty wins over a populated cache for the same server.
93    #[test]
94    fn env_nonempty_wins_over_cache() {
95        let cache = cache_with_token(SERVER, CACHE_TOKEN);
96        let result = resolve_token(Some(ENV_TOKEN.to_string()), &cache, SERVER);
97        let rt = result.unwrap();
98        assert_eq!(rt.origin, TokenOrigin::Env);
99        assert_eq!(rt.token, ENV_TOKEN);
100    }
101
102    /// 2a. env whitespace-only falls through; cache present → origin Cache.
103    #[test]
104    fn env_whitespace_only_falls_through_to_cache() {
105        let cache = cache_with_token(SERVER, CACHE_TOKEN);
106        let result = resolve_token(Some("   ".to_string()), &cache, SERVER);
107        let rt = result.unwrap();
108        assert_eq!(rt.origin, TokenOrigin::Cache);
109        assert_eq!(rt.token, CACHE_TOKEN);
110    }
111
112    /// 2b. env whitespace-only with no cache → None.
113    #[test]
114    fn env_whitespace_only_no_cache_returns_none() {
115        let cache = AuthCache::default();
116        let result = resolve_token(Some("   ".to_string()), &cache, SERVER);
117        assert!(result.is_none());
118    }
119
120    /// 3a. env empty string ("") falls through; cache present → origin Cache.
121    #[test]
122    fn env_empty_string_falls_through_to_cache() {
123        let cache = cache_with_token(SERVER, CACHE_TOKEN);
124        let result = resolve_token(Some(String::new()), &cache, SERVER);
125        let rt = result.unwrap();
126        assert_eq!(rt.origin, TokenOrigin::Cache);
127        assert_eq!(rt.token, CACHE_TOKEN);
128    }
129
130    /// 3b. env empty string with no cache → None.
131    #[test]
132    fn env_empty_string_no_cache_returns_none() {
133        let cache = AuthCache::default();
134        let result = resolve_token(Some(String::new()), &cache, SERVER);
135        assert!(result.is_none());
136    }
137
138    /// 4. env None + cache has token → origin Cache.
139    #[test]
140    fn env_none_uses_cache() {
141        let cache = cache_with_token(SERVER, CACHE_TOKEN);
142        let result = resolve_token(None, &cache, SERVER);
143        let rt = result.unwrap();
144        assert_eq!(rt.origin, TokenOrigin::Cache);
145        assert_eq!(rt.token, CACHE_TOKEN);
146    }
147
148    /// 5. env set + cache has a token for a DIFFERENT server → still origin Env (server-agnostic).
149    #[test]
150    fn env_set_cache_for_different_server_still_env() {
151        let cache = cache_with_token(OTHER_SERVER, CACHE_TOKEN);
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    /// 6. env set + empty cache → origin Env.
159    #[test]
160    fn env_set_empty_cache_returns_env() {
161        let cache = AuthCache::default();
162        let result = resolve_token(Some(ENV_TOKEN.to_string()), &cache, SERVER);
163        let rt = result.unwrap();
164        assert_eq!(rt.origin, TokenOrigin::Env);
165        assert_eq!(rt.token, ENV_TOKEN);
166    }
167
168    /// 7. both absent (env None + empty cache) → None.
169    #[test]
170    fn both_absent_returns_none() {
171        let cache = AuthCache::default();
172        let result = resolve_token(None, &cache, SERVER);
173        assert!(result.is_none());
174    }
175
176    /// 8. Debug-redaction: the token value must not appear in the Debug output.
177    #[test]
178    fn debug_redacts_token() {
179        let secret = "super-secret-bearer-token";
180        let rt = ResolvedToken {
181            token: secret.to_string(),
182            origin: TokenOrigin::Env,
183        };
184        let rendered = format!("{rt:?}");
185        assert!(
186            !rendered.contains(secret),
187            "Debug impl leaked the token: {rendered}"
188        );
189        assert!(
190            rendered.contains("REDACTED"),
191            "expected redaction marker in Debug output, got: {rendered}"
192        );
193    }
194}