1use 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
29pub fn run(cfg: &Config) -> Result<(), Diagnostic> {
39 let env_token = std::env::var("DSP_TOKEN").ok();
40 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 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 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 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 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 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 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 #[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 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 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 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 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 #[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 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 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 #[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 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 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"); 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}