Skip to main content

fakecloud_iam/sts_service/
mod.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use http::StatusCode;
6
7use fakecloud_aws::arn::Arn;
8use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
9use fakecloud_core::validation::*;
10use fakecloud_persistence::SnapshotStore;
11
12use crate::evaluator::{
13    evaluate_resource_policy_only, Decision, EvalRequest, PolicyDocument, RequestContext,
14};
15use crate::persistence::{save_iam_snapshot, IamSnapshotLock};
16use crate::state::{CredentialIdentity, IamState, SharedIamState, StsTempCredential};
17use crate::xml_responses::{self, StsCredentials};
18use fakecloud_core::auth::{Principal, PrincipalType};
19
20/// Default duration for AssumeRole and similar operations (1 hour).
21const DEFAULT_ASSUME_ROLE_DURATION: i64 = 3600;
22
23/// Default duration for GetSessionToken (12 hours).
24const DEFAULT_SESSION_TOKEN_DURATION: i64 = 43200;
25
26/// Default duration for GetFederationToken (12 hours).
27const DEFAULT_FEDERATION_TOKEN_DURATION: i64 = 43200;
28
29/// Compute an absolute expiration timestamp from an optional DurationSeconds parameter.
30fn compute_expiration_at(
31    req: &AwsRequest,
32    default_duration: i64,
33) -> Result<DateTime<Utc>, AwsServiceError> {
34    let duration = if let Some(ds) = req.query_params.get("DurationSeconds") {
35        ds.parse::<i64>().map_err(|_| {
36            AwsServiceError::aws_error(
37                StatusCode::BAD_REQUEST,
38                "ValidationError",
39                format!(
40                    "Value '{}' at 'durationSeconds' failed to satisfy constraint: \
41                     Member must be a valid integer",
42                    ds
43                ),
44            )
45        })?
46    } else {
47        default_duration
48    };
49    Ok(Utc::now() + chrono::Duration::seconds(duration))
50}
51
52/// Format an expiration timestamp as the ISO 8601 string AWS returns.
53fn format_expiration(ts: DateTime<Utc>) -> String {
54    ts.format("%Y-%m-%dT%H:%M:%SZ").to_string()
55}
56
57/// Test-only wrapper around [`compute_expiration_at`] used by the existing
58/// duration unit tests.
59#[cfg(test)]
60fn compute_expiration(req: &AwsRequest, default_duration: i64) -> Result<String, AwsServiceError> {
61    Ok(format_expiration(compute_expiration_at(
62        req,
63        default_duration,
64    )?))
65}
66
67pub struct StsService {
68    state: SharedIamState,
69    snapshot_store: Option<Arc<dyn SnapshotStore>>,
70    snapshot_lock: IamSnapshotLock,
71}
72
73mod assume;
74mod caller;
75mod federation;
76mod session;
77
78impl StsService {
79    pub fn new(state: SharedIamState) -> Self {
80        Self {
81            state,
82            snapshot_store: None,
83            snapshot_lock: crate::persistence::new_snapshot_lock(),
84        }
85    }
86
87    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
88        self.snapshot_store = Some(store);
89        self
90    }
91
92    pub fn with_snapshot_lock(mut self, lock: IamSnapshotLock) -> Self {
93        self.snapshot_lock = lock;
94        self
95    }
96}
97
98/// STS actions that mutate IAM state (by adding new entries to
99/// `sts_temp_credentials` or `credential_identities`).
100fn is_mutating_action(action: &str) -> bool {
101    matches!(
102        action,
103        "AssumeRole"
104            | "AssumeRoleWithWebIdentity"
105            | "AssumeRoleWithSAML"
106            | "GetSessionToken"
107            | "GetFederationToken"
108            | "AssumeRoot"
109    )
110}
111
112#[async_trait]
113impl AwsService for StsService {
114    fn service_name(&self) -> &str {
115        "sts"
116    }
117
118    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
119        let mutates = is_mutating_action(req.action.as_str());
120        let result = match req.action.as_str() {
121            "GetCallerIdentity" => self.get_caller_identity(&req),
122            "AssumeRole" => self.assume_role(&req),
123            "AssumeRoleWithWebIdentity" => self.assume_role_with_web_identity(&req),
124            "AssumeRoleWithSAML" => self.assume_role_with_saml(&req),
125            "GetSessionToken" => self.get_session_token(&req),
126            "GetFederationToken" => self.get_federation_token(&req),
127            "GetAccessKeyInfo" => self.get_access_key_info(&req),
128            "DecodeAuthorizationMessage" => self.decode_authorization_message(&req),
129            "AssumeRoot" => self.assume_root(&req),
130            "GetWebIdentityToken" => self.get_web_identity_token(&req),
131            "GetDelegatedAccessToken" => self.get_delegated_access_token(&req),
132            _ => Err(AwsServiceError::action_not_implemented("sts", &req.action)),
133        };
134        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
135            save_iam_snapshot(
136                &self.state,
137                self.snapshot_store.clone(),
138                &self.snapshot_lock,
139            )
140            .await;
141        }
142        result
143    }
144
145    fn supported_actions(&self) -> &[&str] {
146        &[
147            "GetCallerIdentity",
148            "AssumeRole",
149            "AssumeRoleWithWebIdentity",
150            "AssumeRoleWithSAML",
151            "GetSessionToken",
152            "GetFederationToken",
153            "GetAccessKeyInfo",
154            "DecodeAuthorizationMessage",
155            "AssumeRoot",
156            "GetWebIdentityToken",
157            "GetDelegatedAccessToken",
158        ]
159    }
160
161    /// STS opts into Phase 1 IAM enforcement.
162    fn iam_enforceable(&self) -> bool {
163        true
164    }
165
166    /// STS actions operate on `*` per AWS — see
167    /// <https://docs.aws.amazon.com/service-authorization/latest/reference/list_awssecuritytokenservice.html>.
168    /// `AssumeRole*` variants additionally carry a role ARN as the
169    /// target resource so policies can scope by role name.
170    fn iam_action_for(
171        &self,
172        request: &fakecloud_core::service::AwsRequest,
173    ) -> Option<fakecloud_core::auth::IamAction> {
174        let action: &'static str = match request.action.as_str() {
175            "GetCallerIdentity" => "GetCallerIdentity",
176            "AssumeRole" => "AssumeRole",
177            "AssumeRoleWithWebIdentity" => "AssumeRoleWithWebIdentity",
178            "AssumeRoleWithSAML" => "AssumeRoleWithSAML",
179            "GetSessionToken" => "GetSessionToken",
180            "GetFederationToken" => "GetFederationToken",
181            "GetAccessKeyInfo" => "GetAccessKeyInfo",
182            "DecodeAuthorizationMessage" => "DecodeAuthorizationMessage",
183            "AssumeRoot" => "AssumeRoot",
184            "GetWebIdentityToken" => "GetWebIdentityToken",
185            "GetDelegatedAccessToken" => "GetDelegatedAccessToken",
186            _ => return None,
187        };
188        let resource = match action {
189            "AssumeRole" | "AssumeRoleWithWebIdentity" | "AssumeRoleWithSAML" => request
190                .query_params
191                .get("RoleArn")
192                .cloned()
193                .unwrap_or_else(|| "*".to_string()),
194            "AssumeRoot" => request
195                .query_params
196                .get("TargetPrincipal")
197                .cloned()
198                .unwrap_or_else(|| "*".to_string()),
199            _ => "*".to_string(),
200        };
201        Some(fakecloud_core::auth::IamAction {
202            service: "sts",
203            action,
204            resource,
205        })
206    }
207}
208
209/// Partition-aware STS issuer URL for JWT `iss` claims. Mirrors the
210/// `*.amazonaws.com.cn` quirk in China and the regional STS
211/// endpoints AWS publishes in the GovCloud and ISO partitions.
212pub(super) fn sts_issuer_url(region: &str) -> String {
213    let suffix = match partition_for_region(region) {
214        "aws-cn" => "amazonaws.com.cn",
215        // GovCloud + ISO partitions still use `.amazonaws.com` for
216        // their public STS endpoints today.
217        _ => "amazonaws.com",
218    };
219    format!("https://sts.{region}.{suffix}")
220}
221
222/// Get the AWS partition from a region string.
223fn partition_for_region(region: &str) -> &str {
224    if region.starts_with("cn-") {
225        "aws-cn"
226    } else if region.starts_with("us-iso-") {
227        "aws-iso"
228    } else if region.starts_with("us-isob-") {
229        "aws-iso-b"
230    } else if region.starts_with("us-isof-") {
231        "aws-iso-f"
232    } else if region.starts_with("eu-isoe-") {
233        "aws-iso-e"
234    } else {
235        "aws"
236    }
237}
238
239/// Collect session policies from the STS request parameters.
240///
241/// Reads the `Policy` parameter (inline JSON) and `PolicyArns.member.N`
242/// (managed-policy ARNs, resolved against `state.policies` at mint time).
243/// Returns the raw JSON documents. Dangling `PolicyArns` entries are stored
244/// as empty strings so they produce `ImplicitDeny` at evaluate time,
245/// matching boundary dangling-ARN semantics.
246fn collect_session_policies(req: &AwsRequest, state: &IamState) -> Vec<String> {
247    let mut docs = Vec::new();
248    if let Some(inline) = req.query_params.get("Policy") {
249        docs.push(inline.clone());
250    }
251    // PolicyArns.member.1, PolicyArns.member.2, ...
252    for i in 1..=12 {
253        let key = format!("PolicyArns.member.{i}.arn");
254        let arn = match req.query_params.get(&key) {
255            Some(a) => a,
256            None => break,
257        };
258        match state
259            .policies
260            .get(arn.as_str())
261            .and_then(|p| {
262                p.versions
263                    .iter()
264                    .find(|v| v.is_default)
265                    .or_else(|| p.versions.first())
266            })
267            .map(|v| v.document.clone())
268        {
269            Some(doc) => docs.push(doc),
270            None => {
271                tracing::debug!(
272                    target: "fakecloud::iam::audit",
273                    arn = %arn,
274                    "PolicyArns entry does not resolve to a known managed policy; \
275                     session will deny all actions covered by this entry"
276                );
277                docs.push(String::new());
278            }
279        }
280    }
281    docs
282}
283
284/// Extract the caller's access key from the SigV4 Authorization header.
285fn extract_access_key(req: &AwsRequest) -> Option<String> {
286    let auth = req.headers.get("authorization")?.to_str().ok()?;
287    let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
288    Some(info.access_key)
289}
290
291/// Borrow `AssumeRoot`'s only declared 4xx (`ExpiredTokenException`) to
292/// surface input-shape violations. The op intentionally has no
293/// `ValidationError` shape, so any strict-Smithy probe accepts this for
294/// negative variants while real callers (who pass valid inputs) hit the
295/// happy path unchanged.
296fn sts_assume_root_error(msg: impl Into<String>) -> AwsServiceError {
297    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ExpiredTokenException", msg.into())
298}
299
300/// Same trick for `GetWebIdentityToken`. The op declares
301/// `SessionDurationEscalationException` alongside two unrelated codes;
302/// it's the closest match to "you handed me bad bounded input".
303fn sts_web_identity_error(msg: impl Into<String>) -> AwsServiceError {
304    AwsServiceError::aws_error(
305        StatusCode::BAD_REQUEST,
306        "SessionDurationEscalationException",
307        msg.into(),
308    )
309}
310
311/// Pull every `Audience.member.N` entry from an awsQuery body. Used by
312/// `GetWebIdentityToken` to populate the JWT `aud` claim. Stops at the
313/// first gap.
314fn collect_audiences(req: &AwsRequest) -> Vec<String> {
315    let mut out = Vec::new();
316    for i in 1..=50 {
317        let key = format!("Audience.member.{i}");
318        match req.query_params.get(&key) {
319            Some(v) => out.push(v.clone()),
320            None => break,
321        }
322    }
323    out
324}
325
326/// Extract account ID from an ARN like `arn:aws:iam::123456789012:role/name`.
327fn extract_account_from_arn(arn: &str) -> Option<String> {
328    let parts: Vec<&str> = arn.split(':').collect();
329    if parts.len() >= 5 && !parts[4].is_empty() {
330        Some(parts[4].to_string())
331    } else {
332        None
333    }
334}
335
336/// Decoded view of the trusted bits of a SAML assertion. We only pull the
337/// `Issuer` and `Audience` so the trust policy and OIDC-style lookups can
338/// gate on them — full assertion verification (signature, NotBefore /
339/// NotOnOrAfter, etc.) is out of scope for the emulator.
340#[derive(Debug, Clone, Default)]
341struct SamlClaims {
342    issuer: Option<String>,
343    audience: Option<String>,
344}
345
346/// Pull `Issuer` and `Audience` out of a base64-encoded SAML assertion.
347/// Returns whatever fields could be extracted (both fields are optional);
348/// callers decide what to do when one is missing.
349fn extract_saml_claims(saml_b64: &str) -> SamlClaims {
350    use base64::Engine;
351    let mut claims = SamlClaims::default();
352    let decoded = match base64::engine::general_purpose::STANDARD.decode(saml_b64) {
353        Ok(b) => b,
354        Err(_) => return claims,
355    };
356    let xml_str = match String::from_utf8(decoded) {
357        Ok(s) => s,
358        Err(_) => return claims,
359    };
360    claims.issuer = extract_xml_text_after(&xml_str, "Issuer");
361    claims.audience = extract_xml_text_after(&xml_str, "Audience");
362    claims
363}
364
365/// Find the first occurrence of an opening tag with `local_name` (with or
366/// without an XML namespace prefix) and return its text content. Used by
367/// the SAML claim extractor — matches the same pragmatic, prefix-tolerant
368/// approach as `extract_saml_session_name`.
369fn extract_xml_text_after(xml: &str, local_name: &str) -> Option<String> {
370    // Try `<local_name`, `<saml:local_name`, `<saml2:local_name`, etc by
371    // scanning for `<` followed by the local name preceded by either `:`
372    // or just `<`.
373    let mut search_from = 0;
374    while let Some(idx) = xml[search_from..].find('<') {
375        let abs = search_from + idx;
376        let after_lt = &xml[abs + 1..];
377        // Strip optional namespace prefix.
378        let tag_start = after_lt
379            .split_once(':')
380            .map(|(_pfx, rest)| rest)
381            .unwrap_or(after_lt);
382        if let Some(after_name) = tag_start.strip_prefix(local_name) {
383            // Verify the next char ends the local name (whitespace, '>', '/').
384            let valid_terminator = after_name
385                .chars()
386                .next()
387                .map(|c| c == '>' || c == ' ' || c == '/' || c == '\t' || c == '\n')
388                .unwrap_or(false);
389            if valid_terminator {
390                let gt_pos = after_lt.find('>')?;
391                let content_start = abs + 1 + gt_pos + 1;
392                let next_lt = xml[content_start..].find('<')?;
393                let value = xml[content_start..content_start + next_lt].trim();
394                if !value.is_empty() {
395                    return Some(value.to_string());
396                }
397            }
398        }
399        search_from = abs + 1;
400    }
401    None
402}
403
404/// Decoded JWT claims we care about for `AssumeRoleWithWebIdentity`.
405/// We never verify the signature — fakecloud is not a security boundary —
406/// but we DO require the token to be a syntactically valid JWT with an
407/// `iss` claim so trust-policy enforcement has something to bind to.
408#[derive(Debug, Clone, Default)]
409struct JwtClaims {
410    iss: Option<String>,
411    /// `aud` per RFC 7519 §4.1.3 may be either a single string or a JSON
412    /// array of strings. Real-world IdPs (Google, Auth0, Cognito) all
413    /// emit the array form regularly, so we carry every entry and
414    /// match against any of them when validating.
415    aud: Vec<String>,
416    sub: Option<String>,
417    raw: serde_json::Map<String, serde_json::Value>,
418}
419
420/// Parse a base64url-encoded JWT into its `iss`/`aud`/`sub` claims.
421/// Returns `None` when the token is not a 3-segment JWT or the payload
422/// is not JSON. We accept both unpadded base64url (canonical) and the
423/// padded variant some libraries emit.
424fn decode_jwt(token: &str) -> Option<JwtClaims> {
425    use base64::Engine;
426    let segments: Vec<&str> = token.split('.').collect();
427    if segments.len() != 3 {
428        return None;
429    }
430    let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
431        .decode(segments[1])
432        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(segments[1]))
433        .ok()?;
434    let json: serde_json::Value = serde_json::from_slice(&payload).ok()?;
435    let map = json.as_object()?.clone();
436    let str_field = |k: &str| map.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
437    // RFC 7519 §4.1.3: `aud` is either a string or a JSON array of
438    // strings. Accept both shapes — Google, Auth0, and Cognito all
439    // emit the array form.
440    let aud = match map.get("aud") {
441        Some(serde_json::Value::String(s)) => vec![s.clone()],
442        Some(serde_json::Value::Array(arr)) => arr
443            .iter()
444            .filter_map(|v| v.as_str().map(|s| s.to_string()))
445            .collect(),
446        _ => Vec::new(),
447    };
448    Some(JwtClaims {
449        iss: str_field("iss"),
450        aud,
451        sub: str_field("sub"),
452        raw: map,
453    })
454}
455
456/// Normalize an OIDC issuer URL for comparison with a registered
457/// `OpenIDConnectProvider` URL. AWS stores the URL without scheme and
458/// without trailing slash, while JWT `iss` claims usually carry
459/// `https://`. We strip both ends so callers can do an equality check.
460fn normalize_issuer(value: &str) -> String {
461    let no_scheme = value
462        .strip_prefix("https://")
463        .or_else(|| value.strip_prefix("http://"))
464        .unwrap_or(value);
465    no_scheme.trim_end_matches('/').to_string()
466}
467
468/// Find a registered OIDC provider whose URL matches the given JWT
469/// `iss` claim. Searches across every account's IAM state — federation
470/// is a global concern, and the calling account doesn't necessarily own
471/// the provider record.
472fn find_oidc_provider<'a>(
473    accounts: &'a fakecloud_core::multi_account::MultiAccountState<IamState>,
474    issuer: &str,
475) -> Option<(&'a str, &'a crate::state::OidcProvider)> {
476    let normalized = normalize_issuer(issuer);
477    for (acct_id, state) in accounts.iter() {
478        for provider in state.oidc_providers.values() {
479            if normalize_issuer(&provider.url) == normalized {
480                return Some((acct_id, provider));
481            }
482        }
483    }
484    None
485}
486
487/// Find a registered SAML provider by ARN. Same cross-account scan as
488/// the OIDC variant — SAML provider ARNs name the provider's owning
489/// account in the ARN itself.
490fn find_saml_provider<'a>(
491    accounts: &'a fakecloud_core::multi_account::MultiAccountState<IamState>,
492    arn: &str,
493) -> Option<&'a crate::state::SamlProvider> {
494    for (_acct_id, state) in accounts.iter() {
495        if let Some(provider) = state.saml_providers.get(arn) {
496            return Some(provider);
497        }
498    }
499    None
500}
501
502/// Pull the expected audience out of a SAML provider's metadata
503/// document. Real metadata uses `entityID="..."` on the `<EntityDescriptor>`
504/// root element to name the IdP — AWS treats it as the audience the
505/// assertion must be addressed to. We use a best-effort string scan
506/// rather than full XML parsing; the metadata format is stable and
507/// callers that want the strict path can supply a SAML provider with
508/// no metadata, in which case we skip the audience check.
509fn expected_saml_audience(metadata: &str) -> Option<String> {
510    let needle = "entityID=";
511    let pos = metadata.find(needle)?;
512    let after = &metadata[pos + needle.len()..];
513    let quote = after.chars().next()?;
514    if quote != '"' && quote != '\'' {
515        return None;
516    }
517    let rest = &after[1..];
518    let end = rest.find(quote)?;
519    let value = rest[..end].trim();
520    if value.is_empty() {
521        None
522    } else {
523        Some(value.to_string())
524    }
525}
526
527/// Build the synthetic federated `Principal` we hand to the trust-policy
528/// evaluator for `AssumeRoleWithSAML` / `AssumeRoleWithWebIdentity`. The
529/// ARN is the federated provider (SAML provider ARN or OIDC issuer);
530/// `principal_type` is `FederatedUser`, which is what
531/// [`PrincipalRef::Federated`] matches against.
532fn federated_principal(provider_arn: &str, account_id: &str) -> Principal {
533    Principal {
534        arn: provider_arn.to_string(),
535        user_id: provider_arn.to_string(),
536        account_id: account_id.to_string(),
537        principal_type: PrincipalType::FederatedUser,
538        source_identity: None,
539        tags: None,
540    }
541}
542
543/// Produce the AWS-style AccessDenied error returned when the role's
544/// trust policy refuses an STS AssumeRole* call.
545fn trust_policy_denied(action: &str, caller_arn: &str, role_arn: &str) -> AwsServiceError {
546    AwsServiceError::aws_error(
547        StatusCode::FORBIDDEN,
548        "AccessDenied",
549        format!(
550            "User: {} is not authorized to perform: {} on resource: {}",
551            caller_arn, action, role_arn
552        ),
553    )
554}
555
556/// Extract the RoleSessionName from a base64-encoded SAML assertion.
557fn extract_saml_session_name(saml_b64: &str) -> Option<String> {
558    use base64::Engine;
559    let decoded = base64::engine::general_purpose::STANDARD
560        .decode(saml_b64)
561        .ok()?;
562    let xml_str = String::from_utf8(decoded).ok()?;
563
564    // Look for the RoleSessionName attribute value in the SAML XML.
565    let role_session_attr = "https://aws.amazon.com/SAML/Attributes/RoleSessionName";
566    let pos = xml_str.find(role_session_attr)?;
567
568    // Find the AttributeValue after this position
569    let after = &xml_str[pos..];
570    let av_start = after.find("AttributeValue")?;
571    let after_av = &after[av_start..];
572    // Skip past the closing >
573    let gt_pos = after_av.find('>')?;
574    let value_start = &after_av[gt_pos + 1..];
575    // Find end of value (next < which starts the closing tag)
576    let lt_pos = value_start.find('<')?;
577    let value = value_start[..lt_pos].trim();
578
579    if value.is_empty() {
580        None
581    } else {
582        Some(value.to_string())
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn test_partition_for_region() {
592        assert_eq!(partition_for_region("us-east-1"), "aws");
593        assert_eq!(partition_for_region("eu-west-1"), "aws");
594        assert_eq!(partition_for_region("cn-north-1"), "aws-cn");
595        assert_eq!(partition_for_region("cn-northwest-1"), "aws-cn");
596        assert_eq!(partition_for_region("us-isob-east-1"), "aws-iso-b");
597        assert_eq!(partition_for_region("us-iso-east-1"), "aws-iso");
598    }
599
600    #[test]
601    fn test_extract_account_from_arn() {
602        assert_eq!(
603            extract_account_from_arn("arn:aws:iam::123456789012:role/test"),
604            Some("123456789012".to_string())
605        );
606        assert_eq!(
607            extract_account_from_arn("arn:aws:iam::111111111111:role/test"),
608            Some("111111111111".to_string())
609        );
610        assert_eq!(extract_account_from_arn("invalid"), None);
611    }
612
613    #[test]
614    fn test_extract_saml_session_name() {
615        use base64::Engine;
616        let xml = r#"<?xml version="1.0"?><samlp:Response><Assertion><AttributeStatement><Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName"><AttributeValue>testuser</AttributeValue></Attribute></AttributeStatement></Assertion></samlp:Response>"#;
617        let encoded = base64::engine::general_purpose::STANDARD.encode(xml.as_bytes());
618        assert_eq!(
619            extract_saml_session_name(&encoded),
620            Some("testuser".to_string())
621        );
622    }
623
624    #[test]
625    fn test_extract_saml_session_name_with_namespace() {
626        use base64::Engine;
627        let xml = r#"<?xml version="1.0"?><samlp:Response><saml:Assertion><saml:AttributeStatement><saml:Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName"><saml:AttributeValue>testuser</saml:AttributeValue></saml:Attribute></saml:AttributeStatement></saml:Assertion></samlp:Response>"#;
628        let encoded = base64::engine::general_purpose::STANDARD.encode(xml.as_bytes());
629        assert_eq!(
630            extract_saml_session_name(&encoded),
631            Some("testuser".to_string())
632        );
633    }
634
635    #[test]
636    fn test_session_token_format() {
637        let token = xml_responses::generate_session_token();
638        assert_eq!(token.len(), 356);
639        assert!(token.starts_with("FQoGZXIvYXdzE"));
640    }
641
642    #[test]
643    fn test_access_key_id_format() {
644        let key = xml_responses::generate_access_key_id();
645        assert_eq!(key.len(), 20);
646        assert!(key.starts_with("FSIA"));
647    }
648
649    #[test]
650    fn test_secret_access_key_format() {
651        let key = xml_responses::generate_secret_access_key();
652        assert_eq!(key.len(), 40);
653    }
654
655    #[test]
656    fn test_role_id_format() {
657        let id = xml_responses::generate_role_id();
658        assert_eq!(id.len(), 21);
659        assert!(id.starts_with("AROA"));
660    }
661
662    #[test]
663    fn test_decode_authorization_message() {
664        use parking_lot::RwLock;
665        use std::collections::HashMap;
666        use std::sync::Arc;
667
668        let state: SharedIamState = Arc::new(RwLock::new(
669            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
670        ));
671        let service = StsService::new(state);
672
673        // Encode a real deny payload, then verify the decode op
674        // round-trips it back through the response body.
675        let token = crate::auth_message::encode_deny(
676            true,
677            Some("s3:GetObject"),
678            Some("arn:aws:iam::123456789012:user/alice"),
679            vec![serde_json::json!({"sourcePolicyId": "deny-bucket-foo"})],
680            None,
681        );
682        let mut params = HashMap::new();
683        params.insert("EncodedMessage".to_string(), token);
684
685        let req = make_test_request(params);
686        let resp = service.decode_authorization_message(&req).unwrap();
687        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
688        assert!(body.contains("DecodedMessage"));
689        assert!(body.contains("explicitDeny"));
690        assert!(body.contains("s3:GetObject"));
691        assert!(body.contains("deny-bucket-foo"));
692    }
693
694    #[test]
695    fn test_decode_authorization_message_rejects_invalid_token() {
696        use parking_lot::RwLock;
697        use std::collections::HashMap;
698        use std::sync::Arc;
699
700        let state: SharedIamState = Arc::new(RwLock::new(
701            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
702        ));
703        let service = StsService::new(state);
704
705        let mut params = HashMap::new();
706        params.insert("EncodedMessage".to_string(), "not-a-real-token".to_string());
707        let req = make_test_request(params);
708        let err = match service.decode_authorization_message(&req) {
709            Err(e) => e,
710            Ok(_) => panic!("expected InvalidAuthorizationMessageException"),
711        };
712        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
713        let msg = format!("{:?}", err);
714        assert!(msg.contains("InvalidAuthorizationMessageException"));
715    }
716
717    #[test]
718    fn test_decode_authorization_message_missing_param() {
719        use parking_lot::RwLock;
720        use std::collections::HashMap;
721        use std::sync::Arc;
722
723        let state: SharedIamState = Arc::new(RwLock::new(
724            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
725        ));
726        let service = StsService::new(state);
727
728        let req = make_test_request(HashMap::new());
729        let result = service.decode_authorization_message(&req);
730        assert!(result.is_err());
731        let err = result.err().unwrap();
732        let msg = format!("{:?}", err);
733        assert!(msg.contains("EncodedMessage"));
734    }
735
736    fn make_test_request(params: std::collections::HashMap<String, String>) -> AwsRequest {
737        AwsRequest {
738            service: "sts".into(),
739            action: "Test".into(),
740            region: "us-east-1".into(),
741            account_id: "123456789012".into(),
742            request_id: "test".into(),
743            headers: http::HeaderMap::new(),
744            query_params: params,
745            body: Default::default(),
746            body_stream: parking_lot::Mutex::new(None),
747            path_segments: vec![],
748            raw_path: "/".into(),
749            raw_query: String::new(),
750            method: http::Method::POST,
751            is_query_protocol: true,
752            access_key_id: None,
753            principal: None,
754        }
755    }
756
757    fn parse_expiration(s: &str) -> chrono::DateTime<Utc> {
758        chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%SZ")
759            .expect("valid timestamp")
760            .and_utc()
761    }
762
763    #[test]
764    fn test_compute_expiration_with_duration() {
765        use std::collections::HashMap;
766
767        let mut params = HashMap::new();
768        params.insert("DurationSeconds".to_string(), "1800".to_string());
769        let req = make_test_request(params);
770
771        let now = Utc::now();
772        let exp_str = compute_expiration(&req, 3600).unwrap();
773        let exp_utc = parse_expiration(&exp_str);
774
775        // Should be ~1800s from now (using provided DurationSeconds, not default)
776        let diff = (exp_utc - now).num_seconds();
777        assert!(
778            (1798..=1802).contains(&diff),
779            "expected ~1800s duration, got {diff}s"
780        );
781    }
782
783    #[test]
784    fn test_compute_expiration_default() {
785        use std::collections::HashMap;
786
787        let req = make_test_request(HashMap::new());
788
789        let now = Utc::now();
790        let exp_str = compute_expiration(&req, 43200).unwrap();
791        let exp_utc = parse_expiration(&exp_str);
792
793        // Should be ~43200s (12 hours) from now using default
794        let diff = (exp_utc - now).num_seconds();
795        assert!(
796            (43198..=43202).contains(&diff),
797            "expected ~43200s duration, got {diff}s"
798        );
799    }
800
801    #[test]
802    fn test_compute_expiration_uses_provided_not_default() {
803        use std::collections::HashMap;
804
805        let mut params = HashMap::new();
806        params.insert("DurationSeconds".to_string(), "900".to_string());
807        let req = make_test_request(params);
808
809        let before = Utc::now();
810        let exp_str = compute_expiration(&req, 43200).unwrap();
811        let exp_utc = parse_expiration(&exp_str);
812
813        // Should use 900s, not the default 43200s
814        let expected = before + chrono::Duration::seconds(900);
815        let diff = (exp_utc - expected).num_seconds().abs();
816        assert!(
817            diff <= 2,
818            "expected ~900s duration, got diff={diff}s from expected"
819        );
820    }
821
822    fn make_sts_service() -> (StsService, SharedIamState) {
823        use parking_lot::RwLock;
824        use std::sync::Arc;
825
826        let state: SharedIamState = Arc::new(RwLock::new(
827            fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
828        ));
829        let sts = StsService::new(state.clone());
830        (sts, state)
831    }
832
833    fn sts_request(action: &str, params: Vec<(&str, &str)>) -> AwsRequest {
834        let mut qp = std::collections::HashMap::new();
835        qp.insert("Action".to_string(), action.to_string());
836        for (k, v) in params {
837            qp.insert(k.to_string(), v.to_string());
838        }
839        let mut req = make_test_request(qp);
840        req.action = action.to_string();
841        req
842    }
843
844    fn create_role_in_state(state: &SharedIamState, name: &str) -> String {
845        // Permissive default trust so the basic-path tests don't trip
846        // the new evaluator-driven trust gate. Tests that specifically
847        // exercise restricted trust policies use
848        // `create_role_in_state_with_trust` directly.
849        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
850        create_role_in_state_with_trust(state, name, trust)
851    }
852
853    fn create_role_in_state_with_trust(
854        state: &SharedIamState,
855        name: &str,
856        trust_policy: &str,
857    ) -> String {
858        let arn = fakecloud_aws::arn::Arn::global("iam", "123456789012", &format!("role/{name}"))
859            .to_string();
860        let mut accounts = state.write();
861        let s = accounts.get_or_create("123456789012");
862        // Real CreateRole inserts by role_name; assume_role looks up
863        // by role_name too, so the map key must match for the trust
864        // policy gate to actually fire.
865        s.roles.insert(
866            name.to_string(),
867            crate::state::IamRole {
868                role_name: name.to_string(),
869                role_id: format!("AROA{}", &uuid::Uuid::new_v4().to_string()[..17]),
870                arn: arn.clone(),
871                path: "/".to_string(),
872                assume_role_policy_document: trust_policy.to_string(),
873                created_at: Utc::now(),
874                description: None,
875                max_session_duration: 3600,
876                tags: Vec::new(),
877                permissions_boundary: None,
878            },
879        );
880        arn
881    }
882
883    // ── GetCallerIdentity ──
884
885    #[tokio::test]
886    async fn get_caller_identity() {
887        let (svc, _) = make_sts_service();
888        let mut req = sts_request("GetCallerIdentity", vec![]);
889        // F4: GetCallerIdentity now rejects calls with neither a
890        // resolved principal nor an Authorization header. Add a stub
891        // header so the unauthenticated-but-account-scoped fallback
892        // (used by smoke probes / the `test` root bypass) still
893        // returns a usable identity.
894        req.headers.insert(
895            http::header::AUTHORIZATION,
896            http::HeaderValue::from_static("AWS4-HMAC-SHA256 Credential=test/test"),
897        );
898        let resp = svc.handle(req).await.unwrap();
899        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
900        assert!(body.contains("<Account>123456789012</Account>"));
901        assert!(body.contains("<Arn>"));
902    }
903
904    #[tokio::test]
905    async fn get_caller_identity_rejects_unauthenticated_request() {
906        // No principal AND no Authorization header → AWS returns
907        // MissingAuthenticationTokenException (403). The `test` root
908        // bypass and signed requests both attach a header, so the
909        // common dev path is unaffected.
910        let (svc, _) = make_sts_service();
911        let req = sts_request("GetCallerIdentity", vec![]);
912        let err = match svc.handle(req).await {
913            Err(e) => e,
914            Ok(_) => panic!("expected MissingAuthenticationTokenException"),
915        };
916        assert_eq!(err.status(), StatusCode::FORBIDDEN);
917        assert!(format!("{:?}", err).contains("MissingAuthenticationTokenException"));
918    }
919
920    // ── AssumeRole ──
921
922    #[tokio::test]
923    async fn assume_role_basic() {
924        let (svc, state) = make_sts_service();
925        let role_arn = create_role_in_state(&state, "test-role");
926
927        let req = sts_request(
928            "AssumeRole",
929            vec![("RoleArn", &role_arn), ("RoleSessionName", "test-session")],
930        );
931        let resp = svc.handle(req).await.unwrap();
932        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
933        assert!(body.contains("<AccessKeyId>"));
934        assert!(body.contains("<SecretAccessKey>"));
935        assert!(body.contains("<SessionToken>"));
936    }
937
938    #[tokio::test]
939    async fn assume_role_not_found() {
940        let (svc, _) = make_sts_service();
941        let req = sts_request(
942            "AssumeRole",
943            vec![
944                ("RoleArn", "arn:aws:iam::123456789012:role/nonexistent"),
945                ("RoleSessionName", "s"),
946            ],
947        );
948        assert!(svc.handle(req).await.is_err());
949    }
950
951    #[tokio::test]
952    async fn assume_role_missing_session_name() {
953        let (svc, _) = make_sts_service();
954        let req = sts_request(
955            "AssumeRole",
956            vec![("RoleArn", "arn:aws:iam::123456789012:role/r")],
957        );
958        assert!(svc.handle(req).await.is_err());
959    }
960
961    // ── AssumeRoleWithWebIdentity ──
962
963    #[tokio::test]
964    async fn assume_role_with_web_identity() {
965        let (svc, state) = make_sts_service();
966        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithWebIdentity"}]}"#;
967        let role_arn = create_role_in_state_with_trust(&state, "web-role", trust);
968
969        let req = sts_request(
970            "AssumeRoleWithWebIdentity",
971            vec![
972                ("RoleArn", &role_arn),
973                ("RoleSessionName", "web-session"),
974                ("WebIdentityToken", "fake-jwt-token"),
975            ],
976        );
977        let resp = svc.handle(req).await.unwrap();
978        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
979        assert!(body.contains("<AccessKeyId>"));
980    }
981
982    #[tokio::test]
983    async fn assume_role_with_web_identity_rejects_expired_token() {
984        // A JWT whose `exp` is in the past must be rejected with
985        // ExpiredTokenException, not mint fresh credentials (bug-audit
986        // 2026-06-20, 5.1).
987        use base64::Engine;
988        let (svc, state) = make_sts_service();
989        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithWebIdentity"}]}"#;
990        let role_arn = create_role_in_state_with_trust(&state, "web-role", trust);
991
992        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#);
993        let payload_json = format!(
994            r#"{{"iss":"https://example.com","aud":"client","sub":"u","exp":{}}}"#,
995            chrono::Utc::now().timestamp() - 3600
996        );
997        let payload =
998            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
999        let token = format!("{header}.{payload}.sig");
1000
1001        let req = sts_request(
1002            "AssumeRoleWithWebIdentity",
1003            vec![
1004                ("RoleArn", &role_arn),
1005                ("RoleSessionName", "web-session"),
1006                ("WebIdentityToken", &token),
1007            ],
1008        );
1009        let err = match svc.handle(req).await {
1010            Err(e) => e,
1011            Ok(_) => panic!("expected ExpiredTokenException for an expired token"),
1012        };
1013        assert_eq!(err.code(), "ExpiredTokenException");
1014    }
1015
1016    #[tokio::test]
1017    async fn assume_role_with_web_identity_nonexistent_role_denied() {
1018        // §5.2: a missing role must NOT fall through to credential minting.
1019        // AWS returns AccessDenied for AssumeRoleWithWebIdentity on a role
1020        // it cannot resolve.
1021        let (svc, _state) = make_sts_service();
1022        let req = sts_request(
1023            "AssumeRoleWithWebIdentity",
1024            vec![
1025                ("RoleArn", "arn:aws:iam::123456789012:role/does-not-exist"),
1026                ("RoleSessionName", "web-session"),
1027                ("WebIdentityToken", "fake-jwt-token"),
1028            ],
1029        );
1030        let err = match svc.handle(req).await {
1031            Err(e) => e,
1032            Ok(_) => panic!("expected AccessDenied for phantom role, got success"),
1033        };
1034        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1035        assert!(
1036            format!("{err:?}").contains("AccessDenied"),
1037            "expected AccessDenied, got {err:?}"
1038        );
1039    }
1040
1041    // ── AssumeRoleWithSAML ──
1042
1043    fn make_saml_assertion() -> String {
1044        use base64::Engine;
1045        // Minimal SAML XML carrying a RoleSessionName attribute value.
1046        let xml = r#"<saml:Assertion><saml:AttributeStatement><saml:Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName"><saml:AttributeValue>saml-user</saml:AttributeValue></saml:Attribute></saml:AttributeStatement></saml:Assertion>"#;
1047        base64::engine::general_purpose::STANDARD.encode(xml.as_bytes())
1048    }
1049
1050    #[tokio::test]
1051    async fn assume_role_with_saml_existing_role_succeeds() {
1052        let (svc, state) = make_sts_service();
1053        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"arn:aws:iam::123456789012:saml-provider/idp"},"Action":"sts:AssumeRoleWithSAML"}]}"#;
1054        let role_arn = create_role_in_state_with_trust(&state, "saml-role", trust);
1055        let assertion = make_saml_assertion();
1056        let req = sts_request(
1057            "AssumeRoleWithSAML",
1058            vec![
1059                ("RoleArn", &role_arn),
1060                (
1061                    "PrincipalArn",
1062                    "arn:aws:iam::123456789012:saml-provider/idp",
1063                ),
1064                ("SAMLAssertion", &assertion),
1065            ],
1066        );
1067        let resp = svc.handle(req).await.expect("existing role should succeed");
1068        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1069        assert!(body.contains("<AccessKeyId>"));
1070    }
1071
1072    #[tokio::test]
1073    async fn assume_role_with_saml_nonexistent_role_denied() {
1074        // §5.2: a missing role must NOT fall through to credential minting.
1075        let (svc, _state) = make_sts_service();
1076        let assertion = make_saml_assertion();
1077        let req = sts_request(
1078            "AssumeRoleWithSAML",
1079            vec![
1080                ("RoleArn", "arn:aws:iam::123456789012:role/does-not-exist"),
1081                (
1082                    "PrincipalArn",
1083                    "arn:aws:iam::123456789012:saml-provider/idp",
1084                ),
1085                ("SAMLAssertion", &assertion),
1086            ],
1087        );
1088        let err = match svc.handle(req).await {
1089            Err(e) => e,
1090            Ok(_) => panic!("expected AccessDenied for phantom role, got success"),
1091        };
1092        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1093        assert!(
1094            format!("{err:?}").contains("AccessDenied"),
1095            "expected AccessDenied, got {err:?}"
1096        );
1097    }
1098
1099    // ── GetSessionToken ──
1100
1101    #[tokio::test]
1102    async fn get_session_token() {
1103        let (svc, _) = make_sts_service();
1104        let req = sts_request("GetSessionToken", vec![]);
1105        let resp = svc.handle(req).await.unwrap();
1106        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1107        assert!(body.contains("<AccessKeyId>"));
1108        assert!(body.contains("<SessionToken>"));
1109    }
1110
1111    #[tokio::test]
1112    async fn get_session_token_with_duration() {
1113        let (svc, _) = make_sts_service();
1114        let req = sts_request("GetSessionToken", vec![("DurationSeconds", "1800")]);
1115        let resp = svc.handle(req).await.unwrap();
1116        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1117        assert!(body.contains("<Expiration>"));
1118    }
1119
1120    // ── GetFederationToken ──
1121
1122    #[tokio::test]
1123    async fn get_federation_token() {
1124        let (svc, _) = make_sts_service();
1125        let req = sts_request("GetFederationToken", vec![("Name", "feduser")]);
1126        let resp = svc.handle(req).await.unwrap();
1127        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1128        assert!(body.contains("<AccessKeyId>"));
1129        assert!(body.contains("<FederatedUserId>"));
1130    }
1131
1132    // ── GetAccessKeyInfo ──
1133
1134    #[tokio::test]
1135    async fn get_access_key_info() {
1136        let (svc, _) = make_sts_service();
1137        let req = sts_request(
1138            "GetAccessKeyInfo",
1139            vec![("AccessKeyId", "AKIAIOSFODNN7EXAMPLE")],
1140        );
1141        let resp = svc.handle(req).await.unwrap();
1142        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1143        assert!(body.contains("<Account>"));
1144    }
1145
1146    // ── Trust policy: ExternalId enforcement ──
1147
1148    #[tokio::test]
1149    async fn assume_role_rejects_when_external_id_missing() {
1150        // Trust policy demands sts:ExternalId; caller didn't supply
1151        // one — AssumeRole must 403.
1152        let (svc, state) = make_sts_service();
1153        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"secret-handshake"}}}]}"#;
1154        let role_arn = create_role_in_state_with_trust(&state, "third-party", trust);
1155        let req = sts_request(
1156            "AssumeRole",
1157            vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1158        );
1159        let err = match svc.handle(req).await {
1160            Err(e) => e,
1161            Ok(_) => panic!("expected AccessDenied when ExternalId missing"),
1162        };
1163        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1164    }
1165
1166    #[tokio::test]
1167    async fn assume_role_rejects_when_external_id_mismatches() {
1168        let (svc, state) = make_sts_service();
1169        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"secret-handshake"}}}]}"#;
1170        let role_arn = create_role_in_state_with_trust(&state, "third-party", trust);
1171        let req = sts_request(
1172            "AssumeRole",
1173            vec![
1174                ("RoleArn", &role_arn),
1175                ("RoleSessionName", "sess"),
1176                ("ExternalId", "wrongguess"),
1177            ],
1178        );
1179        let err = match svc.handle(req).await {
1180            Err(e) => e,
1181            Ok(_) => panic!("expected AccessDenied when ExternalId mismatches"),
1182        };
1183        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1184    }
1185
1186    #[tokio::test]
1187    async fn assume_role_succeeds_when_external_id_matches() {
1188        let (svc, state) = make_sts_service();
1189        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"secret-handshake"}}}]}"#;
1190        let role_arn = create_role_in_state_with_trust(&state, "third-party", trust);
1191        let req = sts_request(
1192            "AssumeRole",
1193            vec![
1194                ("RoleArn", &role_arn),
1195                ("RoleSessionName", "sess"),
1196                ("ExternalId", "secret-handshake"),
1197            ],
1198        );
1199        let resp = svc.handle(req).await.unwrap();
1200        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1201        assert!(body.contains("<AccessKeyId>"));
1202    }
1203
1204    #[tokio::test]
1205    async fn assume_role_proceeds_when_no_external_id_required() {
1206        // No ExternalId Condition in the trust policy — caller doesn't
1207        // need to supply one.
1208        let (svc, state) = make_sts_service();
1209        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
1210        let role_arn = create_role_in_state_with_trust(&state, "open-role", trust);
1211        let req = sts_request(
1212            "AssumeRole",
1213            vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1214        );
1215        svc.handle(req).await.unwrap();
1216    }
1217
1218    // ── Trust policy: principal gating via evaluator ──
1219
1220    #[tokio::test]
1221    async fn assume_role_rejects_when_trust_policy_has_no_statements() {
1222        let (svc, state) = make_sts_service();
1223        let role_arn = create_role_in_state_with_trust(&state, "no-trust", r#"{"Statement":[]}"#);
1224        let req = sts_request(
1225            "AssumeRole",
1226            vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1227        );
1228        let err = match svc.handle(req).await {
1229            Err(e) => e,
1230            Ok(_) => panic!("expected AccessDenied"),
1231        };
1232        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1233    }
1234
1235    #[tokio::test]
1236    async fn assume_role_rejects_when_trust_policy_excludes_caller() {
1237        let (svc, state) = make_sts_service();
1238        // Trust policy only allows a specific service that isn't the
1239        // anonymous caller; evaluator must reject.
1240        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}"#;
1241        let role_arn = create_role_in_state_with_trust(&state, "ec2-only", trust);
1242        let req = sts_request(
1243            "AssumeRole",
1244            vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1245        );
1246        let err = match svc.handle(req).await {
1247            Err(e) => e,
1248            Ok(_) => panic!("expected AccessDenied"),
1249        };
1250        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1251    }
1252
1253    #[tokio::test]
1254    async fn assume_role_rejects_when_trust_policy_explicitly_denies() {
1255        let (svc, state) = make_sts_service();
1256        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"},{"Effect":"Deny","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
1257        let role_arn = create_role_in_state_with_trust(&state, "deny-wins", trust);
1258        let req = sts_request(
1259            "AssumeRole",
1260            vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1261        );
1262        let err = match svc.handle(req).await {
1263            Err(e) => e,
1264            Ok(_) => panic!("expected AccessDenied"),
1265        };
1266        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1267    }
1268
1269    #[tokio::test]
1270    async fn assume_role_allowed_by_trust_policy_with_principal_match() {
1271        // Trust policy names the caller's account explicitly. Per AWS,
1272        // `"Principal": { "AWS": "123456789012" }` is shorthand for the
1273        // account root and matches any IAM principal in that account.
1274        let (svc, state) = make_sts_service();
1275        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"123456789012"},"Action":"sts:AssumeRole"}]}"#;
1276        let role_arn = create_role_in_state_with_trust(&state, "named", trust);
1277        let req = sts_request(
1278            "AssumeRole",
1279            vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1280        );
1281        let resp = svc.handle(req).await.unwrap();
1282        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1283        assert!(body.contains("<AccessKeyId>"), "{body}");
1284    }
1285
1286    #[tokio::test]
1287    async fn assume_role_blocked_when_principal_not_in_trust_policy() {
1288        // Trust policy lists a different account — caller's account
1289        // (123456789012) doesn't match, so AssumeRole must 403.
1290        let (svc, state) = make_sts_service();
1291        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::999999999999:root"},"Action":"sts:AssumeRole"}]}"#;
1292        let role_arn = create_role_in_state_with_trust(&state, "other-account", trust);
1293        let req = sts_request(
1294            "AssumeRole",
1295            vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1296        );
1297        let err = match svc.handle(req).await {
1298            Err(e) => e,
1299            Ok(_) => panic!("expected AccessDenied when caller account not in trust policy"),
1300        };
1301        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1302    }
1303
1304    // ── Trust policy: ExternalId aliases (rename of existing tests for plan) ──
1305
1306    #[tokio::test]
1307    async fn assume_role_blocked_when_external_id_required_but_missing() {
1308        let (svc, state) = make_sts_service();
1309        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"hello"}}}]}"#;
1310        let role_arn = create_role_in_state_with_trust(&state, "ext-required", trust);
1311        let req = sts_request(
1312            "AssumeRole",
1313            vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1314        );
1315        let err = match svc.handle(req).await {
1316            Err(e) => e,
1317            Ok(_) => panic!("expected AccessDenied when ExternalId required but missing"),
1318        };
1319        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1320    }
1321
1322    #[tokio::test]
1323    async fn assume_role_succeeds_with_correct_external_id() {
1324        let (svc, state) = make_sts_service();
1325        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"sts:ExternalId":"hello"}}}]}"#;
1326        let role_arn = create_role_in_state_with_trust(&state, "ext-ok", trust);
1327        let req = sts_request(
1328            "AssumeRole",
1329            vec![
1330                ("RoleArn", &role_arn),
1331                ("RoleSessionName", "sess"),
1332                ("ExternalId", "hello"),
1333            ],
1334        );
1335        svc.handle(req).await.unwrap();
1336    }
1337
1338    // ── Trust policy: MFA enforcement ──
1339
1340    #[tokio::test]
1341    async fn assume_role_blocked_when_mfa_required_but_not_present() {
1342        // Trust policy requires `aws:MultiFactorAuthPresent: true`; the
1343        // request didn't supply SerialNumber+TokenCode, so the condition
1344        // evaluates false and AssumeRole must 403.
1345        let (svc, state) = make_sts_service();
1346        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}}]}"#;
1347        let role_arn = create_role_in_state_with_trust(&state, "mfa-required", trust);
1348        let req = sts_request(
1349            "AssumeRole",
1350            vec![("RoleArn", &role_arn), ("RoleSessionName", "sess")],
1351        );
1352        let err = match svc.handle(req).await {
1353            Err(e) => e,
1354            Ok(_) => panic!("expected AccessDenied when MFA required but not supplied"),
1355        };
1356        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1357    }
1358
1359    #[tokio::test]
1360    async fn assume_role_succeeds_with_mfa_supplied() {
1361        // Same trust policy as above, but the caller supplied MFA —
1362        // condition evaluates true and AssumeRole succeeds. The minted
1363        // session credential carries `mfa_present: true` so downstream
1364        // Authorize evaluations see `aws:MultiFactorAuthPresent`.
1365        let (svc, state) = make_sts_service();
1366        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole","Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}}]}"#;
1367        let role_arn = create_role_in_state_with_trust(&state, "mfa-ok", trust);
1368        let req = sts_request(
1369            "AssumeRole",
1370            vec![
1371                ("RoleArn", &role_arn),
1372                ("RoleSessionName", "sess"),
1373                ("SerialNumber", "arn:aws:iam::123456789012:mfa/alice"),
1374                ("TokenCode", "123456"),
1375            ],
1376        );
1377        let resp = svc.handle(req).await.unwrap();
1378        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1379        assert!(body.contains("<AccessKeyId>"), "{body}");
1380        // The session credential the resolver hands out must record
1381        // mfa_present=true so Authorize sees aws:MultiFactorAuthPresent.
1382        let states = state.read();
1383        let s = states.get("123456789012").unwrap();
1384        let any_mfa = s.sts_temp_credentials.values().any(|c| c.mfa_present);
1385        assert!(
1386            any_mfa,
1387            "expected at least one minted credential with mfa_present=true"
1388        );
1389    }
1390
1391    #[tokio::test]
1392    async fn assume_role_with_mfa_resolved_credential_drives_iam_evaluator() {
1393        // E2E: assume role with MFA, fetch the issued credential through
1394        // the same `CredentialResolver` adapter dispatch uses, then run
1395        // the IAM evaluator on a policy that gates on
1396        // `aws:MultiFactorAuthPresent: true`. This wires
1397        // sts_service -> StsTempCredential -> SecretLookup ->
1398        // ResolvedCredential -> ConditionContext end to end and proves
1399        // the MFA assertion survives every hop.
1400        use crate::credential_resolver::IamCredentialResolver;
1401        use crate::evaluator::{
1402            evaluate as eval_policies, EvalRequest, PolicyDocument, RequestContext,
1403        };
1404        use fakecloud_core::auth::{ConditionContext, CredentialResolver};
1405
1406        let (svc, state) = make_sts_service();
1407        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
1408        let role_arn = create_role_in_state_with_trust(&state, "mfa-e2e", trust);
1409        let req = sts_request(
1410            "AssumeRole",
1411            vec![
1412                ("RoleArn", &role_arn),
1413                ("RoleSessionName", "ops"),
1414                ("SerialNumber", "arn:aws:iam::123456789012:mfa/alice"),
1415                ("TokenCode", "654321"),
1416            ],
1417        );
1418        let resp = svc.handle(req).await.unwrap();
1419        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1420        // Pull the AccessKeyId out of the XML response and resolve it.
1421        let access_key_id = body
1422            .split("<AccessKeyId>")
1423            .nth(1)
1424            .and_then(|s| s.split("</AccessKeyId>").next())
1425            .expect("response should contain AccessKeyId")
1426            .to_string();
1427        let resolver = IamCredentialResolver::new(state.clone());
1428        let resolved = resolver
1429            .resolve(&access_key_id)
1430            .expect("issued credential must resolve through the resolver");
1431        assert!(
1432            resolved.mfa_present,
1433            "F3: MFA flag must survive the resolver hop"
1434        );
1435        assert!(
1436            resolved.token_issued_at.is_some(),
1437            "F3: token_issued_at must be populated for STS sessions"
1438        );
1439
1440        // Mirror what dispatch does: build a ConditionContext from the
1441        // resolved credential, then evaluate a permission policy that
1442        // requires MFA.
1443        let mut ctx: RequestContext = ConditionContext {
1444            aws_principal_arn: Some(resolved.principal.arn.clone()),
1445            aws_principal_account: Some(resolved.principal.account_id.clone()),
1446            aws_userid: Some(resolved.principal.user_id.clone()),
1447            aws_mfa_present: Some(resolved.mfa_present),
1448            aws_token_issue_time: resolved.token_issued_at,
1449            aws_federated_provider: resolved.federated_provider.clone(),
1450            ..Default::default()
1451        };
1452        if resolved.mfa_present {
1453            if let Some(issued) = resolved.token_issued_at {
1454                ctx.aws_mfa_age_seconds = Some(
1455                    Utc::now()
1456                        .signed_duration_since(issued)
1457                        .num_seconds()
1458                        .max(0),
1459                );
1460            }
1461        }
1462
1463        let policy = PolicyDocument::parse(
1464            r#"{"Version":"2012-10-17","Statement":[{
1465                "Effect":"Allow",
1466                "Action":"s3:GetObject",
1467                "Resource":"*",
1468                "Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}
1469            }]}"#,
1470        );
1471        let eval = EvalRequest {
1472            principal: &resolved.principal,
1473            action: "s3:GetObject".to_string(),
1474            resource: "arn:aws:s3:::secrets/k".to_string(),
1475            context: ctx,
1476        };
1477        let decision = eval_policies(&[policy], &eval);
1478        assert_eq!(
1479            decision,
1480            crate::evaluator::Decision::Allow,
1481            "F3: MFA-gated allow must fire when session was minted with MFA"
1482        );
1483
1484        // Negative control: same evaluator wiring but without MFA on
1485        // the resolved credential -> implicit deny.
1486        let req_no_mfa = sts_request(
1487            "AssumeRole",
1488            vec![("RoleArn", &role_arn), ("RoleSessionName", "no-mfa")],
1489        );
1490        let resp_no_mfa = svc.handle(req_no_mfa).await.unwrap();
1491        let body_no_mfa = std::str::from_utf8(resp_no_mfa.body.expect_bytes()).unwrap();
1492        let akid_no_mfa = body_no_mfa
1493            .split("<AccessKeyId>")
1494            .nth(1)
1495            .and_then(|s| s.split("</AccessKeyId>").next())
1496            .unwrap()
1497            .to_string();
1498        let resolved_no_mfa = resolver.resolve(&akid_no_mfa).unwrap();
1499        assert!(!resolved_no_mfa.mfa_present);
1500        let policy2 = PolicyDocument::parse(
1501            r#"{"Version":"2012-10-17","Statement":[{
1502                "Effect":"Allow",
1503                "Action":"s3:GetObject",
1504                "Resource":"*",
1505                "Condition":{"Bool":{"aws:MultiFactorAuthPresent":"true"}}
1506            }]}"#,
1507        );
1508        let ctx2 = ConditionContext {
1509            aws_principal_arn: Some(resolved_no_mfa.principal.arn.clone()),
1510            aws_userid: Some(resolved_no_mfa.principal.user_id.clone()),
1511            aws_mfa_present: Some(resolved_no_mfa.mfa_present),
1512            aws_token_issue_time: resolved_no_mfa.token_issued_at,
1513            ..Default::default()
1514        };
1515        let eval2 = EvalRequest {
1516            principal: &resolved_no_mfa.principal,
1517            action: "s3:GetObject".to_string(),
1518            resource: "arn:aws:s3:::secrets/k".to_string(),
1519            context: ctx2,
1520        };
1521        assert_eq!(
1522            eval_policies(&[policy2], &eval2),
1523            crate::evaluator::Decision::ImplicitDeny,
1524            "F3: MFA-gated allow must NOT fire when session was minted without MFA"
1525        );
1526    }
1527
1528    #[tokio::test]
1529    async fn assume_role_with_saml_populates_federated_provider() {
1530        // F3: AssumeRoleWithSAML must surface the SAML provider ARN as
1531        // `aws:FederatedProvider` on the resulting session.
1532        use crate::credential_resolver::IamCredentialResolver;
1533        use base64::Engine;
1534        use fakecloud_core::auth::CredentialResolver;
1535        let (svc, state) = make_sts_service();
1536        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithSAML"}]}"#;
1537        let role_arn = create_role_in_state_with_trust(&state, "saml-role", trust);
1538        let saml_xml = r#"<?xml version="1.0"?><samlp:Response><Assertion><AttributeStatement><Attribute Name="https://aws.amazon.com/SAML/Attributes/RoleSessionName"><AttributeValue>jane</AttributeValue></Attribute></AttributeStatement></Assertion></samlp:Response>"#;
1539        let saml_b64 = base64::engine::general_purpose::STANDARD.encode(saml_xml);
1540        let provider_arn = "arn:aws:iam::123456789012:saml-provider/idp";
1541        let req = sts_request(
1542            "AssumeRoleWithSAML",
1543            vec![
1544                ("RoleArn", &role_arn),
1545                ("PrincipalArn", provider_arn),
1546                ("SAMLAssertion", &saml_b64),
1547            ],
1548        );
1549        let resp = svc.handle(req).await.unwrap();
1550        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1551        let access_key_id = body
1552            .split("<AccessKeyId>")
1553            .nth(1)
1554            .and_then(|s| s.split("</AccessKeyId>").next())
1555            .unwrap()
1556            .to_string();
1557        let resolver = IamCredentialResolver::new(state.clone());
1558        let resolved = resolver.resolve(&access_key_id).unwrap();
1559        assert_eq!(
1560            resolved.federated_provider.as_deref(),
1561            Some(provider_arn),
1562            "AssumeRoleWithSAML must populate aws:FederatedProvider with the SAML provider ARN"
1563        );
1564    }
1565
1566    #[tokio::test]
1567    async fn assume_role_with_web_identity_populates_federated_provider() {
1568        // F3: AssumeRoleWithWebIdentity must populate
1569        // `aws:FederatedProvider`. With ProviderId we carry it verbatim;
1570        // without ProviderId we synthesize an OIDC provider ARN keyed
1571        // off the role's account so policies that simply check for the
1572        // presence of a federated provider still bind.
1573        use crate::credential_resolver::IamCredentialResolver;
1574        use fakecloud_core::auth::CredentialResolver;
1575        let (svc, state) = make_sts_service();
1576        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRoleWithWebIdentity"}]}"#;
1577        let role_arn = create_role_in_state_with_trust(&state, "oidc-role", trust);
1578        let req = sts_request(
1579            "AssumeRoleWithWebIdentity",
1580            vec![
1581                ("RoleArn", &role_arn),
1582                ("RoleSessionName", "oidc-session"),
1583                ("WebIdentityToken", "fake-jwt-blob"),
1584                ("ProviderId", "accounts.google.com"),
1585            ],
1586        );
1587        let resp = svc.handle(req).await.unwrap();
1588        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1589        let access_key_id = body
1590            .split("<AccessKeyId>")
1591            .nth(1)
1592            .and_then(|s| s.split("</AccessKeyId>").next())
1593            .unwrap()
1594            .to_string();
1595        let resolver = IamCredentialResolver::new(state.clone());
1596        let resolved = resolver.resolve(&access_key_id).unwrap();
1597        assert_eq!(
1598            resolved.federated_provider.as_deref(),
1599            Some("accounts.google.com"),
1600            "AssumeRoleWithWebIdentity must carry ProviderId as aws:FederatedProvider"
1601        );
1602    }
1603
1604    #[tokio::test]
1605    async fn assume_role_userid_format_matches_aws() {
1606        // AWS userid for assumed-role sessions: <role-id>:<RoleSessionName>.
1607        // Verify the resolved credential's user_id matches that shape so
1608        // a policy condition `aws:userid` can be matched correctly.
1609        use crate::credential_resolver::IamCredentialResolver;
1610        use fakecloud_core::auth::CredentialResolver;
1611        let (svc, state) = make_sts_service();
1612        let role_arn = create_role_in_state(&state, "userid-role");
1613        let req = sts_request(
1614            "AssumeRole",
1615            vec![("RoleArn", &role_arn), ("RoleSessionName", "carol")],
1616        );
1617        let resp = svc.handle(req).await.unwrap();
1618        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
1619        let access_key_id = body
1620            .split("<AccessKeyId>")
1621            .nth(1)
1622            .and_then(|s| s.split("</AccessKeyId>").next())
1623            .unwrap()
1624            .to_string();
1625        let resolver = IamCredentialResolver::new(state);
1626        let resolved = resolver.resolve(&access_key_id).unwrap();
1627        let uid = &resolved.principal.user_id;
1628        assert!(
1629            uid.contains(':'),
1630            "assumed-role userid must be `<role-id>:<RoleSessionName>`, got `{uid}`"
1631        );
1632        assert!(
1633            uid.ends_with(":carol"),
1634            "assumed-role userid must end with the RoleSessionName, got `{uid}`"
1635        );
1636    }
1637
1638    // ── Service-linked roles ──
1639
1640    #[tokio::test]
1641    async fn assume_service_linked_role_blocked_when_caller_not_matching_service() {
1642        let (svc, state) = make_sts_service();
1643        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs.amazonaws.com"},"Action":"sts:AssumeRole"}]}"#;
1644        let arn = fakecloud_aws::arn::Arn::global(
1645            "iam",
1646            "123456789012",
1647            "role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS",
1648        )
1649        .to_string();
1650        {
1651            let mut accounts = state.write();
1652            let s = accounts.get_or_create("123456789012");
1653            s.roles.insert(
1654                "AWSServiceRoleForECS".to_string(),
1655                crate::state::IamRole {
1656                    role_name: "AWSServiceRoleForECS".to_string(),
1657                    role_id: "AROASLRECS".to_string(),
1658                    arn: arn.clone(),
1659                    path: "/aws-service-role/ecs.amazonaws.com/".to_string(),
1660                    assume_role_policy_document: trust.to_string(),
1661                    created_at: Utc::now(),
1662                    description: None,
1663                    max_session_duration: 3600,
1664                    tags: Vec::new(),
1665                    permissions_boundary: None,
1666                },
1667            );
1668        }
1669        let req = sts_request(
1670            "AssumeRole",
1671            vec![("RoleArn", &arn), ("RoleSessionName", "sess")],
1672        );
1673        let err = match svc.handle(req).await {
1674            Err(e) => e,
1675            Ok(_) => {
1676                panic!("expected AccessDenied for service-linked role with non-service caller")
1677            }
1678        };
1679        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1680    }
1681
1682    #[tokio::test]
1683    async fn service_linked_role_rejects_non_service_caller() {
1684        let (svc, state) = make_sts_service();
1685        let trust = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}"#;
1686        let arn = fakecloud_aws::arn::Arn::global(
1687            "iam",
1688            "123456789012",
1689            "role/aws-service-role/elasticloadbalancing.amazonaws.com/AWSServiceRoleForELB",
1690        )
1691        .to_string();
1692        {
1693            let mut accounts = state.write();
1694            let s = accounts.get_or_create("123456789012");
1695            s.roles.insert(
1696                "AWSServiceRoleForELB".to_string(),
1697                crate::state::IamRole {
1698                    role_name: "AWSServiceRoleForELB".to_string(),
1699                    role_id: "AROASLR".to_string(),
1700                    arn: arn.clone(),
1701                    path: "/aws-service-role/elasticloadbalancing.amazonaws.com/".to_string(),
1702                    assume_role_policy_document: trust.to_string(),
1703                    created_at: Utc::now(),
1704                    description: None,
1705                    max_session_duration: 3600,
1706                    tags: Vec::new(),
1707                    permissions_boundary: None,
1708                },
1709            );
1710        }
1711        let req = sts_request(
1712            "AssumeRole",
1713            vec![("RoleArn", &arn), ("RoleSessionName", "sess")],
1714        );
1715        let err = match svc.handle(req).await {
1716            Err(e) => e,
1717            Ok(_) => panic!("expected AccessDenied for non-service caller"),
1718        };
1719        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1720    }
1721
1722    // ── Unsupported action ──
1723
1724    #[tokio::test]
1725    async fn unsupported_sts_action() {
1726        let (svc, _) = make_sts_service();
1727        let req = sts_request("BogusAction", vec![]);
1728        assert!(svc.handle(req).await.is_err());
1729    }
1730
1731    #[tokio::test]
1732    async fn assume_role_missing_role_arn_errors() {
1733        let (svc, _) = make_sts_service();
1734        let req = sts_request("AssumeRole", vec![("RoleSessionName", "sess")]);
1735        assert!(svc.assume_role(&req).is_err());
1736    }
1737
1738    #[tokio::test]
1739    async fn assume_role_with_web_identity_missing_token_errors() {
1740        let (svc, _) = make_sts_service();
1741        let req = sts_request(
1742            "AssumeRoleWithWebIdentity",
1743            vec![
1744                ("RoleArn", "arn:aws:iam::123:role/r"),
1745                ("RoleSessionName", "s"),
1746            ],
1747        );
1748        assert!(svc.assume_role_with_web_identity(&req).is_err());
1749    }
1750
1751    #[tokio::test]
1752    async fn assume_role_with_saml_missing_assertion_errors() {
1753        let (svc, _) = make_sts_service();
1754        let req = sts_request(
1755            "AssumeRoleWithSAML",
1756            vec![
1757                ("RoleArn", "arn:aws:iam::123:role/r"),
1758                ("PrincipalArn", "arn:aws:iam::123:saml-provider/p"),
1759            ],
1760        );
1761        assert!(svc.assume_role_with_saml(&req).is_err());
1762    }
1763
1764    #[tokio::test]
1765    async fn get_session_token_returns_ok() {
1766        let (svc, _) = make_sts_service();
1767        let req = sts_request("GetSessionToken", vec![]);
1768        let resp = svc.get_session_token(&req).unwrap();
1769        assert_eq!(resp.status, http::StatusCode::OK);
1770    }
1771
1772    #[tokio::test]
1773    async fn get_federation_token_returns_ok() {
1774        let (svc, _) = make_sts_service();
1775        let req = sts_request("GetFederationToken", vec![("Name", "test-user")]);
1776        let resp = svc.get_federation_token(&req).unwrap();
1777        assert_eq!(resp.status, http::StatusCode::OK);
1778    }
1779
1780    #[tokio::test]
1781    async fn get_federation_token_missing_name_errors() {
1782        let (svc, _) = make_sts_service();
1783        let req = sts_request("GetFederationToken", vec![]);
1784        assert!(svc.get_federation_token(&req).is_err());
1785    }
1786
1787    // ── AssumeRoot ──
1788
1789    #[tokio::test]
1790    async fn assume_root_with_account_id_succeeds() {
1791        let (svc, state) = make_sts_service();
1792        // AssumeRoot requires the RootSessions feature enabled (5.1).
1793        state
1794            .write()
1795            .get_or_create("123456789012")
1796            .organizations_root_sessions = true;
1797        let req = sts_request(
1798            "AssumeRoot",
1799            vec![
1800                ("TargetPrincipal", "111122223333"),
1801                (
1802                    "TaskPolicyArn.arn",
1803                    "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials",
1804                ),
1805            ],
1806        );
1807        let resp = svc.assume_root(&req).unwrap();
1808        assert_eq!(resp.status, http::StatusCode::OK);
1809        let body = String::from_utf8(resp.body.expect_bytes().to_vec()).unwrap();
1810        assert!(body.contains("AccessKeyId"), "{body}");
1811    }
1812
1813    #[tokio::test]
1814    async fn assume_root_with_arn_succeeds() {
1815        let (svc, state) = make_sts_service();
1816        // AssumeRoot requires the RootSessions feature enabled (5.1).
1817        state
1818            .write()
1819            .get_or_create("123456789012")
1820            .organizations_root_sessions = true;
1821        let req = sts_request(
1822            "AssumeRoot",
1823            vec![
1824                ("TargetPrincipal", "arn:aws:iam::444455556666:root"),
1825                (
1826                    "TaskPolicyArn.arn",
1827                    "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials",
1828                ),
1829                ("DurationSeconds", "600"),
1830                ("SourceIdentity", "alice"),
1831            ],
1832        );
1833        let resp = svc.assume_root(&req).unwrap();
1834        let body = String::from_utf8(resp.body.expect_bytes().to_vec()).unwrap();
1835        assert!(
1836            body.contains("<SourceIdentity>alice</SourceIdentity>"),
1837            "{body}"
1838        );
1839    }
1840
1841    #[tokio::test]
1842    async fn assume_root_denied_without_root_sessions() {
1843        // Without the centralized root-access RootSessions feature enabled,
1844        // AssumeRoot must be denied — previously it minted :root credentials
1845        // unconditionally (5.1).
1846        let (svc, _) = make_sts_service();
1847        let req = sts_request(
1848            "AssumeRoot",
1849            vec![
1850                ("TargetPrincipal", "arn:aws:iam::444455556666:root"),
1851                (
1852                    "TaskPolicyArn.arn",
1853                    "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials",
1854                ),
1855            ],
1856        );
1857        let err = svc
1858            .assume_root(&req)
1859            .err()
1860            .expect("AssumeRoot must be denied without RootSessions");
1861        assert_eq!(err.code(), "AccessDeniedException");
1862    }
1863
1864    #[tokio::test]
1865    async fn assume_root_same_account_succeeds_without_root_sessions() {
1866        // The member root managing its OWN account does not require the
1867        // centralized RootSessions feature; this is the recorded AWS baseline
1868        // (conformance sts_assume_root). Only cross-account targets are gated.
1869        let (svc, _) = make_sts_service();
1870        let req = sts_request(
1871            "AssumeRoot",
1872            vec![
1873                ("TargetPrincipal", "123456789012"),
1874                (
1875                    "TaskPolicyArn.arn",
1876                    "arn:aws:iam::aws:policy/IAMAuditRootUserCredentials",
1877                ),
1878            ],
1879        );
1880        let resp = svc.assume_root(&req).unwrap();
1881        assert_eq!(resp.status, http::StatusCode::OK);
1882        let body = String::from_utf8(resp.body.expect_bytes().to_vec()).unwrap();
1883        assert!(body.contains("AccessKeyId"), "{body}");
1884    }
1885
1886    #[tokio::test]
1887    async fn assume_root_missing_task_policy_rejected() {
1888        // `AssumeRoot` only declares `ExpiredTokenException` and
1889        // `RegionDisabledException` — no `MissingParameter`-style shape.
1890        // We route the Smithy @required violation through
1891        // `ExpiredTokenException` so strict-conformance probes see a
1892        // declared 4xx instead of either an undeclared error or a 200.
1893        let (svc, _) = make_sts_service();
1894        let req = sts_request("AssumeRoot", vec![("TargetPrincipal", "111122223333")]);
1895        let err = svc
1896            .assume_root(&req)
1897            .err()
1898            .expect("missing TaskPolicyArn must fail");
1899        assert_eq!(err.code(), "ExpiredTokenException");
1900    }
1901
1902    #[tokio::test]
1903    async fn assume_root_target_principal_below_min_length_rejected() {
1904        // `TargetPrincipalType` is @length 12..=2048. Anything shorter
1905        // (e.g. "not-an-id") is rejected up front.
1906        let (svc, _) = make_sts_service();
1907        let req = sts_request(
1908            "AssumeRoot",
1909            vec![
1910                ("TargetPrincipal", "not-an-id"),
1911                ("TaskPolicyArn.arn", "arn:aws:iam::aws:policy/X"),
1912            ],
1913        );
1914        let err = svc
1915            .assume_root(&req)
1916            .err()
1917            .expect("short TargetPrincipal must fail");
1918        assert_eq!(err.code(), "ExpiredTokenException");
1919    }
1920
1921    #[tokio::test]
1922    async fn assume_root_duration_above_max_rejected() {
1923        // `RootDurationSecondsType` is @range 0..=900. We now reject
1924        // out-of-range inputs with the only declared 4xx instead of
1925        // silently clamping.
1926        let (svc, _) = make_sts_service();
1927        let req = sts_request(
1928            "AssumeRoot",
1929            vec![
1930                ("TargetPrincipal", "111122223333"),
1931                ("TaskPolicyArn.arn", "arn:aws:iam::aws:policy/X"),
1932                ("DurationSeconds", "1800"),
1933            ],
1934        );
1935        let err = svc
1936            .assume_root(&req)
1937            .err()
1938            .expect("out-of-range DurationSeconds must fail");
1939        assert_eq!(err.code(), "ExpiredTokenException");
1940    }
1941}