diff --git a/crates/oidc-provider/src/backend_auth.rs b/crates/oidc-provider/src/backend_auth.rs index 77ab561..fe798d6 100644 --- a/crates/oidc-provider/src/backend_auth.rs +++ b/crates/oidc-provider/src/backend_auth.rs @@ -40,6 +40,8 @@ impl AwsBackendAuth { ) })?; let subject = config.option("oidc_subject").unwrap_or("s3-proxy"); + // Optional per-bucket `aud`; without it the provider's default applies. + let audience = config.option("oidc_audience"); // Name the assumed-role session after the subject so the caller's // CloudTrail attributes each `AssumeRoleWithWebIdentity` to the @@ -48,10 +50,18 @@ impl AwsBackendAuth { // `sub`, so sanitize before use. let mut exchange = AwsExchange::new(role_arn.to_string()); exchange.session_name = sts_session_name(subject); - let creds = self - .provider - .get_credentials(role_arn, &exchange, subject, &[]) - .await?; + let creds = match audience { + Some(aud) => { + self.provider + .get_credentials_with_audience(role_arn, &exchange, subject, aud, &[]) + .await? + } + None => { + self.provider + .get_credentials(role_arn, &exchange, subject, &[]) + .await? + } + }; // Inject the temporary credentials via `BackendCredentials::apply_to` // (defined in `multistore` core), so the option-key set and the @@ -66,6 +76,7 @@ impl AwsBackendAuth { options.remove("auth_type"); options.remove("oidc_role_arn"); options.remove("oidc_subject"); + options.remove("oidc_audience"); Ok(resolved.backend_options) } @@ -305,6 +316,74 @@ mod tests { assert_eq!(session.as_deref(), Some("scv1_conn_abc-1")); } + /// The `aud` claim of the JWT in the last STS request. + fn sent_audience(http: &MockHttp) -> String { + use base64::Engine; + let form = http.last_form.lock().unwrap(); + let token = &form + .iter() + .find(|(k, _)| k == "WebIdentityToken") + .unwrap() + .1; + let payload = token.split('.').nth(1).unwrap(); + let claims: serde_json::Value = serde_json::from_slice( + &base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .unwrap(), + ) + .unwrap(); + claims["aud"].as_str().unwrap().to_string() + } + + #[tokio::test] + async fn oidc_audience_sets_aud_and_is_stripped() { + let http = MockHttp::new(); + let provider = OidcCredentialProvider::new( + test_signer(), + http.clone(), + "https://issuer.example.com".into(), + "sts.amazonaws.com".into(), + ); + let auth = AwsBackendAuth::new(provider); + + auth.resolve_credentials(&oidc_bucket_config()) + .await + .unwrap(); + assert_eq!(sent_audience(&http), "sts.amazonaws.com"); + + let mut config = oidc_bucket_config(); + config + .backend_options + .insert("oidc_audience".into(), "scv1:conn:abc-1".into()); + let resolved = auth.resolve_credentials(&config).await.unwrap().unwrap(); + assert_eq!(sent_audience(&http), "scv1:conn:abc-1"); + assert!(!resolved.contains_key("oidc_audience")); + } + + #[tokio::test] + async fn audiences_do_not_share_cached_credentials() { + let http = MockHttp::new(); + let provider = OidcCredentialProvider::new( + test_signer(), + http.clone(), + "https://issuer.example.com".into(), + "sts.amazonaws.com".into(), + ); + let auth = AwsBackendAuth::new(provider); + let mut config = oidc_bucket_config(); + + auth.resolve_credentials(&config).await.unwrap(); + auth.resolve_credentials(&config).await.unwrap(); + assert_eq!(http.call_count.load(Ordering::SeqCst), 1); + + // Same role and subject, another audience: a fresh exchange. + config + .backend_options + .insert("oidc_audience".into(), "scv1:conn:abc-1".into()); + auth.resolve_credentials(&config).await.unwrap(); + assert_eq!(http.call_count.load(Ordering::SeqCst), 2); + } + #[tokio::test] async fn resolve_passes_through_static_bucket() { let http = MockHttp::new(); diff --git a/crates/oidc-provider/src/lib.rs b/crates/oidc-provider/src/lib.rs index d531f6f..58dd474 100644 --- a/crates/oidc-provider/src/lib.rs +++ b/crates/oidc-provider/src/lib.rs @@ -105,12 +105,38 @@ impl OidcCredentialProvider { subject: &str, extra_claims: &[(&str, &str)], ) -> Result, OidcProviderError> { + self.get_credentials_with_audience( + cache_key, + exchange, + subject, + &self.audience, + extra_claims, + ) + .await + } + + /// [`get_credentials`](Self::get_credentials), with the JWT's `aud` set to + /// `audience` instead of the provider's default. + /// + /// The cache entry is scoped by `audience` as well as `subject`: a cloud + /// checks `aud` against the client IDs registered for the issuer before it + /// evaluates any trust policy, so a credential minted under one audience + /// must never stand in for an exchange under another. + pub async fn get_credentials_with_audience>( + &self, + cache_key: &str, + exchange: &E, + subject: &str, + audience: &str, + extra_claims: &[(&str, &str)], + ) -> Result, OidcProviderError> { + let key = cache::scoped_key(&cache::scoped_key(cache_key, subject), audience); self.cache - .get_or_fetch(&cache::scoped_key(cache_key, subject), || async { + .get_or_fetch(&key, || async { // Cache miss (or due for refresh): mint a JWT and exchange it. - let token = - self.signer - .sign(subject, &self.issuer, &self.audience, extra_claims)?; + let token = self + .signer + .sign(subject, &self.issuer, audience, extra_claims)?; let creds: BackendCredentials = exchange.exchange(&self.http, &token).await?; Ok(Arc::new(creds)) })