Skip to main content

cpex_hosts_python/
credentials.rs

1// Location: ./crates/cpex-hosts-python/src/credentials.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// Capability-gated credential wire DTO.
7//
8// # Why this exists
9//
10// `RawInboundToken.token` and `RawDelegatedToken.token` are `#[serde(skip)]`,
11// so serializing an `Extensions` yields no token bytes — which means an
12// out-of-process identity resolver or token delegator cannot do its job over
13// that channel.
14//
15// This module is the narrow, documented exception `cpex-core`'s
16// `RawCredentialsExtension` points at: a dedicated DTO
17// carries the token as a plain string, built only on the capability-gated
18// dispatch path for the two hooks whose Python payload models a raw token at
19// all. The production types keep their serde guard and are never serialized;
20// the FFI, Python-bindings, and audit paths are untouched. The new leak surface
21// is one constructor and one serialize site, which is the point.
22//
23// The DTO is built by reading the in-memory `Zeroizing` token field *directly*.
24// A serialize-then-reparse would yield an empty token, because the serde guard
25// strips the bytes on the way out — the guard is doing its job, so the only way
26// to read the value is to read the field.
27//
28// # Fail closed
29//
30// Two rules, both load-bearing:
31//
32//   1. No declared capability → no DTO, no token material. Not an error; the
33//      plugin simply never sees credentials.
34//   2. Declared capability that cannot be honored → error, not a silent
35//      no-token send. A plugin that asked for a token and got none would
36//      otherwise validate an empty bearer, or fall through to a code path that
37//      treats "no credential" as "no authentication required".
38//
39// # Residual exposure
40//
41// The capability gate controls *which* plugin receives a token. It does not
42// constrain what happens next: once the plaintext is resident in the worker
43// process, every transitively-installed dependency in that venv can read it.
44// That is a materially larger and less audited trust boundary than the
45// in-process host, and neither this gate nor the transport can close it. See
46// the `worker` module for the transport side.
47
48use std::collections::{HashMap, HashSet};
49
50use cpex_core::extensions::raw_credentials::{
51    DelegationKey, DelegationMode, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole,
52};
53use cpex_core::extensions::Extensions;
54use serde::Serialize;
55use serde_json::Value;
56
57use crate::conversion::payload_kind_for_hook;
58use crate::error::HostError;
59use crate::PayloadKind;
60
61/// Task field the DTO rides on. Contract with `worker.py`'s
62/// `CREDENTIAL_FIELD`; both sides must agree verbatim.
63pub const CREDENTIAL_FIELD: &str = "credential";
64
65/// Capability a plugin declares to receive the inbound credential.
66pub const CAP_READ_INBOUND: &str = "read_inbound_credentials";
67
68/// Capability a plugin declares to receive a delegated token.
69pub const CAP_READ_DELEGATED: &str = "read_delegated_tokens";
70
71/// Placeholder printed instead of token bytes in `Debug`.
72const REDACTED: &str = "<redacted>";
73
74/// The `credential` object attached to a task.
75///
76/// Exactly one sub-object is populated, chosen by hook. `Debug` is
77/// hand-written so neither ends up in a log line or panic message.
78#[derive(Clone, Default, Serialize)]
79pub struct CredentialDto {
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub inbound: Option<InboundCredential>,
82
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub delegated: Option<DelegatedCredential>,
85}
86
87impl std::fmt::Debug for CredentialDto {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        // Which sub-object is present is safe and useful; its contents are not.
90        f.debug_struct("CredentialDto")
91            .field("inbound", &self.inbound.as_ref().map(|_| REDACTED))
92            .field("delegated", &self.delegated.as_ref().map(|_| REDACTED))
93            .finish()
94    }
95}
96
97/// `credential.inbound` — built from `RawInboundToken`, for `identity_resolve`.
98///
99/// The worker maps these onto `IdentityPayload`: `token` → `raw_token`, `kind`
100/// → `source`, `headers` → `headers` (with the plaintext scrubbed out of the
101/// values, since `IdentityPayload.headers` does not redact on serialization).
102#[derive(Clone, Serialize)]
103pub struct InboundCredential {
104    /// The plaintext, read straight from the in-memory `Zeroizing` field.
105    pub token: String,
106
107    /// Header the credential arrived in.
108    pub source_header: String,
109
110    /// Wire-format family — `jwt`, `opaque`, `spiffe_jwt`, `ucan`, `txn_token`.
111    pub kind: String,
112
113    /// Headers to forward. Synthesized as `{source_header: token}` when the
114    /// extension carries none, so a header-driven extractor still learns which
115    /// header carried the credential.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub headers: Option<HashMap<String, String>>,
118}
119
120impl std::fmt::Debug for InboundCredential {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.debug_struct("InboundCredential")
123            .field("token", &REDACTED)
124            .field("source_header", &self.source_header)
125            .field("kind", &self.kind)
126            // Values can embed the token ("Bearer <token>"), so names only.
127            .field(
128                "header_names",
129                &self.headers.as_ref().map(|h| h.keys().collect::<Vec<_>>()),
130            )
131            .finish()
132    }
133}
134
135/// `credential.delegated` — built from `RawDelegatedToken`, for
136/// `token_delegate`.
137///
138/// The worker maps `token` → `DelegationPayload.bearer_token`; the rest is
139/// audit and validation context.
140#[derive(Clone, Serialize)]
141pub struct DelegatedCredential {
142    /// The plaintext, read straight from the in-memory `Zeroizing` field.
143    pub token: String,
144
145    /// Where the consumer should attach the token upstream.
146    pub outbound_header: String,
147
148    /// Audience the token was minted for.
149    pub audience: String,
150
151    /// Effective scopes on the minted token.
152    pub scopes: Vec<String>,
153}
154
155impl std::fmt::Debug for DelegatedCredential {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        f.debug_struct("DelegatedCredential")
158            .field("token", &REDACTED)
159            .field("outbound_header", &self.outbound_header)
160            .field("audience", &self.audience)
161            .field("scopes", &self.scopes)
162            .finish()
163    }
164}
165
166/// The wire name for a `TokenKind`.
167///
168/// Matches the `snake_case` serde rename on the enum, which is what
169/// `worker.py`'s `_BEARER_TOKEN_KINDS` matches against. Written out rather
170/// than round-tripped through serde so the mapping is reviewable next to the
171/// contract it implements.
172fn token_kind_wire_name(kind: &TokenKind) -> &'static str {
173    match kind {
174        TokenKind::Jwt => "jwt",
175        TokenKind::Opaque => "opaque",
176        TokenKind::SpiffeJwt => "spiffe_jwt",
177        TokenKind::Ucan => "ucan",
178        TokenKind::TxnToken => "txn_token",
179        // `TokenKind` is `#[non_exhaustive]`, so a kind added upstream lands
180        // here. "opaque" is the safe default: the worker maps unknown kinds to
181        // `source: "custom"`, which makes a validator inspect the headers
182        // itself rather than trust a source it does not recognize.
183        _ => "opaque",
184    }
185}
186
187/// Attach the `credential` object to a task, when the hook and the plugin's
188/// declared capabilities both call for it.
189///
190/// A no-op for every hook other than `identity_resolve` and `token_delegate`,
191/// and for any plugin that declared neither capability.
192pub fn attach_credential(
193    task: &mut Value,
194    hook_name: &str,
195    extensions: &Extensions,
196    capabilities: &HashSet<String>,
197) -> Result<(), HostError> {
198    let kind = payload_kind_for_hook(hook_name);
199
200    let dto = match kind {
201        PayloadKind::IdentityResolve => {
202            if !capabilities.contains(CAP_READ_INBOUND) {
203                return Ok(());
204            }
205            CredentialDto {
206                inbound: Some(build_inbound(extensions, hook_name)?),
207                delegated: None,
208            }
209        },
210        PayloadKind::TokenDelegate => {
211            if !capabilities.contains(CAP_READ_DELEGATED) {
212                return Ok(());
213            }
214            // The audience the hook was actually invoked for. Read from the
215            // already-serialized payload, which is the only place the host
216            // learns it — `DelegationPayload.target_audience` names the upstream
217            // this delegation is for, and a token minted for a *different*
218            // audience is the wrong credential to hand over.
219            let requested_audience = requested_audience(task);
220            CredentialDto {
221                inbound: None,
222                delegated: Some(build_delegated(
223                    extensions,
224                    hook_name,
225                    requested_audience.as_deref(),
226                )?),
227            }
228        },
229        // No other hook has a payload field to receive a token.
230        _ => return Ok(()),
231    };
232
233    let object = task.as_object_mut().ok_or_else(|| HostError::Protocol {
234        message: "task must be a JSON object to carry a credential".into(),
235    })?;
236
237    let serialized = serde_json::to_value(&dto).map_err(|e| HostError::Credential {
238        // The error names the failure, never the value.
239        message: format!("could not serialize the credential DTO: {e}"),
240    })?;
241    object.insert(CREDENTIAL_FIELD.to_string(), serialized);
242
243    Ok(())
244}
245
246/// Build `credential.inbound` from the filtered extensions view.
247///
248/// The plugin declared `read_inbound_credentials`, so a missing or empty token
249/// is a fail-closed error rather than an omitted field: sending no token to a
250/// plugin that asked for one invites it to treat the request as unauthenticated.
251fn build_inbound(extensions: &Extensions, hook_name: &str) -> Result<InboundCredential, HostError> {
252    let raw = extensions
253        .raw_credentials
254        .as_ref()
255        .ok_or_else(|| HostError::Credential {
256            message: format!(
257                "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the request \
258                 carries no raw-credentials extension — refusing to dispatch without the token \
259                 it asked for"
260            ),
261        })?;
262
263    // Prefer the user token, then the client token, then a workload JWT-SVID.
264    // Ordering matters: an identity resolver wants the subject's credential,
265    // and falling straight to the client token would resolve the gateway's own
266    // identity as if it were the user's.
267    let token = [
268        TokenRole::User,
269        TokenRole::Client,
270        TokenRole::CallerWorkload,
271    ]
272    .iter()
273    .find_map(|role| raw.inbound_tokens.get(role))
274    .or_else(|| raw.inbound_tokens.values().next())
275    .ok_or_else(|| HostError::Credential {
276        message: format!(
277            "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but no inbound token \
278                 is present — refusing to dispatch without it"
279        ),
280    })?;
281
282    inbound_from_token(token, hook_name)
283}
284
285/// Convert one `RawInboundToken` into its wire form.
286fn inbound_from_token(
287    token: &RawInboundToken,
288    hook_name: &str,
289) -> Result<InboundCredential, HostError> {
290    // Reading `*token.token` is the whole point: `serde` would give back an
291    // empty string here, because the field is `#[serde(skip)]`.
292    let plaintext = token.token.as_str();
293
294    // Whitespace-only is rejected as well as empty: "Authorization: Bearer   "
295    // is not meaningfully different from an empty bearer downstream, and
296    // `worker.py` rejects it on its side too.
297    if plaintext.trim().is_empty() {
298        return Err(HostError::Credential {
299            message: format!(
300                "the inbound token for hook '{hook_name}' is empty — refusing to send an empty \
301                 credential (an empty bearer authenticates nowhere, and a plugin may read it as \
302                 'no authentication required')"
303            ),
304        });
305    }
306
307    Ok(InboundCredential {
308        token: plaintext.to_string(),
309        source_header: token.source_header.clone(),
310        kind: token_kind_wire_name(&token.kind).to_string(),
311        // Synthesized so a header-driven extractor learns which header carried
312        // the credential. The worker scrubs the plaintext back out of these
313        // values before a plugin can echo them onto a serialized payload.
314        headers: Some(HashMap::from([(
315            token.source_header.clone(),
316            plaintext.to_string(),
317        )])),
318    })
319}
320
321/// The audience the `token_delegate` hook was invoked for, if the payload names
322/// one.
323///
324/// `DelegationPayload.target_audience` is optional, so a `None` here means the
325/// caller did not scope the delegation and any audience is acceptable.
326fn requested_audience(task: &Value) -> Option<String> {
327    task.get("payload")?
328        .get("target_audience")?
329        .as_str()
330        .map(str::to_string)
331}
332
333/// Build `credential.delegated` from the filtered extensions view.
334///
335/// # Selection
336///
337/// `delegated_tokens` is a `HashMap`, so iteration order is unspecified and
338/// varies run to run. Selecting on `mode` alone therefore let map order decide
339/// *which* token a plugin received whenever more than one candidate shared a
340/// mode — including tokens minted for entirely different upstreams. Audience is
341/// the field that makes a delegated token the right or wrong credential, so it
342/// is filtered on first, and the remaining choice is broken deterministically:
343///
344///   1. Keep only tokens whose audience matches the hook's `target_audience`
345///      (every token, when the payload named no audience).
346///   2. Prefer `OnBehalfOfUser` over any other mode — a delegator asked to act
347///      for the user must not fall back to the gateway's own identity.
348///   3. Within one mode, order by the key's `(subject_id, scopes)` and take the
349///      first. Arbitrary but *stable*, so the same request picks the same token
350///      every time instead of rotating with the hasher's seed.
351///
352/// No token matching both audience and mode is a fail-closed error, not a
353/// fallback to some other audience's token: handing a plugin a credential
354/// minted for a different upstream is worse than handing it none, because it
355/// would be attached to a request the audience never authorized it for.
356fn build_delegated(
357    extensions: &Extensions,
358    hook_name: &str,
359    requested_audience: Option<&str>,
360) -> Result<DelegatedCredential, HostError> {
361    let raw = extensions
362        .raw_credentials
363        .as_ref()
364        .ok_or_else(|| HostError::Credential {
365            message: format!(
366                "plugin declared '{CAP_READ_DELEGATED}' for hook '{hook_name}' but the request \
367                 carries no raw-credentials extension — refusing to dispatch without the token \
368                 it asked for"
369            ),
370        })?;
371
372    // Match on the key's audience *and* the token's own, so a token whose two
373    // copies of the field disagree is never treated as a match for either.
374    let mut candidates: Vec<(&DelegationKey, &RawDelegatedToken)> = raw
375        .delegated_tokens
376        .iter()
377        .filter(|(key, token)| match requested_audience {
378            Some(audience) => key.audience == audience && token.audience == audience,
379            None => true,
380        })
381        .collect();
382
383    // Deterministic total order: preferred mode first, then the key's stable
384    // fields. Without this the pick rides on `HashMap` iteration order.
385    candidates.sort_by(|(left, _), (right, _)| {
386        mode_rank(&left.mode)
387            .cmp(&mode_rank(&right.mode))
388            .then_with(|| left.subject_id.cmp(&right.subject_id))
389            .then_with(|| left.scopes.cmp(&right.scopes))
390    });
391
392    let (_, token) = candidates.first().ok_or_else(|| {
393        // The message names the audience — an identifier for an upstream, not
394        // credential material — because "no token" and "no token *for this
395        // audience*" call for different operator fixes.
396        let scope = match requested_audience {
397            Some(audience) => format!(" for audience '{audience}'"),
398            None => String::new(),
399        };
400        HostError::Credential {
401            message: format!(
402                "plugin declared '{CAP_READ_DELEGATED}' for hook '{hook_name}' but no delegated \
403                 token{scope} is present — refusing to dispatch without it, and refusing to \
404                 substitute a token minted for a different audience"
405            ),
406        }
407    })?;
408
409    delegated_from_token(token, hook_name)
410}
411
412/// Sort key expressing the mode preference. Lower sorts first.
413///
414/// `DelegationMode` is matched exhaustively-by-fallback rather than listed, so a
415/// mode added upstream ranks after the two the host reasons about instead of
416/// silently outranking `OnBehalfOfUser`.
417fn mode_rank(mode: &DelegationMode) -> u8 {
418    match mode {
419        DelegationMode::OnBehalfOfUser => 0,
420        DelegationMode::AsThisWorkload => 1,
421        _ => 2,
422    }
423}
424
425/// Convert one `RawDelegatedToken` into its wire form.
426fn delegated_from_token(
427    token: &RawDelegatedToken,
428    hook_name: &str,
429) -> Result<DelegatedCredential, HostError> {
430    let plaintext = token.token.as_str();
431
432    if plaintext.trim().is_empty() {
433        return Err(HostError::Credential {
434            message: format!(
435                "the delegated token for hook '{hook_name}' is empty — refusing to send an empty \
436                 credential"
437            ),
438        });
439    }
440
441    Ok(DelegatedCredential {
442        token: plaintext.to_string(),
443        outbound_header: token.outbound_header.clone(),
444        audience: token.audience.clone(),
445        scopes: token.scopes.clone(),
446    })
447}
448
449#[cfg(test)]
450mod tests {
451    use std::sync::Arc;
452
453    use cpex_core::extensions::raw_credentials::{DelegationKey, RawCredentialsExtension};
454
455    use super::*;
456
457    const INBOUND_SECRET: &str = "eyJhbGciOiJSUzI1NiJ9.INBOUND-SECRET-BYTES.sig";
458    const DELEGATED_SECRET: &str = "MINTED-DELEGATED-SECRET-BYTES";
459
460    fn caps(names: &[&str]) -> HashSet<String> {
461        names.iter().map(|s| (*s).to_string()).collect()
462    }
463
464    /// Extensions carrying both an inbound and a delegated token.
465    fn extensions_with_credentials() -> Extensions {
466        let mut raw = RawCredentialsExtension::default();
467        raw.inbound_tokens.insert(
468            TokenRole::User,
469            RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt),
470        );
471        raw.delegated_tokens.insert(
472            DelegationKey::new(
473                DelegationMode::OnBehalfOfUser,
474                "https://billing.example.com",
475                vec!["read".into()],
476            )
477            .with_subject_id("alice"),
478            RawDelegatedToken::new(
479                DELEGATED_SECRET,
480                "Authorization",
481                "https://billing.example.com",
482                vec!["read".into()],
483                chrono::Utc::now(),
484            ),
485        );
486
487        Extensions {
488            raw_credentials: Some(Arc::new(raw)),
489            ..Default::default()
490        }
491    }
492
493    fn task() -> Value {
494        serde_json::json!({ "task_type": "load_and_run_hook" })
495    }
496
497    /// A `token_delegate` task whose payload names the audience the delegation
498    /// is for — the shape `plugin.rs` actually builds.
499    fn delegate_task(target_audience: &str) -> Value {
500        serde_json::json!({
501            "task_type": "load_and_run_hook",
502            "hook_type": "token_delegate",
503            "payload": {
504                "target_name": "billing",
505                "target_type": "tool",
506                "target_audience": target_audience,
507            },
508        })
509    }
510
511    /// One delegated token, keyed consistently with its own audience field.
512    fn delegated(
513        subject: &str,
514        audience: &str,
515        mode: DelegationMode,
516        secret: &str,
517    ) -> (DelegationKey, RawDelegatedToken) {
518        (
519            DelegationKey::new(mode, audience, vec![]).with_subject_id(subject),
520            RawDelegatedToken::new(
521                secret,
522                "Authorization",
523                audience,
524                vec![],
525                chrono::Utc::now(),
526            ),
527        )
528    }
529
530    fn extensions_from(tokens: Vec<(DelegationKey, RawDelegatedToken)>) -> Extensions {
531        let mut raw = RawCredentialsExtension::default();
532        for (key, token) in tokens {
533            raw.delegated_tokens.insert(key, token);
534        }
535        Extensions {
536            raw_credentials: Some(Arc::new(raw)),
537            ..Default::default()
538        }
539    }
540
541    /// The delegated token a `token_delegate` dispatch would carry, or the
542    /// fail-closed error.
543    fn pick_delegated(extensions: &Extensions, target_audience: &str) -> Result<String, HostError> {
544        let mut task = delegate_task(target_audience);
545        attach_credential(
546            &mut task,
547            "token_delegate",
548            extensions,
549            &caps(&[CAP_READ_DELEGATED]),
550        )?;
551        Ok(task[CREDENTIAL_FIELD]["delegated"]["token"]
552            .as_str()
553            .expect("a delegated token was attached")
554            .to_string())
555    }
556
557    // --- the capability gate -------------------------------------------------
558
559    #[test]
560    fn a_declaring_plugin_receives_the_inbound_token() {
561        let mut task = task();
562        attach_credential(
563            &mut task,
564            "identity_resolve",
565            &extensions_with_credentials(),
566            &caps(&[CAP_READ_INBOUND]),
567        )
568        .expect("a declared capability is honored");
569
570        let credential = &task[CREDENTIAL_FIELD];
571        assert_eq!(credential["inbound"]["token"], INBOUND_SECRET);
572        assert_eq!(credential["inbound"]["source_header"], "Authorization");
573        assert_eq!(credential["inbound"]["kind"], "jwt");
574        assert!(
575            credential.get("delegated").is_none(),
576            "an identity hook must not receive delegated material"
577        );
578    }
579
580    #[test]
581    fn a_non_declaring_plugin_receives_no_token_material() {
582        let mut task = task();
583        attach_credential(
584            &mut task,
585            "identity_resolve",
586            &extensions_with_credentials(),
587            &caps(&[]),
588        )
589        .expect("declaring nothing is not an error");
590
591        assert!(
592            task.get(CREDENTIAL_FIELD).is_none(),
593            "no credential field at all"
594        );
595        assert!(
596            !serde_json::to_string(&task)
597                .unwrap()
598                .contains("INBOUND-SECRET-BYTES"),
599            "no token bytes anywhere in the task"
600        );
601    }
602
603    #[test]
604    fn two_plugins_on_one_hook_are_gated_independently() {
605        // The capability-gated acceptance example: same hook, same request, one
606        // plugin declared the capability and the other did not.
607        let extensions = extensions_with_credentials();
608
609        let mut declaring = task();
610        attach_credential(
611            &mut declaring,
612            "identity_resolve",
613            &extensions,
614            &caps(&[CAP_READ_INBOUND]),
615        )
616        .unwrap();
617
618        let mut non_declaring = task();
619        attach_credential(
620            &mut non_declaring,
621            "identity_resolve",
622            &extensions,
623            &caps(&[]),
624        )
625        .unwrap();
626
627        assert!(serde_json::to_string(&declaring)
628            .unwrap()
629            .contains(INBOUND_SECRET));
630        assert!(!serde_json::to_string(&non_declaring)
631            .unwrap()
632            .contains("INBOUND-SECRET-BYTES"));
633    }
634
635    #[test]
636    fn the_wrong_capability_does_not_unlock_a_hook() {
637        // `read_delegated_tokens` must not grant inbound material, and vice
638        // versa — the two are independently scoped.
639        let extensions = extensions_with_credentials();
640
641        let mut identity = task();
642        attach_credential(
643            &mut identity,
644            "identity_resolve",
645            &extensions,
646            &caps(&[CAP_READ_DELEGATED]),
647        )
648        .unwrap();
649        assert!(identity.get(CREDENTIAL_FIELD).is_none());
650
651        let mut delegate = task();
652        attach_credential(
653            &mut delegate,
654            "token_delegate",
655            &extensions,
656            &caps(&[CAP_READ_INBOUND]),
657        )
658        .unwrap();
659        assert!(delegate.get(CREDENTIAL_FIELD).is_none());
660    }
661
662    #[test]
663    fn a_declaring_plugin_receives_the_delegated_token() {
664        let mut task = task();
665        attach_credential(
666            &mut task,
667            "token_delegate",
668            &extensions_with_credentials(),
669            &caps(&[CAP_READ_DELEGATED]),
670        )
671        .expect("a declared capability is honored");
672
673        let delegated = &task[CREDENTIAL_FIELD]["delegated"];
674        assert_eq!(delegated["token"], DELEGATED_SECRET);
675        assert_eq!(delegated["outbound_header"], "Authorization");
676        assert_eq!(delegated["audience"], "https://billing.example.com");
677        assert_eq!(delegated["scopes"][0], "read");
678        assert!(task[CREDENTIAL_FIELD].get("inbound").is_none());
679    }
680
681    #[test]
682    fn non_credential_hooks_never_receive_a_credential() {
683        // Even a plugin that declared both capabilities gets nothing on a hook
684        // whose payload has nowhere to put a token.
685        for hook in [
686            "tool_pre_invoke",
687            "tool_post_invoke",
688            "prompt_pre_fetch",
689            "resource_post_fetch",
690            "cmf.tool_pre_invoke",
691            "some_custom_hook",
692        ] {
693            let mut task = task();
694            attach_credential(
695                &mut task,
696                hook,
697                &extensions_with_credentials(),
698                &caps(&[CAP_READ_INBOUND, CAP_READ_DELEGATED]),
699            )
700            .unwrap();
701
702            assert!(
703                task.get(CREDENTIAL_FIELD).is_none(),
704                "hook '{hook}' must not receive credentials"
705            );
706        }
707    }
708
709    // --- fail closed ---------------------------------------------------------
710
711    #[test]
712    fn a_declared_capability_with_no_extension_errors() {
713        // Fail closed rather than dispatching without the token: a plugin that
714        // asked for a credential and silently got none may treat the request
715        // as unauthenticated.
716        let err = attach_credential(
717            &mut task(),
718            "identity_resolve",
719            &Extensions::default(),
720            &caps(&[CAP_READ_INBOUND]),
721        )
722        .expect_err("a declared capability that cannot be honored must error");
723
724        assert!(matches!(err, HostError::Credential { .. }), "got {err:?}");
725    }
726
727    #[test]
728    fn a_declared_capability_with_no_matching_token_errors() {
729        // The extension exists but carries only delegated tokens, so the
730        // inbound capability cannot be honored.
731        let mut raw = RawCredentialsExtension::default();
732        raw.delegated_tokens.insert(
733            DelegationKey::new(DelegationMode::AsThisWorkload, "aud", vec![])
734                .with_subject_id("alice"),
735            RawDelegatedToken::new("t", "Authorization", "aud", vec![], chrono::Utc::now()),
736        );
737        let extensions = Extensions {
738            raw_credentials: Some(Arc::new(raw)),
739            ..Default::default()
740        };
741
742        let err = attach_credential(
743            &mut task(),
744            "identity_resolve",
745            &extensions,
746            &caps(&[CAP_READ_INBOUND]),
747        )
748        .expect_err("no inbound token means the capability cannot be honored");
749        assert!(matches!(err, HostError::Credential { .. }));
750    }
751
752    #[test]
753    fn an_empty_token_is_rejected_rather_than_sent() {
754        // A `#[serde(skip)]`-stripped token deserializes to "" — exactly the
755        // shape that reaches here if anyone ever reintroduces a
756        // serialize-then-reparse. Sending it would authenticate as an empty
757        // bearer.
758        for token in ["", "   ", "\t\n"] {
759            let mut raw = RawCredentialsExtension::default();
760            raw.inbound_tokens.insert(
761                TokenRole::User,
762                RawInboundToken::new(token, "Authorization", TokenKind::Jwt),
763            );
764            let extensions = Extensions {
765                raw_credentials: Some(Arc::new(raw)),
766                ..Default::default()
767            };
768
769            let err = attach_credential(
770                &mut task(),
771                "identity_resolve",
772                &extensions,
773                &caps(&[CAP_READ_INBOUND]),
774            )
775            .expect_err("an empty or whitespace-only token must be refused");
776            assert!(matches!(err, HostError::Credential { .. }));
777        }
778    }
779
780    #[test]
781    fn an_empty_delegated_token_is_rejected() {
782        let mut raw = RawCredentialsExtension::default();
783        raw.delegated_tokens.insert(
784            DelegationKey::new(DelegationMode::OnBehalfOfUser, "aud", vec![])
785                .with_subject_id("alice"),
786            RawDelegatedToken::new("", "Authorization", "aud", vec![], chrono::Utc::now()),
787        );
788        let extensions = Extensions {
789            raw_credentials: Some(Arc::new(raw)),
790            ..Default::default()
791        };
792
793        let err = attach_credential(
794            &mut task(),
795            "token_delegate",
796            &extensions,
797            &caps(&[CAP_READ_DELEGATED]),
798        )
799        .expect_err("an empty delegated token must be refused");
800        assert!(matches!(err, HostError::Credential { .. }));
801    }
802
803    #[test]
804    fn a_fail_closed_error_never_names_the_token() {
805        let mut raw = RawCredentialsExtension::default();
806        raw.inbound_tokens.insert(
807            TokenRole::User,
808            RawInboundToken::new("   ", "Authorization", TokenKind::Jwt),
809        );
810        let extensions = Extensions {
811            raw_credentials: Some(Arc::new(raw)),
812            ..Default::default()
813        };
814
815        let err = attach_credential(
816            &mut task(),
817            "identity_resolve",
818            &extensions,
819            &caps(&[CAP_READ_INBOUND]),
820        )
821        .expect_err("errors");
822        let message = err.to_string();
823        assert!(
824            message.contains("identity_resolve"),
825            "the message should name the hook: {message}"
826        );
827        assert!(message.contains("empty"));
828    }
829
830    // --- the DTO is the only carrier ----------------------------------------
831
832    #[test]
833    fn production_credential_types_still_refuse_to_serialize_tokens() {
834        // The guard this module works around must remain intact: nothing here
835        // loosened it, so a direct serialize of the production types still
836        // strips the bytes. If this ever fails, the DTO stopped being the only
837        // path token material can take.
838        let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt);
839        let json = serde_json::to_string(&inbound).unwrap();
840        assert!(
841            !json.contains("INBOUND-SECRET-BYTES"),
842            "the serde guard regressed: {json}"
843        );
844
845        let delegated = RawDelegatedToken::new(
846            DELEGATED_SECRET,
847            "Authorization",
848            "aud",
849            vec![],
850            chrono::Utc::now(),
851        );
852        let json = serde_json::to_string(&delegated).unwrap();
853        assert!(
854            !json.contains("MINTED-DELEGATED"),
855            "the serde guard regressed: {json}"
856        );
857
858        // And the inbound sub-map, which is what an audit or trace path dumps.
859        // (`delegated_tokens` is keyed by a struct, so the extension as a whole
860        // is not JSON-serializable — a pre-existing property of the type, not
861        // something this host changed.)
862        let extensions = extensions_with_credentials();
863        let raw = extensions.raw_credentials.as_ref().unwrap();
864        let json = serde_json::to_string(&raw.inbound_tokens).unwrap();
865        assert!(
866            !json.contains("INBOUND-SECRET-BYTES"),
867            "the serde guard regressed: {json}"
868        );
869
870        for token in raw.delegated_tokens.values() {
871            let json = serde_json::to_string(token).unwrap();
872            assert!(
873                !json.contains("MINTED-DELEGATED"),
874                "the serde guard regressed: {json}"
875            );
876        }
877    }
878
879    #[test]
880    fn a_serialize_then_reparse_would_have_yielded_an_empty_token() {
881        // Documents *why* the DTO reads the in-memory field directly. This is
882        // the tempting shortcut, and it silently produces an empty credential.
883        let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt);
884        let round_tripped: RawInboundToken =
885            serde_json::from_str(&serde_json::to_string(&inbound).unwrap()).unwrap();
886
887        assert_eq!(
888            &*round_tripped.token, "",
889            "the guard strips the bytes on the way out"
890        );
891        assert_eq!(round_tripped.source_header, "Authorization");
892    }
893
894    // --- redaction -----------------------------------------------------------
895
896    #[test]
897    fn the_dto_debug_output_hides_the_token() {
898        let dto = CredentialDto {
899            inbound: Some(InboundCredential {
900                token: INBOUND_SECRET.into(),
901                source_header: "Authorization".into(),
902                kind: "jwt".into(),
903                headers: Some(HashMap::from([(
904                    "Authorization".into(),
905                    format!("Bearer {INBOUND_SECRET}"),
906                )])),
907            }),
908            delegated: None,
909        };
910
911        // Both the outer DTO and the inner sub-object are printed, since either
912        // could reach a log line on its own.
913        let outer = format!("{dto:?}");
914        let inner = format!("{:?}", dto.inbound.as_ref().unwrap());
915
916        for debug in [&outer, &inner] {
917            assert!(
918                !debug.contains("INBOUND-SECRET-BYTES"),
919                "token leaked into Debug: {debug}"
920            );
921        }
922        // Non-secret context stays diagnosable.
923        assert!(inner.contains("Authorization"));
924        assert!(inner.contains("jwt"));
925    }
926
927    #[test]
928    fn the_delegated_dto_debug_output_hides_the_token() {
929        let credential = DelegatedCredential {
930            token: DELEGATED_SECRET.into(),
931            outbound_header: "Authorization".into(),
932            audience: "https://billing.example.com".into(),
933            scopes: vec!["read".into()],
934        };
935
936        let debug = format!("{credential:?}");
937        assert!(
938            !debug.contains("MINTED-DELEGATED"),
939            "token leaked into Debug: {debug}"
940        );
941        assert!(debug.contains("billing.example.com"));
942        assert!(debug.contains("read"));
943    }
944
945    // --- wire contract details ----------------------------------------------
946
947    #[test]
948    fn token_kinds_use_the_wire_names_the_worker_matches_on() {
949        // worker.py's _BEARER_TOKEN_KINDS is {jwt, opaque, spiffe_jwt, ucan};
950        // a mismatch here would silently map a bearer to source "custom".
951        assert_eq!(token_kind_wire_name(&TokenKind::Jwt), "jwt");
952        assert_eq!(token_kind_wire_name(&TokenKind::Opaque), "opaque");
953        assert_eq!(token_kind_wire_name(&TokenKind::SpiffeJwt), "spiffe_jwt");
954        assert_eq!(token_kind_wire_name(&TokenKind::Ucan), "ucan");
955        assert_eq!(token_kind_wire_name(&TokenKind::TxnToken), "txn_token");
956    }
957
958    #[test]
959    fn wire_names_match_the_serde_representation() {
960        // Guards the hand-written mapping against the enum's own serde rename,
961        // so adding a variant upstream cannot silently desync the two.
962        for kind in [
963            TokenKind::Jwt,
964            TokenKind::Opaque,
965            TokenKind::SpiffeJwt,
966            TokenKind::Ucan,
967            TokenKind::TxnToken,
968        ] {
969            let via_serde = serde_json::to_value(&kind).unwrap();
970            assert_eq!(
971                via_serde.as_str().unwrap(),
972                token_kind_wire_name(&kind),
973                "hand-written wire name disagrees with serde for {kind:?}"
974            );
975        }
976    }
977
978    #[test]
979    fn the_user_token_is_preferred_over_the_client_token() {
980        // An identity resolver wants the subject's credential; resolving the
981        // gateway's own token as the user's would be an authorization bug.
982        let mut raw = RawCredentialsExtension::default();
983        raw.inbound_tokens.insert(
984            TokenRole::Client,
985            RawInboundToken::new("CLIENT-TOKEN", "X-Client", TokenKind::Opaque),
986        );
987        raw.inbound_tokens.insert(
988            TokenRole::User,
989            RawInboundToken::new("USER-TOKEN", "Authorization", TokenKind::Jwt),
990        );
991        let extensions = Extensions {
992            raw_credentials: Some(Arc::new(raw)),
993            ..Default::default()
994        };
995
996        let mut task = task();
997        attach_credential(
998            &mut task,
999            "identity_resolve",
1000            &extensions,
1001            &caps(&[CAP_READ_INBOUND]),
1002        )
1003        .unwrap();
1004        assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], "USER-TOKEN");
1005    }
1006
1007    #[test]
1008    fn headers_are_synthesized_from_the_source_header() {
1009        let mut task = task();
1010        attach_credential(
1011            &mut task,
1012            "identity_resolve",
1013            &extensions_with_credentials(),
1014            &caps(&[CAP_READ_INBOUND]),
1015        )
1016        .unwrap();
1017
1018        // The worker scrubs the plaintext out of these values before a plugin
1019        // can echo them; the host's job is only to name the carrying header.
1020        assert_eq!(
1021            task[CREDENTIAL_FIELD]["inbound"]["headers"]["Authorization"],
1022            INBOUND_SECRET
1023        );
1024    }
1025
1026    #[test]
1027    fn an_on_behalf_of_user_token_is_preferred_over_a_gateway_token() {
1028        let mut raw = RawCredentialsExtension::default();
1029        raw.delegated_tokens.insert(
1030            DelegationKey::new(DelegationMode::AsThisWorkload, "aud", vec![]).with_subject_id("gw"),
1031            RawDelegatedToken::new(
1032                "GATEWAY-TOKEN",
1033                "Authorization",
1034                "aud",
1035                vec![],
1036                chrono::Utc::now(),
1037            ),
1038        );
1039        raw.delegated_tokens.insert(
1040            DelegationKey::new(DelegationMode::OnBehalfOfUser, "aud", vec![])
1041                .with_subject_id("alice"),
1042            RawDelegatedToken::new(
1043                "USER-DELEGATED-TOKEN",
1044                "Authorization",
1045                "aud",
1046                vec![],
1047                chrono::Utc::now(),
1048            ),
1049        );
1050        let extensions = Extensions {
1051            raw_credentials: Some(Arc::new(raw)),
1052            ..Default::default()
1053        };
1054
1055        let mut task = task();
1056        attach_credential(
1057            &mut task,
1058            "token_delegate",
1059            &extensions,
1060            &caps(&[CAP_READ_DELEGATED]),
1061        )
1062        .unwrap();
1063        assert_eq!(
1064            task[CREDENTIAL_FIELD]["delegated"]["token"], "USER-DELEGATED-TOKEN",
1065            "a delegator acting for the user must not fall back to the gateway's identity"
1066        );
1067    }
1068
1069    // --- delegated-token selection ------------------------------------------
1070
1071    #[test]
1072    fn the_delegated_pick_matches_the_requested_audience() {
1073        // The reviewer's finding: filtering on mode alone let `HashMap` order
1074        // decide which token a plugin got. Three tokens share the preferred
1075        // mode and differ only by audience, so a mode-only filter picks one of
1076        // the three at random — and two of those three are credentials minted
1077        // for a completely different upstream.
1078        let extensions = extensions_from(vec![
1079            delegated(
1080                "alice",
1081                "https://billing.example.com",
1082                DelegationMode::OnBehalfOfUser,
1083                "BILLING-TOKEN",
1084            ),
1085            delegated(
1086                "alice",
1087                "https://search.example.com",
1088                DelegationMode::OnBehalfOfUser,
1089                "SEARCH-TOKEN",
1090            ),
1091            delegated(
1092                "alice",
1093                "https://payroll.example.com",
1094                DelegationMode::OnBehalfOfUser,
1095                "PAYROLL-TOKEN",
1096            ),
1097        ]);
1098
1099        assert_eq!(
1100            pick_delegated(&extensions, "https://search.example.com").unwrap(),
1101            "SEARCH-TOKEN",
1102            "a token minted for another audience must never be substituted"
1103        );
1104        assert_eq!(
1105            pick_delegated(&extensions, "https://billing.example.com").unwrap(),
1106            "BILLING-TOKEN"
1107        );
1108    }
1109
1110    #[test]
1111    fn the_delegated_pick_is_stable_across_runs_and_insertion_orders() {
1112        // `HashMap` seeds its hasher per process *and* orders by insertion
1113        // history, so the pre-fix selection could differ run to run and between
1114        // two extensions holding the same tokens. Both are pinned here: every
1115        // repetition, under every insertion order, yields the same token.
1116        let forward = vec![
1117            delegated("alice", "aud-a", DelegationMode::OnBehalfOfUser, "A"),
1118            delegated("bob", "aud-a", DelegationMode::OnBehalfOfUser, "B"),
1119            delegated("carol", "aud-a", DelegationMode::OnBehalfOfUser, "C"),
1120            delegated("dave", "aud-a", DelegationMode::OnBehalfOfUser, "D"),
1121        ];
1122        let reversed: Vec<_> = forward.iter().rev().cloned().collect();
1123
1124        // `alice` sorts first among the equal-mode candidates.
1125        const EXPECTED: &str = "A";
1126
1127        for _ in 0..200 {
1128            let one = extensions_from(forward.clone());
1129            let other = extensions_from(reversed.clone());
1130            assert_eq!(pick_delegated(&one, "aud-a").unwrap(), EXPECTED);
1131            assert_eq!(
1132                pick_delegated(&other, "aud-a").unwrap(),
1133                EXPECTED,
1134                "insertion order must not change the pick"
1135            );
1136        }
1137    }
1138
1139    #[test]
1140    fn no_token_for_the_requested_audience_fails_closed() {
1141        // Fail closed rather than fall back: attaching another audience's token
1142        // would send a credential to an upstream that never authorized it.
1143        let extensions = extensions_from(vec![delegated(
1144            "alice",
1145            "https://billing.example.com",
1146            DelegationMode::OnBehalfOfUser,
1147            "BILLING-TOKEN",
1148        )]);
1149
1150        let err = pick_delegated(&extensions, "https://attacker.example.com")
1151            .expect_err("no matching audience means no token");
1152
1153        let message = err.to_string();
1154        assert!(matches!(err, HostError::Credential { .. }), "{message}");
1155        assert!(
1156            message.contains("attacker.example.com"),
1157            "the error should name the audience that had no token: {message}"
1158        );
1159        assert!(
1160            !message.contains("BILLING-TOKEN"),
1161            "the error must not name the token it declined to substitute: {message}"
1162        );
1163    }
1164
1165    #[test]
1166    fn the_audience_filter_is_applied_before_the_mode_preference() {
1167        // Mode preference must not reach across audiences: the only token for
1168        // the requested audience is `AsThisWorkload`, and the `OnBehalfOfUser` token
1169        // belongs to a different upstream. Preferring mode first would hand
1170        // over the wrong-audience token.
1171        let extensions = extensions_from(vec![
1172            delegated(
1173                "alice",
1174                "https://other.example.com",
1175                DelegationMode::OnBehalfOfUser,
1176                "OTHER-AUDIENCE-USER-TOKEN",
1177            ),
1178            delegated(
1179                "gw",
1180                "https://billing.example.com",
1181                DelegationMode::AsThisWorkload,
1182                "BILLING-GATEWAY-TOKEN",
1183            ),
1184        ]);
1185
1186        assert_eq!(
1187            pick_delegated(&extensions, "https://billing.example.com").unwrap(),
1188            "BILLING-GATEWAY-TOKEN"
1189        );
1190    }
1191
1192    #[test]
1193    fn within_one_audience_the_user_token_still_wins() {
1194        // The original mode preference survives the audience filter: among
1195        // same-audience candidates, a delegator acting for the user must not
1196        // get the gateway's own identity — even when the gateway token sorts
1197        // first by subject.
1198        let extensions = extensions_from(vec![
1199            delegated(
1200                "aaa-gw",
1201                "aud",
1202                DelegationMode::AsThisWorkload,
1203                "GATEWAY-TOKEN",
1204            ),
1205            delegated(
1206                "zzz-user",
1207                "aud",
1208                DelegationMode::OnBehalfOfUser,
1209                "USER-TOKEN",
1210            ),
1211        ]);
1212
1213        assert_eq!(pick_delegated(&extensions, "aud").unwrap(), "USER-TOKEN");
1214    }
1215
1216    #[test]
1217    fn a_key_whose_audience_disagrees_with_its_token_is_not_a_match() {
1218        // The audience appears on both the cache key and the token. A mismatch
1219        // means one of the two is wrong, and there is no safe way to guess
1220        // which — so neither audience matches it.
1221        let (key, _) = delegated("alice", "aud-key", DelegationMode::OnBehalfOfUser, "T");
1222        let token = RawDelegatedToken::new(
1223            "MISMATCHED-TOKEN",
1224            "Authorization",
1225            "aud-token",
1226            vec![],
1227            chrono::Utc::now(),
1228        );
1229        let extensions = extensions_from(vec![(key, token)]);
1230
1231        for audience in ["aud-key", "aud-token"] {
1232            assert!(
1233                pick_delegated(&extensions, audience).is_err(),
1234                "a self-inconsistent token must not satisfy '{audience}'"
1235            );
1236        }
1237    }
1238
1239    #[test]
1240    fn an_unscoped_delegation_accepts_any_audience() {
1241        // `DelegationPayload.target_audience` is optional. With no audience to
1242        // filter on there is nothing to be wrong about, so the mode preference
1243        // and the stable tiebreak carry the pick — but it must still be
1244        // deterministic.
1245        let tokens = vec![
1246            delegated("alice", "aud-a", DelegationMode::OnBehalfOfUser, "A"),
1247            delegated("bob", "aud-b", DelegationMode::OnBehalfOfUser, "B"),
1248        ];
1249
1250        for _ in 0..50 {
1251            let mut task = serde_json::json!({
1252                "task_type": "load_and_run_hook",
1253                "payload": { "target_name": "billing", "target_type": "tool" },
1254            });
1255            attach_credential(
1256                &mut task,
1257                "token_delegate",
1258                &extensions_from(tokens.clone()),
1259                &caps(&[CAP_READ_DELEGATED]),
1260            )
1261            .expect("an unscoped delegation is still served");
1262            assert_eq!(task[CREDENTIAL_FIELD]["delegated"]["token"], "A");
1263        }
1264    }
1265
1266    #[test]
1267    fn the_mode_rank_puts_the_user_first_and_unknown_modes_last() {
1268        // An upstream-added mode must not outrank `OnBehalfOfUser` by accident.
1269        assert!(
1270            mode_rank(&DelegationMode::OnBehalfOfUser) < mode_rank(&DelegationMode::AsThisWorkload)
1271        );
1272    }
1273
1274    #[test]
1275    fn the_requested_audience_is_read_from_the_payload() {
1276        assert_eq!(
1277            requested_audience(&delegate_task("https://billing.example.com")).as_deref(),
1278            Some("https://billing.example.com")
1279        );
1280        // Absent, non-string, and payload-less tasks all mean "unscoped".
1281        assert_eq!(requested_audience(&task()), None);
1282        assert_eq!(
1283            requested_audience(&serde_json::json!({ "payload": { "target_audience": 7 } })),
1284            None
1285        );
1286        assert_eq!(
1287            requested_audience(&serde_json::json!({ "payload": {} })),
1288            None
1289        );
1290    }
1291
1292    #[test]
1293    fn the_credential_field_name_matches_the_worker_contract() {
1294        assert_eq!(
1295            CREDENTIAL_FIELD, "credential",
1296            "the worker reads task_data['credential']"
1297        );
1298    }
1299}