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 praxis_policy_core::delegation::DelegationPayload;
51use praxis_policy_core::extensions::Extensions;
52use praxis_policy_core::extensions::raw_credentials::{
53    DelegationKey, DelegationMode, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole,
54};
55use praxis_policy_core::hooks::payload::PluginPayload;
56use 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 praxis_policy_core::extensions::raw_credentials::{DelegationKey, RawCredentialsExtension};
582
583    use super::*;
584
585    const INBOUND_SECRET: &str = "eyJhbGciOiJSUzI1NiJ9.INBOUND-SECRET-BYTES.sig";
586    const DELEGATED_SECRET: &str = "MINTED-DELEGATED-SECRET-BYTES";
587
588    fn caps(names: &[&str]) -> HashSet<String> {
589        names.iter().map(|s| (*s).to_string()).collect()
590    }
591
592    /// Extensions carrying both an inbound and a delegated token.
593    fn extensions_with_credentials() -> Extensions {
594        let mut raw = RawCredentialsExtension::default();
595        raw.inbound_tokens.insert(
596            TokenRole::User,
597            RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt),
598        );
599        raw.delegated_tokens.insert(
600            DelegationKey::new(
601                DelegationMode::OnBehalfOfUser,
602                "https://billing.example.com",
603                vec!["read".into()],
604            )
605            .with_subject_id("alice"),
606            RawDelegatedToken::new(
607                DELEGATED_SECRET,
608                "Authorization",
609                "https://billing.example.com",
610                vec!["read".into()],
611                chrono::Utc::now(),
612            ),
613        );
614
615        Extensions {
616            raw_credentials: Some(Arc::new(raw)),
617            ..Default::default()
618        }
619    }
620
621    fn task() -> Value {
622        serde_json::json!({ "task_type": "load_and_run_hook" })
623    }
624
625    /// A `token.delegate` task whose payload names the audience the delegation
626    /// is for — the shape `plugin.rs` actually builds.
627    fn delegate_task(target_audience: &str) -> Value {
628        serde_json::json!({
629            "task_type": "load_and_run_hook",
630            "hook_type": "token.delegate",
631            "payload": {
632                "target_name": "billing",
633                "target_type": "tool",
634                "target_audience": target_audience,
635            },
636        })
637    }
638
639    /// One delegated token, keyed consistently with its own audience field.
640    fn delegated(
641        subject: &str,
642        audience: &str,
643        mode: DelegationMode,
644        secret: &str,
645    ) -> (DelegationKey, RawDelegatedToken) {
646        (
647            DelegationKey::new(mode, audience, vec![]).with_subject_id(subject),
648            RawDelegatedToken::new(
649                secret,
650                "Authorization",
651                audience,
652                vec![],
653                chrono::Utc::now(),
654            ),
655        )
656    }
657
658    fn extensions_from(tokens: Vec<(DelegationKey, RawDelegatedToken)>) -> Extensions {
659        let mut raw = RawCredentialsExtension::default();
660        for (key, token) in tokens {
661            raw.delegated_tokens.insert(key, token);
662        }
663        Extensions {
664            raw_credentials: Some(Arc::new(raw)),
665            ..Default::default()
666        }
667    }
668
669    /// The delegated token a `token.delegate` dispatch would carry, or the
670    /// fail-closed error.
671    fn pick_delegated(extensions: &Extensions, target_audience: &str) -> Result<String, HostError> {
672        let mut task = delegate_task(target_audience);
673        attach_credential(
674            &mut task,
675            "token.delegate",
676            extensions,
677            &caps(&[CAP_READ_DELEGATED]),
678        )?;
679        Ok(task[CREDENTIAL_FIELD]["delegated"]["token"]
680            .as_str()
681            .expect("a delegated token was attached")
682            .to_string())
683    }
684
685    // --- the capability gate -------------------------------------------------
686
687    #[test]
688    fn a_declaring_plugin_receives_the_inbound_token() {
689        let mut task = task();
690        attach_credential(
691            &mut task,
692            "identity.resolve",
693            &extensions_with_credentials(),
694            &caps(&[CAP_READ_INBOUND]),
695        )
696        .expect("a declared capability is honored");
697
698        let credential = &task[CREDENTIAL_FIELD];
699        assert_eq!(credential["inbound"]["token"], INBOUND_SECRET);
700        assert_eq!(credential["inbound"]["source_header"], "Authorization");
701        assert_eq!(credential["inbound"]["kind"], "jwt");
702        assert!(
703            credential.get("delegated").is_none(),
704            "an identity hook must not receive delegated material"
705        );
706    }
707
708    #[test]
709    fn a_non_declaring_plugin_receives_no_token_material() {
710        let mut task = task();
711        attach_credential(
712            &mut task,
713            "identity.resolve",
714            &extensions_with_credentials(),
715            &caps(&[]),
716        )
717        .expect("declaring nothing is not an error");
718
719        assert!(
720            task.get(CREDENTIAL_FIELD).is_none(),
721            "no credential field at all"
722        );
723        assert!(
724            !serde_json::to_string(&task)
725                .unwrap()
726                .contains("INBOUND-SECRET-BYTES"),
727            "no token bytes anywhere in the task"
728        );
729    }
730
731    #[test]
732    fn two_plugins_on_one_hook_are_gated_independently() {
733        // The capability-gated acceptance example: same hook, same request, one
734        // plugin declared the capability and the other did not.
735        let extensions = extensions_with_credentials();
736
737        let mut declaring = task();
738        attach_credential(
739            &mut declaring,
740            "identity.resolve",
741            &extensions,
742            &caps(&[CAP_READ_INBOUND]),
743        )
744        .unwrap();
745
746        let mut non_declaring = task();
747        attach_credential(
748            &mut non_declaring,
749            "identity.resolve",
750            &extensions,
751            &caps(&[]),
752        )
753        .unwrap();
754
755        assert!(
756            serde_json::to_string(&declaring)
757                .unwrap()
758                .contains(INBOUND_SECRET)
759        );
760        assert!(
761            !serde_json::to_string(&non_declaring)
762                .unwrap()
763                .contains("INBOUND-SECRET-BYTES")
764        );
765    }
766
767    #[test]
768    fn the_wrong_capability_does_not_unlock_a_hook() {
769        // `read_delegated_tokens` must not grant inbound material, and vice
770        // versa — the two are independently scoped.
771        let extensions = extensions_with_credentials();
772
773        let mut identity = task();
774        attach_credential(
775            &mut identity,
776            "identity.resolve",
777            &extensions,
778            &caps(&[CAP_READ_DELEGATED]),
779        )
780        .unwrap();
781        assert!(identity.get(CREDENTIAL_FIELD).is_none());
782
783        let mut delegate = task();
784        attach_credential(
785            &mut delegate,
786            "token.delegate",
787            &extensions,
788            &caps(&[CAP_READ_INBOUND]),
789        )
790        .unwrap();
791        assert!(delegate.get(CREDENTIAL_FIELD).is_none());
792    }
793
794    #[test]
795    fn a_declaring_plugin_receives_the_delegated_token() {
796        let mut task = task();
797        attach_credential(
798            &mut task,
799            "token.delegate",
800            &extensions_with_credentials(),
801            &caps(&[CAP_READ_DELEGATED]),
802        )
803        .expect("a declared capability is honored");
804
805        let delegated = &task[CREDENTIAL_FIELD]["delegated"];
806        assert_eq!(delegated["token"], DELEGATED_SECRET);
807        assert_eq!(delegated["outbound_header"], "Authorization");
808        assert_eq!(delegated["audience"], "https://billing.example.com");
809        assert_eq!(delegated["scopes"][0], "read");
810        assert!(task[CREDENTIAL_FIELD].get("inbound").is_none());
811    }
812
813    #[test]
814    fn non_credential_hooks_never_receive_a_credential() {
815        // Even a plugin that declared both capabilities gets nothing on a hook
816        // whose payload has nowhere to put a token.
817        for hook in [
818            "tool_pre_invoke",
819            "tool_post_invoke",
820            "prompt_pre_fetch",
821            "resource_post_fetch",
822            "cmf.tool_pre_invoke",
823            "cmf.prompt_pre_invoke",
824            "http.request",
825            "some_custom_hook",
826            // The unprefixed names PPE no longer dispatches. A host that
827            // registers them gets the generic payload and no credential.
828            "identity_resolve",
829            "token_delegate",
830        ] {
831            let mut task = task();
832            attach_credential(
833                &mut task,
834                hook,
835                &extensions_with_credentials(),
836                &caps(&[CAP_READ_INBOUND, CAP_READ_DELEGATED]),
837            )
838            .unwrap();
839
840            assert!(
841                task.get(CREDENTIAL_FIELD).is_none(),
842                "hook '{hook}' must not receive credentials"
843            );
844        }
845    }
846
847    // --- fail closed ---------------------------------------------------------
848
849    #[test]
850    fn a_declared_capability_with_no_extension_errors() {
851        // Fail closed rather than dispatching without the token: a plugin that
852        // asked for a credential and silently got none may treat the request
853        // as unauthenticated.
854        let err = attach_credential(
855            &mut task(),
856            "identity.resolve",
857            &Extensions::default(),
858            &caps(&[CAP_READ_INBOUND]),
859        )
860        .expect_err("a declared capability that cannot be honored must error");
861
862        assert!(matches!(err, HostError::Credential { .. }), "got {err:?}");
863    }
864
865    #[test]
866    fn a_declared_capability_with_no_matching_token_errors() {
867        // The extension exists but carries only delegated tokens, so the
868        // inbound capability cannot be honored.
869        let mut raw = RawCredentialsExtension::default();
870        raw.delegated_tokens.insert(
871            DelegationKey::new(DelegationMode::AsThisWorkload, "aud", vec![])
872                .with_subject_id("alice"),
873            RawDelegatedToken::new("t", "Authorization", "aud", vec![], chrono::Utc::now()),
874        );
875        let extensions = Extensions {
876            raw_credentials: Some(Arc::new(raw)),
877            ..Default::default()
878        };
879
880        let err = attach_credential(
881            &mut task(),
882            "identity.resolve",
883            &extensions,
884            &caps(&[CAP_READ_INBOUND]),
885        )
886        .expect_err("no inbound token means the capability cannot be honored");
887        assert!(matches!(err, HostError::Credential { .. }));
888    }
889
890    #[test]
891    fn an_empty_token_is_rejected_rather_than_sent() {
892        // A `#[serde(skip)]`-stripped token deserializes to "" — exactly the
893        // shape that reaches here if anyone ever reintroduces a
894        // serialize-then-reparse. Sending it would authenticate as an empty
895        // bearer.
896        for token in ["", "   ", "\t\n"] {
897            let mut raw = RawCredentialsExtension::default();
898            raw.inbound_tokens.insert(
899                TokenRole::User,
900                RawInboundToken::new(token, "Authorization", TokenKind::Jwt),
901            );
902            let extensions = Extensions {
903                raw_credentials: Some(Arc::new(raw)),
904                ..Default::default()
905            };
906
907            let err = attach_credential(
908                &mut task(),
909                "identity.resolve",
910                &extensions,
911                &caps(&[CAP_READ_INBOUND]),
912            )
913            .expect_err("an empty or whitespace-only token must be refused");
914            assert!(matches!(err, HostError::Credential { .. }));
915        }
916    }
917
918    #[test]
919    fn an_empty_delegated_token_is_rejected() {
920        let mut raw = RawCredentialsExtension::default();
921        raw.delegated_tokens.insert(
922            DelegationKey::new(DelegationMode::OnBehalfOfUser, "aud", vec![])
923                .with_subject_id("alice"),
924            RawDelegatedToken::new("", "Authorization", "aud", vec![], chrono::Utc::now()),
925        );
926        let extensions = Extensions {
927            raw_credentials: Some(Arc::new(raw)),
928            ..Default::default()
929        };
930
931        let err = attach_credential(
932            &mut task(),
933            "token.delegate",
934            &extensions,
935            &caps(&[CAP_READ_DELEGATED]),
936        )
937        .expect_err("an empty delegated token must be refused");
938        assert!(matches!(err, HostError::Credential { .. }));
939    }
940
941    #[test]
942    fn a_fail_closed_error_never_names_the_token() {
943        let mut raw = RawCredentialsExtension::default();
944        raw.inbound_tokens.insert(
945            TokenRole::User,
946            RawInboundToken::new("   ", "Authorization", TokenKind::Jwt),
947        );
948        let extensions = Extensions {
949            raw_credentials: Some(Arc::new(raw)),
950            ..Default::default()
951        };
952
953        let err = attach_credential(
954            &mut task(),
955            "identity.resolve",
956            &extensions,
957            &caps(&[CAP_READ_INBOUND]),
958        )
959        .expect_err("errors");
960        let message = err.to_string();
961        assert!(
962            message.contains("identity.resolve"),
963            "the message should name the hook: {message}"
964        );
965        assert!(message.contains("empty"));
966    }
967
968    // --- the DTO is the only carrier ----------------------------------------
969
970    #[test]
971    fn production_credential_types_still_refuse_to_serialize_tokens() {
972        // The guard this module works around must remain intact: nothing here
973        // loosened it, so a direct serialize of the production types still
974        // strips the bytes. If this ever fails, the DTO stopped being the only
975        // path token material can take.
976        let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt);
977        let json = serde_json::to_string(&inbound).unwrap();
978        assert!(
979            !json.contains("INBOUND-SECRET-BYTES"),
980            "the serde guard regressed: {json}"
981        );
982
983        let delegated = RawDelegatedToken::new(
984            DELEGATED_SECRET,
985            "Authorization",
986            "aud",
987            vec![],
988            chrono::Utc::now(),
989        );
990        let json = serde_json::to_string(&delegated).unwrap();
991        assert!(
992            !json.contains("MINTED-DELEGATED"),
993            "the serde guard regressed: {json}"
994        );
995
996        // And the inbound sub-map, which is what an audit or trace path dumps.
997        // (`delegated_tokens` is keyed by a struct, so the extension as a whole
998        // is not JSON-serializable — a pre-existing property of the type, not
999        // something this host changed.)
1000        let extensions = extensions_with_credentials();
1001        let raw = extensions.raw_credentials.as_ref().unwrap();
1002        let json = serde_json::to_string(&raw.inbound_tokens).unwrap();
1003        assert!(
1004            !json.contains("INBOUND-SECRET-BYTES"),
1005            "the serde guard regressed: {json}"
1006        );
1007
1008        for token in raw.delegated_tokens.values() {
1009            let json = serde_json::to_string(token).unwrap();
1010            assert!(
1011                !json.contains("MINTED-DELEGATED"),
1012                "the serde guard regressed: {json}"
1013            );
1014        }
1015    }
1016
1017    #[test]
1018    fn a_serialize_then_reparse_would_have_yielded_an_empty_token() {
1019        // Documents *why* the DTO reads the in-memory field directly. This is
1020        // the tempting shortcut, and it silently produces an empty credential.
1021        let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt);
1022        let round_tripped: RawInboundToken =
1023            serde_json::from_str(&serde_json::to_string(&inbound).unwrap()).unwrap();
1024
1025        assert_eq!(
1026            &*round_tripped.token, "",
1027            "the guard strips the bytes on the way out"
1028        );
1029        assert_eq!(round_tripped.source_header, "Authorization");
1030    }
1031
1032    // --- redaction -----------------------------------------------------------
1033
1034    #[test]
1035    fn the_dto_debug_output_hides_the_token() {
1036        let dto = CredentialDto {
1037            inbound: Some(InboundCredential {
1038                token: INBOUND_SECRET.into(),
1039                source_header: "Authorization".into(),
1040                kind: "jwt".into(),
1041                headers: Some(HashMap::from([(
1042                    "Authorization".into(),
1043                    format!("Bearer {INBOUND_SECRET}"),
1044                )])),
1045            }),
1046            delegated: None,
1047        };
1048
1049        // Both the outer DTO and the inner sub-object are printed, since either
1050        // could reach a log line on its own.
1051        let outer = format!("{dto:?}");
1052        let inner = format!("{:?}", dto.inbound.as_ref().unwrap());
1053
1054        for debug in [&outer, &inner] {
1055            assert!(
1056                !debug.contains("INBOUND-SECRET-BYTES"),
1057                "token leaked into Debug: {debug}"
1058            );
1059        }
1060        // Non-secret context stays diagnosable.
1061        assert!(inner.contains("Authorization"));
1062        assert!(inner.contains("jwt"));
1063    }
1064
1065    #[test]
1066    fn the_delegated_dto_debug_output_hides_the_token() {
1067        let credential = DelegatedCredential {
1068            token: DELEGATED_SECRET.into(),
1069            outbound_header: "Authorization".into(),
1070            audience: "https://billing.example.com".into(),
1071            scopes: vec!["read".into()],
1072        };
1073
1074        let debug = format!("{credential:?}");
1075        assert!(
1076            !debug.contains("MINTED-DELEGATED"),
1077            "token leaked into Debug: {debug}"
1078        );
1079        assert!(debug.contains("billing.example.com"));
1080        assert!(debug.contains("read"));
1081    }
1082
1083    // --- wire contract details ----------------------------------------------
1084
1085    #[test]
1086    fn token_kinds_use_the_wire_names_the_worker_matches_on() {
1087        // worker.py's _BEARER_TOKEN_KINDS is {jwt, opaque, spiffe_jwt, ucan};
1088        // a mismatch here would silently map a bearer to source "custom".
1089        assert_eq!(token_kind_wire_name(&TokenKind::Jwt), "jwt");
1090        assert_eq!(token_kind_wire_name(&TokenKind::Opaque), "opaque");
1091        assert_eq!(token_kind_wire_name(&TokenKind::SpiffeJwt), "spiffe_jwt");
1092        assert_eq!(token_kind_wire_name(&TokenKind::Ucan), "ucan");
1093        assert_eq!(token_kind_wire_name(&TokenKind::TxnToken), "txn_token");
1094    }
1095
1096    #[test]
1097    fn wire_names_match_the_serde_representation() {
1098        // Guards the hand-written mapping against the enum's own serde rename,
1099        // so adding a variant upstream cannot silently desync the two.
1100        for kind in [
1101            TokenKind::Jwt,
1102            TokenKind::Opaque,
1103            TokenKind::SpiffeJwt,
1104            TokenKind::Ucan,
1105            TokenKind::TxnToken,
1106        ] {
1107            let via_serde = serde_json::to_value(&kind).unwrap();
1108            assert_eq!(
1109                via_serde.as_str().unwrap(),
1110                token_kind_wire_name(&kind),
1111                "hand-written wire name disagrees with serde for {kind:?}"
1112            );
1113        }
1114    }
1115
1116    #[test]
1117    fn the_user_token_is_preferred_over_the_client_token() {
1118        // An identity resolver wants the subject's credential; resolving the
1119        // gateway's own token as the user's would be an authorization bug.
1120        let mut raw = RawCredentialsExtension::default();
1121        raw.inbound_tokens.insert(
1122            TokenRole::Client,
1123            RawInboundToken::new("CLIENT-TOKEN", "X-Client", TokenKind::Opaque),
1124        );
1125        raw.inbound_tokens.insert(
1126            TokenRole::User,
1127            RawInboundToken::new("USER-TOKEN", "Authorization", TokenKind::Jwt),
1128        );
1129        let extensions = Extensions {
1130            raw_credentials: Some(Arc::new(raw)),
1131            ..Default::default()
1132        };
1133
1134        let mut task = task();
1135        attach_credential(
1136            &mut task,
1137            "identity.resolve",
1138            &extensions,
1139            &caps(&[CAP_READ_INBOUND]),
1140        )
1141        .unwrap();
1142        assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], "USER-TOKEN");
1143    }
1144
1145    #[test]
1146    fn headers_are_synthesized_from_the_source_header() {
1147        let mut task = task();
1148        attach_credential(
1149            &mut task,
1150            "identity.resolve",
1151            &extensions_with_credentials(),
1152            &caps(&[CAP_READ_INBOUND]),
1153        )
1154        .unwrap();
1155
1156        // The worker scrubs the plaintext out of these values before a plugin
1157        // can echo them; the host's job is only to name the carrying header.
1158        assert_eq!(
1159            task[CREDENTIAL_FIELD]["inbound"]["headers"]["Authorization"],
1160            INBOUND_SECRET
1161        );
1162    }
1163
1164    #[test]
1165    fn an_on_behalf_of_user_token_is_preferred_over_a_gateway_token() {
1166        let mut raw = RawCredentialsExtension::default();
1167        raw.delegated_tokens.insert(
1168            DelegationKey::new(DelegationMode::AsThisWorkload, "aud", vec![]).with_subject_id("gw"),
1169            RawDelegatedToken::new(
1170                "GATEWAY-TOKEN",
1171                "Authorization",
1172                "aud",
1173                vec![],
1174                chrono::Utc::now(),
1175            ),
1176        );
1177        raw.delegated_tokens.insert(
1178            DelegationKey::new(DelegationMode::OnBehalfOfUser, "aud", vec![])
1179                .with_subject_id("alice"),
1180            RawDelegatedToken::new(
1181                "USER-DELEGATED-TOKEN",
1182                "Authorization",
1183                "aud",
1184                vec![],
1185                chrono::Utc::now(),
1186            ),
1187        );
1188        let extensions = Extensions {
1189            raw_credentials: Some(Arc::new(raw)),
1190            ..Default::default()
1191        };
1192
1193        let mut task = task();
1194        attach_credential(
1195            &mut task,
1196            "token.delegate",
1197            &extensions,
1198            &caps(&[CAP_READ_DELEGATED]),
1199        )
1200        .unwrap();
1201        assert_eq!(
1202            task[CREDENTIAL_FIELD]["delegated"]["token"], "USER-DELEGATED-TOKEN",
1203            "a delegator acting for the user must not fall back to the gateway's identity"
1204        );
1205    }
1206
1207    // --- delegated-token selection ------------------------------------------
1208
1209    #[test]
1210    fn the_delegated_pick_matches_the_requested_audience() {
1211        // The reviewer's finding: filtering on mode alone let `HashMap` order
1212        // decide which token a plugin got. Three tokens share the preferred
1213        // mode and differ only by audience, so a mode-only filter picks one of
1214        // the three at random — and two of those three are credentials minted
1215        // for a completely different upstream.
1216        let extensions = extensions_from(vec![
1217            delegated(
1218                "alice",
1219                "https://billing.example.com",
1220                DelegationMode::OnBehalfOfUser,
1221                "BILLING-TOKEN",
1222            ),
1223            delegated(
1224                "alice",
1225                "https://search.example.com",
1226                DelegationMode::OnBehalfOfUser,
1227                "SEARCH-TOKEN",
1228            ),
1229            delegated(
1230                "alice",
1231                "https://payroll.example.com",
1232                DelegationMode::OnBehalfOfUser,
1233                "PAYROLL-TOKEN",
1234            ),
1235        ]);
1236
1237        assert_eq!(
1238            pick_delegated(&extensions, "https://search.example.com").unwrap(),
1239            "SEARCH-TOKEN",
1240            "a token minted for another audience must never be substituted"
1241        );
1242        assert_eq!(
1243            pick_delegated(&extensions, "https://billing.example.com").unwrap(),
1244            "BILLING-TOKEN"
1245        );
1246    }
1247
1248    #[test]
1249    fn the_delegated_pick_is_stable_across_runs_and_insertion_orders() {
1250        // `HashMap` seeds its hasher per process *and* orders by insertion
1251        // history, so the pre-fix selection could differ run to run and between
1252        // two extensions holding the same tokens. Both are pinned here: every
1253        // repetition, under every insertion order, yields the same token.
1254        let forward = vec![
1255            delegated("alice", "aud-a", DelegationMode::OnBehalfOfUser, "A"),
1256            delegated("bob", "aud-a", DelegationMode::OnBehalfOfUser, "B"),
1257            delegated("carol", "aud-a", DelegationMode::OnBehalfOfUser, "C"),
1258            delegated("dave", "aud-a", DelegationMode::OnBehalfOfUser, "D"),
1259        ];
1260        let reversed: Vec<_> = forward.iter().rev().cloned().collect();
1261
1262        // `alice` sorts first among the equal-mode candidates.
1263        const EXPECTED: &str = "A";
1264
1265        for _ in 0..200 {
1266            let one = extensions_from(forward.clone());
1267            let other = extensions_from(reversed.clone());
1268            assert_eq!(pick_delegated(&one, "aud-a").unwrap(), EXPECTED);
1269            assert_eq!(
1270                pick_delegated(&other, "aud-a").unwrap(),
1271                EXPECTED,
1272                "insertion order must not change the pick"
1273            );
1274        }
1275    }
1276
1277    #[test]
1278    fn no_token_for_the_requested_audience_fails_closed() {
1279        // Fail closed rather than fall back: attaching another audience's token
1280        // would send a credential to an upstream that never authorized it.
1281        let extensions = extensions_from(vec![delegated(
1282            "alice",
1283            "https://billing.example.com",
1284            DelegationMode::OnBehalfOfUser,
1285            "BILLING-TOKEN",
1286        )]);
1287
1288        let err = pick_delegated(&extensions, "https://attacker.example.com")
1289            .expect_err("no matching audience means no token");
1290
1291        let message = err.to_string();
1292        assert!(matches!(err, HostError::Credential { .. }), "{message}");
1293        assert!(
1294            message.contains("attacker.example.com"),
1295            "the error should name the audience that had no token: {message}"
1296        );
1297        assert!(
1298            !message.contains("BILLING-TOKEN"),
1299            "the error must not name the token it declined to substitute: {message}"
1300        );
1301    }
1302
1303    #[test]
1304    fn the_audience_filter_is_applied_before_the_mode_preference() {
1305        // Mode preference must not reach across audiences: the only token for
1306        // the requested audience is `AsThisWorkload`, and the `OnBehalfOfUser` token
1307        // belongs to a different upstream. Preferring mode first would hand
1308        // over the wrong-audience token.
1309        let extensions = extensions_from(vec![
1310            delegated(
1311                "alice",
1312                "https://other.example.com",
1313                DelegationMode::OnBehalfOfUser,
1314                "OTHER-AUDIENCE-USER-TOKEN",
1315            ),
1316            delegated(
1317                "gw",
1318                "https://billing.example.com",
1319                DelegationMode::AsThisWorkload,
1320                "BILLING-GATEWAY-TOKEN",
1321            ),
1322        ]);
1323
1324        assert_eq!(
1325            pick_delegated(&extensions, "https://billing.example.com").unwrap(),
1326            "BILLING-GATEWAY-TOKEN"
1327        );
1328    }
1329
1330    #[test]
1331    fn within_one_audience_the_user_token_still_wins() {
1332        // The original mode preference survives the audience filter: among
1333        // same-audience candidates, a delegator acting for the user must not
1334        // get the gateway's own identity — even when the gateway token sorts
1335        // first by subject.
1336        let extensions = extensions_from(vec![
1337            delegated(
1338                "aaa-gw",
1339                "aud",
1340                DelegationMode::AsThisWorkload,
1341                "GATEWAY-TOKEN",
1342            ),
1343            delegated(
1344                "zzz-user",
1345                "aud",
1346                DelegationMode::OnBehalfOfUser,
1347                "USER-TOKEN",
1348            ),
1349        ]);
1350
1351        assert_eq!(pick_delegated(&extensions, "aud").unwrap(), "USER-TOKEN");
1352    }
1353
1354    #[test]
1355    fn a_key_whose_audience_disagrees_with_its_token_is_not_a_match() {
1356        // The audience appears on both the cache key and the token. A mismatch
1357        // means one of the two is wrong, and there is no safe way to guess
1358        // which — so neither audience matches it.
1359        let (key, _) = delegated("alice", "aud-key", DelegationMode::OnBehalfOfUser, "T");
1360        let token = RawDelegatedToken::new(
1361            "MISMATCHED-TOKEN",
1362            "Authorization",
1363            "aud-token",
1364            vec![],
1365            chrono::Utc::now(),
1366        );
1367        let extensions = extensions_from(vec![(key, token)]);
1368
1369        for audience in ["aud-key", "aud-token"] {
1370            assert!(
1371                pick_delegated(&extensions, audience).is_err(),
1372                "a self-inconsistent token must not satisfy '{audience}'"
1373            );
1374        }
1375    }
1376
1377    #[test]
1378    fn an_unscoped_delegation_accepts_any_audience() {
1379        // `DelegationPayload.target_audience` is optional. With no audience to
1380        // filter on there is nothing to be wrong about, so the mode preference
1381        // and the stable tiebreak carry the pick — but it must still be
1382        // deterministic.
1383        let tokens = vec![
1384            delegated("alice", "aud-a", DelegationMode::OnBehalfOfUser, "A"),
1385            delegated("bob", "aud-b", DelegationMode::OnBehalfOfUser, "B"),
1386        ];
1387
1388        for _ in 0..50 {
1389            let mut task = serde_json::json!({
1390                "task_type": "load_and_run_hook",
1391                "payload": { "target_name": "billing", "target_type": "tool" },
1392            });
1393            attach_credential(
1394                &mut task,
1395                "token.delegate",
1396                &extensions_from(tokens.clone()),
1397                &caps(&[CAP_READ_DELEGATED]),
1398            )
1399            .expect("an unscoped delegation is still served");
1400            assert_eq!(task[CREDENTIAL_FIELD]["delegated"]["token"], "A");
1401        }
1402    }
1403
1404    #[test]
1405    fn the_mode_rank_puts_the_user_first_and_unknown_modes_last() {
1406        // An upstream-added mode must not outrank `OnBehalfOfUser` by accident.
1407        assert!(
1408            mode_rank(&DelegationMode::OnBehalfOfUser) < mode_rank(&DelegationMode::AsThisWorkload)
1409        );
1410    }
1411
1412    #[test]
1413    fn the_requested_audience_is_read_from_the_payload() {
1414        assert_eq!(
1415            requested_audience(&delegate_task("https://billing.example.com")).as_deref(),
1416            Some("https://billing.example.com")
1417        );
1418        // Absent, non-string, and payload-less tasks all mean "unscoped".
1419        assert_eq!(requested_audience(&task()), None);
1420        assert_eq!(
1421            requested_audience(&serde_json::json!({ "payload": { "target_audience": 7 } })),
1422            None
1423        );
1424        assert_eq!(
1425            requested_audience(&serde_json::json!({ "payload": {} })),
1426            None
1427        );
1428    }
1429
1430    #[test]
1431    fn the_credential_field_name_matches_the_worker_contract() {
1432        assert_eq!(
1433            CREDENTIAL_FIELD, "credential",
1434            "the worker reads task_data['credential']"
1435        );
1436    }
1437
1438    // --- PPE identity and delegation payloads ---------------------------------
1439
1440    use praxis_policy_core::delegation::DelegationPayload;
1441    use praxis_policy_core::identity::{IdentityPayload, TokenSource};
1442
1443    fn identity_payload() -> IdentityPayload {
1444        IdentityPayload::new(INBOUND_SECRET, TokenSource::Bearer).with_headers(HashMap::from([
1445            ("authorization".into(), format!("Bearer {INBOUND_SECRET}")),
1446            ("cookie".into(), "session=abc".into()),
1447            ("x-tenant".into(), "acme".into()),
1448        ]))
1449    }
1450
1451    #[test]
1452    fn an_identity_payload_supplies_its_own_token_to_a_declaring_plugin() {
1453        let mut task = task();
1454        // No raw-credentials extension: PPE dispatches identity.resolve with the
1455        // credential on the payload.
1456        attach_credential_for_payload(
1457            &mut task,
1458            "identity.resolve",
1459            &identity_payload(),
1460            &Extensions::default(),
1461            &caps(&[CAP_READ_INBOUND]),
1462        )
1463        .expect("the payload's token honors the capability");
1464
1465        let inbound = &task[CREDENTIAL_FIELD]["inbound"];
1466        assert_eq!(inbound["token"], INBOUND_SECRET);
1467        assert_eq!(inbound["source_header"], "authorization");
1468        assert_eq!(inbound["kind"], "opaque");
1469        // The gated channel carries the whole header map; the worker scrubs it.
1470        assert_eq!(inbound["headers"]["cookie"], "session=abc");
1471        assert_eq!(inbound["headers"]["x-tenant"], "acme");
1472    }
1473
1474    #[test]
1475    fn a_header_borne_identity_credential_is_found() {
1476        let payload: IdentityPayload = serde_json::from_value(serde_json::json!({
1477            "source": "bearer",
1478            "source_header": "X-User-Token",
1479            "headers": {"x-user-token": INBOUND_SECRET},
1480        }))
1481        .unwrap();
1482        let mut task = task();
1483        attach_credential_for_payload(
1484            &mut task,
1485            "identity.resolve",
1486            &payload,
1487            &Extensions::default(),
1488            &caps(&[CAP_READ_INBOUND]),
1489        )
1490        .unwrap();
1491        assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], INBOUND_SECRET);
1492        assert_eq!(
1493            task[CREDENTIAL_FIELD]["inbound"]["source_header"],
1494            "x-user-token"
1495        );
1496    }
1497
1498    #[test]
1499    fn an_identity_payload_gives_a_non_declaring_plugin_nothing() {
1500        let mut task = task();
1501        attach_credential_for_payload(
1502            &mut task,
1503            "identity.resolve",
1504            &identity_payload(),
1505            &extensions_with_credentials(),
1506            &caps(&[CAP_READ_DELEGATED]),
1507        )
1508        .unwrap();
1509        assert!(task.get(CREDENTIAL_FIELD).is_none());
1510    }
1511
1512    #[test]
1513    fn an_identity_payload_without_a_credential_fails_closed() {
1514        let payload = IdentityPayload::new("", TokenSource::Bearer);
1515        let mut task = task();
1516        // The extensions hold a token, but it is not the one this hook is
1517        // about, so it is not substituted.
1518        let err = attach_credential_for_payload(
1519            &mut task,
1520            "identity.resolve",
1521            &payload,
1522            &extensions_with_credentials(),
1523            &caps(&[CAP_READ_INBOUND]),
1524        )
1525        .unwrap_err();
1526        assert!(matches!(err, HostError::Credential { .. }), "{err:?}");
1527        assert!(task.get(CREDENTIAL_FIELD).is_none());
1528    }
1529
1530    #[test]
1531    fn a_delegation_payload_supplies_its_bearer_token_to_an_inbound_declaring_plugin() {
1532        let payload = DelegationPayload::new(INBOUND_SECRET, "billing")
1533            .with_target_audience("https://billing.example.com")
1534            .with_required_permissions(vec!["read".into()]);
1535        let mut gated = task();
1536        attach_credential_for_payload(
1537            &mut gated,
1538            "token.delegate",
1539            &payload,
1540            &Extensions::default(),
1541            &caps(&[CAP_READ_INBOUND]),
1542        )
1543        .unwrap();
1544        let delegated = &gated[CREDENTIAL_FIELD]["delegated"];
1545        assert_eq!(delegated["token"], INBOUND_SECRET);
1546        assert_eq!(delegated["audience"], "https://billing.example.com");
1547        assert_eq!(delegated["scopes"], serde_json::json!(["read"]));
1548
1549        // The bearer is an inbound credential, so the delegated-token
1550        // capability alone does not unlock it.
1551        let mut task = task();
1552        attach_credential_for_payload(
1553            &mut task,
1554            "token.delegate",
1555            &payload,
1556            &Extensions::default(),
1557            &caps(&[CAP_READ_DELEGATED]),
1558        )
1559        .unwrap();
1560        assert!(task.get(CREDENTIAL_FIELD).is_none());
1561    }
1562
1563    #[test]
1564    fn a_delegation_payload_without_a_bearer_token_fails_closed() {
1565        let payload = DelegationPayload::new("  ", "billing");
1566        let mut task = task();
1567        let err = attach_credential_for_payload(
1568            &mut task,
1569            "token.delegate",
1570            &payload,
1571            &extensions_with_credentials(),
1572            &caps(&[CAP_READ_INBOUND]),
1573        )
1574        .unwrap_err();
1575        assert!(matches!(err, HostError::Credential { .. }), "{err:?}");
1576    }
1577
1578    #[test]
1579    fn a_legacy_payload_still_reads_the_extensions() {
1580        let mut task = task();
1581        attach_credential_for_payload(
1582            &mut task,
1583            "identity.resolve",
1584            &crate::legacy::IdentityResolvePayload::default(),
1585            &extensions_with_credentials(),
1586            &caps(&[CAP_READ_INBOUND]),
1587        )
1588        .unwrap();
1589        assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], INBOUND_SECRET);
1590    }
1591}