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 = 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 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 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 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 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 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 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 #[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 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 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 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 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 #[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 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 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 #[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 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 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"); 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}