1use crate::aws::AwsClientConfigExt;
2use std::fmt::Debug;
3
4use crate::aws::aws_request_utils::{AwsRequestBuilderExt, AwsSignConfig};
5use crate::aws::{AwsClientConfig, AwsCredentials};
6use alien_client_core::{ErrorData, Result};
7use alien_error::ContextError;
8use bon::Builder;
9use form_urlencoded;
10
11#[cfg(feature = "test-utils")]
12use mockall::automock;
13use quick_xml;
14use reqwest::{Client, StatusCode};
15use serde::de::DeserializeOwned;
16use serde::{Deserialize, Serialize};
17
18#[cfg_attr(feature = "test-utils", automock)]
19#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
20#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
21pub trait StsApi: Send + Sync + Debug {
22 async fn assume_role(&self, request: AssumeRoleRequest) -> Result<AssumeRoleResponse>;
23 async fn assume_role_with_web_identity(
24 &self,
25 request: AssumeRoleWithWebIdentityRequest,
26 ) -> Result<AssumeRoleWithWebIdentityResponse>;
27 async fn get_caller_identity(&self) -> Result<GetCallerIdentityResponse>;
28}
29
30#[derive(Debug, Clone)]
32pub struct StsClient {
33 client: Client,
34 config: AwsClientConfig,
35}
36
37impl StsClient {
38 pub fn new(client: Client, config: AwsClientConfig) -> Self {
39 Self { client, config }
40 }
41
42 async fn sign_config(&self, operation_name: &str) -> Result<AwsSignConfig> {
43 let config = if operation_name == "AssumeRoleWithWebIdentity" {
44 self.config.clone()
45 } else if matches!(self.config.credentials, AwsCredentials::WebIdentity { .. }) {
46 self.config.get_web_identity_credentials().await?
47 } else {
48 self.config.clone()
49 };
50
51 Ok(AwsSignConfig {
52 service_name: "sts".into(),
53 region: config.region.clone(),
54 credentials: config.get_credentials(),
55 signing_region: None,
56 })
57 }
58
59 fn get_base_url(&self) -> String {
60 if let Some(override_url) = self.config.get_service_endpoint_option("sts") {
61 override_url.to_string()
62 } else {
63 format!("https://sts.{}.amazonaws.com", self.config.region)
64 }
65 }
66
67 fn build_form_body(action: &str, version: &str, params: Vec<(String, String)>) -> String {
68 let mut all = vec![
69 ("Action".to_string(), action.to_string()),
70 ("Version".to_string(), version.to_string()),
71 ];
72 all.extend(params);
73
74 all.into_iter()
75 .map(|(k, v)| {
76 format!(
77 "{}={}",
78 k,
79 form_urlencoded::byte_serialize(v.as_bytes()).collect::<String>()
80 )
81 })
82 .collect::<Vec<String>>()
83 .join("&")
84 }
85
86 async fn post_xml<T: DeserializeOwned + Send + 'static>(
88 &self,
89 body: String,
90 operation_name: &str,
91 resource_name: &str,
92 ) -> Result<T> {
93 let base_url = self.get_base_url();
94 let url = format!("{}/", base_url.trim_end_matches('/'));
95 let builder = self
96 .client
97 .post(&url)
98 .content_type_form()
99 .body(body.clone());
100
101 let sign_config = self.sign_config(operation_name).await?;
102 let result = crate::aws::aws_request_utils::sign_send_xml(builder, &sign_config).await;
103
104 match result {
105 Ok(v) => Ok(v),
106 Err(e) => {
107 if let Some(ErrorData::HttpResponseError {
108 http_status,
109 http_response_text: Some(ref text),
110 ..
111 }) = &e.error
112 {
113 let status = StatusCode::from_u16(*http_status)
114 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
115 if let Some(mapped) =
116 Self::map_sts_error(status, text, operation_name, resource_name, &body)
117 {
118 Err(e.context(mapped))
119 } else {
120 Err(e)
122 }
123 } else {
124 Err(e)
125 }
126 }
127 }
128 }
129
130 fn map_sts_error(
131 status: StatusCode,
132 error_body: &str,
133 operation: &str,
134 resource_name: &str,
135 request_body: &str,
136 ) -> Option<ErrorData> {
137 let parsed_error: std::result::Result<StsErrorResponse, _> =
139 quick_xml::de::from_str(error_body);
140
141 let (error_code, error_message) = match parsed_error {
142 Ok(e) => (
143 e.error.code.unwrap_or_else(|| "UnknownErrorCode".into()),
144 e.error.message.unwrap_or_else(|| "Unknown error".into()),
145 ),
146 Err(_) => {
147 return None;
149 }
150 };
151
152 Some(match error_code.as_str() {
153 "AccessDenied"
155 | "AccessDeniedException"
156 | "UnauthorizedOperation"
157 | "InvalidUserID.NotFound"
158 | "AuthFailure"
159 | "SignatureDoesNotMatch"
160 | "TokenRefreshRequired"
161 | "NotAuthorized"
162 | "InvalidClientTokenId"
163 | "MissingAuthenticationToken"
164 | "OptInRequired" => ErrorData::RemoteAccessDenied {
165 resource_type: "STS Resource".into(),
166 resource_name: resource_name.into(),
167 },
168
169 "Throttling" | "ThrottlingException" | "RequestLimitExceeded" => {
171 ErrorData::RateLimitExceeded {
172 message: error_message,
173 }
174 }
175
176 "ServiceUnavailable" | "InternalFailure" | "ServiceFailure" => {
178 ErrorData::RemoteServiceUnavailable {
179 message: error_message,
180 }
181 }
182
183 "ExpiredToken" => ErrorData::AuthenticationError {
185 message: "Security token has expired".into(),
186 },
187
188 "MalformedPolicyDocument" => ErrorData::InvalidInput {
189 message: format!("Malformed policy document: {}", error_message),
190 field_name: Some("PolicyDocument".into()),
191 },
192
193 "PackedPolicyTooLarge" => ErrorData::InvalidInput {
194 message: format!("Policy document is too large: {}", error_message),
195 field_name: Some("PolicyDocument".into()),
196 },
197
198 "RegionDisabled" => ErrorData::RemoteServiceUnavailable {
199 message: format!("STS is not enabled in this region: {}", error_message),
200 },
201
202 "InvalidParameterValue" => ErrorData::InvalidInput {
203 message: error_message,
204 field_name: None,
205 },
206
207 _ => match status {
209 StatusCode::CONFLICT => ErrorData::RemoteResourceConflict {
210 message: error_message,
211 resource_type: "STS Resource".into(),
212 resource_name: resource_name.into(),
213 },
214 StatusCode::NOT_FOUND => ErrorData::RemoteResourceNotFound {
215 resource_type: "STS Resource".into(),
216 resource_name: resource_name.into(),
217 },
218 StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => ErrorData::RemoteAccessDenied {
219 resource_type: "STS Resource".into(),
220 resource_name: resource_name.into(),
221 },
222 StatusCode::TOO_MANY_REQUESTS => ErrorData::RateLimitExceeded {
223 message: error_message,
224 },
225 StatusCode::SERVICE_UNAVAILABLE
226 | StatusCode::BAD_GATEWAY
227 | StatusCode::GATEWAY_TIMEOUT => ErrorData::RemoteServiceUnavailable {
228 message: error_message,
229 },
230 _ => ErrorData::HttpResponseError {
231 message: format!("STS {operation} failed: {error_message}"),
232 url: "sts.amazonaws.com".into(),
233 http_status: status.as_u16(),
234 http_response_text: Some(error_body.into()),
235 http_request_text: Some(request_body.into()),
236 },
237 },
238 })
239 }
240}
241
242#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
243#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
244impl StsApi for StsClient {
245 async fn assume_role(&self, request: AssumeRoleRequest) -> Result<AssumeRoleResponse> {
246 let mut params: Vec<(String, String)> = vec![
247 ("RoleArn".to_string(), request.role_arn.clone()),
248 (
249 "RoleSessionName".to_string(),
250 request.role_session_name.clone(),
251 ),
252 ];
253
254 if let Some(duration) = request.duration_seconds {
255 params.push(("DurationSeconds".to_string(), duration.to_string()));
256 }
257 if let Some(ref external_id) = request.external_id {
258 params.push(("ExternalId".to_string(), external_id.clone()));
259 }
260 if let Some(ref policy) = request.policy {
261 params.push(("Policy".to_string(), policy.clone()));
262 }
263 if let Some(ref serial_number) = request.serial_number {
264 params.push(("SerialNumber".to_string(), serial_number.clone()));
265 }
266 if let Some(ref token_code) = request.token_code {
267 params.push(("TokenCode".to_string(), token_code.clone()));
268 }
269 if let Some(ref source_identity) = request.source_identity {
270 params.push(("SourceIdentity".to_string(), source_identity.clone()));
271 }
272
273 if let Some(ref policy_arns) = request.policy_arns {
275 for (i, policy_arn) in policy_arns.iter().enumerate() {
276 params.push((
277 format!("PolicyArns.member.{}.arn", i + 1),
278 policy_arn.clone(),
279 ));
280 }
281 }
282
283 if let Some(ref tags) = request.tags {
285 for (i, tag) in tags.iter().enumerate() {
286 params.push((format!("Tags.member.{}.Key", i + 1), tag.key.clone()));
287 params.push((format!("Tags.member.{}.Value", i + 1), tag.value.clone()));
288 }
289 }
290
291 if let Some(ref transitive_tag_keys) = request.transitive_tag_keys {
293 for (i, key) in transitive_tag_keys.iter().enumerate() {
294 params.push((format!("TransitiveTagKeys.member.{}", i + 1), key.clone()));
295 }
296 }
297
298 let body = Self::build_form_body("AssumeRole", "2011-06-15", params);
299 self.post_xml(body, "AssumeRole", &request.role_arn).await
300 }
301
302 async fn assume_role_with_web_identity(
303 &self,
304 request: AssumeRoleWithWebIdentityRequest,
305 ) -> Result<AssumeRoleWithWebIdentityResponse> {
306 let mut params: Vec<(String, String)> = vec![
307 ("RoleArn".to_string(), request.role_arn.clone()),
308 (
309 "RoleSessionName".to_string(),
310 request.role_session_name.clone(),
311 ),
312 (
313 "WebIdentityToken".to_string(),
314 request.web_identity_token.clone(),
315 ),
316 ];
317
318 if let Some(duration) = request.duration_seconds {
319 params.push(("DurationSeconds".to_string(), duration.to_string()));
320 }
321 if let Some(ref policy) = request.policy {
322 params.push(("Policy".to_string(), policy.clone()));
323 }
324 if let Some(ref provider_id) = request.provider_id {
325 params.push(("ProviderId".to_string(), provider_id.clone()));
326 }
327
328 if let Some(ref policy_arns) = request.policy_arns {
330 for (i, policy_arn) in policy_arns.iter().enumerate() {
331 params.push((
332 format!("PolicyArns.member.{}.arn", i + 1),
333 policy_arn.clone(),
334 ));
335 }
336 }
337
338 let body = Self::build_form_body("AssumeRoleWithWebIdentity", "2011-06-15", params);
339 self.post_xml(body, "AssumeRoleWithWebIdentity", &request.role_arn)
340 .await
341 }
342
343 async fn get_caller_identity(&self) -> Result<GetCallerIdentityResponse> {
344 let params = vec![];
345 let body = Self::build_form_body("GetCallerIdentity", "2011-06-15", params);
346 self.post_xml(body, "GetCallerIdentity", "caller").await
347 }
348}
349
350#[derive(Deserialize, Debug)]
355#[serde(rename_all = "PascalCase")]
356struct StsErrorResponse {
357 pub error: StsErrorDetails,
358}
359
360#[derive(Deserialize, Debug)]
361#[serde(rename_all = "PascalCase")]
362struct StsErrorDetails {
363 pub code: Option<String>,
364 pub message: Option<String>,
365}
366
367#[derive(Serialize, Debug, Clone, Builder)]
372#[serde(rename_all = "PascalCase")]
373pub struct AssumeRoleRequest {
374 pub role_arn: String,
376 pub role_session_name: String,
378 pub duration_seconds: Option<i32>,
380 pub external_id: Option<String>,
382 pub policy: Option<String>,
384 pub policy_arns: Option<Vec<String>>,
386 pub serial_number: Option<String>,
388 pub token_code: Option<String>,
390 pub source_identity: Option<String>,
392 pub tags: Option<Vec<Tag>>,
394 pub transitive_tag_keys: Option<Vec<String>>,
396}
397
398#[derive(Serialize, Debug, Clone, Builder)]
399#[serde(rename_all = "PascalCase")]
400pub struct AssumeRoleWithWebIdentityRequest {
401 pub role_arn: String,
403 pub role_session_name: String,
405 pub web_identity_token: String,
407 pub duration_seconds: Option<i32>,
409 pub policy: Option<String>,
411 pub policy_arns: Option<Vec<String>>,
413 pub provider_id: Option<String>,
415}
416
417#[derive(Serialize, Deserialize, Debug, Clone)]
418#[serde(rename_all = "PascalCase")]
419pub struct Tag {
420 pub key: String,
421 pub value: String,
422}
423
424#[derive(Deserialize, Debug)]
425#[serde(rename_all = "PascalCase")]
426pub struct AssumeRoleResponse {
427 pub assume_role_result: AssumeRoleResult,
428}
429
430#[derive(Deserialize, Debug)]
431#[serde(rename_all = "PascalCase")]
432pub struct AssumeRoleWithWebIdentityResponse {
433 pub assume_role_with_web_identity_result: AssumeRoleWithWebIdentityResult,
434}
435
436#[derive(Deserialize, Debug)]
437#[serde(rename_all = "PascalCase")]
438pub struct AssumeRoleWithWebIdentityResult {
439 pub assumed_role_user: AssumedRoleUser,
440 pub credentials: Credentials,
441 pub packed_policy_size: Option<i32>,
442 pub provider: Option<String>,
443 pub audience: Option<String>,
444 pub source_identity: Option<String>,
445 pub subject_from_web_identity_token: Option<String>,
446}
447
448#[derive(Deserialize, Debug)]
449#[serde(rename_all = "PascalCase")]
450pub struct AssumeRoleResult {
451 pub assumed_role_user: AssumedRoleUser,
452 pub credentials: Credentials,
453 pub packed_policy_size: Option<i32>,
454 pub source_identity: Option<String>,
455}
456
457#[derive(Deserialize, Debug)]
458#[serde(rename_all = "PascalCase")]
459pub struct AssumedRoleUser {
460 pub arn: String,
461 pub assumed_role_id: String,
462}
463
464#[derive(Deserialize, Debug)]
465#[serde(rename_all = "PascalCase")]
466pub struct Credentials {
467 pub access_key_id: String,
468 pub secret_access_key: String,
469 pub session_token: String,
470 pub expiration: String,
471}
472
473#[derive(Deserialize, Debug)]
474#[serde(rename_all = "PascalCase")]
475pub struct GetCallerIdentityResponse {
476 pub get_caller_identity_result: GetCallerIdentityResult,
477}
478
479#[derive(Deserialize, Debug)]
480#[serde(rename_all = "PascalCase")]
481pub struct GetCallerIdentityResult {
482 pub arn: Option<String>,
483 pub user_id: Option<String>,
484 pub account: Option<String>,
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use alien_core::{AwsServiceOverrides, AwsWebIdentityConfig};
491 use std::collections::HashMap;
492 use std::io::{Read, Write};
493 use std::net::{TcpListener, TcpStream};
494 use std::sync::{Arc, Mutex};
495
496 #[tokio::test]
497 async fn get_caller_identity_exchanges_web_identity_before_signing() {
498 let observed = Arc::new(Mutex::new(Vec::new()));
499 let (endpoint, server) = start_sts_test_server(observed.clone());
500
501 let token_file = tempfile::NamedTempFile::new().expect("create token file");
502 std::fs::write(token_file.path(), "test-web-identity-token").expect("write token file");
503
504 let config = AwsClientConfig {
505 account_id: "123456789012".to_string(),
506 region: "us-east-2".to_string(),
507 credentials: AwsCredentials::WebIdentity {
508 config: AwsWebIdentityConfig {
509 role_arn: "arn:aws:iam::123456789012:role/test-role".to_string(),
510 session_name: Some("test-session".to_string()),
511 web_identity_token_file: token_file.path().display().to_string(),
512 duration_seconds: Some(900),
513 },
514 },
515 service_overrides: Some(AwsServiceOverrides {
516 endpoints: HashMap::from([("sts".to_string(), endpoint)]),
517 }),
518 };
519
520 let response = StsClient::new(Client::new(), config)
521 .get_caller_identity()
522 .await
523 .expect("get caller identity should use exchanged credentials");
524
525 assert_eq!(
526 response.get_caller_identity_result.account.as_deref(),
527 Some("123456789012")
528 );
529
530 server.join().expect("server thread should finish");
531 let observed = observed.lock().expect("observed requests lock");
532 assert_eq!(observed.len(), 2);
533 assert!(observed[0]
534 .body
535 .contains("Action=AssumeRoleWithWebIdentity"));
536 assert!(observed[1].body.contains("Action=GetCallerIdentity"));
537 assert!(
538 observed[1].authorization.contains("ASIATESTACCESS"),
539 "GetCallerIdentity must be signed with credentials returned by AssumeRoleWithWebIdentity, got: {}",
540 observed[1].authorization
541 );
542 }
543
544 #[derive(Debug)]
545 struct ObservedRequest {
546 body: String,
547 authorization: String,
548 }
549
550 fn start_sts_test_server(
551 observed: Arc<Mutex<Vec<ObservedRequest>>>,
552 ) -> (String, std::thread::JoinHandle<()>) {
553 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test STS server");
554 let endpoint = format!("http://{}", listener.local_addr().expect("local addr"));
555 let server = std::thread::spawn(move || {
556 for _ in 0..2 {
557 let (mut stream, _) = listener.accept().expect("accept STS request");
558 let (headers, body) = read_http_request(&mut stream);
559 let authorization = headers
560 .lines()
561 .find_map(|line| {
562 line.split_once(':').and_then(|(name, value)| {
563 name.eq_ignore_ascii_case("authorization")
564 .then(|| value.trim())
565 })
566 })
567 .unwrap_or_default()
568 .to_string();
569 observed
570 .lock()
571 .expect("observed requests lock")
572 .push(ObservedRequest {
573 body: body.clone(),
574 authorization: authorization.clone(),
575 });
576
577 if body.contains("Action=AssumeRoleWithWebIdentity") {
578 write_xml_response(&mut stream, assume_role_with_web_identity_response());
579 } else if body.contains("Action=GetCallerIdentity")
580 && authorization.contains("ASIATESTACCESS")
581 {
582 write_xml_response(&mut stream, get_caller_identity_response());
583 } else {
584 write_forbidden_response(&mut stream);
585 }
586 }
587 });
588 (endpoint, server)
589 }
590
591 fn read_http_request(stream: &mut TcpStream) -> (String, String) {
592 let mut buffer = Vec::new();
593 let mut scratch = [0_u8; 4096];
594 let header_end;
595 loop {
596 let read = stream.read(&mut scratch).expect("read request");
597 assert!(read > 0, "connection closed before headers");
598 buffer.extend_from_slice(&scratch[..read]);
599 if let Some(position) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {
600 header_end = position + 4;
601 break;
602 }
603 }
604
605 let headers = String::from_utf8(buffer[..header_end].to_vec()).expect("headers utf8");
606 let content_length = headers
607 .lines()
608 .find_map(|line| {
609 line.to_ascii_lowercase()
610 .strip_prefix("content-length: ")
611 .and_then(|value| value.trim().parse::<usize>().ok())
612 })
613 .expect("content-length header");
614
615 while buffer.len() < header_end + content_length {
616 let read = stream.read(&mut scratch).expect("read request body");
617 assert!(read > 0, "connection closed before body");
618 buffer.extend_from_slice(&scratch[..read]);
619 }
620
621 let body = String::from_utf8(buffer[header_end..header_end + content_length].to_vec())
622 .expect("body utf8");
623 (headers, body)
624 }
625
626 fn write_xml_response(stream: &mut TcpStream, body: String) {
627 let response = format!(
628 "HTTP/1.1 200 OK\r\ncontent-type: text/xml\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
629 body.len(),
630 body
631 );
632 stream
633 .write_all(response.as_bytes())
634 .expect("write XML response");
635 }
636
637 fn write_forbidden_response(stream: &mut TcpStream) {
638 let body = r#"<ErrorResponse><Error><Code>AccessDenied</Code><Message>denied</Message></Error></ErrorResponse>"#;
639 let response = format!(
640 "HTTP/1.1 403 Forbidden\r\ncontent-type: text/xml\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
641 body.len(),
642 body
643 );
644 stream
645 .write_all(response.as_bytes())
646 .expect("write forbidden response");
647 }
648
649 fn assume_role_with_web_identity_response() -> String {
650 r#"<AssumeRoleWithWebIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
651 <AssumeRoleWithWebIdentityResult>
652 <SubjectFromWebIdentityToken>system:serviceaccount:test:agent</SubjectFromWebIdentityToken>
653 <Audience>sts.amazonaws.com</Audience>
654 <Provider>provider</Provider>
655 <AssumedRoleUser>
656 <Arn>arn:aws:sts::123456789012:assumed-role/test-role/test-session</Arn>
657 <AssumedRoleId>AROA:test-session</AssumedRoleId>
658 </AssumedRoleUser>
659 <Credentials>
660 <AccessKeyId>ASIATESTACCESS</AccessKeyId>
661 <SecretAccessKey>test-secret</SecretAccessKey>
662 <SessionToken>test-session-token</SessionToken>
663 <Expiration>2026-05-27T11:00:00Z</Expiration>
664 </Credentials>
665 </AssumeRoleWithWebIdentityResult>
666 <ResponseMetadata><RequestId>request-1</RequestId></ResponseMetadata>
667</AssumeRoleWithWebIdentityResponse>"#
668 .to_string()
669 }
670
671 fn get_caller_identity_response() -> String {
672 r#"<GetCallerIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
673 <GetCallerIdentityResult>
674 <Arn>arn:aws:sts::123456789012:assumed-role/test-role/test-session</Arn>
675 <UserId>AROA:test-session</UserId>
676 <Account>123456789012</Account>
677 </GetCallerIdentityResult>
678 <ResponseMetadata><RequestId>request-2</RequestId></ResponseMetadata>
679</GetCallerIdentityResponse>"#
680 .to_string()
681 }
682}