Skip to content

Commit e1ab685

Browse files
authored
feat(a2a-bridge): wire auth callout client and outbound upstream forwarder (#380)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent 0e7df8b commit e1ab685

6 files changed

Lines changed: 669 additions & 0 deletions

File tree

rsworkspace/Cargo.lock

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

rsworkspace/crates/a2a-bridge/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,14 @@ workspace = true
1616
[dependencies]
1717
a2a-auth-callout = { workspace = true }
1818
a2a-nats = { workspace = true }
19+
async-nats = { workspace = true }
20+
async-trait = { workspace = true }
21+
bytes = { workspace = true }
22+
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
1923
serde_json = { workspace = true }
24+
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] }
25+
26+
[dev-dependencies]
27+
nkeys = { workspace = true }
28+
serde_json = { workspace = true }
29+
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
mod callout_mint;
2+
3+
use std::sync::Arc;
4+
use std::time::Duration;
5+
6+
use async_trait::async_trait;
7+
8+
use a2a_auth_callout::{BridgeMintRequest, BridgeMintResponse};
9+
10+
pub use callout_mint::{BridgeTenantAccount, InProcessCalloutDispatcherMintWire};
11+
12+
use crate::error::BridgeError;
13+
use crate::identity::{BridgeUserJwt, CallerHttpsAuth};
14+
15+
#[async_trait]
16+
pub trait AuthCalloutClient: Send + Sync {
17+
async fn mint(&self, caller_auth: &CallerHttpsAuth) -> Result<BridgeUserJwt, BridgeError>;
18+
}
19+
20+
#[derive(Clone)]
21+
pub struct StubAuthCalloutClient;
22+
23+
#[async_trait]
24+
impl AuthCalloutClient for StubAuthCalloutClient {
25+
async fn mint(&self, _caller_auth: &CallerHttpsAuth) -> Result<BridgeUserJwt, BridgeError> {
26+
Err(BridgeError::Mint(
27+
"auth callout not configured for this deployment".into(),
28+
))
29+
}
30+
}
31+
32+
/// Test/dev mint that returns a fixed Account User JWT without a live auth-callout deployment.
33+
#[derive(Clone, Debug)]
34+
pub struct StubAuthCalloutMint {
35+
jwt: BridgeUserJwt,
36+
}
37+
38+
impl StubAuthCalloutMint {
39+
pub fn new(jwt: impl Into<String>) -> Result<Self, BridgeError> {
40+
Ok(Self {
41+
jwt: BridgeUserJwt::new(jwt.into())?,
42+
})
43+
}
44+
45+
pub fn fixture() -> Result<Self, BridgeError> {
46+
// A real compact JWT shape (header.payload.signature) so the
47+
// dev/test stub satisfies the same gate production tokens go
48+
// through — keeps the smuggling-prevention invariant intact
49+
// even on fixture paths.
50+
Self::new("eyJhbGciOiJub25lIn0.eyJzdWIiOiJoYXJuZXNzIn0.sig")
51+
}
52+
}
53+
54+
#[async_trait]
55+
impl AuthCalloutClient for StubAuthCalloutMint {
56+
async fn mint(&self, _caller_auth: &CallerHttpsAuth) -> Result<BridgeUserJwt, BridgeError> {
57+
Ok(self.jwt.clone())
58+
}
59+
}
60+
61+
#[async_trait]
62+
pub trait AuthMintWire: Send + Sync {
63+
async fn roundtrip_message(&self, subject: String, payload: BytesPayload) -> Result<Vec<u8>, BridgeError>;
64+
}
65+
66+
#[derive(Clone, Debug)]
67+
pub struct BytesPayload(pub Vec<u8>);
68+
69+
#[derive(Clone)]
70+
pub struct AsyncNatsAuthMintWire {
71+
client: Arc<async_nats::Client>,
72+
request_timeout: Duration,
73+
}
74+
75+
impl AsyncNatsAuthMintWire {
76+
pub fn new(client: Arc<async_nats::Client>, request_timeout: Duration) -> Self {
77+
Self {
78+
client,
79+
request_timeout,
80+
}
81+
}
82+
83+
#[must_use]
84+
pub fn from_client(client: async_nats::Client, request_timeout: Duration) -> Self {
85+
Self::new(Arc::new(client), request_timeout)
86+
}
87+
}
88+
89+
#[async_trait]
90+
impl AuthMintWire for AsyncNatsAuthMintWire {
91+
async fn roundtrip_message(&self, subject: String, payload: BytesPayload) -> Result<Vec<u8>, BridgeError> {
92+
tokio::time::timeout(self.request_timeout, self.client.request(subject, payload.0.into()))
93+
.await
94+
.map_err(|_| BridgeError::Mint("auth mint NATS roundtrip exceeded timeout".into()))?
95+
.map_err(|e| BridgeError::Mint(e.to_string()))
96+
.map(|m| m.payload.to_vec())
97+
}
98+
}
99+
100+
pub struct AuthCalloutJsonMintClient<W: AuthMintWire> {
101+
wire: Arc<W>,
102+
mint_subject: Arc<str>,
103+
tenant_account: Option<BridgeTenantAccount>,
104+
}
105+
106+
impl<W: AuthMintWire + 'static> AuthCalloutJsonMintClient<W> {
107+
#[must_use]
108+
pub fn new(wire: Arc<W>, mint_subject: impl Into<Arc<str>>) -> Self {
109+
Self::with_tenant_account(wire, mint_subject, None)
110+
}
111+
112+
#[must_use]
113+
pub fn with_tenant_account(
114+
wire: Arc<W>,
115+
mint_subject: impl Into<Arc<str>>,
116+
tenant_account: Option<BridgeTenantAccount>,
117+
) -> Self {
118+
Self {
119+
wire,
120+
mint_subject: mint_subject.into(),
121+
tenant_account,
122+
}
123+
}
124+
125+
#[must_use]
126+
pub fn default_mint_subject() -> &'static str {
127+
"a2a.bridge.auth.callout.request"
128+
}
129+
}
130+
131+
#[async_trait]
132+
impl<W: AuthMintWire + 'static> AuthCalloutClient for AuthCalloutJsonMintClient<W> {
133+
async fn mint(&self, caller_auth: &CallerHttpsAuth) -> Result<BridgeUserJwt, BridgeError> {
134+
let bearer_jwt = caller_auth
135+
.as_str()
136+
.strip_prefix("Bearer ")
137+
.or_else(|| caller_auth.as_str().strip_prefix("bearer "))
138+
.map(str::to_owned);
139+
let envelope = BridgeMintRequest {
140+
user_nkey: None,
141+
user_jwt: bearer_jwt,
142+
account: self.tenant_account.as_ref().map(|a| a.as_str().to_owned()),
143+
client_info: None,
144+
connect_opts: None,
145+
};
146+
let bytes = serde_json::to_vec(&envelope).map_err(|e: serde_json::Error| BridgeError::Serialize(e))?;
147+
// The wire impl owns the deadline (e.g. AsyncNatsAuthMintWire's
148+
// request_timeout) — wrapping a fixed 30s timeout here would
149+
// mask longer wire configs and surface a different error
150+
// message on short timeouts.
151+
let reply = self
152+
.wire
153+
.roundtrip_message(self.mint_subject.to_string(), BytesPayload(bytes))
154+
.await?;
155+
156+
let response: BridgeMintResponse =
157+
serde_json::from_slice(&reply).map_err(|e: serde_json::Error| BridgeError::Deserialize(e))?;
158+
BridgeUserJwt::new(response.user_jwt)
159+
}
160+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
use std::sync::Arc;
2+
use std::sync::atomic::{AtomicUsize, Ordering};
3+
4+
use a2a_auth_callout::dispatcher::AuthDispatcher;
5+
use a2a_auth_callout::wire::ServerAuthRequestClaims;
6+
use a2a_auth_callout::{BridgeAuthScheme, BridgeConnectOpts, BridgeMintRequest, BridgeMintResponse};
7+
use async_trait::async_trait;
8+
9+
use super::{AuthMintWire, BytesPayload};
10+
use crate::error::BridgeError;
11+
12+
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13+
pub struct BridgeTenantAccount(String);
14+
15+
impl BridgeTenantAccount {
16+
pub fn new(account: impl Into<String>) -> Result<Self, BridgeError> {
17+
let raw = account.into();
18+
let trimmed = raw.trim();
19+
if trimmed.is_empty() {
20+
return Err(BridgeError::Mint("bridge tenant account must be non-empty".into()));
21+
}
22+
Ok(Self(trimmed.to_owned()))
23+
}
24+
25+
pub fn as_str(&self) -> &str {
26+
&self.0
27+
}
28+
}
29+
30+
/// In-process [`AuthMintWire`] that delegates to [`CalloutDispatcher`] (no live NATS).
31+
pub struct InProcessCalloutDispatcherMintWire {
32+
dispatcher: Arc<dyn AuthDispatcher>,
33+
tenant: BridgeTenantAccount,
34+
mint_count: Arc<AtomicUsize>,
35+
}
36+
37+
impl InProcessCalloutDispatcherMintWire {
38+
pub fn new(dispatcher: Arc<dyn AuthDispatcher>, tenant: BridgeTenantAccount) -> Self {
39+
Self {
40+
dispatcher,
41+
tenant,
42+
mint_count: Arc::new(AtomicUsize::new(0)),
43+
}
44+
}
45+
46+
#[must_use]
47+
pub fn mint_count(&self) -> usize {
48+
self.mint_count.load(Ordering::SeqCst)
49+
}
50+
}
51+
52+
#[async_trait]
53+
impl AuthMintWire for InProcessCalloutDispatcherMintWire {
54+
async fn roundtrip_message(&self, _subject: String, payload: BytesPayload) -> Result<Vec<u8>, BridgeError> {
55+
self.mint_count.fetch_add(1, Ordering::SeqCst);
56+
let mut request: BridgeMintRequest =
57+
serde_json::from_slice(&payload.0).map_err(|e: serde_json::Error| BridgeError::Deserialize(e))?;
58+
if request.account.is_none() {
59+
request.account = Some(self.tenant.as_str().to_owned());
60+
}
61+
if request.connect_opts.is_none() && request.user_jwt.is_some() {
62+
request.connect_opts = Some(BridgeConnectOpts {
63+
auth_scheme: Some(BridgeAuthScheme::Oidc),
64+
api_key: None,
65+
});
66+
}
67+
let claims =
68+
ServerAuthRequestClaims::from_bridge_mint(request).map_err(|e| BridgeError::Mint(e.to_string()))?;
69+
let user_jwt = self
70+
.dispatcher
71+
.dispatch(claims)
72+
.await
73+
.map_err(|e| BridgeError::Mint(e.to_string()))?;
74+
let response = BridgeMintResponse {
75+
user_jwt: user_jwt.as_str().to_owned(),
76+
};
77+
serde_json::to_vec(&response).map_err(|e: serde_json::Error| BridgeError::Serialize(e))
78+
}
79+
}
80+
81+
#[cfg(test)]
82+
pub(crate) fn harness_callout_dispatcher(caller_id: &str) -> a2a_auth_callout::dispatcher::CalloutDispatcher {
83+
use std::sync::Arc;
84+
use std::time::Duration;
85+
86+
use a2a_auth_callout::StaticAccountResolver;
87+
use a2a_auth_callout::credentials::oidc::{BearerToken, OidcVerifier};
88+
use a2a_auth_callout::dispatcher::{CalloutDispatcher, CalloutDispatcherConfig};
89+
use a2a_auth_callout::jwt::{AudienceAccount, CallerId, ExternalSubject, SpiceDbPrincipal, UserJwtClaims};
90+
use a2a_auth_callout::permissions::IssuedPermissions;
91+
use a2a_auth_callout::signing_key_source::{KeyVersion, SigningKeySource, StaticSigningKeySource};
92+
use serde_json::json;
93+
94+
struct HarnessOidcVerifier {
95+
caller_id: CallerId,
96+
}
97+
98+
#[async_trait::async_trait]
99+
impl OidcVerifier for HarnessOidcVerifier {
100+
async fn verify(
101+
&self,
102+
_token: &BearerToken,
103+
account: &AudienceAccount,
104+
) -> Result<UserJwtClaims, a2a_auth_callout::AuthCalloutError> {
105+
Ok(UserJwtClaims {
106+
kid: KeyVersion::new("pending").expect("fixture kid"),
107+
sub: ExternalSubject::new("harness-sub").expect("fixture sub"),
108+
aud: account.clone(),
109+
data: SpiceDbPrincipal(json!({"spicedb_subject": "harness-sub"})),
110+
nats_permissions: IssuedPermissions::default_for_caller(&self.caller_id),
111+
caller_id: self.caller_id.clone(),
112+
})
113+
}
114+
}
115+
116+
let caller = CallerId::new(caller_id).expect("harness caller_id");
117+
let oidc: Arc<dyn OidcVerifier> = Arc::new(HarnessOidcVerifier { caller_id: caller });
118+
let resolver: Arc<dyn a2a_auth_callout::AccountResolver> =
119+
Arc::new(StaticAccountResolver::new(["tenant-harness".to_string()]));
120+
let issuer = nkeys::KeyPair::new_account();
121+
let issuer_seed = issuer.seed().expect("issuer seed");
122+
let signing_key_source: Arc<dyn SigningKeySource> = Arc::new(
123+
StaticSigningKeySource::new(&issuer_seed, KeyVersion::new("test").expect("harness version"))
124+
.expect("harness signing source"),
125+
);
126+
CalloutDispatcher::new(CalloutDispatcherConfig {
127+
signing_key_source,
128+
user_jwt_ttl: Duration::from_secs(60),
129+
account_resolver: resolver,
130+
oidc: Some(oidc),
131+
mtls: None,
132+
api_key: None,
133+
})
134+
}
135+
136+
#[cfg(test)]
137+
mod tests {
138+
use std::sync::Arc;
139+
140+
use a2a_auth_callout::caller_id_from_minted_jwt;
141+
142+
use super::*;
143+
use crate::auth::{AuthCalloutClient, AuthCalloutJsonMintClient};
144+
use crate::identity::CallerHttpsAuth;
145+
146+
#[tokio::test]
147+
async fn harness_mint_wire_returns_deterministic_caller_id_jwt() {
148+
let tenant = BridgeTenantAccount::new("tenant-harness").unwrap();
149+
let dispatcher = Arc::new(harness_callout_dispatcher("bridge-harness-caller"));
150+
let wire = Arc::new(InProcessCalloutDispatcherMintWire::new(dispatcher, tenant.clone()));
151+
let client =
152+
AuthCalloutJsonMintClient::with_tenant_account(wire, "a2a.bridge.auth.callout.request", Some(tenant));
153+
let jwt = client
154+
.mint(&CallerHttpsAuth::new("Bearer fixture-token"))
155+
.await
156+
.expect("harness mint");
157+
let caller = caller_id_from_minted_jwt(jwt.as_str()).expect("caller_id claim");
158+
assert_eq!(caller.as_str(), "bridge-harness-caller");
159+
}
160+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
#![doc = include_str!("../README.md")]
22
#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))]
33

4+
pub mod auth;
45
pub mod error;
56
pub mod identity;
7+
pub mod outbound;
68

9+
pub use auth::{
10+
AsyncNatsAuthMintWire, AuthCalloutClient, AuthCalloutJsonMintClient, BridgeTenantAccount,
11+
InProcessCalloutDispatcherMintWire, StubAuthCalloutClient, StubAuthCalloutMint,
12+
};
713
pub use error::BridgeError;
814
pub use identity::{BridgeAgentId, BridgeUserJwt, CallerHttpsAuth, MintedCallerId};
15+
pub use outbound::{AgentRegistrationId, MethodSegment, OutboundHttpsAgentUpstream, StubOutboundForwarder, forward};

0 commit comments

Comments
 (0)