Skip to main content

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}