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