1use std::io::Write;
2use std::path::Path;
3
4use crate::{Result, cli_util, env_file, jwt};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum ProfileTokenSource {
8 None,
9 Profile,
10 EnvFallback { env_name: String },
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum CliAuthSource {
15 None,
16 TokenProfile,
17 EnvFallback { env_name: String },
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct CliJwtValidationEnv<'a> {
22 pub enabled_var: &'a str,
23 pub strict_var: &'a str,
24 pub leeway_var: &'a str,
25 pub tool_label: &'a str,
26}
27
28impl From<ProfileTokenSource> for CliAuthSource {
29 fn from(value: ProfileTokenSource) -> Self {
30 match value {
31 ProfileTokenSource::None => Self::None,
32 ProfileTokenSource::Profile => Self::TokenProfile,
33 ProfileTokenSource::EnvFallback { env_name } => Self::EnvFallback { env_name },
34 }
35 }
36}
37
38pub fn validate_cli_bearer_jwt(
39 bearer_token: &str,
40 auth_source: &CliAuthSource,
41 token_name: &str,
42 env: CliJwtValidationEnv<'_>,
43 stderr: &mut dyn Write,
44) -> Result<()> {
45 let enabled = cli_util::bool_from_env(
46 std::env::var(env.enabled_var).ok(),
47 env.enabled_var,
48 true,
49 Some(env.tool_label),
50 stderr,
51 );
52 let strict = cli_util::bool_from_env(
53 std::env::var(env.strict_var).ok(),
54 env.strict_var,
55 false,
56 Some(env.tool_label),
57 stderr,
58 );
59 let leeway_seconds = cli_util::parse_u64_default(std::env::var(env.leeway_var).ok(), 0, 0);
60
61 let label = match auth_source {
62 CliAuthSource::TokenProfile => format!("token profile '{token_name}'"),
63 CliAuthSource::EnvFallback { env_name } => env_name.to_string(),
64 CliAuthSource::None => "token".to_string(),
65 };
66
67 let opts = jwt::JwtValidationOptions {
68 enabled,
69 strict,
70 leeway_seconds: i64::try_from(leeway_seconds).unwrap_or(i64::MAX),
71 };
72
73 match jwt::check_bearer_jwt(bearer_token, &label, opts)? {
74 jwt::JwtCheck::Ok => Ok(()),
75 jwt::JwtCheck::Warn(msg) => {
76 let tool_label = env.tool_label.trim();
77 if tool_label.is_empty() {
78 let _ = writeln!(stderr, "warning: {msg}");
79 } else {
80 let _ = writeln!(stderr, "{tool_label}: warning: {msg}");
81 }
82 Ok(())
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ProfileTokenResolution {
89 pub bearer_token: Option<String>,
90 pub token_name: String,
91 pub source: ProfileTokenSource,
92}
93
94#[derive(Debug, Clone, Copy)]
95pub struct ProfileTokenConfig<'a> {
96 pub token_name_arg: Option<&'a str>,
97 pub token_name_env_var: &'a str,
98 pub token_name_file_var: &'a str,
99 pub token_var_prefix: &'a str,
100 pub tokens_env: &'a Path,
101 pub tokens_local: &'a Path,
102 pub tokens_files: &'a [&'a Path],
103 pub missing_profile_hint: &'a str,
104 pub env_fallback_keys: &'a [&'a str],
105}
106
107pub fn resolve_profile_or_env_fallback(
108 config: ProfileTokenConfig<'_>,
109) -> Result<ProfileTokenResolution> {
110 let token_name_arg = config.token_name_arg.and_then(cli_util::trim_non_empty);
111 let token_name_env = std::env::var(config.token_name_env_var)
112 .ok()
113 .and_then(|s| cli_util::trim_non_empty(&s));
114 let token_name_file = if !config.tokens_files.is_empty() {
115 env_file::read_var_last_wins(config.token_name_file_var, config.tokens_files)?
116 } else {
117 None
118 };
119
120 let token_profile_selected =
121 token_name_arg.is_some() || token_name_env.is_some() || token_name_file.is_some();
122 let token_name = token_name_arg
123 .or(token_name_env)
124 .or(token_name_file)
125 .unwrap_or_else(|| "default".to_string())
126 .to_ascii_lowercase();
127
128 if token_profile_selected {
129 let token_key = cli_util::to_env_key(&token_name);
130 let token_var = format!("{}{}", config.token_var_prefix, token_key);
131 let bearer_token = env_file::read_var_last_wins(&token_var, config.tokens_files)?;
132 let Some(bearer_token) = bearer_token else {
133 let available = available_token_profiles(
134 config.tokens_env,
135 config.tokens_local,
136 config.token_var_prefix,
137 );
138 anyhow::bail!(
139 "Token profile '{token_name}' is empty/missing (available: {available}). {}",
140 config.missing_profile_hint
141 );
142 };
143
144 return Ok(ProfileTokenResolution {
145 bearer_token: Some(bearer_token),
146 token_name,
147 source: ProfileTokenSource::Profile,
148 });
149 }
150
151 if let Some((token, env_name)) = resolve_env_fallback(config.env_fallback_keys) {
152 return Ok(ProfileTokenResolution {
153 bearer_token: Some(token),
154 token_name,
155 source: ProfileTokenSource::EnvFallback { env_name },
156 });
157 }
158
159 Ok(ProfileTokenResolution {
160 bearer_token: None,
161 token_name,
162 source: ProfileTokenSource::None,
163 })
164}
165
166fn available_token_profiles(
167 tokens_env: &Path,
168 tokens_local: &Path,
169 token_var_prefix: &str,
170) -> String {
171 let mut available = cli_util::list_available_suffixes(tokens_env, token_var_prefix);
172 if tokens_local.is_file() {
173 available.extend(cli_util::list_available_suffixes(
174 tokens_local,
175 token_var_prefix,
176 ));
177 available.sort();
178 available.dedup();
179 }
180 available.retain(|name| name != "name");
181 if available.is_empty() {
182 "none".to_string()
183 } else {
184 available.join(" ")
185 }
186}
187
188pub fn resolve_env_fallback(keys: &[&str]) -> Option<(String, String)> {
189 for &key in keys {
190 let Ok(value) = std::env::var(key) else {
191 continue;
192 };
193 if let Some(token) = cli_util::trim_non_empty(&value) {
194 return Some((token, key.to_string()));
195 }
196 }
197 None
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use nils_test_support::{EnvGuard, GlobalStateLock};
204 use tempfile::TempDir;
205
206 fn write_file(path: &Path, contents: &str) {
207 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
208 std::fs::write(path, contents).expect("write");
209 }
210
211 fn test_jwt_env() -> CliJwtValidationEnv<'static> {
212 CliJwtValidationEnv {
213 enabled_var: "TEST_JWT_VALIDATE_ENABLED",
214 strict_var: "TEST_JWT_VALIDATE_STRICT",
215 leeway_var: "TEST_JWT_VALIDATE_LEEWAY_SECONDS",
216 tool_label: "api-test",
217 }
218 }
219
220 #[test]
221 fn resolve_env_fallback_prefers_order() {
222 let lock = GlobalStateLock::new();
223 let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", "access");
224 let _service = EnvGuard::set(&lock, "SERVICE_TOKEN", "service");
225
226 assert_eq!(
227 resolve_env_fallback(&["ACCESS_TOKEN", "SERVICE_TOKEN"]),
228 Some(("access".to_string(), "ACCESS_TOKEN".to_string()))
229 );
230 }
231
232 #[test]
233 fn cli_auth_source_maps_profile_source_variants() {
234 assert_eq!(
235 CliAuthSource::from(ProfileTokenSource::None),
236 CliAuthSource::None
237 );
238 assert_eq!(
239 CliAuthSource::from(ProfileTokenSource::Profile),
240 CliAuthSource::TokenProfile
241 );
242 assert_eq!(
243 CliAuthSource::from(ProfileTokenSource::EnvFallback {
244 env_name: "ACCESS_TOKEN".to_string()
245 }),
246 CliAuthSource::EnvFallback {
247 env_name: "ACCESS_TOKEN".to_string()
248 }
249 );
250 }
251
252 #[test]
253 fn validate_cli_bearer_jwt_warns_when_non_strict() {
254 let lock = GlobalStateLock::new();
255 let _enabled = EnvGuard::set(&lock, "TEST_JWT_VALIDATE_ENABLED", "true");
256 let _strict = EnvGuard::set(&lock, "TEST_JWT_VALIDATE_STRICT", "false");
257 let _leeway = EnvGuard::remove(&lock, "TEST_JWT_VALIDATE_LEEWAY_SECONDS");
258
259 let mut stderr = Vec::new();
260 validate_cli_bearer_jwt(
261 "not.a.jwt",
262 &CliAuthSource::None,
263 "default",
264 test_jwt_env(),
265 &mut stderr,
266 )
267 .expect("non-strict invalid token should warn");
268
269 let msg = String::from_utf8(stderr).expect("utf8");
270 assert!(msg.contains("api-test: warning:"));
271 assert!(msg.contains("token for token is not a valid JWT"));
272 }
273
274 #[test]
275 fn validate_cli_bearer_jwt_errors_when_strict_invalid() {
276 let lock = GlobalStateLock::new();
277 let _enabled = EnvGuard::set(&lock, "TEST_JWT_VALIDATE_ENABLED", "true");
278 let _strict = EnvGuard::set(&lock, "TEST_JWT_VALIDATE_STRICT", "true");
279 let _leeway = EnvGuard::remove(&lock, "TEST_JWT_VALIDATE_LEEWAY_SECONDS");
280
281 let mut stderr = Vec::new();
282 let err = validate_cli_bearer_jwt(
283 "not.a.jwt",
284 &CliAuthSource::TokenProfile,
285 "svc",
286 test_jwt_env(),
287 &mut stderr,
288 )
289 .expect_err("strict invalid token should fail");
290
291 assert!(
292 err.to_string()
293 .contains("invalid JWT for token profile 'svc'")
294 );
295 }
296
297 #[test]
298 fn resolve_env_fallback_skips_empty_and_whitespace() {
299 let lock = GlobalStateLock::new();
300 let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", " ");
301 let _service = EnvGuard::set(&lock, "SERVICE_TOKEN", "service");
302
303 assert_eq!(
304 resolve_env_fallback(&["ACCESS_TOKEN", "SERVICE_TOKEN"]),
305 Some(("service".to_string(), "SERVICE_TOKEN".to_string()))
306 );
307 }
308
309 #[test]
310 fn resolve_env_fallback_returns_none_when_missing() {
311 let lock = GlobalStateLock::new();
312 let _access = EnvGuard::remove(&lock, "ACCESS_TOKEN");
313 let _service = EnvGuard::remove(&lock, "SERVICE_TOKEN");
314
315 assert_eq!(
316 resolve_env_fallback(&["ACCESS_TOKEN", "SERVICE_TOKEN"]),
317 None
318 );
319 }
320
321 #[test]
322 fn resolve_profile_or_env_fallback_prefers_selected_profile() {
323 let lock = GlobalStateLock::new();
324 let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", "env-token");
325 let _name = EnvGuard::remove(&lock, "REST_TOKEN_NAME");
326
327 let tmp = TempDir::new().expect("tmp");
328 let tokens_env = tmp.path().join("tokens.env");
329 let tokens_local = tmp.path().join("tokens.local.env");
330 write_file(&tokens_env, "REST_TOKEN_SVC=svc-token\n");
331
332 let files = [&tokens_env as &Path, &tokens_local as &Path];
333 let resolved = resolve_profile_or_env_fallback(ProfileTokenConfig {
334 token_name_arg: Some("svc"),
335 token_name_env_var: "REST_TOKEN_NAME",
336 token_name_file_var: "REST_TOKEN_NAME",
337 token_var_prefix: "REST_TOKEN_",
338 tokens_env: &tokens_env,
339 tokens_local: &tokens_local,
340 tokens_files: &files,
341 missing_profile_hint: "hint",
342 env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
343 })
344 .expect("profile token resolution");
345
346 assert_eq!(resolved.bearer_token.as_deref(), Some("svc-token"));
347 assert_eq!(resolved.token_name, "svc");
348 assert_eq!(resolved.source, ProfileTokenSource::Profile);
349 }
350
351 #[test]
352 fn resolve_profile_or_env_fallback_uses_env_fallback_without_profile_selection() {
353 let lock = GlobalStateLock::new();
354 let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", "env-token");
355 let _name = EnvGuard::remove(&lock, "REST_TOKEN_NAME");
356
357 let tmp = TempDir::new().expect("tmp");
358 let tokens_env = tmp.path().join("tokens.env");
359 let tokens_local = tmp.path().join("tokens.local.env");
360 let files = [&tokens_env as &Path, &tokens_local as &Path];
361
362 let resolved = resolve_profile_or_env_fallback(ProfileTokenConfig {
363 token_name_arg: None,
364 token_name_env_var: "REST_TOKEN_NAME",
365 token_name_file_var: "REST_TOKEN_NAME",
366 token_var_prefix: "REST_TOKEN_",
367 tokens_env: &tokens_env,
368 tokens_local: &tokens_local,
369 tokens_files: &files,
370 missing_profile_hint: "hint",
371 env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
372 })
373 .expect("fallback resolution");
374
375 assert_eq!(resolved.bearer_token.as_deref(), Some("env-token"));
376 assert_eq!(resolved.token_name, "default");
377 assert_eq!(
378 resolved.source,
379 ProfileTokenSource::EnvFallback {
380 env_name: "ACCESS_TOKEN".to_string()
381 }
382 );
383 }
384
385 #[test]
386 fn resolve_profile_or_env_fallback_reports_available_profiles_when_missing() {
387 let lock = GlobalStateLock::new();
388 let _name = EnvGuard::set(&lock, "REST_TOKEN_NAME", "missing");
389 let _access = EnvGuard::remove(&lock, "ACCESS_TOKEN");
390
391 let tmp = TempDir::new().expect("tmp");
392 let tokens_env = tmp.path().join("tokens.env");
393 let tokens_local = tmp.path().join("tokens.local.env");
394 write_file(
395 &tokens_env,
396 "REST_TOKEN_SVC=svc-token\nREST_TOKEN_DEV=dev-token\n",
397 );
398 let files = [&tokens_env as &Path, &tokens_local as &Path];
399
400 let err = resolve_profile_or_env_fallback(ProfileTokenConfig {
401 token_name_arg: None,
402 token_name_env_var: "REST_TOKEN_NAME",
403 token_name_file_var: "REST_TOKEN_NAME",
404 token_var_prefix: "REST_TOKEN_",
405 tokens_env: &tokens_env,
406 tokens_local: &tokens_local,
407 tokens_files: &files,
408 missing_profile_hint: "Set it in setup/rest/tokens.local.env.",
409 env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
410 })
411 .expect_err("missing profile should error");
412
413 let text = err.to_string();
414 assert!(text.contains("Token profile 'missing' is empty/missing"));
415 assert!(text.contains("svc dev") || text.contains("dev svc"));
416 assert!(text.contains("setup/rest/tokens.local.env"));
417 }
418
419 #[test]
420 fn resolve_profile_or_env_fallback_prefers_env_over_file_for_name() {
421 let lock = GlobalStateLock::new();
422 let _name = EnvGuard::set(&lock, "REST_TOKEN_NAME", "prod");
423
424 let tmp = TempDir::new().expect("tmp");
425 let tokens_env = tmp.path().join("tokens.env");
426 let tokens_local = tmp.path().join("tokens.local.env");
427 write_file(
428 &tokens_env,
429 "REST_TOKEN_NAME=staging\nREST_TOKEN_PROD=prod-token\n",
430 );
431 let files = [&tokens_env as &Path, &tokens_local as &Path];
432
433 let resolved = resolve_profile_or_env_fallback(ProfileTokenConfig {
434 token_name_arg: None,
435 token_name_env_var: "REST_TOKEN_NAME",
436 token_name_file_var: "REST_TOKEN_NAME",
437 token_var_prefix: "REST_TOKEN_",
438 tokens_env: &tokens_env,
439 tokens_local: &tokens_local,
440 tokens_files: &files,
441 missing_profile_hint: "hint",
442 env_fallback_keys: &["ACCESS_TOKEN", "SERVICE_TOKEN"],
443 })
444 .expect("env token name resolution");
445
446 assert_eq!(resolved.bearer_token.as_deref(), Some("prod-token"));
447 assert_eq!(resolved.token_name, "prod");
448 assert_eq!(resolved.source, ProfileTokenSource::Profile);
449 }
450}