Skip to main content

deslicer_cli/
oidc_exchange.rs

1use 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}