Skip to main content

api_testing_core/graphql/
auth.rs

1use std::io::Write;
2use std::path::{Path, PathBuf};
3
4use crate::{Result, auth_env, cli_util, env_file, jwt};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum GraphqlAuthSourceUsed {
8    None,
9    JwtProfile { name: String },
10    EnvFallback { env_name: String },
11}
12
13#[derive(Debug, Clone)]
14pub struct GraphqlAuthResolution {
15    pub bearer_token: Option<String>,
16    pub source: GraphqlAuthSourceUsed,
17    pub warnings: Vec<String>,
18}
19
20fn extract_login_root_field_name(operation_text: &str) -> Option<String> {
21    let mut in_sel = false;
22    for raw_line in operation_text.lines() {
23        let line = raw_line.trim_end_matches('\r');
24        let line = line.trim_start();
25        if line.starts_with('#') || line.is_empty() {
26            continue;
27        }
28
29        let mut s = line;
30        if !in_sel {
31            if let Some(pos) = s.find('{') {
32                in_sel = true;
33                s = &s[pos + 1..];
34            } else {
35                continue;
36            }
37        }
38
39        let s = s.trim_start();
40        if s.is_empty() || s.starts_with('}') {
41            continue;
42        }
43
44        let mut chars = s.chars();
45        let Some(first) = chars.next() else {
46            continue;
47        };
48        if !(first == '_' || first.is_ascii_alphabetic()) {
49            continue;
50        }
51        let mut out = String::new();
52        out.push(first);
53        for c in chars {
54            if c == '_' || c.is_ascii_alphanumeric() {
55                out.push(c);
56            } else {
57                break;
58            }
59        }
60        if !out.is_empty() {
61            return Some(out);
62        }
63    }
64    None
65}
66
67fn find_login_operation(setup_dir: &Path, profile: &str) -> Option<PathBuf> {
68    let candidates = [
69        setup_dir.to_path_buf(),
70        setup_dir.join("operations"),
71        setup_dir.join("ops"),
72    ];
73
74    for dir in candidates {
75        if !dir.is_dir() {
76            continue;
77        }
78
79        let prof = dir.join(format!("login.{profile}.graphql"));
80        if prof.is_file() {
81            return Some(prof);
82        }
83        let generic = dir.join("login.graphql");
84        if generic.is_file() {
85            return Some(generic);
86        }
87    }
88
89    None
90}
91
92fn find_login_variables(login_op: &Path, profile: &str) -> Option<PathBuf> {
93    let dir = login_op.parent().unwrap_or_else(|| Path::new("."));
94    let candidates = [
95        dir.join(format!("login.{profile}.variables.local.json")),
96        dir.join(format!("login.{profile}.variables.json")),
97        dir.join("login.variables.local.json"),
98        dir.join("login.variables.json"),
99    ];
100    candidates.into_iter().find(|p| p.is_file())
101}
102
103fn find_token_in_value(value: &serde_json::Value) -> Option<String> {
104    match value {
105        serde_json::Value::String(s) => {
106            let t = s.trim();
107            (!t.is_empty()).then(|| t.to_string())
108        }
109        serde_json::Value::Array(values) => values.iter().find_map(find_token_in_value),
110        serde_json::Value::Object(map) => {
111            if let Some(v) = map.get("accessToken").or_else(|| map.get("token"))
112                && let Some(t) = find_token_in_value(v)
113            {
114                return Some(t);
115            }
116            for v in map.values() {
117                if let Some(t) = find_token_in_value(v) {
118                    return Some(t);
119                }
120            }
121            None
122        }
123        _ => None,
124    }
125}
126
127fn maybe_auto_login(
128    setup_dir: &Path,
129    endpoint_url: &str,
130    profile: &str,
131    op_path: Option<&Path>,
132) -> Result<Option<String>> {
133    let Some(login_op) = find_login_operation(setup_dir, profile) else {
134        return Ok(None);
135    };
136
137    if let Some(op_path) = op_path {
138        let op_abs = std::fs::canonicalize(op_path).unwrap_or_else(|_| op_path.to_path_buf());
139        let login_abs = std::fs::canonicalize(&login_op).unwrap_or_else(|_| login_op.to_path_buf());
140        if op_abs == login_abs {
141            return Ok(None);
142        }
143    }
144
145    let login_vars = find_login_variables(&login_op, profile);
146    let op_file = crate::graphql::schema::GraphqlOperationFile::load(&login_op)?;
147    let vars_json = match login_vars.as_deref() {
148        None => None,
149        Some(path) => {
150            let vars = crate::graphql::vars::GraphqlVariablesFile::load(path, 0)?;
151            Some(vars.variables)
152        }
153    };
154
155    let executed = crate::graphql::runner::execute_graphql_request(
156        endpoint_url,
157        None,
158        &op_file.operation,
159        vars_json.as_ref(),
160    )?;
161
162    let root_field = extract_login_root_field_name(&op_file.operation).ok_or_else(|| {
163        anyhow::anyhow!(
164            "Failed to determine login root field from: {}",
165            login_op.display()
166        )
167    })?;
168
169    let body_json: serde_json::Value = serde_json::from_slice(&executed.response.body)
170        .ok()
171        .unwrap_or(serde_json::Value::Null);
172    let token = body_json
173        .get("data")
174        .and_then(|d| d.get(&root_field))
175        .and_then(find_token_in_value);
176
177    if let Some(token) = token {
178        return Ok(Some(token));
179    }
180
181    anyhow::bail!("Failed to extract JWT from login response (field: {root_field}).");
182}
183
184pub fn resolve_bearer_token(
185    setup_dir: &Path,
186    endpoint_url: &str,
187    operation_file: Option<&Path>,
188    jwt_name_arg: Option<&str>,
189    stderr: &mut dyn Write,
190) -> Result<GraphqlAuthResolution> {
191    let mut warnings = Vec::new();
192
193    let jwts_env = setup_dir.join("jwts.env");
194    let jwts_local = setup_dir.join("jwts.local.env");
195    let jwts_files: Vec<&Path> = if jwts_env.is_file() || jwts_local.is_file() {
196        vec![&jwts_env, &jwts_local]
197    } else {
198        Vec::new()
199    };
200
201    let jwt_name_file = if !jwts_files.is_empty() {
202        env_file::read_var_last_wins("GQL_JWT_NAME", &jwts_files)?
203    } else {
204        None
205    };
206    let jwt_name_env = std::env::var("GQL_JWT_NAME")
207        .ok()
208        .and_then(|s| cli_util::trim_non_empty(&s));
209    let jwt_name_arg = jwt_name_arg.and_then(cli_util::trim_non_empty);
210
211    let jwt_profile_selected =
212        jwt_name_arg.is_some() || jwt_name_env.is_some() || jwt_name_file.is_some();
213
214    let (bearer_token, source) = if jwt_profile_selected {
215        let jwt_name = jwt_name_arg
216            .or(jwt_name_env)
217            .or(jwt_name_file)
218            .unwrap_or_else(|| "default".to_string())
219            .to_ascii_lowercase();
220
221        let token = env_file::read_prefixed_var("GQL_JWT_", &jwt_name, &jwts_files)?
222            .and_then(|s| cli_util::trim_non_empty(&s));
223
224        let token = if let Some(token) = token {
225            token
226        } else if let Some(token) =
227            maybe_auto_login(setup_dir, endpoint_url, &jwt_name, operation_file)?
228        {
229            token
230        } else {
231            anyhow::bail!(
232                "JWT profile '{jwt_name}' is selected but no token was found and auto-login is not configured."
233            );
234        };
235
236        (
237            Some(token),
238            GraphqlAuthSourceUsed::JwtProfile { name: jwt_name },
239        )
240    } else if let Some((token, env_name)) =
241        auth_env::resolve_env_fallback(&["ACCESS_TOKEN", "SERVICE_TOKEN"])
242    {
243        (Some(token), GraphqlAuthSourceUsed::EnvFallback { env_name })
244    } else {
245        (None, GraphqlAuthSourceUsed::None)
246    };
247
248    if let Some(token) = bearer_token.as_deref() {
249        let enabled = cli_util::bool_from_env(
250            std::env::var("GQL_JWT_VALIDATE_ENABLED").ok(),
251            "GQL_JWT_VALIDATE_ENABLED",
252            true,
253            None,
254            &mut warnings,
255        );
256        let strict = cli_util::bool_from_env(
257            std::env::var("GQL_JWT_VALIDATE_STRICT").ok(),
258            "GQL_JWT_VALIDATE_STRICT",
259            false,
260            None,
261            &mut warnings,
262        );
263        let leeway_seconds = cli_util::parse_u64_default(
264            std::env::var("GQL_JWT_VALIDATE_LEEWAY_SECONDS").ok(),
265            0,
266            0,
267        );
268
269        let label = match &source {
270            GraphqlAuthSourceUsed::JwtProfile { name } => format!("jwt profile '{name}'"),
271            GraphqlAuthSourceUsed::EnvFallback { env_name } => env_name.to_string(),
272            GraphqlAuthSourceUsed::None => "token".to_string(),
273        };
274
275        let opts = jwt::JwtValidationOptions {
276            enabled,
277            strict,
278            leeway_seconds: i64::try_from(leeway_seconds).unwrap_or(i64::MAX),
279        };
280
281        match jwt::check_bearer_jwt(token, &label, opts)? {
282            jwt::JwtCheck::Ok => {}
283            jwt::JwtCheck::Warn(msg) => {
284                let _ = writeln!(stderr, "api-gql: warning: {msg}");
285            }
286        }
287    }
288
289    for w in warnings {
290        let _ = writeln!(stderr, "api-gql: warning: {w}");
291    }
292
293    Ok(GraphqlAuthResolution {
294        bearer_token,
295        source,
296        warnings: Vec::new(),
297    })
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use pretty_assertions::assert_eq;
304
305    use nils_test_support::http::{HttpResponse, LoopbackServer};
306    use nils_test_support::{EnvGuard, GlobalStateLock};
307    use tempfile::TempDir;
308
309    fn write_file(path: &Path, contents: &str) {
310        std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
311        std::fs::write(path, contents).expect("write");
312    }
313
314    #[test]
315    fn graphql_auth_extracts_root_field_name_best_effort() {
316        let op = r#"
317# comment
318query Login {
319  login {
320    accessToken
321  }
322}
323"#;
324        assert_eq!(extract_login_root_field_name(op).as_deref(), Some("login"));
325    }
326
327    #[test]
328    fn graphql_auth_login_file_search_prefers_profile_specific() {
329        let tmp = TempDir::new().expect("tmp");
330        let setup = tmp.path().join("setup/graphql");
331        std::fs::create_dir_all(&setup).expect("mkdir");
332        write_file(
333            &setup.join("login.admin.graphql"),
334            "query Login { login { accessToken } }",
335        );
336        write_file(
337            &setup.join("login.graphql"),
338            "query Login { login { token } }",
339        );
340
341        let found = find_login_operation(&setup, "admin").expect("found");
342        assert!(found.ends_with("login.admin.graphql"));
343    }
344
345    #[test]
346    fn graphql_auth_helper_parsers_cover_defaults() {
347        let mut warnings = Vec::new();
348        assert!(cli_util::bool_from_env(
349            Some("true".into()),
350            "X",
351            false,
352            None,
353            &mut warnings
354        ));
355        assert!(!cli_util::bool_from_env(
356            Some("false".into()),
357            "X",
358            true,
359            None,
360            &mut warnings
361        ));
362
363        let mut warnings = Vec::new();
364        assert!(!cli_util::bool_from_env(
365            Some("nope".into()),
366            "X",
367            true,
368            None,
369            &mut warnings
370        ));
371        assert_eq!(warnings.len(), 1);
372        assert!(warnings[0].contains("X must be true|false"));
373
374        assert_eq!(cli_util::parse_u64_default(Some("".into()), 5, 1), 5);
375        assert_eq!(cli_util::parse_u64_default(Some("nope".into()), 5, 1), 5);
376        assert_eq!(cli_util::parse_u64_default(Some("0".into()), 5, 1), 1);
377        assert_eq!(cli_util::parse_u64_default(Some("10".into()), 5, 1), 10);
378    }
379
380    #[test]
381    fn graphql_auth_find_token_in_value_handles_nested_structures() {
382        let value = serde_json::json!({
383            "data": {
384                "login": {
385                    "token": "abc"
386                }
387            }
388        });
389        assert_eq!(find_token_in_value(&value), Some("abc".to_string()));
390
391        let array_value = serde_json::json!([{"accessToken": "def"}]);
392        assert_eq!(find_token_in_value(&array_value), Some("def".to_string()));
393
394        let blank = serde_json::json!("  ");
395        assert_eq!(find_token_in_value(&blank), None);
396    }
397
398    #[test]
399    fn graphql_auth_resolve_uses_access_token_env() {
400        let lock = GlobalStateLock::new();
401        let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", "env-token");
402        let _service = EnvGuard::set(&lock, "SERVICE_TOKEN", "service-token");
403        let _jwt_enabled = EnvGuard::set(&lock, "GQL_JWT_VALIDATE_ENABLED", "false");
404        let _jwt_name = EnvGuard::remove(&lock, "GQL_JWT_NAME");
405
406        let tmp = TempDir::new().expect("tmp");
407        let mut stderr = Vec::new();
408        let out = resolve_bearer_token(
409            tmp.path(),
410            "http://localhost/graphql",
411            None,
412            None,
413            &mut stderr,
414        )
415        .expect("resolve");
416
417        assert_eq!(out.bearer_token.as_deref(), Some("env-token"));
418        assert_eq!(
419            out.source,
420            GraphqlAuthSourceUsed::EnvFallback {
421                env_name: "ACCESS_TOKEN".to_string()
422            }
423        );
424    }
425
426    #[test]
427    fn graphql_auth_resolve_falls_back_to_service_token_env() {
428        let lock = GlobalStateLock::new();
429        let _access = EnvGuard::set(&lock, "ACCESS_TOKEN", "  ");
430        let _service = EnvGuard::set(&lock, "SERVICE_TOKEN", "service-token");
431        let _jwt_enabled = EnvGuard::set(&lock, "GQL_JWT_VALIDATE_ENABLED", "false");
432        let _jwt_name = EnvGuard::remove(&lock, "GQL_JWT_NAME");
433
434        let tmp = TempDir::new().expect("tmp");
435        let mut stderr = Vec::new();
436        let out = resolve_bearer_token(
437            tmp.path(),
438            "http://localhost/graphql",
439            None,
440            None,
441            &mut stderr,
442        )
443        .expect("resolve");
444
445        assert_eq!(out.bearer_token.as_deref(), Some("service-token"));
446        assert_eq!(
447            out.source,
448            GraphqlAuthSourceUsed::EnvFallback {
449                env_name: "SERVICE_TOKEN".to_string()
450            }
451        );
452    }
453
454    #[test]
455    fn graphql_auth_resolve_ignores_blank_service_token() {
456        let lock = GlobalStateLock::new();
457        let _access = EnvGuard::remove(&lock, "ACCESS_TOKEN");
458        let _service = EnvGuard::set(&lock, "SERVICE_TOKEN", "  ");
459        let _jwt_enabled = EnvGuard::set(&lock, "GQL_JWT_VALIDATE_ENABLED", "false");
460        let _jwt_name = EnvGuard::remove(&lock, "GQL_JWT_NAME");
461
462        let tmp = TempDir::new().expect("tmp");
463        let mut stderr = Vec::new();
464        let out = resolve_bearer_token(
465            tmp.path(),
466            "http://localhost/graphql",
467            None,
468            None,
469            &mut stderr,
470        )
471        .expect("resolve");
472
473        assert_eq!(out.bearer_token, None);
474        assert_eq!(out.source, GraphqlAuthSourceUsed::None);
475    }
476
477    #[test]
478    fn graphql_auth_resolve_prefers_profile_token_from_files() {
479        let lock = GlobalStateLock::new();
480        let _access = EnvGuard::remove(&lock, "ACCESS_TOKEN");
481        let _jwt_enabled = EnvGuard::set(&lock, "GQL_JWT_VALIDATE_ENABLED", "false");
482        let _jwt_name = EnvGuard::remove(&lock, "GQL_JWT_NAME");
483
484        let tmp = TempDir::new().expect("tmp");
485        write_file(
486            &tmp.path().join("jwts.env"),
487            "GQL_JWT_ADMIN=token-from-file\n",
488        );
489
490        let mut stderr = Vec::new();
491        let out = resolve_bearer_token(
492            tmp.path(),
493            "http://localhost/graphql",
494            None,
495            Some("admin"),
496            &mut stderr,
497        )
498        .expect("resolve");
499
500        assert_eq!(out.bearer_token.as_deref(), Some("token-from-file"));
501        assert_eq!(
502            out.source,
503            GraphqlAuthSourceUsed::JwtProfile {
504                name: "admin".to_string()
505            }
506        );
507    }
508
509    #[test]
510    fn graphql_auth_auto_login_fetches_token_and_vars() {
511        let lock = GlobalStateLock::new();
512        let _access = EnvGuard::remove(&lock, "ACCESS_TOKEN");
513        let _jwt_enabled = EnvGuard::set(&lock, "GQL_JWT_VALIDATE_ENABLED", "false");
514        let _jwt_name = EnvGuard::remove(&lock, "GQL_JWT_NAME");
515
516        let tmp = TempDir::new().expect("tmp");
517        let setup = tmp.path().join("setup/graphql");
518        std::fs::create_dir_all(&setup).expect("mkdir");
519        write_file(
520            &setup.join("login.admin.graphql"),
521            "query Login($user: String!) { login { accessToken } }",
522        );
523        write_file(
524            &setup.join("login.admin.variables.json"),
525            r#"{"user":"alice"}"#,
526        );
527
528        let server = LoopbackServer::new().expect("server");
529        server.add_route(
530            "POST",
531            "/graphql",
532            HttpResponse::new(200, r#"{"data":{"login":{"accessToken":"auto-token"}}}"#)
533                .with_header("Content-Type", "application/json"),
534        );
535
536        let endpoint = format!("{}/graphql", server.url());
537        let mut stderr = Vec::new();
538        let out = resolve_bearer_token(&setup, &endpoint, None, Some("admin"), &mut stderr)
539            .expect("resolve");
540
541        assert_eq!(out.bearer_token.as_deref(), Some("auto-token"));
542        assert_eq!(
543            out.source,
544            GraphqlAuthSourceUsed::JwtProfile {
545                name: "admin".to_string()
546            }
547        );
548
549        let requests = server.take_requests();
550        assert_eq!(requests.len(), 1);
551        let body = requests[0].body_text();
552        assert!(body.contains("\"variables\""));
553        assert!(body.contains("\"user\":\"alice\""));
554    }
555}