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