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..4297f02 --- /dev/null +++ b/crates/sts/src/aws_identity.rs @@ -0,0 +1,373 @@ +//! 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..85095da 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,64 @@ 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) + // 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 request failed"); - build_sts_error_response(&e) + { + 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 +238,105 @@ 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