Skip to main content

cpex_core/extensions/
raw_credentials.rs

1// Location: ./crates/cpex-core/src/extensions/raw_credentials.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// `RawCredentialsExtension` — Layer 3 of the three-layer credential
7// storage model (docs/specs/delegation-hooks-rust-spec.md §4.2).
8// Carries the *raw* token material — bearer JWTs, opaque session
9// strings, SPIFFE-JWT-SVIDs, UCAN tokens, transaction tokens — that
10// IdentityResolve and TokenDelegate handlers need to do their jobs.
11//
12// # Why this is its own extension
13//
14// `SubjectExtension` / `ClientExtension` / `WorkloadIdentity` carry
15// *validated* identity — claims already extracted, signature already
16// checked, scopes already enumerated. Most plugins want that and
17// nothing more. A small set of plugins (identity resolvers, token
18// exchangers, forwarding proxies) genuinely need the raw material to
19// re-attach it to outbound calls or hand it to an introspection
20// endpoint. Separating raw from validated lets us gate the raw layer
21// behind narrowly-scoped capabilities (`read_inbound_credentials`,
22// `read_delegated_tokens`) so a buggy or malicious plugin without
23// those caps can't get at credential strings.
24//
25// # Serialization safety
26//
27// `RawInboundToken.token` and `RawDelegatedToken.token` are
28// `#[serde(skip)]`. Any normal serialization of an `Extensions` —
29// debug dumps, audit logs, trace snapshots, hot-reload bundles —
30// produces JSON / YAML where the token field is absent. A deserialize
31// then yields a struct with `Zeroizing::new(String::new())` as the
32// token, which is explicitly safe (empty bearer authenticates
33// nowhere) but a deliberate foot-gun: a plugin that deserializes an
34// extension snapshot and expects to find a working token will fail
35// loudly, not silently leak credentials by accident.
36//
37// This implicitly means **out-of-process plugins (remote / WASM)
38// cannot read or write raw credentials**. That's by design — the
39// security audit story is much simpler when "raw credentials never
40// leave the host process" is an invariant rather than a per-plugin
41// trust decision. Handlers that need raw material must run in-process.
42// See the slice plan and the architecture discussion in
43// `docs/raw-credentials-slice-plan.md` for the reasoning.
44//
45// # Memory hygiene
46//
47// `Zeroizing<String>` wipes the underlying bytes when the struct is
48// dropped. The protection is real but not absolute — bytes can still
49// leak via String::clone, format!, or temporaries created on the way
50// to the wrapper. Treat tokens as best-effort cleared, not
51// guaranteed.
52
53use std::collections::HashMap;
54
55use chrono::{DateTime, Utc};
56use serde::{Deserialize, Serialize};
57use zeroize::Zeroizing;
58
59/// Which principal a raw inbound token represents. Lookups in
60/// `RawCredentialsExtension.inbound_tokens` are by this key.
61///
62/// `Custom(String)` is the escape hatch for host-defined roles —
63/// HashMap equality is by value, so callers must construct the same
64/// `Custom("foo".into())` for both insert and lookup.
65#[non_exhaustive]
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum TokenRole {
69    /// The user / subject token (e.g. `id_token`, `X-User-Token`).
70    User,
71    /// The OAuth client / gateway-access token (e.g. `Authorization:
72    /// Bearer ...` from a session JWT).
73    Client,
74    /// A JWT-SVID presented by the inbound workload, when SPIFFE
75    /// attestation is JWT-based instead of mTLS-based.
76    Workload,
77    /// Host-defined role.
78    #[serde(untagged)]
79    Custom(String),
80}
81
82/// The wire-format family of a raw token. Lets handlers pick the
83/// right validation path without parsing the token first.
84#[non_exhaustive]
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum TokenKind {
88    /// Standard JWT — three base64url segments joined by dots.
89    Jwt,
90    /// Opaque bearer — handler must introspect (RFC 7662) to validate.
91    Opaque,
92    /// SPIFFE JWT-SVID — JWT-shaped but with SPIFFE-specific claims.
93    SpiffeJwt,
94    /// UCAN capability token.
95    Ucan,
96    /// Transaction token — short-lived, single-request scope.
97    TxnToken,
98}
99
100/// Whether a delegated outbound token represents the user's identity
101/// or the gateway's own identity to the downstream service. Affects
102/// scope-narrowing rules and audit-log attribution.
103#[non_exhaustive]
104#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum DelegationMode {
107    /// Outbound token represents the original user (RFC 8693
108    /// on-behalf-of / actor-token flows, UCAN delegation).
109    OnBehalfOfUser,
110    /// Outbound token represents the gateway / agent itself as the
111    /// principal; user identity is conveyed via separate context.
112    AsGateway,
113}
114
115/// One inbound credential, captured at the wire layer and stashed
116/// here by an identity-resolver plugin. Validation happens elsewhere
117/// — this struct just carries the bytes and a few hints.
118///
119/// The `token` field is `#[serde(skip)]`. Serializing a struct of
120/// this type yields `{ "source_header": "...", "kind": "..." }` —
121/// the secret material is left out. Deserializing produces a struct
122/// whose `token` is `Zeroizing::new(String::new())`. Document this
123/// invariant when handing instances across any process boundary.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct RawInboundToken {
126    /// The raw credential bytes. Cleared on drop via `Zeroizing`.
127    /// **Never serialized** — `#[serde(skip)]` strips this field.
128    #[serde(skip)]
129    pub token: Zeroizing<String>,
130
131    /// The HTTP header (or other wire-level slot) the token arrived
132    /// in — `"Authorization"`, `"X-User-Token"`, etc. Forwarding
133    /// plugins re-attach under the same name; audit logs cite it.
134    pub source_header: String,
135
136    /// Wire-format family of the token. Lets handlers route to the
137    /// right validator without re-parsing the token contents.
138    pub kind: TokenKind,
139}
140
141impl RawInboundToken {
142    /// Build a token from raw material + metadata. The most common
143    /// constructor; identity-resolver plugins call this once per
144    /// recognized credential.
145    pub fn new(
146        token: impl Into<String>,
147        source_header: impl Into<String>,
148        kind: TokenKind,
149    ) -> Self {
150        Self {
151            token: Zeroizing::new(token.into()),
152            source_header: source_header.into(),
153            kind,
154        }
155    }
156}
157
158/// Composite key for cached delegated tokens. Token cache lookups
159/// hit on `(subject, audience, scopes, mode)` so different audiences
160/// or scope sets for the same subject mint independent tokens.
161///
162/// `scopes` is a `Vec<String>` (not a `HashSet`) because Cedar / OPA
163/// policies frequently care about scope *order* — `["read", "write"]`
164/// and `["write", "read"]` may carry different semantics in some IdPs.
165/// Callers that want set semantics should sort before constructing.
166#[derive(Debug, Hash, Eq, PartialEq, Clone, Serialize, Deserialize)]
167pub struct DelegationKey {
168    pub subject_id: String,
169    pub audience: String,
170    pub scopes: Vec<String>,
171    pub mode: DelegationMode,
172}
173
174/// One minted outbound credential, produced by a TokenDelegate
175/// handler and cached for re-use until expiry. The `token` field is
176/// serde-skipped under the same invariant as `RawInboundToken.token`.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct RawDelegatedToken {
179    /// The minted outbound credential. Cleared on drop.
180    #[serde(skip)]
181    pub token: Zeroizing<String>,
182
183    /// Where the consuming plugin should attach the token on the
184    /// upstream request. Often `"Authorization"`, sometimes
185    /// audience-specific.
186    pub outbound_header: String,
187
188    /// The audience the token was minted for. Cache keys include
189    /// this; the field here is for audit / debugging.
190    pub audience: String,
191
192    /// Effective scopes on the minted token. May be narrower than
193    /// the inbound credential's scopes — monotonic narrowing is a
194    /// framework-level invariant enforced by TokenDelegate.
195    pub scopes: Vec<String>,
196
197    /// Cache eviction trigger. Handlers re-mint when `now >=
198    /// expires_at - safety_margin`.
199    pub expires_at: DateTime<Utc>,
200}
201
202impl RawDelegatedToken {
203    pub fn new(
204        token: impl Into<String>,
205        outbound_header: impl Into<String>,
206        audience: impl Into<String>,
207        scopes: Vec<String>,
208        expires_at: DateTime<Utc>,
209    ) -> Self {
210        Self {
211            token: Zeroizing::new(token.into()),
212            outbound_header: outbound_header.into(),
213            audience: audience.into(),
214            scopes,
215            expires_at,
216        }
217    }
218}
219
220/// The Layer-3 raw-credentials extension.
221///
222/// Lives on `Extensions.raw_credentials`. Two maps:
223///
224/// - `inbound_tokens` — what the wire layer handed us, keyed by
225///   `TokenRole`. Populated by identity-resolver plugins.
226/// - `delegated_tokens` — what we minted for outbound calls, keyed
227///   by `DelegationKey`. Populated by TokenDelegate handlers and
228///   read by forwarding / proxy plugins.
229///
230/// `plugin_credentials` (spec §10.7) is intentionally absent until
231/// a plugin-credential consumer exists.
232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
233pub struct RawCredentialsExtension {
234    /// Raw inbound tokens, captured at request entry by identity
235    /// resolvers. Read with `read_inbound_credentials`; write with
236    /// `write_inbound_credentials` (resolvers only).
237    #[serde(default)]
238    pub inbound_tokens: HashMap<TokenRole, RawInboundToken>,
239
240    /// Outbound delegated tokens, minted on demand by TokenDelegate
241    /// handlers and cached for re-use. Read with
242    /// `read_delegated_tokens`; write with `write_delegated_tokens`
243    /// (TokenDelegate handlers only).
244    #[serde(default)]
245    pub delegated_tokens: HashMap<DelegationKey, RawDelegatedToken>,
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn raw_inbound_token_serializes_without_secret() {
254        let tok = RawInboundToken::new(
255            "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.sig",
256            "Authorization",
257            TokenKind::Jwt,
258        );
259        let json = serde_json::to_string(&tok).unwrap();
260        // The secret string must not appear in the serialized form —
261        // this is the load-bearing invariant of the whole extension.
262        assert!(
263            !json.contains("eyJhbGciOiJSUzI1NiJ9"),
264            "raw token leaked into serialized form: {}",
265            json
266        );
267        assert!(json.contains("Authorization"));
268        assert!(json.contains("jwt"));
269    }
270
271    #[test]
272    fn raw_inbound_token_deserializes_with_empty_token() {
273        let json = r#"{"source_header":"Authorization","kind":"jwt"}"#;
274        let tok: RawInboundToken = serde_json::from_str(json).unwrap();
275        assert_eq!(&*tok.token, "");
276        assert_eq!(tok.source_header, "Authorization");
277        assert!(matches!(tok.kind, TokenKind::Jwt));
278    }
279
280    #[test]
281    fn raw_delegated_token_serializes_without_secret() {
282        let tok = RawDelegatedToken::new(
283            "minted-secret-bytes",
284            "Authorization",
285            "https://downstream.example.com",
286            vec!["read".into()],
287            Utc::now(),
288        );
289        let json = serde_json::to_string(&tok).unwrap();
290        assert!(
291            !json.contains("minted-secret-bytes"),
292            "delegated token leaked: {}",
293            json
294        );
295        assert!(json.contains("downstream.example.com"));
296    }
297
298    #[test]
299    fn token_role_custom_is_hashmap_compatible() {
300        // Documents the lookup pattern — equal Custom values produce
301        // equal hashes so they collide in a HashMap as expected.
302        let mut map: HashMap<TokenRole, &str> = HashMap::new();
303        map.insert(TokenRole::Custom("partner".into()), "p");
304        assert_eq!(map.get(&TokenRole::Custom("partner".into())), Some(&"p"));
305        assert_eq!(map.get(&TokenRole::Custom("other".into())), None);
306    }
307
308    #[test]
309    fn delegation_key_hash_eq_consistency() {
310        let k1 = DelegationKey {
311            subject_id: "alice".into(),
312            audience: "https://api.example.com".into(),
313            scopes: vec!["read".into(), "write".into()],
314            mode: DelegationMode::OnBehalfOfUser,
315        };
316        let k2 = DelegationKey {
317            subject_id: "alice".into(),
318            audience: "https://api.example.com".into(),
319            scopes: vec!["read".into(), "write".into()],
320            mode: DelegationMode::OnBehalfOfUser,
321        };
322        assert_eq!(k1, k2);
323
324        // Scope order matters (Vec, not HashSet) — different order is
325        // intentionally a different key.
326        let k3 = DelegationKey {
327            scopes: vec!["write".into(), "read".into()],
328            ..k1.clone()
329        };
330        assert_ne!(k1, k3);
331    }
332
333    #[test]
334    fn extension_round_trip_drops_tokens() {
335        let mut ext = RawCredentialsExtension::default();
336        ext.inbound_tokens.insert(
337            TokenRole::User,
338            RawInboundToken::new("user-jwt", "X-User-Token", TokenKind::Jwt),
339        );
340
341        let json = serde_json::to_string(&ext).unwrap();
342        assert!(!json.contains("user-jwt"));
343
344        let restored: RawCredentialsExtension = serde_json::from_str(&json).unwrap();
345        // Round-trip preserves the structure but strips secret material.
346        let restored_tok = restored.inbound_tokens.get(&TokenRole::User).unwrap();
347        assert_eq!(&*restored_tok.token, "");
348        assert_eq!(restored_tok.source_header, "X-User-Token");
349    }
350}