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}