1use reqwest_middleware::ClientWithMiddleware;
4
5use crate::{DetectionState, DetectionStrategy};
6
7#[derive(Debug, thiserror::Error)]
9pub enum Error {
10 #[error("insufficient permissions: {0}")]
15 InsufficientPermissions(&'static str),
16 #[error("HTTP request failed: {0}")]
18 Request(#[from] reqwest_middleware::Error),
19}
20
21#[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 .filter(|v| v == "true")
40 .map(|_| GitHubActions {
41 client: state.client.clone(),
42 })
43 }
44
45 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 #[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")); }
100
101 #[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 #[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}