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}