Skip to main content

api_testing_core/
auth_env.rs

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}