deslicer_cli/
oidc_exchange.rs1use crate::ci::CiPlatform;
2use crate::errors::CliError;
3use serde::Serialize;
4
5#[derive(Serialize)]
6struct CiOidcRequest<'a> {
7 #[serde(skip_serializing_if = "Option::is_none")]
8 environment: Option<&'a str>,
9}
10
11#[derive(serde::Deserialize)]
12struct CiOidcResponse {
13 access_token: String,
14}
15
16pub async fn exchange(
17 observer_api_url: &url::Url,
18 jwt: &str,
19 platform: CiPlatform,
20 environment: Option<&str>,
21) -> Result<String, CliError> {
22 let url = observer_api_url
23 .join("api/v1/auth/ci-oidc")
24 .map_err(|e| CliError::Transport(format!("invalid URL join: {e}")))?;
25
26 let body = match environment {
27 Some(env) => CiOidcRequest {
28 environment: Some(env),
29 },
30 None => CiOidcRequest { environment: None },
31 };
32
33 let http = reqwest::Client::new();
34 let response = http
35 .post(url)
36 .header("Authorization", format!("Bearer {jwt}"))
37 .header("X-Deslicer-CI-Platform", platform.header_value())
38 .header("Content-Type", "application/json")
39 .json(&body)
40 .send()
41 .await
42 .map_err(|e| CliError::Transport(e.to_string()))?;
43
44 let status = response.status();
45 let retry_after = parse_retry_after_header(response.headers());
46 let response_body = response
47 .text()
48 .await
49 .map_err(|e| CliError::Transport(e.to_string()))?;
50
51 if status.is_success() {
52 let parsed: CiOidcResponse = serde_json::from_str(&response_body)
53 .map_err(|e| CliError::Transport(format!("invalid ci-oidc JSON: {e}")))?;
54 return Ok(parsed.access_token);
55 }
56
57 Err(map_exchange_error(status, &response_body, retry_after))
58}
59
60fn map_exchange_error(
61 status: reqwest::StatusCode,
62 body: &str,
63 retry_after_secs: Option<u64>,
64) -> CliError {
65 let message = error_message(body, status);
66 match status.as_u16() {
67 400 => CliError::UnsupportedPlatform(message),
68 401 => CliError::OidcRejected(message),
69 403 => {
70 if mentions_environment(body) {
71 CliError::EnvironmentNotBound(message)
72 } else {
73 CliError::RepoNotAllowlisted(message)
74 }
75 }
76 409 => CliError::AmbiguousBinding(message),
77 429 => CliError::RateLimited {
78 retry_after_secs: retry_after_secs.unwrap_or(30),
79 },
80 500..=599 => CliError::BackendUnavailable(status.to_string()),
81 _ => CliError::Other(message),
82 }
83}
84
85fn error_message(body: &str, status: reqwest::StatusCode) -> String {
86 if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
87 for key in ["detail", "error", "message"] {
88 if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
89 if !text.is_empty() {
90 return text.to_string();
91 }
92 }
93 }
94 }
95 if body.trim().is_empty() {
96 format!("HTTP {status}")
97 } else {
98 body.trim().to_string()
99 }
100}
101
102fn mentions_environment(text: &str) -> bool {
103 text.to_ascii_lowercase().contains("environment")
104}
105
106fn parse_retry_after_header(headers: &reqwest::header::HeaderMap) -> Option<u64> {
107 headers
108 .get(reqwest::header::RETRY_AFTER)
109 .and_then(|v| v.to_str().ok())
110 .and_then(|s| s.trim().parse::<u64>().ok())
111}