deslicer_cli/ci/
github.rs1use super::{OidcError, OidcTokenProvider};
2
3pub struct GithubProvider;
4
5fn percent_encode_query(value: &str) -> String {
6 let mut encoded = String::with_capacity(value.len());
7 for byte in value.bytes() {
8 match byte {
9 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
10 encoded.push(byte as char);
11 }
12 _ => {
13 encoded.push('%');
14 encoded.push_str(&format!("{byte:02X}"));
15 }
16 }
17 }
18 encoded
19}
20
21fn required_env(name: &str) -> Result<String, OidcError> {
22 std::env::var(name).map_err(|_| OidcError::MissingEnv(name.to_string()))
23}
24
25#[derive(serde::Deserialize)]
26struct GithubTokenResponse {
27 value: String,
28}
29
30#[async_trait::async_trait]
31impl OidcTokenProvider for GithubProvider {
32 async fn fetch_token(&self, audience: &str) -> Result<String, OidcError> {
33 let request_token = required_env("ACTIONS_ID_TOKEN_REQUEST_TOKEN")?;
34 let request_url = required_env("ACTIONS_ID_TOKEN_REQUEST_URL")?;
35
36 let url = format!("{request_url}&audience={}", percent_encode_query(audience));
37
38 let client = reqwest::Client::new();
39 let response = client
40 .get(&url)
41 .header("Authorization", format!("Bearer {request_token}"))
42 .send()
43 .await
44 .map_err(|e| OidcError::Http(e.to_string()))?;
45
46 if !response.status().is_success() {
47 return Err(OidcError::Http(format!(
48 "GitHub OIDC request failed with status {}",
49 response.status()
50 )));
51 }
52
53 let body = response
54 .json::<GithubTokenResponse>()
55 .await
56 .map_err(|e| OidcError::Http(e.to_string()))?;
57
58 Ok(body.value)
59 }
60}
61
62#[cfg(test)]
63#[allow(clippy::await_holding_lock)]
66mod tests {
67 use super::*;
68 use std::sync::Mutex;
69 use wiremock::matchers::{header, method, query_param};
70 use wiremock::{Mock, MockServer, ResponseTemplate};
71
72 static ENV_LOCK: Mutex<()> = Mutex::new(());
73
74 #[tokio::test]
75 async fn fetch_token_returns_jwt_from_mock_server() {
76 let _guard = ENV_LOCK.lock().unwrap();
77
78 let server = MockServer::start().await;
79 let audience = "https://api.deslicer.ai";
80
81 Mock::given(method("GET"))
82 .and(header("Authorization", "Bearer dummy-request-token"))
83 .and(query_param("audience", audience))
84 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
85 "value": "github-jwt-token"
86 })))
87 .mount(&server)
88 .await;
89
90 std::env::set_var(
91 "ACTIONS_ID_TOKEN_REQUEST_URL",
92 format!("{}?something=1", server.uri()),
93 );
94 std::env::set_var("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "dummy-request-token");
95
96 let token = GithubProvider.fetch_token(audience).await.unwrap();
97
98 assert_eq!(token, "github-jwt-token");
99
100 std::env::remove_var("ACTIONS_ID_TOKEN_REQUEST_URL");
101 std::env::remove_var("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
102 }
103
104 #[tokio::test]
105 async fn fetch_token_errors_when_env_missing() {
106 let _guard = ENV_LOCK.lock().unwrap();
107
108 std::env::remove_var("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
109 std::env::remove_var("ACTIONS_ID_TOKEN_REQUEST_URL");
110
111 let err = GithubProvider
112 .fetch_token("https://api.deslicer.ai")
113 .await
114 .unwrap_err();
115
116 assert!(matches!(err, OidcError::MissingEnv(_)));
117 }
118}