Skip to main content

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}