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
87 changes: 83 additions & 4 deletions crates/oidc-provider/src/backend_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ impl<H: HttpExchange> AwsBackendAuth<H> {
)
})?;
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
Expand All @@ -48,10 +50,18 @@ impl<H: HttpExchange> AwsBackendAuth<H> {
// `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
Expand All @@ -66,6 +76,7 @@ impl<H: HttpExchange> AwsBackendAuth<H> {
options.remove("auth_type");
options.remove("oidc_role_arn");
options.remove("oidc_subject");
options.remove("oidc_audience");

Ok(resolved.backend_options)
}
Expand Down Expand Up @@ -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();
Expand Down
34 changes: 30 additions & 4 deletions crates/oidc-provider/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,38 @@ impl<H: HttpExchange> OidcCredentialProvider<H> {
subject: &str,
extra_claims: &[(&str, &str)],
) -> Result<Arc<BackendCredentials>, 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<E: CredentialExchange<H>>(
&self,
cache_key: &str,
exchange: &E,
subject: &str,
audience: &str,
extra_claims: &[(&str, &str)],
) -> Result<Arc<BackendCredentials>, 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))
})
Expand Down
Loading