|
| 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 | +} |
0 commit comments