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