Skip to content

Commit 7daf7c4

Browse files
authored
feat(a2a-auth-callout): wire production main and add docker-gated integration test (#377)
Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
1 parent a4475c7 commit 7daf7c4

5 files changed

Lines changed: 966 additions & 22 deletions

File tree

rsworkspace/Cargo.lock

Lines changed: 9 additions & 7 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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ sha2 = { workspace = true }
3737
time = { workspace = true }
3838
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "sync"] }
3939
tracing = { workspace = true }
40+
tracing-subscriber = { version = "=0.3.23", features = ["fmt", "env-filter"] }
4041
uuid = { workspace = true }
4142
x509-parser = { version = "=0.16.0", features = ["verify"] }
4243
data-encoding = "=2.10.0"
@@ -46,6 +47,7 @@ tempfile = { workspace = true }
4647
rand = { workspace = true }
4748
rcgen = "=0.13.2"
4849
rsa = { version = "=0.9.7", features = ["pem"] }
50+
testcontainers = "=0.27.0"
4951
wiremock = { workspace = true }
5052

5153
[features]

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,6 @@ pub use signing_key_source::{
4141
EnvSigningKeySource, FileSigningKeySource, KeyVersion, KeyVersionError, MintingMaterial, SigningKeyHandle,
4242
SigningKeySource, StaticSigningKeySource, VaultSigningKeySource, signing_key_source_from_process_env,
4343
};
44+
pub use subscriber::{DenialPublisherConfig, Subscriber};
45+
pub use wire::AuthCalloutWireCodec;
4446
pub use wire::{NkeyPublic, NkeySeed, XkeyPublic};
Lines changed: 215 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,217 @@
1-
// Stubbed in PR-a1 (scaffolding only). The real entrypoint that wires
2-
// signing keys, dispatcher, subscriber and graceful shutdown lands with the
3-
// final PR in this series — see `.trogonai/todos/a2a-auth-callout-pr-plan.internal.trogonai.md`.
4-
#[cfg(not(coverage))]
5-
fn main() {}
6-
7-
#[cfg(coverage)]
8-
fn main() {}
9-
10-
#[cfg(test)]
11-
mod tests {
12-
#[test]
13-
#[cfg(coverage)]
14-
fn coverage_main_stub_is_callable() {
15-
super::main();
1+
use std::sync::Arc;
2+
use std::time::Duration;
3+
4+
use tracing::{info, warn};
5+
6+
use a2a_auth_callout::credentials::mtls::MTlsVerifier;
7+
use a2a_auth_callout::credentials::mtls::{TrustAnchorPem, X509MtlsVerifier};
8+
use a2a_auth_callout::credentials::oidc::{JwksOidcVerifier, OidcIssuerUrl, OidcVerifier};
9+
use a2a_auth_callout::dispatcher::{CalloutDispatcher, CalloutDispatcherConfig};
10+
use a2a_auth_callout::error::AuthCalloutError;
11+
use a2a_auth_callout::signing_key_source::signing_key_source_from_process_env;
12+
use a2a_auth_callout::{
13+
AccountResolver, AuthCalloutWireCodec, NkeyPublic, NkeySeed, StaticAccountResolver, Subscriber, XkeyPublic,
14+
};
15+
16+
#[allow(dead_code)]
17+
const DEFAULT_CALLOUT_ISSUER: &str = "AUTH_CALLOUT_DEV_ISSUER";
18+
19+
const DEFAULT_USER_JWT_TTL_SECS: u64 = 300;
20+
21+
fn split_env_list(name: &str) -> Vec<String> {
22+
std::env::var(name)
23+
.ok()
24+
.map(|v| {
25+
v.split(',')
26+
.map(str::trim)
27+
.filter(|s| !s.is_empty())
28+
.map(String::from)
29+
.collect()
30+
})
31+
.unwrap_or_default()
32+
}
33+
34+
fn env_required(name: &'static str) -> Result<String, AuthCalloutError> {
35+
std::env::var(name).map_err(|_| AuthCalloutError::MissingEnvVar(name))
36+
}
37+
38+
fn load_nkey_seed_env(name: &'static str) -> Result<NkeySeed, AuthCalloutError> {
39+
NkeySeed::parse(env_required(name)?)
40+
}
41+
42+
fn load_nkey_public_env(name: &'static str) -> Result<NkeyPublic, AuthCalloutError> {
43+
NkeyPublic::parse(env_required(name)?)
44+
}
45+
46+
async fn build_oidc_verifier() -> Option<Arc<dyn OidcVerifier>> {
47+
let issuer_raw = std::env::var("AUTH_CALLOUT_OIDC_ISSUER").ok()?;
48+
let issuer = match OidcIssuerUrl::parse(&issuer_raw) {
49+
Ok(i) => i,
50+
Err(e) => {
51+
warn!(error = %e, "AUTH_CALLOUT_OIDC_ISSUER set but invalid; OIDC disabled");
52+
return None;
53+
}
54+
};
55+
let audiences = split_env_list("AUTH_CALLOUT_OIDC_AUDIENCES");
56+
if audiences.is_empty() {
57+
warn!("AUTH_CALLOUT_OIDC_ISSUER set but AUTH_CALLOUT_OIDC_AUDIENCES empty; OIDC disabled");
58+
return None;
59+
}
60+
match JwksOidcVerifier::discover(issuer, audiences).await {
61+
Ok(v) => Some(Arc::new(v)),
62+
Err(e) => {
63+
warn!(error = %e, "OIDC discovery failed; OIDC disabled");
64+
None
65+
}
66+
}
67+
}
68+
69+
fn build_mtls_verifier() -> Option<Arc<dyn MTlsVerifier>> {
70+
let path = std::env::var("AUTH_CALLOUT_MTLS_TRUST_ANCHORS").ok()?;
71+
let bundle = match std::fs::read_to_string(&path) {
72+
Ok(s) => s,
73+
Err(e) => {
74+
warn!(path = %path, error = %e, "failed to read mTLS trust anchors; mTLS disabled");
75+
return None;
76+
}
77+
};
78+
Some(Arc::new(X509MtlsVerifier::new(TrustAnchorPem::new(bundle))))
79+
}
80+
81+
#[tokio::main]
82+
async fn main() {
83+
tracing_subscriber::fmt()
84+
.with_env_filter(
85+
tracing_subscriber::EnvFilter::try_from_default_env()
86+
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
87+
)
88+
.init();
89+
90+
let nats_url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".into());
91+
let signing_key_source = match signing_key_source_from_process_env() {
92+
Ok(s) => s,
93+
Err(e) => {
94+
tracing::error!(error = %e, "invalid signing key source configuration");
95+
std::process::exit(1);
96+
}
97+
};
98+
let allowed_accounts = split_env_list("AUTH_CALLOUT_ALLOWED_ACCOUNTS");
99+
if allowed_accounts.is_empty() {
100+
tracing::error!("AUTH_CALLOUT_ALLOWED_ACCOUNTS must list at least one tenant account");
101+
std::process::exit(1);
102+
}
103+
let user_jwt_ttl = match std::env::var("AUTH_CALLOUT_USER_JWT_TTL_SECS") {
104+
Ok(raw) => match raw.parse::<u64>() {
105+
Ok(secs) => Duration::from_secs(secs),
106+
Err(e) => {
107+
tracing::error!(error = %e, raw = %raw, "invalid AUTH_CALLOUT_USER_JWT_TTL_SECS");
108+
std::process::exit(1);
109+
}
110+
},
111+
Err(_) => Duration::from_secs(DEFAULT_USER_JWT_TTL_SECS),
112+
};
113+
114+
let server_issuer = match load_nkey_public_env("AUTH_CALLOUT_SERVER_NKEY_PUBLIC") {
115+
Ok(k) => k,
116+
Err(e) => {
117+
tracing::error!(error = %e, "invalid server NKey configuration");
118+
std::process::exit(1);
119+
}
120+
};
121+
let callout_issuer_seed = match load_nkey_seed_env("AUTH_CALLOUT_ISSUER_NKEY_SEED") {
122+
Ok(k) => k,
123+
Err(e) => {
124+
tracing::error!(error = %e, "invalid callout issuer NKey seed");
125+
std::process::exit(1);
126+
}
127+
};
128+
let account_xkey_seed = match std::env::var("AUTH_CALLOUT_XKEY_SEED")
129+
.ok()
130+
.map(NkeySeed::parse)
131+
.transpose()
132+
{
133+
Ok(s) => s,
134+
Err(e) => {
135+
tracing::error!(error = %e, "invalid AUTH_CALLOUT_XKEY_SEED");
136+
std::process::exit(1);
137+
}
138+
};
139+
140+
let server_xkey_public = match std::env::var("AUTH_CALLOUT_SERVER_XKEY_PUBLIC")
141+
.ok()
142+
.map(XkeyPublic::parse)
143+
.transpose()
144+
{
145+
Ok(s) => s,
146+
Err(e) => {
147+
tracing::error!(error = %e, "invalid AUTH_CALLOUT_SERVER_XKEY_PUBLIC");
148+
std::process::exit(1);
149+
}
150+
};
151+
152+
let wire = match AuthCalloutWireCodec::new(
153+
server_issuer,
154+
callout_issuer_seed,
155+
account_xkey_seed,
156+
server_xkey_public,
157+
) {
158+
Ok(w) => w,
159+
Err(e) => {
160+
tracing::error!(error = %e, "failed to build auth callout wire codec");
161+
std::process::exit(1);
162+
}
163+
};
164+
165+
let resolver: Arc<dyn AccountResolver> = Arc::new(StaticAccountResolver::new(allowed_accounts.clone()));
166+
let oidc = build_oidc_verifier().await;
167+
let mtls = build_mtls_verifier();
168+
169+
if oidc.is_none() && mtls.is_none() {
170+
// A process that boots with zero verifiers looks healthy but
171+
// denies every callout — keep the failure mode loud and
172+
// consistent with the other required-config exits above.
173+
tracing::error!(
174+
"no credential verifiers configured; set AUTH_CALLOUT_OIDC_ISSUER and/or AUTH_CALLOUT_MTLS_TRUST_ANCHORS"
175+
);
176+
std::process::exit(1);
177+
}
178+
179+
info!(nats_url = %nats_url, accounts = ?allowed_accounts, "connecting to NATS for auth callout");
180+
181+
let connect_opts = match (std::env::var("NATS_USER").ok(), std::env::var("NATS_PASSWORD").ok()) {
182+
(Some(user), Some(password)) => async_nats::ConnectOptions::new().user_and_password(user, password),
183+
_ => async_nats::ConnectOptions::new(),
184+
};
185+
let client = connect_opts.connect(&nats_url).await.unwrap_or_else(|e| {
186+
tracing::error!(error = %e, "failed to connect to NATS");
187+
std::process::exit(1);
188+
});
189+
190+
let dispatcher = CalloutDispatcher::new(CalloutDispatcherConfig {
191+
signing_key_source,
192+
user_jwt_ttl,
193+
account_resolver: resolver,
194+
oidc,
195+
mtls,
196+
api_key: None,
197+
});
198+
let subscriber = Subscriber::new(client, dispatcher, wire);
199+
200+
info!("auth callout subscriber running");
201+
202+
// `Subscriber::run` returns `Ok(())` when the NATS subscription closes
203+
// (connection drop, shutdown, etc.) — that is never a healthy steady
204+
// state for this process, so exit non-zero in both arms so the
205+
// orchestrator restarts the pod instead of treating the silent exit
206+
// as a clean shutdown.
207+
match subscriber.run().await {
208+
Ok(()) => {
209+
tracing::error!("auth callout subscriber exited cleanly; NATS subscription closed");
210+
std::process::exit(1);
211+
}
212+
Err(e) => {
213+
tracing::error!(error = %e, "auth callout subscriber exited with error");
214+
std::process::exit(1);
215+
}
16216
}
17217
}

0 commit comments

Comments
 (0)