From 7169142cd97b47d38dd09b574e0c804ff11ec8dc Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 22 Jul 2026 17:04:40 -0700 Subject: [PATCH 1/3] test(integration): assert configure-aws-credentials grants write access PR #112 made /.sts a drop-in AssumeRoleWithWebIdentity target for AWS SDK STS clients, which send parameters in a form-encoded POST body rather than the query string. aws-actions/configure-aws-credentials relies on that path. The integration suite only exercised the query-string GET, so a regression in form-body parsing would go unnoticed. - Extract XML->creds parsing into `_parse_sts_credentials`, shared by both the existing query GET helper and the new form-POST helper. - Add `assume_role_form_post`, reproducing the exact wire request the action emits (application/x-www-form-urlencoded body via requests `data=`). - Add `test_configure_aws_credentials_action_grants_write_access`: assume the github-actions role that way and prove a PUT/GET round-trip on private-uploads succeeds. Runs in the OIDC-gated class (id-token: write CI). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration/test_integration.py | 72 +++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index d4d80a1..5e900c0 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -31,16 +31,8 @@ # Helpers # --------------------------------------------------------------------------- -def assume_role(role_arn: str, oidc_token: str) -> dict: - """Assume a role via the STS proxy and return parsed credentials.""" - resp = requests.get( - f"{PROXY_URL}/.sts", - params={ - "Action": "AssumeRoleWithWebIdentity", - "RoleArn": role_arn, - "WebIdentityToken": oidc_token, - }, - ) +def _parse_sts_credentials(resp: requests.Response) -> dict: + """Parse an AssumeRoleWithWebIdentity XML response into a creds dict.""" resp.raise_for_status() root = ET.fromstring(resp.text) creds_el = root.find(".//{*}Credentials") @@ -58,6 +50,43 @@ def text(tag: str) -> str: } +def assume_role(role_arn: str, oidc_token: str) -> dict: + """Assume a role via the STS proxy (query-string params) and return creds.""" + return _parse_sts_credentials( + requests.get( + f"{PROXY_URL}/.sts", + params={ + "Action": "AssumeRoleWithWebIdentity", + "RoleArn": role_arn, + "WebIdentityToken": oidc_token, + }, + ) + ) + + +def assume_role_form_post(role_arn: str, oidc_token: str) -> dict: + """Assume a role the way `aws-actions/configure-aws-credentials` (and every + AWS SDK's STS client) does: AssumeRoleWithWebIdentity parameters carried in + an `application/x-www-form-urlencoded` POST body, not the query string. + + This is the drop-in SDK path enabled by the form-body parsing in PR #112. + `requests` form-encodes a `data=` dict and sets the content type, so this + reproduces the exact wire shape the action emits against the proxy. + """ + return _parse_sts_credentials( + requests.post( + f"{PROXY_URL}/.sts", + data={ + "Action": "AssumeRoleWithWebIdentity", + "Version": "2011-06-15", + "RoleArn": role_arn, + "RoleSessionName": "configure-aws-credentials-integration", + "WebIdentityToken": oidc_token, + }, + ) + ) + + def s3_client(creds: dict): """Create an S3 client using the given credentials against the proxy.""" return boto3.client( @@ -828,6 +857,29 @@ def test_no_access_role_denied(self, no_access_credentials): client.list_objects_v2(Bucket="public-data", MaxKeys=1) assert exc_info.value.response["Error"]["Code"] == "AccessDenied" + def test_configure_aws_credentials_action_grants_write_access(self, oidc_token): + """`aws-actions/configure-aws-credentials` against the proxy must yield + credentials with write access. + + The action assumes the role by POSTing the AssumeRoleWithWebIdentity + parameters as a form-encoded body (the shape every AWS SDK STS client + sends). `assume_role_form_post` reproduces that request; a successful + PUT/GET round-trip with the returned credentials proves the action is a + drop-in way to gain write access — the guarantee added by PR #112. + """ + creds = assume_role_form_post("github-actions", oidc_token) + assert creds["AccessKeyId"] and creds["SessionToken"] + + client = s3_client(creds) + key = f"action-write-{uuid.uuid4()}.txt" + body = b"written via configure-aws-credentials" + + client.put_object(Bucket="private-uploads", Key=key, Body=body) + resp = client.get_object(Bucket="private-uploads", Key=key) + assert resp["Body"].read() == body + + client.delete_object(Bucket="private-uploads", Key=key) + # --------------------------------------------------------------------------- # Anonymous access From 5d1f62883020e0e1b8646bc6cff29054d2c4a17e Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 22 Jul 2026 17:25:19 -0700 Subject: [PATCH 2/3] feat(sts): implement GetCallerIdentity for drop-in STS compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aws-actions/configure-aws-credentials` (and other AWS tooling) validates freshly assumed credentials by issuing an unconditional, SigV4-signed `GetCallerIdentity` call before exporting them. multistore-sts only parsed `AssumeRoleWithWebIdentity`, so the action could never succeed against the proxy — it would retry GetCallerIdentity 12 times and then fail the step. Closes #127. - **`caller_identity`** (new module) — `handle_get_caller_identity` authenticates the call against the sealed session token: it recovers the minted credentials from `x-amz-security-token`, checks the auth-header access key matches, and verifies the SigV4 signature with the proxy's own `verify_sigv4_signature` over the recovered secret. AWS SDKs sign STS POSTs over the SHA-256 of the form body, usually without an `x-amz-content-sha256` header, so the payload hash is taken from that header when present and recomputed from the collected body otherwise. Verification runs over the raw signing path so the trailing slash AWS SDK JS v3 appends (`/.sts` -> `/.sts/`) is honored, not normalized. - **`request::is_get_caller_identity`** — detects the action in a query string or form body (the two places AWS SDKs put STS parameters). - **`responses`** — `build_caller_identity_response` emits STS-shaped `GetCallerIdentityResponse` XML. `Account` is the fabricated `SYNTHETIC_ACCOUNT_ID` (`000000000000`) — the proxy has no real AWS account — used consistently in the assumed-role ARN so the identity is coherent. `build_sts_error_response` now maps `SignatureDoesNotMatch`/`ExpiredCredentials` to STS-shaped 403s instead of a generic 500. - **`route_handler`** — `StsHandler` dispatches GetCallerIdentity (authenticated, needs the full request) ahead of the unauthenticated assume-role exchange. `with_sts` now registers both `/.sts` and `/.sts/` (adding a `Clone` bound on the config) so SDK-JS callers, which hit the trailing-slash path, reach the handler. - **docs** — `auth/proxy-auth.md` documents GetCallerIdentity, the synthetic account, the trailing-slash rule, and a `configure-aws-credentials` workflow example; `reference/operations.md` and `architecture/crate-layout.md` updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 21 +++-- crates/sts/Cargo.toml | 1 + crates/sts/src/caller_identity.rs | 149 ++++++++++++++++++++++++++++++ crates/sts/src/lib.rs | 11 ++- crates/sts/src/request.rs | 28 ++++++ crates/sts/src/responses.rs | 92 ++++++++++++++++++ crates/sts/src/route_handler.rs | 51 ++++++++-- docs/architecture/crate-layout.md | 6 +- docs/auth/proxy-auth.md | 53 +++++++++++ docs/reference/operations.md | 1 + 10 files changed, 388 insertions(+), 25 deletions(-) create mode 100644 crates/sts/src/caller_identity.rs diff --git a/Cargo.lock b/Cargo.lock index ebd149e..8ecb469 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1314,7 +1314,7 @@ dependencies = [ [[package]] name = "multistore" -version = "0.6.4" +version = "0.7.0" dependencies = [ "async-trait", "base64", @@ -1341,7 +1341,7 @@ dependencies = [ [[package]] name = "multistore-cf-workers" -version = "0.6.4" +version = "0.7.0" dependencies = [ "async-trait", "bytes", @@ -1366,7 +1366,7 @@ dependencies = [ [[package]] name = "multistore-cf-workers-example" -version = "0.6.4" +version = "0.7.0" dependencies = [ "bytes", "console_error_panic_hook", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "multistore-lambda" -version = "0.6.4" +version = "0.7.0" dependencies = [ "bytes", "http", @@ -1411,7 +1411,7 @@ dependencies = [ [[package]] name = "multistore-metering" -version = "0.6.4" +version = "0.7.0" dependencies = [ "bytes", "futures", @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "multistore-oidc-provider" -version = "0.6.4" +version = "0.7.0" dependencies = [ "base64", "chrono", @@ -1443,7 +1443,7 @@ dependencies = [ [[package]] name = "multistore-path-mapping" -version = "0.6.4" +version = "0.7.0" dependencies = [ "multistore", "percent-encoding", @@ -1452,7 +1452,7 @@ dependencies = [ [[package]] name = "multistore-server" -version = "0.6.4" +version = "0.7.0" dependencies = [ "axum", "bytes", @@ -1474,7 +1474,7 @@ dependencies = [ [[package]] name = "multistore-static-config" -version = "0.6.4" +version = "0.7.0" dependencies = [ "chrono", "multistore", @@ -1486,11 +1486,12 @@ dependencies = [ [[package]] name = "multistore-sts" -version = "0.6.4" +version = "0.7.0" dependencies = [ "aes-gcm", "base64", "chrono", + "hex", "http", "multistore", "quick-xml 0.41.0", diff --git a/crates/sts/Cargo.toml b/crates/sts/Cargo.toml index 061b97b..650311a 100644 --- a/crates/sts/Cargo.toml +++ b/crates/sts/Cargo.toml @@ -15,6 +15,7 @@ base64.workspace = true rand.workspace = true rsa.workspace = true sha2.workspace = true +hex.workspace = true reqwest.workspace = true tracing.workspace = true quick-xml.workspace = true diff --git a/crates/sts/src/caller_identity.rs b/crates/sts/src/caller_identity.rs new file mode 100644 index 0000000..4c1665d --- /dev/null +++ b/crates/sts/src/caller_identity.rs @@ -0,0 +1,149 @@ +//! `GetCallerIdentity` handling. +//! +//! Standard AWS tooling validates the credentials it obtains from +//! `AssumeRoleWithWebIdentity` by immediately issuing a SigV4-signed +//! `GetCallerIdentity` call against the same endpoint — +//! `aws-actions/configure-aws-credentials` does this unconditionally, with no +//! opt-out. For the proxy to be a drop-in STS target this call must succeed, so +//! this module authenticates it against the sealed-token credentials and +//! returns an STS-shaped identity document. +//! +//! Unlike `AssumeRoleWithWebIdentity` (an unauthenticated token exchange), +//! `GetCallerIdentity` is authenticated: the temporary session token travels in +//! `x-amz-security-token` and the request is SigV4-signed with the temporary +//! secret key. Verification reuses the proxy's own SigV4 machinery, so an +//! assumed-role session proves itself here exactly as it would when signing an +//! S3 request. + +use multistore::auth::{parse_sigv4_auth, verify_sigv4_signature}; +use multistore::error::ProxyError; +use multistore::route_handler::RequestInfo; +use sha2::{Digest, Sha256}; + +use crate::responses::{build_caller_identity_response, build_sts_error_response}; +use crate::TokenKey; + +/// Authenticate a `GetCallerIdentity` request and return `(status, xml)`. +/// +/// Success yields `(200, )`; any authentication +/// failure yields STS-shaped error XML (never a proxy error), so unsigned or +/// mis-signed callers see a well-formed STS error just as real STS would. +pub fn handle_get_caller_identity( + req: &RequestInfo<'_>, + token_key: Option<&TokenKey>, +) -> (u16, String) { + match resolve_caller_identity(req, token_key) { + Ok(xml) => (200, xml), + Err(e) => { + tracing::warn!(error = %e, "GetCallerIdentity request rejected"); + build_sts_error_response(&e) + } + } +} + +fn resolve_caller_identity( + req: &RequestInfo<'_>, + token_key: Option<&TokenKey>, +) -> Result { + let token_key = token_key.ok_or_else(|| { + tracing::error!("GetCallerIdentity received but SESSION_TOKEN_KEY is not configured"); + ProxyError::ConfigError("STS requires SESSION_TOKEN_KEY to be configured".into()) + })?; + + // Recover the minted credentials from the session token, then confirm the + // request was signed with the matching secret key. + let session_token = header(req, "x-amz-security-token").ok_or(ProxyError::AccessDenied)?; + let creds = token_key + .unseal(session_token)? + .ok_or(ProxyError::AccessDenied)?; + + let auth_header = header(req, "authorization").ok_or(ProxyError::AccessDenied)?; + let sig = parse_sigv4_auth(auth_header)?; + if sig.access_key_id != creds.access_key_id { + tracing::warn!( + header_key = %sig.access_key_id, + resolved_key = %creds.access_key_id, + "access key mismatch between auth header and session token" + ); + return Err(ProxyError::AccessDenied); + } + + // AWS SDKs sign an STS POST over the SHA-256 of its form body. They usually + // also echo that digest in x-amz-content-sha256; honor the header when + // present, otherwise recompute it from the body the runtime collected. + let body = req.form_body.unwrap_or_default(); + let payload_hash = header(req, "x-amz-content-sha256") + .map(str::to_owned) + .unwrap_or_else(|| hex::encode(Sha256::digest(body.as_bytes()))); + + // Verify over the raw path/query the client actually signed. AWS SDK JS v3 + // (used by configure-aws-credentials) appends a trailing slash to the + // endpoint path — `/.sts` is signed as `/.sts/` — so the canonical URI must + // be whatever arrived, never a normalized form. + let signing_path = req.signing_path.unwrap_or(req.path); + let signing_query = req.signing_query.or(req.query).unwrap_or(""); + + if !verify_sigv4_signature( + req.method, + signing_path, + signing_query, + req.headers, + &sig, + &creds.secret_access_key, + &payload_hash, + )? { + return Err(ProxyError::SignatureDoesNotMatch); + } + + Ok(build_caller_identity_response(&creds)) +} + +fn header<'a>(req: &'a RequestInfo<'_>, name: &str) -> Option<&'a str> { + req.headers.get(name).and_then(|v| v.to_str().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use http::{HeaderMap, Method}; + + fn test_key() -> crate::TokenKey { + let encoded = base64::engine::general_purpose::STANDARD.encode([0x11u8; 32]); + crate::TokenKey::from_base64(&encoded).unwrap() + } + + fn req_with_headers<'a>( + method: &'a Method, + path: &'a str, + headers: &'a HeaderMap, + form_body: Option<&'a str>, + ) -> RequestInfo<'a> { + RequestInfo::new(method, path, None, headers, None).with_form_body(form_body) + } + + #[test] + fn missing_session_token_is_access_denied() { + let key = test_key(); + let method = Method::POST; + let headers = HeaderMap::new(); + let req = req_with_headers( + &method, + "/.sts/", + &headers, + Some("Action=GetCallerIdentity&Version=2011-06-15"), + ); + let (status, xml) = handle_get_caller_identity(&req, Some(&key)); + assert_eq!(status, 403, "{xml}"); + assert!(xml.contains("AccessDenied"), "{xml}"); + } + + #[test] + fn missing_token_key_is_internal_error() { + let method = Method::POST; + let headers = HeaderMap::new(); + let req = req_with_headers(&method, "/.sts/", &headers, None); + let (status, _) = handle_get_caller_identity(&req, None); + assert_eq!(status, 500); + } +} diff --git a/crates/sts/src/lib.rs b/crates/sts/src/lib.rs index d18c205..3dd0dd2 100644 --- a/crates/sts/src/lib.rs +++ b/crates/sts/src/lib.rs @@ -2,7 +2,10 @@ //! //! 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. +//! credentials scoped to specific buckets and prefixes. It also implements +//! `GetCallerIdentity` (see [`caller_identity`]), which standard AWS tooling — +//! notably `aws-actions/configure-aws-credentials` — calls to validate assumed +//! credentials, so the endpoint is a drop-in STS target. //! //! # Integration //! @@ -26,6 +29,7 @@ //! //! The client then uses these credentials to sign S3 requests normally. +pub mod caller_identity; pub mod jwks; pub mod request; pub mod responses; @@ -33,13 +37,14 @@ pub mod route_handler; pub mod sealed_token; pub mod sts; +pub use caller_identity::handle_get_caller_identity; pub use jwks::JwksCache; use multistore::error::ProxyError; use multistore::registry::CredentialRegistry; use multistore::types::TemporaryCredentials; -pub use request::try_parse_sts_request; use request::StsRequest; -pub use responses::{build_sts_error_response, build_sts_response}; +pub use request::{is_get_caller_identity, try_parse_sts_request}; +pub use responses::{build_caller_identity_response, build_sts_error_response, build_sts_response}; pub use sealed_token::TokenKey; /// Try to handle an STS request. Returns `Some((status, xml))` if the query diff --git a/crates/sts/src/request.rs b/crates/sts/src/request.rs index ab41703..62182b9 100644 --- a/crates/sts/src/request.rs +++ b/crates/sts/src/request.rs @@ -40,6 +40,18 @@ pub fn try_parse_sts_request(query: Option<&str>) -> Option) -> bool { + let Some(q) = params else { return false }; + url::form_urlencoded::parse(q.as_bytes()) + .any(|(k, v)| k == "Action" && v == "GetCallerIdentity") +} + fn parse_sts_params(params: &[(String, String)]) -> Result { let role_arn = params .iter() @@ -106,4 +118,20 @@ mod tests { let result = try_parse_sts_request(Some(query)).unwrap(); assert!(result.is_err()); } + + #[test] + fn test_is_get_caller_identity() { + assert!(is_get_caller_identity(Some( + "Action=GetCallerIdentity&Version=2011-06-15" + ))); + // Order-independent and tolerant of extra params. + assert!(is_get_caller_identity(Some( + "Version=2011-06-15&Action=GetCallerIdentity" + ))); + assert!(!is_get_caller_identity(Some( + "Action=AssumeRoleWithWebIdentity&RoleArn=r&WebIdentityToken=t" + ))); + assert!(!is_get_caller_identity(Some("prefix=foo/"))); + assert!(!is_get_caller_identity(None)); + } } diff --git a/crates/sts/src/responses.rs b/crates/sts/src/responses.rs index afe35c4..e32a855 100644 --- a/crates/sts/src/responses.rs +++ b/crates/sts/src/responses.rs @@ -73,6 +73,56 @@ pub fn build_sts_response(creds: &TemporaryCredentials) -> (u16, String) { (200, response.to_xml()) } +/// The fabricated 12-digit AWS account id used in synthetic STS identities. +/// +/// The proxy fronts arbitrary S3 backends and has no real AWS account, so +/// `GetCallerIdentity` reports a zero account. Using it consistently in both the +/// account field and the assumed-role ARN keeps the identity document +/// internally coherent for tooling (e.g. `aws-actions/configure-aws-credentials` +/// surfaces `Account` as its `aws-account-id` output). +pub const SYNTHETIC_ACCOUNT_ID: &str = "000000000000"; + +/// STS `GetCallerIdentity` response. +#[derive(Debug, Serialize)] +#[serde(rename = "GetCallerIdentityResponse")] +struct GetCallerIdentityResponse { + #[serde(rename = "GetCallerIdentityResult")] + result: GetCallerIdentityResult, +} + +#[derive(Debug, Serialize)] +struct GetCallerIdentityResult { + #[serde(rename = "Arn")] + arn: String, + #[serde(rename = "UserId")] + user_id: String, + #[serde(rename = "Account")] + account: String, +} + +/// Build a `GetCallerIdentity` XML document describing an assumed-role session. +/// +/// The identity is synthesized from the sealed credentials: `Account` is the +/// fabricated [`SYNTHETIC_ACCOUNT_ID`], and `Arn`/`UserId` are derived from the +/// assumed role id and the OIDC subject that assumed it. `quick_xml` escapes the +/// subject, which may contain characters like `:` and `/` from an OIDC `sub`. +pub fn build_caller_identity_response(creds: &TemporaryCredentials) -> String { + let response = GetCallerIdentityResponse { + result: GetCallerIdentityResult { + arn: format!( + "arn:aws:sts::{}:assumed-role/{}/{}", + SYNTHETIC_ACCOUNT_ID, creds.assumed_role_id, creds.source_identity + ), + user_id: format!("{}:{}", creds.assumed_role_id, creds.source_identity), + account: SYNTHETIC_ACCOUNT_ID.to_string(), + }, + }; + format!( + "\n{}", + xml_to_string(&response).unwrap_or_default() + ) +} + /// Build an STS error response (status code + XML body) from a ProxyError. pub fn build_sts_error_response(err: &ProxyError) -> (u16, String) { let (status, code, message) = match err { @@ -84,6 +134,17 @@ pub fn build_sts_error_response(err: &ProxyError) -> (u16, String) { ProxyError::InvalidOidcToken(msg) => (400, "InvalidIdentityToken", msg.clone()), ProxyError::InvalidRequest(msg) => (400, "InvalidParameterValue", msg.clone()), ProxyError::AccessDenied => (403, "AccessDenied", "access denied".to_string()), + // GetCallerIdentity authentication failures. + ProxyError::SignatureDoesNotMatch => ( + 403, + "SignatureDoesNotMatch", + "the request signature does not match".to_string(), + ), + ProxyError::ExpiredCredentials => ( + 403, + "ExpiredToken", + "the security token included in the request is expired".to_string(), + ), _ => (500, "InternalError", "internal error".to_string()), }; @@ -99,3 +160,34 @@ pub fn build_sts_error_response(err: &ProxyError) -> (u16, String) { ); (status, xml) } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + fn creds() -> TemporaryCredentials { + TemporaryCredentials { + access_key_id: "STSPRXYAAA".into(), + secret_access_key: "secret".into(), + session_token: "sealed".into(), + expiration: Utc::now(), + allowed_scopes: vec![], + assumed_role_id: "github-actions".into(), + source_identity: "repo:org/repo:ref:refs/heads/main".into(), + } + } + + #[test] + fn caller_identity_xml_has_account_arn_userid() { + let xml = build_caller_identity_response(&creds()); + assert!(xml.contains("000000000000"), "{xml}"); + assert!(xml.contains( + "arn:aws:sts::000000000000:assumed-role/github-actions/repo:org/repo:ref:refs/heads/main" + ), "{xml}"); + assert!( + xml.contains("github-actions:repo:org/repo:ref:refs/heads/main"), + "{xml}" + ); + } +} diff --git a/crates/sts/src/route_handler.rs b/crates/sts/src/route_handler.rs index 426d65a..5f0e50d 100644 --- a/crates/sts/src/route_handler.rs +++ b/crates/sts/src/route_handler.rs @@ -3,12 +3,15 @@ //! Intercepts STS queries before they reach the proxy dispatch pipeline //! and delegates to [`try_handle_sts`]. -use crate::{try_handle_sts, JwksCache, TokenKey}; +use crate::{ + handle_get_caller_identity, is_get_caller_identity, try_handle_sts, JwksCache, TokenKey, +}; use multistore::registry::CredentialRegistry; use multistore::route_handler::{ProxyResult, RequestInfo, RouteHandler, RouteHandlerFuture}; use multistore::router::Router; -/// Handler that intercepts `AssumeRoleWithWebIdentity` STS requests. +/// Handler that intercepts STS `AssumeRoleWithWebIdentity` and +/// `GetCallerIdentity` requests. struct StsHandler { config: C, cache: JwksCache, @@ -18,6 +21,13 @@ struct StsHandler { impl RouteHandler for StsHandler { fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> RouteHandlerFuture<'a> { Box::pin(async move { + // GetCallerIdentity is authenticated (SigV4 over the temporary + // credentials) and needs the full request, so it is dispatched + // before the unauthenticated AssumeRoleWithWebIdentity exchange. + if is_get_caller_identity(req.query) || is_get_caller_identity(req.form_body) { + let (status, xml) = handle_get_caller_identity(req, self.key.as_ref()); + return Some(ProxyResult::xml(status, xml)); + } let (status, xml) = try_handle_sts( req.query, req.form_body, @@ -35,13 +45,18 @@ impl RouteHandler for StsHandler { pub trait StsRouterExt { /// Register the STS handler on the given `path`. /// - /// STS requests are identified by their parameters - /// (`Action=AssumeRoleWithWebIdentity`) — in the query string or, as AWS - /// SDKs send them, in a form-encoded `POST` body surfaced via - /// [`RequestInfo::form_body`] — not by path, so any path can be used - /// (e.g. `"/"` or `"/.sts"`). Runtimes must populate `form_body` for + /// STS requests are identified by their parameters (`Action=...`) — in the + /// query string or, as AWS SDKs send them, in a form-encoded `POST` body + /// surfaced via [`RequestInfo::form_body`] — not by path, so any path can be + /// used (e.g. `"/"` or `"/.sts"`). Runtimes must populate `form_body` for /// form-encoded `POST`s or SDK clients will fall through unhandled. - fn with_sts( + /// + /// The handler is registered on both `path` and its trailing-slash variant + /// (`/.sts` and `/.sts/`). AWS SDK JS v3 — the STS client inside + /// `aws-actions/configure-aws-credentials` — appends a trailing slash to the + /// configured endpoint path, so its requests arrive at `/.sts/`; without the + /// second route they would miss the handler entirely. + fn with_sts( self, path: &str, config: C, @@ -51,14 +66,30 @@ pub trait StsRouterExt { } impl StsRouterExt for Router { - fn with_sts( + fn with_sts( self, path: &str, config: C, cache: JwksCache, key: Option, ) -> Self { - self.route(path, StsHandler { config, cache, key }) + let router = self.route( + path, + StsHandler { + config: config.clone(), + cache: cache.clone(), + key: key.clone(), + }, + ); + // Also accept the trailing-slash form (see doc comment). Skip it when + // `path` already ends in `/` to avoid registering the same route twice + // (matchit panics on a duplicate). + let with_slash = format!("{}/", path.trim_end_matches('/')); + if with_slash == path { + router + } else { + router.route(&with_slash, StsHandler { config, cache, key }) + } } } diff --git a/docs/architecture/crate-layout.md b/docs/architecture/crate-layout.md index 05e0889..c6a61fa 100644 --- a/docs/architecture/crate-layout.md +++ b/docs/architecture/crate-layout.md @@ -6,7 +6,7 @@ The project is organized as a Cargo workspace with libraries (traits and logic) crates/ ├── core/ (multistore) # Runtime-agnostic: traits, S3 parsing, SigV4, registries ├── metering/ (multistore-metering) # Usage metering and quota enforcement middleware -├── sts/ (multistore-sts) # OIDC/STS token exchange (AssumeRoleWithWebIdentity) +├── sts/ (multistore-sts) # OIDC/STS (AssumeRoleWithWebIdentity + GetCallerIdentity) ├── oidc-provider/ (multistore-oidc-provider) # Outbound OIDC provider (JWT signing, JWKS, exchange) ├── static-config/ (multistore-static-config) # Static config provider (buckets/roles/credentials) ├── path-mapping/ (multistore-path-mapping) # Hierarchical path-based backend resolution @@ -50,12 +50,14 @@ Usage metering and quota enforcement middleware: ### `multistore-sts` -OIDC token exchange implementing `AssumeRoleWithWebIdentity`: +OIDC token exchange implementing `AssumeRoleWithWebIdentity` and `GetCallerIdentity`: - `StsRouterExt` — registers a closure that intercepts STS requests on the `Router` - JWT decoding and validation (RS256) - JWKS fetching and caching - Trust policy evaluation (issuer, audience, subject conditions) - Temporary credential minting with scope template variables +- `GetCallerIdentity` (SigV4-verified against the sealed session token) so + `aws-actions/configure-aws-credentials` and other AWS tooling work unmodified ### `multistore-oidc-provider` diff --git a/docs/auth/proxy-auth.md b/docs/auth/proxy-auth.md index 2019394..19386d6 100644 --- a/docs/auth/proxy-auth.md +++ b/docs/auth/proxy-auth.md @@ -128,6 +128,40 @@ The response follows the standard AWS STS XML format: ``` +### GetCallerIdentity + +The endpoint also serves `Action=GetCallerIdentity`. Standard AWS tooling — +notably [`aws-actions/configure-aws-credentials`](https://github.com/aws-actions/configure-aws-credentials) +— validates freshly assumed credentials with an unconditional, SigV4-signed +`GetCallerIdentity` call before exporting them, so serving it is what makes the +proxy a drop-in STS target rather than a bespoke exchange. + +Unlike `AssumeRoleWithWebIdentity`, this call is **authenticated**: the temporary +session token travels in `x-amz-security-token` and the request is SigV4-signed +with the temporary secret key. The proxy unseals the token, verifies the +signature against the recovered secret, and returns a synthetic identity: + +```xml + + + arn:aws:sts::000000000000:assumed-role/github-actions-deployer/repo:myorg/myapp:ref:refs/heads/main + github-actions-deployer:repo:myorg/myapp:ref:refs/heads/main + 000000000000 + + +``` + +The proxy fronts arbitrary S3 backends and has no real AWS account, so `Account` +is the fabricated `000000000000`. A request with no session token, or a +signature that does not match, receives an STS-shaped `403` error rather than an +identity. + +> **Trailing slash.** AWS SDK JS v3 (used by `configure-aws-credentials`) appends +> a trailing slash to the endpoint path, so an endpoint of `.../.sts` is called +> as `POST /.sts/` and *signed* over `/.sts/`. The handler is registered on both +> `/.sts` and `/.sts/`, and verifies the signature over the raw path exactly as +> received — never a normalized form. + ## Integrating with OIDC Providers The proxy works with any OIDC-compliant identity provider that serves a JWKS endpoint and issues RS256-signed JWTs. You need: @@ -194,6 +228,25 @@ jobs: --endpoint-url https://s3proxy.example.com ``` +Because the proxy also serves `GetCallerIdentity`, the official +[`aws-actions/configure-aws-credentials`](https://github.com/aws-actions/configure-aws-credentials) +action works unmodified — point its STS client at the proxy with `sts-endpoint` +and it handles the OIDC token fetch, assume-role, and validation for you: + +```yaml + - name: Configure AWS credentials via the proxy + uses: aws-actions/configure-aws-credentials@v6 + with: + aws-region: us-east-1 + role-to-assume: github-actions-deployer # sent verbatim as RoleArn + sts-endpoint: https://s3proxy.example.com/.sts + + - name: Upload to proxy + run: | + aws s3 cp ./bundle.tar.gz s3://deploy-bundles/releases/v1.2.3.tar.gz \ + --endpoint-url https://s3proxy.example.com +``` + #### Key Details - **Issuer URL**: `https://token.actions.githubusercontent.com` diff --git a/docs/reference/operations.md b/docs/reference/operations.md index a21274d..39d9aee 100644 --- a/docs/reference/operations.md +++ b/docs/reference/operations.md @@ -52,6 +52,7 @@ Handled by an STS closure (registered on the `Router` via `StsRouterExt`). | Operation | HTTP Method | Description | |-----------|------------|-------------| | AssumeRoleWithWebIdentity | `POST /?Action=AssumeRoleWithWebIdentity&...` (params in the query string or a form-encoded body, as AWS SDKs send them) | Exchange OIDC JWT for temporary credentials | +| GetCallerIdentity | `POST /?Action=GetCallerIdentity` (SigV4-signed with the temporary credentials) | Validate assumed credentials; returns a synthetic identity. Called unconditionally by `aws-actions/configure-aws-credentials`. | ## OIDC Discovery Endpoints From 45268da7c82b5e90cc9fb11b247dc899cfc63166 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 22 Jul 2026 17:25:31 -0700 Subject: [PATCH 3/3] test(integration): exercise the real configure-aws-credentials action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python suite only reproduced the action's wire requests. Now that the proxy serves GetCallerIdentity (#127), run the actual action end to end and prove the credentials it exports can write. - **ci.yml** — after pytest, the `integration` job runs `aws-actions/configure-aws-credentials@v6` (SHA-pinned) with `sts-endpoint` pointed at the proxy, then `aws s3 cp` uploads/downloads/deletes an object in `private-uploads` using the exported credentials. The action's own mandatory GetCallerIdentity validation must pass for the step to succeed, so this covers the full assume-role -> validate -> use flow. - **wrangler.integration.toml** — adds a role keyed by the full ARN `arn:aws:iam::000000000000:role/github-actions` (write scope on private-uploads). The action sends `role-to-assume` verbatim as the STS RoleArn and `get_role` matches `role_id` exactly, so the config key must be the full ARN; account `000000000000` matches the synthetic GetCallerIdentity account. - **test_integration.py** — adds `test_get_caller_identity` (botocore signs a real GetCallerIdentity over the assumed creds; asserts the synthetic account and ARN) and `test_get_caller_identity_rejects_static_credentials` (no session token -> 403). Rescopes the earlier form-POST test to `test_sdk_form_post_assume_role_grants_write_access`, since the headline "action works" claim is now proven by the real action in CI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 30 +++++++++ examples/cf-workers/wrangler.integration.toml | 19 ++++++ tests/integration/test_integration.py | 63 ++++++++++++++++--- 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2649c9..c4a418a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,3 +155,33 @@ jobs: - name: Run integration tests run: uvx --with pytest,boto3,requests pytest tests/integration/ -v + + # End-to-end proof that the flagship consumer of the STS endpoint works + # unmodified: aws-actions/configure-aws-credentials assumes a role against + # the proxy (AssumeRoleWithWebIdentity) and validates the result with a + # signed GetCallerIdentity call — both served by multistore-sts. It exports + # AWS_ACCESS_KEY_ID / _SECRET_ACCESS_KEY / _SESSION_TOKEN for later steps. + - name: Assume a role via aws-actions/configure-aws-credentials + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6 + with: + aws-region: us-east-1 + # Sent verbatim as the STS RoleArn; must match a role_id in + # wrangler.integration.toml. + role-to-assume: arn:aws:iam::000000000000:role/github-actions + # Point the action's STS client (both AssumeRoleWithWebIdentity and its + # mandatory GetCallerIdentity validation) at the proxy. + sts-endpoint: http://localhost:8787/.sts + + - name: Write to the proxy with the action's credentials + run: | + set -euo pipefail + # The proxy uses path-style addressing. + aws configure set default.s3.addressing_style path + endpoint=http://localhost:8787 + key="action-write/$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT.txt" + echo "written by configure-aws-credentials" > /tmp/action-write.txt + + aws s3 cp /tmp/action-write.txt "s3://private-uploads/$key" --endpoint-url "$endpoint" + aws s3 cp "s3://private-uploads/$key" /tmp/action-read.txt --endpoint-url "$endpoint" + diff /tmp/action-write.txt /tmp/action-read.txt + aws s3 rm "s3://private-uploads/$key" --endpoint-url "$endpoint" diff --git a/examples/cf-workers/wrangler.integration.toml b/examples/cf-workers/wrangler.integration.toml index b1e2466..4e61225 100644 --- a/examples/cf-workers/wrangler.integration.toml +++ b/examples/cf-workers/wrangler.integration.toml @@ -109,6 +109,25 @@ trusted_oidc_issuers = [ "https://token.actions.githubusercontent.com", ] +# Role keyed by a full ARN, for the aws-actions/configure-aws-credentials +# integration test. The action sends its `role-to-assume` input verbatim as the +# STS `RoleArn`, and `get_role` matches `role_id` exactly — so this must be the +# full ARN the workflow passes. Account `000000000000` matches the synthetic +# account the proxy's `GetCallerIdentity` reports. +[[vars.PROXY_CONFIG.roles]] +max_session_duration_secs = 3600 +name = "GitHub Actions (configure-aws-credentials)" +role_id = "arn:aws:iam::000000000000:role/github-actions" +subject_conditions = ["*"] +trusted_oidc_issuers = [ + "https://token.actions.githubusercontent.com", +] + +[[vars.PROXY_CONFIG.roles.allowed_scopes]] +actions = ["get_object", "head_object", "put_object", "delete_object", "list_bucket"] +bucket = "private-uploads" +prefixes = [] + # --- Rate limiting bindings (required by wrangler dev) --- [[ratelimits]] diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 5e900c0..2b97159 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -100,6 +100,23 @@ def s3_client(creds: dict): ) +def sts_client(creds: dict): + """Create an STS client (assumed-role creds) pointed at the proxy endpoint. + + Used to exercise GetCallerIdentity: botocore signs the call with SigV4 over + the temporary credentials, exactly as `aws-actions/configure-aws-credentials` + does when it validates the credentials it just assumed. + """ + return boto3.client( + "sts", + endpoint_url=f"{PROXY_URL}/.sts", + aws_access_key_id=creds["AccessKeyId"], + aws_secret_access_key=creds["SecretAccessKey"], + aws_session_token=creds["SessionToken"], + region_name="us-east-1", + ) + + def static_client( access_key: str = "AKTEST000000000001", secret_key: str = "testSecretKey00000000000000000001", @@ -857,22 +874,22 @@ def test_no_access_role_denied(self, no_access_credentials): client.list_objects_v2(Bucket="public-data", MaxKeys=1) assert exc_info.value.response["Error"]["Code"] == "AccessDenied" - def test_configure_aws_credentials_action_grants_write_access(self, oidc_token): - """`aws-actions/configure-aws-credentials` against the proxy must yield + def test_sdk_form_post_assume_role_grants_write_access(self, oidc_token): + """Assuming a role via a form-encoded POST body (the shape every AWS SDK + STS client — and `aws-actions/configure-aws-credentials` — sends) yields credentials with write access. - The action assumes the role by POSTing the AssumeRoleWithWebIdentity - parameters as a form-encoded body (the shape every AWS SDK STS client - sends). `assume_role_form_post` reproduces that request; a successful - PUT/GET round-trip with the returned credentials proves the action is a - drop-in way to gain write access — the guarantee added by PR #112. + This is the drop-in SDK path added by PR #112. `assume_role_form_post` + reproduces the request; a PUT/GET round-trip proves the resulting + credentials can write. (The real action is exercised end-to-end in the + `integration` CI job.) """ creds = assume_role_form_post("github-actions", oidc_token) assert creds["AccessKeyId"] and creds["SessionToken"] client = s3_client(creds) key = f"action-write-{uuid.uuid4()}.txt" - body = b"written via configure-aws-credentials" + body = b"written via SDK form-post assume-role" client.put_object(Bucket="private-uploads", Key=key, Body=body) resp = client.get_object(Bucket="private-uploads", Key=key) @@ -880,6 +897,36 @@ def test_configure_aws_credentials_action_grants_write_access(self, oidc_token): client.delete_object(Bucket="private-uploads", Key=key) + def test_get_caller_identity(self, actions_credentials): + """A signed GetCallerIdentity call returns an STS-shaped identity. + + `aws-actions/configure-aws-credentials` issues exactly this call + (unconditionally) to validate the credentials it assumes, so the proxy + must serve it. botocore signs the request with SigV4 over the temporary + credentials; the proxy verifies the signature against the sealed session + token and reports the synthetic account. + """ + identity = sts_client(actions_credentials).get_caller_identity() + assert identity["Account"] == "000000000000" + assert identity["Arn"].startswith( + "arn:aws:sts::000000000000:assumed-role/" + ) + assert identity["UserId"] + + def test_get_caller_identity_rejects_static_credentials(self): + """GetCallerIdentity requires a sealed session token; a request signed + with static (non-assumed) credentials is denied, not served.""" + sts = boto3.client( + "sts", + endpoint_url=f"{PROXY_URL}/.sts", + aws_access_key_id="AKTEST000000000001", + aws_secret_access_key="testSecretKey00000000000000000001", + region_name="us-east-1", + ) + with pytest.raises(ClientError) as exc_info: + sts.get_caller_identity() + assert exc_info.value.response["ResponseMetadata"]["HTTPStatusCode"] == 403 + # --------------------------------------------------------------------------- # Anonymous access