use std::collections::HashMap;
use plexus_auth_core::{
AttachmentSite, CapturedCredential, CookieName, CredentialId, CredentialMetadata,
};
use serde::Serialize;
use serde_json::{Map, Value};
fn captured_to_wire_json(captured: &CapturedCredential, include_value: bool) -> Value {
let mut obj = Map::new();
if include_value {
obj.insert("value".to_string(), captured.value.clone());
}
obj.insert(
"metadata".to_string(),
serde_json::to_value(&captured.metadata).unwrap_or(Value::Null),
);
Value::Object(obj)
}
pub(crate) fn build_credentials_envelope(
captured: HashMap<CredentialId, CapturedCredential>,
projector: &CookieProjector,
) -> (Option<Value>, Vec<CookieProjectionHint>) {
if captured.is_empty() {
return (None, Vec::new());
}
let mut entries: Vec<(CredentialId, CapturedCredential)> = captured.into_iter().collect();
entries.sort_by(|(a, _), (b, _)| a.as_str().cmp(b.as_str()));
let mut wire_map = Map::new();
let mut hints = Vec::new();
for (id, cap) in entries {
let cookie_target: Option<CookieName> = match &cap.metadata.attach_as {
AttachmentSite::Cookie { name } => Some(name.clone()),
_ => None,
};
let should_project = cookie_target
.as_ref()
.is_some_and(|name| projector.projects(name));
let include_value = !should_project;
let wire_entry = captured_to_wire_json(&cap, include_value);
if should_project {
hints.push(CookieProjectionHint {
cookie_name: cookie_target
.expect("cookie_target is Some on the should_project branch"),
cookie_value: cap.value.clone(),
metadata: cap.metadata.clone(),
});
}
wire_map.insert(id.as_str().to_string(), wire_entry);
}
(Some(Value::Object(wire_map)), hints)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CookieProjector {
All,
None,
}
impl CookieProjector {
pub fn projects(&self, _name: &CookieName) -> bool {
match self {
CookieProjector::All => true,
CookieProjector::None => false,
}
}
}
impl Default for CookieProjector {
fn default() -> Self {
CookieProjector::None
}
}
#[derive(Debug, Clone)]
pub struct CookieProjectionHint {
pub cookie_name: CookieName,
pub cookie_value: Value,
pub metadata: CredentialMetadata,
}
pub fn format_set_cookie_header(hint: &CookieProjectionHint) -> String {
let value_str = match &hint.cookie_value {
Value::String(s) => s.clone(),
other => other.to_string(),
};
let mut out = format!(
"{}={}; HttpOnly; Secure; SameSite=None; Path=/",
hint.cookie_name.as_str(),
value_str
);
if let Some(expires_at) = hint.metadata.expires_at {
let now = chrono::Utc::now();
let delta = expires_at.signed_duration_since(now);
let max_age = delta.num_seconds().max(0);
out.push_str(&format!("; Max-Age={max_age}"));
}
out
}
pub(crate) fn assemble_envelope_content(
serialized_payload: Value,
captured: HashMap<CredentialId, CapturedCredential>,
projector: &CookieProjector,
) -> (Value, Vec<CookieProjectionHint>) {
let (credentials_map, hints) = build_credentials_envelope(captured, projector);
let Some(credentials_map) = credentials_map else {
return (serialized_payload, hints);
};
match serialized_payload {
Value::Object(mut map) => {
map.insert("_credentials".to_string(), credentials_map);
(Value::Object(map), hints)
}
other => {
let mut wrapper = Map::new();
wrapper.insert("value".to_string(), other);
wrapper.insert("_credentials".to_string(), credentials_map);
(Value::Object(wrapper), hints)
}
}
}
pub(crate) fn serialize_with_credential_capture<T: Serialize>(
payload: &T,
) -> (Value, HashMap<CredentialId, CapturedCredential>) {
let (value, captured_vec) = plexus_auth_core::credential::run_with_credential_capture(|| {
serde_json::to_value(payload).unwrap_or(Value::Null)
});
let captured: HashMap<CredentialId, CapturedCredential> = captured_vec
.into_iter()
.map(|c| (c.id.clone(), c))
.collect();
(value, captured)
}
pub fn warn_on_credentials_field_collision(plugin: &str, method: &str) {
tracing::warn!(
target: "plexus_core::credentials",
plugin = plugin,
method = method,
"method's return-type schema declares a top-level `_credentials` \
field; this name is reserved by the framework's credential \
sidecar (AUTHZ-CRED-CORE-2) and will be shadowed at dispatch \
time. Rename the domain field to avoid the collision."
);
}
pub fn check_returns_schema_for_credentials_collision(
plugin: &str,
method: &str,
returns_schema: &Value,
) -> bool {
let Some(properties) = returns_schema.get("properties") else {
return false;
};
let Some(props_obj) = properties.as_object() else {
return false;
};
if props_obj.contains_key("_credentials") {
warn_on_credentials_field_collision(plugin, method);
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
use serde_json::json;
use plexus_auth_core::{
AttachmentSite, CookieName, CredentialIssuer, CredentialKind, CredentialMetadata,
CredentialScheme, HeaderName, MethodPath, Origin, Scope,
};
fn sample_issuer() -> CredentialIssuer {
CredentialIssuer::new(
Origin::new("ws://localhost:4444"),
MethodPath::try_new("auth.login").unwrap(),
)
}
fn header_metadata() -> CredentialMetadata {
CredentialMetadata::new(
CredentialKind::Bearer,
AttachmentSite::Header {
name: HeaderName::try_new("authorization").unwrap(),
},
Some(CredentialScheme::new("Bearer ")),
vec![Scope::new("cone.send")],
None,
None,
None,
sample_issuer(),
)
}
fn cookie_metadata() -> CredentialMetadata {
CredentialMetadata::new(
CredentialKind::Cookie,
AttachmentSite::Cookie {
name: CookieName::try_new("plexus_session").unwrap(),
},
None,
vec![],
Some(Utc::now() + Duration::seconds(3600)),
None,
None,
sample_issuer(),
)
}
fn capture(id: &str, value: Value, metadata: CredentialMetadata) -> (CredentialId, CapturedCredential) {
let cred_id = CredentialId::new(id);
(
cred_id.clone(),
CapturedCredential {
id: cred_id,
value,
metadata,
},
)
}
#[test]
fn ac3_zero_credentials_produces_no_envelope_field() {
let payload = json!({ "user": "alice", "ok": true });
let (content, hints) = assemble_envelope_content(
payload.clone(),
HashMap::new(),
&CookieProjector::All,
);
assert_eq!(content, payload, "wire payload unchanged when no credentials");
assert!(hints.is_empty());
let obj = content.as_object().unwrap();
assert!(!obj.contains_key("_credentials"));
}
#[test]
fn ac1_single_credential_produces_envelope_with_value_and_metadata() {
let payload = json!({
"user_id": "alice",
"session": { "$credential": "cred_0" }
});
let mut captured = HashMap::new();
let (id, cap) = capture(
"cred_0",
Value::String("jwt-bytes".into()),
header_metadata(),
);
captured.insert(id, cap);
let (content, hints) =
assemble_envelope_content(payload, captured, &CookieProjector::All);
let obj = content.as_object().expect("content is object");
assert_eq!(
obj["session"],
json!({ "$credential": "cred_0" })
);
let creds = obj.get("_credentials").expect("sidecar present");
let entry = creds.get("cred_0").expect("cred_0 entry");
assert_eq!(entry["value"], Value::String("jwt-bytes".into()));
assert!(entry.get("metadata").is_some());
assert!(hints.is_empty());
}
#[test]
fn ac2_multi_credential_produces_one_envelope_with_stable_keys() {
let payload = json!({
"access": { "$credential": "cred_0" },
"refresh": { "$credential": "cred_1" }
});
let mut captured = HashMap::new();
let (id0, c0) = capture(
"cred_0",
Value::String("access-jwt".into()),
header_metadata(),
);
let (id1, c1) = capture(
"cred_1",
Value::String("refresh-jwt".into()),
header_metadata(),
);
captured.insert(id0, c0);
captured.insert(id1, c1);
let (content, _) =
assemble_envelope_content(payload, captured, &CookieProjector::All);
let creds = content.get("_credentials").expect("sidecar");
assert_eq!(creds.as_object().unwrap().len(), 2);
let keys: Vec<&String> = creds.as_object().unwrap().keys().collect();
assert_eq!(keys, vec!["cred_0", "cred_1"]);
assert_eq!(creds["cred_0"]["value"], Value::String("access-jwt".into()));
assert_eq!(creds["cred_1"]["value"], Value::String("refresh-jwt".into()));
}
#[test]
fn ac4_cookie_credential_over_http_transport_strips_value_and_emits_hint() {
let payload = json!({
"user": "alice",
"session": { "$credential": "cred_0" }
});
let mut captured = HashMap::new();
let (id, cap) = capture(
"cred_0",
Value::String("opaque-session-id".into()),
cookie_metadata(),
);
captured.insert(id, cap);
let (content, hints) =
assemble_envelope_content(payload, captured, &CookieProjector::All);
let entry = content.get("_credentials").and_then(|c| c.get("cred_0")).unwrap();
assert!(entry.get("value").is_none(), "value must be stripped");
assert!(entry.get("metadata").is_some(), "metadata must remain");
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].cookie_name.as_str(), "plexus_session");
assert_eq!(hints[0].cookie_value, Value::String("opaque-session-id".into()));
}
#[test]
fn ac5_cookie_credential_over_stdio_transport_keeps_value_no_hint() {
let payload = json!({
"user": "alice",
"session": { "$credential": "cred_0" }
});
let mut captured = HashMap::new();
let (id, cap) = capture(
"cred_0",
Value::String("opaque-session-id".into()),
cookie_metadata(),
);
captured.insert(id, cap);
let (content, hints) =
assemble_envelope_content(payload, captured, &CookieProjector::None);
let entry = content.get("_credentials").and_then(|c| c.get("cred_0")).unwrap();
assert_eq!(entry["value"], Value::String("opaque-session-id".into()));
assert!(hints.is_empty());
}
#[test]
fn header_kind_attachment_no_projection_either_way() {
let payload = json!({
"user": "alice",
"auth": { "$credential": "cred_0" }
});
let mut captured = HashMap::new();
let (id, cap) = capture(
"cred_0",
Value::String("jwt".into()),
header_metadata(),
);
captured.insert(id, cap);
let (content, hints) =
assemble_envelope_content(payload, captured, &CookieProjector::All);
let entry = content.get("_credentials").and_then(|c| c.get("cred_0")).unwrap();
assert_eq!(entry["value"], Value::String("jwt".into()));
assert!(hints.is_empty());
}
#[test]
fn set_cookie_header_format_has_required_attributes() {
let hint = CookieProjectionHint {
cookie_name: CookieName::try_new("plexus_session").unwrap(),
cookie_value: Value::String("abc123".into()),
metadata: cookie_metadata(),
};
let out = format_set_cookie_header(&hint);
assert!(out.starts_with("plexus_session=abc123"));
assert!(out.contains("; HttpOnly"));
assert!(out.contains("; Secure"));
assert!(out.contains("; SameSite=None"));
assert!(out.contains("; Path=/"));
assert!(out.contains("; Max-Age="));
}
#[test]
fn set_cookie_header_omits_max_age_when_no_expiry() {
let mut meta = cookie_metadata();
meta.expires_at = None;
let hint = CookieProjectionHint {
cookie_name: CookieName::try_new("plexus_session").unwrap(),
cookie_value: Value::String("abc".into()),
metadata: meta,
};
let out = format_set_cookie_header(&hint);
assert!(!out.contains("Max-Age"));
}
#[test]
fn ac8_schema_build_warning_fires_on_credentials_field_collision() {
let returns_with_collision = json!({
"type": "object",
"properties": {
"user_id": { "type": "string" },
"_credentials": { "type": "object" }
}
});
let collided = check_returns_schema_for_credentials_collision(
"auth",
"login",
&returns_with_collision,
);
assert!(collided, "collision must be detected");
let returns_without = json!({
"type": "object",
"properties": {
"user_id": { "type": "string" },
"session": { "$ref": "#/$defs/Credential" }
}
});
let not_collided = check_returns_schema_for_credentials_collision(
"auth",
"login",
&returns_without,
);
assert!(!not_collided, "non-collision must not be detected");
}
#[test]
fn cookie_projector_default_is_safe_none() {
assert_eq!(CookieProjector::default(), CookieProjector::None);
let name = CookieName::try_new("plexus_session").unwrap();
assert!(!CookieProjector::None.projects(&name));
assert!(CookieProjector::All.projects(&name));
}
#[test]
fn non_object_payload_gets_wrapped_when_credentials_present() {
let payload = Value::String("scalar-payload".into());
let mut captured = HashMap::new();
let (id, cap) = capture("cred_0", Value::String("v".into()), header_metadata());
captured.insert(id, cap);
let (content, _) =
assemble_envelope_content(payload, captured, &CookieProjector::All);
let obj = content.as_object().expect("wrapped into object");
assert_eq!(obj["value"], Value::String("scalar-payload".into()));
assert!(obj.contains_key("_credentials"));
}
#[test]
fn non_object_payload_unchanged_when_no_credentials() {
let payload = Value::String("scalar-payload".into());
let (content, hints) = assemble_envelope_content(
payload.clone(),
HashMap::new(),
&CookieProjector::All,
);
assert_eq!(content, payload);
assert!(hints.is_empty());
}
#[test]
fn serialize_with_credential_capture_returns_empty_for_plain_payload() {
#[derive(Serialize)]
struct Simple {
x: u32,
}
let s = Simple { x: 42 };
let (value, captured) = serialize_with_credential_capture(&s);
assert_eq!(value, json!({ "x": 42 }));
assert!(captured.is_empty(), "no credentials -> empty map");
}
use plexus_auth_core::credential::{Credential, CredentialMinter};
fn mint_bearer(minter: &CredentialMinter, value: &str) -> Credential<String> {
minter.mint(value.to_string(), header_metadata())
}
fn mint_cookie(minter: &CredentialMinter, value: &str) -> Credential<String> {
minter.mint(value.to_string(), cookie_metadata())
}
#[test]
fn ac1_end_to_end_real_credential_emits_wire_envelope() {
#[derive(Serialize)]
struct LoginResponse {
user_id: String,
session: Credential<String>,
}
let minter = CredentialMinter::new_for_test(sample_issuer());
let session = mint_bearer(&minter, "jwt-token-bytes");
let session_id = session.id().clone();
let payload = LoginResponse {
user_id: "alice".into(),
session,
};
let (value, captured) = serialize_with_credential_capture(&payload);
let session_field = value.get("session").expect("session field");
assert_eq!(
session_field.get("$credential").and_then(|v| v.as_str()),
Some(session_id.as_str())
);
let body_str = serde_json::to_string(&value).unwrap();
assert!(
!body_str.contains("jwt-token-bytes"),
"inner JWT must not appear in serialized body: {body_str}"
);
assert_eq!(captured.len(), 1);
let entry = captured.get(&session_id).expect("captured by id");
assert_eq!(entry.value, Value::String("jwt-token-bytes".into()));
let (content, hints) =
assemble_envelope_content(value, captured, &CookieProjector::All);
let creds = content.get("_credentials").expect("sidecar present");
let entry = creds
.get(session_id.as_str())
.expect("sidecar contains id");
assert_eq!(entry["value"], Value::String("jwt-token-bytes".into()));
assert!(entry.get("metadata").is_some());
assert!(hints.is_empty());
}
#[test]
fn ac2_end_to_end_real_multi_credential_payload() {
#[derive(Serialize)]
struct TokenSet {
access: Credential<String>,
refresh: Credential<String>,
}
let minter = CredentialMinter::new_for_test(sample_issuer());
let access = mint_bearer(&minter, "access-bytes");
let refresh = mint_bearer(&minter, "refresh-bytes");
let access_id = access.id().clone();
let refresh_id = refresh.id().clone();
let payload = TokenSet { access, refresh };
let (value, captured) = serialize_with_credential_capture(&payload);
assert_eq!(
value
.get("access")
.and_then(|v| v.get("$credential"))
.and_then(|v| v.as_str()),
Some(access_id.as_str())
);
assert_eq!(
value
.get("refresh")
.and_then(|v| v.get("$credential"))
.and_then(|v| v.as_str()),
Some(refresh_id.as_str())
);
assert_eq!(captured.len(), 2);
assert!(captured.contains_key(&access_id));
assert!(captured.contains_key(&refresh_id));
assert_ne!(access_id, refresh_id);
}
#[test]
fn ac4_end_to_end_cookie_credential_over_http_strips_value() {
#[derive(Serialize)]
struct LoginResponse {
user: String,
session: Credential<String>,
}
let minter = CredentialMinter::new_for_test(sample_issuer());
let session = mint_cookie(&minter, "opaque-cookie-value");
let session_id = session.id().clone();
let payload = LoginResponse {
user: "alice".into(),
session,
};
let (value, captured) = serialize_with_credential_capture(&payload);
let (content, hints) =
assemble_envelope_content(value, captured, &CookieProjector::All);
let entry = content
.get("_credentials")
.and_then(|c| c.get(session_id.as_str()))
.expect("sidecar entry");
assert!(
entry.get("value").is_none(),
"cookie projection must strip value from sidecar"
);
assert!(entry.get("metadata").is_some(), "metadata must remain");
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].cookie_name.as_str(), "plexus_session");
assert_eq!(
hints[0].cookie_value,
Value::String("opaque-cookie-value".into())
);
}
#[test]
fn ac5_end_to_end_cookie_credential_over_stdio_keeps_value() {
#[derive(Serialize)]
struct LoginResponse {
user: String,
session: Credential<String>,
}
let minter = CredentialMinter::new_for_test(sample_issuer());
let session = mint_cookie(&minter, "opaque-cookie-value");
let session_id = session.id().clone();
let payload = LoginResponse {
user: "alice".into(),
session,
};
let (value, captured) = serialize_with_credential_capture(&payload);
let (content, hints) =
assemble_envelope_content(value, captured, &CookieProjector::None);
let entry = content
.get("_credentials")
.and_then(|c| c.get(session_id.as_str()))
.expect("sidecar entry");
assert_eq!(
entry["value"],
Value::String("opaque-cookie-value".into())
);
assert!(hints.is_empty());
}
}