From 68b5ef23d2daca68bd561afbdd4654216c280a36 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 22 Jul 2026 17:04:40 -0700 Subject: [PATCH 1/5] 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 ecbb811..e826640 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( @@ -831,6 +860,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 f25694f26a06200480ed6d2a024d0b8d39f92a52 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 22 Jul 2026 17:25:19 -0700 Subject: [PATCH 2/5] 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 | 1 + 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, 378 insertions(+), 15 deletions(-) create mode 100644 crates/sts/src/caller_identity.rs diff --git a/Cargo.lock b/Cargo.lock index f224089..a361b0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1490,6 +1490,7 @@ 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 4b5716f..27d803e 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 ef1c79f..3a9cd55 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 674d022..f06022f 100644 --- a/docs/auth/proxy-auth.md +++ b/docs/auth/proxy-auth.md @@ -130,6 +130,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: @@ -196,6 +230,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 f1c6aef..63f2df5 100644 --- a/docs/reference/operations.md +++ b/docs/reference/operations.md @@ -69,6 +69,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 f5f9068c3a5458596835703cf67d174a1a92c22d Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 22 Jul 2026 17:25:31 -0700 Subject: [PATCH 3/5] 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 35b0964..5b0e19b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,3 +156,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 5c725aa..809c770 100644 --- a/examples/cf-workers/wrangler.integration.toml +++ b/examples/cf-workers/wrangler.integration.toml @@ -111,6 +111,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 e826640..8b21936 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", @@ -860,22 +877,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) @@ -883,6 +900,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 From 99b2fd0203882da597fbe1321ee65583d74a68d1 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 25 Sep 2026 16:09:43 -0700 Subject: [PATCH 4/5] test(integration): give the configure-aws-credentials role an audience Since #146 a role with no required_audiences fails config validation at startup, because it could never accept a token. The role this PR adds for the configure-aws-credentials test had none, so wrangler dev refused the whole PROXY_CONFIG and every integration test failed. It now requires sts.amazonaws.com, the action's default audience and the one the other GitHub Actions roles use. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R1eiTse4416N6uTgAy4Ddd --- examples/cf-workers/wrangler.integration.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/cf-workers/wrangler.integration.toml b/examples/cf-workers/wrangler.integration.toml index 809c770..f4b7486 100644 --- a/examples/cf-workers/wrangler.integration.toml +++ b/examples/cf-workers/wrangler.integration.toml @@ -120,6 +120,7 @@ trusted_oidc_issuers = [ max_session_duration_secs = 3600 name = "GitHub Actions (configure-aws-credentials)" role_id = "arn:aws:iam::000000000000:role/github-actions" +required_audiences = ["sts.amazonaws.com"] subject_conditions = ["*"] trusted_oidc_issuers = [ "https://token.actions.githubusercontent.com", From d3b4c7cb3ba513fb86cca52e368094f197ceb591 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Mon, 28 Sep 2026 16:34:28 -0700 Subject: [PATCH 5/5] refactor(sts): compare GetCallerIdentity access key in constant time Align with the access-key check in auth/identity.rs by exporting constant_time_eq from multistore::auth. Co-Authored-By: Claude Opus 5.5 --- crates/core/src/auth/mod.rs | 2 +- crates/core/src/auth/sigv4.rs | 4 +++- crates/sts/src/caller_identity.rs | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/core/src/auth/mod.rs b/crates/core/src/auth/mod.rs index 6561acd..212d02e 100644 --- a/crates/core/src/auth/mod.rs +++ b/crates/core/src/auth/mod.rs @@ -11,7 +11,7 @@ pub mod sigv4; pub use authorize::{authorize, key_authorized}; pub use identity::resolve_identity; -pub use sigv4::{parse_sigv4_auth, verify_sigv4_signature, SigV4Auth}; +pub use sigv4::{constant_time_eq, parse_sigv4_auth, verify_sigv4_signature, SigV4Auth}; use crate::error::ProxyError; use crate::maybe_send::{MaybeSend, MaybeSync}; diff --git a/crates/core/src/auth/sigv4.rs b/crates/core/src/auth/sigv4.rs index 131ab53..fcc55bf 100644 --- a/crates/core/src/auth/sigv4.rs +++ b/crates/core/src/auth/sigv4.rs @@ -196,7 +196,9 @@ pub(crate) fn hmac_sha256(key: &[u8], data: &[u8]) -> Result, ProxyError Ok(mac.finalize().into_bytes().to_vec()) } -pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { +/// Compare two byte slices in constant time (for a given length), so the +/// comparison doesn't leak how many leading bytes matched. +pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } diff --git a/crates/sts/src/caller_identity.rs b/crates/sts/src/caller_identity.rs index 4c1665d..291d539 100644 --- a/crates/sts/src/caller_identity.rs +++ b/crates/sts/src/caller_identity.rs @@ -15,7 +15,7 @@ //! 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::auth::{constant_time_eq, parse_sigv4_auth, verify_sigv4_signature}; use multistore::error::ProxyError; use multistore::route_handler::RequestInfo; use sha2::{Digest, Sha256}; @@ -59,7 +59,7 @@ fn resolve_caller_identity( 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 { + if !constant_time_eq(sig.access_key_id.as_bytes(), creds.access_key_id.as_bytes()) { tracing::warn!( header_key = %sig.access_key_id, resolved_key = %creds.access_key_id,