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 formation member's door also takes formation tokens.** A door built with a
19//! [`FormationVerifier`] lets in a [`crate::formation_credentials`] token issued to it as the
20//! credential `member:<caller>`, with exactly [`FORMATION_CALL_SCOPES`]. A genuine token issued for
21//! another member's door is `403 not_permitted`; any other token of that family is the same `401`
22//! as any other invalid token. A door with no verifier refuses every formation token as invalid.
23//!
24//! **A scope limits methods; the credential limits tasks.** The operator token reaches every task
25//! of the session, as every caller of a public door does. Any other token reaches only the tasks a
26//! request carrying its credential started, through every method that names or lists a task:
27//! `GetTask`, `ListTasks`, `CancelTask`, `SubscribeToTask`, a message's `taskId` and its
28//! `referenceTaskIds`. A task it does not reach is answered as one the session never held.
29//! `murmur.WatchSession` and `murmur.StopSession` are the session's own methods, and reach every
30//! task whatever the credential holding them started. [`crate::identity::TaskCaller`] states the
31//! rule.
32//!
33//! A token reaches no guest, no trace, no log, no error body and no `Debug` output. [`DoorToken`]
34//! implements neither `Display` nor `Serialize`, and [`DoorToken::expose`] is the one way to read
35//! it.
36
37use murmur_artifact::{
38    security_warning_link, NetworkAuthentication, RuntimeManifest, DOOR_SCOPES,
39    OPERATOR_CREDENTIAL, W_SEC_032,
40};
41use serde_json::{json, Value};
42
43use crate::formation_credentials::{
44    FormationRefusal, FormationVerifier, FORMATION_CALL_SCOPES, FORMATION_CREDENTIAL_PREFIX,
45    FORMATION_TOKEN_TAG,
46};
47use crate::mac_token::{self, MintKey};
48use crate::resource_plane::ResourceResponse;
49
50/// The version tag of a door token.
51pub const DOOR_TOKEN_TAG: &str = "mdt1";
52
53/// The MAC domain of a door token. Distinct from every other family's, so no other token this
54/// process mints can verify at the door.
55pub const DOOR_TOKEN_DOMAIN: &[u8] = b"murmur-door-token-v1";
56
57/// The environment variable `mur cancel` and `mur watch` read a door token from when the capsule is
58/// named by `--url` rather than by session address.
59pub const DOOR_TOKEN_ENV: &str = "MURMUR_DOOR_TOKEN";
60
61/// The scope that reaches the operator resource plane under `/resources/files`.
62pub const RESOURCES_FILES_SCOPE: &str = "resources/files";
63
64/// The scheme name in `Authorization`, matched without regard to case.
65const BEARER_SCHEME: &str = "bearer";
66
67// ── Tokens ────────────────────────────────────────────────────────────────────
68
69/// One door token's text.
70///
71/// `Debug` prints `DoorToken(<redacted>)`. There is no `Display` and no `Serialize`, so the text
72/// reaches a log line, a trace event or an error body by no route but [`Self::expose`].
73#[derive(Clone, PartialEq, Eq)]
74pub struct DoorToken(String);
75
76impl DoorToken {
77    pub fn new(token: String) -> Self {
78        Self(token)
79    }
80
81    /// The token text, for an `Authorization` header, the running record, or `mur token`'s output.
82    pub fn expose(&self) -> &str {
83        &self.0
84    }
85}
86
87impl std::fmt::Debug for DoorToken {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.write_str("DoorToken(<redacted>)")
90    }
91}
92
93/// The `Authorization` header value that presents `token`.
94pub fn bearer_header(token: &DoorToken) -> String {
95    format!("Bearer {}", token.expose())
96}
97
98/// The token an `Authorization` header value presents under the `Bearer` scheme, or `None` for
99/// any other shape. The scheme is matched without regard to case (RFC 7235); the token is returned
100/// as sent, because every token murmur mints is case-sensitive base64url.
101pub(crate) fn bearer_token(authorization: &str) -> Option<&str> {
102    let (scheme, token) = authorization
103        .trim()
104        .split_once(|c: char| c.is_ascii_whitespace())?;
105    scheme
106        .eq_ignore_ascii_case(BEARER_SCHEME)
107        .then(|| token.trim())
108}
109
110// ── Minting and verifying ─────────────────────────────────────────────────────
111
112/// One session's door key and the tokens minted with it, and — for a formation member — what its
113/// door checks a formation token with.
114pub struct DoorAuth {
115    key: MintKey,
116    /// Operator first, then the declared credentials in name order.
117    tokens: Vec<(String, DoorToken)>,
118    formation: Option<FormationVerifier>,
119}
120
121impl std::fmt::Debug for DoorAuth {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("DoorAuth")
124            .field(
125                "credentials",
126                &self
127                    .tokens
128                    .iter()
129                    .map(|(name, _)| name.as_str())
130                    .collect::<Vec<_>>(),
131            )
132            .field("formation", &self.formation)
133            .finish_non_exhaustive()
134    }
135}
136
137impl DoorAuth {
138    /// Generates this session's key and mints the operator token, holding every scope in
139    /// [`DOOR_SCOPES`], and one token per declared credential with exactly its scopes.
140    ///
141    /// The `Err` is the operating system refusing randomness, and carries no key material.
142    pub fn mint(authentication: &NetworkAuthentication) -> Result<Self, String> {
143        let key = MintKey::generate()?;
144        let operator_scopes: Vec<String> = DOOR_SCOPES.iter().map(|s| s.to_string()).collect();
145        let mut tokens = vec![(
146            OPERATOR_CREDENTIAL.to_string(),
147            seal(&key, OPERATOR_CREDENTIAL, &operator_scopes),
148        )];
149        for credential in &authentication.credentials {
150            tokens.push((
151                credential.name.clone(),
152                seal(&key, &credential.name, &credential.scopes),
153            ));
154        }
155        Ok(Self {
156            key,
157            tokens,
158            formation: None,
159        })
160    }
161
162    /// This door, also taking formation tokens issued to the member `verifier` names.
163    pub fn with_formation(mut self, verifier: FormationVerifier) -> Self {
164        self.formation = Some(verifier);
165        self
166    }
167
168    /// The token holding every scope.
169    pub fn operator_token(&self) -> &DoorToken {
170        &self.tokens[0].1
171    }
172
173    /// Every token this session minted, operator first, then the declared credentials by name.
174    pub fn tokens(&self) -> &[(String, DoorToken)] {
175        &self.tokens
176    }
177
178    /// The grant `authorization_headers` present: every `Authorization` header value on the
179    /// request, as sent.
180    ///
181    /// No header is [`AuthRefusal::Missing`]. More than one, a scheme other than `Bearer`, a token
182    /// this key did not seal, and a payload naming a scope outside [`DOOR_SCOPES`] are all
183    /// [`AuthRefusal::Invalid`], indistinguishably. So is a formation token that does not verify,
184    /// and every formation token at a door with no verifier. A genuine formation token issued for
185    /// another door is [`AuthRefusal::NotPermitted`].
186    pub fn verify(&self, authorization_headers: &[&str]) -> Result<DoorGrant, AuthRefusal> {
187        let header = match authorization_headers {
188            [] => return Err(AuthRefusal::Missing),
189            [header] => header,
190            _ => return Err(AuthRefusal::Invalid),
191        };
192        let token = bearer_token(header).ok_or(AuthRefusal::Invalid)?;
193        if token.split('.').next() == Some(FORMATION_TOKEN_TAG) {
194            return self.verify_formation(token);
195        }
196        let payload = mac_token::open(&self.key, DOOR_TOKEN_TAG, DOOR_TOKEN_DOMAIN, token, &[])
197            .map_err(|_| AuthRefusal::Invalid)?;
198        let payload: Value = serde_json::from_slice(&payload).map_err(|_| AuthRefusal::Invalid)?;
199        let credential = payload["credential"]
200            .as_str()
201            .ok_or(AuthRefusal::Invalid)?
202            .to_string();
203        let scopes = payload["scopes"]
204            .as_array()
205            .ok_or(AuthRefusal::Invalid)?
206            .iter()
207            .map(|scope| {
208                scope
209                    .as_str()
210                    .filter(|scope| DOOR_SCOPES.contains(scope))
211                    .map(str::to_string)
212                    .ok_or(AuthRefusal::Invalid)
213            })
214            .collect::<Result<Vec<_>, _>>()?;
215        Ok(DoorGrant {
216            credential,
217            scopes,
218            formation_caller: None,
219        })
220    }
221
222    fn verify_formation(&self, token: &str) -> Result<DoorGrant, AuthRefusal> {
223        let verifier = self.formation.as_ref().ok_or(AuthRefusal::Invalid)?;
224        match verifier.verify(token) {
225            Ok(caller) => Ok(DoorGrant {
226                credential: format!("{FORMATION_CREDENTIAL_PREFIX}{}", caller.from()),
227                scopes: FORMATION_CALL_SCOPES
228                    .iter()
229                    .map(|s| s.to_string())
230                    .collect(),
231                formation_caller: Some(caller.from().to_string()),
232            }),
233            Err(FormationRefusal::Invalid) => Err(AuthRefusal::Invalid),
234            Err(FormationRefusal::NotPermitted { caller }) => {
235                Err(AuthRefusal::NotPermitted { caller })
236            }
237        }
238    }
239}
240
241fn seal(key: &MintKey, credential: &str, scopes: &[String]) -> DoorToken {
242    let payload = json!({"credential": credential, "scopes": scopes});
243    DoorToken(mac_token::seal(
244        key,
245        DOOR_TOKEN_TAG,
246        DOOR_TOKEN_DOMAIN,
247        payload.to_string().as_bytes(),
248        &[],
249    ))
250}
251
252/// What a verified token reaches.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct DoorGrant {
255    credential: String,
256    scopes: Vec<String>,
257    /// The member a formation token was issued to, for a grant from one.
258    formation_caller: Option<String>,
259}
260
261impl DoorGrant {
262    /// The credential the token was minted for: `operator`, a declared name, or `member:<name>`
263    /// for a formation token.
264    pub fn credential(&self) -> &str {
265        &self.credential
266    }
267
268    /// The calling member's roster name when this grant came from a formation token, and `None`
269    /// for every door token.
270    pub fn formation_caller(&self) -> Option<&str> {
271        self.formation_caller.as_deref()
272    }
273
274    /// Whether the token carries `scope`.
275    pub fn allows(&self, scope: &str) -> bool {
276        self.scopes.iter().any(|held| held == scope)
277    }
278
279    /// `Ok` when the token carries `scope`, and the `403` refusal naming both otherwise.
280    pub fn require(&self, scope: &str) -> Result<(), AuthRefusal> {
281        if self.allows(scope) {
282            Ok(())
283        } else {
284            Err(AuthRefusal::InsufficientScope {
285                credential: self.credential.clone(),
286                scope: scope.to_string(),
287            })
288        }
289    }
290}
291
292/// Why the door refused a request before looking at what it asked for.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum AuthRefusal {
295    /// No `Authorization` header.
296    Missing,
297    /// An `Authorization` header that does not present a token this session minted, for any
298    /// reason at all.
299    Invalid,
300    /// A valid token whose credential does not carry the scope the request needs.
301    InsufficientScope { credential: String, scope: String },
302    /// A genuine formation token issued for another member's door: `caller` is a member of this
303    /// formation that the roster does not let call this one.
304    NotPermitted { caller: String },
305}
306
307impl AuthRefusal {
308    pub fn status(&self) -> u16 {
309        match self {
310            AuthRefusal::Missing | AuthRefusal::Invalid => 401,
311            AuthRefusal::InsufficientScope { .. } | AuthRefusal::NotPermitted { .. } => 403,
312        }
313    }
314
315    /// The body's `error` code.
316    pub fn code(&self) -> &'static str {
317        match self {
318            AuthRefusal::Missing => "unauthenticated",
319            AuthRefusal::Invalid => "invalid_token",
320            AuthRefusal::InsufficientScope { .. } => "insufficient_scope",
321            AuthRefusal::NotPermitted { .. } => "not_permitted",
322        }
323    }
324
325    /// The `www-authenticate` challenge, in RFC 6750's terms, for a door whose realm is `realm`.
326    pub fn challenge(&self, realm: &str) -> String {
327        match self {
328            AuthRefusal::Missing => format!("Bearer realm=\"{realm}\""),
329            AuthRefusal::Invalid => format!("Bearer realm=\"{realm}\", error=\"invalid_token\""),
330            AuthRefusal::InsufficientScope { scope, .. } => {
331                format!("Bearer realm=\"{realm}\", error=\"insufficient_scope\", scope=\"{scope}\"")
332            }
333            AuthRefusal::NotPermitted { .. } => {
334                format!("Bearer realm=\"{realm}\", error=\"insufficient_scope\"")
335            }
336        }
337    }
338
339    /// The body's `message`. The `403`s name the credential or the member that presented the
340    /// token, and go only to its holder; neither `401` says why. `not_permitted` names nothing
341    /// else — not this member, not the one the token was issued for.
342    pub fn message(&self) -> String {
343        match self {
344            AuthRefusal::Missing => "this door takes a token this capsule's runtime minted, as \
345                                     Authorization: Bearer <token>"
346                .to_string(),
347            AuthRefusal::Invalid => {
348                "the Authorization header does not present a token this session minted".to_string()
349            }
350            AuthRefusal::InsufficientScope { credential, scope } => {
351                format!("credential '{credential}' does not reach {scope}")
352            }
353            AuthRefusal::NotPermitted { caller } => {
354                format!("member '{caller}' may not call this capsule")
355            }
356        }
357    }
358
359    /// The refusal as an HTTP response: status, challenge, and the
360    /// `{"error": <code>, "message": <sentence>}` body the resource plane also answers with.
361    pub fn response(&self, realm: &str) -> ResourceResponse {
362        let body = json!({"error": self.code(), "message": self.message()});
363        ResourceResponse::framed(
364            self.status(),
365            vec![
366                ("content-type".to_string(), "application/json".to_string()),
367                ("www-authenticate".to_string(), self.challenge(realm)),
368            ],
369            serde_json::to_vec(&body).unwrap_or_default(),
370        )
371    }
372}
373
374// ── The public-door warning ───────────────────────────────────────────────────
375
376/// Whether a `--bind` address keeps the door on this host: an IPv4 address in `127.0.0.0/8`,
377/// `::1`, or `localhost`. Every other address, `0.0.0.0` and `::` among them, and every other
378/// hostname, exposes it.
379pub fn is_loopback_bind(addr: &str) -> bool {
380    let addr = addr.trim();
381    if addr.eq_ignore_ascii_case("localhost") {
382        return true;
383    }
384    let bare = addr
385        .strip_prefix('[')
386        .and_then(|rest| rest.strip_suffix(']'))
387        .unwrap_or(addr);
388    match bare.parse::<std::net::IpAddr>() {
389        Ok(std::net::IpAddr::V4(v4)) => v4.is_loopback(),
390        Ok(std::net::IpAddr::V6(v6)) => v6 == std::net::Ipv6Addr::LOCALHOST,
391        Err(_) => false,
392    }
393}
394
395/// How the door is reachable off this host.
396#[derive(Debug, Clone, Copy)]
397pub enum DoorExposure<'a> {
398    /// `mur run --bind <addr>` with a non-loopback address.
399    Bound(&'a str),
400    /// `mur deploy`, which publishes the loopback-bound port at this URL.
401    Published(&'a str),
402}
403
404impl DoorExposure<'_> {
405    fn phrase(&self) -> String {
406        match self {
407            DoorExposure::Bound(addr) => format!("bound to {addr}"),
408            DoorExposure::Published(url) => format!("published at {url}"),
409        }
410    }
411}
412
413/// What a public door hands any caller, read from the manifest alone so that every surface
414/// renders the same line for the same manifest.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct PublicDoorDisclosure {
417    pub methods: Vec<&'static str>,
418    pub tools: Vec<String>,
419    pub shell: bool,
420    pub network: bool,
421    pub planes: Vec<&'static str>,
422}
423
424impl PublicDoorDisclosure {
425    /// The disclosure of `manifest`'s door: the methods served under its
426    /// `lifecycle.task_acceptance`, its LLM-visible artifacts, whether it grants shell and network
427    /// egress, and its declared planes.
428    pub fn of(manifest: &RuntimeManifest) -> Self {
429        let acceptance = manifest
430            .lifecycle
431            .as_ref()
432            .map(|lifecycle| lifecycle.task_acceptance.clone())
433            .unwrap_or_default();
434        let capabilities = manifest.capabilities.as_ref();
435        let exports = manifest.exports.as_ref();
436        let planes = [
437            (exports.is_some_and(|e| e.files.is_some()), "files"),
438            (
439                exports.is_some_and(|e| e.peer_files.is_some()),
440                "peer_files",
441            ),
442        ]
443        .into_iter()
444        .filter_map(|(declared, name)| declared.then_some(name))
445        .collect();
446        Self {
447            methods: crate::identity::served_methods(&acceptance, false),
448            tools: manifest
449                .artifacts
450                .iter()
451                .filter(|artifact| artifact.runtime.is_llm_visible())
452                .map(|artifact| artifact.name.clone())
453                .collect(),
454            shell: capabilities
455                .and_then(|c| c.shell.as_ref())
456                .is_some_and(|shell| !shell.allow.is_empty()),
457            network: capabilities
458                .and_then(|c| c.network.as_ref())
459                .is_some_and(|network| !network.allow.is_empty()),
460            planes,
461        }
462    }
463}
464
465/// The one `W-SEC-032` line.
466pub fn render_public_door_warning(
467    exposure: DoorExposure<'_>,
468    disclosure: &PublicDoorDisclosure,
469) -> String {
470    format!(
471        "warning[{W_SEC_032}]: the door is {} and network.authentication is not declared — any \
472         caller that reaches it can call {}, and the agent card publishes to any A2A client the \
473         session id, tools [{}], shell: {}, network: {} and planes [{}] ({})",
474        exposure.phrase(),
475        disclosure.methods.join(", "),
476        disclosure.tools.join(", "),
477        disclosure.shell,
478        disclosure.network,
479        disclosure.planes.join(", "),
480        security_warning_link(W_SEC_032),
481    )
482}
483
484/// The `W-SEC-032` line for `manifest` exposed as `exposure`, or `None` when the manifest declares
485/// `network.authentication` or serves no door at all (no `inference:` block).
486pub fn public_door_warning(
487    manifest: &RuntimeManifest,
488    exposure: DoorExposure<'_>,
489) -> Option<String> {
490    let authenticated = manifest
491        .network
492        .as_ref()
493        .is_some_and(|network| network.authentication.is_some());
494    if authenticated || manifest.inference.is_none() {
495        return None;
496    }
497    Some(render_public_door_warning(
498        exposure,
499        &PublicDoorDisclosure::of(manifest),
500    ))
501}
502
503/// The `W-SEC-032` line for `mur run --bind <bind_addr>` and `mur doctor --bind <bind_addr>`, or
504/// `None` for a loopback bind.
505pub fn public_door_bind_warning(manifest: &RuntimeManifest, bind_addr: &str) -> Option<String> {
506    if is_loopback_bind(bind_addr) {
507        return None;
508    }
509    public_door_warning(manifest, DoorExposure::Bound(bind_addr))
510}
511
512/// The one-line posture `mur doctor` prints for the door: `public (no network.authentication)`,
513/// or `bearer — operator, <name> (<scopes>), …`.
514pub fn door_posture(manifest: &RuntimeManifest) -> String {
515    match manifest
516        .network
517        .as_ref()
518        .and_then(|network| network.authentication.as_ref())
519    {
520        None => "door: public (no network.authentication)".to_string(),
521        Some(authentication) => {
522            let mut credentials = vec![OPERATOR_CREDENTIAL.to_string()];
523            credentials.extend(authentication.credentials.iter().map(|credential| {
524                format!("{} ({})", credential.name, credential.scopes.join(", "))
525            }));
526            format!("door: bearer — {}", credentials.join(", "))
527        }
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use murmur_artifact::{AuthenticationScheme, DoorCredential};
535
536    fn authentication() -> NetworkAuthentication {
537        NetworkAuthentication {
538            scheme: AuthenticationScheme::Bearer,
539            credentials: vec![
540                DoorCredential {
541                    name: "reader".to_string(),
542                    scopes: vec!["resources/files".to_string()],
543                },
544                DoorCredential {
545                    name: "watcher".to_string(),
546                    scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
547                },
548            ],
549        }
550    }
551
552    fn header(token: &DoorToken) -> String {
553        bearer_header(token)
554    }
555
556    #[test]
557    fn door_auth_mints_operator_first_then_declared_credentials_by_name() {
558        let auth = DoorAuth::mint(&authentication()).unwrap();
559        let names: Vec<&str> = auth.tokens().iter().map(|(n, _)| n.as_str()).collect();
560        assert_eq!(names, ["operator", "reader", "watcher"]);
561        assert_eq!(auth.operator_token(), &auth.tokens()[0].1);
562        for (_, token) in auth.tokens() {
563            assert!(token.expose().starts_with("mdt1."), "{}", token.expose());
564        }
565    }
566
567    #[test]
568    fn door_auth_token_payload_is_credential_and_scopes() {
569        let auth = DoorAuth::mint(&authentication()).unwrap();
570        let payload = |token: &DoorToken| -> Value {
571            serde_json::from_slice(
572                &mac_token::payload_segment(DOOR_TOKEN_TAG, token.expose()).unwrap(),
573            )
574            .unwrap()
575        };
576        assert_eq!(
577            payload(auth.operator_token()),
578            json!({"credential": "operator", "scopes": DOOR_SCOPES})
579        );
580        assert_eq!(
581            payload(&auth.tokens()[2].1),
582            json!({"credential": "watcher", "scopes": ["GetTask", "murmur.WatchSession"]})
583        );
584        let raw = mac_token::payload_segment(DOOR_TOKEN_TAG, auth.tokens()[1].1.expose()).unwrap();
585        assert_eq!(
586            String::from_utf8(raw).unwrap(),
587            r#"{"credential":"reader","scopes":["resources/files"]}"#
588        );
589    }
590
591    #[test]
592    fn door_auth_tokens_open_under_the_door_family_with_nothing_bound() {
593        let auth = DoorAuth::mint(&authentication()).unwrap();
594        let token = auth.operator_token().expose();
595        assert!(mac_token::open(&auth.key, DOOR_TOKEN_TAG, DOOR_TOKEN_DOMAIN, token, &[]).is_ok());
596        assert!(mac_token::open(
597            &auth.key,
598            DOOR_TOKEN_TAG,
599            b"murmur-control-token-v1",
600            token,
601            &[]
602        )
603        .is_err());
604    }
605
606    #[test]
607    fn door_auth_verify_grants_each_token_its_scopes() {
608        let auth = DoorAuth::mint(&authentication()).unwrap();
609        let operator = auth.verify(&[&header(auth.operator_token())]).unwrap();
610        assert_eq!(operator.credential(), "operator");
611        for scope in DOOR_SCOPES {
612            assert!(operator.allows(scope), "{scope}");
613        }
614        let watcher = auth.verify(&[&header(&auth.tokens()[2].1)]).unwrap();
615        assert_eq!(watcher.credential(), "watcher");
616        assert!(watcher.allows("GetTask"));
617        assert!(watcher.allows("murmur.WatchSession"));
618        assert!(!watcher.allows("murmur.StopSession"));
619        assert!(!watcher.allows("SendMessage"));
620        assert_eq!(
621            watcher.require("SendMessage"),
622            Err(AuthRefusal::InsufficientScope {
623                credential: "watcher".to_string(),
624                scope: "SendMessage".to_string(),
625            })
626        );
627        assert_eq!(watcher.require("GetTask"), Ok(()));
628    }
629
630    #[test]
631    fn door_auth_verify_refusals() {
632        let auth = DoorAuth::mint(&authentication()).unwrap();
633        let other = DoorAuth::mint(&authentication()).unwrap();
634        let token = auth.operator_token().expose().to_string();
635
636        assert_eq!(auth.verify(&[]), Err(AuthRefusal::Missing));
637        let good = format!("Bearer {token}");
638        assert_eq!(auth.verify(&[&good, &good]), Err(AuthRefusal::Invalid));
639        for header in [
640            format!("Basic {token}"),
641            format!("Token {token}"),
642            token.clone(),
643            "Bearer".to_string(),
644            "Bearer ".to_string(),
645            "Bearer garbage".to_string(),
646            format!("Bearer {}", other.operator_token().expose()),
647            format!("Bearer {}", token.to_uppercase()),
648            format!("Bearer {}x", token),
649        ] {
650            assert_eq!(
651                auth.verify(&[&header]),
652                Err(AuthRefusal::Invalid),
653                "{header}"
654            );
655        }
656        // The scheme is matched without regard to case; the token verbatim.
657        for scheme in ["bearer", "BEARER", "BeArEr"] {
658            assert!(
659                auth.verify(&[&format!("{scheme} {token}")]).is_ok(),
660                "{scheme}"
661            );
662        }
663    }
664
665    #[test]
666    fn door_auth_verify_refuses_a_sealed_payload_with_a_scope_outside_the_set() {
667        let auth = DoorAuth::mint(&authentication()).unwrap();
668        let forged = seal(&auth.key, "watcher", &["tasks/list".to_string()]);
669        assert_eq!(auth.verify(&[&header(&forged)]), Err(AuthRefusal::Invalid));
670        let not_json = mac_token::seal(&auth.key, DOOR_TOKEN_TAG, DOOR_TOKEN_DOMAIN, b"[]", &[]);
671        assert_eq!(
672            auth.verify(&[&format!("Bearer {not_json}")]),
673            Err(AuthRefusal::Invalid)
674        );
675    }
676
677    #[test]
678    fn door_auth_debug_prints_no_token() {
679        let auth = DoorAuth::mint(&authentication()).unwrap();
680        let token = auth.operator_token().clone();
681        assert_eq!(format!("{token:?}"), "DoorToken(<redacted>)");
682        let debug = format!("{auth:?}");
683        for (_, token) in auth.tokens() {
684            assert!(!debug.contains(token.expose()), "{debug}");
685        }
686        assert!(debug.contains("watcher"), "{debug}");
687    }
688
689    #[test]
690    fn door_auth_refusal_responses() {
691        let realm = "my-agent";
692        let cases = [
693            (
694                AuthRefusal::Missing,
695                401,
696                "Bearer realm=\"my-agent\"",
697                "unauthenticated",
698            ),
699            (
700                AuthRefusal::Invalid,
701                401,
702                "Bearer realm=\"my-agent\", error=\"invalid_token\"",
703                "invalid_token",
704            ),
705            (
706                AuthRefusal::InsufficientScope {
707                    credential: "watcher".to_string(),
708                    scope: "SendMessage".to_string(),
709                },
710                403,
711                "Bearer realm=\"my-agent\", error=\"insufficient_scope\", scope=\"SendMessage\"",
712                "insufficient_scope",
713            ),
714        ];
715        for (refusal, status, challenge, code) in cases {
716            let response = refusal.response(realm);
717            assert_eq!(response.status, status);
718            assert!(response
719                .headers
720                .contains(&("www-authenticate".to_string(), challenge.to_string())));
721            let body: Value = serde_json::from_slice(&response.body).unwrap();
722            assert_eq!(body["error"], code);
723            assert!(body["message"].is_string());
724        }
725        let body: Value = serde_json::from_slice(
726            &AuthRefusal::InsufficientScope {
727                credential: "watcher".to_string(),
728                scope: "SendMessage".to_string(),
729            }
730            .response(realm)
731            .body,
732        )
733        .unwrap();
734        assert_eq!(
735            body["message"],
736            "credential 'watcher' does not reach SendMessage"
737        );
738    }
739
740    fn formation_door(
741        authority: &crate::formation_credentials::FormationAuthority,
742        member: &str,
743    ) -> DoorAuth {
744        DoorAuth::mint(&authentication())
745            .unwrap()
746            .with_formation(authority.verifier_for(member))
747    }
748
749    #[test]
750    fn door_auth_formation_tokens_reach_their_audience_with_five_scopes() {
751        let authority = crate::formation_credentials::FormationAuthority::for_test();
752        let coder = formation_door(&authority, "coder");
753        let token = authority.mint("planner", "coder");
754        let grant = coder
755            .verify(&[&format!("Bearer {}", token.expose())])
756            .unwrap();
757        assert_eq!(grant.credential(), "member:planner");
758        assert_eq!(grant.formation_caller(), Some("planner"));
759        assert_eq!(
760            FORMATION_CALL_SCOPES,
761            [
762                "SendMessage",
763                "GetTask",
764                "CancelTask",
765                "ListTasks",
766                "SubscribeToTask"
767            ]
768        );
769        for scope in DOOR_SCOPES {
770            assert_eq!(
771                grant.allows(scope),
772                FORMATION_CALL_SCOPES.contains(scope),
773                "{scope}"
774            );
775        }
776        assert_eq!(
777            grant.require("murmur.StopSession"),
778            Err(AuthRefusal::InsufficientScope {
779                credential: "member:planner".to_string(),
780                scope: "murmur.StopSession".to_string(),
781            })
782        );
783        assert!(!grant.allows("SendStreamingMessage"));
784        assert_eq!(
785            grant.require("SendStreamingMessage"),
786            Err(AuthRefusal::InsufficientScope {
787                credential: "member:planner".to_string(),
788                scope: "SendStreamingMessage".to_string(),
789            })
790        );
791        let operator = coder.verify(&[&header(coder.operator_token())]).unwrap();
792        assert_eq!(operator.formation_caller(), None);
793
794        let reviewer = formation_door(&authority, "reviewer");
795        let refused = reviewer
796            .verify(&[&format!("Bearer {}", token.expose())])
797            .unwrap_err();
798        assert_eq!(
799            refused,
800            AuthRefusal::NotPermitted {
801                caller: "planner".to_string()
802            }
803        );
804        let response = refused.response("reviewer-capsule");
805        assert_eq!(response.status, 403);
806        let body: Value = serde_json::from_slice(&response.body).unwrap();
807        assert_eq!(body["error"], "not_permitted");
808        assert_eq!(
809            body["message"],
810            "member 'planner' may not call this capsule"
811        );
812        assert!(response.headers.contains(&(
813            "www-authenticate".to_string(),
814            "Bearer realm=\"reviewer-capsule\", error=\"insufficient_scope\"".to_string()
815        )));
816    }
817
818    /// A forged, foreign or tampered formation token, and any formation token at a door with no
819    /// verifier, is the same `401` as a garbage token.
820    #[test]
821    fn door_auth_formation_forgeries_are_the_ordinary_invalid() {
822        let authority = crate::formation_credentials::FormationAuthority::for_test();
823        let other = crate::formation_credentials::FormationAuthority::for_test();
824        let coder = formation_door(&authority, "coder");
825        let garbage = coder.verify(&["Bearer garbage"]).unwrap_err();
826        assert_eq!(garbage, AuthRefusal::Invalid);
827        let genuine = authority.mint("planner", "coder");
828        for header in [
829            format!("Bearer {}", other.mint("planner", "coder").expose()),
830            format!("Bearer {}x", genuine.expose()),
831            "Bearer mft1.x.y".to_string(),
832        ] {
833            assert_eq!(
834                coder.verify(&[&header]),
835                Err(AuthRefusal::Invalid),
836                "{header}"
837            );
838        }
839        let plain = DoorAuth::mint(&authentication()).unwrap();
840        assert_eq!(
841            plain.verify(&[&format!("Bearer {}", genuine.expose())]),
842            Err(AuthRefusal::Invalid)
843        );
844        assert_eq!(
845            AuthRefusal::Invalid.response("r").body,
846            garbage.response("r").body
847        );
848        let debug = format!("{coder:?}");
849        assert!(!debug.contains("mft1."), "{debug}");
850        assert!(!debug.contains(&authority.verify_key().render()), "{debug}");
851        assert!(debug.contains("coder"), "{debug}");
852    }
853
854    /// The manifest's scope list and the door's method table cannot drift: every gated method is a
855    /// scope, and every scope but the resource plane's is a gated method.
856    #[test]
857    fn door_auth_scopes_are_the_gated_methods_and_the_resource_plane() {
858        use crate::identity::DoorMethod;
859        let mut from_methods: Vec<&str> = DoorMethod::ALL
860            .into_iter()
861            .filter(|method| *method != DoorMethod::GetExtendedAgentCard)
862            .map(DoorMethod::wire_name)
863            .collect();
864        from_methods.push(RESOURCES_FILES_SCOPE);
865        for scope in &from_methods {
866            assert!(DOOR_SCOPES.contains(scope), "{scope} is not in DOOR_SCOPES");
867        }
868        for scope in DOOR_SCOPES {
869            assert!(
870                from_methods.contains(scope),
871                "{scope} names no gated method or plane"
872            );
873        }
874        assert_eq!(from_methods.len(), DOOR_SCOPES.len());
875        for method in DoorMethod::ALL {
876            assert_eq!(
877                method.scope(),
878                (method != DoorMethod::GetExtendedAgentCard).then(|| method.wire_name())
879            );
880        }
881    }
882
883    /// A retired scope names no method the door serves and no scope a token carries, and what
884    /// replaces it is a method the door serves.
885    #[test]
886    fn door_auth_retired_scopes_name_no_live_method() {
887        use crate::identity::DoorMethod;
888        let wire_names: Vec<&str> = DoorMethod::ALL.map(DoorMethod::wire_name).to_vec();
889        for (retired, replacement) in murmur_artifact::RETIRED_DOOR_SCOPES {
890            assert!(!wire_names.contains(retired), "{retired} is served");
891            assert!(!DOOR_SCOPES.contains(retired), "{retired} is a scope");
892            assert!(
893                wire_names.contains(replacement),
894                "{retired} is replaced by {replacement}, which the door does not serve"
895            );
896        }
897    }
898
899    #[test]
900    fn public_door_warning_is_loopback_bind() {
901        for addr in [
902            "127.0.0.1",
903            "127.1.2.3",
904            "127.255.255.255",
905            "::1",
906            "[::1]",
907            "localhost",
908            "LOCALHOST",
909        ] {
910            assert!(is_loopback_bind(addr), "{addr}");
911        }
912        for addr in [
913            "0.0.0.0",
914            "::",
915            "[::]",
916            "10.0.0.1",
917            "192.168.1.2",
918            "128.0.0.1",
919            "example.com",
920            "localhost.localdomain",
921            "::2",
922            "",
923        ] {
924            assert!(!is_loopback_bind(addr), "{addr}");
925        }
926    }
927
928    fn manifest(extra: &str) -> RuntimeManifest {
929        RuntimeManifest::from_yaml_str(&format!(
930            "name: my-agent\nversion: 0.1.0\nartifacts:\n  - name: local/bash\n    version: 1.0.0\n    \
931             runtime: tool\n  - name: local/drv\n    version: 1.0.0\n    runtime: driver\n    \
932             gateway:\n      endpoint: https://api.example.com\n      api_key: k\n\
933             inference:\n  transport: http\n  model: m\n  driver:\n    artifact: local/drv\n\
934             capabilities:\n  shell:\n    allow: [git]\n  network:\n    allow: [api.example.com]\n\
935             exports:\n  files:\n    root: out\n    mode: read-only\n{extra}"
936        ))
937        .unwrap()
938    }
939
940    #[test]
941    fn public_door_warning_text() {
942        let line = public_door_bind_warning(&manifest(""), "0.0.0.0").expect("a warning");
943        assert_eq!(
944            line,
945            format!(
946                "warning[W-SEC-032]: the door is bound to 0.0.0.0 and network.authentication is \
947                 not declared — any caller that reaches it can call SendMessage, \
948                 SendStreamingMessage, murmur.WatchSession, GetTask, ListTasks, CancelTask, \
949                 SubscribeToTask, murmur.StopSession, and the agent card publishes to any A2A client the session id, tools [local/bash], shell: \
950                 true, network: true and planes [files] ({})",
951                security_warning_link(W_SEC_032)
952            )
953        );
954        let published = public_door_warning(
955            &manifest("lifecycle:\n  task_acceptance: none\n"),
956            DoorExposure::Published("http://203.0.113.9:41873"),
957        )
958        .unwrap();
959        assert!(
960            published.contains("published at http://203.0.113.9:41873"),
961            "{published}"
962        );
963        assert!(
964            published.contains(
965                "can call murmur.WatchSession, GetTask, ListTasks, CancelTask, murmur.StopSession,"
966            ),
967            "{published}"
968        );
969    }
970
971    #[test]
972    fn public_door_warning_is_silent_for_loopback_and_authenticated_doors() {
973        assert_eq!(public_door_bind_warning(&manifest(""), "127.0.0.1"), None);
974        assert_eq!(public_door_bind_warning(&manifest(""), "localhost"), None);
975        let authenticated = manifest("network:\n  authentication:\n    scheme: bearer\n");
976        assert_eq!(public_door_bind_warning(&authenticated, "0.0.0.0"), None);
977        assert_eq!(
978            public_door_warning(&authenticated, DoorExposure::Published("http://h:1")),
979            None
980        );
981    }
982
983    #[test]
984    fn public_door_warning_posture_line() {
985        assert_eq!(
986            door_posture(&manifest("")),
987            "door: public (no network.authentication)"
988        );
989        let authenticated = manifest(
990            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
991             watcher: {scopes: [GetTask, murmur.WatchSession]}\n      \
992             reader: {scopes: [resources/files]}\n",
993        );
994        assert_eq!(
995            door_posture(&authenticated),
996            "door: bearer — operator, reader (resources/files), watcher (GetTask, \
997             murmur.WatchSession)"
998        );
999    }
1000}