Skip to main content

ambient_id/
github.rs

1//! GitHub Actions OIDC token detection.
2
3use reqwest_middleware::ClientWithMiddleware;
4
5use crate::{DetectionState, DetectionStrategy};
6
7/// Possible errors during GitHub Actions OIDC token detection.
8#[derive(Debug, thiserror::Error)]
9pub enum Error {
10    /// The GitHub Actions environment lacks necessary permissions.
11    ///
12    /// This is typically resolved by adding `id-token: write` to the
13    /// job's `permissions` block.
14    #[error("insufficient permissions: {0}")]
15    InsufficientPermissions(&'static str),
16    /// The HTTP request to fetch the ID token failed.
17    #[error("HTTP request failed: {0}")]
18    Request(#[from] reqwest_middleware::Error),
19}
20
21/// The JSON payload returned by GitHub's ID token endpoint.
22#[derive(serde::Deserialize)]
23struct TokenRequestResponse {
24    value: String,
25}
26
27pub(crate) struct GitHubActions {
28    client: ClientWithMiddleware,
29}
30
31impl DetectionStrategy for GitHubActions {
32    type Error = Error;
33
34    fn new(state: &DetectionState) -> Option<Self> {
35        std::env::var("GITHUB_ACTIONS")
36            .ok()
37            // Per GitHub docs, this is exactly "true" when
38            // running in GitHub Actions.
39            .filter(|v| v == "true")
40            .map(|_| GitHubActions {
41                client: state.client.clone(),
42            })
43    }
44
45    /// On GitHub Actions, the OIDC token URL is provided
46    /// via the ACTIONS_ID_TOKEN_REQUEST_URL environment variable.
47    /// We additionally need to fetch the ACTIONS_ID_TOKEN_REQUEST_TOKEN
48    /// environment variable to authenticate the request.
49    ///
50    /// The absence of either variable indicates insufficient permissions.
51    async fn detect(&self, audience: &str) -> Result<crate::IdToken, Self::Error> {
52        let url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL")
53            .map_err(|_| Error::InsufficientPermissions("missing ACTIONS_ID_TOKEN_REQUEST_URL"))?;
54        let token = std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN").map_err(|_| {
55            Error::InsufficientPermissions("missing ACTIONS_ID_TOKEN_REQUEST_TOKEN")
56        })?;
57
58        let resp = self
59            .client
60            .get(&url)
61            .bearer_auth(token)
62            .query(&[("audience", audience)])
63            .send()
64            .await?
65            .error_for_status()
66            .map_err(reqwest_middleware::Error::Reqwest)?
67            .json::<TokenRequestResponse>()
68            .await
69            .map_err(reqwest_middleware::Error::Reqwest)?;
70
71        Ok(crate::IdToken(resp.value.into()))
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use wiremock::{
78        Mock, MockServer,
79        matchers::{method, path},
80    };
81
82    use crate::{DetectionStrategy as _, tests::EnvScope};
83
84    use super::GitHubActions;
85
86    /// Happy path for GitHub Actions OIDC token detection.
87    #[tokio::test]
88    #[cfg_attr(not(feature = "test-github-1p"), ignore)]
89    async fn test_1p_detection_ok() {
90        let _ = EnvScope::new();
91        let state = Default::default();
92        let detector = GitHubActions::new(&state).expect("should detect GitHub Actions");
93        let token = detector
94            .detect("test_1p_detection_ok")
95            .await
96            .expect("should fetch token");
97
98        assert!(token.reveal().starts_with("eyJ")); // JWTs start with "eyJ"
99    }
100
101    // Sad path: we're in GitHub Actions, but `ACTIONS_ID_TOKEN_REQUEST_URL`
102    // is unset.
103    #[tokio::test]
104    #[cfg_attr(not(feature = "test-github-1p"), ignore)]
105    async fn test_1p_detection_missing_url() {
106        let mut scope = EnvScope::new();
107        scope.unsetenv("ACTIONS_ID_TOKEN_REQUEST_URL");
108
109        let state = Default::default();
110        let detector = GitHubActions::new(&state).expect("should detect GitHub Actions");
111
112        match detector.detect("test_1p_detection_missing_url").await {
113            Err(super::Error::InsufficientPermissions(what)) => {
114                assert_eq!(what, "missing ACTIONS_ID_TOKEN_REQUEST_URL")
115            }
116            _ => panic!("expected insufficient permissions error"),
117        }
118    }
119
120    /// Sad path: we're in GitHub Actions, but `ACTIONS_ID_TOKEN_REQUEST_TOKEN`
121    /// is unset.
122    #[tokio::test]
123    #[cfg_attr(not(feature = "test-github-1p"), ignore)]
124    async fn test_1p_detection_missing_token() {
125        let mut scope = EnvScope::new();
126        scope.unsetenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
127
128        let state = Default::default();
129        let detector = GitHubActions::new(&state).expect("should detect GitHub Actions");
130
131        match detector.detect("test_1p_detection_missing_token").await {
132            Err(super::Error::InsufficientPermissions(what)) => {
133                assert_eq!(what, "missing ACTIONS_ID_TOKEN_REQUEST_TOKEN")
134            }
135            _ => panic!("expected insufficient permissions error"),
136        }
137    }
138
139    #[tokio::test]
140    async fn test_not_detected() {
141        let mut scope = EnvScope::new();
142        scope.unsetenv("GITHUB_ACTIONS");
143
144        let state = Default::default();
145        assert!(GitHubActions::new(&state).is_none());
146    }
147
148    #[tokio::test]
149    async fn test_detected() {
150        let mut scope = EnvScope::new();
151        scope.setenv("GITHUB_ACTIONS", "true");
152
153        let state = Default::default();
154        assert!(GitHubActions::new(&state).is_some());
155    }
156
157    #[tokio::test]
158    async fn test_not_detected_wrong_value() {
159        for value in &["", "false", "TRUE", "1", "yes"] {
160            let mut scope = EnvScope::new();
161            scope.setenv("GITHUB_ACTIONS", value);
162
163            let state = Default::default();
164            assert!(GitHubActions::new(&state).is_none());
165        }
166    }
167
168    #[tokio::test]
169    async fn test_error_code() {
170        let mut scope = EnvScope::new();
171        let server = MockServer::start().await;
172
173        scope.setenv("GITHUB_ACTIONS", "true");
174        scope.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "bogus");
175        scope.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", &server.uri());
176
177        Mock::given(method("GET"))
178            .and(path("/"))
179            .respond_with(wiremock::ResponseTemplate::new(503))
180            .mount(&server)
181            .await;
182
183        let state = Default::default();
184        let detector = GitHubActions::new(&state).expect("should detect GitHub Actions");
185        assert!(matches!(
186            detector.detect("test_error_code").await,
187            Err(super::Error::Request(_))
188        ));
189    }
190
191    #[tokio::test]
192    async fn test_invalid_response() {
193        let mut scope = EnvScope::new();
194        let server = MockServer::start().await;
195
196        scope.setenv("GITHUB_ACTIONS", "true");
197        scope.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "bogus");
198        scope.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", &server.uri());
199
200        Mock::given(method("GET"))
201            .and(path("/"))
202            .respond_with(
203                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
204                    "bogus": "response"
205                })),
206            )
207            .mount(&server)
208            .await;
209
210        let state = Default::default();
211        let detector = GitHubActions::new(&state).expect("should detect GitHub Actions");
212        assert!(matches!(
213            detector.detect("test_invalid_response").await,
214            Err(super::Error::Request(_))
215        ));
216    }
217
218    #[tokio::test]
219    async fn test_ok() {
220        let mut scope = EnvScope::new();
221        let server = MockServer::start().await;
222
223        scope.setenv("GITHUB_ACTIONS", "true");
224        scope.setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "bogus");
225        scope.setenv("ACTIONS_ID_TOKEN_REQUEST_URL", &server.uri());
226
227        Mock::given(method("GET"))
228            .and(path("/"))
229            .respond_with(
230                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
231                    "value": "test-ok-token"
232                })),
233            )
234            .mount(&server)
235            .await;
236
237        let state = Default::default();
238        let detector = GitHubActions::new(&state).expect("should detect GitHub Actions");
239        let token = detector
240            .detect("test_ok")
241            .await
242            .expect("should fetch token");
243
244        assert_eq!(token.reveal(), "test-ok-token");
245    }
246}