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/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 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 d4d80a1..2b97159 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( @@ -71,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", @@ -828,6 +874,59 @@ 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_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. + + 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 SDK form-post assume-role" + + 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) + + 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