1use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
6
7use tokio::sync::RwLock;
8
9use crate::TokenProvider;
10use crate::error::AuthError;
11use crate::helpers::{
12 InFlight, format_oauth_transport_error, hex_encode, lead_or_follow, oauth_http_client,
13 parse_rfc3339, parse_token_endpoint, percent_encode, read_oauth_body, vendor_rejected_summary,
14};
15
16const DEFAULT_REGION: &str = "us-east-1";
17const STS_VERSION: &str = "2011-06-15";
18const SIGV4_ALGO: &str = "AWS4-HMAC-SHA256";
19
20#[derive(Clone)]
22pub struct AwsCredentials {
23 pub access_key_id: String,
25 pub secret_access_key: String,
27 pub session_token: String,
29 pub expiration: Option<SystemTime>,
31}
32
33impl std::fmt::Debug for AwsCredentials {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 f.debug_struct("AwsCredentials")
36 .field("access_key_id", &self.access_key_id)
37 .field("secret_access_key", &"[REDACTED]")
38 .field("session_token", &"[REDACTED]")
39 .field("expiration", &self.expiration)
40 .finish()
41 }
42}
43
44#[derive(Clone, Debug)]
46pub struct AwsSignParams<'a> {
47 pub method: &'a str,
49 pub host: &'a str,
51 pub path: &'a str,
53 pub query: &'a str,
55 pub region: &'a str,
57 pub service: &'a str,
59 pub extra_headers: &'a [(&'a str, &'a str)],
61 pub payload: &'a [u8],
63 pub amz_date: &'a str,
65}
66
67#[derive(Clone)]
69pub struct AwsStsConfig {
70 pub access_key_id: String,
72 pub secret_access_key: String,
74 pub role_arn: String,
76 pub role_session_name: String,
78 pub region: String,
80 pub session_token: Option<String>,
82 pub endpoint: Option<String>,
84}
85
86impl std::fmt::Debug for AwsStsConfig {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("AwsStsConfig")
89 .field("access_key_id", &self.access_key_id)
90 .field("secret_access_key", &"[REDACTED]")
91 .field("role_arn", &self.role_arn)
92 .field("role_session_name", &self.role_session_name)
93 .field("region", &self.region)
94 .field(
95 "session_token",
96 &self.session_token.as_ref().map(|_| "[REDACTED]"),
97 )
98 .field("endpoint", &self.endpoint)
99 .finish()
100 }
101}
102
103struct CachedCreds {
104 creds: AwsCredentials,
105 acquired_at: Instant,
106 lifetime: Duration,
107}
108
109impl CachedCreds {
110 fn needs_refresh(&self) -> bool {
111 self.acquired_at.elapsed() >= self.lifetime.mul_f64(0.8)
112 }
113}
114
115#[derive(Clone)]
117pub struct AwsStsTokenProvider {
118 inner: Arc<Inner>,
119}
120
121struct Inner {
122 config: AwsStsConfig,
123 http: reqwest::Client,
124 force_refresh: AtomicBool,
125 state: RwLock<Option<CachedCreds>>,
126 inflight: InFlight,
127}
128
129impl std::fmt::Debug for AwsStsTokenProvider {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 f.debug_struct("AwsStsTokenProvider")
132 .field("config", &self.inner.config)
133 .finish()
134 }
135}
136
137impl AwsStsTokenProvider {
138 pub fn new(mut config: AwsStsConfig) -> Result<Self, AuthError> {
140 if config.access_key_id.trim().is_empty() {
141 return Err(AuthError::MissingField("AWS access_key_id".into()));
142 }
143 if config.secret_access_key.trim().is_empty() {
144 return Err(AuthError::MissingField("AWS secret_access_key".into()));
145 }
146 if config.role_arn.trim().is_empty() {
147 return Err(AuthError::MissingField("AWS role_arn".into()));
148 }
149 if config.role_session_name.trim().is_empty() {
150 config.role_session_name = "wiremux".into();
151 }
152 if config.region.trim().is_empty() {
153 config.region = DEFAULT_REGION.into();
154 }
155 Ok(Self {
156 inner: Arc::new(Inner {
157 config,
158 http: oauth_http_client()?,
159 force_refresh: AtomicBool::new(false),
160 state: RwLock::new(None),
161 inflight: InFlight::new(),
162 }),
163 })
164 }
165
166 pub async fn get_credentials(&self) -> Result<AwsCredentials, AuthError> {
168 {
171 let state = self.inner.state.read().await;
172 if !self.inner.force_refresh.load(Ordering::SeqCst)
173 && let Some(cached) = state.as_ref()
174 && !cached.needs_refresh()
175 && !self.inner.inflight.is_busy()
176 {
177 return Ok(cached.creds.clone());
178 }
179 }
180 lead_or_follow(&self.inner.inflight, || async {
181 let force = self.inner.force_refresh.swap(false, Ordering::SeqCst);
182 let result = self.refresh_as_leader(force).await;
183 if result.is_err() && force {
184 self.inner.force_refresh.store(true, Ordering::SeqCst);
185 }
186 result
187 })
188 .await?;
189 let state = self.inner.state.read().await;
190 state
191 .as_ref()
192 .map(|c| c.creds.clone())
193 .ok_or_else(|| AuthError::TokenProvider("STS cache empty after refresh".to_string()))
194 }
195
196 async fn refresh_as_leader(&self, force: bool) -> Result<String, AuthError> {
197 {
198 let state = self.inner.state.read().await;
199 if !force
200 && let Some(cached) = state.as_ref()
201 && !cached.needs_refresh()
202 {
203 return Ok(cached.creds.access_key_id.clone());
204 }
205 }
206 let creds = self.assume_role().await?;
207 let marker = creds.access_key_id.clone();
208 let lifetime = creds
209 .expiration
210 .and_then(|exp| exp.duration_since(SystemTime::now()).ok())
211 .unwrap_or(Duration::from_secs(3600));
212 *self.inner.state.write().await = Some(CachedCreds {
213 creds,
214 acquired_at: Instant::now(),
215 lifetime,
216 });
217 Ok(marker)
218 }
219
220 async fn assume_role(&self) -> Result<AwsCredentials, AuthError> {
221 let cfg = &self.inner.config;
222 let endpoint = cfg
223 .endpoint
224 .clone()
225 .unwrap_or_else(|| default_sts_endpoint(&cfg.region));
226 let url = parse_token_endpoint(&endpoint)?;
227 let host = url.host_str().unwrap_or("sts.amazonaws.com").to_string();
228 let path = if url.path().is_empty() {
229 "/".to_string()
230 } else {
231 url.path().to_string()
232 };
233 let body = format!(
234 "Action=AssumeRole&Version={}&RoleArn={}&RoleSessionName={}",
235 percent_encode(STS_VERSION),
236 percent_encode(&cfg.role_arn),
237 percent_encode(&cfg.role_session_name),
238 );
239 let amz_date = amz_date_now();
240 let sign_creds = AwsCredentials {
241 access_key_id: cfg.access_key_id.clone(),
242 secret_access_key: cfg.secret_access_key.clone(),
243 session_token: cfg.session_token.clone().unwrap_or_default(),
244 expiration: None,
245 };
246 let extra = [("content-type", "application/x-www-form-urlencoded")];
247 let authorization = sign_aws_request(
248 &sign_creds,
249 &AwsSignParams {
250 method: "POST",
251 host: &host,
252 path: &path,
253 query: "",
254 region: &cfg.region,
255 service: "sts",
256 extra_headers: &extra,
257 payload: body.as_bytes(),
258 amz_date: &amz_date,
259 },
260 )?;
261 let parsed = parse_token_endpoint(&endpoint)?;
262 let mut req = self
263 .inner
264 .http
265 .post(parsed)
266 .header("content-type", "application/x-www-form-urlencoded")
267 .header("host", &host)
268 .header("x-amz-date", &amz_date)
269 .header("authorization", &authorization)
270 .body(body);
271 if let Some(token) = cfg.session_token.as_deref().filter(|s| !s.is_empty()) {
272 req = req.header("x-amz-security-token", token);
273 }
274 let resp = req.send().await.map_err(|e| {
275 AuthError::TokenProvider(format_oauth_transport_error(
276 "STS AssumeRole request failed",
277 &e,
278 &endpoint,
279 ))
280 })?;
281 let status = resp.status().as_u16();
282 let text = read_oauth_body(resp).await?;
283 if !(200..300).contains(&status) {
284 return Err(AuthError::VendorRejected {
285 status,
286 summary: vendor_rejected_summary("STS AssumeRole failed", &text, &endpoint),
287 });
288 }
289 parse_assume_role_xml(&text)
290 }
291}
292
293fn default_sts_endpoint(region: &str) -> String {
294 if region == DEFAULT_REGION {
295 "https://sts.amazonaws.com/".into()
296 } else {
297 format!("https://sts.{region}.amazonaws.com/")
298 }
299}
300
301fn xml_tag(body: &str, tag: &str) -> Option<String> {
302 let open = format!("<{tag}>");
303 let close = format!("</{tag}>");
304 let start = body.find(&open)? + open.len();
305 let rest = body.get(start..)?;
306 let end = rest.find(&close)?;
307 Some(rest[..end].trim().to_string())
308}
309
310fn parse_assume_role_xml(body: &str) -> Result<AwsCredentials, AuthError> {
311 let access_key_id = xml_tag(body, "AccessKeyId").filter(|s| !s.is_empty());
312 let secret_access_key = xml_tag(body, "SecretAccessKey").filter(|s| !s.is_empty());
313 let session_token = xml_tag(body, "SessionToken").unwrap_or_default();
314 let (Some(access_key_id), Some(secret_access_key)) = (access_key_id, secret_access_key) else {
315 return Err(AuthError::TokenProvider(
316 "STS AssumeRole XML missing AccessKeyId or SecretAccessKey".into(),
317 ));
318 };
319 let expiration = xml_tag(body, "Expiration")
320 .as_deref()
321 .and_then(|s| parse_rfc3339(s).ok());
322 Ok(AwsCredentials {
323 access_key_id,
324 secret_access_key,
325 session_token,
326 expiration,
327 })
328}
329
330pub fn sign_aws_request(
332 creds: &AwsCredentials,
333 params: &AwsSignParams<'_>,
334) -> Result<String, AuthError> {
335 if params.amz_date.len() < 8 {
336 return Err(AuthError::TokenProvider("x-amz-date is too short".into()));
337 }
338 let date_stamp = ¶ms.amz_date[..8];
339 let mut headers: Vec<(String, String)> = Vec::new();
340 headers.push(("host".into(), params.host.to_string()));
341 headers.push(("x-amz-date".into(), params.amz_date.to_string()));
342 if !creds.session_token.is_empty()
343 && !params
344 .extra_headers
345 .iter()
346 .any(|(k, _)| k.eq_ignore_ascii_case("x-amz-security-token"))
347 {
348 headers.push(("x-amz-security-token".into(), creds.session_token.clone()));
349 }
350 for (k, v) in params.extra_headers {
351 headers.push((k.to_ascii_lowercase(), (*v).to_string()));
352 }
353 headers.sort_by(|a, b| a.0.cmp(&b.0));
354 headers.dedup_by(|a, b| a.0 == b.0);
355
356 let canonical_headers = headers
357 .iter()
358 .map(|(k, v)| format!("{k}:{}\n", trim_header_value(v)))
359 .collect::<String>();
360 let signed_headers = headers
361 .iter()
362 .map(|(k, _)| k.as_str())
363 .collect::<Vec<_>>()
364 .join(";");
365 let path = if params.path.is_empty() {
366 "/"
367 } else {
368 params.path
369 };
370 let payload_hash = hex_encode(&sha256(params.payload));
371 let canonical = format!(
372 "{}\n{}\n{}\n{}\n{}\n{}",
373 params.method.to_ascii_uppercase(),
374 canonical_uri(path),
375 params.query,
376 canonical_headers,
377 signed_headers,
378 payload_hash
379 );
380 let credential_scope = format!(
381 "{}/{}/{}/aws4_request",
382 date_stamp, params.region, params.service
383 );
384 let string_to_sign = format!(
385 "{SIGV4_ALGO}\n{}\n{credential_scope}\n{}",
386 params.amz_date,
387 hex_encode(&sha256(canonical.as_bytes()))
388 );
389 let signing_key = signing_key(
390 &creds.secret_access_key,
391 date_stamp,
392 params.region,
393 params.service,
394 )?;
395 let signature = hex_encode(&hmac_sha256(&signing_key, string_to_sign.as_bytes())?);
396 Ok(format!(
397 "{SIGV4_ALGO} Credential={}/{}, SignedHeaders={signed_headers}, Signature={signature}",
398 creds.access_key_id, credential_scope
399 ))
400}
401
402fn trim_header_value(v: &str) -> String {
403 let mut out = String::new();
404 let mut prev_space = false;
405 for ch in v.trim().chars() {
406 if ch.is_whitespace() {
407 if !prev_space {
408 out.push(' ');
409 prev_space = true;
410 }
411 } else {
412 out.push(ch);
413 prev_space = false;
414 }
415 }
416 out
417}
418
419fn canonical_uri(path: &str) -> String {
420 if path.is_empty() {
421 return "/".into();
422 }
423 path.split('/')
424 .map(|seg| {
425 if seg.is_empty() {
426 String::new()
427 } else {
428 percent_encode(seg)
429 }
430 })
431 .collect::<Vec<_>>()
432 .join("/")
433}
434
435fn sha256(data: &[u8]) -> [u8; 32] {
436 use sha2::{Digest, Sha256};
437 Sha256::digest(data).into()
438}
439
440fn hmac_sha256(key: &[u8], data: &[u8]) -> Result<Vec<u8>, AuthError> {
441 use hmac::{Hmac, KeyInit, Mac};
442 use sha2::Sha256;
443 let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(key)
444 .map_err(|e| AuthError::TokenProvider(format!("HMAC-SHA256 key: {e}")))?;
445 mac.update(data);
446 Ok(mac.finalize().into_bytes().to_vec())
447}
448
449fn signing_key(
450 secret: &str,
451 date: &str,
452 region: &str,
453 service: &str,
454) -> Result<Vec<u8>, AuthError> {
455 let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), date.as_bytes())?;
456 let k_region = hmac_sha256(&k_date, region.as_bytes())?;
457 let k_service = hmac_sha256(&k_region, service.as_bytes())?;
458 hmac_sha256(&k_service, b"aws4_request")
459}
460
461fn amz_date_now() -> String {
462 let secs = SystemTime::now()
463 .duration_since(UNIX_EPOCH)
464 .unwrap_or_default()
465 .as_secs();
466 format_amz_date(secs).0
467}
468
469pub(crate) fn format_amz_date(unix_secs: u64) -> (String, String) {
471 let days = (unix_secs / 86_400) as i64;
472 let sod = unix_secs % 86_400;
473 let (year, month, day) = civil_from_days(days);
474 let hour = sod / 3600;
475 let min = (sod % 3600) / 60;
476 let sec = sod % 60;
477 (
478 format!("{year:04}{month:02}{day:02}T{hour:02}{min:02}{sec:02}Z"),
479 format!("{year:04}{month:02}{day:02}"),
480 )
481}
482
483fn civil_from_days(z: i64) -> (i32, u32, u32) {
485 let z = z + 719_468;
486 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
487 let doe = (z - era * 146_097) as u64;
488 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
489 let y = yoe as i64 + era * 400;
490 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
491 let mp = (5 * doy + 2) / 153;
492 let d = doy - (153 * mp + 2) / 5 + 1;
493 let m = if mp < 10 { mp + 3 } else { mp - 9 };
494 let y = if m <= 2 { y + 1 } else { y };
495 (y as i32, m as u32, d as u32)
496}
497
498impl TokenProvider for AwsStsTokenProvider {
499 fn mark_stale(&self) {
500 self.inner.force_refresh.store(true, Ordering::SeqCst);
501 }
502
503 async fn get_token(&self) -> Result<String, AuthError> {
504 Err(AuthError::TokenProvider(
505 "AWS STS session is not a Bearer API key; use get_credentials + sign_aws_request"
506 .into(),
507 ))
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use std::io::{Read, Write};
515 use std::net::TcpListener;
516 use std::thread;
517
518 #[test]
519 fn sts_cache_empty_error_does_not_include_the_access_key_id() {
520 let src = include_str!("aws.rs");
521 let marker = format!("({}token{})", '{', '}');
522 let bad = format!("STS cache empty after refresh {marker}");
523 assert!(
524 !src.contains(&bad),
525 "the refresh marker is an access key id and must not be formatted into the error"
526 );
527 }
528
529 const STS_XML: &str = r#"<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
530 <AssumeRoleResult>
531 <Credentials>
532 <AccessKeyId>ASIAEXAMPLE</AccessKeyId>
533 <SecretAccessKey>secretExample</SecretAccessKey>
534 <SessionToken>tokenExample</SessionToken>
535 <Expiration>2099-01-01T00:00:00Z</Expiration>
536 </Credentials>
537 </AssumeRoleResult>
538</AssumeRoleResponse>"#;
539
540 fn read_http_headers(stream: &mut impl Read) -> Vec<u8> {
541 let mut buf = Vec::new();
542 let mut tmp = [0u8; 2048];
543 loop {
544 match stream.read(&mut tmp) {
545 Ok(0) => break,
546 Ok(n) => {
547 buf.extend_from_slice(&tmp[..n]);
548 if buf.windows(4).any(|w| w == b"\r\n\r\n") {
549 break;
550 }
551 }
552 Err(_) => break,
553 }
554 }
555 buf
556 }
557
558 fn write_http_xml(stream: &mut impl Write, status: u16, body: &str) {
559 let resp = format!(
560 "HTTP/1.1 {status} OK\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
561 body.len()
562 );
563 let _ = stream.write_all(resp.as_bytes());
564 }
565
566 fn spawn_http_server(status: u16, body: &str) -> (String, thread::JoinHandle<String>) {
567 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
568 let addr = listener.local_addr().expect("local_addr");
569 let body = body.to_owned();
570 let handle = thread::spawn(move || {
571 let (mut stream, _) = listener.accept().expect("accept");
572 stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
573 let buf = read_http_headers(&mut stream);
574 write_http_xml(&mut stream, status, &body);
575 String::from_utf8_lossy(&buf).into_owned()
576 });
577 (format!("http://{addr}/"), handle)
578 }
579
580 fn spawn_http_server_optional(
582 status: u16,
583 body: &str,
584 accept_timeout: Duration,
585 ) -> (String, thread::JoinHandle<Option<String>>) {
586 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
587 listener.set_nonblocking(true).expect("nonblocking");
588 let addr = listener.local_addr().expect("local_addr");
589 let body = body.to_owned();
590 let handle = thread::spawn(move || {
591 let start = Instant::now();
592 loop {
593 match listener.accept() {
594 Ok((mut stream, _)) => {
595 stream.set_nonblocking(false).ok();
596 stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
597 let buf = read_http_headers(&mut stream);
598 write_http_xml(&mut stream, status, &body);
599 return Some(String::from_utf8_lossy(&buf).into_owned());
600 }
601 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
602 if start.elapsed() >= accept_timeout {
603 return None;
604 }
605 thread::sleep(Duration::from_millis(10));
606 }
607 Err(_) => return None,
608 }
609 }
610 });
611 (format!("http://{addr}/"), handle)
612 }
613
614 fn spawn_http_script(responses: &[(u16, &str)]) -> (String, thread::JoinHandle<usize>) {
615 let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
616 let addr = listener.local_addr().expect("local_addr");
617 let responses: Vec<(u16, String)> = responses
618 .iter()
619 .map(|(status, body)| (*status, (*body).to_owned()))
620 .collect();
621 let handle = thread::spawn(move || {
622 let mut served = 0usize;
623 for (status, body) in responses {
624 let (mut stream, _) = listener.accept().expect("accept");
625 stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
626 let _ = read_http_headers(&mut stream);
627 write_http_xml(&mut stream, status, &body);
628 served += 1;
629 }
630 served
631 });
632 (format!("http://{addr}/"), handle)
633 }
634
635 fn cfg(endpoint: &str) -> AwsStsConfig {
636 AwsStsConfig {
637 access_key_id: "AKIDEXAMPLE".into(),
638 secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(),
639 role_arn: "arn:aws:iam::123456789012:role/demo".into(),
640 role_session_name: "wiremux-test".into(),
641 region: "us-east-1".into(),
642 session_token: None,
643 endpoint: Some(endpoint.into()),
644 }
645 }
646
647 #[test]
648 fn empty_access_key_fails_construction() {
649 let err = AwsStsTokenProvider::new(AwsStsConfig {
650 access_key_id: String::new(),
651 secret_access_key: "secret".into(),
652 role_arn: "arn:aws:iam::1:role/x".into(),
653 role_session_name: "s".into(),
654 region: "us-east-1".into(),
655 session_token: None,
656 endpoint: None,
657 })
658 .expect_err("empty key");
659 assert!(err.to_string().contains("access_key_id"), "{err}");
660 }
661
662 #[test]
663 fn empty_secret_fails_construction() {
664 let err = AwsStsTokenProvider::new(AwsStsConfig {
665 access_key_id: "AKI".into(),
666 secret_access_key: " ".into(),
667 role_arn: "arn:aws:iam::1:role/x".into(),
668 role_session_name: "s".into(),
669 region: "us-east-1".into(),
670 session_token: None,
671 endpoint: None,
672 })
673 .expect_err("empty secret");
674 assert!(err.to_string().contains("secret_access_key"), "{err}");
675 }
676
677 #[test]
678 fn debug_redacts_secrets() {
679 let p = AwsStsTokenProvider::new(cfg("https://sts.amazonaws.com/")).expect("new");
680 let debug = format!("{p:?}");
681 assert!(!debug.contains("wJalrXUtnFEMI"), "{debug}");
682 assert!(debug.contains("[REDACTED]"), "{debug}");
683 }
684
685 #[test]
686 fn format_amz_date_known_vector() {
687 let (full, date) = format_amz_date(1_440_938_160);
688 assert_eq!(full, "20150830T123600Z");
689 assert_eq!(date, "20150830");
690 let (epoch, epoch_day) = format_amz_date(0);
691 assert_eq!(epoch, "19700101T000000Z");
692 assert_eq!(epoch_day, "19700101");
693 }
694
695 #[test]
696 fn signing_key_matches_aws_docs() {
697 let key = signing_key(
698 "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
699 "20150830",
700 "us-east-1",
701 "iam",
702 )
703 .expect("signing_key");
704 assert_eq!(
705 hex_encode(&key),
706 "c4afb1cc5771d871763a393e44b703571b55cc28424d1a5e86da6ed3c154a4b9"
707 );
708 }
709
710 #[test]
711 fn hmac_sha256_empty_key_does_not_panic() {
712 let mac = hmac_sha256(b"", b"data").expect("HMAC-SHA256 accepts empty key");
713 assert_eq!(mac.len(), 32);
714 }
715
716 #[test]
717 fn canonical_uri_double_encodes_bedrock_arn_slash() {
718 let path =
719 "/model/arn:aws:bedrock:us-east-1:123:inference-profile%2Fus.anthropic.claude/converse";
720 assert_eq!(
721 canonical_uri(path),
722 "/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A123%3Ainference-profile%252Fus.anthropic.claude/converse"
723 );
724 }
725
726 #[test]
727 fn sign_aws_request_matches_iam_get_example() {
728 let creds = AwsCredentials {
729 access_key_id: "AKIDEXAMPLE".into(),
730 secret_access_key: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(),
731 session_token: String::new(),
732 expiration: None,
733 };
734 let extra = [(
735 "content-type",
736 "application/x-www-form-urlencoded; charset=utf-8",
737 )];
738 let auth = sign_aws_request(
739 &creds,
740 &AwsSignParams {
741 method: "GET",
742 host: "iam.amazonaws.com",
743 path: "/",
744 query: "Action=ListUsers&Version=2010-05-08",
745 region: "us-east-1",
746 service: "iam",
747 extra_headers: &extra,
748 payload: b"",
749 amz_date: "20150830T123600Z",
750 },
751 )
752 .expect("sign");
753 assert!(
754 auth.contains(
755 "Signature=5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7"
756 ),
757 "{auth}"
758 );
759 assert!(
760 auth.contains("Credential=AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request"),
761 "{auth}"
762 );
763 }
764
765 #[tokio::test]
766 async fn assume_role_returns_temp_keys_and_get_token_refuses() {
767 let (url, handle) = spawn_http_server(200, STS_XML);
768 let p = AwsStsTokenProvider::new(cfg(&url)).expect("new");
769 let creds = p.get_credentials().await.expect("creds");
770 assert_eq!(creds.access_key_id, "ASIAEXAMPLE");
771 assert_eq!(creds.secret_access_key, "secretExample");
772 assert_eq!(creds.session_token, "tokenExample");
773 let err = p.get_token().await.expect_err("not a bearer");
774 let msg = err.to_string();
775 assert!(msg.contains("get_credentials"), "{msg}");
776 assert!(msg.contains("sign_aws_request"), "{msg}");
777 let req = handle.join().expect("join");
778 assert!(
779 req.contains("Action=AssumeRole") || req.contains("Action%3DAssumeRole"),
780 "{req}"
781 );
782 }
783
784 #[test]
785 fn parse_sts_xml() {
786 let creds = parse_assume_role_xml(STS_XML).expect("xml");
787 assert_eq!(creds.access_key_id, "ASIAEXAMPLE");
788 assert_eq!(
789 creds.expiration,
790 Some(UNIX_EPOCH + Duration::from_secs(4_070_908_800))
791 );
792 }
793
794 #[tokio::test]
795 async fn get_token_does_not_call_assume_role() {
796 let (url, handle) = spawn_http_server_optional(200, STS_XML, Duration::from_millis(400));
797 let p = AwsStsTokenProvider::new(cfg(&url)).expect("new");
798 let err = p.get_token().await.expect_err("not a bearer");
799 let msg = err.to_string();
800 assert!(msg.contains("not a Bearer"), "{msg}");
801 let accepted = handle.join().expect("join");
802 assert!(
803 accepted.is_none(),
804 "get_token must not call STS, got {accepted:?}"
805 );
806 }
807
808 const STS_XML_RETRY: &str = r#"<AssumeRoleResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
809 <AssumeRoleResult>
810 <Credentials>
811 <AccessKeyId>ASIARETRY</AccessKeyId>
812 <SecretAccessKey>secretRetry</SecretAccessKey>
813 <SessionToken>tokenRetry</SessionToken>
814 <Expiration>2099-01-01T00:00:00Z</Expiration>
815 </Credentials>
816 </AssumeRoleResult>
817</AssumeRoleResponse>"#;
818
819 #[tokio::test]
820 async fn get_credentials_retries_after_forced_refresh_http_error() {
821 let (url, handle) = spawn_http_script(&[
822 (200, STS_XML),
823 (
824 403,
825 "<ErrorResponse><Error><Code>AccessDenied</Code></Error></ErrorResponse>",
826 ),
827 (200, STS_XML_RETRY),
828 ]);
829 let p = AwsStsTokenProvider::new(cfg(&url)).expect("new");
830 let first = p.get_credentials().await.expect("cache");
831 assert_eq!(first.access_key_id, "ASIAEXAMPLE");
832 p.mark_stale();
833 let err = p.get_credentials().await.expect_err("forced 403");
834 let msg = err.to_string();
835 assert!(msg.contains("403") || msg.contains("AssumeRole"), "{msg}");
836 assert_eq!(
837 msg.matches("(HTTP ").count(),
838 1,
839 "VendorRejected must not re-wrap HTTP: {msg}"
840 );
841 let retry = p.get_credentials().await.expect("retry after failed force");
842 assert_eq!(retry.access_key_id, "ASIARETRY");
843 assert_eq!(handle.join().expect("join"), 3);
844 }
845}