Skip to main content

capsule_runtime/
door_auth.rs

1//! Who may call the A2A door: the tokens a capsule declaring `network.authentication` mints at
2//! launch, the check every gated request passes, and the refusals it answers with.
3//!
4//! **A token is a pure bearer token.** It is a [`crate::mac_token`] token of the family
5//! [`DOOR_TOKEN_TAG`], sealed with a [`MintKey`] generated in memory for one session and bound to
6//! nothing, so holding it is the whole of what a caller must do. That is what lets a standard A2A
7//! client given a token call the door. Its payload is `{"credential": "<name>", "scopes": [...]}`.
8//! The key is never written anywhere, so a token dies with the session that minted it and a
9//! restart mints new ones. There is no revocation list: dropping the key revokes every token.
10//!
11//! **The operator token always exists.** It holds every scope in [`DOOR_SCOPES`]. Each credential
12//! the manifest declares mints one more token, carrying exactly that credential's scopes, so a
13//! declared credential exists to hand out less than the operator token.
14//!
15//! **Verification order is fixed: header → MAC → payload.** A refusal never says why a token was
16//! not accepted, so the door cannot be used as an oracle.
17//!
18//! **A scope limits methods, not tasks.** Every authenticated caller shares the session's one task
19//! and context space, as every caller shares it on a public door.
20//!
21//! A token reaches no guest, no trace, no log, no error body and no `Debug` output. [`DoorToken`]
22//! implements neither `Display` nor `Serialize`, and [`DoorToken::expose`] is the one way to read
23//! it.
24
25use murmur_artifact::{
26    security_warning_link, NetworkAuthentication, RuntimeManifest, DOOR_SCOPES,
27    OPERATOR_CREDENTIAL, W_SEC_032,
28};
29use serde_json::{json, Value};
30
31use crate::mac_token::{self, MintKey};
32use crate::resource_plane::ResourceResponse;
33
34/// The version tag of a door token.
35pub const DOOR_TOKEN_TAG: &str = "mdt1";
36
37/// The MAC domain of a door token. Distinct from every other family's, so no other token this
38/// process mints can verify at the door.
39pub const DOOR_TOKEN_DOMAIN: &[u8] = b"murmur-door-token-v1";
40
41/// The environment variable `mur cancel` and `mur watch` read a door token from when the capsule is
42/// named by `--url` rather than by session address.
43pub const DOOR_TOKEN_ENV: &str = "MURMUR_DOOR_TOKEN";
44
45/// The scope that reaches the operator resource plane under `/resources/files`.
46pub const RESOURCES_FILES_SCOPE: &str = "resources/files";
47
48/// The scheme name in `Authorization`, matched without regard to case.
49const BEARER_SCHEME: &str = "bearer";
50
51// ── Tokens ────────────────────────────────────────────────────────────────────
52
53/// One door token's text.
54///
55/// `Debug` prints `DoorToken(<redacted>)`. There is no `Display` and no `Serialize`, so the text
56/// reaches a log line, a trace event or an error body by no route but [`Self::expose`].
57#[derive(Clone, PartialEq, Eq)]
58pub struct DoorToken(String);
59
60impl DoorToken {
61    pub fn new(token: String) -> Self {
62        Self(token)
63    }
64
65    /// The token text, for an `Authorization` header or the line `mur run` prints.
66    pub fn expose(&self) -> &str {
67        &self.0
68    }
69}
70
71impl std::fmt::Debug for DoorToken {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.write_str("DoorToken(<redacted>)")
74    }
75}
76
77/// The `Authorization` header value that presents `token`.
78pub fn bearer_header(token: &DoorToken) -> String {
79    format!("Bearer {}", token.expose())
80}
81
82/// The token an `Authorization` header value presents under the `Bearer` scheme, or `None` for
83/// any other shape. The scheme is matched without regard to case (RFC 7235); the token is returned
84/// as sent, because every token murmur mints is case-sensitive base64url.
85pub(crate) fn bearer_token(authorization: &str) -> Option<&str> {
86    let (scheme, token) = authorization
87        .trim()
88        .split_once(|c: char| c.is_ascii_whitespace())?;
89    scheme
90        .eq_ignore_ascii_case(BEARER_SCHEME)
91        .then(|| token.trim())
92}
93
94// ── Minting and verifying ─────────────────────────────────────────────────────
95
96/// One session's door key and the tokens minted with it.
97pub struct DoorAuth {
98    key: MintKey,
99    /// Operator first, then the declared credentials in name order.
100    tokens: Vec<(String, DoorToken)>,
101}
102
103impl std::fmt::Debug for DoorAuth {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("DoorAuth")
106            .field(
107                "credentials",
108                &self
109                    .tokens
110                    .iter()
111                    .map(|(name, _)| name.as_str())
112                    .collect::<Vec<_>>(),
113            )
114            .finish_non_exhaustive()
115    }
116}
117
118impl DoorAuth {
119    /// Generates this session's key and mints the operator token, holding every scope in
120    /// [`DOOR_SCOPES`], and one token per declared credential with exactly its scopes.
121    ///
122    /// The `Err` is the operating system refusing randomness, and carries no key material.
123    pub fn mint(authentication: &NetworkAuthentication) -> Result<Self, String> {
124        let key = MintKey::generate()?;
125        let operator_scopes: Vec<String> = DOOR_SCOPES.iter().map(|s| s.to_string()).collect();
126        let mut tokens = vec![(
127            OPERATOR_CREDENTIAL.to_string(),
128            seal(&key, OPERATOR_CREDENTIAL, &operator_scopes),
129        )];
130        for credential in &authentication.credentials {
131            tokens.push((
132                credential.name.clone(),
133                seal(&key, &credential.name, &credential.scopes),
134            ));
135        }
136        Ok(Self { key, tokens })
137    }
138
139    /// The token holding every scope.
140    pub fn operator_token(&self) -> &DoorToken {
141        &self.tokens[0].1
142    }
143
144    /// Every token this session minted, operator first, then the declared credentials by name.
145    pub fn tokens(&self) -> &[(String, DoorToken)] {
146        &self.tokens
147    }
148
149    /// The grant `authorization_headers` present: every `Authorization` header value on the
150    /// request, as sent.
151    ///
152    /// No header is [`AuthRefusal::Missing`]. More than one, a scheme other than `Bearer`, a token
153    /// this key did not seal, and a payload naming a scope outside [`DOOR_SCOPES`] are all
154    /// [`AuthRefusal::Invalid`], indistinguishably.
155    pub fn verify(&self, authorization_headers: &[&str]) -> Result<DoorGrant, AuthRefusal> {
156        let header = match authorization_headers {
157            [] => return Err(AuthRefusal::Missing),
158            [header] => header,
159            _ => return Err(AuthRefusal::Invalid),
160        };
161        let token = bearer_token(header).ok_or(AuthRefusal::Invalid)?;
162        let payload = mac_token::open(&self.key, DOOR_TOKEN_TAG, DOOR_TOKEN_DOMAIN, token, &[])
163            .map_err(|_| AuthRefusal::Invalid)?;
164        let payload: Value = serde_json::from_slice(&payload).map_err(|_| AuthRefusal::Invalid)?;
165        let credential = payload["credential"]
166            .as_str()
167            .ok_or(AuthRefusal::Invalid)?
168            .to_string();
169        let scopes = payload["scopes"]
170            .as_array()
171            .ok_or(AuthRefusal::Invalid)?
172            .iter()
173            .map(|scope| {
174                scope
175                    .as_str()
176                    .filter(|scope| DOOR_SCOPES.contains(scope))
177                    .map(str::to_string)
178                    .ok_or(AuthRefusal::Invalid)
179            })
180            .collect::<Result<Vec<_>, _>>()?;
181        Ok(DoorGrant { credential, scopes })
182    }
183}
184
185fn seal(key: &MintKey, credential: &str, scopes: &[String]) -> DoorToken {
186    let payload = json!({"credential": credential, "scopes": scopes});
187    DoorToken(mac_token::seal(
188        key,
189        DOOR_TOKEN_TAG,
190        DOOR_TOKEN_DOMAIN,
191        payload.to_string().as_bytes(),
192        &[],
193    ))
194}
195
196/// What a verified token reaches.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct DoorGrant {
199    credential: String,
200    scopes: Vec<String>,
201}
202
203impl DoorGrant {
204    /// The credential the token was minted for: `operator` or a declared name.
205    pub fn credential(&self) -> &str {
206        &self.credential
207    }
208
209    /// Whether the token carries `scope`.
210    pub fn allows(&self, scope: &str) -> bool {
211        self.scopes.iter().any(|held| held == scope)
212    }
213
214    /// `Ok` when the token carries `scope`, and the `403` refusal naming both otherwise.
215    pub fn require(&self, scope: &str) -> Result<(), AuthRefusal> {
216        if self.allows(scope) {
217            Ok(())
218        } else {
219            Err(AuthRefusal::InsufficientScope {
220                credential: self.credential.clone(),
221                scope: scope.to_string(),
222            })
223        }
224    }
225}
226
227/// Why the door refused a request before looking at what it asked for.
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub enum AuthRefusal {
230    /// No `Authorization` header.
231    Missing,
232    /// An `Authorization` header that does not present a token this session minted, for any
233    /// reason at all.
234    Invalid,
235    /// A valid token whose credential does not carry the scope the request needs.
236    InsufficientScope { credential: String, scope: String },
237}
238
239impl AuthRefusal {
240    pub fn status(&self) -> u16 {
241        match self {
242            AuthRefusal::Missing | AuthRefusal::Invalid => 401,
243            AuthRefusal::InsufficientScope { .. } => 403,
244        }
245    }
246
247    /// The body's `error` code.
248    pub fn code(&self) -> &'static str {
249        match self {
250            AuthRefusal::Missing => "unauthenticated",
251            AuthRefusal::Invalid => "invalid_token",
252            AuthRefusal::InsufficientScope { .. } => "insufficient_scope",
253        }
254    }
255
256    /// The `www-authenticate` challenge, in RFC 6750's terms, for a door whose realm is `realm`.
257    pub fn challenge(&self, realm: &str) -> String {
258        match self {
259            AuthRefusal::Missing => format!("Bearer realm=\"{realm}\""),
260            AuthRefusal::Invalid => format!("Bearer realm=\"{realm}\", error=\"invalid_token\""),
261            AuthRefusal::InsufficientScope { scope, .. } => {
262                format!("Bearer realm=\"{realm}\", error=\"insufficient_scope\", scope=\"{scope}\"")
263            }
264        }
265    }
266
267    /// The body's `message`. The `403` names the credential and the scope it lacks, and goes only
268    /// to that credential's holder; neither `401` says why.
269    pub fn message(&self) -> String {
270        match self {
271            AuthRefusal::Missing => "this door takes a token this capsule's runtime minted, as \
272                                     Authorization: Bearer <token>"
273                .to_string(),
274            AuthRefusal::Invalid => {
275                "the Authorization header does not present a token this session minted".to_string()
276            }
277            AuthRefusal::InsufficientScope { credential, scope } => {
278                format!("credential '{credential}' does not reach {scope}")
279            }
280        }
281    }
282
283    /// The refusal as an HTTP response: status, challenge, and the
284    /// `{"error": <code>, "message": <sentence>}` body the resource plane also answers with.
285    pub fn response(&self, realm: &str) -> ResourceResponse {
286        let body = json!({"error": self.code(), "message": self.message()});
287        ResourceResponse::framed(
288            self.status(),
289            vec![
290                ("content-type".to_string(), "application/json".to_string()),
291                ("www-authenticate".to_string(), self.challenge(realm)),
292            ],
293            serde_json::to_vec(&body).unwrap_or_default(),
294        )
295    }
296}
297
298// ── The public-door warning ───────────────────────────────────────────────────
299
300/// Whether a `--bind` address keeps the door on this host: an IPv4 address in `127.0.0.0/8`,
301/// `::1`, or `localhost`. Every other address, `0.0.0.0` and `::` among them, and every other
302/// hostname, exposes it.
303pub fn is_loopback_bind(addr: &str) -> bool {
304    let addr = addr.trim();
305    if addr.eq_ignore_ascii_case("localhost") {
306        return true;
307    }
308    let bare = addr
309        .strip_prefix('[')
310        .and_then(|rest| rest.strip_suffix(']'))
311        .unwrap_or(addr);
312    match bare.parse::<std::net::IpAddr>() {
313        Ok(std::net::IpAddr::V4(v4)) => v4.is_loopback(),
314        Ok(std::net::IpAddr::V6(v6)) => v6 == std::net::Ipv6Addr::LOCALHOST,
315        Err(_) => false,
316    }
317}
318
319/// How the door is reachable off this host.
320#[derive(Debug, Clone, Copy)]
321pub enum DoorExposure<'a> {
322    /// `mur run --bind <addr>` with a non-loopback address.
323    Bound(&'a str),
324    /// `mur deploy`, which publishes the loopback-bound port at this URL.
325    Published(&'a str),
326}
327
328impl DoorExposure<'_> {
329    fn phrase(&self) -> String {
330        match self {
331            DoorExposure::Bound(addr) => format!("bound to {addr}"),
332            DoorExposure::Published(url) => format!("published at {url}"),
333        }
334    }
335}
336
337/// What a public door hands any caller, read from the manifest alone so that every surface
338/// renders the same line for the same manifest.
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct PublicDoorDisclosure {
341    pub methods: Vec<&'static str>,
342    pub tools: Vec<String>,
343    pub shell: bool,
344    pub network: bool,
345    pub planes: Vec<&'static str>,
346}
347
348impl PublicDoorDisclosure {
349    /// The disclosure of `manifest`'s door: the methods served under its
350    /// `lifecycle.task_acceptance`, its LLM-visible artifacts, whether it grants shell and network
351    /// egress, and its declared planes.
352    pub fn of(manifest: &RuntimeManifest) -> Self {
353        let acceptance = manifest
354            .lifecycle
355            .as_ref()
356            .map(|lifecycle| lifecycle.task_acceptance.clone())
357            .unwrap_or_default();
358        let capabilities = manifest.capabilities.as_ref();
359        let exports = manifest.exports.as_ref();
360        let planes = [
361            (exports.is_some_and(|e| e.files.is_some()), "files"),
362            (
363                exports.is_some_and(|e| e.peer_files.is_some()),
364                "peer_files",
365            ),
366        ]
367        .into_iter()
368        .filter_map(|(declared, name)| declared.then_some(name))
369        .collect();
370        Self {
371            methods: crate::identity::served_methods(&acceptance, false),
372            tools: manifest
373                .artifacts
374                .iter()
375                .filter(|artifact| artifact.runtime.is_llm_visible())
376                .map(|artifact| artifact.name.clone())
377                .collect(),
378            shell: capabilities
379                .and_then(|c| c.shell.as_ref())
380                .is_some_and(|shell| !shell.allow.is_empty()),
381            network: capabilities
382                .and_then(|c| c.network.as_ref())
383                .is_some_and(|network| !network.allow.is_empty()),
384            planes,
385        }
386    }
387}
388
389/// The one `W-SEC-032` line.
390pub fn render_public_door_warning(
391    exposure: DoorExposure<'_>,
392    disclosure: &PublicDoorDisclosure,
393) -> String {
394    format!(
395        "warning[{W_SEC_032}]: the door is {} and network.authentication is not declared — any \
396         caller that reaches it can call {}, and the agent card publishes to any A2A client the \
397         session id, tools [{}], shell: {}, network: {} and planes [{}] ({})",
398        exposure.phrase(),
399        disclosure.methods.join(", "),
400        disclosure.tools.join(", "),
401        disclosure.shell,
402        disclosure.network,
403        disclosure.planes.join(", "),
404        security_warning_link(W_SEC_032),
405    )
406}
407
408/// The `W-SEC-032` line for `manifest` exposed as `exposure`, or `None` when the manifest declares
409/// `network.authentication` or serves no door at all (no `inference:` block).
410pub fn public_door_warning(
411    manifest: &RuntimeManifest,
412    exposure: DoorExposure<'_>,
413) -> Option<String> {
414    let authenticated = manifest
415        .network
416        .as_ref()
417        .is_some_and(|network| network.authentication.is_some());
418    if authenticated || manifest.inference.is_none() {
419        return None;
420    }
421    Some(render_public_door_warning(
422        exposure,
423        &PublicDoorDisclosure::of(manifest),
424    ))
425}
426
427/// The `W-SEC-032` line for `mur run --bind <bind_addr>` and `mur doctor --bind <bind_addr>`, or
428/// `None` for a loopback bind.
429pub fn public_door_bind_warning(manifest: &RuntimeManifest, bind_addr: &str) -> Option<String> {
430    if is_loopback_bind(bind_addr) {
431        return None;
432    }
433    public_door_warning(manifest, DoorExposure::Bound(bind_addr))
434}
435
436/// The one-line posture `mur doctor` prints for the door: `public (no network.authentication)`,
437/// or `bearer — operator, <name> (<scopes>), …`.
438pub fn door_posture(manifest: &RuntimeManifest) -> String {
439    match manifest
440        .network
441        .as_ref()
442        .and_then(|network| network.authentication.as_ref())
443    {
444        None => "door: public (no network.authentication)".to_string(),
445        Some(authentication) => {
446            let mut credentials = vec![OPERATOR_CREDENTIAL.to_string()];
447            credentials.extend(authentication.credentials.iter().map(|credential| {
448                format!("{} ({})", credential.name, credential.scopes.join(", "))
449            }));
450            format!("door: bearer — {}", credentials.join(", "))
451        }
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use murmur_artifact::{AuthenticationScheme, DoorCredential};
459
460    fn authentication() -> NetworkAuthentication {
461        NetworkAuthentication {
462            scheme: AuthenticationScheme::Bearer,
463            credentials: vec![
464                DoorCredential {
465                    name: "reader".to_string(),
466                    scopes: vec!["resources/files".to_string()],
467                },
468                DoorCredential {
469                    name: "watcher".to_string(),
470                    scopes: vec!["tasks/get".to_string(), "stream/watch".to_string()],
471                },
472            ],
473        }
474    }
475
476    fn header(token: &DoorToken) -> String {
477        bearer_header(token)
478    }
479
480    #[test]
481    fn door_auth_mints_operator_first_then_declared_credentials_by_name() {
482        let auth = DoorAuth::mint(&authentication()).unwrap();
483        let names: Vec<&str> = auth.tokens().iter().map(|(n, _)| n.as_str()).collect();
484        assert_eq!(names, ["operator", "reader", "watcher"]);
485        assert_eq!(auth.operator_token(), &auth.tokens()[0].1);
486        for (_, token) in auth.tokens() {
487            assert!(token.expose().starts_with("mdt1."), "{}", token.expose());
488        }
489    }
490
491    #[test]
492    fn door_auth_token_payload_is_credential_and_scopes() {
493        let auth = DoorAuth::mint(&authentication()).unwrap();
494        let payload = |token: &DoorToken| -> Value {
495            serde_json::from_slice(
496                &mac_token::payload_segment(DOOR_TOKEN_TAG, token.expose()).unwrap(),
497            )
498            .unwrap()
499        };
500        assert_eq!(
501            payload(auth.operator_token()),
502            json!({"credential": "operator", "scopes": DOOR_SCOPES})
503        );
504        assert_eq!(
505            payload(&auth.tokens()[2].1),
506            json!({"credential": "watcher", "scopes": ["tasks/get", "stream/watch"]})
507        );
508        let raw = mac_token::payload_segment(DOOR_TOKEN_TAG, auth.tokens()[1].1.expose()).unwrap();
509        assert_eq!(
510            String::from_utf8(raw).unwrap(),
511            r#"{"credential":"reader","scopes":["resources/files"]}"#
512        );
513    }
514
515    #[test]
516    fn door_auth_tokens_open_under_the_door_family_with_nothing_bound() {
517        let auth = DoorAuth::mint(&authentication()).unwrap();
518        let token = auth.operator_token().expose();
519        assert!(mac_token::open(&auth.key, DOOR_TOKEN_TAG, DOOR_TOKEN_DOMAIN, token, &[]).is_ok());
520        assert!(mac_token::open(
521            &auth.key,
522            DOOR_TOKEN_TAG,
523            b"murmur-control-token-v1",
524            token,
525            &[]
526        )
527        .is_err());
528    }
529
530    #[test]
531    fn door_auth_verify_grants_each_token_its_scopes() {
532        let auth = DoorAuth::mint(&authentication()).unwrap();
533        let operator = auth.verify(&[&header(auth.operator_token())]).unwrap();
534        assert_eq!(operator.credential(), "operator");
535        for scope in DOOR_SCOPES {
536            assert!(operator.allows(scope), "{scope}");
537        }
538        let watcher = auth.verify(&[&header(&auth.tokens()[2].1)]).unwrap();
539        assert_eq!(watcher.credential(), "watcher");
540        assert!(watcher.allows("tasks/get"));
541        assert!(watcher.allows("stream/watch"));
542        assert!(!watcher.allows("message/send"));
543        assert_eq!(
544            watcher.require("message/send"),
545            Err(AuthRefusal::InsufficientScope {
546                credential: "watcher".to_string(),
547                scope: "message/send".to_string(),
548            })
549        );
550        assert_eq!(watcher.require("tasks/get"), Ok(()));
551    }
552
553    #[test]
554    fn door_auth_verify_refusals() {
555        let auth = DoorAuth::mint(&authentication()).unwrap();
556        let other = DoorAuth::mint(&authentication()).unwrap();
557        let token = auth.operator_token().expose().to_string();
558
559        assert_eq!(auth.verify(&[]), Err(AuthRefusal::Missing));
560        let good = format!("Bearer {token}");
561        assert_eq!(auth.verify(&[&good, &good]), Err(AuthRefusal::Invalid));
562        for header in [
563            format!("Basic {token}"),
564            format!("Token {token}"),
565            token.clone(),
566            "Bearer".to_string(),
567            "Bearer ".to_string(),
568            "Bearer garbage".to_string(),
569            format!("Bearer {}", other.operator_token().expose()),
570            format!("Bearer {}", token.to_uppercase()),
571            format!("Bearer {}x", token),
572        ] {
573            assert_eq!(
574                auth.verify(&[&header]),
575                Err(AuthRefusal::Invalid),
576                "{header}"
577            );
578        }
579        // The scheme is matched without regard to case; the token verbatim.
580        for scheme in ["bearer", "BEARER", "BeArEr"] {
581            assert!(
582                auth.verify(&[&format!("{scheme} {token}")]).is_ok(),
583                "{scheme}"
584            );
585        }
586    }
587
588    #[test]
589    fn door_auth_verify_refuses_a_sealed_payload_with_a_scope_outside_the_set() {
590        let auth = DoorAuth::mint(&authentication()).unwrap();
591        let forged = seal(&auth.key, "watcher", &["tasks/list".to_string()]);
592        assert_eq!(auth.verify(&[&header(&forged)]), Err(AuthRefusal::Invalid));
593        let not_json = mac_token::seal(&auth.key, DOOR_TOKEN_TAG, DOOR_TOKEN_DOMAIN, b"[]", &[]);
594        assert_eq!(
595            auth.verify(&[&format!("Bearer {not_json}")]),
596            Err(AuthRefusal::Invalid)
597        );
598    }
599
600    #[test]
601    fn door_auth_debug_prints_no_token() {
602        let auth = DoorAuth::mint(&authentication()).unwrap();
603        let token = auth.operator_token().clone();
604        assert_eq!(format!("{token:?}"), "DoorToken(<redacted>)");
605        let debug = format!("{auth:?}");
606        for (_, token) in auth.tokens() {
607            assert!(!debug.contains(token.expose()), "{debug}");
608        }
609        assert!(debug.contains("watcher"), "{debug}");
610    }
611
612    #[test]
613    fn door_auth_refusal_responses() {
614        let realm = "my-agent";
615        let cases = [
616            (
617                AuthRefusal::Missing,
618                401,
619                "Bearer realm=\"my-agent\"",
620                "unauthenticated",
621            ),
622            (
623                AuthRefusal::Invalid,
624                401,
625                "Bearer realm=\"my-agent\", error=\"invalid_token\"",
626                "invalid_token",
627            ),
628            (
629                AuthRefusal::InsufficientScope {
630                    credential: "watcher".to_string(),
631                    scope: "message/send".to_string(),
632                },
633                403,
634                "Bearer realm=\"my-agent\", error=\"insufficient_scope\", scope=\"message/send\"",
635                "insufficient_scope",
636            ),
637        ];
638        for (refusal, status, challenge, code) in cases {
639            let response = refusal.response(realm);
640            assert_eq!(response.status, status);
641            assert!(response
642                .headers
643                .contains(&("www-authenticate".to_string(), challenge.to_string())));
644            let body: Value = serde_json::from_slice(&response.body).unwrap();
645            assert_eq!(body["error"], code);
646            assert!(body["message"].is_string());
647        }
648        let body: Value = serde_json::from_slice(
649            &AuthRefusal::InsufficientScope {
650                credential: "watcher".to_string(),
651                scope: "message/send".to_string(),
652            }
653            .response(realm)
654            .body,
655        )
656        .unwrap();
657        assert_eq!(
658            body["message"],
659            "credential 'watcher' does not reach message/send"
660        );
661    }
662
663    /// The manifest's scope list and the door's method table cannot drift: every gated method is a
664    /// scope, and every scope but the resource plane's is a gated method.
665    #[test]
666    fn door_auth_scopes_are_the_gated_methods_and_the_resource_plane() {
667        use crate::identity::DoorMethod;
668        let mut from_methods: Vec<&str> = DoorMethod::ALL
669            .into_iter()
670            .filter(|method| *method != DoorMethod::GetAuthenticatedExtendedCard)
671            .map(DoorMethod::wire_name)
672            .collect();
673        from_methods.push(RESOURCES_FILES_SCOPE);
674        for scope in &from_methods {
675            assert!(DOOR_SCOPES.contains(scope), "{scope} is not in DOOR_SCOPES");
676        }
677        for scope in DOOR_SCOPES {
678            assert!(
679                from_methods.contains(scope),
680                "{scope} names no gated method or plane"
681            );
682        }
683        assert_eq!(from_methods.len(), DOOR_SCOPES.len());
684        for method in DoorMethod::ALL {
685            assert_eq!(
686                method.scope(),
687                (method != DoorMethod::GetAuthenticatedExtendedCard).then(|| method.wire_name())
688            );
689        }
690    }
691
692    #[test]
693    fn public_door_warning_is_loopback_bind() {
694        for addr in [
695            "127.0.0.1",
696            "127.1.2.3",
697            "127.255.255.255",
698            "::1",
699            "[::1]",
700            "localhost",
701            "LOCALHOST",
702        ] {
703            assert!(is_loopback_bind(addr), "{addr}");
704        }
705        for addr in [
706            "0.0.0.0",
707            "::",
708            "[::]",
709            "10.0.0.1",
710            "192.168.1.2",
711            "128.0.0.1",
712            "example.com",
713            "localhost.localdomain",
714            "::2",
715            "",
716        ] {
717            assert!(!is_loopback_bind(addr), "{addr}");
718        }
719    }
720
721    fn manifest(extra: &str) -> RuntimeManifest {
722        RuntimeManifest::from_yaml_str(&format!(
723            "name: my-agent\nversion: 0.1.0\nartifacts:\n  - name: bash\n    version: 1.0.0\n    \
724             runtime: tool\n  - name: drv\n    version: 1.0.0\n    runtime: driver\n    gateway:\n      \
725             endpoint: https://api.example.com\n      api_key: k\n\
726             inference:\n  transport: http\n  model: m\n  driver:\n    artifact: drv\n\
727             capabilities:\n  shell:\n    allow: [git]\n  network:\n    allow: [api.example.com]\n\
728             exports:\n  files:\n    root: out\n    mode: read-only\n{extra}"
729        ))
730        .unwrap()
731    }
732
733    #[test]
734    fn public_door_warning_text() {
735        let line = public_door_bind_warning(&manifest(""), "0.0.0.0").expect("a warning");
736        assert_eq!(
737            line,
738            format!(
739                "warning[W-SEC-032]: the door is bound to 0.0.0.0 and network.authentication is \
740                 not declared — any caller that reaches it can call message/send, message/stream, \
741                 stream/watch, tasks/get, tasks/cancel, session/stop, and the agent card publishes \
742                 to any A2A client the session id, tools [bash], shell: true, network: true and \
743                 planes [files] ({})",
744                security_warning_link(W_SEC_032)
745            )
746        );
747        let published = public_door_warning(
748            &manifest("lifecycle:\n  task_acceptance: none\n"),
749            DoorExposure::Published("http://203.0.113.9:41873"),
750        )
751        .unwrap();
752        assert!(
753            published.contains("published at http://203.0.113.9:41873"),
754            "{published}"
755        );
756        assert!(
757            published.contains("can call stream/watch, tasks/get, tasks/cancel, session/stop,"),
758            "{published}"
759        );
760    }
761
762    #[test]
763    fn public_door_warning_is_silent_for_loopback_and_authenticated_doors() {
764        assert_eq!(public_door_bind_warning(&manifest(""), "127.0.0.1"), None);
765        assert_eq!(public_door_bind_warning(&manifest(""), "localhost"), None);
766        let authenticated = manifest("network:\n  authentication:\n    scheme: bearer\n");
767        assert_eq!(public_door_bind_warning(&authenticated, "0.0.0.0"), None);
768        assert_eq!(
769            public_door_warning(&authenticated, DoorExposure::Published("http://h:1")),
770            None
771        );
772    }
773
774    #[test]
775    fn public_door_warning_posture_line() {
776        assert_eq!(
777            door_posture(&manifest("")),
778            "door: public (no network.authentication)"
779        );
780        let authenticated = manifest(
781            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
782             watcher: {scopes: [tasks/get, stream/watch]}\n      reader: {scopes: [resources/files]}\n",
783        );
784        assert_eq!(
785            door_posture(&authenticated),
786            "door: bearer — operator, reader (resources/files), watcher (tasks/get, stream/watch)"
787        );
788    }
789}