Skip to main content

ambient_id/
lib.rs

1//! Detects ambient OIDC credentials in a variety of environments.
2//!
3//! # Supported Environments
4//!
5//! * GitHub Actions (with `id-token: write`)
6//! * GitLab CI
7//! * Buildkite
8//!
9//! # Usage
10//!
11//! ```rust,ignore
12//! let audience = "my-service";
13//! let detector = ambient_id::Detector::new();
14//! match detector.detect(audience).await? {
15//!     Some(token) => println!("Detected ID token: {}", token.reveal()),
16//!     None => println!("No ambient ID token detected"),
17//! }
18//! ```
19
20#![deny(rustdoc::broken_intra_doc_links)]
21#![deny(missing_docs)]
22#![deny(unsafe_code)]
23
24use reqwest_middleware::ClientWithMiddleware;
25use secrecy::{ExposeSecret, SecretString};
26
27mod buildkite;
28mod circleci;
29mod github;
30mod gitlab;
31
32pub use buildkite::Error as BuildkiteError;
33pub use github::Error as GitHubError;
34pub use gitlab::Error as GitLabError;
35
36/// A detected ID token.
37///
38/// This is a newtype around a [`SecretString`] that ensures zero-on-drop
39/// semantics for the token.
40///
41/// The only way to get the token's value is via [`reveal`](IdToken::reveal).
42pub struct IdToken(SecretString);
43
44impl IdToken {
45    /// Reveals the detected ID token.
46    ///
47    /// This returns a reference to the inner token, which is a secret
48    /// and must be handled with care.
49    pub fn reveal(&self) -> &str {
50        self.0.expose_secret()
51    }
52}
53
54/// Errors that can occur during detection.
55#[derive(Debug, thiserror::Error)]
56pub enum Error {
57    /// An error occurred while detecting GitHub Actions credentials.
58    #[error("GitHub Actions detection error")]
59    GitHubActions(#[from] GitHubError),
60    /// An error occurred while detecting GitLab CI credentials.
61    #[error("GitLab CI detection error")]
62    GitLabCI(#[from] GitLabError),
63    /// An error occurred while detecting Buildkite credentials.
64    #[error("Buildkite detection error")]
65    Buildkite(#[from] buildkite::Error),
66    /// An error occurred while detecting CircleCI credentials.
67    #[error("CircleCI detection error")]
68    CircleCI(#[from] circleci::Error),
69}
70
71#[derive(Default)]
72struct DetectionState {
73    client: ClientWithMiddleware,
74}
75
76/// A trait for detecting ambient OIDC credentials.
77trait DetectionStrategy {
78    type Error;
79
80    fn new(state: &DetectionState) -> Option<Self>
81    where
82        Self: Sized;
83
84    async fn detect(&self, audience: &str) -> Result<IdToken, Self::Error>;
85}
86
87/// Detector for ambient OIDC credentials.
88pub struct Detector {
89    state: DetectionState,
90}
91
92impl Detector {
93    /// Creates a new detector with default settings.
94    pub fn new() -> Self {
95        Detector {
96            state: Default::default(),
97        }
98    }
99
100    /// Creates a new detector with the given HTTP client middleware stack.
101    pub fn new_with_client(client: impl Into<ClientWithMiddleware>) -> Self {
102        Detector {
103            state: DetectionState {
104                client: client.into(),
105            },
106        }
107    }
108
109    /// Detects ambient OIDC credentials in the current environment.
110    ///
111    /// The given `audience` controls the `aud` claim in the returned ID token.
112    ///
113    /// This function runs a series of detection strategies and returns
114    /// the first successful one. If no credentials are found,
115    /// it returns `Ok(None)`.
116    ///
117    /// If any (hard) errors occur during detection, it returns `Err`.
118    pub async fn detect(&self, audience: &str) -> Result<Option<IdToken>, Error> {
119        macro_rules! detect {
120        ($detector:path) => {
121            if let Some(detector) = <$detector>::new(&self.state) {
122                detector.detect(audience).await.map_err(Into::into).map(Some)
123            } else {
124                Ok(None)
125            }
126        };
127        ($detector:path, $($rest:path),+) => {
128            if let Some(detector) = <$detector>::new(&self.state) {
129                detector.detect(audience).await.map_err(Into::into).map(Some)
130            } else {
131                detect!($($rest),+)
132            }
133        };
134    }
135
136        detect!(
137            github::GitHubActions,
138            gitlab::GitLabCI,
139            buildkite::Buildkite,
140            circleci::CircleCI
141        )
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use crate::Detector;
148
149    /// An environment variable delta.
150    enum EnvDelta {
151        /// Set an environment variable to a value.
152        Add(String, String),
153        /// Unset an environment variable.
154        Remove(String),
155    }
156
157    /// A RAII guard for setting and unsetting environment variables.
158    ///
159    /// This maintains a stack of changes to unwind on drop; changes
160    /// are unwound the reverse order of application
161    pub(crate) struct EnvScope {
162        changes: Vec<EnvDelta>,
163    }
164
165    impl EnvScope {
166        pub fn new() -> Self {
167            EnvScope { changes: vec![] }
168        }
169
170        /// Sets an environment variable for the duration of this scope.
171        #[allow(unsafe_code)]
172        pub fn setenv(&mut self, key: &str, value: &str) {
173            match std::env::var(key) {
174                // Key was set before; restore old value on drop.
175                Ok(old) => self.changes.push(EnvDelta::Add(key.to_string(), old)),
176                // Key was not set before; remove it on drop.
177                Err(_) => self.changes.push(EnvDelta::Remove(key.to_string())),
178            }
179
180            unsafe { std::env::set_var(key, value) };
181        }
182
183        /// Removes an environment variable for the duration of this scope.
184        #[allow(unsafe_code)]
185        pub fn unsetenv(&mut self, key: &str) {
186            match std::env::var(key) {
187                // Key was set before; restore old value on drop.
188                Ok(old) => self.changes.push(EnvDelta::Add(key.to_string(), old)),
189                // Key was not set before; nothing to do.
190                Err(_) => {}
191            }
192
193            unsafe { std::env::remove_var(key) };
194        }
195    }
196
197    impl Drop for EnvScope {
198        #[allow(unsafe_code)]
199        fn drop(&mut self) {
200            // Unwind changes in reverse order.
201            for change in self.changes.drain(..).rev() {
202                match change {
203                    EnvDelta::Add(key, value) => unsafe { std::env::set_var(key, value) },
204                    EnvDelta::Remove(key) => unsafe { std::env::remove_var(key) },
205                }
206            }
207        }
208    }
209
210    #[tokio::test]
211    async fn test_no_detection() {
212        let mut scope = EnvScope::new();
213        scope.unsetenv("GITHUB_ACTIONS");
214        scope.unsetenv("GITLAB_CI");
215        scope.unsetenv("BUILDKITE");
216        scope.unsetenv("CIRCLECI");
217
218        let detector = Detector::new();
219
220        assert!(
221            detector
222                .detect("bupkis")
223                .await
224                .expect("should not error")
225                .is_none()
226        );
227    }
228}