diff --git a/Cargo.lock b/Cargo.lock index a3fa476..8b4a847 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1034,9 +1034,9 @@ dependencies = [ [[package]] name = "multistore" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d34cce7e97df06f76b1a711d6bd655ce079dc7b32bea049d2432cfa2780d" +checksum = "ea9464aadbed4ff2dfc054011ef2b29f56073d60201d040611a1729d583ad9d9" dependencies = [ "async-trait", "base64", @@ -1063,9 +1063,9 @@ dependencies = [ [[package]] name = "multistore-cf-workers" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8d3c198c3e31ea7903e15f7af15c10b5d5b1c62f16b62a00e458872d5ae1d72" +checksum = "9e3740111f8d8f10d6150c24fb2317fb4524e46964b7726eaf5e22b65514e7ee" dependencies = [ "async-trait", "bytes", @@ -1090,9 +1090,9 @@ dependencies = [ [[package]] name = "multistore-oidc-provider" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ecb56666012a49cc8fdbaf60564337b04c1f89230bace11240604c4ddf56ef" +checksum = "b7f5e58d0113ff4abfc6134005198ad8c9d433b0e961b473502711ae943fa628" dependencies = [ "base64", "chrono", @@ -1109,9 +1109,9 @@ dependencies = [ [[package]] name = "multistore-path-mapping" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e0738451d593e53eafdb2e33f7c2ceef622d2d8b21a42f59e389649a2c294d" +checksum = "3430654120a6f5a7d0bea2972b13b9153dba0a21d52db02c5c62bc4f1922602d" dependencies = [ "multistore", "percent-encoding", @@ -1120,13 +1120,14 @@ dependencies = [ [[package]] name = "multistore-sts" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "449762c5aac6de3ca7f96430dc6af2e668b1f93c041e58aa181148bb5dd7bebb" +checksum = "3f7df919318bb3e59f3169d4fce3dc91387a9dc939e850ca91a5cd5877a16049" dependencies = [ "aes-gcm", "base64", "chrono", + "hex", "multistore", "quick-xml", "rand 0.8.7", diff --git a/Cargo.toml b/Cargo.toml index 16047e6..f626321 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,10 +40,10 @@ path = "tests/keys.rs" [dependencies] # Multistore -multistore = { version = "0.7.2", features = ["azure", "gcp"] } -multistore-oidc-provider = "0.7.2" -multistore-path-mapping = "0.7.2" -multistore-sts = "0.7.2" +multistore = { version = "0.8.0", features = ["azure", "gcp"] } +multistore-oidc-provider = "0.8.0" +multistore-path-mapping = "0.8.0" +multistore-sts = "0.8.0" # Serialization serde = { version = "1", features = ["derive"] } @@ -74,7 +74,7 @@ getrandom = { version = "0.4", features = ["wasm_js"] } # ring is transitive (via object_store); this direct entry only turns the # feature on for the wasm target. ring = { version = "0.17", features = ["wasm32_unknown_unknown_js"] } -multistore-cf-workers = { version = "0.7.2", features = ["azure", "gcp"] } +multistore-cf-workers = { version = "0.8.0", features = ["azure", "gcp"] } # On wasm32-unknown-unknown reqwest selects its browser `fetch` backend by # target arch (not a cargo feature), so the default TLS/backend features are # unnecessary here. The `form` feature gates `.form()`, which the STS diff --git a/README.md b/README.md index fad9748..01cd38f 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Any other name is refused with `MalformedPolicyDocument`, never mapped to a defa A token from a platform issuer in `PLATFORM_ISSUERS`, such as GitHub Actions, says which workload is calling but not which account it may act as. It is presented at `/.sts` from a POST form body only, as an API key is: a token in the URL is refused, because the URL is logged. It acts as the service account in `RoleArn`, `arn:aws:iam::--:role/FullAccess`, and only if that account trusts the token's issuer and subject (ADR-014); an account that is not a service account is refused before anything else. The proxy verifies the token against the issuer's JWKS (looked up again for a key id it has not seen; a failure to fetch it is a retryable `InternalError`), with that issuer's own audiences and a required `exp`, then, unless a cached answer settles it and within `STS_EXCHANGE_LIMIT`, asks `POST {SOURCE_API_URL}/api/v1/accounts/{account}/trusts/exchanges` with `{"issuer", "subject"}`, as the account. Per account, issuer and subject, a yes is cached for 60 seconds and a no for 10, and the credentials' principal is the account, never the token's subject. Every refusal reads `AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity (request id …)`. A token from `AUTH_ISSUER` still acts as its own subject and ignores the account in `RoleArn`. -`aws-actions/configure-aws-credentials` fails after the exchange succeeds: it checks the credentials it exports with `GetCallerIdentity`, which the proxy cannot answer until developmentseed/multistore#126 lands. Until then a workflow saves its token to a file and lets an AWS SDK exchange it, with `AWS_WEB_IDENTITY_TOKEN_FILE`, `AWS_ROLE_ARN`, `AWS_ENDPOINT_URL_STS=/.sts`, `AWS_ENDPOINT_URL_S3=` and `AWS_REGION`. +`aws-actions/configure-aws-credentials` works: after the exchange it checks the credentials it exports with `GetCallerIdentity`, which the proxy answers at `/.sts` (and `/.sts/`, where its SDK sends it). The response's `Arn` and `UserId` name the Role and the account; `Account` is multistore's fixed synthetic id. A workflow can instead save its token to a file and let an AWS SDK exchange it, with `AWS_WEB_IDENTITY_TOKEN_FILE`, `AWS_ROLE_ARN`, `AWS_ENDPOINT_URL_STS=/.sts`, `AWS_ENDPOINT_URL_S3=` and `AWS_REGION`. ### Secrets diff --git a/src/keys.rs b/src/keys.rs index 33eb4eb..95a423f 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -101,7 +101,7 @@ pub fn credentials_for( duration, "STSPRXY", &serde_json::json!({}), - ); + )?; creds.session_token = token_key.seal(&creds)?; Ok(creds) } diff --git a/src/source_api/registry.rs b/src/source_api/registry.rs index f7e93dc..d23141c 100644 --- a/src/source_api/registry.rs +++ b/src/source_api/registry.rs @@ -243,7 +243,7 @@ async fn resolve_product( let config = BucketConfig { name: format!("{}{}{}", account, crate::BUCKET_SEPARATOR, product), - backend_type, + backend_type: backend_type.parse()?, backend_prefix, // Proxy-client-facing: Source Cooperative authorizes callers via its own // JWT (enforced upstream at the subject-scoped fetch), not S3 request diff --git a/src/sts.rs b/src/sts.rs index db2f080..2f16a42 100644 --- a/src/sts.rs +++ b/src/sts.rs @@ -78,7 +78,11 @@ pub(crate) fn role( name: name.to_string(), trusted_oidc_issuers: vec![oidc_issuer], required_audiences, - subject_conditions: vec![], + // Any subject: the person issuer's token names the person, and a + // platform token acts only as an account that trusts its subject. + subject_conditions: vec!["*".to_string()], + // Every issuer must set `exp`; a token without one is replayable for good. + allow_missing_exp_from: vec![], allowed_scopes, max_session_duration_secs, })