Skip to content

Commit 1e29970

Browse files
authored
feat(a2a-auth-callout): dispatcher + remaining wire codec (#374)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent ec45801 commit 1e29970

12 files changed

Lines changed: 1165 additions & 12 deletions

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
@@ -18,6 +18,7 @@ path = "src/lib.rs"
1818
workspace = true
1919

2020
[dependencies]
21+
async-nats = { workspace = true }
2122
async-trait = { workspace = true }
2223
base64 = { workspace = true }
2324
hex = { workspace = true }
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
use std::sync::Arc;
2+
use std::time::{Duration, SystemTime};
3+
4+
use serde::{Deserialize, Serialize};
5+
6+
use crate::account_resolver::AccountResolver;
7+
#[allow(deprecated)]
8+
use crate::credentials::api_key::ApiKeyVerifier;
9+
use crate::credentials::mtls::MTlsVerifier;
10+
use crate::credentials::oidc::{BearerToken, OidcVerifier};
11+
use crate::error::AuthCalloutError;
12+
use crate::jwt::{MintedUserJwt, UserJwtSubject};
13+
use crate::signing_key_source::SigningKeySource;
14+
use crate::wire::ServerAuthRequestClaims;
15+
16+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17+
#[serde(rename_all = "snake_case")]
18+
pub enum AuthScheme {
19+
Oidc,
20+
MTls,
21+
ApiKey,
22+
}
23+
24+
/// Trait that the subscriber loop delegates auth decisions to.
25+
#[async_trait::async_trait]
26+
pub trait AuthDispatcher: Send + Sync + 'static {
27+
async fn dispatch(&self, request: ServerAuthRequestClaims) -> Result<MintedUserJwt, AuthCalloutError>;
28+
}
29+
30+
pub struct CalloutDispatcherConfig {
31+
pub signing_key_source: Arc<dyn SigningKeySource>,
32+
pub user_jwt_ttl: Duration,
33+
pub account_resolver: Arc<dyn AccountResolver>,
34+
pub oidc: Option<Arc<dyn OidcVerifier>>,
35+
pub mtls: Option<Arc<dyn MTlsVerifier>>,
36+
#[allow(deprecated)]
37+
pub api_key: Option<Arc<dyn ApiKeyVerifier>>,
38+
}
39+
40+
pub struct CalloutDispatcher {
41+
config: CalloutDispatcherConfig,
42+
}
43+
44+
impl CalloutDispatcher {
45+
pub fn new(config: CalloutDispatcherConfig) -> Self {
46+
Self { config }
47+
}
48+
49+
fn select_scheme(&self, request: &ServerAuthRequestClaims) -> Result<AuthScheme, AuthCalloutError> {
50+
if request.connect_opts_jwt().is_some() || request.connect_opts_opaque_pass().is_some() {
51+
return Ok(AuthScheme::Oidc);
52+
}
53+
if request.primary_client_cert().is_some() {
54+
return Ok(AuthScheme::MTls);
55+
}
56+
if request.connect_opts_auth_token().is_some() {
57+
return Ok(AuthScheme::ApiKey);
58+
}
59+
Err(AuthCalloutError::CredentialVerification(
60+
"no credential material in authorization request".into(),
61+
))
62+
}
63+
}
64+
65+
#[async_trait::async_trait]
66+
impl AuthDispatcher for CalloutDispatcher {
67+
async fn dispatch(&self, request: ServerAuthRequestClaims) -> Result<MintedUserJwt, AuthCalloutError> {
68+
let requested = request.requested_account()?;
69+
let account = self.config.account_resolver.resolve(&requested)?;
70+
71+
let scheme = self.select_scheme(&request)?;
72+
let claims =
73+
match scheme {
74+
AuthScheme::Oidc => {
75+
let verifier = self.config.oidc.as_ref().ok_or_else(|| {
76+
AuthCalloutError::CredentialVerification("OIDC verifier not configured".into())
77+
})?;
78+
let token = request
79+
.connect_opts_jwt()
80+
.or_else(|| request.connect_opts_opaque_pass())
81+
.ok_or_else(|| {
82+
AuthCalloutError::CredentialVerification(
83+
"OIDC scheme but connect_opts.jwt and connect_opts.pass missing".into(),
84+
)
85+
})?;
86+
verifier.verify(&BearerToken::new(token.to_owned()), &account).await?
87+
}
88+
AuthScheme::MTls => {
89+
let verifier = self.config.mtls.as_ref().ok_or_else(|| {
90+
AuthCalloutError::CredentialVerification("mTLS verifier not configured".into())
91+
})?;
92+
let pem = request.primary_client_cert().ok_or_else(|| {
93+
AuthCalloutError::CredentialVerification("mTLS scheme but client_tls certs missing".into())
94+
})?;
95+
verifier.verify(&pem, &account).await?
96+
}
97+
AuthScheme::ApiKey => {
98+
#[allow(deprecated)]
99+
let verifier = self.config.api_key.as_ref().ok_or_else(|| {
100+
AuthCalloutError::CredentialVerification("API-key verifier not configured".into())
101+
})?;
102+
let key = request.connect_opts_auth_token().ok_or_else(|| {
103+
AuthCalloutError::CredentialVerification(
104+
"API-key scheme but connect_opts.auth_token missing".into(),
105+
)
106+
})?;
107+
// ApiKeyVerifier::verify now takes the resolved account
108+
// directly and refuses mismatches itself, so the explicit
109+
// post-check that was here is now redundant.
110+
#[allow(deprecated)]
111+
verifier.verify(key, &account).await?
112+
}
113+
};
114+
115+
let user_nkey = request.user_nkey()?;
116+
let user_subject = UserJwtSubject::from_user_nkey(user_nkey);
117+
let material = self.config.signing_key_source.current().minting_material();
118+
claims
119+
.mint(&material, &user_subject, SystemTime::now(), self.config.user_jwt_ttl)
120+
.map_err(AuthCalloutError::from)
121+
}
122+
}
123+
124+
#[cfg(test)]
125+
#[allow(deprecated)]
126+
pub(crate) mod tests {
127+
use super::*;
128+
use crate::account_resolver::StaticAccountResolver;
129+
use crate::credentials::api_key::{ApiKey, ApiKeyEntry, ApiKeyRegistry, HmacApiKeyVerifier};
130+
use crate::credentials::mtls::ClientCertPem;
131+
use crate::credentials::oidc::BearerToken;
132+
use crate::jwt::{AudienceAccount, CallerId, ExternalSubject, SpiceDbPrincipal, UserJwtClaims};
133+
use crate::permissions::IssuedPermissions;
134+
use crate::signing_key_source::{KeyVersion, StaticSigningKeySource};
135+
use crate::wire::test_encode::signed_auth_request;
136+
use crate::wire::{NkeyPublic, ServerAuthRequestEnvelope};
137+
use nats_jwt_rs::Claims;
138+
use nats_jwt_rs::authorization::AuthRequest;
139+
use nkeys::KeyPair;
140+
use serde_json::json;
141+
142+
fn encode_fixture_request(
143+
server: &KeyPair,
144+
patch: impl FnMut(&mut Claims<AuthRequest>),
145+
) -> ServerAuthRequestClaims {
146+
let user = KeyPair::new_user();
147+
let token = signed_auth_request(server, &user, patch);
148+
let server_pub = NkeyPublic::parse(server.public_key()).unwrap();
149+
ServerAuthRequestEnvelope::from_bytes(token.into_bytes())
150+
.decode(&server_pub, None, None)
151+
.unwrap()
152+
}
153+
154+
pub struct AlwaysDenyDispatcher;
155+
156+
#[async_trait::async_trait]
157+
impl AuthDispatcher for AlwaysDenyDispatcher {
158+
async fn dispatch(&self, _request: ServerAuthRequestClaims) -> Result<MintedUserJwt, AuthCalloutError> {
159+
Err(AuthCalloutError::CredentialVerification("stub: always deny".into()))
160+
}
161+
}
162+
163+
#[tokio::test]
164+
async fn always_deny_returns_err() {
165+
let server = KeyPair::new_account();
166+
let req = encode_fixture_request(&server, |_| {});
167+
assert!(AlwaysDenyDispatcher.dispatch(req).await.is_err());
168+
}
169+
170+
struct StubOidcVerifier {
171+
sub: &'static str,
172+
}
173+
174+
#[async_trait::async_trait]
175+
impl OidcVerifier for StubOidcVerifier {
176+
async fn verify(
177+
&self,
178+
_token: &BearerToken,
179+
account: &AudienceAccount,
180+
) -> Result<UserJwtClaims, AuthCalloutError> {
181+
let caller_id = CallerId::new("usrstub").unwrap();
182+
Ok(UserJwtClaims {
183+
kid: crate::signing_key_source::unminted_placeholder(),
184+
sub: ExternalSubject::new(self.sub).unwrap(),
185+
aud: account.clone(),
186+
data: SpiceDbPrincipal(json!({"spicedb_subject": self.sub})),
187+
nats_permissions: IssuedPermissions::default_for_caller(&caller_id),
188+
caller_id,
189+
})
190+
}
191+
}
192+
193+
struct StubMtlsVerifier;
194+
195+
#[async_trait::async_trait]
196+
impl MTlsVerifier for StubMtlsVerifier {
197+
async fn verify(
198+
&self,
199+
_cert: &ClientCertPem,
200+
account: &AudienceAccount,
201+
) -> Result<UserJwtClaims, AuthCalloutError> {
202+
let caller_id = CallerId::new("mtlsstub").unwrap();
203+
Ok(UserJwtClaims {
204+
kid: crate::signing_key_source::unminted_placeholder(),
205+
sub: ExternalSubject::new("CN=svc").unwrap(),
206+
aud: account.clone(),
207+
data: SpiceDbPrincipal(json!({"spicedb_subject": "svc"})),
208+
nats_permissions: IssuedPermissions::default_for_caller(&caller_id),
209+
caller_id,
210+
})
211+
}
212+
}
213+
214+
fn dispatcher_with(
215+
oidc: Option<Arc<dyn OidcVerifier>>,
216+
mtls: Option<Arc<dyn MTlsVerifier>>,
217+
api_key: Option<Arc<dyn ApiKeyVerifier>>,
218+
allowed: &[&str],
219+
) -> CalloutDispatcher {
220+
let resolver: Arc<dyn AccountResolver> =
221+
Arc::new(StaticAccountResolver::new(allowed.iter().map(|s| s.to_string())));
222+
let account = KeyPair::new_account();
223+
let account_seed = account.seed().expect("account seed");
224+
let signing_key_source: Arc<dyn SigningKeySource> = Arc::new(
225+
StaticSigningKeySource::new(&account_seed, KeyVersion::new("test").expect("test key version"))
226+
.expect("static signing source"),
227+
);
228+
CalloutDispatcher::new(CalloutDispatcherConfig {
229+
signing_key_source,
230+
user_jwt_ttl: Duration::from_secs(60),
231+
account_resolver: resolver,
232+
oidc,
233+
mtls,
234+
api_key,
235+
})
236+
}
237+
238+
#[tokio::test]
239+
async fn dispatch_oidc_happy_path_mints_jwt() {
240+
let server = KeyPair::new_account();
241+
let oidc: Arc<dyn OidcVerifier> = Arc::new(StubOidcVerifier { sub: "user-1" });
242+
let d = dispatcher_with(Some(oidc), None, None, &["tenant-acme"]);
243+
let req = encode_fixture_request(&server, |_| {});
244+
let resp = d.dispatch(req).await.unwrap();
245+
assert_eq!(resp.as_str().split('.').count(), 3);
246+
}
247+
248+
#[tokio::test]
249+
async fn dispatch_rejects_unknown_account() {
250+
let server = KeyPair::new_account();
251+
let oidc: Arc<dyn OidcVerifier> = Arc::new(StubOidcVerifier { sub: "user-1" });
252+
let d = dispatcher_with(Some(oidc), None, None, &["tenant-acme"]);
253+
let req = encode_fixture_request(&server, |c| {
254+
c.nats.connect_opts.user = Some("tenant-evil".into());
255+
c.nats.client_info.user = "tenant-evil".into();
256+
});
257+
assert!(d.dispatch(req).await.is_err());
258+
}
259+
260+
#[tokio::test]
261+
async fn dispatch_rejects_request_without_account() {
262+
let server = KeyPair::new_account();
263+
let oidc: Arc<dyn OidcVerifier> = Arc::new(StubOidcVerifier { sub: "user-1" });
264+
let d = dispatcher_with(Some(oidc), None, None, &["tenant-acme"]);
265+
let req = encode_fixture_request(&server, |c| {
266+
c.nats.connect_opts.user = None;
267+
c.nats.client_info.user = String::new();
268+
});
269+
assert!(d.dispatch(req).await.is_err());
270+
}
271+
272+
#[tokio::test]
273+
async fn dispatch_infers_oidc_when_user_jwt_present() {
274+
let server = KeyPair::new_account();
275+
let oidc: Arc<dyn OidcVerifier> = Arc::new(StubOidcVerifier { sub: "user-1" });
276+
let d = dispatcher_with(Some(oidc), None, None, &["tenant-acme"]);
277+
let req = encode_fixture_request(&server, |_| {});
278+
assert!(d.dispatch(req).await.is_ok());
279+
}
280+
281+
#[tokio::test]
282+
async fn dispatch_mtls_when_cert_present() {
283+
use crate::bridge_mint::{BridgeClientInfo, BridgeMintRequest};
284+
285+
let mtls: Arc<dyn MTlsVerifier> = Arc::new(StubMtlsVerifier);
286+
let d = dispatcher_with(None, Some(mtls), None, &["tenant-acme"]);
287+
let req = ServerAuthRequestClaims::from_bridge_mint(BridgeMintRequest {
288+
user_nkey: None,
289+
user_jwt: None,
290+
account: Some("tenant-acme".into()),
291+
client_info: Some(BridgeClientInfo {
292+
client_cert_pem: Some("-----BEGIN CERT-----\n-----END CERT-----".into()),
293+
}),
294+
connect_opts: None,
295+
})
296+
.unwrap();
297+
assert!(d.dispatch(req).await.is_ok());
298+
}
299+
300+
#[tokio::test]
301+
async fn dispatch_api_key_happy_path() {
302+
let server = KeyPair::new_account();
303+
let mut registry = ApiKeyRegistry::new(b"hmac-test-secret".to_vec());
304+
let key = ApiKey::new("k_live_demo").unwrap();
305+
registry.register(
306+
&key,
307+
ApiKeyEntry {
308+
spicedb_principal: SpiceDbPrincipal::new("svc/demo"),
309+
audience: AudienceAccount::new("tenant-acme"),
310+
external_subject: ExternalSubject::new("svc-demo").unwrap(),
311+
},
312+
);
313+
let verifier: Arc<dyn ApiKeyVerifier> = Arc::new(HmacApiKeyVerifier::new(Arc::new(registry)));
314+
let d = dispatcher_with(None, None, Some(verifier), &["tenant-acme"]);
315+
let req = encode_fixture_request(&server, |c| {
316+
c.nats.connect_opts.jwt = None;
317+
c.nats.connect_opts.auth_token = Some("k_live_demo".into());
318+
});
319+
assert!(d.dispatch(req).await.is_ok());
320+
}
321+
322+
#[tokio::test]
323+
async fn dispatch_api_key_rejects_audience_mismatch() {
324+
let server = KeyPair::new_account();
325+
let mut registry = ApiKeyRegistry::new(b"hmac-test-secret".to_vec());
326+
let key = ApiKey::new("k_live_demo").unwrap();
327+
registry.register(
328+
&key,
329+
ApiKeyEntry {
330+
spicedb_principal: SpiceDbPrincipal::new("svc/demo"),
331+
audience: AudienceAccount::new("tenant-foo"),
332+
external_subject: ExternalSubject::new("svc-demo").unwrap(),
333+
},
334+
);
335+
let verifier: Arc<dyn ApiKeyVerifier> = Arc::new(HmacApiKeyVerifier::new(Arc::new(registry)));
336+
let d = dispatcher_with(None, None, Some(verifier), &["tenant-acme", "tenant-foo"]);
337+
let req = encode_fixture_request(&server, |c| {
338+
c.nats.connect_opts.jwt = None;
339+
c.nats.connect_opts.auth_token = Some("k_live_demo".into());
340+
});
341+
assert!(d.dispatch(req).await.is_err());
342+
}
343+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub mod credentials;
1111
pub mod denial_category;
1212
pub mod denial_claims;
1313
pub mod denial_reason;
14+
pub mod dispatcher;
1415
pub mod error;
1516
pub mod jwt;
1617
pub mod permissions;

0 commit comments

Comments
 (0)