use cpex_core::extensions::Extensions;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionSource {
Agent,
TokenClaim,
Identity,
}
impl SessionSource {
pub fn as_str(self) -> &'static str {
match self {
SessionSource::Agent => "agent",
SessionSource::TokenClaim => "token_claim",
SessionSource::Identity => "identity",
}
}
}
fn short_hash(raw: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(raw.as_bytes());
let digest = hasher.finalize();
digest
.iter()
.take(8)
.map(|b| format!("{:02x}", b))
.collect()
}
fn subject_scoped(subject_id: Option<&str>, raw: &str) -> Option<String> {
let sub = subject_id?;
Some(short_hash(&format!("{}:{}", sub, raw)))
}
pub fn resolve_session(ext: &Extensions) -> Option<(String, SessionSource)> {
let subject_id = ext
.security
.as_deref()
.and_then(|s| s.subject.as_ref())
.and_then(|s| s.id.as_deref());
if let Some(agent) = ext.agent.as_deref() {
if let Some(sid) = agent.session_id.as_deref() {
if !sid.is_empty() {
if let Some(bound) = subject_scoped(subject_id, sid) {
return Some((bound, SessionSource::Agent));
}
}
}
}
if let Some(sec) = ext.security.as_deref() {
if let Some(subj) = sec.subject.as_ref() {
if let Some(sid) = subj.claims.get("session_id") {
if !sid.is_empty() {
if let Some(bound) = subject_scoped(subject_id, sid) {
return Some((bound, SessionSource::TokenClaim));
}
}
}
}
}
if let Some(sec) = ext.security.as_deref() {
let sub = sec.subject.as_ref().and_then(|s| s.id.as_deref());
if let Some(sub) = sub {
let actor = sec
.caller_workload
.as_ref()
.and_then(|w| w.client_id.as_deref())
.unwrap_or("-");
let aud = sec
.this_workload
.as_ref()
.and_then(|w| w.client_id.as_deref())
.unwrap_or("-");
let raw = format!("{}:{}:{}", sub, actor, aud);
return Some((short_hash(&raw), SessionSource::Identity));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use cpex_core::extensions::{
AgentExtension, Extensions, HttpExtension, SecurityExtension, SubjectExtension,
WorkloadIdentity,
};
use std::sync::Arc;
fn extensions_with_security(sec: SecurityExtension) -> Extensions {
Extensions {
security: Some(Arc::new(sec)),
..Default::default()
}
}
fn subject_with_claims(id: Option<&str>, claims: &[(&str, &str)]) -> SubjectExtension {
SubjectExtension {
id: id.map(String::from),
claims: claims
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
..Default::default()
}
}
fn extensions_with_agent_and_subject(session_id: &str, subject_id: &str) -> Extensions {
let mut agent = AgentExtension::default();
agent.session_id = Some(session_id.into());
Extensions {
agent: Some(Arc::new(agent)),
security: Some(Arc::new(SecurityExtension {
subject: Some(subject_with_claims(Some(subject_id), &[])),
..Default::default()
})),
..Default::default()
}
}
#[test]
fn tier0_agent_session_id_is_subject_bound() {
let ext = extensions_with_agent_and_subject("sess-upstream", "alice");
let (sid, src) = resolve_session(&ext).expect("should resolve");
assert_eq!(src, SessionSource::Agent);
assert_eq!(sid, subject_scoped(Some("alice"), "sess-upstream").unwrap());
assert_ne!(sid, "sess-upstream", "raw client value must not be the key");
}
#[test]
fn tier0_same_session_id_different_subjects_are_distinct() {
let alice = extensions_with_agent_and_subject("shared-sid", "alice");
let bob = extensions_with_agent_and_subject("shared-sid", "bob");
let (sid_a, _) = resolve_session(&alice).unwrap();
let (sid_b, _) = resolve_session(&bob).unwrap();
assert_ne!(
sid_a, sid_b,
"same client session id under different subjects must not collide",
);
}
#[test]
fn tier0_stable_for_same_subject_and_session_id() {
let (sid1, _) = resolve_session(&extensions_with_agent_and_subject("s1", "bob")).unwrap();
let (sid2, _) = resolve_session(&extensions_with_agent_and_subject("s1", "bob")).unwrap();
assert_eq!(sid1, sid2);
}
#[test]
fn tier0_no_subject_falls_through() {
let mut agent = AgentExtension::default();
agent.session_id = Some("sess-upstream".into());
let ext = Extensions {
agent: Some(Arc::new(agent)),
..Default::default()
};
assert!(resolve_session(&ext).is_none());
}
#[test]
fn tier0_skips_empty_agent_session_id() {
let mut agent = AgentExtension::default();
agent.session_id = Some("".into());
let ext = Extensions {
agent: Some(Arc::new(agent)),
security: Some(Arc::new(SecurityExtension {
subject: Some(subject_with_claims(Some("alice"), &[])),
..Default::default()
})),
..Default::default()
};
let (_, src) = resolve_session(&ext).expect("should fall through to identity");
assert_eq!(src, SessionSource::Identity);
}
#[test]
fn tier0_wins_over_token_claim() {
let mut agent = AgentExtension::default();
agent.session_id = Some("from-agent".into());
let sec = SecurityExtension {
subject: Some(subject_with_claims(
Some("alice"),
&[("session_id", "from-token")],
)),
..Default::default()
};
let ext = Extensions {
agent: Some(Arc::new(agent)),
security: Some(Arc::new(sec)),
..Default::default()
};
let (sid, src) = resolve_session(&ext).unwrap();
assert_eq!(src, SessionSource::Agent);
assert_eq!(sid, subject_scoped(Some("alice"), "from-agent").unwrap());
}
#[test]
fn tier0_wins_over_identity() {
let mut agent = AgentExtension::default();
agent.session_id = Some("from-agent".into());
let sec = SecurityExtension {
subject: Some(subject_with_claims(Some("alice"), &[])),
caller_workload: Some(WorkloadIdentity {
client_id: Some("agent-007".into()),
..Default::default()
}),
this_workload: Some(WorkloadIdentity {
client_id: Some("praxis-gateway".into()),
..Default::default()
}),
..Default::default()
};
let ext = Extensions {
agent: Some(Arc::new(agent)),
security: Some(Arc::new(sec)),
..Default::default()
};
let (sid, src) = resolve_session(&ext).unwrap();
assert_eq!(
src,
SessionSource::Agent,
"T0 must win over T2 when both are available",
);
assert_eq!(sid, subject_scoped(Some("alice"), "from-agent").unwrap());
}
#[test]
fn tier1_token_claim_hits_when_session_id_claim_present() {
let sec = SecurityExtension {
subject: Some(subject_with_claims(
Some("alice@corp.com"),
&[("session_id", "sess-from-token-789")],
)),
..Default::default()
};
let ext = extensions_with_security(sec);
let (sid, src) = resolve_session(&ext).expect("should resolve");
assert_eq!(src, SessionSource::TokenClaim);
assert_eq!(
sid,
subject_scoped(Some("alice@corp.com"), "sess-from-token-789").unwrap()
);
assert_ne!(sid, "sess-from-token-789");
}
#[test]
fn tier1_skips_empty_session_id_claim() {
let sec = SecurityExtension {
subject: Some(subject_with_claims(Some("alice"), &[("session_id", "")])),
..Default::default()
};
let ext = extensions_with_security(sec);
let (_, src) = resolve_session(&ext).expect("should fall through to identity");
assert_eq!(src, SessionSource::Identity);
}
#[test]
fn tier1_same_session_id_claim_different_subjects_are_distinct() {
let mk = |sub: &str| -> SecurityExtension {
SecurityExtension {
subject: Some(subject_with_claims(
Some(sub),
&[("session_id", "issuer-shared-sid")],
)),
..Default::default()
}
};
let (sid_a, _) = resolve_session(&extensions_with_security(mk("alice"))).unwrap();
let (sid_b, _) = resolve_session(&extensions_with_security(mk("bob"))).unwrap();
assert_ne!(
sid_a, sid_b,
"same JWT session_id claim under different subjects must not collide",
);
}
#[test]
fn tier1_stable_for_same_subject_and_session_id_claim() {
let mk = || -> Extensions {
extensions_with_security(SecurityExtension {
subject: Some(subject_with_claims(
Some("alice"),
&[("session_id", "claim-value-42")],
)),
..Default::default()
})
};
let (sid1, _) = resolve_session(&mk()).unwrap();
let (sid2, _) = resolve_session(&mk()).unwrap();
assert_eq!(sid1, sid2);
}
#[test]
fn tier1_no_subject_id_falls_through() {
let sec = SecurityExtension {
subject: Some(SubjectExtension {
id: None,
claims: [("session_id".to_string(), "claim-value".to_string())]
.into_iter()
.collect(),
..Default::default()
}),
..Default::default()
};
let ext = extensions_with_security(sec);
assert!(
resolve_session(&ext).is_none(),
"claim with no subject id has no safe scope; must not honor",
);
}
#[test]
fn tier1_wins_over_identity() {
let sec = SecurityExtension {
subject: Some(subject_with_claims(
Some("alice"),
&[("session_id", "from-claim")],
)),
caller_workload: Some(WorkloadIdentity {
client_id: Some("agent-007".into()),
..Default::default()
}),
this_workload: Some(WorkloadIdentity {
client_id: Some("praxis-gateway".into()),
..Default::default()
}),
..Default::default()
};
let ext = extensions_with_security(sec);
let (sid, src) = resolve_session(&ext).unwrap();
assert_eq!(
src,
SessionSource::TokenClaim,
"T1 must win over T2 when both are available",
);
assert_eq!(sid, subject_scoped(Some("alice"), "from-claim").unwrap());
}
#[test]
fn tier2_identity_derived_when_no_claim() {
let sec = SecurityExtension {
subject: Some(subject_with_claims(Some("alice@corp.com"), &[])),
caller_workload: Some(WorkloadIdentity {
client_id: Some("agent-007".into()),
..Default::default()
}),
this_workload: Some(WorkloadIdentity {
client_id: Some("praxis-gateway".into()),
..Default::default()
}),
..Default::default()
};
let ext = extensions_with_security(sec);
let (sid, src) = resolve_session(&ext).expect("should resolve");
assert_eq!(src, SessionSource::Identity);
assert_eq!(sid.len(), 16);
assert!(sid.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn tier2_identity_is_stable_across_calls() {
let mk = || -> SecurityExtension {
SecurityExtension {
subject: Some(subject_with_claims(Some("alice@corp.com"), &[])),
caller_workload: Some(WorkloadIdentity {
client_id: Some("agent-007".into()),
..Default::default()
}),
this_workload: Some(WorkloadIdentity {
client_id: Some("praxis-gateway".into()),
..Default::default()
}),
..Default::default()
}
};
let ext1 = extensions_with_security(mk());
let ext2 = extensions_with_security(mk());
let (sid1, _) = resolve_session(&ext1).unwrap();
let (sid2, _) = resolve_session(&ext2).unwrap();
assert_eq!(sid1, sid2);
}
#[test]
fn tier2_distinguishes_different_users() {
let alice = SecurityExtension {
subject: Some(subject_with_claims(Some("alice"), &[])),
..Default::default()
};
let bob = SecurityExtension {
subject: Some(subject_with_claims(Some("bob"), &[])),
..Default::default()
};
let (sid_a, _) = resolve_session(&extensions_with_security(alice)).unwrap();
let (sid_b, _) = resolve_session(&extensions_with_security(bob)).unwrap();
assert_ne!(sid_a, sid_b);
}
#[test]
fn tier2_distinguishes_different_agents() {
let mk = |agent: &str| -> SecurityExtension {
SecurityExtension {
subject: Some(subject_with_claims(Some("alice"), &[])),
caller_workload: Some(WorkloadIdentity {
client_id: Some(agent.into()),
..Default::default()
}),
..Default::default()
}
};
let (sid1, _) = resolve_session(&extensions_with_security(mk("agent-a"))).unwrap();
let (sid2, _) = resolve_session(&extensions_with_security(mk("agent-b"))).unwrap();
assert_ne!(sid1, sid2);
}
#[test]
fn tier3_no_session_when_no_data() {
let ext = Extensions::default();
assert!(resolve_session(&ext).is_none());
}
#[test]
fn tier3_no_session_when_no_subject_id() {
let sec = SecurityExtension {
subject: Some(SubjectExtension::default()), ..Default::default()
};
let ext = extensions_with_security(sec);
assert!(resolve_session(&ext).is_none());
}
#[test]
fn separator_format_collides_when_subject_contains_colon() {
let a = subject_scoped(Some("alice:foo"), "bar").unwrap();
let b = subject_scoped(Some("alice"), "foo:bar").unwrap();
assert_eq!(
a, b,
"current format collides; if subject IDs can contain colons, switch to length-prefix",
);
}
#[test]
fn header_x_cpex_session_id_is_ignored() {
let sec = SecurityExtension {
subject: Some(subject_with_claims(Some("alice"), &[])),
caller_workload: Some(WorkloadIdentity {
client_id: Some("agent-007".into()),
..Default::default()
}),
..Default::default()
};
let mut http = HttpExtension::default();
http.request_headers
.insert("X-CPEX-Session-Id".into(), "sess-bob-stolen".into());
let ext = Extensions {
security: Some(Arc::new(sec)),
http: Some(Arc::new(http)),
..Default::default()
};
let (sid, src) = resolve_session(&ext).expect("identity should still hit");
assert_eq!(
src,
SessionSource::Identity,
"header tier was removed; resolver must NOT honor X-CPEX-Session-Id",
);
assert_ne!(
sid, "sess-bob-stolen",
"header value must never become the session id",
);
}
}