Skip to content

Commit a4475c7

Browse files
authored
feat(a2a-auth-callout): subscriber (#375)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 0ab5970 commit a4475c7

13 files changed

Lines changed: 423 additions & 161 deletions

File tree

rsworkspace/Cargo.lock

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

rsworkspace/crates/a2a-auth-callout/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ workspace = true
2121
async-nats = { workspace = true }
2222
async-trait = { workspace = true }
2323
base64 = { workspace = true }
24+
futures = { workspace = true }
2425
hex = { workspace = true }
2526
hmac = { workspace = true }
2627
jsonwebtoken = { workspace = true }

rsworkspace/crates/a2a-auth-callout/src/account_resolver.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,14 @@ impl std::error::Error for AccountResolverError {}
4141

4242
impl From<AccountResolverError> for AuthCalloutError {
4343
fn from(value: AccountResolverError) -> Self {
44-
Self::CredentialVerification(value.to_string())
44+
// Variant-to-variant routing — no string matching on the failure
45+
// message, the category is determined by the typed enum tag.
46+
match value {
47+
AccountResolverError::EmptyRequest => {
48+
crate::error::CredentialError::InvalidRequest("requested account is empty".into()).into()
49+
}
50+
AccountResolverError::Unknown(name) => crate::error::CredentialError::UnknownAccount(name).into(),
51+
}
4552
}
4653
}
4754

rsworkspace/crates/a2a-auth-callout/src/credentials/api_key.rs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,25 @@ impl std::error::Error for ApiKeyError {
4848

4949
impl From<ApiKeyError> for AuthCalloutError {
5050
fn from(e: ApiKeyError) -> Self {
51-
// Preserve the typed source via the std::error::Error chain rather
52-
// than collapsing to a string. CredentialVerification is the right
53-
// bucket; the source() chain still surfaces the inner JwtError.
54-
Self::CredentialVerification(e.to_string())
51+
// Variant-to-variant — DenialCategory derives the wire response
52+
// category from the typed CredentialError tag, not from substring
53+
// matching on a stringified message.
54+
match e {
55+
ApiKeyError::Empty => {
56+
crate::error::CredentialError::InvalidRequest("API key must not be empty".into()).into()
57+
}
58+
ApiKeyError::Unknown => {
59+
crate::error::CredentialError::InvalidCredentials("API key not found".into()).into()
60+
}
61+
ApiKeyError::CallerIdDerivation(je) => {
62+
crate::error::CredentialError::InvalidCredentials(format!("API key caller_id derivation failed: {je}"))
63+
.into()
64+
}
65+
ApiKeyError::AudienceMismatch { requested, registered } => crate::error::CredentialError::InvalidRequest(
66+
format!("API key audience mismatch: requested={requested:?} registered={registered:?}"),
67+
)
68+
.into(),
69+
}
5570
}
5671
}
5772

rsworkspace/crates/a2a-auth-callout/src/credentials/mtls.rs

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use x509_parser::prelude::FromDer;
44
use x509_parser::prelude::X509Certificate;
55
use x509_parser::time::ASN1Time;
66

7-
use crate::error::AuthCalloutError;
7+
use crate::error::{AuthCalloutError, CredentialError};
88
use crate::jwt::{
99
AudienceAccount, ExternalSubject, UserJwtClaims, derive_caller_id, external_subject_from_der,
1010
spicedb_bundle_for_opaque,
@@ -51,23 +51,26 @@ impl X509MtlsVerifier {
5151
fn chain_ders(pem: &str) -> Result<Vec<Vec<u8>>, AuthCalloutError> {
5252
let mut out = Vec::new();
5353
for pem_result in Pem::iter_from_buffer(pem.as_bytes()) {
54-
let pem =
55-
pem_result.map_err(|e| AuthCalloutError::CredentialVerification(format!("PEM parse error: {e}")))?;
54+
let pem = pem_result.map_err(|e| CredentialError::InvalidCredentials(format!("PEM parse error: {e}")))?;
5655
if pem.label.to_uppercase().contains("CERTIFICATE") {
5756
out.push(pem.contents);
5857
}
5958
}
6059
if out.is_empty() {
6160
return Err(AuthCalloutError::CredentialVerification(
62-
"client certificate PEM contained no CERTIFICATE block".into(),
61+
CredentialError::InvalidCredentials("client certificate PEM contained no CERTIFICATE block".into()),
6362
));
6463
}
6564
Ok(out)
6665
}
6766

6867
fn anchor_pems(bundle: &str) -> Result<Vec<Pem>, AuthCalloutError> {
6968
Pem::iter_from_buffer(bundle.as_bytes())
70-
.map(|r| r.map_err(|e| AuthCalloutError::CredentialVerification(format!("trust anchor PEM: {e}"))))
69+
.map(|r| {
70+
r.map_err(|e| -> AuthCalloutError {
71+
CredentialError::InvalidCredentials(format!("trust anchor PEM: {e}")).into()
72+
})
73+
})
7174
.collect()
7275
}
7376

@@ -79,12 +82,12 @@ impl X509MtlsVerifier {
7982
}
8083
let x509 = pem
8184
.parse_x509()
82-
.map_err(|e| AuthCalloutError::CredentialVerification(format!("invalid trust anchor cert DER: {e}")))?;
85+
.map_err(|e| CredentialError::InvalidCredentials(format!("invalid trust anchor cert DER: {e}")))?;
8386
out.push(x509);
8487
}
8588
if out.is_empty() {
8689
return Err(AuthCalloutError::CredentialVerification(
87-
"trust anchor bundle contained no certificates".into(),
90+
CredentialError::InvalidCredentials("trust anchor bundle contained no certificates".into()),
8891
));
8992
}
9093
Ok(out)
@@ -103,7 +106,7 @@ impl X509MtlsVerifier {
103106
.map(|d| {
104107
X509Certificate::from_der(d)
105108
.map(|(_, c)| c)
106-
.map_err(|e| AuthCalloutError::CredentialVerification(format!("invalid client chain cert: {e}")))
109+
.map_err(|e| CredentialError::InvalidCredentials(format!("invalid client chain cert: {e}")))
107110
})
108111
.collect::<Result<_, _>>()?;
109112
let leaf = &chain[0];
@@ -113,7 +116,9 @@ impl X509MtlsVerifier {
113116

114117
if !leaf.validity().is_valid_at(ASN1Time::from(now)) {
115118
return Err(AuthCalloutError::CredentialVerification(
116-
"client certificate validity window does not include verification time".into(),
119+
CredentialError::InvalidCredentials(
120+
"client certificate validity window does not include verification time".into(),
121+
),
117122
));
118123
}
119124

@@ -126,7 +131,7 @@ impl X509MtlsVerifier {
126131
&& bc.value.ca
127132
{
128133
return Err(AuthCalloutError::CredentialVerification(
129-
"client certificate is a CA, expected end-entity".into(),
134+
CredentialError::InvalidCredentials("client certificate is a CA, expected end-entity".into()),
130135
));
131136
}
132137

@@ -166,18 +171,20 @@ impl X509MtlsVerifier {
166171

167172
if !trusted {
168173
return Err(AuthCalloutError::CredentialVerification(
169-
"client certificate does not chain to a configured trust anchor".into(),
174+
CredentialError::InvalidCredentials(
175+
"client certificate does not chain to a configured trust anchor".into(),
176+
),
170177
));
171178
}
172179

173180
let sub = ExternalSubject::from_x509(leaf, &leaf_der)
174-
.map_err(|e| AuthCalloutError::CredentialVerification(format!("mTLS subject extraction failed: {e}")))?;
181+
.map_err(|e| CredentialError::InvalidCredentials(format!("mTLS subject extraction failed: {e}")))?;
175182
let data = spicedb_bundle_for_opaque(serde_json::json!({
176183
"spicedb_subject": sub.as_str(),
177184
"mtls": true,
178185
}));
179186
let caller_id = derive_caller_id(sub.as_str(), account)
180-
.map_err(|e| AuthCalloutError::CredentialVerification(format!("caller_id derivation failed: {e}")))?;
187+
.map_err(|e| CredentialError::InvalidCredentials(format!("caller_id derivation failed: {e}")))?;
181188

182189
let nats_permissions = crate::permissions::IssuedPermissions::default_for_caller(&caller_id);
183190
Ok(UserJwtClaims {
@@ -200,11 +207,16 @@ impl ExternalSubjectExt for ExternalSubject {
200207
let dn = leaf.subject().to_string();
201208
if dn.is_empty() {
202209
return external_subject_from_der("mtls", leaf_der).map_err(|e| {
203-
AuthCalloutError::CredentialVerification(format!("fallback subject encoding failed: {e}"))
210+
AuthCalloutError::from(CredentialError::InvalidCredentials(format!(
211+
"fallback subject encoding failed: {e}"
212+
)))
204213
});
205214
}
206-
ExternalSubject::new(dn)
207-
.map_err(|e| AuthCalloutError::CredentialVerification(format!("invalid external subject from DN: {e}")))
215+
ExternalSubject::new(dn).map_err(|e| {
216+
AuthCalloutError::from(CredentialError::InvalidCredentials(format!(
217+
"invalid external subject from DN: {e}"
218+
)))
219+
})
208220
}
209221
}
210222

@@ -229,7 +241,10 @@ mod tests {
229241
fn rejects_empty_trust_bundle() {
230242
let empty: Vec<Pem> = Vec::new();
231243
let err = X509MtlsVerifier::parse_cas(&empty).unwrap_err();
232-
assert!(matches!(err, AuthCalloutError::CredentialVerification(_)));
244+
assert!(matches!(
245+
err,
246+
AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(_))
247+
));
233248
let _ = X509MtlsVerifier::new(TrustAnchorPem::new(""));
234249
}
235250

@@ -294,6 +309,9 @@ mod tests {
294309
.verify(&ClientCertPem::new(ee.pem()), &AudienceAccount::new("a"))
295310
.await
296311
.unwrap_err();
297-
assert!(matches!(err, AuthCalloutError::CredentialVerification(_)));
312+
assert!(matches!(
313+
err,
314+
AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(_))
315+
));
298316
}
299317
}

rsworkspace/crates/a2a-auth-callout/src/credentials/oidc.rs

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::sync::Arc;
22

3-
use crate::error::AuthCalloutError;
3+
use crate::error::{AuthCalloutError, CredentialError};
44
use crate::jwt::{
55
AudienceAccount, ExternalSubject, UserJwtClaims, derive_caller_id, spicedb_principal_from_oidc_claims,
66
};
@@ -19,7 +19,7 @@ impl OidcIssuerUrl {
1919
}
2020
if s.is_empty() {
2121
return Err(AuthCalloutError::CredentialVerification(
22-
"OIDC issuer URL is empty".into(),
22+
CredentialError::InvalidCredentials("OIDC issuer URL is empty".into()),
2323
));
2424
}
2525
Ok(Self(s))
@@ -38,7 +38,7 @@ impl OidcClientId {
3838
let s = id.into();
3939
if s.trim().is_empty() {
4040
return Err(AuthCalloutError::CredentialVerification(
41-
"OIDC client ID must not be empty".into(),
41+
CredentialError::InvalidCredentials("OIDC client ID must not be empty".into()),
4242
));
4343
}
4444
Ok(Self(s))
@@ -100,43 +100,45 @@ impl JwksOidcVerifier {
100100
.connect_timeout(std::time::Duration::from_secs(5))
101101
.redirect(reqwest::redirect::Policy::none())
102102
.build()
103-
.map_err(|e| AuthCalloutError::CredentialVerification(e.to_string()))?;
103+
.map_err(|e| CredentialError::InvalidCredentials(e.to_string()))?;
104104
let uri = format!("{}/.well-known/openid-configuration", issuer.as_str());
105105
let resp = http
106106
.get(uri)
107107
.send()
108108
.await
109-
.map_err(|e| AuthCalloutError::CredentialVerification(e.to_string()))?;
109+
.map_err(|e| CredentialError::InvalidCredentials(e.to_string()))?;
110110
let doc: serde_json::Value = resp
111111
.json()
112112
.await
113-
.map_err(|e| AuthCalloutError::CredentialVerification(e.to_string()))?;
113+
.map_err(|e| CredentialError::InvalidCredentials(e.to_string()))?;
114114
// Refuse a discovery doc whose `issuer` claim doesn't match the one
115115
// we asked for — the doc is fetched off the network and a MITM /
116116
// misconfigured DNS could redirect us at an attacker's IdP.
117117
let doc_issuer = doc
118118
.get("issuer")
119119
.and_then(|v| v.as_str())
120-
.ok_or_else(|| AuthCalloutError::CredentialVerification("missing issuer in OIDC discovery".into()))?;
120+
.ok_or_else(|| CredentialError::InvalidCredentials("missing issuer in OIDC discovery".into()))?;
121121
if doc_issuer.trim_end_matches('/') != issuer.as_str().trim_end_matches('/') {
122-
return Err(AuthCalloutError::CredentialVerification(format!(
122+
return Err(CredentialError::InvalidCredentials(format!(
123123
"OIDC discovery issuer mismatch: configured={:?} discovered={:?}",
124124
issuer.as_str(),
125125
doc_issuer
126-
)));
126+
))
127+
.into());
127128
}
128129
let jwks_uri = doc
129130
.get("jwks_uri")
130131
.and_then(|v| v.as_str())
131-
.ok_or_else(|| AuthCalloutError::CredentialVerification("missing jwks_uri in OIDC discovery".into()))?;
132+
.ok_or_else(|| CredentialError::InvalidCredentials("missing jwks_uri in OIDC discovery".into()))?;
132133
// Constrain jwks_uri to the issuer's origin so a tampered discovery
133134
// doc can't point us at attacker-controlled JWKS while tokens still
134135
// claim the expected `iss`.
135136
if !same_origin(jwks_uri, issuer.as_str()) {
136-
return Err(AuthCalloutError::CredentialVerification(format!(
137+
return Err(CredentialError::InvalidCredentials(format!(
137138
"OIDC jwks_uri {jwks_uri:?} is outside issuer origin {:?}",
138139
issuer.as_str()
139-
)));
140+
))
141+
.into());
140142
}
141143
Ok(Self {
142144
issuer,
@@ -195,21 +197,24 @@ impl JwksOidcVerifier {
195197
.get(jwks_uri)
196198
.send()
197199
.await
198-
.map_err(|e| AuthCalloutError::CredentialVerification(e.to_string()))?;
200+
.map_err(|e| CredentialError::InvalidCredentials(e.to_string()))?;
199201
resp.json::<JwkSet>()
200202
.await
201-
.map_err(|e| AuthCalloutError::CredentialVerification(e.to_string()))
203+
.map_err(|e| AuthCalloutError::from(CredentialError::InvalidCredentials(e.to_string())))
202204
}
203205
JwksSource::Static(j) => Ok((**j).clone()),
204206
}
205207
}
206208

207209
fn decoding_key_for_jwk(jwk: &jsonwebtoken::jwk::Jwk) -> Result<DecodingKey, AuthCalloutError> {
208210
match &jwk.algorithm {
209-
AlgorithmParameters::RSA(rsa) => DecodingKey::from_rsa_components(&rsa.n, &rsa.e)
210-
.map_err(|e| AuthCalloutError::CredentialVerification(format!("invalid RSA JWK components: {e}"))),
211+
AlgorithmParameters::RSA(rsa) => DecodingKey::from_rsa_components(&rsa.n, &rsa.e).map_err(|e| {
212+
AuthCalloutError::from(CredentialError::InvalidCredentials(format!(
213+
"invalid RSA JWK components: {e}"
214+
)))
215+
}),
211216
_ => Err(AuthCalloutError::CredentialVerification(
212-
"OIDC JWK must be RSA for this verifier".into(),
217+
CredentialError::InvalidCredentials("OIDC JWK must be RSA for this verifier".into()),
213218
)),
214219
}
215220
}
@@ -221,37 +226,36 @@ impl JwksOidcVerifier {
221226
) -> Result<UserJwtClaims, AuthCalloutError> {
222227
if self.expected_id_token_audiences.is_empty() {
223228
return Err(AuthCalloutError::CredentialVerification(
224-
"no expected OIDC token audiences configured".into(),
229+
CredentialError::InvalidCredentials("no expected OIDC token audiences configured".into()),
225230
));
226231
}
227232
let jwks = self.fetch_jwks().await?;
228233
let header = decode_header(token.as_str())
229-
.map_err(|e| AuthCalloutError::CredentialVerification(format!("invalid JWT header: {e}")))?;
234+
.map_err(|e| CredentialError::InvalidCredentials(format!("invalid JWT header: {e}")))?;
230235
let kid = header
231236
.kid
232237
.as_ref()
233-
.ok_or_else(|| AuthCalloutError::CredentialVerification("JWT header missing kid".into()))?;
238+
.ok_or_else(|| CredentialError::InvalidCredentials("JWT header missing kid".into()))?;
234239
let jwk = jwks
235240
.find(kid)
236-
.ok_or_else(|| AuthCalloutError::CredentialVerification(format!("no JWK for kid {kid}")))?;
241+
.ok_or_else(|| CredentialError::InvalidCredentials(format!("no JWK for kid {kid}")))?;
237242
let auds: Vec<&str> = self.expected_id_token_audiences.iter().map(String::as_str).collect();
238243
let mut validation = Validation::new(header.alg);
239244
validation.set_issuer(&[self.issuer.as_str()]);
240245
validation.set_audience(&auds);
241246
let decoding_key = Self::decoding_key_for_jwk(jwk)?;
242247
let token_data = decode::<serde_json::Value>(token.as_str(), &decoding_key, &validation)
243-
.map_err(|e| AuthCalloutError::CredentialVerification(format!("OIDC token validation failed: {e}")))?;
248+
.map_err(|e| CredentialError::InvalidCredentials(format!("OIDC token validation failed: {e}")))?;
244249
let sub_str = token_data
245250
.claims
246251
.get("sub")
247252
.and_then(|v| v.as_str())
248-
.ok_or_else(|| AuthCalloutError::CredentialVerification("OIDC token missing sub".into()))?;
249-
let sub = ExternalSubject::new(sub_str).map_err(|e| {
250-
AuthCalloutError::CredentialVerification(format!("invalid external subject in OIDC token: {e}"))
251-
})?;
253+
.ok_or_else(|| CredentialError::InvalidCredentials("OIDC token missing sub".into()))?;
254+
let sub = ExternalSubject::new(sub_str)
255+
.map_err(|e| CredentialError::InvalidCredentials(format!("invalid external subject in OIDC token: {e}")))?;
252256
let data = spicedb_principal_from_oidc_claims(&token_data.claims);
253257
let caller_id = derive_caller_id(sub_str, account)
254-
.map_err(|e| AuthCalloutError::CredentialVerification(format!("caller_id derivation failed: {e}")))?;
258+
.map_err(|e| CredentialError::InvalidCredentials(format!("caller_id derivation failed: {e}")))?;
255259
let nats_permissions = IssuedPermissions::default_for_caller(&caller_id);
256260
Ok(UserJwtClaims {
257261
kid: crate::signing_key_source::unminted_placeholder(),
@@ -348,7 +352,10 @@ mod tests {
348352
let err = rt
349353
.block_on(v.verify_internal(&BearerToken::new("x.y.z"), &AudienceAccount::new("acct")))
350354
.unwrap_err();
351-
assert!(matches!(err, AuthCalloutError::CredentialVerification(_)));
355+
assert!(matches!(
356+
err,
357+
AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(_))
358+
));
352359
}
353360

354361
#[tokio::test]
@@ -447,7 +454,10 @@ mod tests {
447454
.verify_internal(&BearerToken::new(bad), &AudienceAccount::new("acct"))
448455
.await
449456
.unwrap_err();
450-
assert!(matches!(err, AuthCalloutError::CredentialVerification(_)));
457+
assert!(matches!(
458+
err,
459+
AuthCalloutError::CredentialVerification(CredentialError::InvalidCredentials(_))
460+
));
451461
}
452462

453463
#[tokio::test]

0 commit comments

Comments
 (0)