plexus_core/plexus/credential_envelope.rs
1//! Dispatch-time credential interception (AUTHZ-CRED-CORE-2).
2//!
3//! This module contains the plexus-core half of the dispatch-time
4//! credential interception described in AUTHZ-CRED-CORE-2. The other half
5//! lives in `plexus-auth-core`:
6//!
7//! - `Credential<T>::Serialize` emits the sentinel `{"$credential": "<id>"}`
8//! inline AND captures the inner value into a thread-local sidecar **when
9//! a `DispatchCaptureGuard` is active** on the current thread.
10//! - `DispatchCaptureGuard::install` is the (currently `pub(crate)`)
11//! setter for that thread-local.
12//!
13//! plexus-core's responsibility is, at every dispatch-time serialization
14//! point that produces a wire stream item:
15//!
16//! 1. Install a fresh dispatch sidecar before serializing.
17//! 2. Serialize the body — credentials within emit sentinels inline and
18//! register their values in the sidecar.
19//! 3. Drain the sidecar and attach the captured entries to the wire
20//! envelope as a `_credentials` field.
21//! 4. Apply cookie projection: for entries whose
22//! `AttachmentSite::Cookie { name }` matches the active transport's
23//! cookie-capable surface, drop the `value` field from the sidecar
24//! entry and record a `Set-Cookie` projection hint that the transport
25//! layer reads.
26//!
27//! See `plans/AUTHZ/AUTHZ-CRED-CORE-2.md` for the full required-behavior
28//! table and `plans/AUTHZ/AUTHZ-CRED-CORE-2-RUN-NOTES.md` for the
29//! capture-side blocker.
30
31use std::collections::HashMap;
32
33use plexus_auth_core::{
34 AttachmentSite, CapturedCredential, CookieName, CredentialId, CredentialMetadata,
35};
36use serde::Serialize;
37use serde_json::{Map, Value};
38
39/// JSON-side projection of a single sidecar entry as it appears in the
40/// `_credentials` map of a wire envelope.
41///
42/// Field shape mirrors AUTHZ-CRED-S01-output §3: `{ "value": <inner>,
43/// "metadata": <CredentialMetadata JSON> }`. When cookie projection has
44/// stripped the value, the `value` field is omitted (not `null`). The
45/// metadata is always present.
46fn captured_to_wire_json(captured: &CapturedCredential, include_value: bool) -> Value {
47 let mut obj = Map::new();
48 if include_value {
49 obj.insert("value".to_string(), captured.value.clone());
50 }
51 obj.insert(
52 "metadata".to_string(),
53 serde_json::to_value(&captured.metadata).unwrap_or(Value::Null),
54 );
55 Value::Object(obj)
56}
57
58/// Build the `_credentials` envelope map from a drained sidecar and a set
59/// of cookie-capable cookie names that the transport will project.
60///
61/// Returns `(credentials_map, cookie_hints)` where:
62///
63/// * `credentials_map` is the JSON value to be written under the
64/// `_credentials` envelope key (an object mapping `CredentialId` →
65/// `{ value?, metadata }`). Returns `None` when the sidecar is empty
66/// so callers can omit the key entirely (wire-format-identical to a
67/// non-credential payload).
68/// * `cookie_hints` is the list of `(cookie_name, value, metadata)`
69/// triples the transport must turn into `Set-Cookie` headers. Empty
70/// when no entries qualified.
71///
72/// "Cookie-capable" is determined by [`CookieProjector`]; see that type
73/// for the per-transport policy.
74pub(crate) fn build_credentials_envelope(
75 captured: HashMap<CredentialId, CapturedCredential>,
76 projector: &CookieProjector,
77) -> (Option<Value>, Vec<CookieProjectionHint>) {
78 if captured.is_empty() {
79 return (None, Vec::new());
80 }
81
82 // Stable id ordering: we sort by id string so the wire output is
83 // deterministic across runs. (The ids themselves are assigned in mint
84 // order via plexus-auth-core's atomic counter, so this is also the
85 // mint order.)
86 let mut entries: Vec<(CredentialId, CapturedCredential)> = captured.into_iter().collect();
87 entries.sort_by(|(a, _), (b, _)| a.as_str().cmp(b.as_str()));
88
89 let mut wire_map = Map::new();
90 let mut hints = Vec::new();
91
92 for (id, cap) in entries {
93 let cookie_target: Option<CookieName> = match &cap.metadata.attach_as {
94 AttachmentSite::Cookie { name } => Some(name.clone()),
95 _ => None,
96 };
97
98 let should_project = cookie_target
99 .as_ref()
100 .is_some_and(|name| projector.projects(name));
101
102 // Cookie projection strips the value from the JSON sidecar entry;
103 // metadata stays. Non-cookie attachment sites or
104 // non-cookie-capable transports keep the value in the sidecar.
105 let include_value = !should_project;
106 let wire_entry = captured_to_wire_json(&cap, include_value);
107
108 if should_project {
109 // The transport reads the hint and emits the `Set-Cookie`
110 // header out-of-band; the JavaScript client sees the JSON
111 // envelope with the sentinel + metadata but no `value`.
112 hints.push(CookieProjectionHint {
113 cookie_name: cookie_target
114 .expect("cookie_target is Some on the should_project branch"),
115 cookie_value: cap.value.clone(),
116 metadata: cap.metadata.clone(),
117 });
118 }
119
120 wire_map.insert(id.as_str().to_string(), wire_entry);
121 }
122
123 (Some(Value::Object(wire_map)), hints)
124}
125
126/// Per-transport policy for which `AttachmentSite::Cookie` credentials
127/// should be projected into `Set-Cookie` headers and removed from the
128/// JSON sidecar's `value` field.
129///
130/// HTTP-bearing transports (WS-over-HTTPS upgrade response, MCP-HTTP,
131/// REST gateway) own a turn of HTTP that can carry response headers;
132/// they should project every cookie-shaped credential.
133///
134/// Non-HTTP-bearing transports (pure stdio, in-process IPC) have no
135/// header surface; they leave the value in the sidecar for the
136/// client-side storage in `AUTHZ-CRED-CLI-1` to handle.
137///
138/// Stored as a small enum (rather than a closure) so it is `Clone` and
139/// crosses `Send` boundaries cheaply.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum CookieProjector {
142 /// Project every cookie-shaped credential. Used by transports that
143 /// own an HTTP turn (WS-over-HTTPS, MCP-HTTP, REST gateway).
144 All,
145 /// Project nothing. Used by transports that have no header surface
146 /// (pure stdio, in-process IPC). The cookie value remains in the
147 /// JSON sidecar and is handled by the client-side storage layer.
148 None,
149}
150
151impl CookieProjector {
152 /// Whether a credential with `AttachmentSite::Cookie { name }` should
153 /// have its value stripped from the sidecar and projected onto a
154 /// `Set-Cookie` header.
155 pub fn projects(&self, _name: &CookieName) -> bool {
156 match self {
157 CookieProjector::All => true,
158 CookieProjector::None => false,
159 }
160 }
161}
162
163impl Default for CookieProjector {
164 /// Default to `None` — pure-stdio is the safer baseline. The
165 /// dispatch layer caller chooses `All` when it knows it has an
166 /// HTTP-bearing turn.
167 fn default() -> Self {
168 CookieProjector::None
169 }
170}
171
172/// Out-of-band projection hint emitted alongside a stream item whose
173/// payload contained a cookie-shaped credential. The transport layer
174/// reads these hints and turns each one into a `Set-Cookie:
175/// <name>=<value>; HttpOnly; Secure; SameSite=None; Path=/;
176/// Max-Age=<seconds>` header on the response.
177///
178/// `Max-Age` is derived from `metadata.expires_at` minus the current
179/// time. When `expires_at` is `None` the transport emits no `Max-Age`
180/// attribute and the cookie is session-scoped per RFC 6265.
181#[derive(Debug, Clone)]
182pub struct CookieProjectionHint {
183 /// The cookie name from `AttachmentSite::Cookie`.
184 pub cookie_name: CookieName,
185 /// The raw cookie value. Sensitive — this is the credential.
186 pub cookie_value: Value,
187 /// The full metadata, in case the transport wants to consult e.g.
188 /// `metadata.scheme` or `metadata.expires_at`.
189 pub metadata: CredentialMetadata,
190}
191
192/// Compute the `Set-Cookie` header string for a single projection hint.
193///
194/// Format: `<name>=<value>; HttpOnly; Secure; SameSite=None; Path=/[;
195/// Max-Age=<seconds>]`.
196///
197/// The cookie value is serialized as JSON string contents (without
198/// surrounding quotes) if the inner JSON is a string; otherwise the
199/// JSON-encoded form is used as a fallback (e.g. for AWS-STS-shaped
200/// composite values). RFC 6265 §4.1.1 cookie-value grammar limits the
201/// character set — values that contain RFC-forbidden characters are
202/// percent-encoded by upstream serializers and we do not encode here.
203pub fn format_set_cookie_header(hint: &CookieProjectionHint) -> String {
204 let value_str = match &hint.cookie_value {
205 Value::String(s) => s.clone(),
206 other => other.to_string(),
207 };
208
209 let mut out = format!(
210 "{}={}; HttpOnly; Secure; SameSite=None; Path=/",
211 hint.cookie_name.as_str(),
212 value_str
213 );
214
215 if let Some(expires_at) = hint.metadata.expires_at {
216 let now = chrono::Utc::now();
217 let delta = expires_at.signed_duration_since(now);
218 let max_age = delta.num_seconds().max(0);
219 out.push_str(&format!("; Max-Age={max_age}"));
220 }
221
222 out
223}
224
225/// Compose a Data stream item's content + captured credentials into the
226/// final wire envelope's JSON form.
227///
228/// This function is the single source of truth for how a stream item's
229/// payload + sidecar become a wire envelope:
230///
231/// 1. Serialize the payload (the caller does this in the credential
232/// capture scope; see `wrap_stream`).
233/// 2. Drain the captured sidecar (the caller does this).
234/// 3. Pass both into this function plus the cookie projector.
235/// 4. The result is a `Map<String, Value>` that goes verbatim into the
236/// `PlexusStreamItem::Data.content` field of the wire item.
237///
238/// Returns the assembled content map AND any cookie projection hints.
239/// The hints flow to the transport via `PlexusStreamItem::Data.cookie_hints`.
240///
241/// **Wire compatibility:** When `captured` is empty, the returned content
242/// is `serialized_payload` unchanged — same map, no `_credentials` key.
243/// This means a method that returns a payload with zero `Credential<T>`
244/// fields produces a wire-format-identical item to today (additive
245/// only).
246pub(crate) fn assemble_envelope_content(
247 serialized_payload: Value,
248 captured: HashMap<CredentialId, CapturedCredential>,
249 projector: &CookieProjector,
250) -> (Value, Vec<CookieProjectionHint>) {
251 let (credentials_map, hints) = build_credentials_envelope(captured, projector);
252
253 let Some(credentials_map) = credentials_map else {
254 return (serialized_payload, hints);
255 };
256
257 // The payload MAY be any JSON value. The `_credentials` envelope key
258 // can only be attached when the payload is an object (the spike's
259 // §3 mandates a top-level field). For non-object payloads we emit
260 // an object envelope of the form `{"value": <payload>, "_credentials":
261 // {...}}` so the wire shape remains deterministic.
262 //
263 // In practice, every credential-bearing payload IS an object
264 // (Credential<T> is a struct field), so the non-object branch is
265 // defensive.
266 match serialized_payload {
267 Value::Object(mut map) => {
268 // Per AUTHZ-CRED-CORE-2 §"Wire envelope shape": the
269 // `_credentials` key is reserved by the framework. The
270 // schema-build validator emits a warning at build time if
271 // a backend defines a top-level field named `_credentials`;
272 // here at dispatch we just shadow it (the framework's
273 // projection wins). The schema-build warning is the user-
274 // facing diagnostic.
275 map.insert("_credentials".to_string(), credentials_map);
276 (Value::Object(map), hints)
277 }
278 other => {
279 let mut wrapper = Map::new();
280 wrapper.insert("value".to_string(), other);
281 wrapper.insert("_credentials".to_string(), credentials_map);
282 (Value::Object(wrapper), hints)
283 }
284 }
285}
286
287/// Serialize `payload` and capture any credentials inside it.
288///
289/// This is the dispatch-time entry point that `wrap_stream` calls for
290/// every stream item. The function installs the
291/// `DispatchCaptureGuard` via plexus-auth-core's public scoped-callback
292/// API (`run_with_credential_capture`, landed by AUTHZ-CRED-CORE-1B), so
293/// any `Credential<T>` inside `payload` registers its inner value into
294/// the sidecar while emitting only the sentinel inline.
295///
296/// Returns `(serialized_value, captured_map)` where `serialized_value`
297/// is the JSON form of `payload` (with credential fields rendered as
298/// `{"$credential": "<id>"}` sentinels) and `captured_map` is the
299/// per-id sidecar of inner values + metadata ready for
300/// [`assemble_envelope_content`].
301pub(crate) fn serialize_with_credential_capture<T: Serialize>(
302 payload: &T,
303) -> (Value, HashMap<CredentialId, CapturedCredential>) {
304 // The scoped-callback runs the serializer inside the guard's
305 // lifetime: any nested `Credential<T>::serialize` call registers
306 // its inner value in the thread-local sidecar; the guard drops at
307 // the end of the closure invocation, so we cannot accidentally
308 // retain capture state past this call.
309 let (value, captured_vec) = plexus_auth_core::credential::run_with_credential_capture(|| {
310 serde_json::to_value(payload).unwrap_or(Value::Null)
311 });
312
313 // Rebuild the by-id map for [`assemble_envelope_content`]. Each
314 // `CapturedCredential` carries its own `id` (AUTHZ-CRED-CORE-1B),
315 // so this is a straight projection.
316 let captured: HashMap<CredentialId, CapturedCredential> = captured_vec
317 .into_iter()
318 .map(|c| (c.id.clone(), c))
319 .collect();
320
321 (value, captured)
322}
323
324/// Schema-build warning emitted when a method's return-type schema
325/// declares a top-level field named `_credentials`. The framework
326/// reserves that name for the credential sidecar (AUTHZ-CRED-CORE-2
327/// §"Wire envelope shape"). When a backend has such a field, the
328/// framework's projection shadows it.
329///
330/// Implemented as a separate function so the schema-build path can
331/// route the diagnostic through `tracing::warn!` without coupling to
332/// the schema module's structure.
333pub fn warn_on_credentials_field_collision(plugin: &str, method: &str) {
334 tracing::warn!(
335 target: "plexus_core::credentials",
336 plugin = plugin,
337 method = method,
338 "method's return-type schema declares a top-level `_credentials` \
339 field; this name is reserved by the framework's credential \
340 sidecar (AUTHZ-CRED-CORE-2) and will be shadowed at dispatch \
341 time. Rename the domain field to avoid the collision."
342 );
343}
344
345/// Inspect a serialized JSON value's top-level keys and emit a
346/// schema-build warning if any of them are `_credentials`. Used by the
347/// schema constructors (`PluginSchema::leaf`, `::hub`) to surface
348/// AUTHZ-CRED-CORE-2 acceptance criterion #8 at build time.
349///
350/// Returns `true` if a collision was detected (so callers can chain
351/// this into a counter or test assertion). The warning is emitted as a
352/// side effect either way.
353pub fn check_returns_schema_for_credentials_collision(
354 plugin: &str,
355 method: &str,
356 returns_schema: &Value,
357) -> bool {
358 // The returns schema is a JSON Schema; the "properties" key holds the
359 // top-level field map for an object schema. Anything else (a scalar
360 // type schema, a `oneOf`, a `$ref`) cannot collide with `_credentials`
361 // by construction — there is no top-level field to collide.
362 let Some(properties) = returns_schema.get("properties") else {
363 return false;
364 };
365 let Some(props_obj) = properties.as_object() else {
366 return false;
367 };
368 if props_obj.contains_key("_credentials") {
369 warn_on_credentials_field_collision(plugin, method);
370 return true;
371 }
372 false
373}
374
375// ---------------------------------------------------------------------------
376// Wire-shape unit tests.
377//
378// These tests cover the envelope-assembly half of the ticket. They use
379// directly-constructed `CapturedCredential` values (which is OK — the
380// type is `pub` and the field shape is `pub`) to exercise the wire
381// envelope path independently of whether the cross-crate capture
382// blocker is resolved.
383// ---------------------------------------------------------------------------
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use chrono::{Duration, Utc};
389 use serde_json::json;
390 use plexus_auth_core::{
391 AttachmentSite, CookieName, CredentialIssuer, CredentialKind, CredentialMetadata,
392 CredentialScheme, HeaderName, MethodPath, Origin, Scope,
393 };
394
395 fn sample_issuer() -> CredentialIssuer {
396 CredentialIssuer::new(
397 Origin::new("ws://localhost:4444"),
398 MethodPath::try_new("auth.login").unwrap(),
399 )
400 }
401
402 fn header_metadata() -> CredentialMetadata {
403 CredentialMetadata::new(
404 CredentialKind::Bearer,
405 AttachmentSite::Header {
406 name: HeaderName::try_new("authorization").unwrap(),
407 },
408 Some(CredentialScheme::new("Bearer ")),
409 vec![Scope::new("cone.send")],
410 None,
411 None,
412 None,
413 sample_issuer(),
414 )
415 }
416
417 fn cookie_metadata() -> CredentialMetadata {
418 CredentialMetadata::new(
419 CredentialKind::Cookie,
420 AttachmentSite::Cookie {
421 name: CookieName::try_new("plexus_session").unwrap(),
422 },
423 None,
424 vec![],
425 Some(Utc::now() + Duration::seconds(3600)),
426 None,
427 None,
428 sample_issuer(),
429 )
430 }
431
432 fn capture(id: &str, value: Value, metadata: CredentialMetadata) -> (CredentialId, CapturedCredential) {
433 let cred_id = CredentialId::new(id);
434 (
435 cred_id.clone(),
436 CapturedCredential {
437 id: cred_id,
438 value,
439 metadata,
440 },
441 )
442 }
443
444 #[test]
445 fn ac3_zero_credentials_produces_no_envelope_field() {
446 // Acceptance criterion 3: a payload with zero Credential<T>
447 // fields produces a wire stream item with NO `_credentials` key.
448 let payload = json!({ "user": "alice", "ok": true });
449 let (content, hints) = assemble_envelope_content(
450 payload.clone(),
451 HashMap::new(),
452 &CookieProjector::All,
453 );
454 assert_eq!(content, payload, "wire payload unchanged when no credentials");
455 assert!(hints.is_empty());
456 // And the assembled value has no _credentials key.
457 let obj = content.as_object().unwrap();
458 assert!(!obj.contains_key("_credentials"));
459 }
460
461 #[test]
462 fn ac1_single_credential_produces_envelope_with_value_and_metadata() {
463 // Acceptance criterion 1: a payload containing one Credential<T>
464 // field produces a wire item with the sentinel in the body and a
465 // top-level `_credentials` map containing metadata + value for
466 // that id.
467 let payload = json!({
468 "user_id": "alice",
469 "session": { "$credential": "cred_0" }
470 });
471 let mut captured = HashMap::new();
472 let (id, cap) = capture(
473 "cred_0",
474 Value::String("jwt-bytes".into()),
475 header_metadata(),
476 );
477 captured.insert(id, cap);
478
479 let (content, hints) =
480 assemble_envelope_content(payload, captured, &CookieProjector::All);
481 let obj = content.as_object().expect("content is object");
482
483 // Body sentinel unchanged.
484 assert_eq!(
485 obj["session"],
486 json!({ "$credential": "cred_0" })
487 );
488 // The _credentials sidecar carries the captured value + metadata.
489 let creds = obj.get("_credentials").expect("sidecar present");
490 let entry = creds.get("cred_0").expect("cred_0 entry");
491 assert_eq!(entry["value"], Value::String("jwt-bytes".into()));
492 // Metadata is present and serializable.
493 assert!(entry.get("metadata").is_some());
494 // Header-attached, not cookie — no projection hint.
495 assert!(hints.is_empty());
496 }
497
498 #[test]
499 fn ac2_multi_credential_produces_one_envelope_with_stable_keys() {
500 // Acceptance criterion 2: multiple Credential<T> fields →
501 // single sidecar with one entry per credential, identifiers
502 // assigned in stable order.
503 let payload = json!({
504 "access": { "$credential": "cred_0" },
505 "refresh": { "$credential": "cred_1" }
506 });
507 let mut captured = HashMap::new();
508 let (id0, c0) = capture(
509 "cred_0",
510 Value::String("access-jwt".into()),
511 header_metadata(),
512 );
513 let (id1, c1) = capture(
514 "cred_1",
515 Value::String("refresh-jwt".into()),
516 header_metadata(),
517 );
518 captured.insert(id0, c0);
519 captured.insert(id1, c1);
520
521 let (content, _) =
522 assemble_envelope_content(payload, captured, &CookieProjector::All);
523 let creds = content.get("_credentials").expect("sidecar");
524 assert_eq!(creds.as_object().unwrap().len(), 2);
525 // Stable iteration order: keys are sorted ascending → cred_0 < cred_1.
526 let keys: Vec<&String> = creds.as_object().unwrap().keys().collect();
527 assert_eq!(keys, vec!["cred_0", "cred_1"]);
528 // Each entry has its own value.
529 assert_eq!(creds["cred_0"]["value"], Value::String("access-jwt".into()));
530 assert_eq!(creds["cred_1"]["value"], Value::String("refresh-jwt".into()));
531 }
532
533 #[test]
534 fn ac4_cookie_credential_over_http_transport_strips_value_and_emits_hint() {
535 // Acceptance criterion 4: AttachmentSite::Cookie over an
536 // HTTP-bearing transport → sidecar has no `value`, hints carry
537 // the value + cookie name, metadata stays in the sidecar.
538 let payload = json!({
539 "user": "alice",
540 "session": { "$credential": "cred_0" }
541 });
542 let mut captured = HashMap::new();
543 let (id, cap) = capture(
544 "cred_0",
545 Value::String("opaque-session-id".into()),
546 cookie_metadata(),
547 );
548 captured.insert(id, cap);
549
550 let (content, hints) =
551 assemble_envelope_content(payload, captured, &CookieProjector::All);
552
553 // The sidecar entry has metadata but NO value.
554 let entry = content.get("_credentials").and_then(|c| c.get("cred_0")).unwrap();
555 assert!(entry.get("value").is_none(), "value must be stripped");
556 assert!(entry.get("metadata").is_some(), "metadata must remain");
557
558 // Exactly one cookie projection hint, carrying the stripped value.
559 assert_eq!(hints.len(), 1);
560 assert_eq!(hints[0].cookie_name.as_str(), "plexus_session");
561 assert_eq!(hints[0].cookie_value, Value::String("opaque-session-id".into()));
562 }
563
564 #[test]
565 fn ac5_cookie_credential_over_stdio_transport_keeps_value_no_hint() {
566 // Acceptance criterion 5: same Cookie-attach credential over a
567 // non-HTTP-bearing transport → value stays in the sidecar; no
568 // cookie hint emitted.
569 let payload = json!({
570 "user": "alice",
571 "session": { "$credential": "cred_0" }
572 });
573 let mut captured = HashMap::new();
574 let (id, cap) = capture(
575 "cred_0",
576 Value::String("opaque-session-id".into()),
577 cookie_metadata(),
578 );
579 captured.insert(id, cap);
580
581 let (content, hints) =
582 assemble_envelope_content(payload, captured, &CookieProjector::None);
583
584 let entry = content.get("_credentials").and_then(|c| c.get("cred_0")).unwrap();
585 assert_eq!(entry["value"], Value::String("opaque-session-id".into()));
586 assert!(hints.is_empty());
587 }
588
589 #[test]
590 fn header_kind_attachment_no_projection_either_way() {
591 // AttachmentSite::Header → no projection even on an HTTP-bearing
592 // transport. (The header attach is the client's job at next-call
593 // time, not the server's response-side projection.)
594 let payload = json!({
595 "user": "alice",
596 "auth": { "$credential": "cred_0" }
597 });
598 let mut captured = HashMap::new();
599 let (id, cap) = capture(
600 "cred_0",
601 Value::String("jwt".into()),
602 header_metadata(),
603 );
604 captured.insert(id, cap);
605
606 let (content, hints) =
607 assemble_envelope_content(payload, captured, &CookieProjector::All);
608
609 let entry = content.get("_credentials").and_then(|c| c.get("cred_0")).unwrap();
610 assert_eq!(entry["value"], Value::String("jwt".into()));
611 assert!(hints.is_empty());
612 }
613
614 #[test]
615 fn set_cookie_header_format_has_required_attributes() {
616 // The format string matches the ticket's exact spec:
617 // Set-Cookie: <name>=<value>; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=<seconds>
618 let hint = CookieProjectionHint {
619 cookie_name: CookieName::try_new("plexus_session").unwrap(),
620 cookie_value: Value::String("abc123".into()),
621 metadata: cookie_metadata(),
622 };
623 let out = format_set_cookie_header(&hint);
624 assert!(out.starts_with("plexus_session=abc123"));
625 assert!(out.contains("; HttpOnly"));
626 assert!(out.contains("; Secure"));
627 assert!(out.contains("; SameSite=None"));
628 assert!(out.contains("; Path=/"));
629 assert!(out.contains("; Max-Age="));
630 }
631
632 #[test]
633 fn set_cookie_header_omits_max_age_when_no_expiry() {
634 let mut meta = cookie_metadata();
635 meta.expires_at = None;
636 let hint = CookieProjectionHint {
637 cookie_name: CookieName::try_new("plexus_session").unwrap(),
638 cookie_value: Value::String("abc".into()),
639 metadata: meta,
640 };
641 let out = format_set_cookie_header(&hint);
642 assert!(!out.contains("Max-Age"));
643 }
644
645 #[test]
646 fn ac8_schema_build_warning_fires_on_credentials_field_collision() {
647 // Acceptance criterion 8: a return-type schema that declares a
648 // top-level field named `_credentials` triggers a schema-build
649 // warning. We assert the predicate that the schema constructors
650 // call; the tracing-side emission is observable via tracing
651 // subscribers in higher-level tests.
652 let returns_with_collision = json!({
653 "type": "object",
654 "properties": {
655 "user_id": { "type": "string" },
656 "_credentials": { "type": "object" }
657 }
658 });
659 let collided = check_returns_schema_for_credentials_collision(
660 "auth",
661 "login",
662 &returns_with_collision,
663 );
664 assert!(collided, "collision must be detected");
665
666 let returns_without = json!({
667 "type": "object",
668 "properties": {
669 "user_id": { "type": "string" },
670 "session": { "$ref": "#/$defs/Credential" }
671 }
672 });
673 let not_collided = check_returns_schema_for_credentials_collision(
674 "auth",
675 "login",
676 &returns_without,
677 );
678 assert!(!not_collided, "non-collision must not be detected");
679 }
680
681 #[test]
682 fn cookie_projector_default_is_safe_none() {
683 // Documenting the default: pure-stdio is the safer baseline; the
684 // dispatch caller must explicitly opt in to All when it has an
685 // HTTP-bearing turn.
686 assert_eq!(CookieProjector::default(), CookieProjector::None);
687 let name = CookieName::try_new("plexus_session").unwrap();
688 assert!(!CookieProjector::None.projects(&name));
689 assert!(CookieProjector::All.projects(&name));
690 }
691
692 #[test]
693 fn non_object_payload_gets_wrapped_when_credentials_present() {
694 // Defensive: a payload that's a scalar (rare but legal) — when
695 // there are credentials, the envelope wraps it in an object so
696 // the `_credentials` field has a place to live.
697 let payload = Value::String("scalar-payload".into());
698 let mut captured = HashMap::new();
699 let (id, cap) = capture("cred_0", Value::String("v".into()), header_metadata());
700 captured.insert(id, cap);
701
702 let (content, _) =
703 assemble_envelope_content(payload, captured, &CookieProjector::All);
704 let obj = content.as_object().expect("wrapped into object");
705 assert_eq!(obj["value"], Value::String("scalar-payload".into()));
706 assert!(obj.contains_key("_credentials"));
707 }
708
709 #[test]
710 fn non_object_payload_unchanged_when_no_credentials() {
711 // Scalar payload + empty sidecar → unchanged, no wrapper.
712 let payload = Value::String("scalar-payload".into());
713 let (content, hints) = assemble_envelope_content(
714 payload.clone(),
715 HashMap::new(),
716 &CookieProjector::All,
717 );
718 assert_eq!(content, payload);
719 assert!(hints.is_empty());
720 }
721
722 #[test]
723 fn serialize_with_credential_capture_returns_empty_for_plain_payload() {
724 // A payload with zero `Credential<T>` fields produces a serialized
725 // body identical to today (no `_credentials` field) and an empty
726 // captured map. Wire-format-identical to pre-CRED-CORE-2 behavior
727 // (additive only).
728 #[derive(Serialize)]
729 struct Simple {
730 x: u32,
731 }
732 let s = Simple { x: 42 };
733 let (value, captured) = serialize_with_credential_capture(&s);
734 assert_eq!(value, json!({ "x": 42 }));
735 assert!(captured.is_empty(), "no credentials -> empty map");
736 }
737
738 // -----------------------------------------------------------------
739 // End-to-end tests with REAL `Credential<T>` values. CRED-CORE-2's
740 // deferred ACs (#1 end-to-end, #2 end-to-end, #4 end-to-end, #5
741 // end-to-end) now pass because plexus-auth-core's
742 // `run_with_credential_capture` (AUTHZ-CRED-CORE-1B) is reachable.
743 // -----------------------------------------------------------------
744
745 use plexus_auth_core::credential::{Credential, CredentialMinter};
746
747 /// Mint a fresh Bearer credential with the test issuer.
748 fn mint_bearer(minter: &CredentialMinter, value: &str) -> Credential<String> {
749 minter.mint(value.to_string(), header_metadata())
750 }
751
752 /// Mint a fresh Cookie credential with the test issuer.
753 fn mint_cookie(minter: &CredentialMinter, value: &str) -> Credential<String> {
754 minter.mint(value.to_string(), cookie_metadata())
755 }
756
757 #[test]
758 fn ac1_end_to_end_real_credential_emits_wire_envelope() {
759 // CRED-CORE-2 acceptance criterion 1, end-to-end: a payload
760 // containing one `Credential<T>` field, serialized via the real
761 // dispatch entry point (`serialize_with_credential_capture` →
762 // `assemble_envelope_content`), produces a wire item with the
763 // sentinel in the body AND a `_credentials` sidecar containing
764 // value+metadata for that credential's id.
765 #[derive(Serialize)]
766 struct LoginResponse {
767 user_id: String,
768 session: Credential<String>,
769 }
770 let minter = CredentialMinter::new_for_test(sample_issuer());
771 let session = mint_bearer(&minter, "jwt-token-bytes");
772 let session_id = session.id().clone();
773 let payload = LoginResponse {
774 user_id: "alice".into(),
775 session,
776 };
777
778 let (value, captured) = serialize_with_credential_capture(&payload);
779
780 // Body: sentinel inline.
781 let session_field = value.get("session").expect("session field");
782 assert_eq!(
783 session_field.get("$credential").and_then(|v| v.as_str()),
784 Some(session_id.as_str())
785 );
786 // Inner value never appears in the body.
787 let body_str = serde_json::to_string(&value).unwrap();
788 assert!(
789 !body_str.contains("jwt-token-bytes"),
790 "inner JWT must not appear in serialized body: {body_str}"
791 );
792
793 // Captured map: one entry keyed by id.
794 assert_eq!(captured.len(), 1);
795 let entry = captured.get(&session_id).expect("captured by id");
796 assert_eq!(entry.value, Value::String("jwt-token-bytes".into()));
797
798 // Envelope assembly: produces the `_credentials` sidecar.
799 let (content, hints) =
800 assemble_envelope_content(value, captured, &CookieProjector::All);
801 let creds = content.get("_credentials").expect("sidecar present");
802 let entry = creds
803 .get(session_id.as_str())
804 .expect("sidecar contains id");
805 assert_eq!(entry["value"], Value::String("jwt-token-bytes".into()));
806 assert!(entry.get("metadata").is_some());
807 // Header attach → no cookie projection hint.
808 assert!(hints.is_empty());
809 }
810
811 #[test]
812 fn ac2_end_to_end_real_multi_credential_payload() {
813 // CRED-CORE-2 acceptance criterion 2, end-to-end: multiple
814 // `Credential<T>` fields produce one sidecar with one entry per
815 // credential, identifiers assigned in stable mint order.
816 #[derive(Serialize)]
817 struct TokenSet {
818 access: Credential<String>,
819 refresh: Credential<String>,
820 }
821 let minter = CredentialMinter::new_for_test(sample_issuer());
822 let access = mint_bearer(&minter, "access-bytes");
823 let refresh = mint_bearer(&minter, "refresh-bytes");
824 let access_id = access.id().clone();
825 let refresh_id = refresh.id().clone();
826 let payload = TokenSet { access, refresh };
827
828 let (value, captured) = serialize_with_credential_capture(&payload);
829
830 // Body: each credential field becomes a sentinel.
831 assert_eq!(
832 value
833 .get("access")
834 .and_then(|v| v.get("$credential"))
835 .and_then(|v| v.as_str()),
836 Some(access_id.as_str())
837 );
838 assert_eq!(
839 value
840 .get("refresh")
841 .and_then(|v| v.get("$credential"))
842 .and_then(|v| v.as_str()),
843 Some(refresh_id.as_str())
844 );
845
846 // Captured map has both, distinct ids.
847 assert_eq!(captured.len(), 2);
848 assert!(captured.contains_key(&access_id));
849 assert!(captured.contains_key(&refresh_id));
850 assert_ne!(access_id, refresh_id);
851 }
852
853 #[test]
854 fn ac4_end_to_end_cookie_credential_over_http_strips_value() {
855 // CRED-CORE-2 acceptance criterion 4, end-to-end: a real
856 // `Credential<T>` with `AttachmentSite::Cookie` over a
857 // cookie-projecting transport (`CookieProjector::All`) → the
858 // sidecar's `value` is stripped and the projection hint carries
859 // the cookie name + value.
860 #[derive(Serialize)]
861 struct LoginResponse {
862 user: String,
863 session: Credential<String>,
864 }
865 let minter = CredentialMinter::new_for_test(sample_issuer());
866 let session = mint_cookie(&minter, "opaque-cookie-value");
867 let session_id = session.id().clone();
868 let payload = LoginResponse {
869 user: "alice".into(),
870 session,
871 };
872
873 let (value, captured) = serialize_with_credential_capture(&payload);
874 let (content, hints) =
875 assemble_envelope_content(value, captured, &CookieProjector::All);
876
877 // Sidecar entry: value stripped, metadata stays.
878 let entry = content
879 .get("_credentials")
880 .and_then(|c| c.get(session_id.as_str()))
881 .expect("sidecar entry");
882 assert!(
883 entry.get("value").is_none(),
884 "cookie projection must strip value from sidecar"
885 );
886 assert!(entry.get("metadata").is_some(), "metadata must remain");
887
888 // One cookie projection hint.
889 assert_eq!(hints.len(), 1);
890 assert_eq!(hints[0].cookie_name.as_str(), "plexus_session");
891 assert_eq!(
892 hints[0].cookie_value,
893 Value::String("opaque-cookie-value".into())
894 );
895 }
896
897 #[test]
898 fn ac5_end_to_end_cookie_credential_over_stdio_keeps_value() {
899 // CRED-CORE-2 acceptance criterion 5, end-to-end: same shape,
900 // but over a non-cookie-projecting transport (`CookieProjector::None`)
901 // → the cookie value stays in the sidecar, no projection hint.
902 #[derive(Serialize)]
903 struct LoginResponse {
904 user: String,
905 session: Credential<String>,
906 }
907 let minter = CredentialMinter::new_for_test(sample_issuer());
908 let session = mint_cookie(&minter, "opaque-cookie-value");
909 let session_id = session.id().clone();
910 let payload = LoginResponse {
911 user: "alice".into(),
912 session,
913 };
914
915 let (value, captured) = serialize_with_credential_capture(&payload);
916 let (content, hints) =
917 assemble_envelope_content(value, captured, &CookieProjector::None);
918
919 let entry = content
920 .get("_credentials")
921 .and_then(|c| c.get(session_id.as_str()))
922 .expect("sidecar entry");
923 assert_eq!(
924 entry["value"],
925 Value::String("opaque-cookie-value".into())
926 );
927 assert!(hints.is_empty());
928 }
929}