Skip to main content

wiremux_auth/providers/
aws.rs

1//! AWS STS AssumeRole TokenProvider and SigV4 signing.
2
3use 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/// Temporary credentials from STS AssumeRole.
21#[derive(Clone)]
22pub struct AwsCredentials {
23    /// STS access key id.
24    pub access_key_id: String,
25    /// STS secret. Redacted in [`Debug`].
26    pub secret_access_key: String,
27    /// Session token for SigV4 `x-amz-security-token`.
28    pub session_token: String,
29    /// When the session expires, if STS sent `Expiration`.
30    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/// Inputs for [`sign_aws_request`].
45#[derive(Clone, Debug)]
46pub struct AwsSignParams<'a> {
47    /// HTTP method (`GET`, `POST`).
48    pub method: &'a str,
49    /// Host header value (`sts.amazonaws.com`).
50    pub host: &'a str,
51    /// Canonical path (`/` when empty).
52    pub path: &'a str,
53    /// Already-canonical query string (no leading `?`).
54    pub query: &'a str,
55    /// AWS region (`us-east-1`).
56    pub region: &'a str,
57    /// AWS service (`sts`, `iam`).
58    pub service: &'a str,
59    /// Extra signed headers (`content-type`, ...). `host` and `x-amz-date` are added.
60    pub extra_headers: &'a [(&'a str, &'a str)],
61    /// Raw body bytes (hashed into the canonical request).
62    pub payload: &'a [u8],
63    /// `YYYYMMDDTHHMMSSZ`.
64    pub amz_date: &'a str,
65}
66
67/// Long-lived keys used to call STS AssumeRole.
68#[derive(Clone)]
69pub struct AwsStsConfig {
70    /// IAM access key id. Must be non-empty.
71    pub access_key_id: String,
72    /// IAM secret. Must be non-empty. Redacted in [`Debug`].
73    pub secret_access_key: String,
74    /// Role to assume.
75    pub role_arn: String,
76    /// STS session name. Empty becomes `wiremux`.
77    pub role_session_name: String,
78    /// Region for SigV4 and the default STS endpoint. Empty becomes `us-east-1`.
79    pub region: String,
80    /// Optional session token if the long-lived keys are themselves temporary.
81    pub session_token: Option<String>,
82    /// Override STS URL (tests, VPC endpoints).
83    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/// STS AssumeRole. Session keys are not a Bearer API token.
116#[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    /// Construct from long-lived keys. Fails if the access key or secret is empty.
139    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    /// Temporary credentials from AssumeRole (cached at 80% of TTL).
167    pub async fn get_credentials(&self) -> Result<AwsCredentials, AuthError> {
168        // load() not swap: a concurrent get_credentials must not see a cleared
169        // force flag before the InFlight claim.
170        {
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
330/// Sign a request with SigV4. Returns the `Authorization` header value.
331pub 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 = &params.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
469/// `(YYYYMMDDTHHMMSSZ, YYYYMMDD)` from a Unix timestamp.
470pub(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
483/// Howard Hinnant civil-from-days. `z` is days since 1970-01-01.
484fn 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    /// Accepts at most one request, then returns `None` if nobody connected.
581    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}