Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
21 changes: 11 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/sts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 149 additions & 0 deletions crates/sts/src/caller_identity.rs
Original file line number Diff line number Diff line change
@@ -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, <GetCallerIdentityResponse>)`; 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<String, ProxyError> {
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);
}
}
11 changes: 8 additions & 3 deletions crates/sts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
Expand All @@ -26,20 +29,22 @@
//!
//! The client then uses these credentials to sign S3 requests normally.

pub mod caller_identity;
pub mod jwks;
pub mod request;
pub mod responses;
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
Expand Down
28 changes: 28 additions & 0 deletions crates/sts/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ pub fn try_parse_sts_request(query: Option<&str>) -> Option<Result<StsRequest, P
Some(parse_sts_params(&params))
}

/// Whether a form-urlencoded parameter string (query or `POST` body) requests
/// `Action=GetCallerIdentity`.
///
/// `GetCallerIdentity` carries no other parameters worth extracting — it is
/// authenticated entirely by the request's SigV4 signature — so a boolean is
/// all the caller needs.
pub fn is_get_caller_identity(params: Option<&str>) -> 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<StsRequest, ProxyError> {
let role_arn = params
.iter()
Expand Down Expand Up @@ -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));
}
}
Loading
Loading