atd_runtime/secrets.rs
1//! Token broker extension point for multi-tenant ATD servers.
2//!
3//! [`TokenBroker`] is the trait an operator implements to map a caller
4//! identity (`CallContext::caller_id`, populated from the SP-12 Hello
5//! handshake) to a [`SecretBundle`] that gets attached to the
6//! [`crate::CallContext`] before `Tool::call` runs. Tools that need secrets
7//! read them via [`crate::CallContext::secrets`]; tools that don't, ignore the
8//! field — full back-compat with single-tenant deployments.
9//!
10//! Secrets are wrapped in [`RedactedString`], whose `Debug`/`Display`
11//! impls refuse to print the value. Audit logs include only a
12//! `secrets_resolved: bool` flag (no key names, no values).
13//!
14//! See `docs/superpowers/specs/2026-04-27-sp-token-broker-phase1-design.md`
15//! for the design rationale; Phase 2 (adopter wiring in healthkit_cli)
16//! and Phase 3 (live two-tenant demo) are separate SPs.
17
18use std::collections::HashMap;
19use std::fmt;
20use std::future::Future;
21use std::pin::Pin;
22use std::sync::Arc;
23
24use thiserror::Error;
25
26/// String wrapper that refuses to render its value in `Debug` or
27/// `Display`. The value is only accessible via [`Self::expose`] — by
28/// convention, callers should not log the result of `expose()`.
29pub struct RedactedString(String);
30
31impl RedactedString {
32 pub fn new(s: impl Into<String>) -> Self {
33 Self(s.into())
34 }
35 /// Returns the underlying value. **Never log or audit the result.**
36 pub fn expose(&self) -> &str {
37 &self.0
38 }
39}
40
41impl fmt::Debug for RedactedString {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 f.write_str("RedactedString(<redacted>)")
44 }
45}
46
47impl fmt::Display for RedactedString {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 f.write_str("<redacted>")
50 }
51}
52
53/// Bag of named secrets resolved for one caller. Keys are
54/// operator-defined (e.g., `"oauth_token"`, `"refresh_token"`,
55/// `"api_key"`).
56pub type SecretBundle = HashMap<String, RedactedString>;
57
58/// Errors that can be returned by a [`TokenBroker::resolve`] or
59/// [`TokenBroker::resolve_bearer`] call.
60#[derive(Error, Debug)]
61pub enum BrokerError {
62 #[error("broker not configured for this server")]
63 NotConfigured,
64 #[error("lookup failed for caller: {0}")]
65 Lookup(String),
66 #[error("internal broker error: {0}")]
67 Internal(String),
68 /// Bearer was recognised but its advertised expiry has passed.
69 /// SP-token-broker-phase2 §4.4. Maps to HTTP 401 at the listener.
70 #[error("bearer expired")]
71 Expired,
72 /// Bearer was recognised but its underlying grant has been
73 /// administratively revoked (status flipped server-side).
74 /// SP-token-broker-phase2 §4.4 + §4.8.
75 #[error("bearer revoked: {0}")]
76 Revoked(String),
77}
78
79/// Owned-future return type for [`TokenBroker::resolve`]. Modeled on
80/// `registry::CallFuture` to avoid pulling in `async_trait`.
81pub type ResolveFuture<'a> =
82 Pin<Box<dyn Future<Output = Result<Option<Arc<SecretBundle>>, BrokerError>> + Send + 'a>>;
83
84/// Owned-future return type for [`TokenBroker::resolve_bearer`].
85/// SP-streamable-http §4.4 + SP-token-broker-phase2 §5.
86pub type ResolveBearerFuture<'a> =
87 Pin<Box<dyn Future<Output = Result<Option<BearerIdentity>, BrokerError>> + Send + 'a>>;
88
89/// Outcome of a successful bearer resolution. Returned by
90/// [`TokenBroker::resolve_bearer`]; the HTTP listener consumes this to
91/// build a `CallContext` per request (SP-streamable-http §4.3).
92///
93/// Fields are public so brokers in any crate can use struct-literal
94/// construction. New fields, if added, will be a minor-version bump.
95#[derive(Debug, Clone)]
96pub struct BearerIdentity {
97 /// Stable caller identifier. Same shape as the
98 /// `CallContext::caller_id` populated from SP-12 `Hello.client_id`,
99 /// so RBAC checks downstream of the listener treat HTTP callers
100 /// uniformly with UDS callers.
101 pub caller_id: String,
102
103 /// Capabilities this bearer's caller is granted. The HTTP listener
104 /// intersects these with the server's `granted_capabilities`
105 /// allow-list before each `tools/call`
106 /// (SP-streamable-http §4.3, SP-12 Hello semantics specialised
107 /// per-request rather than per-connection).
108 pub granted_capabilities: Vec<String>,
109
110 /// Optional secret bundle, same role as the phase-1
111 /// [`TokenBroker::resolve`] return. Brokers MAY supply both
112 /// `secrets` and the bearer identity in one resolve_bearer call
113 /// when the bearer carries enough info to pre-stage secrets;
114 /// otherwise leave `None` and let the listener call `resolve`
115 /// separately. Celia leaves this `None` because the DEK lives in
116 /// `KeyCache` only (patent §13.1) and is never relayed.
117 pub secrets: Option<Arc<SecretBundle>>,
118
119 /// Absolute time at which this bearer ceases to be valid. `None`
120 /// means "no advertised expiry" (Celia process-lifetime semantics
121 /// — pairing codes live until the user revokes them in the wizard
122 /// or the host process restarts). SSE listeners use this to
123 /// schedule re-validation cadence per SP-token-broker-phase2 §4.7.
124 pub expires_at: Option<std::time::SystemTime>,
125
126 /// Hint to the broker's own cache layer: do not return this
127 /// `BearerIdentity` from cache after this time without
128 /// revalidating. `None` lets the broker choose freely.
129 pub cache_until: Option<std::time::SystemTime>,
130}
131
132/// Server-side extension point that resolves secrets for a caller.
133///
134/// Implementations should be cheap to call (per-`CallContext` overhead);
135/// long-lived bundles ought to be cached by the broker itself.
136pub trait TokenBroker: Send + Sync {
137 /// Resolve a secret bundle for the given caller.
138 ///
139 /// - `Ok(None)` — no bundle is registered for this caller. Dispatch
140 /// proceeds with `ctx.secrets() = None`; the tool falls back to
141 /// whatever pre-broker mechanism it used (env vars, saved file).
142 /// - `Ok(Some(bundle))` — bundle attached to the call.
143 /// - `Err(_)` — hard failure; dispatch returns
144 /// `ERR_BROKER_FAILED (1003)` and `Tool::call` is not invoked.
145 fn resolve<'a>(&'a self, caller_id: Option<&'a str>) -> ResolveFuture<'a>;
146
147 /// Resolve a bearer token (from an HTTP `Authorization: Bearer …`
148 /// header) to a [`BearerIdentity`]. The HTTP listener calls this
149 /// once per request before dispatch (SP-streamable-http §4.3).
150 ///
151 /// - `Ok(None)` — bearer was syntactically acceptable but unknown
152 /// to this broker. Listener treats as anonymous (or 401, per
153 /// `require_bearer`).
154 /// - `Ok(Some(identity))` — bearer validated; `identity` carries
155 /// caller id, capabilities, optional secrets + expiry hints.
156 /// - `Err(BrokerError::Lookup)` — transient look-up failure (DB
157 /// down, network blip). HTTP listener maps to 503 + `Retry-After: 5`
158 /// per SP-token-broker-phase2 §4.4. Brokers using `Lookup` for
159 /// "malformed bearer" should switch to a synchronous reject path
160 /// (e.g. return `Ok(None)`) so the listener emits 401 +
161 /// `WWW-Authenticate: Bearer error="invalid_token"` instead.
162 /// - `Err(BrokerError::Expired)` — bearer recognised but past expiry.
163 /// - `Err(BrokerError::Revoked)` — bearer recognised but its grant
164 /// was administratively revoked.
165 /// - `Err(BrokerError::NotConfigured)` — broker does not support
166 /// bearer auth (default impl). Listener treats as anonymous mode.
167 ///
168 /// Default impl returns `Err(NotConfigured)` so phase-1 brokers
169 /// (`InMemoryTokenBroker`) and third-party brokers compile
170 /// unchanged — the only adopters who get HTTP bearer auth are the
171 /// ones who override this method (SP-streamable-http §4.4,
172 /// SP-token-broker-phase2 §5).
173 fn resolve_bearer<'a>(&'a self, _bearer: &'a str) -> ResolveBearerFuture<'a> {
174 Box::pin(async move { Err(BrokerError::NotConfigured) })
175 }
176
177 /// Hint to the operator + diagnostics paths about which token
178 /// format(s) this broker accepts (e.g. `["ce-pairing-code"]`,
179 /// `["jwt-rs256"]`, `["opaque"]`). Listener does NOT route on this
180 /// — it is informational, surfaced through `atd-ref-server --doctor`
181 /// and the `/initialize` server-info echo. Default `&[]` means
182 /// "unspecified / introspect via try-resolve". SP-token-broker-phase2
183 /// §4.2.
184 fn accepted_token_formats(&self) -> &'static [&'static str] {
185 &[]
186 }
187}
188
189/// Reference broker for unit tests + small deployments. Production
190/// adopters should implement their own `TokenBroker` against a real
191/// secret manager (Vault, AWS Secrets Manager, Doppler, …).
192///
193/// SP-capability-v2 (2026-05-11): gained a UCAN-JWT branch in
194/// [`TokenBroker::resolve_bearer`]. Adopters register a mapping from
195/// a UCAN `did:key:z...` audience to the caller id they want assigned
196/// to that DID via [`Self::register_ucan_audience`]. JWT-shape bearers
197/// then resolve to that caller id with the chain's attenuated caps.
198/// Non-JWT bearers continue to return [`BrokerError::NotConfigured`]
199/// (unchanged from phase 1).
200#[derive(Default)]
201pub struct InMemoryTokenBroker {
202 bundles: HashMap<String, Arc<SecretBundle>>,
203 /// SP-capability-v2: `did:key:z...` → `caller_id` mapping. Populated
204 /// at pairing time; consulted on every UCAN bearer presentation.
205 ucan_audiences: HashMap<String, String>,
206 /// SP-capability-v2: per-broker UCAN verifier config. Default uses
207 /// `max_chain_depth = 5`, no revocation store. Adopters that want
208 /// to share a `SharedServerConfig`-level revocation store with this
209 /// broker can override post-construction via [`Self::with_max_chain_depth`]
210 /// and [`Self::with_revocation_store`].
211 ucan_max_chain_depth: u8,
212 ucan_revocation_store: Option<Arc<dyn crate::ucan::UcanRevocationStore>>,
213}
214
215impl InMemoryTokenBroker {
216 pub fn new() -> Self {
217 Self {
218 bundles: HashMap::new(),
219 ucan_audiences: HashMap::new(),
220 ucan_max_chain_depth: 5,
221 ucan_revocation_store: None,
222 }
223 }
224
225 pub fn insert(&mut self, caller_id: impl Into<String>, bundle: SecretBundle) {
226 self.bundles.insert(caller_id.into(), Arc::new(bundle));
227 }
228
229 /// Register a UCAN `did:key:z...` audience as a known identity for
230 /// this broker. A UCAN bearer whose leaf `aud` matches `did_key`
231 /// will resolve to [`BearerIdentity::caller_id`] = `caller_id`.
232 ///
233 /// SP-capability-v2 §4.6 + §6 — celia's analogous mapping is the
234 /// new `consent.did_to_grantee` column.
235 pub fn register_ucan_audience(
236 &mut self,
237 did_key: impl Into<String>,
238 caller_id: impl Into<String>,
239 ) {
240 self.ucan_audiences.insert(did_key.into(), caller_id.into());
241 }
242
243 /// Set the verifier's max chain depth (default `5`).
244 pub fn with_max_chain_depth(mut self, depth: u8) -> Self {
245 self.ucan_max_chain_depth = depth;
246 self
247 }
248
249 /// Attach a revocation store consulted on every UCAN link.
250 pub fn with_revocation_store(
251 mut self,
252 store: Arc<dyn crate::ucan::UcanRevocationStore>,
253 ) -> Self {
254 self.ucan_revocation_store = Some(store);
255 self
256 }
257}
258
259/// Heuristic: a JWT compact form is `<header>.<payload>.<signature>` —
260/// exactly 3 dot-separated, non-empty, base64url-shaped segments.
261/// Used by [`InMemoryTokenBroker::resolve_bearer`] to dispatch between
262/// the UCAN-JWT branch and the legacy-opaque branch. Cheap; runs once
263/// per bearer presentation.
264fn looks_like_jwt(s: &str) -> bool {
265 let parts: Vec<&str> = s.split('.').collect();
266 if parts.len() != 3 {
267 return false;
268 }
269 parts.iter().all(|seg| {
270 !seg.is_empty()
271 && seg
272 .chars()
273 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
274 })
275}
276
277impl TokenBroker for InMemoryTokenBroker {
278 fn resolve<'a>(&'a self, caller_id: Option<&'a str>) -> ResolveFuture<'a> {
279 Box::pin(async move {
280 let Some(id) = caller_id else {
281 return Ok(None);
282 };
283 Ok(self.bundles.get(id).cloned())
284 })
285 }
286
287 fn accepted_token_formats(&self) -> &'static [&'static str] {
288 // Reference broker advertises both: opaque has a hint-only role
289 // (existing phase-1 callers see the same NotConfigured return)
290 // and ucan-jwt is the post-SP-capability-v2 path.
291 &["ucan-jwt", "opaque"]
292 }
293
294 fn resolve_bearer<'a>(&'a self, bearer: &'a str) -> ResolveBearerFuture<'a> {
295 Box::pin(async move {
296 if !looks_like_jwt(bearer) {
297 // Phase-1 semantics preserved: non-JWT bearers are not
298 // recognised by the reference broker.
299 return Err(BrokerError::NotConfigured);
300 }
301 // Parse just enough to extract the leaf's audience (the
302 // signature is re-verified in full by verify_jwt below).
303 // SP-token-broker-phase2 §4.4 wire mapping update: parse
304 // failures, unregistered audiences, and verify failures
305 // (signature / attenuation / etc.) are all "well-formed-
306 // looking but invalid token" → `Ok(None)` → HTTP 401
307 // `invalid_token`. Reserve `Err(Lookup)` for transient
308 // broker storage failures only; reserve `Err(Internal)`
309 // for broker bugs.
310 let leaf_payload = match crate::ucan::parse_jwt(bearer) {
311 Ok(p) => p,
312 Err(_) => return Ok(None),
313 };
314
315 let Some(caller_id) = self.ucan_audiences.get(&leaf_payload.aud).cloned() else {
316 return Ok(None);
317 };
318
319 // The broker pins audience to the JWT's own aud (which we
320 // just confirmed maps to a registered caller). Dispatch can
321 // further constrain via its own VerifyConfig if needed
322 // (Phase C does so on UDS via Hello.client_id).
323 let mut cfg = crate::ucan::VerifyConfig::new(leaf_payload.aud.clone());
324 cfg.max_chain_depth = self.ucan_max_chain_depth;
325 cfg.revocation_store = self.ucan_revocation_store.clone();
326
327 let caps = match crate::ucan::verify_jwt(bearer, &cfg, std::time::SystemTime::now()) {
328 Ok(c) => c,
329 Err(crate::ucan::UcanVerifyError::Expired { .. }) => {
330 return Err(BrokerError::Expired);
331 }
332 Err(crate::ucan::UcanVerifyError::Revoked { cid }) => {
333 return Err(BrokerError::Revoked(cid));
334 }
335 Err(_) => return Ok(None),
336 };
337
338 let secrets = self.bundles.get(&caller_id).cloned();
339 Ok(Some(BearerIdentity {
340 caller_id,
341 granted_capabilities: caps.granted(),
342 secrets,
343 // Phase D leaves expiry hints as None; a follow-up SP
344 // can plumb min_exp through ucan::verify_jwt's return
345 // shape if real adopters need SSE re-validation cadence.
346 expires_at: None,
347 cache_until: None,
348 }))
349 })
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn redacted_string_debug_does_not_leak() {
359 let s = RedactedString::new("super-secret-token");
360 let dbg = format!("{:?}", s);
361 assert!(!dbg.contains("super-secret-token"), "leaked: {dbg}");
362 assert!(dbg.contains("redacted"));
363 }
364
365 #[test]
366 fn redacted_string_display_does_not_leak() {
367 let s = RedactedString::new("super-secret-token");
368 let disp = format!("{}", s);
369 assert!(!disp.contains("super-secret-token"), "leaked: {disp}");
370 assert_eq!(disp, "<redacted>");
371 }
372
373 #[test]
374 fn redacted_string_expose_returns_value() {
375 let s = RedactedString::new("super-secret-token");
376 assert_eq!(s.expose(), "super-secret-token");
377 }
378
379 #[test]
380 fn secret_bundle_debug_does_not_leak_values() {
381 let mut bundle = SecretBundle::new();
382 bundle.insert(
383 "oauth_token".to_string(),
384 RedactedString::new("plaintext-token-value"),
385 );
386 let dbg = format!("{:?}", bundle);
387 // Key may appear; value must NOT.
388 assert!(!dbg.contains("plaintext-token-value"), "leaked: {dbg}");
389 assert!(dbg.contains("oauth_token"));
390 }
391
392 #[tokio::test]
393 async fn in_memory_broker_resolves_known_caller() {
394 let mut broker = InMemoryTokenBroker::new();
395 let mut bundle = SecretBundle::new();
396 bundle.insert("oauth".into(), RedactedString::new("tok-A"));
397 broker.insert("agent-A", bundle);
398 let resolved = broker.resolve(Some("agent-A")).await.unwrap();
399 let bundle = resolved.expect("bundle present");
400 assert_eq!(bundle.get("oauth").unwrap().expose(), "tok-A");
401 }
402
403 #[tokio::test]
404 async fn in_memory_broker_returns_none_for_unknown_caller() {
405 let broker = InMemoryTokenBroker::new();
406 assert!(broker.resolve(Some("unknown")).await.unwrap().is_none());
407 }
408
409 #[tokio::test]
410 async fn in_memory_broker_returns_none_for_anonymous_caller() {
411 let mut broker = InMemoryTokenBroker::new();
412 broker.insert("agent-A", SecretBundle::new());
413 // Even with bundles registered, a None caller_id resolves to None.
414 assert!(broker.resolve(None).await.unwrap().is_none());
415 }
416
417 #[tokio::test]
418 async fn non_jwt_bearer_returns_not_configured() {
419 // The reference broker's UCAN-JWT branch (SP-capability-v2 Phase
420 // D) only engages for 3-segment dot-delimited base64url-shaped
421 // input. Opaque tokens like Celia's `ce_<hex>` still fall through
422 // to NotConfigured so adopters with their own opaque schemes
423 // override resolve_bearer themselves. SP-token-broker-phase2 §4.4.
424 let broker = InMemoryTokenBroker::new();
425 let err = broker.resolve_bearer("ce_0123456789abcdef").await;
426 assert!(matches!(err, Err(BrokerError::NotConfigured)));
427 }
428
429 #[test]
430 fn default_accepted_token_formats_lists_ucan_jwt_and_opaque() {
431 // SP-capability-v2 Phase D: InMemoryTokenBroker now advertises
432 // ucan-jwt as its primary accepted format. Listeners surface
433 // this through their introspection endpoint.
434 let broker = InMemoryTokenBroker::new();
435 let formats = broker.accepted_token_formats();
436 assert!(formats.contains(&"ucan-jwt"));
437 assert!(formats.contains(&"opaque"));
438 }
439
440 // ==================== SP-capability-v2 Phase D ====================
441
442 mod ucan_jwt_branch {
443 //! Tests for [`InMemoryTokenBroker::resolve_bearer`]'s UCAN-JWT
444 //! dispatch. JWT-shape input → parse → audience lookup →
445 //! signature/chain verify → BearerIdentity.
446
447 use super::*;
448 use base64::Engine;
449 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
450 use ed25519_dalek::{Signer, SigningKey};
451 use serde_json::json;
452 use std::time::UNIX_EPOCH;
453
454 fn signing_key_for_seed(seed: u8) -> SigningKey {
455 let mut bytes = [0u8; 32];
456 bytes[0] = seed;
457 SigningKey::from_bytes(&bytes)
458 }
459
460 fn did_key_for(sk: &SigningKey) -> String {
461 let raw = sk.verifying_key().to_bytes();
462 let mut prefixed = Vec::with_capacity(34);
463 prefixed.extend_from_slice(&[0xed, 0x01]);
464 prefixed.extend_from_slice(&raw);
465 let mb = multibase::encode(multibase::Base::Base58Btc, &prefixed);
466 format!("did:key:{mb}")
467 }
468
469 fn build_jwt(payload: serde_json::Value, sk: &SigningKey) -> String {
470 let header = json!({"alg": "EdDSA", "typ": "ucan/1.0+jwt", "ucv": "1.0"});
471 let h = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
472 let p = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
473 let signed = format!("{h}.{p}");
474 let sig = sk.sign(signed.as_bytes());
475 let s = URL_SAFE_NO_PAD.encode(sig.to_bytes());
476 format!("{h}.{p}.{s}")
477 }
478
479 fn future_exp() -> i64 {
480 (std::time::SystemTime::now()
481 .duration_since(UNIX_EPOCH)
482 .unwrap()
483 .as_secs()
484 + 3600) as i64
485 }
486
487 fn past_exp() -> i64 {
488 (std::time::SystemTime::now()
489 .duration_since(UNIX_EPOCH)
490 .unwrap()
491 .as_secs()
492 - 3600) as i64
493 }
494
495 fn payload_with(
496 iss: &str,
497 aud: &str,
498 caps: &[&str],
499 prf: &[String],
500 exp: i64,
501 ) -> serde_json::Value {
502 json!({
503 "iss": iss,
504 "aud": aud,
505 "sub": iss,
506 "cmd": "atd-cap",
507 "args": { "caps": caps, "with": [] },
508 "nonce": "test-nonce-fixed",
509 "exp": exp,
510 "prf": prf
511 })
512 }
513
514 #[tokio::test]
515 async fn resolve_bearer_ucan_jwt_returns_identity_from_registered_audience() {
516 let sk_user = signing_key_for_seed(1);
517 let sk_agent = signing_key_for_seed(2);
518 let agent_did = did_key_for(&sk_agent);
519
520 let p = payload_with(
521 &did_key_for(&sk_user),
522 &agent_did,
523 &["records:read"],
524 &[],
525 future_exp(),
526 );
527 let jwt = build_jwt(p, &sk_user);
528
529 let mut broker = InMemoryTokenBroker::new();
530 broker.register_ucan_audience(&agent_did, "agent:A:hk-9001");
531
532 let identity = broker.resolve_bearer(&jwt).await.unwrap().unwrap();
533 assert_eq!(identity.caller_id, "agent:A:hk-9001");
534 assert_eq!(identity.granted_capabilities, vec!["records:read"]);
535 }
536
537 #[tokio::test]
538 async fn resolve_bearer_ucan_jwt_unregistered_aud_rejects_with_lookup() {
539 // Audience DID has not been registered → Lookup error (the
540 // bearer is well-formed but the broker doesn't know who its
541 // intended recipient is).
542 let sk_user = signing_key_for_seed(1);
543 let sk_agent = signing_key_for_seed(2);
544 let p = payload_with(
545 &did_key_for(&sk_user),
546 &did_key_for(&sk_agent),
547 &["records:read"],
548 &[],
549 future_exp(),
550 );
551 let jwt = build_jwt(p, &sk_user);
552
553 let broker = InMemoryTokenBroker::new(); // no register_ucan_audience
554 // SP-token-broker-phase2 §4.4: well-formed but unrecognised
555 // → Ok(None) → 401 invalid_token. (Previously this was
556 // BrokerError::Lookup; corrected in phase-2 to match the
557 // semantic of "Lookup = transient backend failure".)
558 let r = broker.resolve_bearer(&jwt).await;
559 assert!(
560 matches!(r, Ok(None)),
561 "expected Ok(None) for unregistered audience, got {r:?}"
562 );
563 }
564
565 #[tokio::test]
566 async fn resolve_bearer_ucan_jwt_expired_returns_expired() {
567 let sk_user = signing_key_for_seed(1);
568 let sk_agent = signing_key_for_seed(2);
569 let agent_did = did_key_for(&sk_agent);
570
571 let p = payload_with(
572 &did_key_for(&sk_user),
573 &agent_did,
574 &["records:read"],
575 &[],
576 past_exp(), // expired
577 );
578 let jwt = build_jwt(p, &sk_user);
579
580 let mut broker = InMemoryTokenBroker::new();
581 broker.register_ucan_audience(&agent_did, "agent:A");
582
583 let r = broker.resolve_bearer(&jwt).await;
584 assert!(
585 matches!(r, Err(BrokerError::Expired)),
586 "expected BrokerError::Expired, got {r:?}"
587 );
588 }
589
590 #[tokio::test]
591 async fn resolve_bearer_ucan_jwt_bad_signature_returns_ok_none() {
592 // Signed by sk_x but iss claims to be sk_user → BadSignature.
593 // SP-token-broker-phase2 §4.4: signature/attenuation/etc.
594 // verify failures are "well-formed-looking but invalid token"
595 // → Ok(None) → HTTP 401 invalid_token. (Previously surfaced
596 // as BrokerError::Lookup, which would have mapped to 503.)
597 let sk_user = signing_key_for_seed(1);
598 let sk_agent = signing_key_for_seed(2);
599 let sk_x = signing_key_for_seed(99);
600 let agent_did = did_key_for(&sk_agent);
601
602 let p = payload_with(
603 &did_key_for(&sk_user),
604 &agent_did,
605 &["records:read"],
606 &[],
607 future_exp(),
608 );
609 let jwt = build_jwt(p, &sk_x); // wrong signer
610
611 let mut broker = InMemoryTokenBroker::new();
612 broker.register_ucan_audience(&agent_did, "agent:A");
613
614 let r = broker.resolve_bearer(&jwt).await;
615 assert!(
616 matches!(r, Ok(None)),
617 "expected Ok(None) for bad signature, got {r:?}"
618 );
619 }
620
621 #[tokio::test]
622 async fn resolve_bearer_ucan_jwt_secrets_attach_when_caller_has_bundle() {
623 // The reference broker still supplies secrets via the same
624 // bundle table indexed by caller_id — so a UCAN-resolved
625 // caller_id with a registered bundle gets that bundle.
626 let sk_user = signing_key_for_seed(1);
627 let sk_agent = signing_key_for_seed(2);
628 let agent_did = did_key_for(&sk_agent);
629
630 let p = payload_with(
631 &did_key_for(&sk_user),
632 &agent_did,
633 &["records:read"],
634 &[],
635 future_exp(),
636 );
637 let jwt = build_jwt(p, &sk_user);
638
639 let mut broker = InMemoryTokenBroker::new();
640 broker.register_ucan_audience(&agent_did, "agent:A");
641 let mut bundle = SecretBundle::new();
642 bundle.insert("hms_oauth".into(), RedactedString::new("tok-A"));
643 broker.insert("agent:A", bundle);
644
645 let identity = broker.resolve_bearer(&jwt).await.unwrap().unwrap();
646 let secrets = identity.secrets.expect("bundle should be attached");
647 assert_eq!(secrets.get("hms_oauth").unwrap().expose(), "tok-A");
648 }
649
650 #[test]
651 fn looks_like_jwt_accepts_three_base64url_segments() {
652 assert!(super::super::looks_like_jwt("aaa.bbb.ccc"));
653 assert!(super::super::looks_like_jwt("a-b_c.dd-ee_.ffG"));
654 assert!(!super::super::looks_like_jwt("only.two"));
655 assert!(!super::super::looks_like_jwt("a.b.c.d"));
656 assert!(!super::super::looks_like_jwt(".b.c")); // empty seg
657 assert!(!super::super::looks_like_jwt("a.b.c$x")); // illegal char
658 assert!(!super::super::looks_like_jwt("ce_0123456789abcdef")); // opaque
659 }
660 }
661}