ci_id/
lib.rs

1//! `ci-id` provides easy access to ambient OIDC credentials in CI systems like
2//! GitHub Actions.
3//!
4//! ```
5//! match ci_id::detect_credentials(Some("my-audience")) {
6//!     Ok(token) => println!("{}", token),
7//!     Err(e) => eprintln!("{}", e)
8//! }
9//! ```
10//!
11//! # Environment specific setup
12//!
13//! Typically the CI environment needs to allow OIDC identity access.
14//!
15//! ## GitHub Actions
16//!
17//! Workflow must be given the [permission](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings)
18//! to use the workflow identity:
19//!
20//! ```yaml
21//! permissions:
22//!     id-token: write
23//! ```
24//!
25//! ## GitLab Pipelines
26//!
27//! An [ID token](https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html)
28//! must be defined in the pipeline:
29//!
30//! ```yaml
31//! id_tokens:
32//!     MY_AUDIENCE_ID_TOKEN:
33//!         aud: my-audience
34//! ```
35//!
36//! The ID token name must be based on the audience so that token name is `<AUD>_ID_TOKEN` where
37//! `<AUD>` is the audience string sanitized for environment variable names (uppercased and all
38//! characters outside of ascii letters and digits are replaced with "_").
39//!
40//! ## CircleCI
41//!
42//! No configuration is needed.
43//!
44//! ## Buildkite
45//!
46//! No configuration is needed.
47
48use 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    /// No supported OIDC identity environment was detected
60    EnvironmentNotDetected,
61    /// Environment was found but there was a problem with acquiring the token
62    EnvironmentError(String),
63    /// Identity token was found but it does not look like JSON Web Token
64    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    // very, very shallow validation: could this be a JWT token?
79    match token.split(".").collect::<Vec<&str>>().len() {
80        3 => Ok(token),
81        _ => Err(CIIDError::MalformedToken),
82    }
83}
84
85/// Returns detected OIDC identity token.
86///
87/// The supported environments are probed in order, the identity token
88/// for the first found environment is returned.
89///
90/// ```
91/// match ci_id::detect_credentials(Some("my-audience")) {
92///     Ok(token) => println!("{}", token),
93///     Err(e) => eprintln!("{}", e)
94/// }
95/// ```
96pub 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// Github implementation
120
121#[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(&params)
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    // gitlab tokens can be in any environment variable: we require the variable name to be
178    // * "<AUDIENCE>_ID_TOKEN" where <AUDIENCE> is the audience string.
179
180    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            // TODO Use serde here? the audience string could be anything...
221            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    // Mutex for all tests that modify environment variables
276    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            // Tests can panic: assume our lock is still fine
291            let guard = match ENV_MUTEX.lock() {
292                Ok(guard) => guard,
293                Err(poison) => poison.into_inner(),
294            };
295
296            // Store current env values, set the test values as the environment
297            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        // Prepares env variables according to `env`, runs the function, then returns environment
331        // to old values
332        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            // empty the path so that this does not accidentally succeed on buildkite
351            [("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        // create a fake 'buildkite-agent' executable
364        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        // TODO: actually make the fake binary check that args are correct?
376
377        // Make sure the fake executable is in PATH, then test non-default audience
378        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        // Make sure the fake executable is in PATH, then test default audience
389        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            // empty the path so that this does not accidentally succeed on CircleCI
414            [("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            // default audience uses specific env var
425            [("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        // create a fake 'circleci' executable
438        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        // Make sure the fake executable is in PATH, then test non-default audience
450        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        // Missing env variables
481        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        // request fails
508        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    // TODO This requires mocking reqwest response
524    // fn github_success() { }
525
526    #[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        // GitLab does not support default audience
536        run_with_env([("GITLAB_CI", Some("1"))], || {
537            assert!(matches!(
538                detect_gitlab(None).unwrap_err(),
539                CIIDError::EnvironmentError(_)
540            ));
541        });
542
543        // Missing token variable for non-default audience
544        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        // Unexpected failure in any detector leads to detect_credentials failure.
586        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        // need to disable GitHub, otherwise we get a "false" positive on CI...
603        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        // need to disable GitHub, otherwise we get a "false" positive on CI...
621        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}