From 406ffcd7628f0dca7a06f8e3315f81ccc67479c8 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 4 Jun 2026 12:20:45 -0700 Subject: [PATCH 1/4] refactor(oidc-provider): consume backend-federation's credential type, drop the duplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit oidc-provider and backend-federation each modeled temporary backend credentials. Collapse to one source of truth in the mechanism crate: - Delete oidc-provider's CloudCredentials (+ its redacting Debug and the From bridge); re-export and consume multistore_backend_federation::FederatedCredentials throughout the AWS/Azure/GCP exchanges, CredentialExchange, CredentialCache, and get_credentials. - backend_auth.rs injects via FederatedCredentials::apply_to instead of re-inserting access_key_id/secret_access_key/token inline. This also clears skip_signature, fixing a latent bug where an anonymous bucket (source.coop's registry.rs starting state) stayed unsigned after federation. OidcProviderError::StsError stays as the boundary translation of FederationError::Sts (signing/key/HTTP failures + status mapping need their own enum) — a bridge, not duplicated mechanism. backend-federation owns the outbound exchange, the credential value type, and BucketConfig injection; oidc-provider owns identity (JWT mint, JWKS, discovery) + orchestration and delegates the rest. Single path for source.coop's OIDC-IdP backend auth. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/oidc-provider/src/backend_auth.rs | 14 +++++--- crates/oidc-provider/src/cache.rs | 18 +++++------ crates/oidc-provider/src/exchange/aws.rs | 24 ++++++-------- crates/oidc-provider/src/exchange/azure.rs | 18 +++++++---- crates/oidc-provider/src/exchange/gcp.rs | 20 ++++++++---- crates/oidc-provider/src/exchange/mod.rs | 4 +-- crates/oidc-provider/src/lib.rs | 37 ++++++---------------- 7 files changed, 63 insertions(+), 72 deletions(-) diff --git a/crates/oidc-provider/src/backend_auth.rs b/crates/oidc-provider/src/backend_auth.rs index 1ebecc5..338dadf 100644 --- a/crates/oidc-provider/src/backend_auth.rs +++ b/crates/oidc-provider/src/backend_auth.rs @@ -47,17 +47,21 @@ impl AwsBackendAuth { .get_credentials(role_arn, &exchange, subject, &[]) .await?; - let mut options = config.backend_options.clone(); - options.insert("access_key_id".into(), creds.access_key_id.clone()); - options.insert("secret_access_key".into(), creds.secret_access_key.clone()); - options.insert("token".into(), creds.session_token.clone()); + // Inject the temporary credentials through the canonical primitive in + // `multistore-backend-federation`, so the option-key set and the + // `skip_signature` clearing stay single-sourced there rather than being + // re-hand-rolled here (the previous inline version forgot to clear + // `skip_signature`). + let mut resolved = config.clone(); + creds.apply_to(&mut resolved); + let options = &mut resolved.backend_options; // Remove OIDC-specific keys so they don't confuse the builder. options.remove("auth_type"); options.remove("oidc_role_arn"); options.remove("oidc_subject"); - Ok(options) + Ok(resolved.backend_options) } /// Internal helper: resolve credentials if bucket needs OIDC. diff --git a/crates/oidc-provider/src/cache.rs b/crates/oidc-provider/src/cache.rs index a4c7612..4b39c35 100644 --- a/crates/oidc-provider/src/cache.rs +++ b/crates/oidc-provider/src/cache.rs @@ -1,6 +1,6 @@ //! TTL credential cache. //! -//! Caches [`CloudCredentials`] by key, evicting entries that are within a +//! Caches [`FederatedCredentials`] by key, evicting entries that are within a //! safety margin of expiration. This avoids redundant STS calls when the //! same backend is accessed repeatedly within a short window. @@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex}; use chrono::{Duration, Utc}; -use crate::CloudCredentials; +use crate::FederatedCredentials; /// Safety margin before expiration — credentials are considered expired /// this many seconds before their actual `expires_at`. @@ -17,7 +17,7 @@ const EXPIRY_MARGIN_SECS: i64 = 60; /// Thread-safe TTL cache for cloud credentials. pub struct CredentialCache { - entries: Mutex>>, + entries: Mutex>>, } impl Default for CredentialCache { @@ -35,11 +35,11 @@ impl CredentialCache { } /// Retrieve cached credentials if they are still valid. - pub fn get(&self, key: &str) -> Option> { + pub fn get(&self, key: &str) -> Option> { let entries = self.entries.lock().unwrap(); if let Some(creds) = entries.get(key) { let margin = Duration::seconds(EXPIRY_MARGIN_SECS); - if creds.expires_at > Utc::now() + margin { + if creds.expiration > Utc::now() + margin { return Some(creds.clone()); } } @@ -47,7 +47,7 @@ impl CredentialCache { } /// Store credentials in the cache. - pub fn put(&self, key: String, creds: Arc) { + pub fn put(&self, key: String, creds: Arc) { let mut entries = self.entries.lock().unwrap(); entries.insert(key, creds); } @@ -57,12 +57,12 @@ impl CredentialCache { mod tests { use super::*; - fn make_creds(expires_in_secs: i64) -> CloudCredentials { - CloudCredentials { + fn make_creds(expires_in_secs: i64) -> FederatedCredentials { + FederatedCredentials { access_key_id: "AKID".into(), secret_access_key: "secret".into(), session_token: "token".into(), - expires_at: Utc::now() + Duration::seconds(expires_in_secs), + expiration: Utc::now() + Duration::seconds(expires_in_secs), } } diff --git a/crates/oidc-provider/src/exchange/aws.rs b/crates/oidc-provider/src/exchange/aws.rs index 7571554..81e422a 100644 --- a/crates/oidc-provider/src/exchange/aws.rs +++ b/crates/oidc-provider/src/exchange/aws.rs @@ -1,10 +1,9 @@ //! AWS STS `AssumeRoleWithWebIdentity` credential exchange. -use crate::{CloudCredentials, HttpExchange, OidcProviderError}; +use crate::{FederatedCredentials, HttpExchange, OidcProviderError}; use super::CredentialExchange; use multistore_backend_federation::aws::{parse_response, AssumeRoleWithWebIdentity}; -use multistore_backend_federation::FederatedCredentials; /// Configuration for exchanging a JWT for AWS credentials. #[derive(Debug, Clone)] @@ -73,10 +72,16 @@ impl AwsExchange { } impl CredentialExchange for AwsExchange { - async fn exchange(&self, http: &H, jwt: &str) -> Result { + async fn exchange( + &self, + http: &H, + jwt: &str, + ) -> Result { // Build the request with the canonical `multistore-backend-federation` // primitive, hand its (unencoded) pairs to the runtime's HTTP client — // which form-urlencodes them — then parse the reply with the same crate. + // The parsed `FederatedCredentials` flow through unchanged: this crate no + // longer keeps a second credential type to convert into. let request = AssumeRoleWithWebIdentity { role_arn: &self.role_arn, web_identity_token: jwt, @@ -90,17 +95,6 @@ impl CredentialExchange for AwsExchange { let body = http.post_form(&self.sts_endpoint, &form).await?; - Ok(parse_response(&body)?.into()) - } -} - -impl From for CloudCredentials { - fn from(c: FederatedCredentials) -> Self { - Self { - access_key_id: c.access_key_id, - secret_access_key: c.secret_access_key, - session_token: c.session_token, - expires_at: c.expiration, - } + Ok(parse_response(&body)?) } } diff --git a/crates/oidc-provider/src/exchange/azure.rs b/crates/oidc-provider/src/exchange/azure.rs index 429202b..3ddcf07 100644 --- a/crates/oidc-provider/src/exchange/azure.rs +++ b/crates/oidc-provider/src/exchange/azure.rs @@ -3,13 +3,13 @@ //! Exchanges a self-signed JWT for an Azure access token via the //! OAuth 2.0 client credentials grant with federated identity. -use crate::{CloudCredentials, HttpExchange, OidcProviderError}; +use crate::{FederatedCredentials, HttpExchange, OidcProviderError}; use super::CredentialExchange; /// Configuration for exchanging a JWT for Azure credentials. /// -/// Azure returns a bearer token only; the resulting [`CloudCredentials`](crate::CloudCredentials) +/// Azure returns a bearer token only; the resulting [`FederatedCredentials`](crate::FederatedCredentials) /// will have `access_key_id` and `secret_access_key` set to empty strings while /// `session_token` carries the bearer token. #[derive(Debug, Clone)] @@ -49,7 +49,11 @@ impl AzureExchange { } impl CredentialExchange for AzureExchange { - async fn exchange(&self, http: &H, jwt: &str) -> Result { + async fn exchange( + &self, + http: &H, + jwt: &str, + ) -> Result { let form = [ ("grant_type", "client_credentials"), ( @@ -68,7 +72,7 @@ impl CredentialExchange for AzureExchange { } /// Parse an Azure AD token response. -fn parse_azure_token_response(json: &str) -> Result { +fn parse_azure_token_response(json: &str) -> Result { let parsed: serde_json::Value = serde_json::from_str(json).map_err(|e| { OidcProviderError::ExchangeError(format!("invalid Azure token response: {e}")) })?; @@ -96,11 +100,11 @@ fn parse_azure_token_response(json: &str) -> Result chrono::Utc::now()); + assert!(creds.expiration > chrono::Utc::now()); } #[test] diff --git a/crates/oidc-provider/src/exchange/gcp.rs b/crates/oidc-provider/src/exchange/gcp.rs index 1a20621..e52f47a 100644 --- a/crates/oidc-provider/src/exchange/gcp.rs +++ b/crates/oidc-provider/src/exchange/gcp.rs @@ -5,13 +5,13 @@ //! 2. Use the federated token to call IAM `generateAccessToken` for a //! service account, obtaining a GCP access token -use crate::{CloudCredentials, HttpExchange, OidcProviderError}; +use crate::{FederatedCredentials, HttpExchange, OidcProviderError}; use super::CredentialExchange; /// Configuration for exchanging a JWT for GCP credentials. /// -/// GCP returns a bearer token only; the resulting [`CloudCredentials`](crate::CloudCredentials) +/// GCP returns a bearer token only; the resulting [`FederatedCredentials`](crate::FederatedCredentials) /// will have `access_key_id` and `secret_access_key` set to empty strings while /// `session_token` carries the bearer token. #[derive(Debug, Clone)] @@ -51,7 +51,11 @@ impl GcpExchange { } impl CredentialExchange for GcpExchange { - async fn exchange(&self, http: &H, jwt: &str) -> Result { + async fn exchange( + &self, + http: &H, + jwt: &str, + ) -> Result { // Step 1: Exchange JWT for federated access token via GCP STS let sts_form = [ ( @@ -122,7 +126,9 @@ fn parse_sts_token_response(json: &str) -> Result { } /// Parse the IAM `generateAccessToken` response. -fn parse_generate_access_token_response(json: &str) -> Result { +fn parse_generate_access_token_response( + json: &str, +) -> Result { let parsed: serde_json::Value = serde_json::from_str(json).map_err(|e| { OidcProviderError::ExchangeError(format!("invalid generateAccessToken response: {e}")) })?; @@ -140,11 +146,11 @@ fn parse_generate_access_token_response(json: &str) -> Result: &self, http: &H, jwt: &str, - ) -> impl std::future::Future> + ) -> impl std::future::Future> + multistore::maybe_send::MaybeSend; } diff --git a/crates/oidc-provider/src/lib.rs b/crates/oidc-provider/src/lib.rs index 7d35252..7d30bb6 100644 --- a/crates/oidc-provider/src/lib.rs +++ b/crates/oidc-provider/src/lib.rs @@ -28,32 +28,15 @@ use cache::CredentialCache; use exchange::CredentialExchange; use jwt::JwtSigner; -/// Temporary cloud credentials obtained via token exchange. +/// The backend credential value type — its fields, secret-redacting `Debug`, and +/// `BucketConfig` injection ([`FederatedCredentials::apply_to`]) — is owned by +/// `multistore-backend-federation`, the outbound-exchange mechanism. It is +/// re-exported here so this crate's exchange and caching APIs speak that one +/// type; there is deliberately no parallel `CloudCredentials`. /// -/// `Debug` redacts the secret access key and session token so credentials are -/// never leaked into logs. -#[derive(Clone)] -pub struct CloudCredentials { - /// AWS access key ID. Empty string for Azure/GCP (bearer-token-only providers). - pub access_key_id: String, - /// AWS secret access key. Empty string for Azure/GCP (bearer-token-only providers). - pub secret_access_key: String, - /// Session or bearer token. For Azure/GCP this is the sole credential. - pub session_token: String, - /// When these credentials expire. - pub expires_at: chrono::DateTime, -} - -impl std::fmt::Debug for CloudCredentials { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CloudCredentials") - .field("access_key_id", &self.access_key_id) - .field("secret_access_key", &"[REDACTED]") - .field("session_token", &"[REDACTED]") - .field("expires_at", &self.expires_at) - .finish() - } -} +/// Bearer-only backends (Azure/GCP) leave `access_key_id`/`secret_access_key` +/// empty and carry the token in `session_token`. +pub use multistore_backend_federation::FederatedCredentials; /// HTTP client abstraction for outbound requests (STS token exchange). /// @@ -108,7 +91,7 @@ impl OidcCredentialProvider { exchange: &E, subject: &str, extra_claims: &[(&str, &str)], - ) -> Result, OidcProviderError> { + ) -> Result, OidcProviderError> { // Check cache first if let Some(creds) = self.cache.get(cache_key) { return Ok(creds); @@ -120,7 +103,7 @@ impl OidcCredentialProvider { .sign(subject, &self.issuer, &self.audience, extra_claims)?; // Exchange it for cloud credentials - let creds: CloudCredentials = exchange.exchange(&self.http, &token).await?; + let creds: FederatedCredentials = exchange.exchange(&self.http, &token).await?; let creds = Arc::new(creds); // Cache From fbea5e48a3e018635a557b2971c1097cbe9fc858 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 4 Jun 2026 13:11:01 -0700 Subject: [PATCH 2/4] refactor(federation): dissolve backend-federation; hoist FederatedCredentials to core Outbound federation lived in two crates with a backwards-feeling edge (oidc-provider -> backend-federation). backend-federation bundled two things of different altitude: the credential value type (foundational) and the AWS STS exchange mechanism. With the proxy as the only consumer of the creds it obtains -- multistore never spends a token it didn't mint nor brokers cloud access to third parties -- the separate "spender" crate had no future tenant. - Move FederatedCredentials (+ apply_to + redacting Debug) into multistore core (crates/core/src/types.rs), beside its inbound sibling TemporaryCredentials. - Absorb the AWS AssumeRoleWithWebIdentity mechanism + FederationError into oidc-provider's exchange/aws.rs (no more delegation); the StsError mapping is now intra-crate. oidc-provider re-exports FederatedCredentials so it stays the single front door for consumers (source.coop never names a federation crate). - Drop the unused body()/endpoint() helpers (body duplicated the form_pairs path; endpoint was never wired in) and the now-unused url dep. - Delete crates/backend-federation; update workspace Cargo.toml, README, release-please config, the wrangler example, and the smoke-test docstring. oidc-provider now depends only on multistore core. Tests: core 74, oidc-provider 29 default / 37 with azure,gcp; no new clippy warnings; cf-workers wasm passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 2 +- .github/workflows/preview.yml | 2 +- Cargo.lock | 14 +- Cargo.toml | 3 - README.md | 5 +- crates/backend-federation/Cargo.toml | 14 - crates/backend-federation/README.md | 47 --- crates/backend-federation/src/aws.rs | 314 ------------------- crates/backend-federation/src/credentials.rs | 63 ---- crates/backend-federation/src/error.rs | 26 -- crates/backend-federation/src/lib.rs | 111 ------- crates/core/src/types.rs | 118 +++++++ crates/oidc-provider/Cargo.toml | 2 +- crates/oidc-provider/src/backend_auth.rs | 10 +- crates/oidc-provider/src/exchange/aws.rs | 283 ++++++++++++++++- crates/oidc-provider/src/lib.rs | 15 +- examples/cf-workers/wrangler.toml | 8 +- release-please-config.json | 5 - tests/smoke/test_federation.py | 2 +- 19 files changed, 418 insertions(+), 626 deletions(-) delete mode 100644 crates/backend-federation/Cargo.toml delete mode 100644 crates/backend-federation/README.md delete mode 100644 crates/backend-federation/src/aws.rs delete mode 100644 crates/backend-federation/src/credentials.rs delete mode 100644 crates/backend-federation/src/error.rs delete mode 100644 crates/backend-federation/src/lib.rs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 145feba..4bd5d7c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -107,7 +107,7 @@ jobs: id-token: write env: DEPLOY_URL: ${{ needs.deploy.outputs.deploy_url }} - # Object key the backend-federation smoke test GETs from the private + # Object key the federation smoke test GETs from the private # bucket (defaults to hello.txt). The full federation path is validated # here on both preview (PR) and staging deploys, against the stable # staging OIDC issuer — see preview.yml / staging.yml. diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 3d2a6f5..d784c68 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -25,7 +25,7 @@ jobs: # All deployments share OIDC_PROVIDER_KEY, so a preview can sign assertions # whose `iss` is the staging URL; AWS then validates them against the # single, already-registered staging IAM OIDC provider. This lets the full - # backend-federation path (tests/smoke/test_federation.py) run on every PR + # federation path (tests/smoke/test_federation.py) run on every PR # WITHOUT creating/tearing down a per-PR identity provider + role. oidc_issuer_override: ${{ vars.STAGING_OIDC_ISSUER }} secrets: diff --git a/Cargo.lock b/Cargo.lock index a20f437..9000841 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1144,18 +1144,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "multistore-backend-federation" -version = "0.4.0" -dependencies = [ - "chrono", - "multistore", - "quick-xml 0.37.5", - "serde", - "thiserror", - "url", -] - [[package]] name = "multistore-cf-workers" version = "0.4.0" @@ -1243,7 +1231,7 @@ dependencies = [ "base64", "chrono", "multistore", - "multistore-backend-federation", + "quick-xml 0.37.5", "rand 0.8.5", "rsa", "serde", diff --git a/Cargo.toml b/Cargo.toml index 17f0ade..beeed22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ members = [ "crates/static-config", "crates/sts", "crates/oidc-provider", - "crates/backend-federation", "examples/server", "examples/lambda", "examples/cf-workers", @@ -19,7 +18,6 @@ default-members = [ "crates/static-config", "crates/sts", "crates/oidc-provider", - "crates/backend-federation", "examples/server", "examples/lambda", ] @@ -110,4 +108,3 @@ multistore-metering = { path = "crates/metering", version = "0.4.0" } multistore-cf-workers = { path = "crates/cf-workers", version = "0.4.0" } multistore-oidc-provider = { path = "crates/oidc-provider", version = "0.4.0" } multistore-path-mapping = { path = "crates/path-mapping", version = "0.4.0" } -multistore-backend-federation = { path = "crates/backend-federation", version = "0.4.0" } diff --git a/README.md b/README.md index 35ae89d..183196b 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,7 @@ The workspace is split into reusable **libraries** (traits and logic) and exampl | [`multistore`](https://docs.rs/multistore) | [`crates/core/`](crates/core) | Runtime-agnostic core: traits, S3 parsing, SigV4, registries | | [`multistore-metering`](https://docs.rs/multistore-metering) | [`crates/metering/`](crates/metering) | Usage metering and quota enforcement middleware | | [`multistore-sts`](https://docs.rs/multistore-sts) | [`crates/sts/`](crates/sts) | OIDC/STS token exchange (`AssumeRoleWithWebIdentity`) | -| [`multistore-oidc-provider`](https://docs.rs/multistore-oidc-provider) | [`crates/oidc-provider/`](crates/oidc-provider) | Outbound OIDC provider (JWT signing, JWKS, exchange) | -| `multistore-backend-federation` | [`crates/backend-federation/`](crates/backend-federation) | Outbound credential federation (OIDC identity → backend cloud STS) | +| [`multistore-oidc-provider`](https://docs.rs/multistore-oidc-provider) | [`crates/oidc-provider/`](crates/oidc-provider) | Outbound OIDC provider (JWT signing, JWKS, exchange, backend-cloud STS federation) | | `multistore-static-config` | [`crates/static-config/`](crates/static-config) | Static config provider (buckets/roles/credentials) | | [`multistore-path-mapping`](https://docs.rs/multistore-path-mapping) | [`crates/path-mapping/`](crates/path-mapping) | Hierarchical path-based backend resolution | | [`multistore-cf-workers`](https://docs.rs/multistore-cf-workers) | [`crates/cf-workers/`](crates/cf-workers) | Cloudflare Workers runtime library (WASM) | @@ -39,7 +38,7 @@ The workspace is split into reusable **libraries** (traits and logic) and exampl | `multistore-lambda` | [`examples/lambda/`](examples/lambda) | AWS Lambda runtime | | `multistore-cf-workers-example` | [`examples/cf-workers/`](examples/cf-workers) | Cloudflare Workers example for edge deployments | -`multistore-backend-federation` and `multistore-static-config` are not published to crates.io; follow their source links for documentation. For per-crate responsibilities and the dependency graph, see [Crate Layout](https://developmentseed.org/multistore/architecture/crate-layout/) in the docs. +`multistore-static-config` is not published to crates.io; follow its source link for documentation. For per-crate responsibilities and the dependency graph, see [Crate Layout](https://developmentseed.org/multistore/architecture/crate-layout/) in the docs. ## Getting Started diff --git a/crates/backend-federation/Cargo.toml b/crates/backend-federation/Cargo.toml deleted file mode 100644 index b7ef2ea..0000000 --- a/crates/backend-federation/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "multistore-backend-federation" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Outbound credential federation (OIDC identity -> backend cloud STS) for the multistore S3 proxy gateway" - -[dependencies] -multistore.workspace = true -chrono.workspace = true -serde.workspace = true -quick-xml.workspace = true -url.workspace = true -thiserror.workspace = true diff --git a/crates/backend-federation/README.md b/crates/backend-federation/README.md deleted file mode 100644 index 8d57cce..0000000 --- a/crates/backend-federation/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# multistore-backend-federation - -Outbound credential federation for the [`multistore`](https://crates.io/crates/multistore) S3 proxy gateway. The runtime-agnostic *client* side of AWS STS `AssumeRoleWithWebIdentity`: the proxy presents its own OIDC identity to a **backend cloud**, assumes a role there, and signs backend requests with the temporary credentials — so the operator never holds long-lived backend keys. - -It is the symmetric counterpart to [`multistore-sts`](../sts), which is the inbound `AssumeRoleWithWebIdentity` **server** (minting proxy credentials for callers). - -``` - inbound outbound - caller ──OIDC──▶ multistore-sts multistore-backend-federation ──OIDC──▶ backend cloud STS - ◀─proxy creds─ (server: mint) (client: build req / parse resp) ◀─backend creds─ -``` - -## How It Works - -``` -proxy's OIDC identity (multistore-oidc-provider mints + signs the JWT) - │ - │ self-signed JWT (web identity token) - ▼ -┌──────────────────────────────────────┐ -│ multistore-backend-federation │ -│ │ -│ 1. build AssumeRoleWithWebIdentity │ ← request URL + form body / pairs -│ request (this crate) │ -│ 2. caller POSTs it to backend STS │ ← transport owned by the caller -│ 3. parse_response(xml) │ ← typed creds or typed FederationError::Sts -│ 4. FederatedCredentials::apply_to │ ← inject into BucketConfig.backend_options -└──────────────────────────────────────┘ - │ - │ temporary backend AccessKeyId + SecretAccessKey + SessionToken - ▼ -multistore S3 backend signs requests to the private bucket -``` - -This crate is **mechanism only**: it owns the STS request/response shapes and the `BucketConfig` injection. It does *not* mint the JWT, perform HTTP, cache, or wire middleware — that orchestration lives in [`multistore-oidc-provider`](../oidc-provider), which delegates its AWS exchange to this crate. - -## Relationship to the other auth crates - -| crate | direction | role | -|---|---|---| -| `multistore-sts` | inbound | server: validate caller OIDC token, mint `TemporaryCredentials` | -| `multistore-oidc-provider` | outbound | mint the proxy's own JWT (sign/JWKS/discovery) + cache + middleware | -| `multistore-backend-federation` | outbound | client: build/parse backend STS exchange, inject `FederatedCredentials` | - -## Bring your own token - -Because the crate only depends on `multistore` (core) and a few wire libraries — no RSA/JWKS machinery — a caller that already holds a web-identity token (an external IdP, a workload-identity assertion, a pre-minted JWT) can use it standalone to exchange that token for backend credentials, without pulling in the full OIDC provider. diff --git a/crates/backend-federation/src/aws.rs b/crates/backend-federation/src/aws.rs deleted file mode 100644 index 67ab034..0000000 --- a/crates/backend-federation/src/aws.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! AWS STS `AssumeRoleWithWebIdentity` federation. -//! -//! This module is **runtime-agnostic**: it builds the request (URL + form body) -//! and parses the XML response, but does not perform the HTTP call itself. -//! Multistore deployments differ in their HTTP stack (reqwest on native, -//! `web_sys::fetch` on Cloudflare Workers), so the caller owns the transport. -//! -//! ``` -//! use multistore_backend_federation::aws::{AssumeRoleWithWebIdentity, parse_response}; -//! -//! let req = AssumeRoleWithWebIdentity { -//! role_arn: "arn:aws:iam::123456789012:role/my-role", -//! web_identity_token: "", -//! role_session_name: "multistore", -//! duration_seconds: Some(3600), -//! session_policy: None, -//! }; -//! assert!(req.body().contains("Action=AssumeRoleWithWebIdentity")); -//! -//! let url = AssumeRoleWithWebIdentity::endpoint("us-east-1"); -//! assert_eq!(url, "https://sts.us-east-1.amazonaws.com/"); -//! -//! // POST `req.body()` (or `req.form_pairs()` if your HTTP client urlencodes -//! // for you) to `url` as application/x-www-form-urlencoded, then parse the reply: -//! let response_xml = r#" -//! -//! ASIAEXAMPLE -//! secret -//! token -//! 2030-01-01T00:00:00Z -//! "#; -//! let creds = parse_response(response_xml)?; -//! assert_eq!(creds.access_key_id, "ASIAEXAMPLE"); -//! # Ok::<(), multistore_backend_federation::FederationError>(()) -//! ``` - -use crate::credentials::FederatedCredentials; -use crate::error::FederationError; -use chrono::{DateTime, Utc}; -use serde::Deserialize; -use std::borrow::Cow; - -/// Parameters for an `AssumeRoleWithWebIdentity` request. -/// -/// The web identity token is the OIDC assertion minted by the proxy (e.g. via -/// `multistore-oidc-provider`); the role's trust policy must trust the proxy's -/// issuer and may condition on the token's `aud`/`sub`. -#[derive(Debug, Clone)] -pub struct AssumeRoleWithWebIdentity<'a> { - /// ARN of the role to assume. - pub role_arn: &'a str, - /// The OIDC token presented as the web identity. - pub web_identity_token: &'a str, - /// Session name recorded in CloudTrail for this assumption. - pub role_session_name: &'a str, - /// Requested credential lifetime, in seconds. `None` omits `DurationSeconds` - /// so AWS applies the role's default (3600s); when set, AWS clamps to the - /// role's maximum and rejects values below 900. - pub duration_seconds: Option, - /// Optional inline session policy (further restricts the session, e.g. to a - /// key prefix) — `None` to use the role's permissions as-is. - pub session_policy: Option<&'a str>, -} - -impl<'a> AssumeRoleWithWebIdentity<'a> { - /// The regional STS endpoint URL to POST to. - /// - /// Regional endpoints are preferred over the global one; for non-standard - /// partitions (GovCloud, China) build the URL yourself. - pub fn endpoint(region: &str) -> String { - format!("https://sts.{region}.amazonaws.com/") - } - - /// The request as form key/value pairs, with values **unencoded**. - /// - /// Use this when the HTTP layer performs its own form-urlencoding (e.g. - /// reqwest's `.form(...)`); feeding it a pre-encoded [`body`](Self::body) - /// instead would double-encode the values. - pub fn form_pairs(&self) -> Vec<(&'static str, Cow<'a, str>)> { - let mut pairs = vec![ - ("Action", Cow::Borrowed("AssumeRoleWithWebIdentity")), - ("Version", Cow::Borrowed("2011-06-15")), - ("RoleArn", Cow::Borrowed(self.role_arn)), - ("RoleSessionName", Cow::Borrowed(self.role_session_name)), - ("WebIdentityToken", Cow::Borrowed(self.web_identity_token)), - ]; - if let Some(duration) = self.duration_seconds { - pairs.push(("DurationSeconds", Cow::Owned(duration.to_string()))); - } - if let Some(policy) = self.session_policy { - pairs.push(("Policy", Cow::Borrowed(policy))); - } - pairs - } - - /// The `application/x-www-form-urlencoded` request body. - /// - /// Use this when the HTTP layer sends a raw body; for a layer that encodes - /// form pairs itself, use [`form_pairs`](Self::form_pairs) to avoid - /// double-encoding. - pub fn body(&self) -> String { - let mut form = url::form_urlencoded::Serializer::new(String::new()); - for (key, value) in self.form_pairs() { - form.append_pair(key, &value); - } - form.finish() - } -} - -/// Parse the body of an `AssumeRoleWithWebIdentity` response into credentials. -/// -/// AWS returns the same XML shape regardless of HTTP status on the error path -/// (an `` document), so this inspects the body rather than -/// relying on the status code: an error document becomes -/// [`FederationError::Sts`] carrying the provider's code and message. -pub fn parse_response(xml: &str) -> Result { - if xml.contains(", -} - -#[derive(Deserialize)] -struct ErrorResponse { - #[serde(rename = "Error")] - error: ErrorDetail, -} - -#[derive(Deserialize)] -struct ErrorDetail { - #[serde(rename = "Code")] - code: String, - #[serde(rename = "Message")] - message: String, -} - -#[cfg(test)] -mod tests { - use super::*; - - const SUCCESS: &str = r#" - - - scv1:conn:test - source-coop-data-proxy - - arn:aws:sts::123456789012:assumed-role/my-role/multistore - AROAEXAMPLE:multistore - - - ASIAEXAMPLE - secret/+key - sess+tok/en== - 2026-06-03T04:13:40Z - - https://data.source.coop - - - 11111111-2222-3333-4444-555555555555 - -"#; - - const ERROR: &str = r#" - - - Sender - InvalidIdentityToken - No OpenIDConnect provider found in your account for https://data.source.coop - - aaaa -"#; - - #[test] - fn parses_credentials() { - let creds = parse_response(SUCCESS).expect("should parse"); - assert_eq!(creds.access_key_id, "ASIAEXAMPLE"); - assert_eq!(creds.secret_access_key, "secret/+key"); - assert_eq!(creds.session_token, "sess+tok/en=="); - assert_eq!(creds.expiration.to_rfc3339(), "2026-06-03T04:13:40+00:00"); - } - - #[test] - fn surfaces_sts_error_as_typed_error() { - match parse_response(ERROR) { - Err(FederationError::Sts { code, message }) => { - assert_eq!(code, "InvalidIdentityToken"); - assert!(message.contains("No OpenIDConnect provider")); - } - other => panic!("expected Sts error, got {other:?}"), - } - } - - #[test] - fn debug_redacts_secrets() { - let creds = parse_response(SUCCESS).unwrap(); - let dbg = format!("{creds:?}"); - assert!(dbg.contains("ASIAEXAMPLE")); - assert!(!dbg.contains("secret/+key")); - assert!(!dbg.contains("sess+tok/en")); - assert!(dbg.contains("[REDACTED]")); - } - - #[test] - fn body_contains_expected_params() { - let req = AssumeRoleWithWebIdentity { - role_arn: "arn:aws:iam::123456789012:role/my-role", - web_identity_token: "tok.tok.tok", - role_session_name: "multistore", - duration_seconds: Some(3600), - session_policy: None, - }; - let body = req.body(); - assert!(body.contains("Action=AssumeRoleWithWebIdentity")); - assert!(body.contains("Version=2011-06-15")); - assert!(body.contains("DurationSeconds=3600")); - // RoleArn is percent-encoded (`:` and `/`). - assert!(body.contains("RoleArn=arn%3Aaws%3Aiam%3A%3A123456789012%3Arole%2Fmy-role")); - assert!(body.contains("WebIdentityToken=tok.tok.tok")); - assert!(!body.contains("Policy=")); - } - - #[test] - fn body_omits_duration_when_none() { - let req = AssumeRoleWithWebIdentity { - role_arn: "arn:aws:iam::1:role/r", - web_identity_token: "t", - role_session_name: "s", - duration_seconds: None, - session_policy: None, - }; - assert!(!req.body().contains("DurationSeconds")); - } - - #[test] - fn body_includes_session_policy_when_present() { - let req = AssumeRoleWithWebIdentity { - role_arn: "arn:aws:iam::1:role/r", - web_identity_token: "t", - role_session_name: "s", - duration_seconds: Some(900), - session_policy: Some("{\"Version\":\"2012-10-17\"}"), - }; - assert!(req.body().contains("Policy=")); - } - - #[test] - fn form_pairs_are_unencoded() { - let req = AssumeRoleWithWebIdentity { - role_arn: "arn:aws:iam::123456789012:role/my-role", - web_identity_token: "tok", - role_session_name: "multistore", - duration_seconds: Some(3600), - session_policy: None, - }; - let pairs = req.form_pairs(); - // Values are raw (the caller's HTTP layer encodes them) — note the `:`/`/` - // are NOT percent-encoded here, unlike in `body()`. - assert!(pairs.iter().any( - |(k, v)| *k == "RoleArn" && v.as_ref() == "arn:aws:iam::123456789012:role/my-role" - )); - assert!(pairs - .iter() - .any(|(k, v)| *k == "DurationSeconds" && v.as_ref() == "3600")); - assert!(pairs.iter().all(|(k, _)| *k != "Policy")); - } - - #[test] - fn endpoint_is_regional() { - assert_eq!( - AssumeRoleWithWebIdentity::endpoint("us-west-2"), - "https://sts.us-west-2.amazonaws.com/" - ); - } -} diff --git a/crates/backend-federation/src/credentials.rs b/crates/backend-federation/src/credentials.rs deleted file mode 100644 index 560f929..0000000 --- a/crates/backend-federation/src/credentials.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Short-lived credentials obtained by federating into a backend cloud, and -//! the glue that injects them into a [`BucketConfig`]. - -use chrono::{DateTime, Utc}; -use multistore::types::BucketConfig; -use std::fmt; - -/// Temporary credentials for a backend object store, obtained by exchanging an -/// OIDC assertion at the backend cloud's STS (e.g. AWS -/// `AssumeRoleWithWebIdentity`). -/// -/// These are *backend* credentials — distinct from -/// [`multistore::types::TemporaryCredentials`], which are minted by the proxy's -/// own STS for callers. They carry only what an object-store client needs to -/// sign requests, plus the expiry so a caller can cache and refresh them. -#[derive(Clone)] -pub struct FederatedCredentials { - /// Temporary access key id (AWS `ASIA…`). - pub access_key_id: String, - /// Temporary secret access key. - pub secret_access_key: String, - /// Session token that must accompany requests using these credentials. - pub session_token: String, - /// When these credentials expire. - pub expiration: DateTime, -} - -impl FederatedCredentials { - /// Inject these credentials into a [`BucketConfig`] so the multistore - /// backend signs requests with them instead of going anonymous. - /// - /// Sets the canonical S3 option keys (`access_key_id`, `secret_access_key`, - /// and `token` — the alias object_store maps to the session token and that - /// multistore redacts in logs) and clears `skip_signature` so the backend - /// signs. - /// - /// This governs only *outbound* (backend) signing. It deliberately leaves - /// [`BucketConfig::anonymous_access`] untouched: that flag controls - /// *inbound* authorization (whether proxy callers may read the bucket - /// unauthenticated), which is orthogonal — a bucket can be public to - /// anonymous callers yet served from a private backend the proxy signs into. - pub fn apply_to(&self, config: &mut BucketConfig) { - let opts = &mut config.backend_options; - opts.insert("access_key_id".to_string(), self.access_key_id.clone()); - opts.insert( - "secret_access_key".to_string(), - self.secret_access_key.clone(), - ); - opts.insert("token".to_string(), self.session_token.clone()); - opts.remove("skip_signature"); - } -} - -impl fmt::Debug for FederatedCredentials { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("FederatedCredentials") - .field("access_key_id", &self.access_key_id) - .field("secret_access_key", &"[REDACTED]") - .field("session_token", &"[REDACTED]") - .field("expiration", &self.expiration) - .finish() - } -} diff --git a/crates/backend-federation/src/error.rs b/crates/backend-federation/src/error.rs deleted file mode 100644 index 3ebf401..0000000 --- a/crates/backend-federation/src/error.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! Errors produced while federating into a backend cloud's STS. - -use thiserror::Error; - -/// Failure modes of an outbound federation exchange. -#[derive(Debug, Error)] -pub enum FederationError { - /// The STS endpoint returned an error document instead of credentials. - /// - /// The `code`/`message` come straight from the provider (e.g. AWS - /// `InvalidIdentityToken` / "No OpenIDConnect provider found…") and are - /// the most useful signal when diagnosing a trust-policy or issuer - /// misconfiguration. - #[error("STS returned an error: {code}: {message}")] - Sts { - /// Provider error code (e.g. `InvalidIdentityToken`). - code: String, - /// Human-readable provider message. - message: String, - }, - - /// The response could not be parsed as either a success or an error - /// document. - #[error("failed to parse STS response")] - Parse(#[from] quick_xml::DeError), -} diff --git a/crates/backend-federation/src/lib.rs b/crates/backend-federation/src/lib.rs deleted file mode 100644 index 1025899..0000000 --- a/crates/backend-federation/src/lib.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! Outbound credential federation for the multistore S3 proxy gateway. -//! -//! This crate is the runtime-agnostic *outbound* STS-exchange primitive: the -//! proxy presents its own OIDC identity to a **backend cloud** and assumes a -//! role there, so it can serve data from a private bucket the operator doesn't -//! hold long-lived keys for. It is the client-side counterpart to -//! [`multistore-sts`], which is the inbound `AssumeRoleWithWebIdentity` -//! *server* (minting proxy credentials for callers presenting an OIDC token). -//! -//! This is *mechanism only* — it builds the STS request and parses the -//! response, nothing more. The minting of the proxy's OIDC assertion, the HTTP -//! transport, caching, and middleware wiring live in -//! [`multistore-oidc-provider`], which delegates its AWS exchange to this crate. -//! -//! The full flow, per backend, at bucket-resolution time: -//! -//! 1. Mint a short-lived OIDC assertion with the proxy's signing key -//! ([`multistore-oidc-provider`]), scoped via its `aud`/`sub` claims. -//! 2. Exchange it at the backend cloud's STS — for AWS, [`aws`]'s -//! [`AssumeRoleWithWebIdentity`](aws::AssumeRoleWithWebIdentity) — for -//! temporary [`FederatedCredentials`]. -//! 3. [`FederatedCredentials::apply_to`] those onto the [`BucketConfig`] so the -//! multistore backend signs requests with them instead of going anonymous. -//! -//! Steps 1 and 2's transport are the caller's responsibility; this crate owns -//! the request/response shapes of step 2 and the config injection of step 3. -//! -//! No long-lived backend secret is stored anywhere: the bucket config only -//! needs a role ARN, and the assumed credentials are short-lived and refreshed -//! before expiry. Trust and blast radius are governed by the backend role's -//! trust and permission policies, which the bucket owner controls. -//! -//! This crate is **runtime-agnostic** — it builds requests and parses -//! responses but does not perform HTTP, leaving transport to the caller (the -//! same split multistore uses elsewhere for native vs. Cloudflare Workers). -//! -//! [`multistore-sts`]: https://docs.rs/multistore-sts -//! [`multistore-oidc-provider`]: https://docs.rs/multistore-oidc-provider -//! [`BucketConfig`]: multistore::types::BucketConfig - -pub mod aws; -mod credentials; -mod error; - -pub use credentials::FederatedCredentials; -pub use error::FederationError; - -#[cfg(test)] -mod tests { - use super::*; - use chrono::{TimeZone, Utc}; - use multistore::types::BucketConfig; - use std::collections::HashMap; - - fn anon_s3_bucket() -> BucketConfig { - let mut backend_options = HashMap::new(); - backend_options.insert("bucket_name".to_string(), "my-bucket".to_string()); - backend_options.insert("region".to_string(), "us-west-2".to_string()); - backend_options.insert("skip_signature".to_string(), "true".to_string()); - BucketConfig { - name: "acct:product".to_string(), - backend_type: "s3".to_string(), - backend_prefix: None, - anonymous_access: true, - allowed_roles: vec![], - backend_options, - } - } - - #[test] - fn apply_to_signs_the_bucket() { - let creds = FederatedCredentials { - access_key_id: "ASIA123".to_string(), - secret_access_key: "secret".to_string(), - session_token: "session".to_string(), - expiration: Utc.with_ymd_and_hms(2026, 6, 3, 4, 13, 40).unwrap(), - }; - - let mut config = anon_s3_bucket(); - creds.apply_to(&mut config); - - assert_eq!(config.option("access_key_id"), Some("ASIA123")); - assert_eq!(config.option("secret_access_key"), Some("secret")); - // `token` is the alias object_store maps to the session token and that - // multistore redacts in `BucketConfig`'s Debug impl. - assert_eq!(config.option("token"), Some("session")); - // Unsigned access must be turned off so the backend signs. - assert_eq!(config.option("skip_signature"), None); - // `apply_to` governs only outbound signing; inbound `anonymous_access` - // is left as-is (the test bucket was public to anonymous callers). - assert!(config.anonymous_access); - // Untouched options remain. - assert_eq!(config.option("bucket_name"), Some("my-bucket")); - } - - #[test] - fn bucket_debug_redacts_applied_secrets() { - let creds = FederatedCredentials { - access_key_id: "ASIA123".to_string(), - secret_access_key: "super-secret".to_string(), - session_token: "super-session".to_string(), - expiration: Utc.with_ymd_and_hms(2026, 6, 3, 4, 13, 40).unwrap(), - }; - let mut config = anon_s3_bucket(); - creds.apply_to(&mut config); - - let dbg = format!("{config:?}"); - assert!(!dbg.contains("super-secret")); - assert!(!dbg.contains("super-session")); - } -} diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 8454e5a..232dfc3 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -231,6 +231,64 @@ impl fmt::Debug for TemporaryCredentials { } } +/// Short-lived credentials obtained by federating the proxy's OIDC identity +/// into a backend cloud's STS (e.g. AWS `AssumeRoleWithWebIdentity`), used to +/// sign requests to the *backend* object store. +/// +/// Distinct from [`TemporaryCredentials`], which the proxy's own STS mints for +/// *callers*: those carry the proxy's authorization model (`allowed_scopes`, +/// `assumed_role_id`, `source_identity`), whereas these carry only what an +/// object-store client needs to sign, plus the expiry so the caller can cache +/// and refresh them. +#[derive(Clone)] +pub struct FederatedCredentials { + /// Temporary access key id (AWS `ASIA…`). + pub access_key_id: String, + /// Temporary secret access key. + pub secret_access_key: String, + /// Session token that must accompany requests using these credentials. + pub session_token: String, + /// When these credentials expire. + pub expiration: DateTime, +} + +impl FederatedCredentials { + /// Inject these credentials into a [`BucketConfig`] so the multistore + /// backend signs requests with them instead of going anonymous. + /// + /// Sets the canonical S3 option keys (`access_key_id`, `secret_access_key`, + /// and `token` — the alias object_store maps to the session token and that + /// `BucketConfig`'s `Debug` redacts) and clears `skip_signature` so the + /// backend signs. + /// + /// This governs only *outbound* (backend) signing. It deliberately leaves + /// [`BucketConfig::anonymous_access`] untouched: that flag controls + /// *inbound* authorization (whether proxy callers may read the bucket + /// unauthenticated), which is orthogonal — a bucket can be public to + /// anonymous callers yet served from a private backend the proxy signs into. + pub fn apply_to(&self, config: &mut BucketConfig) { + let opts = &mut config.backend_options; + opts.insert("access_key_id".to_string(), self.access_key_id.clone()); + opts.insert( + "secret_access_key".to_string(), + self.secret_access_key.clone(), + ); + opts.insert("token".to_string(), self.session_token.clone()); + opts.remove("skip_signature"); + } +} + +impl fmt::Debug for FederatedCredentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FederatedCredentials") + .field("access_key_id", &self.access_key_id) + .field("secret_access_key", &"[REDACTED]") + .field("session_token", &"[REDACTED]") + .field("expiration", &self.expiration) + .finish() + } +} + /// The authenticated identity after credential verification. /// /// This is the output of the authentication pipeline. It contains only @@ -424,4 +482,64 @@ mod tests { assert_eq!(S3Operation::ListBuckets.key(), ""); } + + fn anon_s3_bucket() -> BucketConfig { + use std::collections::HashMap; + let mut backend_options = HashMap::new(); + backend_options.insert("bucket_name".to_string(), "my-bucket".to_string()); + backend_options.insert("region".to_string(), "us-west-2".to_string()); + backend_options.insert("skip_signature".to_string(), "true".to_string()); + BucketConfig { + name: "acct:product".to_string(), + backend_type: "s3".to_string(), + backend_prefix: None, + anonymous_access: true, + allowed_roles: vec![], + backend_options, + } + } + + #[test] + fn federated_credentials_apply_to_signs_the_bucket() { + use chrono::{TimeZone, Utc}; + let creds = FederatedCredentials { + access_key_id: "ASIA123".to_string(), + secret_access_key: "secret".to_string(), + session_token: "session".to_string(), + expiration: Utc.with_ymd_and_hms(2026, 6, 3, 4, 13, 40).unwrap(), + }; + + let mut config = anon_s3_bucket(); + creds.apply_to(&mut config); + + assert_eq!(config.option("access_key_id"), Some("ASIA123")); + assert_eq!(config.option("secret_access_key"), Some("secret")); + // `token` is the alias object_store maps to the session token and that + // multistore redacts in `BucketConfig`'s Debug impl. + assert_eq!(config.option("token"), Some("session")); + // Unsigned access must be turned off so the backend signs. + assert_eq!(config.option("skip_signature"), None); + // `apply_to` governs only outbound signing; inbound `anonymous_access` + // is left as-is (the test bucket was public to anonymous callers). + assert!(config.anonymous_access); + // Untouched options remain. + assert_eq!(config.option("bucket_name"), Some("my-bucket")); + } + + #[test] + fn federated_credentials_bucket_debug_redacts_applied_secrets() { + use chrono::{TimeZone, Utc}; + let creds = FederatedCredentials { + access_key_id: "ASIA123".to_string(), + secret_access_key: "super-secret".to_string(), + session_token: "super-session".to_string(), + expiration: Utc.with_ymd_and_hms(2026, 6, 3, 4, 13, 40).unwrap(), + }; + let mut config = anon_s3_bucket(); + creds.apply_to(&mut config); + + let dbg = format!("{config:?}"); + assert!(!dbg.contains("super-secret")); + assert!(!dbg.contains("super-session")); + } } diff --git a/crates/oidc-provider/Cargo.toml b/crates/oidc-provider/Cargo.toml index 500b44d..a6c311b 100644 --- a/crates/oidc-provider/Cargo.toml +++ b/crates/oidc-provider/Cargo.toml @@ -12,11 +12,11 @@ gcp = [] [dependencies] multistore.workspace = true -multistore-backend-federation.workspace = true async-trait.workspace = true thiserror.workspace = true serde.workspace = true serde_json.workspace = true +quick-xml.workspace = true chrono.workspace = true base64.workspace = true rsa.workspace = true diff --git a/crates/oidc-provider/src/backend_auth.rs b/crates/oidc-provider/src/backend_auth.rs index 338dadf..0b27a96 100644 --- a/crates/oidc-provider/src/backend_auth.rs +++ b/crates/oidc-provider/src/backend_auth.rs @@ -47,11 +47,11 @@ impl AwsBackendAuth { .get_credentials(role_arn, &exchange, subject, &[]) .await?; - // Inject the temporary credentials through the canonical primitive in - // `multistore-backend-federation`, so the option-key set and the - // `skip_signature` clearing stay single-sourced there rather than being - // re-hand-rolled here (the previous inline version forgot to clear - // `skip_signature`). + // Inject the temporary credentials via `FederatedCredentials::apply_to` + // (defined in `multistore` core), so the option-key set and the + // `skip_signature` clearing stay single-sourced on the credential type + // rather than being re-hand-rolled here (the previous inline version + // forgot to clear `skip_signature`). let mut resolved = config.clone(); creds.apply_to(&mut resolved); let options = &mut resolved.backend_options; diff --git a/crates/oidc-provider/src/exchange/aws.rs b/crates/oidc-provider/src/exchange/aws.rs index 81e422a..dda8653 100644 --- a/crates/oidc-provider/src/exchange/aws.rs +++ b/crates/oidc-provider/src/exchange/aws.rs @@ -1,9 +1,20 @@ //! AWS STS `AssumeRoleWithWebIdentity` credential exchange. +//! +//! This module owns both the runtime-agnostic request/response *mechanism* +//! (build the `AssumeRoleWithWebIdentity` form, parse the XML reply) and the +//! [`AwsExchange`] that drives it over the caller's HTTP transport. The +//! mechanism performs no HTTP itself — multistore deployments differ in their +//! HTTP stack (reqwest on native, `web_sys::fetch` on Cloudflare Workers), so +//! the transport stays with the caller. -use crate::{FederatedCredentials, HttpExchange, OidcProviderError}; +use std::borrow::Cow; + +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use thiserror::Error; use super::CredentialExchange; -use multistore_backend_federation::aws::{parse_response, AssumeRoleWithWebIdentity}; +use crate::{FederatedCredentials, HttpExchange, OidcProviderError}; /// Configuration for exchanging a JWT for AWS credentials. #[derive(Debug, Clone)] @@ -77,11 +88,9 @@ impl CredentialExchange for AwsExchange { http: &H, jwt: &str, ) -> Result { - // Build the request with the canonical `multistore-backend-federation` - // primitive, hand its (unencoded) pairs to the runtime's HTTP client — - // which form-urlencodes them — then parse the reply with the same crate. - // The parsed `FederatedCredentials` flow through unchanged: this crate no - // longer keeps a second credential type to convert into. + // Build the request with this module's `AssumeRoleWithWebIdentity`, hand + // its (unencoded) pairs to the runtime's HTTP client — which + // form-urlencodes them — then parse the reply. let request = AssumeRoleWithWebIdentity { role_arn: &self.role_arn, web_identity_token: jwt, @@ -98,3 +107,263 @@ impl CredentialExchange for AwsExchange { Ok(parse_response(&body)?) } } + +// ── AWS STS request/response mechanism ────────────────────────────────────── +// Runtime-agnostic: builds the request (URL + form body) and parses the XML +// reply, but performs no HTTP itself — `AwsExchange` (above) owns the transport. + +/// Failure modes of parsing an `AssumeRoleWithWebIdentity` reply. +/// +/// Mapped to [`OidcProviderError`](crate::OidcProviderError) at the crate root +/// (`Sts` → `StsError`, `Parse` → `ExchangeError`). +#[derive(Debug, Error)] +pub(crate) enum FederationError { + /// The STS endpoint returned an error document instead of credentials. + /// + /// The `code`/`message` come straight from the provider (e.g. AWS + /// `InvalidIdentityToken` / "No OpenIDConnect provider found…") and are the + /// most useful signal when diagnosing a trust-policy or issuer + /// misconfiguration. + #[error("STS returned an error: {code}: {message}")] + Sts { + /// Provider error code (e.g. `InvalidIdentityToken`). + code: String, + /// Human-readable provider message. + message: String, + }, + + /// The response could not be parsed as either a success or an error document. + #[error("failed to parse STS response")] + Parse(#[from] quick_xml::DeError), +} + +/// Parameters for an `AssumeRoleWithWebIdentity` request. +/// +/// The web identity token is the OIDC assertion minted by the proxy; the role's +/// trust policy must trust the proxy's issuer and may condition on the token's +/// `aud`/`sub`. +#[derive(Debug, Clone)] +pub(crate) struct AssumeRoleWithWebIdentity<'a> { + /// ARN of the role to assume. + pub role_arn: &'a str, + /// The OIDC token presented as the web identity. + pub web_identity_token: &'a str, + /// Session name recorded in CloudTrail for this assumption. + pub role_session_name: &'a str, + /// Requested credential lifetime, in seconds. `None` omits `DurationSeconds` + /// so AWS applies the role's default (3600s); when set, AWS clamps to the + /// role's maximum and rejects values below 900. + pub duration_seconds: Option, + /// Optional inline session policy (further restricts the session, e.g. to a + /// key prefix) — `None` to use the role's permissions as-is. + pub session_policy: Option<&'a str>, +} + +impl<'a> AssumeRoleWithWebIdentity<'a> { + /// The request as form key/value pairs, with values **unencoded**. + /// + /// Use this when the HTTP layer performs its own form-urlencoding (e.g. + /// reqwest's `.form(...)`); feeding it a pre-encoded [`body`](Self::body) + /// instead would double-encode the values. + pub fn form_pairs(&self) -> Vec<(&'static str, Cow<'a, str>)> { + let mut pairs = vec![ + ("Action", Cow::Borrowed("AssumeRoleWithWebIdentity")), + ("Version", Cow::Borrowed("2011-06-15")), + ("RoleArn", Cow::Borrowed(self.role_arn)), + ("RoleSessionName", Cow::Borrowed(self.role_session_name)), + ("WebIdentityToken", Cow::Borrowed(self.web_identity_token)), + ]; + if let Some(duration) = self.duration_seconds { + pairs.push(("DurationSeconds", Cow::Owned(duration.to_string()))); + } + if let Some(policy) = self.session_policy { + pairs.push(("Policy", Cow::Borrowed(policy))); + } + pairs + } +} + +/// Parse the body of an `AssumeRoleWithWebIdentity` response into credentials. +/// +/// AWS returns the same XML shape regardless of HTTP status on the error path +/// (an `` document), so this inspects the body rather than +/// relying on the status code: an error document becomes +/// [`FederationError::Sts`] carrying the provider's code and message. +pub(crate) fn parse_response(xml: &str) -> Result { + if xml.contains(", +} + +#[derive(Deserialize)] +struct ErrorResponse { + #[serde(rename = "Error")] + error: ErrorDetail, +} + +#[derive(Deserialize)] +struct ErrorDetail { + #[serde(rename = "Code")] + code: String, + #[serde(rename = "Message")] + message: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + const SUCCESS: &str = r#" + + + scv1:conn:test + source-coop-data-proxy + + arn:aws:sts::123456789012:assumed-role/my-role/multistore + AROAEXAMPLE:multistore + + + ASIAEXAMPLE + secret/+key + sess+tok/en== + 2026-06-03T04:13:40Z + + https://data.source.coop + + + 11111111-2222-3333-4444-555555555555 + +"#; + + const ERROR: &str = r#" + + + Sender + InvalidIdentityToken + No OpenIDConnect provider found in your account for https://data.source.coop + + aaaa +"#; + + #[test] + fn parses_credentials() { + let creds = parse_response(SUCCESS).expect("should parse"); + assert_eq!(creds.access_key_id, "ASIAEXAMPLE"); + assert_eq!(creds.secret_access_key, "secret/+key"); + assert_eq!(creds.session_token, "sess+tok/en=="); + assert_eq!(creds.expiration.to_rfc3339(), "2026-06-03T04:13:40+00:00"); + } + + #[test] + fn surfaces_sts_error_as_typed_error() { + match parse_response(ERROR) { + Err(FederationError::Sts { code, message }) => { + assert_eq!(code, "InvalidIdentityToken"); + assert!(message.contains("No OpenIDConnect provider")); + } + other => panic!("expected Sts error, got {other:?}"), + } + } + + #[test] + fn debug_redacts_secrets() { + let creds = parse_response(SUCCESS).unwrap(); + let dbg = format!("{creds:?}"); + assert!(dbg.contains("ASIAEXAMPLE")); + assert!(!dbg.contains("secret/+key")); + assert!(!dbg.contains("sess+tok/en")); + assert!(dbg.contains("[REDACTED]")); + } + + #[test] + fn form_pairs_includes_duration_and_policy_when_set() { + let req = AssumeRoleWithWebIdentity { + role_arn: "arn:aws:iam::1:role/r", + web_identity_token: "t", + role_session_name: "s", + duration_seconds: Some(900), + session_policy: Some("{\"Version\":\"2012-10-17\"}"), + }; + let pairs = req.form_pairs(); + assert!(pairs + .iter() + .any(|(k, v)| *k == "DurationSeconds" && v.as_ref() == "900")); + assert!(pairs.iter().any(|(k, _)| *k == "Policy")); + } + + #[test] + fn form_pairs_omit_duration_and_policy_when_unset() { + let req = AssumeRoleWithWebIdentity { + role_arn: "arn:aws:iam::1:role/r", + web_identity_token: "t", + role_session_name: "s", + duration_seconds: None, + session_policy: None, + }; + let pairs = req.form_pairs(); + assert!(pairs.iter().all(|(k, _)| *k != "DurationSeconds")); + assert!(pairs.iter().all(|(k, _)| *k != "Policy")); + } + + #[test] + fn form_pairs_are_unencoded() { + let req = AssumeRoleWithWebIdentity { + role_arn: "arn:aws:iam::123456789012:role/my-role", + web_identity_token: "tok", + role_session_name: "multistore", + duration_seconds: Some(3600), + session_policy: None, + }; + let pairs = req.form_pairs(); + // Values are raw (the caller's HTTP layer encodes them) — note the `:`/`/` + // are NOT percent-encoded here, unlike in `body()`. + assert!(pairs.iter().any( + |(k, v)| *k == "RoleArn" && v.as_ref() == "arn:aws:iam::123456789012:role/my-role" + )); + assert!(pairs + .iter() + .any(|(k, v)| *k == "DurationSeconds" && v.as_ref() == "3600")); + assert!(pairs.iter().all(|(k, _)| *k != "Policy")); + } +} diff --git a/crates/oidc-provider/src/lib.rs b/crates/oidc-provider/src/lib.rs index 7d30bb6..6cea3a3 100644 --- a/crates/oidc-provider/src/lib.rs +++ b/crates/oidc-provider/src/lib.rs @@ -30,13 +30,14 @@ use jwt::JwtSigner; /// The backend credential value type — its fields, secret-redacting `Debug`, and /// `BucketConfig` injection ([`FederatedCredentials::apply_to`]) — is owned by -/// `multistore-backend-federation`, the outbound-exchange mechanism. It is -/// re-exported here so this crate's exchange and caching APIs speak that one -/// type; there is deliberately no parallel `CloudCredentials`. +/// `multistore` core (next to the `BucketConfig` it injects into, and its +/// sibling `TemporaryCredentials`). It is re-exported here so this crate is the +/// single front door: callers import the type from `multistore-oidc-provider` +/// and need not name core's `types` module. /// /// Bearer-only backends (Azure/GCP) leave `access_key_id`/`secret_access_key` /// empty and carry the token in `session_token`. -pub use multistore_backend_federation::FederatedCredentials; +pub use multistore::types::FederatedCredentials; /// HTTP client abstraction for outbound requests (STS token exchange). /// @@ -146,9 +147,9 @@ pub enum OidcProviderError { HttpError(String), } -impl From for OidcProviderError { - fn from(e: multistore_backend_federation::FederationError) -> Self { - use multistore_backend_federation::FederationError as F; +impl From for OidcProviderError { + fn from(e: crate::exchange::aws::FederationError) -> Self { + use crate::exchange::aws::FederationError as F; match e { F::Sts { code, message } => OidcProviderError::StsError { code, message }, F::Parse(e) => OidcProviderError::ExchangeError(e.to_string()), diff --git a/examples/cf-workers/wrangler.toml b/examples/cf-workers/wrangler.toml index 21533cd..9bfc6b5 100644 --- a/examples/cf-workers/wrangler.toml +++ b/examples/cf-workers/wrangler.toml @@ -38,9 +38,9 @@ bucket_name = "private-uploads" endpoint = "http://localhost:9000" secret_access_key = "minioadmin" -# Outbound backend federation (multistore-backend-federation via -# multistore-oidc-provider): instead of long-lived backend keys, the proxy mints -# its own OIDC assertion and assumes an AWS IAM role at request time +# Outbound backend federation (via multistore-oidc-provider): instead of +# long-lived backend keys, the proxy mints its own OIDC assertion and assumes +# an AWS IAM role at request time # (AssumeRoleWithWebIdentity), then signs backend requests with the resulting # temporary credentials. # @@ -59,7 +59,7 @@ name = "federated-private" [vars.PROXY_CONFIG.buckets.backend_options] auth_type = "oidc" -oidc_role_arn = "arn:aws:iam::123456789012:role/multistore-backend-federation" +oidc_role_arn = "arn:aws:iam::123456789012:role/multistore-federated-private" oidc_subject = "multistore" bucket_name = "my-private-bucket" endpoint = "https://s3.us-east-1.amazonaws.com" diff --git a/release-please-config.json b/release-please-config.json index b637447..a9275d7 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -44,11 +44,6 @@ "type": "toml", "path": "Cargo.toml", "jsonpath": "$.workspace.dependencies.multistore-path-mapping.version" - }, - { - "type": "toml", - "path": "Cargo.toml", - "jsonpath": "$.workspace.dependencies.multistore-backend-federation.version" } ] } diff --git a/tests/smoke/test_federation.py b/tests/smoke/test_federation.py index 8faf6f9..bd6179c 100644 --- a/tests/smoke/test_federation.py +++ b/tests/smoke/test_federation.py @@ -1,4 +1,4 @@ -"""Full backend-federation smoke test against the deployed proxy. +"""Full federation smoke test against the deployed proxy. The `federated-test` bucket (see examples/cf-workers/wrangler.deploy.toml) is configured with `auth_type=oidc`: at request time the proxy mints its own OIDC From 7fe2c76d135da9680767bbb1b757758fbdf4c70a Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 4 Jun 2026 13:23:41 -0700 Subject: [PATCH 3/4] docs(crate-layout): reflect FederatedCredentials in core and STS exchange in oidc-provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crate-layout page predates `backend-federation` and already describes `oidc-provider` owning the AWS exchange and depending only on core, so this refactor needs no removals — just two clarifications matching its outcome: - Name `FederatedCredentials` among core's type definitions (it now lives in core, next to `TemporaryCredentials`). - Note that `oidc-provider` owns the `AssumeRoleWithWebIdentity` request/parse mechanism itself, not only the `AwsBackendAuth` middleware. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/crate-layout.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture/crate-layout.md b/docs/architecture/crate-layout.md index 5a76cd7..fb4cf0b 100644 --- a/docs/architecture/crate-layout.md +++ b/docs/architecture/crate-layout.md @@ -33,7 +33,7 @@ The runtime-agnostic core. Contains: - S3 request parsing, XML response building, list prefix rewriting - SigV4 signature verification - Sealed session token encryption/decryption -- Type definitions (`BucketConfig`, `RoleConfig`, `AccessScope`, etc.) +- Type definitions (`BucketConfig`, `RoleConfig`, `AccessScope`, `TemporaryCredentials`, `FederatedCredentials`, etc.) — including `FederatedCredentials`, the backend credential value type the outbound exchange produces and injects into a `BucketConfig` **Feature flags:** - `azure` — Azure Blob Storage support @@ -64,7 +64,7 @@ Outbound OIDC identity provider for backend authentication: - RSA JWT signing (`JwtSigner`) - JWKS endpoint serving - OpenID Connect discovery document -- AWS credential exchange (`AwsBackendAuth` middleware) +- AWS STS credential exchange — the `AssumeRoleWithWebIdentity` request build + XML parse, plus the `AwsBackendAuth` middleware that drives it - Credential caching ### `multistore-static-config` From 82bed28bc03d4b59dae1a8cd25f5f006afecd074 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 4 Jun 2026 14:01:12 -0700 Subject: [PATCH 4/4] refactor(core): rename FederatedCredentials -> BackendCredentials Names the type by what it is for (signing backend object-store requests), matching core's backend_* vocabulary (backend_type / backend_options / backend_prefix) and the apply_to(&mut BucketConfig) -> backend_options flow. "Federated" described how the creds are obtained, but that does not distinguish them from the sibling TemporaryCredentials, which are also a product of AssumeRoleWithWebIdentity (inbound, minted for callers); the axis that actually differs is purpose -- backend-facing vs caller-facing -- which this name names. Pure rename across multistore core + oidc-provider (which re-exports it) and the crate-layout doc. Tests unchanged: core 74, oidc-provider 29 / 37 (azure,gcp). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/core/src/types.rs | 16 ++++++++-------- crates/oidc-provider/src/backend_auth.rs | 2 +- crates/oidc-provider/src/cache.rs | 14 +++++++------- crates/oidc-provider/src/exchange/aws.rs | 8 ++++---- crates/oidc-provider/src/exchange/azure.rs | 10 +++++----- crates/oidc-provider/src/exchange/gcp.rs | 10 +++++----- crates/oidc-provider/src/exchange/mod.rs | 4 ++-- crates/oidc-provider/src/lib.rs | 8 ++++---- docs/architecture/crate-layout.md | 2 +- 9 files changed, 37 insertions(+), 37 deletions(-) diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 232dfc3..64a33e6 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -241,7 +241,7 @@ impl fmt::Debug for TemporaryCredentials { /// object-store client needs to sign, plus the expiry so the caller can cache /// and refresh them. #[derive(Clone)] -pub struct FederatedCredentials { +pub struct BackendCredentials { /// Temporary access key id (AWS `ASIA…`). pub access_key_id: String, /// Temporary secret access key. @@ -252,7 +252,7 @@ pub struct FederatedCredentials { pub expiration: DateTime, } -impl FederatedCredentials { +impl BackendCredentials { /// Inject these credentials into a [`BucketConfig`] so the multistore /// backend signs requests with them instead of going anonymous. /// @@ -278,9 +278,9 @@ impl FederatedCredentials { } } -impl fmt::Debug for FederatedCredentials { +impl fmt::Debug for BackendCredentials { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("FederatedCredentials") + f.debug_struct("BackendCredentials") .field("access_key_id", &self.access_key_id) .field("secret_access_key", &"[REDACTED]") .field("session_token", &"[REDACTED]") @@ -500,9 +500,9 @@ mod tests { } #[test] - fn federated_credentials_apply_to_signs_the_bucket() { + fn backend_credentials_apply_to_signs_the_bucket() { use chrono::{TimeZone, Utc}; - let creds = FederatedCredentials { + let creds = BackendCredentials { access_key_id: "ASIA123".to_string(), secret_access_key: "secret".to_string(), session_token: "session".to_string(), @@ -527,9 +527,9 @@ mod tests { } #[test] - fn federated_credentials_bucket_debug_redacts_applied_secrets() { + fn backend_credentials_bucket_debug_redacts_applied_secrets() { use chrono::{TimeZone, Utc}; - let creds = FederatedCredentials { + let creds = BackendCredentials { access_key_id: "ASIA123".to_string(), secret_access_key: "super-secret".to_string(), session_token: "super-session".to_string(), diff --git a/crates/oidc-provider/src/backend_auth.rs b/crates/oidc-provider/src/backend_auth.rs index 0b27a96..c100271 100644 --- a/crates/oidc-provider/src/backend_auth.rs +++ b/crates/oidc-provider/src/backend_auth.rs @@ -47,7 +47,7 @@ impl AwsBackendAuth { .get_credentials(role_arn, &exchange, subject, &[]) .await?; - // Inject the temporary credentials via `FederatedCredentials::apply_to` + // Inject the temporary credentials via `BackendCredentials::apply_to` // (defined in `multistore` core), so the option-key set and the // `skip_signature` clearing stay single-sourced on the credential type // rather than being re-hand-rolled here (the previous inline version diff --git a/crates/oidc-provider/src/cache.rs b/crates/oidc-provider/src/cache.rs index 4b39c35..9ef156a 100644 --- a/crates/oidc-provider/src/cache.rs +++ b/crates/oidc-provider/src/cache.rs @@ -1,6 +1,6 @@ //! TTL credential cache. //! -//! Caches [`FederatedCredentials`] by key, evicting entries that are within a +//! Caches [`BackendCredentials`] by key, evicting entries that are within a //! safety margin of expiration. This avoids redundant STS calls when the //! same backend is accessed repeatedly within a short window. @@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex}; use chrono::{Duration, Utc}; -use crate::FederatedCredentials; +use crate::BackendCredentials; /// Safety margin before expiration — credentials are considered expired /// this many seconds before their actual `expires_at`. @@ -17,7 +17,7 @@ const EXPIRY_MARGIN_SECS: i64 = 60; /// Thread-safe TTL cache for cloud credentials. pub struct CredentialCache { - entries: Mutex>>, + entries: Mutex>>, } impl Default for CredentialCache { @@ -35,7 +35,7 @@ impl CredentialCache { } /// Retrieve cached credentials if they are still valid. - pub fn get(&self, key: &str) -> Option> { + pub fn get(&self, key: &str) -> Option> { let entries = self.entries.lock().unwrap(); if let Some(creds) = entries.get(key) { let margin = Duration::seconds(EXPIRY_MARGIN_SECS); @@ -47,7 +47,7 @@ impl CredentialCache { } /// Store credentials in the cache. - pub fn put(&self, key: String, creds: Arc) { + pub fn put(&self, key: String, creds: Arc) { let mut entries = self.entries.lock().unwrap(); entries.insert(key, creds); } @@ -57,8 +57,8 @@ impl CredentialCache { mod tests { use super::*; - fn make_creds(expires_in_secs: i64) -> FederatedCredentials { - FederatedCredentials { + fn make_creds(expires_in_secs: i64) -> BackendCredentials { + BackendCredentials { access_key_id: "AKID".into(), secret_access_key: "secret".into(), session_token: "token".into(), diff --git a/crates/oidc-provider/src/exchange/aws.rs b/crates/oidc-provider/src/exchange/aws.rs index dda8653..821a4b7 100644 --- a/crates/oidc-provider/src/exchange/aws.rs +++ b/crates/oidc-provider/src/exchange/aws.rs @@ -14,7 +14,7 @@ use serde::Deserialize; use thiserror::Error; use super::CredentialExchange; -use crate::{FederatedCredentials, HttpExchange, OidcProviderError}; +use crate::{BackendCredentials, HttpExchange, OidcProviderError}; /// Configuration for exchanging a JWT for AWS credentials. #[derive(Debug, Clone)] @@ -87,7 +87,7 @@ impl CredentialExchange for AwsExchange { &self, http: &H, jwt: &str, - ) -> Result { + ) -> Result { // Build the request with this module's `AssumeRoleWithWebIdentity`, hand // its (unencoded) pairs to the runtime's HTTP client — which // form-urlencodes them — then parse the reply. @@ -189,7 +189,7 @@ impl<'a> AssumeRoleWithWebIdentity<'a> { /// (an `` document), so this inspects the body rather than /// relying on the status code: an error document becomes /// [`FederationError::Sts`] carrying the provider's code and message. -pub(crate) fn parse_response(xml: &str) -> Result { +pub(crate) fn parse_response(xml: &str) -> Result { if xml.contains(" Result CredentialExchange for AzureExchange { &self, http: &H, jwt: &str, - ) -> Result { + ) -> Result { let form = [ ("grant_type", "client_credentials"), ( @@ -72,7 +72,7 @@ impl CredentialExchange for AzureExchange { } /// Parse an Azure AD token response. -fn parse_azure_token_response(json: &str) -> Result { +fn parse_azure_token_response(json: &str) -> Result { let parsed: serde_json::Value = serde_json::from_str(json).map_err(|e| { OidcProviderError::ExchangeError(format!("invalid Azure token response: {e}")) })?; @@ -100,7 +100,7 @@ fn parse_azure_token_response(json: &str) -> Result CredentialExchange for GcpExchange { &self, http: &H, jwt: &str, - ) -> Result { + ) -> Result { // Step 1: Exchange JWT for federated access token via GCP STS let sts_form = [ ( @@ -128,7 +128,7 @@ fn parse_sts_token_response(json: &str) -> Result { /// Parse the IAM `generateAccessToken` response. fn parse_generate_access_token_response( json: &str, -) -> Result { +) -> Result { let parsed: serde_json::Value = serde_json::from_str(json).map_err(|e| { OidcProviderError::ExchangeError(format!("invalid generateAccessToken response: {e}")) })?; @@ -146,7 +146,7 @@ fn parse_generate_access_token_response( .with_timezone(&chrono::Utc); // GCP returns a bearer token; same pattern as Azure. - Ok(FederatedCredentials { + Ok(BackendCredentials { access_key_id: String::new(), secret_access_key: String::new(), session_token: access_token.to_string(), diff --git a/crates/oidc-provider/src/exchange/mod.rs b/crates/oidc-provider/src/exchange/mod.rs index d149ff8..8c7ff71 100644 --- a/crates/oidc-provider/src/exchange/mod.rs +++ b/crates/oidc-provider/src/exchange/mod.rs @@ -6,7 +6,7 @@ pub mod azure; #[cfg(feature = "gcp")] pub mod gcp; -use crate::{FederatedCredentials, HttpExchange, OidcProviderError}; +use crate::{BackendCredentials, HttpExchange, OidcProviderError}; /// Trait for exchanging a self-signed JWT for cloud provider credentials. /// @@ -22,6 +22,6 @@ pub trait CredentialExchange: &self, http: &H, jwt: &str, - ) -> impl std::future::Future> + ) -> impl std::future::Future> + multistore::maybe_send::MaybeSend; } diff --git a/crates/oidc-provider/src/lib.rs b/crates/oidc-provider/src/lib.rs index 6cea3a3..18d642d 100644 --- a/crates/oidc-provider/src/lib.rs +++ b/crates/oidc-provider/src/lib.rs @@ -29,7 +29,7 @@ use exchange::CredentialExchange; use jwt::JwtSigner; /// The backend credential value type — its fields, secret-redacting `Debug`, and -/// `BucketConfig` injection ([`FederatedCredentials::apply_to`]) — is owned by +/// `BucketConfig` injection ([`BackendCredentials::apply_to`]) — is owned by /// `multistore` core (next to the `BucketConfig` it injects into, and its /// sibling `TemporaryCredentials`). It is re-exported here so this crate is the /// single front door: callers import the type from `multistore-oidc-provider` @@ -37,7 +37,7 @@ use jwt::JwtSigner; /// /// Bearer-only backends (Azure/GCP) leave `access_key_id`/`secret_access_key` /// empty and carry the token in `session_token`. -pub use multistore::types::FederatedCredentials; +pub use multistore::types::BackendCredentials; /// HTTP client abstraction for outbound requests (STS token exchange). /// @@ -92,7 +92,7 @@ impl OidcCredentialProvider { exchange: &E, subject: &str, extra_claims: &[(&str, &str)], - ) -> Result, OidcProviderError> { + ) -> Result, OidcProviderError> { // Check cache first if let Some(creds) = self.cache.get(cache_key) { return Ok(creds); @@ -104,7 +104,7 @@ impl OidcCredentialProvider { .sign(subject, &self.issuer, &self.audience, extra_claims)?; // Exchange it for cloud credentials - let creds: FederatedCredentials = exchange.exchange(&self.http, &token).await?; + let creds: BackendCredentials = exchange.exchange(&self.http, &token).await?; let creds = Arc::new(creds); // Cache diff --git a/docs/architecture/crate-layout.md b/docs/architecture/crate-layout.md index fb4cf0b..8843898 100644 --- a/docs/architecture/crate-layout.md +++ b/docs/architecture/crate-layout.md @@ -33,7 +33,7 @@ The runtime-agnostic core. Contains: - S3 request parsing, XML response building, list prefix rewriting - SigV4 signature verification - Sealed session token encryption/decryption -- Type definitions (`BucketConfig`, `RoleConfig`, `AccessScope`, `TemporaryCredentials`, `FederatedCredentials`, etc.) — including `FederatedCredentials`, the backend credential value type the outbound exchange produces and injects into a `BucketConfig` +- Type definitions (`BucketConfig`, `RoleConfig`, `AccessScope`, `TemporaryCredentials`, `BackendCredentials`, etc.) — including `BackendCredentials`, the backend credential value type the outbound exchange produces and injects into a `BucketConfig` **Feature flags:** - `azure` — Azure Blob Storage support