reqsign_aliyun_oss/provide_credential/
assume_role_with_oidc.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use crate::{Credential, constants::*};
19use async_trait::async_trait;
20use reqsign_core::Result;
21use reqsign_core::time::Timestamp;
22use reqsign_core::{Context, ProvideCredential};
23use serde::Deserialize;
24
25/// AssumeRoleWithOidcCredentialProvider loads credential via assume role with OIDC.
26///
27/// This provider reads configuration from environment variables at runtime:
28/// - `ALIBABA_CLOUD_ROLE_ARN`: The ARN of the role to assume
29/// - `ALIBABA_CLOUD_OIDC_PROVIDER_ARN`: The ARN of the OIDC provider
30/// - `ALIBABA_CLOUD_OIDC_TOKEN_FILE`: Path to the OIDC token file
31/// - `ALIBABA_CLOUD_STS_ENDPOINT`: Optional custom STS endpoint
32#[derive(Debug, Default, Clone)]
33pub struct AssumeRoleWithOidcCredentialProvider {
34    sts_endpoint: Option<String>,
35}
36
37impl AssumeRoleWithOidcCredentialProvider {
38    /// Create a new `AssumeRoleWithOidcCredentialProvider` instance.
39    /// This will read configuration from environment variables at runtime.
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    /// Set the STS endpoint.
45    pub fn with_sts_endpoint(mut self, endpoint: impl Into<String>) -> Self {
46        self.sts_endpoint = Some(endpoint.into());
47        self
48    }
49
50    fn get_sts_endpoint(&self, envs: &std::collections::HashMap<String, String>) -> String {
51        if let Some(endpoint) = &self.sts_endpoint {
52            return endpoint.clone();
53        }
54
55        match envs.get(ALIBABA_CLOUD_STS_ENDPOINT) {
56            Some(endpoint) => format!("https://{endpoint}"),
57            None => "https://sts.aliyuncs.com".to_string(),
58        }
59    }
60}
61
62#[async_trait]
63impl ProvideCredential for AssumeRoleWithOidcCredentialProvider {
64    type Credential = Credential;
65
66    async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
67        let envs = ctx.env_vars();
68
69        // Get values from environment variables
70        let token_file = envs.get(ALIBABA_CLOUD_OIDC_TOKEN_FILE);
71        let role_arn = envs.get(ALIBABA_CLOUD_ROLE_ARN);
72        let provider_arn = envs.get(ALIBABA_CLOUD_OIDC_PROVIDER_ARN);
73
74        let (token_file, role_arn, provider_arn) = match (token_file, role_arn, provider_arn) {
75            (Some(tf), Some(ra), Some(pa)) => (tf, ra, pa),
76            _ => return Ok(None),
77        };
78
79        let token = ctx.file_read(token_file).await?;
80        let token = String::from_utf8(token)?;
81        let role_session_name = "reqsign"; // Default session name
82
83        // Construct request to Aliyun STS Service.
84        let url = format!(
85            "{}/?Action=AssumeRoleWithOIDC&OIDCProviderArn={}&RoleArn={}&RoleSessionName={}&Format=JSON&Version=2015-04-01&Timestamp={}&OIDCToken={}",
86            self.get_sts_endpoint(&envs),
87            provider_arn,
88            role_arn,
89            role_session_name,
90            Timestamp::now().format_rfc3339_zulu(),
91            token
92        );
93
94        let req = http::Request::builder()
95            .method(http::Method::GET)
96            .uri(&url)
97            .header(
98                http::header::CONTENT_TYPE,
99                "application/x-www-form-urlencoded",
100            )
101            .body(Vec::new())?;
102
103        let resp = ctx.http_send(req.map(|body| body.into())).await?;
104
105        if resp.status() != http::StatusCode::OK {
106            let content = String::from_utf8_lossy(resp.body());
107            return Err(reqsign_core::Error::unexpected(format!(
108                "request to Aliyun STS Services failed: {content}"
109            )));
110        }
111
112        let resp: AssumeRoleWithOidcResponse =
113            serde_json::from_slice(resp.body()).map_err(|e| {
114                reqsign_core::Error::unexpected(format!("Failed to parse STS response: {e}"))
115            })?;
116        let resp_cred = resp.credentials;
117
118        let cred = Credential {
119            access_key_id: resp_cred.access_key_id,
120            access_key_secret: resp_cred.access_key_secret,
121            security_token: Some(resp_cred.security_token),
122            expires_in: Some(resp_cred.expiration.parse()?),
123        };
124
125        Ok(Some(cred))
126    }
127}
128
129#[derive(Default, Debug, Deserialize)]
130#[serde(default)]
131struct AssumeRoleWithOidcResponse {
132    #[serde(rename = "Credentials")]
133    credentials: AssumeRoleWithOidcCredentials,
134}
135
136#[derive(Default, Debug, Deserialize)]
137#[serde(default, rename_all = "PascalCase")]
138struct AssumeRoleWithOidcCredentials {
139    access_key_id: String,
140    access_key_secret: String,
141    security_token: String,
142    expiration: String,
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use reqsign_core::StaticEnv;
149    use reqsign_file_read_tokio::TokioFileRead;
150    use reqsign_http_send_reqwest::ReqwestHttpSend;
151    use std::collections::HashMap;
152
153    #[test]
154    fn test_parse_assume_role_with_oidc_response() -> Result<()> {
155        let content = r#"{
156    "RequestId": "3D57EAD2-8723-1F26-B69C-F8707D8B565D",
157    "OIDCTokenInfo": {
158        "Subject": "KryrkIdjylZb7agUgCEf****",
159        "Issuer": "https://dev-xxxxxx.okta.com",
160        "ClientIds": "496271242565057****"
161    },
162    "AssumedRoleUser": {
163        "AssumedRoleId": "33157794895460****",
164        "Arn": "acs:ram::113511544585****:role/testoidc/TestOidcAssumedRoleSession"
165    },
166    "Credentials": {
167        "SecurityToken": "CAIShwJ1q6Ft5B2yfSjIr5bSEsj4g7BihPWGWHz****",
168        "Expiration": "2021-10-20T04:27:09Z",
169        "AccessKeySecret": "CVwjCkNzTMupZ8NbTCxCBRq3K16jtcWFTJAyBEv2****",
170        "AccessKeyId": "STS.NUgYrLnoC37mZZCNnAbez****"
171    }
172}"#;
173
174        let resp: AssumeRoleWithOidcResponse =
175            serde_json::from_str(content).expect("json deserialize must success");
176
177        assert_eq!(
178            &resp.credentials.access_key_id,
179            "STS.NUgYrLnoC37mZZCNnAbez****"
180        );
181        assert_eq!(
182            &resp.credentials.access_key_secret,
183            "CVwjCkNzTMupZ8NbTCxCBRq3K16jtcWFTJAyBEv2****"
184        );
185        assert_eq!(
186            &resp.credentials.security_token,
187            "CAIShwJ1q6Ft5B2yfSjIr5bSEsj4g7BihPWGWHz****"
188        );
189        assert_eq!(&resp.credentials.expiration, "2021-10-20T04:27:09Z");
190
191        Ok(())
192    }
193
194    #[tokio::test]
195    async fn test_assume_role_with_oidc_loader_without_config() {
196        let ctx = Context::new()
197            .with_file_read(TokioFileRead)
198            .with_http_send(ReqwestHttpSend::default());
199        let ctx = ctx.with_env(StaticEnv {
200            home_dir: None,
201            envs: HashMap::new(),
202        });
203
204        let loader = AssumeRoleWithOidcCredentialProvider::new();
205        let credential = loader.provide_credential(&ctx).await.unwrap();
206
207        assert!(credential.is_none());
208    }
209}