1use regex::Regex;
49use serde::Deserialize;
50use std::{collections::HashMap, env, fmt, process::Command};
51pub type Result<T> = std::result::Result<T, CIIDError>;
52
53#[cfg(test)]
54#[macro_use]
55extern crate lazy_static;
56
57#[derive(Debug, Clone, PartialEq)]
58pub enum CIIDError {
59 EnvironmentNotDetected,
61 EnvironmentError(String),
63 MalformedToken,
65}
66impl fmt::Display for CIIDError {
67 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
68 match self {
69 CIIDError::EnvironmentError(s) => write!(f, "credential detection failed: {}", s),
70 _ => write!(f, "credential detection failed"),
71 }
72 }
73}
74
75type DetectFn = fn(Option<&str>) -> Result<String>;
76
77fn validate_token(token: String) -> Result<String> {
78 match token.split(".").collect::<Vec<&str>>().len() {
80 3 => Ok(token),
81 _ => Err(CIIDError::MalformedToken),
82 }
83}
84
85pub fn detect_credentials(audience: Option<&str>) -> Result<String> {
97 for (name, detect) in [
98 ("GitHub Actions", detect_github as DetectFn),
99 ("GitLab Pipelines", detect_gitlab as DetectFn),
100 ("CircleCI", detect_circleci as DetectFn),
101 ("Buildkite", detect_buildkite as DetectFn),
102 ] {
103 match detect(audience) {
104 Ok(token) => {
105 let token = validate_token(token)?;
106 log::debug!("{}: Token found", name);
107 return Ok(token);
108 }
109 Err(CIIDError::EnvironmentNotDetected) => {
110 log::debug!("{}: Environment not detected", name);
111 }
112 Err(e) => return Err(e),
113 }
114 }
115
116 Err(CIIDError::EnvironmentNotDetected)
117}
118
119#[derive(Deserialize)]
122struct GitHubTokenResponse {
123 value: String,
124}
125
126fn detect_github(audience: Option<&str>) -> Result<String> {
127 if env::var("GITHUB_ACTIONS").is_err() {
128 return Err(CIIDError::EnvironmentNotDetected);
129 };
130
131 let Ok(token_token) = env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN") else {
132 return Err(CIIDError::EnvironmentError(
133 "GitHub Actions: ACTIONS_ID_TOKEN_REQUEST_TOKEN is not set. This could \
134 imply that the job does not have 'id-token: write' permission"
135 .into(),
136 ));
137 };
138 let Ok(token_url) = env::var("ACTIONS_ID_TOKEN_REQUEST_URL") else {
139 return Err(CIIDError::EnvironmentError(
140 "GitHub Actions: ACTIONS_ID_TOKEN_REQUEST_URL is not set".into(),
141 ));
142 };
143 let mut params = HashMap::new();
144 if let Some(aud) = audience {
145 params.insert("audience", aud);
146 }
147
148 log::debug!("GitHub Actions: Requesting token");
149 let client = reqwest::blocking::Client::new();
150 let http_response = match client
151 .get(token_url)
152 .header(
153 reqwest::header::AUTHORIZATION,
154 format!("bearer {}", token_token),
155 )
156 .query(¶ms)
157 .send()
158 {
159 Ok(response) => response,
160 Err(e) => {
161 return Err(CIIDError::EnvironmentError(format!(
162 "GitHub Actions: Token request failed: {}",
163 e
164 )))
165 }
166 };
167 match http_response.json::<GitHubTokenResponse>() {
168 Ok(token_response) => Ok(token_response.value),
169 Err(e) => Err(CIIDError::EnvironmentError(format!(
170 "GitHub Actions: Failed to parse token reponse: {}",
171 e
172 ))),
173 }
174}
175
176fn detect_gitlab(audience: Option<&str>) -> Result<String> {
177 if env::var("GITLAB_CI").is_err() {
181 return Err(CIIDError::EnvironmentNotDetected);
182 };
183
184 let var_name = match audience {
185 None => {
186 return Err(CIIDError::EnvironmentError(
187 "GitLab: audience must be set".into(),
188 ));
189 }
190 Some(audience) => {
191 let upper_audience = audience.to_uppercase();
192 let re = Regex::new(r"[^A-Z0-9_]|^[^A-Z_]").unwrap();
193 format!("{}_ID_TOKEN", re.replace_all(&upper_audience, "_"))
194 }
195 };
196 log::debug!("GitLab Pipelines: Looking for token in {}", var_name);
197 match env::var(&var_name) {
198 Ok(token) => Ok(token),
199 Err(_) => Err(CIIDError::EnvironmentError(format!(
200 "GitLab Pipelines: {} is not set. This could imply that the \
201 pipeline does not define an id token with that name",
202 var_name
203 ))),
204 }
205}
206
207fn detect_circleci(audience: Option<&str>) -> Result<String> {
208 if env::var("CIRCLECI").is_err() {
209 return Err(CIIDError::EnvironmentNotDetected);
210 };
211 let payload;
212 match audience {
213 None => match env::var("CIRCLE_OIDC_TOKEN_V2") {
214 Ok(token) => Ok(token),
215 Err(_) => Err(CIIDError::EnvironmentError(
216 "CircleCI: CIRCLE_OIDC_TOKEN_V2 is not set.".into(),
217 )),
218 },
219 Some(audience) => {
220 payload = format!("{{\"aud\":\"{}\"}}", audience);
222 let args = ["run", "oidc", "get", "--claims", &payload];
223 match Command::new("circleci").args(args).output() {
224 Ok(output) => match String::from_utf8(output.stdout) {
225 Ok(token) => Ok(token.trim_end().to_string()),
226 Err(_) => Err(CIIDError::EnvironmentError(
227 "CircleCI; Failed to read token".into(),
228 )),
229 },
230 Err(e) => Err(CIIDError::EnvironmentError(format!(
231 "CircleCI: Call to circle CLI failed: {}",
232 e
233 ))),
234 }
235 }
236 }
237}
238
239fn detect_buildkite(audience: Option<&str>) -> Result<String> {
240 if env::var("BUILDKITE").is_err() {
241 return Err(CIIDError::EnvironmentNotDetected);
242 };
243
244 let args = match audience {
245 Some(audience) => vec!["oidc", "request-token", "--audience", audience],
246 None => vec!["oidc", "request-token"],
247 };
248 match Command::new("buildkite-agent").args(args).output() {
249 Ok(output) => match String::from_utf8(output.stdout) {
250 Ok(token) => Ok(token.trim_end().to_string()),
251 Err(_) => Err(CIIDError::EnvironmentError(
252 "Buildkite; Failed to read token".into(),
253 )),
254 },
255 Err(e) => Err(CIIDError::EnvironmentError(format!(
256 "Buildkite: Call to buildkite-agent failed: {}",
257 e
258 ))),
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 use std::{
267 fs::{self, File},
268 io::Write,
269 os::unix::fs::PermissionsExt,
270 sync::{Mutex, MutexGuard},
271 };
272
273 const TOKEN: &str = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjMxNjA2OGMzM2ZhMjg2OTZhZmI5YzM5YWI2OTMxMjY1ZDk0Y2I3NTUifQ.eyJpc3MiOiJodHRwczovL29hdXRoMi5zaWdzdG9yZS5kZXYvYXV0aCIsInN1YiI6IkNnVXpNVGc0T1JJbWFIUjBjSE02SlRKR0pUSkdaMmwwYUhWaUxtTnZiU1V5Um14dloybHVKVEpHYjJGMWRHZyIsImF1ZCI6InNpZ3N0b3JlIiwiZXhwIjoxNzI5NTEyOTMwLCJpYXQiOjE3Mjk1MTI4NzAsIm5vbmNlIjoiNTI3NjM3Y2UtN2Q2MS00MDA5LThkM2EtNGNjZGM3OGJiZDg1IiwiYXRfaGFzaCI6IktmMUNPTXB5TVJDTkdzWWp1QXczclEiLCJlbWFpbCI6ImprdUBnb3RvLmZpIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImZlZGVyYXRlZF9jbGFpbXMiOnsiY29ubmVjdG9yX2lkIjoiaHR0cHM6Ly9naXRodWIuY29tL2xvZ2luL29hdXRoIiwidXNlcl9pZCI6IjMxODg5In19.s27uZ3vpIzRS4eWdC3pM0FSsYkHNvScQoii_TcSRVZhtrcPAbA4D95Pw_R_UB-qRquMK1BHepKmeN1b1-CQ00jiFZgUOf9sDLC3Hy3oQejGJsYKb-7oeHs7amLz3SBzPwDwVd09e-7Yu1x9YV5k6aezqruLLt42C_kyOTsHeCIWWMEVmGp32105Jkj8YT5uEYXS-aOEvQFvAYsDfKgGuiJtGybUycVcJEfqyWI3cami7fkjU5PcCx8oFyP2E7YNRw4UeNWCTn7WFtL2onrgDm0oa2AqF3gtH4Q-9ByksVq3y6xQdoLj1ydzWcoCzsF43oZ6O6DkLmWk5fu3FxNyewg";
274
275 lazy_static! {
277 static ref ENV_MUTEX: Mutex<()> = Mutex::new(());
278 }
279
280 struct SavedEnv<'a> {
281 old_env: HashMap<&'a str, Option<String>>,
282 _guard: MutexGuard<'a, ()>,
283 }
284
285 impl<'a> SavedEnv<'a> {
286 fn new<T>(test_env: T) -> Self
287 where
288 T: IntoIterator<Item = (&'a str, Option<&'a str>)>,
289 {
290 let guard = match ENV_MUTEX.lock() {
292 Ok(guard) => guard,
293 Err(poison) => poison.into_inner(),
294 };
295
296 let mut old_env = HashMap::new();
298 for (key, val) in test_env {
299 let old_val = env::var(key).ok();
300 old_env.insert(key, old_val);
301 match val {
302 Some(val) => env::set_var(key, val),
303 None => env::remove_var(key),
304 }
305 }
306
307 Self {
308 old_env,
309 _guard: guard,
310 }
311 }
312 }
313
314 impl<'a> Drop for SavedEnv<'a> {
315 fn drop(&mut self) {
316 for (key, val) in self.old_env.drain() {
317 match val {
318 Some(val) => env::set_var(key, val),
319 None => env::remove_var(key),
320 }
321 }
322 }
323 }
324
325 fn run_with_env<'a, T, F>(test_env: T, f: F)
326 where
327 F: Fn(),
328 T: IntoIterator<Item = (&'a str, Option<&'a str>)>,
329 {
330 let saved_env = SavedEnv::new(test_env);
333 f();
334 drop(saved_env);
335 }
336
337 #[test]
338 fn buildkite_not_detected() {
339 run_with_env([("BUILDKITE", None)], || {
340 assert_eq!(
341 detect_buildkite(None),
342 Err(CIIDError::EnvironmentNotDetected)
343 );
344 });
345 }
346
347 #[test]
348 fn buildkite_env_failure() {
349 run_with_env(
350 [("BUILDKITE", Some("1")), ("PATH", Some(""))],
352 || {
353 assert!(matches!(
354 detect_buildkite("my-audience".into()).unwrap_err(),
355 CIIDError::EnvironmentError(_)
356 ));
357 },
358 );
359 }
360
361 #[test]
362 fn buildkite_success() {
363 let tmpdir = tempfile::tempdir().unwrap();
365 let dir_path = tmpdir.into_path();
366 let path = dir_path.join("buildkite-agent");
367 let mut f = File::create(&path).unwrap();
368 let script = format!("#!/bin/sh\necho -n {}\n", TOKEN);
369 f.write_all(script.as_bytes()).unwrap();
370 let mut permissions = f.metadata().unwrap().permissions();
371 drop(f);
372 permissions.set_mode(0o744);
373 fs::set_permissions(path, permissions).unwrap();
374
375 run_with_env(
379 [
380 ("BUILDKITE", Some("1")),
381 ("PATH", Some(dir_path.to_str().unwrap())),
382 ],
383 || {
384 assert_eq!(detect_buildkite("my-audience".into()), Ok(TOKEN.into()));
385 },
386 );
387
388 run_with_env(
390 [
391 ("BUILDKITE", Some("1")),
392 ("PATH", Some(dir_path.to_str().unwrap())),
393 ],
394 || {
395 assert_eq!(detect_buildkite(None), Ok(TOKEN.into()));
396 },
397 );
398 }
399
400 #[test]
401 fn circleci_not_detected() {
402 run_with_env([("CIRCLECI", None)], || {
403 assert_eq!(
404 detect_circleci(None),
405 Err(CIIDError::EnvironmentNotDetected)
406 );
407 });
408 }
409
410 #[test]
411 fn circleci_env_failure() {
412 run_with_env(
413 [("CIRCLECI", Some("1")), ("PATH", Some(""))],
415 || {
416 assert!(matches!(
417 detect_circleci("my-audience".into()).unwrap_err(),
418 CIIDError::EnvironmentError(_)
419 ));
420 },
421 );
422
423 run_with_env(
424 [("CIRCLECI", Some("1")), ("CIRCLE_OIDC_TOKEN_V2", None)],
426 || {
427 assert!(matches!(
428 detect_circleci(None).unwrap_err(),
429 CIIDError::EnvironmentError(_)
430 ));
431 },
432 );
433 }
434
435 #[test]
436 fn circleci_success() {
437 let tmpdir = tempfile::tempdir().unwrap();
439 let dir_path = tmpdir.into_path();
440 let path = dir_path.join("circleci");
441 let mut f = File::create(&path).unwrap();
442 let script = format!("#!/bin/sh\necho -n {}\n", TOKEN);
443 f.write_all(script.as_bytes()).unwrap();
444 let mut permissions = f.metadata().unwrap().permissions();
445 drop(f);
446 permissions.set_mode(0o744);
447 fs::set_permissions(path, permissions).unwrap();
448
449 run_with_env(
451 [
452 ("CIRCLECI", Some("1")),
453 ("PATH", Some(dir_path.to_str().unwrap())),
454 ],
455 || {
456 assert_eq!(detect_circleci("my-audience".into()), Ok(TOKEN.into()));
457 },
458 );
459
460 run_with_env(
461 [
462 ("CIRCLECI", Some("1")),
463 ("CIRCLE_OIDC_TOKEN_V2", Some(TOKEN)),
464 ],
465 || {
466 assert_eq!(detect_circleci(None), Ok(TOKEN.into()));
467 },
468 );
469 }
470
471 #[test]
472 fn github_not_detected() {
473 run_with_env([("GITHUB_ACTIONS", None)], || {
474 assert_eq!(detect_github(None), Err(CIIDError::EnvironmentNotDetected));
475 });
476 }
477
478 #[test]
479 fn github_env_failure() {
480 run_with_env(
482 [
483 ("GITHUB_ACTIONS", Some("1")),
484 ("ACTIONS_ID_TOKEN_REQUEST_TOKEN", None),
485 ],
486 || {
487 assert!(matches!(
488 detect_github(None).unwrap_err(),
489 CIIDError::EnvironmentError(_)
490 ));
491 },
492 );
493 run_with_env(
494 [
495 ("GITHUB_ACTIONS", Some("1")),
496 ("ACTIONS_ID_TOKEN_REQUEST_TOKEN", Some("token")),
497 ("ACTIONS_ID_TOKEN_REQUEST_URL", None),
498 ],
499 || {
500 assert!(matches!(
501 detect_github(None).unwrap_err(),
502 CIIDError::EnvironmentError(_)
503 ));
504 },
505 );
506
507 run_with_env(
509 [
510 ("GITHUB_ACTIONS", Some("1")),
511 ("ACTIONS_ID_TOKEN_REQUEST_TOKEN", Some("token")),
512 ("ACTIONS_ID_TOKEN_REQUEST_URL", Some("http://invalid")),
513 ],
514 || {
515 assert_eq!(
516 detect_github(None).unwrap_err(),
517 CIIDError::EnvironmentError("GitHub Actions: Token request failed: error sending request for url (http://invalid/)".into())
518 );
519 },
520 );
521 }
522
523 #[test]
527 fn gitlab_not_detected() {
528 run_with_env([("GITLAB_CI", None)], || {
529 assert_eq!(detect_gitlab(None), Err(CIIDError::EnvironmentNotDetected));
530 });
531 }
532
533 #[test]
534 fn gitlab_env_failure() {
535 run_with_env([("GITLAB_CI", Some("1"))], || {
537 assert!(matches!(
538 detect_gitlab(None).unwrap_err(),
539 CIIDError::EnvironmentError(_)
540 ));
541 });
542
543 run_with_env(
545 [("GITLAB_CI", Some("1")), ("MY_AUD_ID_TOKEN", None)],
546 || {
547 assert!(matches!(
548 detect_gitlab(Some("my-aud")).unwrap_err(),
549 CIIDError::EnvironmentError(_)
550 ));
551 },
552 );
553 }
554
555 #[test]
556 fn gitlab_success() {
557 run_with_env(
558 [("GITLAB_CI", Some("1")), ("MY_AUD_ID_TOKEN", Some(TOKEN))],
559 || {
560 assert_eq!(detect_gitlab(Some("my-aud")), Ok(TOKEN.into()));
561 },
562 );
563 }
564
565 #[test]
566 fn detect_credentials_no_environments() {
567 run_with_env(
568 [
569 ("BUILDKITE", None),
570 ("CIRCLECI", None),
571 ("GITLAB_CI", None),
572 ("GITHUB_ACTIONS", None),
573 ],
574 || {
575 assert_eq!(
576 detect_credentials(None),
577 Err(CIIDError::EnvironmentNotDetected)
578 );
579 },
580 );
581 }
582
583 #[test]
584 fn detect_credentials_failure() {
585 run_with_env(
587 [
588 ("GITHUB_ACTIONS", Some("1")),
589 ("ACTIONS_ID_TOKEN_REQUEST_TOKEN", None),
590 ],
591 || {
592 assert!(matches!(
593 detect_credentials(None).unwrap_err(),
594 CIIDError::EnvironmentError(_)
595 ));
596 },
597 );
598 }
599
600 #[test]
601 fn detect_credentials_malformed_token() {
602 run_with_env(
604 [
605 ("GITHUB_ACTIONS", None),
606 ("GITLAB_CI", Some("1")),
607 ("MY_AUD_ID_TOKEN", Some("token value")),
608 ],
609 || {
610 assert_eq!(
611 detect_credentials(Some("my-aud")),
612 Err(CIIDError::MalformedToken)
613 );
614 },
615 );
616 }
617
618 #[test]
619 fn detect_credentials_success() {
620 run_with_env(
622 [
623 ("GITHUB_ACTIONS", None),
624 ("GITLAB_CI", Some("1")),
625 ("MY_AUD_ID_TOKEN", Some(TOKEN)),
626 ],
627 || {
628 assert_eq!(detect_credentials(Some("my-aud")), Ok(TOKEN.into()));
629 },
630 );
631 }
632}