Skip to main content

auths_infra_http/
oidc_platforms.rs

1/// GitHub Actions OIDC token acquisition.
2///
3/// # Usage
4///
5/// ```ignore
6/// let token = github_actions_oidc_token().await?;
7/// ```
8#[allow(clippy::disallowed_methods)] // CI platform boundary: GitHub Actions env vars
9pub async fn github_actions_oidc_token() -> Result<String, String> {
10    let actions_id_token_url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL").map_err(|_| {
11        "ACTIONS_ID_TOKEN_REQUEST_URL not set (not running in GitHub Actions)".to_string()
12    })?;
13
14    let actions_id_token_request_token =
15        std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN").map_err(|_| {
16            "ACTIONS_ID_TOKEN_REQUEST_TOKEN not set (not running in GitHub Actions)".to_string()
17        })?;
18
19    let client = crate::default_http_client();
20
21    let response = client
22        .get(&actions_id_token_url)
23        .bearer_auth(&actions_id_token_request_token)
24        .send()
25        .await
26        .map_err(|e| format!("failed to acquire GitHub Actions OIDC token: {}", e))?;
27
28    let json: serde_json::Value = response
29        .json()
30        .await
31        .map_err(|e| format!("failed to parse GitHub Actions token response: {}", e))?;
32
33    json.get("token")
34        .and_then(|t| t.as_str())
35        .map(|s| s.to_string())
36        .ok_or_else(|| "GitHub Actions token response missing 'token' field".to_string())
37}
38
39/// GitLab CI OIDC token acquisition.
40///
41/// # Usage
42///
43/// ```ignore
44/// let token = gitlab_ci_oidc_token().await?;
45/// ```
46#[allow(clippy::disallowed_methods)] // CI platform boundary: GitLab env vars
47pub async fn gitlab_ci_oidc_token() -> Result<String, String> {
48    let ci_job_jwt_v2 = std::env::var("CI_JOB_JWT_V2")
49        .map_err(|_| "CI_JOB_JWT_V2 not set (not running in GitLab CI)".to_string())?;
50
51    Ok(ci_job_jwt_v2)
52}
53
54/// CircleCI OIDC token acquisition.
55///
56/// # Usage
57///
58/// ```ignore
59/// let token = circleci_oidc_token().await?;
60/// ```
61#[allow(clippy::disallowed_methods)] // CI platform boundary: CircleCI env vars
62pub async fn circleci_oidc_token() -> Result<String, String> {
63    let circle_oidc_token = std::env::var("CIRCLE_OIDC_TOKEN")
64        .map_err(|_| "CIRCLE_OIDC_TOKEN not set (not running in CircleCI)".to_string())?;
65
66    Ok(circle_oidc_token)
67}
68
69/// Normalize platform-specific OIDC claims to a standard WorkloadIdentity format.
70///
71/// Maps GitHub Actions, GitLab CI, and CircleCI claims to common fields:
72/// - repository/project name
73/// - actor/user identifier
74/// - workflow/pipeline identifier
75/// - job identifier
76pub fn normalize_workload_claims(
77    platform: &str,
78    claims: serde_json::Value,
79) -> Result<serde_json::Map<String, serde_json::Value>, String> {
80    let mut normalized = serde_json::Map::new();
81
82    match platform {
83        "github" => {
84            // GitHub Actions standard claims
85            if let Some(repo) = claims.get("repository").and_then(|v| v.as_str()) {
86                normalized.insert(
87                    "repository".to_string(),
88                    serde_json::Value::String(repo.to_string()),
89                );
90            }
91            if let Some(actor) = claims.get("actor").and_then(|v| v.as_str()) {
92                normalized.insert(
93                    "actor".to_string(),
94                    serde_json::Value::String(actor.to_string()),
95                );
96            }
97            if let Some(workflow) = claims.get("workflow").and_then(|v| v.as_str()) {
98                normalized.insert(
99                    "workflow".to_string(),
100                    serde_json::Value::String(workflow.to_string()),
101                );
102            }
103            if let Some(job_workflow_ref) = claims.get("job_workflow_ref").and_then(|v| v.as_str())
104            {
105                normalized.insert(
106                    "job_workflow_ref".to_string(),
107                    serde_json::Value::String(job_workflow_ref.to_string()),
108                );
109            }
110            if let Some(run_id) = claims.get("run_id").and_then(|v| v.as_str()) {
111                normalized.insert(
112                    "run_id".to_string(),
113                    serde_json::Value::String(run_id.to_string()),
114                );
115            }
116            if let Some(run_number) = claims.get("run_number").and_then(|v| v.as_str()) {
117                normalized.insert(
118                    "run_number".to_string(),
119                    serde_json::Value::String(run_number.to_string()),
120                );
121            }
122            Ok(normalized)
123        }
124        "gitlab" => {
125            // GitLab CI ID token claims
126            if let Some(project_id) = claims.get("project_id").and_then(|v| v.as_i64()) {
127                normalized.insert(
128                    "project_id".to_string(),
129                    serde_json::Value::Number(project_id.into()),
130                );
131            }
132            if let Some(project_path) = claims.get("project_path").and_then(|v| v.as_str()) {
133                normalized.insert(
134                    "project_path".to_string(),
135                    serde_json::Value::String(project_path.to_string()),
136                );
137            }
138            if let Some(user_id) = claims.get("user_id").and_then(|v| v.as_i64()) {
139                normalized.insert(
140                    "user_id".to_string(),
141                    serde_json::Value::Number(user_id.into()),
142                );
143            }
144            if let Some(user_login) = claims.get("user_login").and_then(|v| v.as_str()) {
145                normalized.insert(
146                    "user_login".to_string(),
147                    serde_json::Value::String(user_login.to_string()),
148                );
149            }
150            if let Some(pipeline_id) = claims.get("pipeline_id").and_then(|v| v.as_i64()) {
151                normalized.insert(
152                    "pipeline_id".to_string(),
153                    serde_json::Value::Number(pipeline_id.into()),
154                );
155            }
156            if let Some(job_id) = claims.get("job_id").and_then(|v| v.as_i64()) {
157                normalized.insert(
158                    "job_id".to_string(),
159                    serde_json::Value::Number(job_id.into()),
160                );
161            }
162            Ok(normalized)
163        }
164        "circleci" => {
165            // CircleCI OIDC token claims
166            if let Some(project_id) = claims.get("project_id").and_then(|v| v.as_str()) {
167                normalized.insert(
168                    "project_id".to_string(),
169                    serde_json::Value::String(project_id.to_string()),
170                );
171            }
172            if let Some(project_name) = claims.get("project_name").and_then(|v| v.as_str()) {
173                normalized.insert(
174                    "project_name".to_string(),
175                    serde_json::Value::String(project_name.to_string()),
176                );
177            }
178            if let Some(workflow_id) = claims.get("workflow_id").and_then(|v| v.as_str()) {
179                normalized.insert(
180                    "workflow_id".to_string(),
181                    serde_json::Value::String(workflow_id.to_string()),
182                );
183            }
184            if let Some(job_number) = claims.get("job_number").and_then(|v| v.as_str()) {
185                normalized.insert(
186                    "job_number".to_string(),
187                    serde_json::Value::String(job_number.to_string()),
188                );
189            }
190            if let Some(org_id) = claims.get("org_id").and_then(|v| v.as_str()) {
191                normalized.insert(
192                    "org_id".to_string(),
193                    serde_json::Value::String(org_id.to_string()),
194                );
195            }
196            Ok(normalized)
197        }
198        _ => Err(format!("unknown OIDC platform: {}", platform)),
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_normalize_github_claims() {
208        let claims = serde_json::json!({
209            "repository": "owner/repo",
210            "actor": "github-user",
211            "workflow": "test.yml",
212            "job_workflow_ref": "owner/repo/.github/workflows/test.yml@main",
213            "run_id": "12345",
214            "run_number": "1"
215        });
216
217        let result = normalize_workload_claims("github", claims);
218        assert!(result.is_ok());
219        let normalized = result.unwrap();
220        assert_eq!(
221            normalized.get("repository").and_then(|v| v.as_str()),
222            Some("owner/repo")
223        );
224        assert_eq!(
225            normalized.get("actor").and_then(|v| v.as_str()),
226            Some("github-user")
227        );
228    }
229
230    #[test]
231    fn test_normalize_gitlab_claims() {
232        let claims = serde_json::json!({
233            "project_id": 123,
234            "project_path": "group/project",
235            "user_id": 456,
236            "user_login": "gitlab-user",
237            "pipeline_id": 789,
238            "job_id": 999
239        });
240
241        let result = normalize_workload_claims("gitlab", claims);
242        assert!(result.is_ok());
243        let normalized = result.unwrap();
244        assert_eq!(
245            normalized.get("project_path").and_then(|v| v.as_str()),
246            Some("group/project")
247        );
248    }
249
250    #[test]
251    fn test_normalize_circleci_claims() {
252        let claims = serde_json::json!({
253            "project_id": "abc123",
254            "project_name": "my-project",
255            "workflow_id": "def456",
256            "job_number": "1",
257            "org_id": "ghi789"
258        });
259
260        let result = normalize_workload_claims("circleci", claims);
261        assert!(result.is_ok());
262        let normalized = result.unwrap();
263        assert_eq!(
264            normalized.get("project_name").and_then(|v| v.as_str()),
265            Some("my-project")
266        );
267    }
268
269    #[test]
270    fn test_unknown_platform() {
271        let claims = serde_json::json!({});
272        let result = normalize_workload_claims("unknown", claims);
273        assert!(result.is_err());
274    }
275}