From 9df7bfeb97cf773ec139345f5c25cb03f7153adf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 23:26:57 +0000 Subject: [PATCH 1/2] feat: add AWS IAM identity verification auth method Allow AWS services (Lambda, EC2, ECS) to authenticate with the proxy using their existing IAM credentials via AssumeRoleWithAWSIdentity, without needing an OIDC provider or long-lived secrets. The flow follows HashiCorp Vault's aws auth pattern: the client signs a GetCallerIdentity request, sends the signed headers to the proxy as base64-encoded query parameters, and the proxy forwards them to AWS STS for cryptographic identity verification. The verified ARN is then checked against the role's trusted_aws_accounts and subject_conditions before minting temporary credentials via the existing pipeline. Changes: - Add trusted_aws_accounts field to RoleConfig - Relax validation: roles need OIDC issuers OR AWS accounts - New aws_identity module: STS URL validation, request forwarding, GetCallerIdentity XML parsing - Wire AssumeRoleWithAWSIdentity into try_handle_sts() - Expose JwksCache HTTP client for reuse - Add docs/auth/aws-iam-auth.md with flow diagrams and Python examples https://claude.ai/code/session_01DeybF37mu27EMVd4kR57Xh --- crates/core/src/config/static_file.rs | 41 ++- crates/core/src/types.rs | 9 +- crates/sts/src/aws_identity.rs | 368 ++++++++++++++++++++++++++ crates/sts/src/jwks.rs | 5 + crates/sts/src/lib.rs | 192 ++++++++++++-- crates/sts/src/route_handler.rs | 10 +- docs/auth/aws-iam-auth.md | 221 ++++++++++++++++ docs/auth/proxy-auth.md | 5 +- 8 files changed, 814 insertions(+), 37 deletions(-) create mode 100644 crates/sts/src/aws_identity.rs create mode 100644 docs/auth/aws-iam-auth.md diff --git a/crates/core/src/config/static_file.rs b/crates/core/src/config/static_file.rs index 57dc600..1408939 100644 --- a/crates/core/src/config/static_file.rs +++ b/crates/core/src/config/static_file.rs @@ -55,9 +55,9 @@ impl StaticConfig { } else if !role_ids.insert(&role.role_id) { errors.push(format!("duplicate role_id: {:?}", role.role_id)); } - if role.trusted_oidc_issuers.is_empty() { + if role.trusted_oidc_issuers.is_empty() && role.trusted_aws_accounts.is_empty() { errors.push(format!( - "role {:?} has no trusted_oidc_issuers (will never accept a token)", + "role {:?} has no trusted_oidc_issuers or trusted_aws_accounts (will never accept a token)", role.role_id )); } @@ -239,6 +239,7 @@ mod tests { name: "My Role".into(), trusted_oidc_issuers: vec!["https://issuer.example.com".into()], required_audience: None, + trusted_aws_accounts: vec![], subject_conditions: vec![], allowed_scopes: vec![], max_session_duration_secs: 3600, @@ -305,11 +306,31 @@ mod tests { } #[test] - fn test_empty_trusted_oidc_issuers() { + fn test_no_trusted_issuers_or_accounts() { let mut config = valid_config(); config.roles[0].trusted_oidc_issuers.clear(); + config.roles[0].trusted_aws_accounts.clear(); let err = config.validate().unwrap_err().to_string(); - assert!(err.contains("no trusted_oidc_issuers"), "{}", err); + assert!( + err.contains("no trusted_oidc_issuers or trusted_aws_accounts"), + "{}", + err + ); + } + + #[test] + fn test_role_with_only_aws_accounts_passes() { + let mut config = valid_config(); + config.roles[0].trusted_oidc_issuers.clear(); + config.roles[0].trusted_aws_accounts = vec!["123456789012".into()]; + config.validate().unwrap(); + } + + #[test] + fn test_role_with_both_oidc_and_aws_passes() { + let mut config = valid_config(); + config.roles[0].trusted_aws_accounts = vec!["123456789012".into()]; + config.validate().unwrap(); } #[test] @@ -368,13 +389,21 @@ mod tests { max_session_duration_secs = 3600 "#; let err = StaticProvider::from_toml(toml).unwrap_err().to_string(); - assert!(err.contains("no trusted_oidc_issuers"), "{}", err); + assert!( + err.contains("no trusted_oidc_issuers or trusted_aws_accounts"), + "{}", + err + ); } #[test] fn test_from_json_runs_validation() { let json = r#"{"roles": [{"role_id": "bad-role", "name": "Bad", "max_session_duration_secs": 3600}]}"#; let err = StaticProvider::from_json(json).unwrap_err().to_string(); - assert!(err.contains("no trusted_oidc_issuers"), "{}", err); + assert!( + err.contains("no trusted_oidc_issuers or trusted_aws_accounts"), + "{}", + err + ); } } diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index bffa96c..eb120fd 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -114,8 +114,15 @@ pub struct RoleConfig { /// Required audience claim value. pub required_audience: Option, + /// AWS account IDs trusted by this role for IAM identity verification. + /// When set, AWS services in these accounts can authenticate by presenting + /// a signed `GetCallerIdentity` request. + #[serde(default)] + pub trusted_aws_accounts: Vec, + /// Conditions on the subject claim (glob patterns). - /// e.g., "repo:myorg/myrepo:ref:refs/heads/main" + /// For OIDC: matched against the `sub` claim (e.g., "repo:myorg/myrepo:ref:refs/heads/main"). + /// For AWS IAM: matched against the caller's IAM ARN (e.g., "arn:aws:sts::123456789012:assumed-role/MyRole/*"). #[serde(default)] pub subject_conditions: Vec, diff --git a/crates/sts/src/aws_identity.rs b/crates/sts/src/aws_identity.rs new file mode 100644 index 0000000..bb071d1 --- /dev/null +++ b/crates/sts/src/aws_identity.rs @@ -0,0 +1,368 @@ +//! AWS IAM identity verification via `GetCallerIdentity`. +//! +//! Allows AWS services to authenticate by presenting a signed +//! `sts:GetCallerIdentity` request. The proxy forwards the signed request +//! to AWS STS, which verifies the signature and returns the caller's +//! identity (account, ARN, user ID). The proxy then maps the identity +//! to a configured role. +//! +//! This follows the same pattern used by HashiCorp Vault's `aws` auth method. + +use base64::Engine; +use multistore::error::ProxyError; +use serde::Deserialize; +use std::collections::HashMap; + +/// Verified AWS caller identity returned by `GetCallerIdentity`. +#[derive(Debug, Clone)] +pub struct AwsCallerIdentity { + pub account: String, + pub arn: String, + pub user_id: String, +} + +/// Parsed `AssumeRoleWithAWSIdentity` request parameters. +#[derive(Debug, Clone)] +pub struct AwsIdentityRequest { + pub role_arn: String, + pub duration_seconds: Option, + pub iam_request_url: String, + pub iam_request_body: String, + pub iam_request_headers: HashMap, +} + +// ── STS URL validation ────────────────────────────────────────────── + +/// Allowed STS hostnames. The proxy only forwards signed requests to +/// these hosts, preventing an attacker from pointing at a controlled +/// server that returns fake identity. +fn validate_sts_url(url: &str) -> Result { + let parsed = url::Url::parse(url) + .map_err(|e| ProxyError::InvalidRequest(format!("invalid STS URL: {}", e)))?; + + if parsed.scheme() != "https" { + return Err(ProxyError::InvalidRequest( + "STS URL must use HTTPS".into(), + )); + } + + let host = parsed.host_str().ok_or_else(|| { + ProxyError::InvalidRequest("STS URL missing host".into()) + })?; + + // Allow: sts.amazonaws.com, sts..amazonaws.com, + // sts-fips..amazonaws.com + let valid = host == "sts.amazonaws.com" + || (host.starts_with("sts.") && host.ends_with(".amazonaws.com")) + || (host.starts_with("sts-fips.") && host.ends_with(".amazonaws.com")); + + if !valid { + return Err(ProxyError::InvalidRequest(format!( + "STS URL host '{}' is not a valid AWS STS endpoint", + host, + ))); + } + + Ok(parsed) +} + +// ── GetCallerIdentity XML response parsing ────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(rename = "GetCallerIdentityResponse")] +struct GetCallerIdentityResponse { + #[serde(rename = "GetCallerIdentityResult")] + result: GetCallerIdentityResult, +} + +#[derive(Debug, Deserialize)] +struct GetCallerIdentityResult { + #[serde(rename = "Account")] + account: String, + #[serde(rename = "Arn")] + arn: String, + #[serde(rename = "UserId")] + user_id: String, +} + +fn parse_caller_identity_xml(xml: &str) -> Result { + let resp: GetCallerIdentityResponse = quick_xml::de::from_str(xml) + .map_err(|e| ProxyError::InvalidRequest(format!( + "failed to parse GetCallerIdentity response: {}", e + )))?; + + Ok(AwsCallerIdentity { + account: resp.result.account, + arn: resp.result.arn, + user_id: resp.result.user_id, + }) +} + +// ── Request parsing ───────────────────────────────────────────────── + +/// Decode a base64url-or-standard-encoded string. +fn b64_decode(s: &str) -> Result { + // Try URL-safe first, then standard base64 (be permissive like Vault) + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(s) + .or_else(|_| base64::engine::general_purpose::STANDARD.decode(s)) + .map_err(|e| ProxyError::InvalidRequest(format!("base64 decode error: {}", e)))?; + + String::from_utf8(bytes) + .map_err(|e| ProxyError::InvalidRequest(format!("invalid UTF-8 in base64 payload: {}", e))) +} + +/// Try to parse an `AssumeRoleWithAWSIdentity` request from query parameters. +/// +/// Query parameters (following Vault's convention): +/// - `Action=AssumeRoleWithAWSIdentity` +/// - `RoleArn=` +/// - `IamRequestUrl=` +/// - `IamRequestBody=` +/// - `IamRequestHeaders=` +/// - `DurationSeconds=` +/// +/// Returns `None` if the query does not contain `Action=AssumeRoleWithAWSIdentity`. +pub fn try_parse_aws_identity_request( + query: Option<&str>, +) -> Option> { + let q = query?; + let params: Vec<(String, String)> = url::form_urlencoded::parse(q.as_bytes()) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let action = params.iter().find(|(k, _)| k == "Action"); + match action { + Some((_, value)) if value == "AssumeRoleWithAWSIdentity" => {} + _ => return None, + } + + Some(parse_aws_identity_params(¶ms)) +} + +fn parse_aws_identity_params( + params: &[(String, String)], +) -> Result { + let role_arn = params + .iter() + .find(|(k, _)| k == "RoleArn") + .map(|(_, v)| v.clone()) + .ok_or_else(|| ProxyError::InvalidRequest("missing RoleArn".into()))?; + + let iam_request_url = params + .iter() + .find(|(k, _)| k == "IamRequestUrl") + .map(|(_, v)| b64_decode(v)) + .ok_or_else(|| ProxyError::InvalidRequest("missing IamRequestUrl".into()))??; + + let iam_request_body = params + .iter() + .find(|(k, _)| k == "IamRequestBody") + .map(|(_, v)| b64_decode(v)) + .ok_or_else(|| ProxyError::InvalidRequest("missing IamRequestBody".into()))??; + + let iam_request_headers_json = params + .iter() + .find(|(k, _)| k == "IamRequestHeaders") + .map(|(_, v)| b64_decode(v)) + .ok_or_else(|| ProxyError::InvalidRequest("missing IamRequestHeaders".into()))??; + + let iam_request_headers: HashMap = + serde_json::from_str(&iam_request_headers_json).map_err(|e| { + ProxyError::InvalidRequest(format!("invalid IamRequestHeaders JSON: {}", e)) + })?; + + let duration_seconds = params + .iter() + .find(|(k, _)| k == "DurationSeconds") + .and_then(|(_, v)| v.parse().ok()); + + Ok(AwsIdentityRequest { + role_arn, + duration_seconds, + iam_request_url, + iam_request_body, + iam_request_headers, + }) +} + +// ── Identity verification via AWS STS ─────────────────────────────── + +/// Forward the client's signed `GetCallerIdentity` request to AWS STS +/// and return the verified caller identity. +pub async fn verify_aws_identity( + client: &reqwest::Client, + request: &AwsIdentityRequest, +) -> Result { + let url = validate_sts_url(&request.iam_request_url)?; + + let mut req_builder = client.post(url); + for (key, value) in &request.iam_request_headers { + // Skip the Host header — reqwest sets it from the URL + if key.eq_ignore_ascii_case("host") { + continue; + } + req_builder = req_builder.header(key.as_str(), value.as_str()); + } + req_builder = req_builder.body(request.iam_request_body.clone()); + + let response = req_builder.send().await.map_err(|e| { + tracing::warn!(error = %e, "failed to forward GetCallerIdentity to AWS STS"); + ProxyError::Internal(format!("STS request failed: {}", e)) + })?; + + let status = response.status(); + let body = response.text().await.map_err(|e| { + ProxyError::Internal(format!("failed to read STS response: {}", e)) + })?; + + if !status.is_success() { + tracing::warn!( + status = %status, + "AWS STS rejected GetCallerIdentity request" + ); + return Err(ProxyError::AccessDenied); + } + + parse_caller_identity_xml(&body) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── URL validation ────────────────────────────────────────────── + + #[test] + fn test_validate_sts_url_global() { + validate_sts_url("https://sts.amazonaws.com/").unwrap(); + } + + #[test] + fn test_validate_sts_url_regional() { + validate_sts_url("https://sts.us-east-1.amazonaws.com/").unwrap(); + validate_sts_url("https://sts.eu-west-1.amazonaws.com/").unwrap(); + validate_sts_url("https://sts.ap-southeast-1.amazonaws.com/").unwrap(); + } + + #[test] + fn test_validate_sts_url_fips() { + validate_sts_url("https://sts-fips.us-east-1.amazonaws.com/").unwrap(); + } + + #[test] + fn test_validate_sts_url_rejects_http() { + let err = validate_sts_url("http://sts.amazonaws.com/").unwrap_err(); + assert!(err.to_string().contains("HTTPS"), "{}", err); + } + + #[test] + fn test_validate_sts_url_rejects_custom_host() { + let err = validate_sts_url("https://sts.evil.com/").unwrap_err(); + assert!(err.to_string().contains("not a valid AWS STS"), "{}", err); + } + + #[test] + fn test_validate_sts_url_rejects_subdomain_trick() { + let err = + validate_sts_url("https://sts.amazonaws.com.evil.com/").unwrap_err(); + assert!(err.to_string().contains("not a valid AWS STS"), "{}", err); + } + + // ── XML parsing ───────────────────────────────────────────────── + + #[test] + fn test_parse_caller_identity_response() { + let xml = r#" + + + arn:aws:sts::123456789012:assumed-role/EtlPipeline/i-0abc123 + AROAEXAMPLE:i-0abc123 + 123456789012 + + + 01234567-89ab-cdef-0123-456789abcdef + + + "#; + let identity = parse_caller_identity_xml(xml).unwrap(); + assert_eq!(identity.account, "123456789012"); + assert_eq!( + identity.arn, + "arn:aws:sts::123456789012:assumed-role/EtlPipeline/i-0abc123" + ); + assert_eq!(identity.user_id, "AROAEXAMPLE:i-0abc123"); + } + + #[test] + fn test_parse_caller_identity_error_xml() { + let xml = r#" + + + InvalidIdentityToken + Token is expired + + + "#; + assert!(parse_caller_identity_xml(xml).is_err()); + } + + // ── Request parsing ───────────────────────────────────────────── + + #[test] + fn test_not_aws_identity_request() { + assert!(try_parse_aws_identity_request(None).is_none()); + assert!(try_parse_aws_identity_request(Some("Action=ListBuckets")).is_none()); + assert!( + try_parse_aws_identity_request(Some("Action=AssumeRoleWithWebIdentity&RoleArn=r&WebIdentityToken=t")).is_none() + ); + } + + #[test] + fn test_parse_aws_identity_request() { + use base64::engine::general_purpose::STANDARD; + + let url_b64 = STANDARD.encode("https://sts.amazonaws.com/"); + let body_b64 = STANDARD.encode("Action=GetCallerIdentity&Version=2011-06-15"); + let headers_b64 = STANDARD.encode(r#"{"Authorization":"AWS4-HMAC-SHA256 ...","X-Amz-Date":"20260305T120000Z"}"#); + + let query = format!( + "Action=AssumeRoleWithAWSIdentity&RoleArn=my-aws-role&IamRequestUrl={}&IamRequestBody={}&IamRequestHeaders={}", + url_b64, body_b64, headers_b64 + ); + + let req = try_parse_aws_identity_request(Some(&query)).unwrap().unwrap(); + assert_eq!(req.role_arn, "my-aws-role"); + assert_eq!(req.iam_request_url, "https://sts.amazonaws.com/"); + assert_eq!( + req.iam_request_body, + "Action=GetCallerIdentity&Version=2011-06-15" + ); + assert!(req.iam_request_headers.contains_key("Authorization")); + assert_eq!(req.duration_seconds, None); + } + + #[test] + fn test_parse_aws_identity_request_with_duration() { + use base64::engine::general_purpose::STANDARD; + + let url_b64 = STANDARD.encode("https://sts.amazonaws.com/"); + let body_b64 = STANDARD.encode("Action=GetCallerIdentity&Version=2011-06-15"); + let headers_b64 = STANDARD.encode(r#"{"Authorization":"sig"}"#); + + let query = format!( + "Action=AssumeRoleWithAWSIdentity&RoleArn=r&IamRequestUrl={}&IamRequestBody={}&IamRequestHeaders={}&DurationSeconds=1800", + url_b64, body_b64, headers_b64 + ); + + let req = try_parse_aws_identity_request(Some(&query)).unwrap().unwrap(); + assert_eq!(req.duration_seconds, Some(1800)); + } + + #[test] + fn test_parse_aws_identity_request_missing_params() { + let query = "Action=AssumeRoleWithAWSIdentity&RoleArn=r"; + let err = try_parse_aws_identity_request(Some(query)).unwrap().unwrap_err(); + assert!(err.to_string().contains("missing IamRequestUrl"), "{}", err); + } +} diff --git a/crates/sts/src/jwks.rs b/crates/sts/src/jwks.rs index 6c444c2..bf95205 100644 --- a/crates/sts/src/jwks.rs +++ b/crates/sts/src/jwks.rs @@ -235,6 +235,11 @@ impl JwksCache { } } + /// Returns a reference to the underlying HTTP client. + pub fn http_client(&self) -> &reqwest::Client { + &self.client + } + /// Fetch JWKS for the given issuer, returning a cached response if fresh. pub async fn get_or_fetch(&self, issuer: &str) -> Result { // Check cache diff --git a/crates/sts/src/lib.rs b/crates/sts/src/lib.rs index 2e3b247..1b115f8 100644 --- a/crates/sts/src/lib.rs +++ b/crates/sts/src/lib.rs @@ -1,8 +1,15 @@ //! OIDC/STS authentication for the S3 proxy gateway. //! -//! This crate implements the `AssumeRoleWithWebIdentity` STS API, allowing -//! workloads like GitHub Actions to exchange OIDC tokens for temporary S3 -//! credentials scoped to specific buckets and prefixes. +//! This crate implements STS-style token exchange APIs, allowing workloads to +//! exchange identity proofs for temporary S3 credentials scoped to specific +//! buckets and prefixes. +//! +//! # Supported auth methods +//! +//! - **`AssumeRoleWithWebIdentity`** — exchange an OIDC JWT for credentials +//! (e.g., GitHub Actions, Auth0) +//! - **`AssumeRoleWithAWSIdentity`** — exchange a signed AWS `GetCallerIdentity` +//! request for credentials (any AWS service with IAM credentials) //! //! # Integration //! @@ -14,7 +21,7 @@ //! .with_route_handler(StsRouteHandler::new(config, jwks_cache, token_key)); //! ``` //! -//! # Flow +//! # OIDC Flow //! //! 1. Client obtains a JWT from their OIDC provider (e.g., GitHub Actions ID token) //! 2. Client calls `AssumeRoleWithWebIdentity` with the JWT and desired role @@ -23,14 +30,25 @@ //! 5. Mints temporary credentials (AccessKeyId/SecretAccessKey/SessionToken) //! 6. Returns credentials to the client //! +//! # AWS IAM Flow +//! +//! 1. Client signs a `GetCallerIdentity` request using its IAM credentials +//! 2. Client calls `AssumeRoleWithAWSIdentity` with the signed request and desired role +//! 3. Proxy forwards the signed request to AWS STS to verify identity +//! 4. Checks trust policy (account, ARN subject conditions) +//! 5. Mints temporary credentials +//! 6. Returns credentials to the client +//! //! The client then uses these credentials to sign S3 requests normally. +pub mod aws_identity; pub mod jwks; pub mod request; pub mod responses; pub mod route_handler; pub mod sts; +use aws_identity::{verify_aws_identity, AwsCallerIdentity}; use base64::Engine; pub use jwks::JwksCache; use multistore::config::ConfigProvider; @@ -44,6 +62,9 @@ pub use responses::{build_sts_error_response, build_sts_response}; /// Try to handle an STS request. Returns `Some((status, xml))` if the query /// contained an STS action, or `None` if it wasn't an STS request. /// +/// Supports both `AssumeRoleWithWebIdentity` (OIDC) and +/// `AssumeRoleWithAWSIdentity` (IAM identity verification). +/// /// Requires a `TokenKey` — minted credentials are encrypted into the session /// token itself, so no server-side storage is needed. If `token_key` is `None` /// and an STS request arrives, an error response is returned. @@ -51,30 +72,58 @@ pub async fn try_handle_sts( query: Option<&str>, config: &C, jwks_cache: &JwksCache, + http_client: &reqwest::Client, token_key: Option<&TokenKey>, ) -> Option<(u16, String)> { - let sts_result = try_parse_sts_request(query)?; - let (status, xml) = match sts_result { - Ok(sts_request) => { - let Some(key) = token_key else { - tracing::error!("STS request received but SESSION_TOKEN_KEY is not configured"); - return Some(build_sts_error_response(&ProxyError::ConfigError( - "STS requires SESSION_TOKEN_KEY to be configured".into(), - ))); - }; - match assume_role_with_web_identity(config, &sts_request, "STSPRXY", jwks_cache, key) - .await - { - Ok(creds) => build_sts_response(&creds), - Err(e) => { - tracing::warn!(error = %e, "STS request failed"); - build_sts_error_response(&e) + // Try OIDC first + if let Some(sts_result) = try_parse_sts_request(query) { + let (status, xml) = match sts_result { + Ok(sts_request) => { + let Some(key) = token_key else { + tracing::error!("STS request received but SESSION_TOKEN_KEY is not configured"); + return Some(build_sts_error_response(&ProxyError::ConfigError( + "STS requires SESSION_TOKEN_KEY to be configured".into(), + ))); + }; + match assume_role_with_web_identity(config, &sts_request, "STSPRXY", jwks_cache, key) + .await + { + Ok(creds) => build_sts_response(&creds), + Err(e) => { + tracing::warn!(error = %e, "STS OIDC request failed"); + build_sts_error_response(&e) + } } } - } - Err(e) => build_sts_error_response(&e), - }; - Some((status, xml)) + Err(e) => build_sts_error_response(&e), + }; + return Some((status, xml)); + } + + // Try AWS IAM identity verification + if let Some(aws_result) = aws_identity::try_parse_aws_identity_request(query) { + let (status, xml) = match aws_result { + Ok(aws_request) => { + let Some(key) = token_key else { + tracing::error!("STS request received but SESSION_TOKEN_KEY is not configured"); + return Some(build_sts_error_response(&ProxyError::ConfigError( + "STS requires SESSION_TOKEN_KEY to be configured".into(), + ))); + }; + match handle_aws_identity_request(config, &aws_request, http_client, key).await { + Ok(creds) => build_sts_response(&creds), + Err(e) => { + tracing::warn!(error = %e, "STS AWS IAM request failed"); + build_sts_error_response(&e) + } + } + } + Err(e) => build_sts_error_response(&e), + }; + return Some((status, xml)); + } + + None } /// Decode JWT header and claims without signature verification. @@ -183,6 +232,101 @@ pub async fn assume_role_with_web_identity( Ok(creds) } +/// Verify AWS IAM identity and mint temporary credentials. +async fn handle_aws_identity_request( + config: &C, + aws_request: &aws_identity::AwsIdentityRequest, + http_client: &reqwest::Client, + token_key: &TokenKey, +) -> Result { + // Forward the signed GetCallerIdentity request to AWS STS + let identity = verify_aws_identity(http_client, aws_request).await?; + + tracing::info!( + account = %identity.account, + arn = %identity.arn, + "verified AWS IAM identity" + ); + + assume_role_with_aws_identity( + config, + &aws_request.role_arn, + aws_request.duration_seconds, + &identity, + "STSPRXY", + token_key, + ) + .await +} + +/// Verify a caller's AWS identity against role trust policy and mint credentials. +/// +/// Checks that the caller's AWS account is in `trusted_aws_accounts` and +/// that the caller's ARN matches `subject_conditions`. Then mints temporary +/// credentials using the same pipeline as the OIDC flow. +pub async fn assume_role_with_aws_identity( + config: &C, + role_arn: &str, + duration_seconds: Option, + identity: &AwsCallerIdentity, + key_prefix: &str, + token_key: &TokenKey, +) -> Result { + // Look up the role + let role = config + .get_role(role_arn) + .await? + .ok_or_else(|| ProxyError::RoleNotFound(role_arn.to_string()))?; + + // Verify the caller's account is trusted + if !role.trusted_aws_accounts.iter().any(|a| a == &identity.account) { + tracing::warn!( + account = %identity.account, + role = %role_arn, + "AWS account not trusted by role" + ); + return Err(ProxyError::AccessDenied); + } + + // Check subject conditions against the caller's ARN + if !role.subject_conditions.is_empty() { + let matches = role + .subject_conditions + .iter() + .any(|pattern| subject_matches(&identity.arn, pattern)); + if !matches { + tracing::warn!( + arn = %identity.arn, + role = %role_arn, + "ARN does not match any subject conditions" + ); + return Err(ProxyError::AccessDenied); + } + } + + // Build synthetic claims for template variable resolution in scopes + let claims = serde_json::json!({ + "sub": &identity.arn, + "aws_account": &identity.account, + "aws_arn": &identity.arn, + "aws_user_id": &identity.user_id, + }); + + // Mint temporary credentials + const MIN_SESSION_DURATION_SECS: u64 = 900; + let duration = duration_seconds + .unwrap_or(3600) + .clamp(MIN_SESSION_DURATION_SECS, role.max_session_duration_secs); + + let mut creds = + sts::mint_temporary_credentials(&role, &identity.arn, duration, key_prefix, &claims); + + // Encrypt into session token + creds.session_token = token_key.seal(&creds)?; + + Ok(creds) +} + /// Simple glob-style matching for subject conditions. /// Supports `*` as a wildcard for any sequence of characters. fn subject_matches(subject: &str, pattern: &str) -> bool { diff --git a/crates/sts/src/route_handler.rs b/crates/sts/src/route_handler.rs index 2768d35..5067e22 100644 --- a/crates/sts/src/route_handler.rs +++ b/crates/sts/src/route_handler.rs @@ -1,7 +1,8 @@ -//! Route handler for STS `AssumeRoleWithWebIdentity` requests. +//! Route handler for STS requests. //! -//! Intercepts STS queries before they reach the proxy dispatch pipeline -//! and delegates to [`try_handle_sts`]. +//! Intercepts STS queries (`AssumeRoleWithWebIdentity` and +//! `AssumeRoleWithAWSIdentity`) before they reach the proxy dispatch +//! pipeline and delegates to [`try_handle_sts`]. use crate::{try_handle_sts, JwksCache}; use multistore::config::ConfigProvider; @@ -9,7 +10,7 @@ use multistore::proxy::{HandlerAction, ProxyResult}; use multistore::route_handler::{RequestInfo, RouteHandler, RouteHandlerFuture}; use multistore::sealed_token::TokenKey; -/// Route handler that intercepts STS `AssumeRoleWithWebIdentity` requests. +/// Route handler that intercepts STS requests (OIDC and AWS IAM). pub struct StsRouteHandler { config: C, jwks_cache: JwksCache, @@ -33,6 +34,7 @@ impl RouteHandler for StsRouteHandler { req.query, &self.config, &self.jwks_cache, + self.jwks_cache.http_client(), self.token_key.as_ref(), ) .await?; diff --git a/docs/auth/aws-iam-auth.md b/docs/auth/aws-iam-auth.md new file mode 100644 index 0000000..47235d5 --- /dev/null +++ b/docs/auth/aws-iam-auth.md @@ -0,0 +1,221 @@ +# AWS IAM Identity Verification + +This page covers authenticating AWS services with the proxy using their existing IAM credentials, without needing an OIDC provider. + +## Overview + +AWS services (Lambda, EC2, ECS tasks, etc.) come with IAM credentials automatically via instance profiles, execution roles, or task roles. The `AssumeRoleWithAWSIdentity` action lets these services authenticate with the proxy by proving their IAM identity — no secrets to distribute, no OIDC provider to set up. + +This follows the same pattern used by [HashiCorp Vault's AWS auth method](https://developer.hashicorp.com/vault/docs/auth/aws). + +## How It Works + +```mermaid +sequenceDiagram + participant Client as AWS Service
(Lambda, EC2, ECS) + participant Proxy as Multistore Proxy + participant STS as AWS STS + + Client->>Client: 1. Sign GetCallerIdentity
request with IAM credentials + Client->>Proxy: 2. AssumeRoleWithAWSIdentity
(signed request + RoleArn) + Proxy->>STS: 3. Forward signed request
to sts.amazonaws.com + STS-->>Proxy: 4. Caller identity
(Account, ARN, UserId) + Proxy->>Proxy: 5. Check trust policy
(account + ARN conditions) + Proxy->>Proxy: 6. Mint temporary credentials
(seal into session token) + Proxy-->>Client: 7. AccessKeyId + SecretAccessKey
+ SessionToken + Expiration + Client->>Proxy: 8. S3 request with SigV4
(using temporary credentials) +``` + +### Verification Flow + +1. The client creates a signed `GetCallerIdentity` request using its IAM credentials (without sending it to AWS) +2. The client base64-encodes the signed URL, body, and headers and sends them to the proxy as query parameters +3. The proxy validates the STS URL is a real AWS endpoint, then forwards the signed request to AWS STS +4. AWS STS verifies the signature cryptographically and returns the caller's identity +5. The proxy checks the trust policy: + - **Account**: the caller's AWS account must be in the role's `trusted_aws_accounts` + - **ARN**: the caller's IAM ARN must match at least one of the role's `subject_conditions` (supports `*` glob wildcards) +6. The proxy mints temporary credentials scoped to the role's `allowed_scopes` +7. The client uses the temporary credentials to sign S3 requests normally + +## Role Configuration + +```toml +[[roles]] +role_id = "aws-etl-role" +name = "ETL Pipeline" +trusted_aws_accounts = ["123456789012"] +subject_conditions = [ + "arn:aws:sts::123456789012:assumed-role/EtlPipeline*/*", +] +max_session_duration_secs = 3600 + +[[roles.allowed_scopes]] +bucket = "ml-artifacts" +prefixes = ["models/production/"] +actions = ["get_object", "head_object", "put_object"] +``` + +> [!NOTE] +> A role can have both `trusted_oidc_issuers` and `trusted_aws_accounts`. This allows the same role to accept both OIDC tokens and AWS IAM identity verification. + +### Subject Conditions for AWS ARNs + +The `subject_conditions` field matches against the caller's full IAM ARN. Common patterns: + +| Pattern | Matches | +|---------|---------| +| `arn:aws:sts::123456789012:assumed-role/MyRole/*` | Any EC2/ECS instance assuming `MyRole` | +| `arn:aws:iam::123456789012:user/deploy-*` | IAM users starting with `deploy-` | +| `arn:aws:sts::123456789012:assumed-role/*/i-*` | Any role assumed by an EC2 instance | +| `arn:aws:sts::*:assumed-role/EtlPipeline/*` | `EtlPipeline` role in any trusted account | + +## STS Request Parameters + +All parameters are sent as query string parameters: + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `Action` | Yes | Must be `AssumeRoleWithAWSIdentity` | +| `RoleArn` | Yes | The `role_id` of the role to assume | +| `IamRequestUrl` | Yes | Base64-encoded STS endpoint URL | +| `IamRequestBody` | Yes | Base64-encoded `GetCallerIdentity` request body | +| `IamRequestHeaders` | Yes | Base64-encoded JSON of the signed HTTP headers | +| `DurationSeconds` | No | Session duration (900s minimum, capped by `max_session_duration_secs`) | + +The response format is identical to `AssumeRoleWithWebIdentity`. + +## Template Variables + +When minting credentials, the following template variables are available in scope definitions: + +| Variable | Description | Example | +|----------|-------------|---------| +| `{sub}` | Caller's IAM ARN (same as `{aws_arn}`) | `arn:aws:sts::123456789012:assumed-role/MyRole/i-0abc` | +| `{aws_account}` | AWS account ID | `123456789012` | +| `{aws_arn}` | Full IAM ARN | `arn:aws:sts::123456789012:assumed-role/MyRole/i-0abc` | +| `{aws_user_id}` | AWS unique user ID | `AROAEXAMPLE:i-0abc123` | + +Example: per-account bucket isolation: + +```toml +[[roles.allowed_scopes]] +bucket = "tenant-{aws_account}" +prefixes = [] +actions = ["get_object", "head_object", "put_object", "list_bucket"] +``` + +## Client Examples + +### Python (Lambda / EC2 / ECS) + +```python +import boto3 +import base64 +import json +import requests +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest + +PROXY_URL = "https://proxy.example.com" + +def get_proxy_credentials(role_arn="aws-etl-role"): + """Exchange IAM identity for proxy credentials.""" + # Step 1: Create a signed GetCallerIdentity request + session = boto3.Session() + credentials = session.get_credentials().get_frozen_credentials() + + sts_request = AWSRequest( + method="POST", + url="https://sts.amazonaws.com/", + data="Action=GetCallerIdentity&Version=2011-06-15", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + SigV4Auth(credentials, "sts", "us-east-1").add_auth(sts_request) + + # Step 2: Base64-encode the signed request components + iam_request_url = base64.b64encode( + b"https://sts.amazonaws.com/" + ).decode() + iam_request_body = base64.b64encode( + sts_request.data.encode() if isinstance(sts_request.data, str) + else sts_request.data + ).decode() + iam_request_headers = base64.b64encode( + json.dumps(dict(sts_request.headers)).encode() + ).decode() + + # Step 3: Call the proxy's STS endpoint + resp = requests.post( + PROXY_URL, + params={ + "Action": "AssumeRoleWithAWSIdentity", + "RoleArn": role_arn, + "IamRequestUrl": iam_request_url, + "IamRequestBody": iam_request_body, + "IamRequestHeaders": iam_request_headers, + }, + ) + resp.raise_for_status() + + # Step 4: Parse the STS XML response + import xml.etree.ElementTree as ET + root = ET.fromstring(resp.text) + ns = {"": "https://sts.amazonaws.com/doc/2011-06-15/"} + creds = root.find(".//Credentials", ns) or root.find(".//Credentials") + + return { + "AccessKeyId": creds.find("AccessKeyId").text, + "SecretAccessKey": creds.find("SecretAccessKey").text, + "SessionToken": creds.find("SessionToken").text, + } + + +def handler(event, context): + creds = get_proxy_credentials() + + s3 = boto3.client( + "s3", + endpoint_url=PROXY_URL, + aws_access_key_id=creds["AccessKeyId"], + aws_secret_access_key=creds["SecretAccessKey"], + aws_session_token=creds["SessionToken"], + region_name="auto", + ) + + s3.download_file( + "ml-artifacts", + "models/production/latest.bin", + "/tmp/model.bin", + ) +``` + +## Security + +### STS URL Validation + +The proxy only forwards signed requests to verified AWS STS endpoints (`sts.amazonaws.com` and regional variants). This prevents an attacker from pointing the proxy at a controlled server that returns fake identity claims. + +### No Credential Exposure + +The proxy never sees the caller's AWS credentials. It only receives a pre-signed request — the SigV4 signature headers. AWS STS performs the actual cryptographic verification. + +### Replay Window + +A signed `GetCallerIdentity` request is valid until the SigV4 signature expires (~15 minutes). An attacker who intercepts the signed request could replay it within that window. This risk is mitigated by: + +- **TLS transport** — the signed request travels over HTTPS +- **Short signature lifetime** — AWS STS rejects stale signatures + +> [!NOTE] +> A future enhancement will support `X-Multistore-Server-ID` header binding (similar to Vault's `iam_server_id_header_value`), which prevents a signed request from being replayed against a different proxy instance. + +### Comparison with Other Auth Methods + +| Property | Long-Lived Keys | OIDC/STS | AWS IAM | +|----------|----------------|----------|---------| +| Secrets to manage | Yes | External IdP | None | +| Identity verification | Bearer token | JWT signature | AWS STS | +| Credential lifetime | Until revoked | Minutes–hours | Minutes–hours | +| AWS setup required | Secrets Manager (optional) | OIDC provider | None (IAM roles exist) | +| Proxy setup required | Per-credential config | Per-role config | Per-role config | diff --git a/docs/auth/proxy-auth.md b/docs/auth/proxy-auth.md index b771ed3..cc8fbb8 100644 --- a/docs/auth/proxy-auth.md +++ b/docs/auth/proxy-auth.md @@ -4,13 +4,14 @@ This page covers how to configure the proxy to authenticate incoming client requ ## Authentication Modes -The proxy supports three authentication modes: +The proxy supports four authentication modes: | Mode | Config | Use Case | |------|--------|----------| | **Anonymous** | `anonymous_access = true` on a bucket | Public datasets, open data | | **Long-lived access keys** | `[[credentials]]` entries | Service accounts, internal tools | -| **OIDC/STS temporary credentials** | `[[roles]]` with trust policies | CI/CD, user sessions, federated identity | +| **OIDC/STS temporary credentials** | `[[roles]]` with `trusted_oidc_issuers` | CI/CD, user sessions, federated identity | +| **AWS IAM identity verification** | `[[roles]]` with `trusted_aws_accounts` | AWS services (Lambda, EC2, ECS) — see [AWS IAM Auth](./aws-iam-auth) | ## Anonymous Access From 3f71a435c85f1131c9c1b8299f7254433e570a12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Mar 2026 03:01:23 +0000 Subject: [PATCH 2/2] style: apply cargo fmt https://claude.ai/code/session_01DeybF37mu27EMVd4kR57Xh --- crates/sts/src/aws_identity.rs | 49 +++++++++++++++++++--------------- crates/sts/src/lib.rs | 16 ++++++++--- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/crates/sts/src/aws_identity.rs b/crates/sts/src/aws_identity.rs index bb071d1..4297f02 100644 --- a/crates/sts/src/aws_identity.rs +++ b/crates/sts/src/aws_identity.rs @@ -41,14 +41,12 @@ fn validate_sts_url(url: &str) -> Result { .map_err(|e| ProxyError::InvalidRequest(format!("invalid STS URL: {}", e)))?; if parsed.scheme() != "https" { - return Err(ProxyError::InvalidRequest( - "STS URL must use HTTPS".into(), - )); + return Err(ProxyError::InvalidRequest("STS URL must use HTTPS".into())); } - let host = parsed.host_str().ok_or_else(|| { - ProxyError::InvalidRequest("STS URL missing host".into()) - })?; + let host = parsed + .host_str() + .ok_or_else(|| ProxyError::InvalidRequest("STS URL missing host".into()))?; // Allow: sts.amazonaws.com, sts..amazonaws.com, // sts-fips..amazonaws.com @@ -86,10 +84,9 @@ struct GetCallerIdentityResult { } fn parse_caller_identity_xml(xml: &str) -> Result { - let resp: GetCallerIdentityResponse = quick_xml::de::from_str(xml) - .map_err(|e| ProxyError::InvalidRequest(format!( - "failed to parse GetCallerIdentity response: {}", e - )))?; + let resp: GetCallerIdentityResponse = quick_xml::de::from_str(xml).map_err(|e| { + ProxyError::InvalidRequest(format!("failed to parse GetCallerIdentity response: {}", e)) + })?; Ok(AwsCallerIdentity { account: resp.result.account, @@ -212,9 +209,10 @@ pub async fn verify_aws_identity( })?; let status = response.status(); - let body = response.text().await.map_err(|e| { - ProxyError::Internal(format!("failed to read STS response: {}", e)) - })?; + let body = response + .text() + .await + .map_err(|e| ProxyError::Internal(format!("failed to read STS response: {}", e)))?; if !status.is_success() { tracing::warn!( @@ -264,8 +262,7 @@ mod tests { #[test] fn test_validate_sts_url_rejects_subdomain_trick() { - let err = - validate_sts_url("https://sts.amazonaws.com.evil.com/").unwrap_err(); + let err = validate_sts_url("https://sts.amazonaws.com.evil.com/").unwrap_err(); assert!(err.to_string().contains("not a valid AWS STS"), "{}", err); } @@ -313,9 +310,10 @@ mod tests { fn test_not_aws_identity_request() { assert!(try_parse_aws_identity_request(None).is_none()); assert!(try_parse_aws_identity_request(Some("Action=ListBuckets")).is_none()); - assert!( - try_parse_aws_identity_request(Some("Action=AssumeRoleWithWebIdentity&RoleArn=r&WebIdentityToken=t")).is_none() - ); + assert!(try_parse_aws_identity_request(Some( + "Action=AssumeRoleWithWebIdentity&RoleArn=r&WebIdentityToken=t" + )) + .is_none()); } #[test] @@ -324,14 +322,17 @@ mod tests { let url_b64 = STANDARD.encode("https://sts.amazonaws.com/"); let body_b64 = STANDARD.encode("Action=GetCallerIdentity&Version=2011-06-15"); - let headers_b64 = STANDARD.encode(r#"{"Authorization":"AWS4-HMAC-SHA256 ...","X-Amz-Date":"20260305T120000Z"}"#); + let headers_b64 = STANDARD + .encode(r#"{"Authorization":"AWS4-HMAC-SHA256 ...","X-Amz-Date":"20260305T120000Z"}"#); let query = format!( "Action=AssumeRoleWithAWSIdentity&RoleArn=my-aws-role&IamRequestUrl={}&IamRequestBody={}&IamRequestHeaders={}", url_b64, body_b64, headers_b64 ); - let req = try_parse_aws_identity_request(Some(&query)).unwrap().unwrap(); + let req = try_parse_aws_identity_request(Some(&query)) + .unwrap() + .unwrap(); assert_eq!(req.role_arn, "my-aws-role"); assert_eq!(req.iam_request_url, "https://sts.amazonaws.com/"); assert_eq!( @@ -355,14 +356,18 @@ mod tests { url_b64, body_b64, headers_b64 ); - let req = try_parse_aws_identity_request(Some(&query)).unwrap().unwrap(); + let req = try_parse_aws_identity_request(Some(&query)) + .unwrap() + .unwrap(); assert_eq!(req.duration_seconds, Some(1800)); } #[test] fn test_parse_aws_identity_request_missing_params() { let query = "Action=AssumeRoleWithAWSIdentity&RoleArn=r"; - let err = try_parse_aws_identity_request(Some(query)).unwrap().unwrap_err(); + let err = try_parse_aws_identity_request(Some(query)) + .unwrap() + .unwrap_err(); assert!(err.to_string().contains("missing IamRequestUrl"), "{}", err); } } diff --git a/crates/sts/src/lib.rs b/crates/sts/src/lib.rs index 1b115f8..85095da 100644 --- a/crates/sts/src/lib.rs +++ b/crates/sts/src/lib.rs @@ -85,8 +85,14 @@ pub async fn try_handle_sts( "STS requires SESSION_TOKEN_KEY to be configured".into(), ))); }; - match assume_role_with_web_identity(config, &sts_request, "STSPRXY", jwks_cache, key) - .await + match assume_role_with_web_identity( + config, + &sts_request, + "STSPRXY", + jwks_cache, + key, + ) + .await { Ok(creds) => build_sts_response(&creds), Err(e) => { @@ -279,7 +285,11 @@ pub async fn assume_role_with_aws_identity( .ok_or_else(|| ProxyError::RoleNotFound(role_arn.to_string()))?; // Verify the caller's account is trusted - if !role.trusted_aws_accounts.iter().any(|a| a == &identity.account) { + if !role + .trusted_aws_accounts + .iter() + .any(|a| a == &identity.account) + { tracing::warn!( account = %identity.account, role = %role_arn,