Skip to content

Commit acfe95b

Browse files
alukachclaude
andcommitted
fix(oidc): key credential cache on (cache_key, subject, extra_claims)
The credential cache keyed on `cache_key` alone (the role ARN). But the backend's authorization gate — an AWS role trust policy, or the Azure/GCP equivalent — conditions on the *minted assertion* (its `subject` and any `extra_claims`) and is evaluated at mint time, inside the exchange. A cache hit skips the mint, so it skips that gate: two subjects sharing a role would share a cached credential, letting the second subject ride on credentials the trust policy might have denied it. `get_credentials` already receives `subject` and `extra_claims`, so fold them into the effective key. Doing it here (not at the call site) closes the footgun for every caller — none can forget to scope by identity. Length-prefixed framing keeps the key unambiguous so no crafted subject/ARN can forge another tuple's key. Cache granularity becomes per-(backend, identity), which is the correct scope — credentials already are per-subject — so hit rate is unaffected in practice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a3a5552 commit acfe95b

2 files changed

Lines changed: 100 additions & 14 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/oidc-provider/src/lib.rs

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,19 @@ impl<H: HttpExchange> OidcCredentialProvider<H> {
8989
/// Get credentials for a backend, using cached values when available.
9090
///
9191
/// `exchange` describes how to trade the self-signed JWT for cloud
92-
/// credentials (AWS, Azure, GCP). `cache_key` identifies the backend for
93-
/// caching purposes (e.g. the role ARN).
92+
/// credentials (AWS, Azure, GCP). `cache_key` identifies the backend (e.g.
93+
/// the role ARN).
9494
///
95-
/// Concurrent calls for the same `cache_key` are single-flighted: only one
95+
/// Credentials are cached per `(cache_key, subject, extra_claims)`, **not**
96+
/// per `cache_key` alone. The backend's authorization gate (an AWS role trust
97+
/// policy, an Azure/GCP equivalent) conditions on the *minted assertion* —
98+
/// its `subject` and any `extra_claims` — and is evaluated at mint time. A
99+
/// cache hit skips the mint, so it skips that gate; keying on `cache_key`
100+
/// alone would let a credential minted for one subject be served to a
101+
/// different subject that shares the backend but that the trust policy would
102+
/// reject. So every input the gate sees is part of the key.
103+
///
104+
/// Concurrent calls for the same effective key are single-flighted: only one
96105
/// JWT mint + exchange runs, and the rest await its result. A cached value
97106
/// is reused until it nears expiry, then proactively re-minted.
98107
pub async fn get_credentials<E: CredentialExchange<H>>(
@@ -102,8 +111,9 @@ impl<H: HttpExchange> OidcCredentialProvider<H> {
102111
subject: &str,
103112
extra_claims: &[(&str, &str)],
104113
) -> Result<Arc<BackendCredentials>, OidcProviderError> {
114+
let key = credential_cache_key(cache_key, subject, extra_claims);
105115
self.cache
106-
.get_or_fetch(cache_key, || async {
116+
.get_or_fetch(&key, || async {
107117
// Cache miss (or due for refresh): mint a JWT and exchange it.
108118
let token =
109119
self.signer
@@ -120,6 +130,30 @@ impl<H: HttpExchange> OidcCredentialProvider<H> {
120130
}
121131
}
122132

133+
/// Build the effective credential-cache key from every input that changes the
134+
/// minted assertion the backend's trust policy evaluates: the backend
135+
/// `cache_key`, the `subject`, and each `extra_claims` pair.
136+
///
137+
/// Length-prefixed (`<len>:<value>`) rather than delimiter-joined so no crafted
138+
/// component value can collide with a different tuple — the key is a security
139+
/// boundary (share a credential across subjects and you bypass a per-subject
140+
/// trust-policy denial), so it must be unambiguous by construction.
141+
fn credential_cache_key(cache_key: &str, subject: &str, extra_claims: &[(&str, &str)]) -> String {
142+
let mut key = String::new();
143+
let mut push = |part: &str| {
144+
key.push_str(&part.len().to_string());
145+
key.push(':');
146+
key.push_str(part);
147+
};
148+
push(cache_key);
149+
push(subject);
150+
for (k, v) in extra_claims {
151+
push(k);
152+
push(v);
153+
}
154+
key
155+
}
156+
123157
/// Errors produced by this crate.
124158
#[derive(Debug, thiserror::Error)]
125159
pub enum OidcProviderError {
@@ -335,6 +369,58 @@ mod tests {
335369
assert_eq!(http.calls(), 2);
336370
}
337371

372+
#[tokio::test]
373+
async fn same_backend_different_subject_makes_separate_calls() {
374+
// Security boundary: two subjects sharing a role must NOT share a cached
375+
// credential — the role's trust policy conditions on the subject at mint
376+
// time, and a cache hit would bypass a denial for the second subject.
377+
let http = MockHttp::new();
378+
let provider = OidcCredentialProvider::new(
379+
test_signer(),
380+
http.clone(),
381+
"https://issuer.example.com".into(),
382+
"sts.amazonaws.com".into(),
383+
);
384+
let exchange = exchange::aws::AwsExchange::new("arn:aws:iam::123:role/Test".into());
385+
386+
provider
387+
.get_credentials("role-a", &exchange, "scv1:conn:A", &[])
388+
.await
389+
.unwrap();
390+
provider
391+
.get_credentials("role-a", &exchange, "scv1:conn:B", &[])
392+
.await
393+
.unwrap();
394+
395+
assert_eq!(http.calls(), 2, "distinct subjects must each mint");
396+
}
397+
398+
#[test]
399+
fn credential_cache_key_is_unambiguous() {
400+
// Length-prefix framing must not let a crafted value forge another
401+
// tuple's key: (role="a:b", sub="c") and (role="a", sub="b:c") share the
402+
// naive `a:b\x1fc` vs `a\x1fb:c` hazard under a plain delimiter, but must
403+
// differ here.
404+
assert_ne!(
405+
credential_cache_key("a:b", "c", &[]),
406+
credential_cache_key("a", "b:c", &[]),
407+
);
408+
// Subject is part of the key; claims are too.
409+
assert_ne!(
410+
credential_cache_key("role", "subA", &[]),
411+
credential_cache_key("role", "subB", &[]),
412+
);
413+
assert_ne!(
414+
credential_cache_key("role", "sub", &[]),
415+
credential_cache_key("role", "sub", &[("x", "1")]),
416+
);
417+
// Same inputs → same key (cache actually hits).
418+
assert_eq!(
419+
credential_cache_key("role", "sub", &[("x", "1")]),
420+
credential_cache_key("role", "sub", &[("x", "1")]),
421+
);
422+
}
423+
338424
#[test]
339425
fn signed_jwt_is_verifiable_via_jwks_public_key() {
340426
use base64::Engine;

0 commit comments

Comments
 (0)