1use std::collections::{HashMap, HashSet};
49
50use praxis_policy_core::delegation::DelegationPayload;
51use praxis_policy_core::extensions::Extensions;
52use praxis_policy_core::extensions::raw_credentials::{
53 DelegationKey, DelegationMode, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole,
54};
55use praxis_policy_core::hooks::payload::PluginPayload;
56use praxis_policy_core::identity::{IdentityPayload, TokenSource};
57use serde::Serialize;
58use serde_json::Value;
59
60use crate::PayloadKind;
61use crate::conversion::payload_kind_for_hook;
62use crate::error::HostError;
63
64pub const CREDENTIAL_FIELD: &str = "credential";
67
68pub const CAP_READ_INBOUND: &str = "read_inbound_credentials";
70
71pub const CAP_READ_DELEGATED: &str = "read_delegated_tokens";
73
74const REDACTED: &str = "<redacted>";
76
77#[derive(Clone, Default, Serialize)]
82pub struct CredentialDto {
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub inbound: Option<InboundCredential>,
85
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub delegated: Option<DelegatedCredential>,
88}
89
90impl std::fmt::Debug for CredentialDto {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("CredentialDto")
94 .field("inbound", &self.inbound.as_ref().map(|_| REDACTED))
95 .field("delegated", &self.delegated.as_ref().map(|_| REDACTED))
96 .finish()
97 }
98}
99
100#[derive(Clone, Serialize)]
106pub struct InboundCredential {
107 pub token: String,
109
110 pub source_header: String,
112
113 pub kind: String,
115
116 #[serde(skip_serializing_if = "Option::is_none")]
120 pub headers: Option<HashMap<String, String>>,
121}
122
123impl std::fmt::Debug for InboundCredential {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 f.debug_struct("InboundCredential")
126 .field("token", &REDACTED)
127 .field("source_header", &self.source_header)
128 .field("kind", &self.kind)
129 .field(
131 "header_names",
132 &self.headers.as_ref().map(|h| h.keys().collect::<Vec<_>>()),
133 )
134 .finish()
135 }
136}
137
138#[derive(Clone, Serialize)]
144pub struct DelegatedCredential {
145 pub token: String,
147
148 pub outbound_header: String,
150
151 pub audience: String,
153
154 pub scopes: Vec<String>,
156}
157
158impl std::fmt::Debug for DelegatedCredential {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 f.debug_struct("DelegatedCredential")
161 .field("token", &REDACTED)
162 .field("outbound_header", &self.outbound_header)
163 .field("audience", &self.audience)
164 .field("scopes", &self.scopes)
165 .finish()
166 }
167}
168
169fn token_kind_wire_name(kind: &TokenKind) -> &'static str {
176 match kind {
177 TokenKind::Jwt => "jwt",
178 TokenKind::Opaque => "opaque",
179 TokenKind::SpiffeJwt => "spiffe_jwt",
180 TokenKind::Ucan => "ucan",
181 TokenKind::TxnToken => "txn_token",
182 _ => "opaque",
187 }
188}
189
190pub fn attach_credential(
196 task: &mut Value,
197 hook_name: &str,
198 extensions: &Extensions,
199 capabilities: &HashSet<String>,
200) -> Result<(), HostError> {
201 let kind = payload_kind_for_hook(hook_name);
202
203 let dto = match kind {
204 PayloadKind::IdentityResolve => {
205 if !capabilities.contains(CAP_READ_INBOUND) {
206 return Ok(());
207 }
208 CredentialDto {
209 inbound: Some(build_inbound(extensions, hook_name)?),
210 delegated: None,
211 }
212 },
213 PayloadKind::TokenDelegate => {
214 if !capabilities.contains(CAP_READ_DELEGATED) {
215 return Ok(());
216 }
217 let requested_audience = requested_audience(task);
223 CredentialDto {
224 inbound: None,
225 delegated: Some(build_delegated(
226 extensions,
227 hook_name,
228 requested_audience.as_deref(),
229 )?),
230 }
231 },
232 _ => return Ok(()),
234 };
235
236 insert_dto(task, &dto)
237}
238
239pub fn attach_credential_for_payload(
252 task: &mut Value,
253 hook_name: &str,
254 payload: &dyn PluginPayload,
255 extensions: &Extensions,
256 capabilities: &HashSet<String>,
257) -> Result<(), HostError> {
258 let any = payload.as_any();
259 let dto = match payload_kind_for_hook(hook_name) {
260 PayloadKind::IdentityResolve => match any.downcast_ref::<IdentityPayload>() {
261 Some(identity) if capabilities.contains(CAP_READ_INBOUND) => CredentialDto {
262 inbound: Some(inbound_from_identity(identity, hook_name)?),
263 delegated: None,
264 },
265 Some(_) => return Ok(()),
266 None => return attach_credential(task, hook_name, extensions, capabilities),
267 },
268 PayloadKind::TokenDelegate => match any.downcast_ref::<DelegationPayload>() {
275 Some(delegation) if capabilities.contains(CAP_READ_INBOUND) => CredentialDto {
276 inbound: None,
277 delegated: Some(delegated_from_payload(delegation, hook_name)?),
278 },
279 Some(_) => return Ok(()),
280 None => return attach_credential(task, hook_name, extensions, capabilities),
281 },
282 _ => return attach_credential(task, hook_name, extensions, capabilities),
283 };
284 insert_dto(task, &dto)
285}
286
287fn inbound_from_identity(
294 identity: &IdentityPayload,
295 hook_name: &str,
296) -> Result<InboundCredential, HostError> {
297 let token = crate::credential_payloads::inbound_token(identity).ok_or_else(|| {
298 HostError::Credential {
299 message: format!(
300 "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the identity \
301 payload carries no credential, in `raw_token` or its source header. Refusing \
302 to dispatch without the token it asked for"
303 ),
304 }
305 })?;
306 let source_header = crate::credential_payloads::credential_header_name(identity);
307 let mut headers = identity.headers().clone();
308 if !headers
309 .keys()
310 .any(|name| name.eq_ignore_ascii_case(&source_header))
311 {
312 headers.insert(source_header.clone(), token.clone());
313 }
314 Ok(InboundCredential {
315 token,
316 source_header,
317 kind: token_source_kind(identity.source()).to_string(),
318 headers: Some(headers),
319 })
320}
321
322fn token_source_kind(source: &TokenSource) -> &'static str {
328 match source {
329 TokenSource::SpiffeJwtSvid => "spiffe_jwt",
330 _ => "opaque",
331 }
332}
333
334fn delegated_from_payload(
339 delegation: &DelegationPayload,
340 hook_name: &str,
341) -> Result<DelegatedCredential, HostError> {
342 let token = delegation.bearer_token();
343 if token.trim().is_empty() {
344 return Err(HostError::Credential {
345 message: format!(
346 "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the delegation \
347 payload carries no bearer token. Refusing to dispatch without the token it \
348 asked for"
349 ),
350 });
351 }
352 Ok(DelegatedCredential {
353 token: token.to_string(),
354 outbound_header: "Authorization".into(),
355 audience: delegation.target_audience().unwrap_or_default().to_string(),
356 scopes: delegation.required_permissions().to_vec(),
357 })
358}
359
360fn insert_dto(task: &mut Value, dto: &CredentialDto) -> Result<(), HostError> {
361 let object = task.as_object_mut().ok_or_else(|| HostError::Protocol {
362 message: "task must be a JSON object to carry a credential".into(),
363 })?;
364
365 let serialized = serde_json::to_value(dto).map_err(|e| HostError::Credential {
366 message: format!("could not serialize the credential DTO: {e}"),
368 })?;
369 object.insert(CREDENTIAL_FIELD.to_string(), serialized);
370
371 Ok(())
372}
373
374fn build_inbound(extensions: &Extensions, hook_name: &str) -> Result<InboundCredential, HostError> {
380 let raw = extensions
381 .raw_credentials
382 .as_ref()
383 .ok_or_else(|| HostError::Credential {
384 message: format!(
385 "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the request \
386 carries no raw-credentials extension — refusing to dispatch without the token \
387 it asked for"
388 ),
389 })?;
390
391 let token = [
396 TokenRole::User,
397 TokenRole::Client,
398 TokenRole::CallerWorkload,
399 ]
400 .iter()
401 .find_map(|role| raw.inbound_tokens.get(role))
402 .or_else(|| raw.inbound_tokens.values().next())
403 .ok_or_else(|| HostError::Credential {
404 message: format!(
405 "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but no inbound token \
406 is present — refusing to dispatch without it"
407 ),
408 })?;
409
410 inbound_from_token(token, hook_name)
411}
412
413fn inbound_from_token(
415 token: &RawInboundToken,
416 hook_name: &str,
417) -> Result<InboundCredential, HostError> {
418 let plaintext = token.token.as_str();
421
422 if plaintext.trim().is_empty() {
426 return Err(HostError::Credential {
427 message: format!(
428 "the inbound token for hook '{hook_name}' is empty — refusing to send an empty \
429 credential (an empty bearer authenticates nowhere, and a plugin may read it as \
430 'no authentication required')"
431 ),
432 });
433 }
434
435 Ok(InboundCredential {
436 token: plaintext.to_string(),
437 source_header: token.source_header.clone(),
438 kind: token_kind_wire_name(&token.kind).to_string(),
439 headers: Some(HashMap::from([(
443 token.source_header.clone(),
444 plaintext.to_string(),
445 )])),
446 })
447}
448
449fn requested_audience(task: &Value) -> Option<String> {
455 task.get("payload")?
456 .get("target_audience")?
457 .as_str()
458 .map(str::to_string)
459}
460
461fn build_delegated(
485 extensions: &Extensions,
486 hook_name: &str,
487 requested_audience: Option<&str>,
488) -> Result<DelegatedCredential, HostError> {
489 let raw = extensions
490 .raw_credentials
491 .as_ref()
492 .ok_or_else(|| HostError::Credential {
493 message: format!(
494 "plugin declared '{CAP_READ_DELEGATED}' for hook '{hook_name}' but the request \
495 carries no raw-credentials extension — refusing to dispatch without the token \
496 it asked for"
497 ),
498 })?;
499
500 let mut candidates: Vec<(&DelegationKey, &RawDelegatedToken)> = raw
503 .delegated_tokens
504 .iter()
505 .filter(|(key, token)| match requested_audience {
506 Some(audience) => key.audience == audience && token.audience == audience,
507 None => true,
508 })
509 .collect();
510
511 candidates.sort_by(|(left, _), (right, _)| {
514 mode_rank(&left.mode)
515 .cmp(&mode_rank(&right.mode))
516 .then_with(|| left.subject_id.cmp(&right.subject_id))
517 .then_with(|| left.scopes.cmp(&right.scopes))
518 });
519
520 let (_, token) = candidates.first().ok_or_else(|| {
521 let scope = match requested_audience {
525 Some(audience) => format!(" for audience '{audience}'"),
526 None => String::new(),
527 };
528 HostError::Credential {
529 message: format!(
530 "plugin declared '{CAP_READ_DELEGATED}' for hook '{hook_name}' but no delegated \
531 token{scope} is present — refusing to dispatch without it, and refusing to \
532 substitute a token minted for a different audience"
533 ),
534 }
535 })?;
536
537 delegated_from_token(token, hook_name)
538}
539
540fn mode_rank(mode: &DelegationMode) -> u8 {
546 match mode {
547 DelegationMode::OnBehalfOfUser => 0,
548 DelegationMode::AsThisWorkload => 1,
549 _ => 2,
550 }
551}
552
553fn delegated_from_token(
555 token: &RawDelegatedToken,
556 hook_name: &str,
557) -> Result<DelegatedCredential, HostError> {
558 let plaintext = token.token.as_str();
559
560 if plaintext.trim().is_empty() {
561 return Err(HostError::Credential {
562 message: format!(
563 "the delegated token for hook '{hook_name}' is empty — refusing to send an empty \
564 credential"
565 ),
566 });
567 }
568
569 Ok(DelegatedCredential {
570 token: plaintext.to_string(),
571 outbound_header: token.outbound_header.clone(),
572 audience: token.audience.clone(),
573 scopes: token.scopes.clone(),
574 })
575}
576
577#[cfg(test)]
578mod tests {
579 use std::sync::Arc;
580
581 use praxis_policy_core::extensions::raw_credentials::{DelegationKey, RawCredentialsExtension};
582
583 use super::*;
584
585 const INBOUND_SECRET: &str = "eyJhbGciOiJSUzI1NiJ9.INBOUND-SECRET-BYTES.sig";
586 const DELEGATED_SECRET: &str = "MINTED-DELEGATED-SECRET-BYTES";
587
588 fn caps(names: &[&str]) -> HashSet<String> {
589 names.iter().map(|s| (*s).to_string()).collect()
590 }
591
592 fn extensions_with_credentials() -> Extensions {
594 let mut raw = RawCredentialsExtension::default();
595 raw.inbound_tokens.insert(
596 TokenRole::User,
597 RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt),
598 );
599 raw.delegated_tokens.insert(
600 DelegationKey::new(
601 DelegationMode::OnBehalfOfUser,
602 "https://billing.example.com",
603 vec!["read".into()],
604 )
605 .with_subject_id("alice"),
606 RawDelegatedToken::new(
607 DELEGATED_SECRET,
608 "Authorization",
609 "https://billing.example.com",
610 vec!["read".into()],
611 chrono::Utc::now(),
612 ),
613 );
614
615 Extensions {
616 raw_credentials: Some(Arc::new(raw)),
617 ..Default::default()
618 }
619 }
620
621 fn task() -> Value {
622 serde_json::json!({ "task_type": "load_and_run_hook" })
623 }
624
625 fn delegate_task(target_audience: &str) -> Value {
628 serde_json::json!({
629 "task_type": "load_and_run_hook",
630 "hook_type": "token.delegate",
631 "payload": {
632 "target_name": "billing",
633 "target_type": "tool",
634 "target_audience": target_audience,
635 },
636 })
637 }
638
639 fn delegated(
641 subject: &str,
642 audience: &str,
643 mode: DelegationMode,
644 secret: &str,
645 ) -> (DelegationKey, RawDelegatedToken) {
646 (
647 DelegationKey::new(mode, audience, vec![]).with_subject_id(subject),
648 RawDelegatedToken::new(
649 secret,
650 "Authorization",
651 audience,
652 vec![],
653 chrono::Utc::now(),
654 ),
655 )
656 }
657
658 fn extensions_from(tokens: Vec<(DelegationKey, RawDelegatedToken)>) -> Extensions {
659 let mut raw = RawCredentialsExtension::default();
660 for (key, token) in tokens {
661 raw.delegated_tokens.insert(key, token);
662 }
663 Extensions {
664 raw_credentials: Some(Arc::new(raw)),
665 ..Default::default()
666 }
667 }
668
669 fn pick_delegated(extensions: &Extensions, target_audience: &str) -> Result<String, HostError> {
672 let mut task = delegate_task(target_audience);
673 attach_credential(
674 &mut task,
675 "token.delegate",
676 extensions,
677 &caps(&[CAP_READ_DELEGATED]),
678 )?;
679 Ok(task[CREDENTIAL_FIELD]["delegated"]["token"]
680 .as_str()
681 .expect("a delegated token was attached")
682 .to_string())
683 }
684
685 #[test]
688 fn a_declaring_plugin_receives_the_inbound_token() {
689 let mut task = task();
690 attach_credential(
691 &mut task,
692 "identity.resolve",
693 &extensions_with_credentials(),
694 &caps(&[CAP_READ_INBOUND]),
695 )
696 .expect("a declared capability is honored");
697
698 let credential = &task[CREDENTIAL_FIELD];
699 assert_eq!(credential["inbound"]["token"], INBOUND_SECRET);
700 assert_eq!(credential["inbound"]["source_header"], "Authorization");
701 assert_eq!(credential["inbound"]["kind"], "jwt");
702 assert!(
703 credential.get("delegated").is_none(),
704 "an identity hook must not receive delegated material"
705 );
706 }
707
708 #[test]
709 fn a_non_declaring_plugin_receives_no_token_material() {
710 let mut task = task();
711 attach_credential(
712 &mut task,
713 "identity.resolve",
714 &extensions_with_credentials(),
715 &caps(&[]),
716 )
717 .expect("declaring nothing is not an error");
718
719 assert!(
720 task.get(CREDENTIAL_FIELD).is_none(),
721 "no credential field at all"
722 );
723 assert!(
724 !serde_json::to_string(&task)
725 .unwrap()
726 .contains("INBOUND-SECRET-BYTES"),
727 "no token bytes anywhere in the task"
728 );
729 }
730
731 #[test]
732 fn two_plugins_on_one_hook_are_gated_independently() {
733 let extensions = extensions_with_credentials();
736
737 let mut declaring = task();
738 attach_credential(
739 &mut declaring,
740 "identity.resolve",
741 &extensions,
742 &caps(&[CAP_READ_INBOUND]),
743 )
744 .unwrap();
745
746 let mut non_declaring = task();
747 attach_credential(
748 &mut non_declaring,
749 "identity.resolve",
750 &extensions,
751 &caps(&[]),
752 )
753 .unwrap();
754
755 assert!(
756 serde_json::to_string(&declaring)
757 .unwrap()
758 .contains(INBOUND_SECRET)
759 );
760 assert!(
761 !serde_json::to_string(&non_declaring)
762 .unwrap()
763 .contains("INBOUND-SECRET-BYTES")
764 );
765 }
766
767 #[test]
768 fn the_wrong_capability_does_not_unlock_a_hook() {
769 let extensions = extensions_with_credentials();
772
773 let mut identity = task();
774 attach_credential(
775 &mut identity,
776 "identity.resolve",
777 &extensions,
778 &caps(&[CAP_READ_DELEGATED]),
779 )
780 .unwrap();
781 assert!(identity.get(CREDENTIAL_FIELD).is_none());
782
783 let mut delegate = task();
784 attach_credential(
785 &mut delegate,
786 "token.delegate",
787 &extensions,
788 &caps(&[CAP_READ_INBOUND]),
789 )
790 .unwrap();
791 assert!(delegate.get(CREDENTIAL_FIELD).is_none());
792 }
793
794 #[test]
795 fn a_declaring_plugin_receives_the_delegated_token() {
796 let mut task = task();
797 attach_credential(
798 &mut task,
799 "token.delegate",
800 &extensions_with_credentials(),
801 &caps(&[CAP_READ_DELEGATED]),
802 )
803 .expect("a declared capability is honored");
804
805 let delegated = &task[CREDENTIAL_FIELD]["delegated"];
806 assert_eq!(delegated["token"], DELEGATED_SECRET);
807 assert_eq!(delegated["outbound_header"], "Authorization");
808 assert_eq!(delegated["audience"], "https://billing.example.com");
809 assert_eq!(delegated["scopes"][0], "read");
810 assert!(task[CREDENTIAL_FIELD].get("inbound").is_none());
811 }
812
813 #[test]
814 fn non_credential_hooks_never_receive_a_credential() {
815 for hook in [
818 "tool_pre_invoke",
819 "tool_post_invoke",
820 "prompt_pre_fetch",
821 "resource_post_fetch",
822 "cmf.tool_pre_invoke",
823 "cmf.prompt_pre_invoke",
824 "http.request",
825 "some_custom_hook",
826 "identity_resolve",
829 "token_delegate",
830 ] {
831 let mut task = task();
832 attach_credential(
833 &mut task,
834 hook,
835 &extensions_with_credentials(),
836 &caps(&[CAP_READ_INBOUND, CAP_READ_DELEGATED]),
837 )
838 .unwrap();
839
840 assert!(
841 task.get(CREDENTIAL_FIELD).is_none(),
842 "hook '{hook}' must not receive credentials"
843 );
844 }
845 }
846
847 #[test]
850 fn a_declared_capability_with_no_extension_errors() {
851 let err = attach_credential(
855 &mut task(),
856 "identity.resolve",
857 &Extensions::default(),
858 &caps(&[CAP_READ_INBOUND]),
859 )
860 .expect_err("a declared capability that cannot be honored must error");
861
862 assert!(matches!(err, HostError::Credential { .. }), "got {err:?}");
863 }
864
865 #[test]
866 fn a_declared_capability_with_no_matching_token_errors() {
867 let mut raw = RawCredentialsExtension::default();
870 raw.delegated_tokens.insert(
871 DelegationKey::new(DelegationMode::AsThisWorkload, "aud", vec![])
872 .with_subject_id("alice"),
873 RawDelegatedToken::new("t", "Authorization", "aud", vec![], chrono::Utc::now()),
874 );
875 let extensions = Extensions {
876 raw_credentials: Some(Arc::new(raw)),
877 ..Default::default()
878 };
879
880 let err = attach_credential(
881 &mut task(),
882 "identity.resolve",
883 &extensions,
884 &caps(&[CAP_READ_INBOUND]),
885 )
886 .expect_err("no inbound token means the capability cannot be honored");
887 assert!(matches!(err, HostError::Credential { .. }));
888 }
889
890 #[test]
891 fn an_empty_token_is_rejected_rather_than_sent() {
892 for token in ["", " ", "\t\n"] {
897 let mut raw = RawCredentialsExtension::default();
898 raw.inbound_tokens.insert(
899 TokenRole::User,
900 RawInboundToken::new(token, "Authorization", TokenKind::Jwt),
901 );
902 let extensions = Extensions {
903 raw_credentials: Some(Arc::new(raw)),
904 ..Default::default()
905 };
906
907 let err = attach_credential(
908 &mut task(),
909 "identity.resolve",
910 &extensions,
911 &caps(&[CAP_READ_INBOUND]),
912 )
913 .expect_err("an empty or whitespace-only token must be refused");
914 assert!(matches!(err, HostError::Credential { .. }));
915 }
916 }
917
918 #[test]
919 fn an_empty_delegated_token_is_rejected() {
920 let mut raw = RawCredentialsExtension::default();
921 raw.delegated_tokens.insert(
922 DelegationKey::new(DelegationMode::OnBehalfOfUser, "aud", vec![])
923 .with_subject_id("alice"),
924 RawDelegatedToken::new("", "Authorization", "aud", vec![], chrono::Utc::now()),
925 );
926 let extensions = Extensions {
927 raw_credentials: Some(Arc::new(raw)),
928 ..Default::default()
929 };
930
931 let err = attach_credential(
932 &mut task(),
933 "token.delegate",
934 &extensions,
935 &caps(&[CAP_READ_DELEGATED]),
936 )
937 .expect_err("an empty delegated token must be refused");
938 assert!(matches!(err, HostError::Credential { .. }));
939 }
940
941 #[test]
942 fn a_fail_closed_error_never_names_the_token() {
943 let mut raw = RawCredentialsExtension::default();
944 raw.inbound_tokens.insert(
945 TokenRole::User,
946 RawInboundToken::new(" ", "Authorization", TokenKind::Jwt),
947 );
948 let extensions = Extensions {
949 raw_credentials: Some(Arc::new(raw)),
950 ..Default::default()
951 };
952
953 let err = attach_credential(
954 &mut task(),
955 "identity.resolve",
956 &extensions,
957 &caps(&[CAP_READ_INBOUND]),
958 )
959 .expect_err("errors");
960 let message = err.to_string();
961 assert!(
962 message.contains("identity.resolve"),
963 "the message should name the hook: {message}"
964 );
965 assert!(message.contains("empty"));
966 }
967
968 #[test]
971 fn production_credential_types_still_refuse_to_serialize_tokens() {
972 let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt);
977 let json = serde_json::to_string(&inbound).unwrap();
978 assert!(
979 !json.contains("INBOUND-SECRET-BYTES"),
980 "the serde guard regressed: {json}"
981 );
982
983 let delegated = RawDelegatedToken::new(
984 DELEGATED_SECRET,
985 "Authorization",
986 "aud",
987 vec![],
988 chrono::Utc::now(),
989 );
990 let json = serde_json::to_string(&delegated).unwrap();
991 assert!(
992 !json.contains("MINTED-DELEGATED"),
993 "the serde guard regressed: {json}"
994 );
995
996 let extensions = extensions_with_credentials();
1001 let raw = extensions.raw_credentials.as_ref().unwrap();
1002 let json = serde_json::to_string(&raw.inbound_tokens).unwrap();
1003 assert!(
1004 !json.contains("INBOUND-SECRET-BYTES"),
1005 "the serde guard regressed: {json}"
1006 );
1007
1008 for token in raw.delegated_tokens.values() {
1009 let json = serde_json::to_string(token).unwrap();
1010 assert!(
1011 !json.contains("MINTED-DELEGATED"),
1012 "the serde guard regressed: {json}"
1013 );
1014 }
1015 }
1016
1017 #[test]
1018 fn a_serialize_then_reparse_would_have_yielded_an_empty_token() {
1019 let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt);
1022 let round_tripped: RawInboundToken =
1023 serde_json::from_str(&serde_json::to_string(&inbound).unwrap()).unwrap();
1024
1025 assert_eq!(
1026 &*round_tripped.token, "",
1027 "the guard strips the bytes on the way out"
1028 );
1029 assert_eq!(round_tripped.source_header, "Authorization");
1030 }
1031
1032 #[test]
1035 fn the_dto_debug_output_hides_the_token() {
1036 let dto = CredentialDto {
1037 inbound: Some(InboundCredential {
1038 token: INBOUND_SECRET.into(),
1039 source_header: "Authorization".into(),
1040 kind: "jwt".into(),
1041 headers: Some(HashMap::from([(
1042 "Authorization".into(),
1043 format!("Bearer {INBOUND_SECRET}"),
1044 )])),
1045 }),
1046 delegated: None,
1047 };
1048
1049 let outer = format!("{dto:?}");
1052 let inner = format!("{:?}", dto.inbound.as_ref().unwrap());
1053
1054 for debug in [&outer, &inner] {
1055 assert!(
1056 !debug.contains("INBOUND-SECRET-BYTES"),
1057 "token leaked into Debug: {debug}"
1058 );
1059 }
1060 assert!(inner.contains("Authorization"));
1062 assert!(inner.contains("jwt"));
1063 }
1064
1065 #[test]
1066 fn the_delegated_dto_debug_output_hides_the_token() {
1067 let credential = DelegatedCredential {
1068 token: DELEGATED_SECRET.into(),
1069 outbound_header: "Authorization".into(),
1070 audience: "https://billing.example.com".into(),
1071 scopes: vec!["read".into()],
1072 };
1073
1074 let debug = format!("{credential:?}");
1075 assert!(
1076 !debug.contains("MINTED-DELEGATED"),
1077 "token leaked into Debug: {debug}"
1078 );
1079 assert!(debug.contains("billing.example.com"));
1080 assert!(debug.contains("read"));
1081 }
1082
1083 #[test]
1086 fn token_kinds_use_the_wire_names_the_worker_matches_on() {
1087 assert_eq!(token_kind_wire_name(&TokenKind::Jwt), "jwt");
1090 assert_eq!(token_kind_wire_name(&TokenKind::Opaque), "opaque");
1091 assert_eq!(token_kind_wire_name(&TokenKind::SpiffeJwt), "spiffe_jwt");
1092 assert_eq!(token_kind_wire_name(&TokenKind::Ucan), "ucan");
1093 assert_eq!(token_kind_wire_name(&TokenKind::TxnToken), "txn_token");
1094 }
1095
1096 #[test]
1097 fn wire_names_match_the_serde_representation() {
1098 for kind in [
1101 TokenKind::Jwt,
1102 TokenKind::Opaque,
1103 TokenKind::SpiffeJwt,
1104 TokenKind::Ucan,
1105 TokenKind::TxnToken,
1106 ] {
1107 let via_serde = serde_json::to_value(&kind).unwrap();
1108 assert_eq!(
1109 via_serde.as_str().unwrap(),
1110 token_kind_wire_name(&kind),
1111 "hand-written wire name disagrees with serde for {kind:?}"
1112 );
1113 }
1114 }
1115
1116 #[test]
1117 fn the_user_token_is_preferred_over_the_client_token() {
1118 let mut raw = RawCredentialsExtension::default();
1121 raw.inbound_tokens.insert(
1122 TokenRole::Client,
1123 RawInboundToken::new("CLIENT-TOKEN", "X-Client", TokenKind::Opaque),
1124 );
1125 raw.inbound_tokens.insert(
1126 TokenRole::User,
1127 RawInboundToken::new("USER-TOKEN", "Authorization", TokenKind::Jwt),
1128 );
1129 let extensions = Extensions {
1130 raw_credentials: Some(Arc::new(raw)),
1131 ..Default::default()
1132 };
1133
1134 let mut task = task();
1135 attach_credential(
1136 &mut task,
1137 "identity.resolve",
1138 &extensions,
1139 &caps(&[CAP_READ_INBOUND]),
1140 )
1141 .unwrap();
1142 assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], "USER-TOKEN");
1143 }
1144
1145 #[test]
1146 fn headers_are_synthesized_from_the_source_header() {
1147 let mut task = task();
1148 attach_credential(
1149 &mut task,
1150 "identity.resolve",
1151 &extensions_with_credentials(),
1152 &caps(&[CAP_READ_INBOUND]),
1153 )
1154 .unwrap();
1155
1156 assert_eq!(
1159 task[CREDENTIAL_FIELD]["inbound"]["headers"]["Authorization"],
1160 INBOUND_SECRET
1161 );
1162 }
1163
1164 #[test]
1165 fn an_on_behalf_of_user_token_is_preferred_over_a_gateway_token() {
1166 let mut raw = RawCredentialsExtension::default();
1167 raw.delegated_tokens.insert(
1168 DelegationKey::new(DelegationMode::AsThisWorkload, "aud", vec![]).with_subject_id("gw"),
1169 RawDelegatedToken::new(
1170 "GATEWAY-TOKEN",
1171 "Authorization",
1172 "aud",
1173 vec![],
1174 chrono::Utc::now(),
1175 ),
1176 );
1177 raw.delegated_tokens.insert(
1178 DelegationKey::new(DelegationMode::OnBehalfOfUser, "aud", vec![])
1179 .with_subject_id("alice"),
1180 RawDelegatedToken::new(
1181 "USER-DELEGATED-TOKEN",
1182 "Authorization",
1183 "aud",
1184 vec![],
1185 chrono::Utc::now(),
1186 ),
1187 );
1188 let extensions = Extensions {
1189 raw_credentials: Some(Arc::new(raw)),
1190 ..Default::default()
1191 };
1192
1193 let mut task = task();
1194 attach_credential(
1195 &mut task,
1196 "token.delegate",
1197 &extensions,
1198 &caps(&[CAP_READ_DELEGATED]),
1199 )
1200 .unwrap();
1201 assert_eq!(
1202 task[CREDENTIAL_FIELD]["delegated"]["token"], "USER-DELEGATED-TOKEN",
1203 "a delegator acting for the user must not fall back to the gateway's identity"
1204 );
1205 }
1206
1207 #[test]
1210 fn the_delegated_pick_matches_the_requested_audience() {
1211 let extensions = extensions_from(vec![
1217 delegated(
1218 "alice",
1219 "https://billing.example.com",
1220 DelegationMode::OnBehalfOfUser,
1221 "BILLING-TOKEN",
1222 ),
1223 delegated(
1224 "alice",
1225 "https://search.example.com",
1226 DelegationMode::OnBehalfOfUser,
1227 "SEARCH-TOKEN",
1228 ),
1229 delegated(
1230 "alice",
1231 "https://payroll.example.com",
1232 DelegationMode::OnBehalfOfUser,
1233 "PAYROLL-TOKEN",
1234 ),
1235 ]);
1236
1237 assert_eq!(
1238 pick_delegated(&extensions, "https://search.example.com").unwrap(),
1239 "SEARCH-TOKEN",
1240 "a token minted for another audience must never be substituted"
1241 );
1242 assert_eq!(
1243 pick_delegated(&extensions, "https://billing.example.com").unwrap(),
1244 "BILLING-TOKEN"
1245 );
1246 }
1247
1248 #[test]
1249 fn the_delegated_pick_is_stable_across_runs_and_insertion_orders() {
1250 let forward = vec![
1255 delegated("alice", "aud-a", DelegationMode::OnBehalfOfUser, "A"),
1256 delegated("bob", "aud-a", DelegationMode::OnBehalfOfUser, "B"),
1257 delegated("carol", "aud-a", DelegationMode::OnBehalfOfUser, "C"),
1258 delegated("dave", "aud-a", DelegationMode::OnBehalfOfUser, "D"),
1259 ];
1260 let reversed: Vec<_> = forward.iter().rev().cloned().collect();
1261
1262 const EXPECTED: &str = "A";
1264
1265 for _ in 0..200 {
1266 let one = extensions_from(forward.clone());
1267 let other = extensions_from(reversed.clone());
1268 assert_eq!(pick_delegated(&one, "aud-a").unwrap(), EXPECTED);
1269 assert_eq!(
1270 pick_delegated(&other, "aud-a").unwrap(),
1271 EXPECTED,
1272 "insertion order must not change the pick"
1273 );
1274 }
1275 }
1276
1277 #[test]
1278 fn no_token_for_the_requested_audience_fails_closed() {
1279 let extensions = extensions_from(vec![delegated(
1282 "alice",
1283 "https://billing.example.com",
1284 DelegationMode::OnBehalfOfUser,
1285 "BILLING-TOKEN",
1286 )]);
1287
1288 let err = pick_delegated(&extensions, "https://attacker.example.com")
1289 .expect_err("no matching audience means no token");
1290
1291 let message = err.to_string();
1292 assert!(matches!(err, HostError::Credential { .. }), "{message}");
1293 assert!(
1294 message.contains("attacker.example.com"),
1295 "the error should name the audience that had no token: {message}"
1296 );
1297 assert!(
1298 !message.contains("BILLING-TOKEN"),
1299 "the error must not name the token it declined to substitute: {message}"
1300 );
1301 }
1302
1303 #[test]
1304 fn the_audience_filter_is_applied_before_the_mode_preference() {
1305 let extensions = extensions_from(vec![
1310 delegated(
1311 "alice",
1312 "https://other.example.com",
1313 DelegationMode::OnBehalfOfUser,
1314 "OTHER-AUDIENCE-USER-TOKEN",
1315 ),
1316 delegated(
1317 "gw",
1318 "https://billing.example.com",
1319 DelegationMode::AsThisWorkload,
1320 "BILLING-GATEWAY-TOKEN",
1321 ),
1322 ]);
1323
1324 assert_eq!(
1325 pick_delegated(&extensions, "https://billing.example.com").unwrap(),
1326 "BILLING-GATEWAY-TOKEN"
1327 );
1328 }
1329
1330 #[test]
1331 fn within_one_audience_the_user_token_still_wins() {
1332 let extensions = extensions_from(vec![
1337 delegated(
1338 "aaa-gw",
1339 "aud",
1340 DelegationMode::AsThisWorkload,
1341 "GATEWAY-TOKEN",
1342 ),
1343 delegated(
1344 "zzz-user",
1345 "aud",
1346 DelegationMode::OnBehalfOfUser,
1347 "USER-TOKEN",
1348 ),
1349 ]);
1350
1351 assert_eq!(pick_delegated(&extensions, "aud").unwrap(), "USER-TOKEN");
1352 }
1353
1354 #[test]
1355 fn a_key_whose_audience_disagrees_with_its_token_is_not_a_match() {
1356 let (key, _) = delegated("alice", "aud-key", DelegationMode::OnBehalfOfUser, "T");
1360 let token = RawDelegatedToken::new(
1361 "MISMATCHED-TOKEN",
1362 "Authorization",
1363 "aud-token",
1364 vec![],
1365 chrono::Utc::now(),
1366 );
1367 let extensions = extensions_from(vec![(key, token)]);
1368
1369 for audience in ["aud-key", "aud-token"] {
1370 assert!(
1371 pick_delegated(&extensions, audience).is_err(),
1372 "a self-inconsistent token must not satisfy '{audience}'"
1373 );
1374 }
1375 }
1376
1377 #[test]
1378 fn an_unscoped_delegation_accepts_any_audience() {
1379 let tokens = vec![
1384 delegated("alice", "aud-a", DelegationMode::OnBehalfOfUser, "A"),
1385 delegated("bob", "aud-b", DelegationMode::OnBehalfOfUser, "B"),
1386 ];
1387
1388 for _ in 0..50 {
1389 let mut task = serde_json::json!({
1390 "task_type": "load_and_run_hook",
1391 "payload": { "target_name": "billing", "target_type": "tool" },
1392 });
1393 attach_credential(
1394 &mut task,
1395 "token.delegate",
1396 &extensions_from(tokens.clone()),
1397 &caps(&[CAP_READ_DELEGATED]),
1398 )
1399 .expect("an unscoped delegation is still served");
1400 assert_eq!(task[CREDENTIAL_FIELD]["delegated"]["token"], "A");
1401 }
1402 }
1403
1404 #[test]
1405 fn the_mode_rank_puts_the_user_first_and_unknown_modes_last() {
1406 assert!(
1408 mode_rank(&DelegationMode::OnBehalfOfUser) < mode_rank(&DelegationMode::AsThisWorkload)
1409 );
1410 }
1411
1412 #[test]
1413 fn the_requested_audience_is_read_from_the_payload() {
1414 assert_eq!(
1415 requested_audience(&delegate_task("https://billing.example.com")).as_deref(),
1416 Some("https://billing.example.com")
1417 );
1418 assert_eq!(requested_audience(&task()), None);
1420 assert_eq!(
1421 requested_audience(&serde_json::json!({ "payload": { "target_audience": 7 } })),
1422 None
1423 );
1424 assert_eq!(
1425 requested_audience(&serde_json::json!({ "payload": {} })),
1426 None
1427 );
1428 }
1429
1430 #[test]
1431 fn the_credential_field_name_matches_the_worker_contract() {
1432 assert_eq!(
1433 CREDENTIAL_FIELD, "credential",
1434 "the worker reads task_data['credential']"
1435 );
1436 }
1437
1438 use praxis_policy_core::delegation::DelegationPayload;
1441 use praxis_policy_core::identity::{IdentityPayload, TokenSource};
1442
1443 fn identity_payload() -> IdentityPayload {
1444 IdentityPayload::new(INBOUND_SECRET, TokenSource::Bearer).with_headers(HashMap::from([
1445 ("authorization".into(), format!("Bearer {INBOUND_SECRET}")),
1446 ("cookie".into(), "session=abc".into()),
1447 ("x-tenant".into(), "acme".into()),
1448 ]))
1449 }
1450
1451 #[test]
1452 fn an_identity_payload_supplies_its_own_token_to_a_declaring_plugin() {
1453 let mut task = task();
1454 attach_credential_for_payload(
1457 &mut task,
1458 "identity.resolve",
1459 &identity_payload(),
1460 &Extensions::default(),
1461 &caps(&[CAP_READ_INBOUND]),
1462 )
1463 .expect("the payload's token honors the capability");
1464
1465 let inbound = &task[CREDENTIAL_FIELD]["inbound"];
1466 assert_eq!(inbound["token"], INBOUND_SECRET);
1467 assert_eq!(inbound["source_header"], "authorization");
1468 assert_eq!(inbound["kind"], "opaque");
1469 assert_eq!(inbound["headers"]["cookie"], "session=abc");
1471 assert_eq!(inbound["headers"]["x-tenant"], "acme");
1472 }
1473
1474 #[test]
1475 fn a_header_borne_identity_credential_is_found() {
1476 let payload: IdentityPayload = serde_json::from_value(serde_json::json!({
1477 "source": "bearer",
1478 "source_header": "X-User-Token",
1479 "headers": {"x-user-token": INBOUND_SECRET},
1480 }))
1481 .unwrap();
1482 let mut task = task();
1483 attach_credential_for_payload(
1484 &mut task,
1485 "identity.resolve",
1486 &payload,
1487 &Extensions::default(),
1488 &caps(&[CAP_READ_INBOUND]),
1489 )
1490 .unwrap();
1491 assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], INBOUND_SECRET);
1492 assert_eq!(
1493 task[CREDENTIAL_FIELD]["inbound"]["source_header"],
1494 "x-user-token"
1495 );
1496 }
1497
1498 #[test]
1499 fn an_identity_payload_gives_a_non_declaring_plugin_nothing() {
1500 let mut task = task();
1501 attach_credential_for_payload(
1502 &mut task,
1503 "identity.resolve",
1504 &identity_payload(),
1505 &extensions_with_credentials(),
1506 &caps(&[CAP_READ_DELEGATED]),
1507 )
1508 .unwrap();
1509 assert!(task.get(CREDENTIAL_FIELD).is_none());
1510 }
1511
1512 #[test]
1513 fn an_identity_payload_without_a_credential_fails_closed() {
1514 let payload = IdentityPayload::new("", TokenSource::Bearer);
1515 let mut task = task();
1516 let err = attach_credential_for_payload(
1519 &mut task,
1520 "identity.resolve",
1521 &payload,
1522 &extensions_with_credentials(),
1523 &caps(&[CAP_READ_INBOUND]),
1524 )
1525 .unwrap_err();
1526 assert!(matches!(err, HostError::Credential { .. }), "{err:?}");
1527 assert!(task.get(CREDENTIAL_FIELD).is_none());
1528 }
1529
1530 #[test]
1531 fn a_delegation_payload_supplies_its_bearer_token_to_an_inbound_declaring_plugin() {
1532 let payload = DelegationPayload::new(INBOUND_SECRET, "billing")
1533 .with_target_audience("https://billing.example.com")
1534 .with_required_permissions(vec!["read".into()]);
1535 let mut gated = task();
1536 attach_credential_for_payload(
1537 &mut gated,
1538 "token.delegate",
1539 &payload,
1540 &Extensions::default(),
1541 &caps(&[CAP_READ_INBOUND]),
1542 )
1543 .unwrap();
1544 let delegated = &gated[CREDENTIAL_FIELD]["delegated"];
1545 assert_eq!(delegated["token"], INBOUND_SECRET);
1546 assert_eq!(delegated["audience"], "https://billing.example.com");
1547 assert_eq!(delegated["scopes"], serde_json::json!(["read"]));
1548
1549 let mut task = task();
1552 attach_credential_for_payload(
1553 &mut task,
1554 "token.delegate",
1555 &payload,
1556 &Extensions::default(),
1557 &caps(&[CAP_READ_DELEGATED]),
1558 )
1559 .unwrap();
1560 assert!(task.get(CREDENTIAL_FIELD).is_none());
1561 }
1562
1563 #[test]
1564 fn a_delegation_payload_without_a_bearer_token_fails_closed() {
1565 let payload = DelegationPayload::new(" ", "billing");
1566 let mut task = task();
1567 let err = attach_credential_for_payload(
1568 &mut task,
1569 "token.delegate",
1570 &payload,
1571 &extensions_with_credentials(),
1572 &caps(&[CAP_READ_INBOUND]),
1573 )
1574 .unwrap_err();
1575 assert!(matches!(err, HostError::Credential { .. }), "{err:?}");
1576 }
1577
1578 #[test]
1579 fn a_legacy_payload_still_reads_the_extensions() {
1580 let mut task = task();
1581 attach_credential_for_payload(
1582 &mut task,
1583 "identity.resolve",
1584 &crate::legacy::IdentityResolvePayload::default(),
1585 &extensions_with_credentials(),
1586 &caps(&[CAP_READ_INBOUND]),
1587 )
1588 .unwrap();
1589 assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], INBOUND_SECRET);
1590 }
1591}