1use std::collections::{HashMap, HashSet};
49
50use cpex_core::extensions::raw_credentials::{
51 DelegationKey, DelegationMode, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole,
52};
53use cpex_core::extensions::Extensions;
54use serde::Serialize;
55use serde_json::Value;
56
57use crate::conversion::payload_kind_for_hook;
58use crate::error::HostError;
59use crate::PayloadKind;
60
61pub const CREDENTIAL_FIELD: &str = "credential";
64
65pub const CAP_READ_INBOUND: &str = "read_inbound_credentials";
67
68pub const CAP_READ_DELEGATED: &str = "read_delegated_tokens";
70
71const REDACTED: &str = "<redacted>";
73
74#[derive(Clone, Default, Serialize)]
79pub struct CredentialDto {
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub inbound: Option<InboundCredential>,
82
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub delegated: Option<DelegatedCredential>,
85}
86
87impl std::fmt::Debug for CredentialDto {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.debug_struct("CredentialDto")
91 .field("inbound", &self.inbound.as_ref().map(|_| REDACTED))
92 .field("delegated", &self.delegated.as_ref().map(|_| REDACTED))
93 .finish()
94 }
95}
96
97#[derive(Clone, Serialize)]
103pub struct InboundCredential {
104 pub token: String,
106
107 pub source_header: String,
109
110 pub kind: String,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
117 pub headers: Option<HashMap<String, String>>,
118}
119
120impl std::fmt::Debug for InboundCredential {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 f.debug_struct("InboundCredential")
123 .field("token", &REDACTED)
124 .field("source_header", &self.source_header)
125 .field("kind", &self.kind)
126 .field(
128 "header_names",
129 &self.headers.as_ref().map(|h| h.keys().collect::<Vec<_>>()),
130 )
131 .finish()
132 }
133}
134
135#[derive(Clone, Serialize)]
141pub struct DelegatedCredential {
142 pub token: String,
144
145 pub outbound_header: String,
147
148 pub audience: String,
150
151 pub scopes: Vec<String>,
153}
154
155impl std::fmt::Debug for DelegatedCredential {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 f.debug_struct("DelegatedCredential")
158 .field("token", &REDACTED)
159 .field("outbound_header", &self.outbound_header)
160 .field("audience", &self.audience)
161 .field("scopes", &self.scopes)
162 .finish()
163 }
164}
165
166fn token_kind_wire_name(kind: &TokenKind) -> &'static str {
173 match kind {
174 TokenKind::Jwt => "jwt",
175 TokenKind::Opaque => "opaque",
176 TokenKind::SpiffeJwt => "spiffe_jwt",
177 TokenKind::Ucan => "ucan",
178 TokenKind::TxnToken => "txn_token",
179 _ => "opaque",
184 }
185}
186
187pub fn attach_credential(
193 task: &mut Value,
194 hook_name: &str,
195 extensions: &Extensions,
196 capabilities: &HashSet<String>,
197) -> Result<(), HostError> {
198 let kind = payload_kind_for_hook(hook_name);
199
200 let dto = match kind {
201 PayloadKind::IdentityResolve => {
202 if !capabilities.contains(CAP_READ_INBOUND) {
203 return Ok(());
204 }
205 CredentialDto {
206 inbound: Some(build_inbound(extensions, hook_name)?),
207 delegated: None,
208 }
209 },
210 PayloadKind::TokenDelegate => {
211 if !capabilities.contains(CAP_READ_DELEGATED) {
212 return Ok(());
213 }
214 let requested_audience = requested_audience(task);
220 CredentialDto {
221 inbound: None,
222 delegated: Some(build_delegated(
223 extensions,
224 hook_name,
225 requested_audience.as_deref(),
226 )?),
227 }
228 },
229 _ => return Ok(()),
231 };
232
233 let object = task.as_object_mut().ok_or_else(|| HostError::Protocol {
234 message: "task must be a JSON object to carry a credential".into(),
235 })?;
236
237 let serialized = serde_json::to_value(&dto).map_err(|e| HostError::Credential {
238 message: format!("could not serialize the credential DTO: {e}"),
240 })?;
241 object.insert(CREDENTIAL_FIELD.to_string(), serialized);
242
243 Ok(())
244}
245
246fn build_inbound(extensions: &Extensions, hook_name: &str) -> Result<InboundCredential, HostError> {
252 let raw = extensions
253 .raw_credentials
254 .as_ref()
255 .ok_or_else(|| HostError::Credential {
256 message: format!(
257 "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the request \
258 carries no raw-credentials extension — refusing to dispatch without the token \
259 it asked for"
260 ),
261 })?;
262
263 let token = [
268 TokenRole::User,
269 TokenRole::Client,
270 TokenRole::CallerWorkload,
271 ]
272 .iter()
273 .find_map(|role| raw.inbound_tokens.get(role))
274 .or_else(|| raw.inbound_tokens.values().next())
275 .ok_or_else(|| HostError::Credential {
276 message: format!(
277 "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but no inbound token \
278 is present — refusing to dispatch without it"
279 ),
280 })?;
281
282 inbound_from_token(token, hook_name)
283}
284
285fn inbound_from_token(
287 token: &RawInboundToken,
288 hook_name: &str,
289) -> Result<InboundCredential, HostError> {
290 let plaintext = token.token.as_str();
293
294 if plaintext.trim().is_empty() {
298 return Err(HostError::Credential {
299 message: format!(
300 "the inbound token for hook '{hook_name}' is empty — refusing to send an empty \
301 credential (an empty bearer authenticates nowhere, and a plugin may read it as \
302 'no authentication required')"
303 ),
304 });
305 }
306
307 Ok(InboundCredential {
308 token: plaintext.to_string(),
309 source_header: token.source_header.clone(),
310 kind: token_kind_wire_name(&token.kind).to_string(),
311 headers: Some(HashMap::from([(
315 token.source_header.clone(),
316 plaintext.to_string(),
317 )])),
318 })
319}
320
321fn requested_audience(task: &Value) -> Option<String> {
327 task.get("payload")?
328 .get("target_audience")?
329 .as_str()
330 .map(str::to_string)
331}
332
333fn build_delegated(
357 extensions: &Extensions,
358 hook_name: &str,
359 requested_audience: Option<&str>,
360) -> Result<DelegatedCredential, HostError> {
361 let raw = extensions
362 .raw_credentials
363 .as_ref()
364 .ok_or_else(|| HostError::Credential {
365 message: format!(
366 "plugin declared '{CAP_READ_DELEGATED}' for hook '{hook_name}' but the request \
367 carries no raw-credentials extension — refusing to dispatch without the token \
368 it asked for"
369 ),
370 })?;
371
372 let mut candidates: Vec<(&DelegationKey, &RawDelegatedToken)> = raw
375 .delegated_tokens
376 .iter()
377 .filter(|(key, token)| match requested_audience {
378 Some(audience) => key.audience == audience && token.audience == audience,
379 None => true,
380 })
381 .collect();
382
383 candidates.sort_by(|(left, _), (right, _)| {
386 mode_rank(&left.mode)
387 .cmp(&mode_rank(&right.mode))
388 .then_with(|| left.subject_id.cmp(&right.subject_id))
389 .then_with(|| left.scopes.cmp(&right.scopes))
390 });
391
392 let (_, token) = candidates.first().ok_or_else(|| {
393 let scope = match requested_audience {
397 Some(audience) => format!(" for audience '{audience}'"),
398 None => String::new(),
399 };
400 HostError::Credential {
401 message: format!(
402 "plugin declared '{CAP_READ_DELEGATED}' for hook '{hook_name}' but no delegated \
403 token{scope} is present — refusing to dispatch without it, and refusing to \
404 substitute a token minted for a different audience"
405 ),
406 }
407 })?;
408
409 delegated_from_token(token, hook_name)
410}
411
412fn mode_rank(mode: &DelegationMode) -> u8 {
418 match mode {
419 DelegationMode::OnBehalfOfUser => 0,
420 DelegationMode::AsThisWorkload => 1,
421 _ => 2,
422 }
423}
424
425fn delegated_from_token(
427 token: &RawDelegatedToken,
428 hook_name: &str,
429) -> Result<DelegatedCredential, HostError> {
430 let plaintext = token.token.as_str();
431
432 if plaintext.trim().is_empty() {
433 return Err(HostError::Credential {
434 message: format!(
435 "the delegated token for hook '{hook_name}' is empty — refusing to send an empty \
436 credential"
437 ),
438 });
439 }
440
441 Ok(DelegatedCredential {
442 token: plaintext.to_string(),
443 outbound_header: token.outbound_header.clone(),
444 audience: token.audience.clone(),
445 scopes: token.scopes.clone(),
446 })
447}
448
449#[cfg(test)]
450mod tests {
451 use std::sync::Arc;
452
453 use cpex_core::extensions::raw_credentials::{DelegationKey, RawCredentialsExtension};
454
455 use super::*;
456
457 const INBOUND_SECRET: &str = "eyJhbGciOiJSUzI1NiJ9.INBOUND-SECRET-BYTES.sig";
458 const DELEGATED_SECRET: &str = "MINTED-DELEGATED-SECRET-BYTES";
459
460 fn caps(names: &[&str]) -> HashSet<String> {
461 names.iter().map(|s| (*s).to_string()).collect()
462 }
463
464 fn extensions_with_credentials() -> Extensions {
466 let mut raw = RawCredentialsExtension::default();
467 raw.inbound_tokens.insert(
468 TokenRole::User,
469 RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt),
470 );
471 raw.delegated_tokens.insert(
472 DelegationKey::new(
473 DelegationMode::OnBehalfOfUser,
474 "https://billing.example.com",
475 vec!["read".into()],
476 )
477 .with_subject_id("alice"),
478 RawDelegatedToken::new(
479 DELEGATED_SECRET,
480 "Authorization",
481 "https://billing.example.com",
482 vec!["read".into()],
483 chrono::Utc::now(),
484 ),
485 );
486
487 Extensions {
488 raw_credentials: Some(Arc::new(raw)),
489 ..Default::default()
490 }
491 }
492
493 fn task() -> Value {
494 serde_json::json!({ "task_type": "load_and_run_hook" })
495 }
496
497 fn delegate_task(target_audience: &str) -> Value {
500 serde_json::json!({
501 "task_type": "load_and_run_hook",
502 "hook_type": "token_delegate",
503 "payload": {
504 "target_name": "billing",
505 "target_type": "tool",
506 "target_audience": target_audience,
507 },
508 })
509 }
510
511 fn delegated(
513 subject: &str,
514 audience: &str,
515 mode: DelegationMode,
516 secret: &str,
517 ) -> (DelegationKey, RawDelegatedToken) {
518 (
519 DelegationKey::new(mode, audience, vec![]).with_subject_id(subject),
520 RawDelegatedToken::new(
521 secret,
522 "Authorization",
523 audience,
524 vec![],
525 chrono::Utc::now(),
526 ),
527 )
528 }
529
530 fn extensions_from(tokens: Vec<(DelegationKey, RawDelegatedToken)>) -> Extensions {
531 let mut raw = RawCredentialsExtension::default();
532 for (key, token) in tokens {
533 raw.delegated_tokens.insert(key, token);
534 }
535 Extensions {
536 raw_credentials: Some(Arc::new(raw)),
537 ..Default::default()
538 }
539 }
540
541 fn pick_delegated(extensions: &Extensions, target_audience: &str) -> Result<String, HostError> {
544 let mut task = delegate_task(target_audience);
545 attach_credential(
546 &mut task,
547 "token_delegate",
548 extensions,
549 &caps(&[CAP_READ_DELEGATED]),
550 )?;
551 Ok(task[CREDENTIAL_FIELD]["delegated"]["token"]
552 .as_str()
553 .expect("a delegated token was attached")
554 .to_string())
555 }
556
557 #[test]
560 fn a_declaring_plugin_receives_the_inbound_token() {
561 let mut task = task();
562 attach_credential(
563 &mut task,
564 "identity_resolve",
565 &extensions_with_credentials(),
566 &caps(&[CAP_READ_INBOUND]),
567 )
568 .expect("a declared capability is honored");
569
570 let credential = &task[CREDENTIAL_FIELD];
571 assert_eq!(credential["inbound"]["token"], INBOUND_SECRET);
572 assert_eq!(credential["inbound"]["source_header"], "Authorization");
573 assert_eq!(credential["inbound"]["kind"], "jwt");
574 assert!(
575 credential.get("delegated").is_none(),
576 "an identity hook must not receive delegated material"
577 );
578 }
579
580 #[test]
581 fn a_non_declaring_plugin_receives_no_token_material() {
582 let mut task = task();
583 attach_credential(
584 &mut task,
585 "identity_resolve",
586 &extensions_with_credentials(),
587 &caps(&[]),
588 )
589 .expect("declaring nothing is not an error");
590
591 assert!(
592 task.get(CREDENTIAL_FIELD).is_none(),
593 "no credential field at all"
594 );
595 assert!(
596 !serde_json::to_string(&task)
597 .unwrap()
598 .contains("INBOUND-SECRET-BYTES"),
599 "no token bytes anywhere in the task"
600 );
601 }
602
603 #[test]
604 fn two_plugins_on_one_hook_are_gated_independently() {
605 let extensions = extensions_with_credentials();
608
609 let mut declaring = task();
610 attach_credential(
611 &mut declaring,
612 "identity_resolve",
613 &extensions,
614 &caps(&[CAP_READ_INBOUND]),
615 )
616 .unwrap();
617
618 let mut non_declaring = task();
619 attach_credential(
620 &mut non_declaring,
621 "identity_resolve",
622 &extensions,
623 &caps(&[]),
624 )
625 .unwrap();
626
627 assert!(serde_json::to_string(&declaring)
628 .unwrap()
629 .contains(INBOUND_SECRET));
630 assert!(!serde_json::to_string(&non_declaring)
631 .unwrap()
632 .contains("INBOUND-SECRET-BYTES"));
633 }
634
635 #[test]
636 fn the_wrong_capability_does_not_unlock_a_hook() {
637 let extensions = extensions_with_credentials();
640
641 let mut identity = task();
642 attach_credential(
643 &mut identity,
644 "identity_resolve",
645 &extensions,
646 &caps(&[CAP_READ_DELEGATED]),
647 )
648 .unwrap();
649 assert!(identity.get(CREDENTIAL_FIELD).is_none());
650
651 let mut delegate = task();
652 attach_credential(
653 &mut delegate,
654 "token_delegate",
655 &extensions,
656 &caps(&[CAP_READ_INBOUND]),
657 )
658 .unwrap();
659 assert!(delegate.get(CREDENTIAL_FIELD).is_none());
660 }
661
662 #[test]
663 fn a_declaring_plugin_receives_the_delegated_token() {
664 let mut task = task();
665 attach_credential(
666 &mut task,
667 "token_delegate",
668 &extensions_with_credentials(),
669 &caps(&[CAP_READ_DELEGATED]),
670 )
671 .expect("a declared capability is honored");
672
673 let delegated = &task[CREDENTIAL_FIELD]["delegated"];
674 assert_eq!(delegated["token"], DELEGATED_SECRET);
675 assert_eq!(delegated["outbound_header"], "Authorization");
676 assert_eq!(delegated["audience"], "https://billing.example.com");
677 assert_eq!(delegated["scopes"][0], "read");
678 assert!(task[CREDENTIAL_FIELD].get("inbound").is_none());
679 }
680
681 #[test]
682 fn non_credential_hooks_never_receive_a_credential() {
683 for hook in [
686 "tool_pre_invoke",
687 "tool_post_invoke",
688 "prompt_pre_fetch",
689 "resource_post_fetch",
690 "cmf.tool_pre_invoke",
691 "some_custom_hook",
692 ] {
693 let mut task = task();
694 attach_credential(
695 &mut task,
696 hook,
697 &extensions_with_credentials(),
698 &caps(&[CAP_READ_INBOUND, CAP_READ_DELEGATED]),
699 )
700 .unwrap();
701
702 assert!(
703 task.get(CREDENTIAL_FIELD).is_none(),
704 "hook '{hook}' must not receive credentials"
705 );
706 }
707 }
708
709 #[test]
712 fn a_declared_capability_with_no_extension_errors() {
713 let err = attach_credential(
717 &mut task(),
718 "identity_resolve",
719 &Extensions::default(),
720 &caps(&[CAP_READ_INBOUND]),
721 )
722 .expect_err("a declared capability that cannot be honored must error");
723
724 assert!(matches!(err, HostError::Credential { .. }), "got {err:?}");
725 }
726
727 #[test]
728 fn a_declared_capability_with_no_matching_token_errors() {
729 let mut raw = RawCredentialsExtension::default();
732 raw.delegated_tokens.insert(
733 DelegationKey::new(DelegationMode::AsThisWorkload, "aud", vec![])
734 .with_subject_id("alice"),
735 RawDelegatedToken::new("t", "Authorization", "aud", vec![], chrono::Utc::now()),
736 );
737 let extensions = Extensions {
738 raw_credentials: Some(Arc::new(raw)),
739 ..Default::default()
740 };
741
742 let err = attach_credential(
743 &mut task(),
744 "identity_resolve",
745 &extensions,
746 &caps(&[CAP_READ_INBOUND]),
747 )
748 .expect_err("no inbound token means the capability cannot be honored");
749 assert!(matches!(err, HostError::Credential { .. }));
750 }
751
752 #[test]
753 fn an_empty_token_is_rejected_rather_than_sent() {
754 for token in ["", " ", "\t\n"] {
759 let mut raw = RawCredentialsExtension::default();
760 raw.inbound_tokens.insert(
761 TokenRole::User,
762 RawInboundToken::new(token, "Authorization", TokenKind::Jwt),
763 );
764 let extensions = Extensions {
765 raw_credentials: Some(Arc::new(raw)),
766 ..Default::default()
767 };
768
769 let err = attach_credential(
770 &mut task(),
771 "identity_resolve",
772 &extensions,
773 &caps(&[CAP_READ_INBOUND]),
774 )
775 .expect_err("an empty or whitespace-only token must be refused");
776 assert!(matches!(err, HostError::Credential { .. }));
777 }
778 }
779
780 #[test]
781 fn an_empty_delegated_token_is_rejected() {
782 let mut raw = RawCredentialsExtension::default();
783 raw.delegated_tokens.insert(
784 DelegationKey::new(DelegationMode::OnBehalfOfUser, "aud", vec![])
785 .with_subject_id("alice"),
786 RawDelegatedToken::new("", "Authorization", "aud", vec![], chrono::Utc::now()),
787 );
788 let extensions = Extensions {
789 raw_credentials: Some(Arc::new(raw)),
790 ..Default::default()
791 };
792
793 let err = attach_credential(
794 &mut task(),
795 "token_delegate",
796 &extensions,
797 &caps(&[CAP_READ_DELEGATED]),
798 )
799 .expect_err("an empty delegated token must be refused");
800 assert!(matches!(err, HostError::Credential { .. }));
801 }
802
803 #[test]
804 fn a_fail_closed_error_never_names_the_token() {
805 let mut raw = RawCredentialsExtension::default();
806 raw.inbound_tokens.insert(
807 TokenRole::User,
808 RawInboundToken::new(" ", "Authorization", TokenKind::Jwt),
809 );
810 let extensions = Extensions {
811 raw_credentials: Some(Arc::new(raw)),
812 ..Default::default()
813 };
814
815 let err = attach_credential(
816 &mut task(),
817 "identity_resolve",
818 &extensions,
819 &caps(&[CAP_READ_INBOUND]),
820 )
821 .expect_err("errors");
822 let message = err.to_string();
823 assert!(
824 message.contains("identity_resolve"),
825 "the message should name the hook: {message}"
826 );
827 assert!(message.contains("empty"));
828 }
829
830 #[test]
833 fn production_credential_types_still_refuse_to_serialize_tokens() {
834 let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt);
839 let json = serde_json::to_string(&inbound).unwrap();
840 assert!(
841 !json.contains("INBOUND-SECRET-BYTES"),
842 "the serde guard regressed: {json}"
843 );
844
845 let delegated = RawDelegatedToken::new(
846 DELEGATED_SECRET,
847 "Authorization",
848 "aud",
849 vec![],
850 chrono::Utc::now(),
851 );
852 let json = serde_json::to_string(&delegated).unwrap();
853 assert!(
854 !json.contains("MINTED-DELEGATED"),
855 "the serde guard regressed: {json}"
856 );
857
858 let extensions = extensions_with_credentials();
863 let raw = extensions.raw_credentials.as_ref().unwrap();
864 let json = serde_json::to_string(&raw.inbound_tokens).unwrap();
865 assert!(
866 !json.contains("INBOUND-SECRET-BYTES"),
867 "the serde guard regressed: {json}"
868 );
869
870 for token in raw.delegated_tokens.values() {
871 let json = serde_json::to_string(token).unwrap();
872 assert!(
873 !json.contains("MINTED-DELEGATED"),
874 "the serde guard regressed: {json}"
875 );
876 }
877 }
878
879 #[test]
880 fn a_serialize_then_reparse_would_have_yielded_an_empty_token() {
881 let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt);
884 let round_tripped: RawInboundToken =
885 serde_json::from_str(&serde_json::to_string(&inbound).unwrap()).unwrap();
886
887 assert_eq!(
888 &*round_tripped.token, "",
889 "the guard strips the bytes on the way out"
890 );
891 assert_eq!(round_tripped.source_header, "Authorization");
892 }
893
894 #[test]
897 fn the_dto_debug_output_hides_the_token() {
898 let dto = CredentialDto {
899 inbound: Some(InboundCredential {
900 token: INBOUND_SECRET.into(),
901 source_header: "Authorization".into(),
902 kind: "jwt".into(),
903 headers: Some(HashMap::from([(
904 "Authorization".into(),
905 format!("Bearer {INBOUND_SECRET}"),
906 )])),
907 }),
908 delegated: None,
909 };
910
911 let outer = format!("{dto:?}");
914 let inner = format!("{:?}", dto.inbound.as_ref().unwrap());
915
916 for debug in [&outer, &inner] {
917 assert!(
918 !debug.contains("INBOUND-SECRET-BYTES"),
919 "token leaked into Debug: {debug}"
920 );
921 }
922 assert!(inner.contains("Authorization"));
924 assert!(inner.contains("jwt"));
925 }
926
927 #[test]
928 fn the_delegated_dto_debug_output_hides_the_token() {
929 let credential = DelegatedCredential {
930 token: DELEGATED_SECRET.into(),
931 outbound_header: "Authorization".into(),
932 audience: "https://billing.example.com".into(),
933 scopes: vec!["read".into()],
934 };
935
936 let debug = format!("{credential:?}");
937 assert!(
938 !debug.contains("MINTED-DELEGATED"),
939 "token leaked into Debug: {debug}"
940 );
941 assert!(debug.contains("billing.example.com"));
942 assert!(debug.contains("read"));
943 }
944
945 #[test]
948 fn token_kinds_use_the_wire_names_the_worker_matches_on() {
949 assert_eq!(token_kind_wire_name(&TokenKind::Jwt), "jwt");
952 assert_eq!(token_kind_wire_name(&TokenKind::Opaque), "opaque");
953 assert_eq!(token_kind_wire_name(&TokenKind::SpiffeJwt), "spiffe_jwt");
954 assert_eq!(token_kind_wire_name(&TokenKind::Ucan), "ucan");
955 assert_eq!(token_kind_wire_name(&TokenKind::TxnToken), "txn_token");
956 }
957
958 #[test]
959 fn wire_names_match_the_serde_representation() {
960 for kind in [
963 TokenKind::Jwt,
964 TokenKind::Opaque,
965 TokenKind::SpiffeJwt,
966 TokenKind::Ucan,
967 TokenKind::TxnToken,
968 ] {
969 let via_serde = serde_json::to_value(&kind).unwrap();
970 assert_eq!(
971 via_serde.as_str().unwrap(),
972 token_kind_wire_name(&kind),
973 "hand-written wire name disagrees with serde for {kind:?}"
974 );
975 }
976 }
977
978 #[test]
979 fn the_user_token_is_preferred_over_the_client_token() {
980 let mut raw = RawCredentialsExtension::default();
983 raw.inbound_tokens.insert(
984 TokenRole::Client,
985 RawInboundToken::new("CLIENT-TOKEN", "X-Client", TokenKind::Opaque),
986 );
987 raw.inbound_tokens.insert(
988 TokenRole::User,
989 RawInboundToken::new("USER-TOKEN", "Authorization", TokenKind::Jwt),
990 );
991 let extensions = Extensions {
992 raw_credentials: Some(Arc::new(raw)),
993 ..Default::default()
994 };
995
996 let mut task = task();
997 attach_credential(
998 &mut task,
999 "identity_resolve",
1000 &extensions,
1001 &caps(&[CAP_READ_INBOUND]),
1002 )
1003 .unwrap();
1004 assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], "USER-TOKEN");
1005 }
1006
1007 #[test]
1008 fn headers_are_synthesized_from_the_source_header() {
1009 let mut task = task();
1010 attach_credential(
1011 &mut task,
1012 "identity_resolve",
1013 &extensions_with_credentials(),
1014 &caps(&[CAP_READ_INBOUND]),
1015 )
1016 .unwrap();
1017
1018 assert_eq!(
1021 task[CREDENTIAL_FIELD]["inbound"]["headers"]["Authorization"],
1022 INBOUND_SECRET
1023 );
1024 }
1025
1026 #[test]
1027 fn an_on_behalf_of_user_token_is_preferred_over_a_gateway_token() {
1028 let mut raw = RawCredentialsExtension::default();
1029 raw.delegated_tokens.insert(
1030 DelegationKey::new(DelegationMode::AsThisWorkload, "aud", vec![]).with_subject_id("gw"),
1031 RawDelegatedToken::new(
1032 "GATEWAY-TOKEN",
1033 "Authorization",
1034 "aud",
1035 vec![],
1036 chrono::Utc::now(),
1037 ),
1038 );
1039 raw.delegated_tokens.insert(
1040 DelegationKey::new(DelegationMode::OnBehalfOfUser, "aud", vec![])
1041 .with_subject_id("alice"),
1042 RawDelegatedToken::new(
1043 "USER-DELEGATED-TOKEN",
1044 "Authorization",
1045 "aud",
1046 vec![],
1047 chrono::Utc::now(),
1048 ),
1049 );
1050 let extensions = Extensions {
1051 raw_credentials: Some(Arc::new(raw)),
1052 ..Default::default()
1053 };
1054
1055 let mut task = task();
1056 attach_credential(
1057 &mut task,
1058 "token_delegate",
1059 &extensions,
1060 &caps(&[CAP_READ_DELEGATED]),
1061 )
1062 .unwrap();
1063 assert_eq!(
1064 task[CREDENTIAL_FIELD]["delegated"]["token"], "USER-DELEGATED-TOKEN",
1065 "a delegator acting for the user must not fall back to the gateway's identity"
1066 );
1067 }
1068
1069 #[test]
1072 fn the_delegated_pick_matches_the_requested_audience() {
1073 let extensions = extensions_from(vec![
1079 delegated(
1080 "alice",
1081 "https://billing.example.com",
1082 DelegationMode::OnBehalfOfUser,
1083 "BILLING-TOKEN",
1084 ),
1085 delegated(
1086 "alice",
1087 "https://search.example.com",
1088 DelegationMode::OnBehalfOfUser,
1089 "SEARCH-TOKEN",
1090 ),
1091 delegated(
1092 "alice",
1093 "https://payroll.example.com",
1094 DelegationMode::OnBehalfOfUser,
1095 "PAYROLL-TOKEN",
1096 ),
1097 ]);
1098
1099 assert_eq!(
1100 pick_delegated(&extensions, "https://search.example.com").unwrap(),
1101 "SEARCH-TOKEN",
1102 "a token minted for another audience must never be substituted"
1103 );
1104 assert_eq!(
1105 pick_delegated(&extensions, "https://billing.example.com").unwrap(),
1106 "BILLING-TOKEN"
1107 );
1108 }
1109
1110 #[test]
1111 fn the_delegated_pick_is_stable_across_runs_and_insertion_orders() {
1112 let forward = vec![
1117 delegated("alice", "aud-a", DelegationMode::OnBehalfOfUser, "A"),
1118 delegated("bob", "aud-a", DelegationMode::OnBehalfOfUser, "B"),
1119 delegated("carol", "aud-a", DelegationMode::OnBehalfOfUser, "C"),
1120 delegated("dave", "aud-a", DelegationMode::OnBehalfOfUser, "D"),
1121 ];
1122 let reversed: Vec<_> = forward.iter().rev().cloned().collect();
1123
1124 const EXPECTED: &str = "A";
1126
1127 for _ in 0..200 {
1128 let one = extensions_from(forward.clone());
1129 let other = extensions_from(reversed.clone());
1130 assert_eq!(pick_delegated(&one, "aud-a").unwrap(), EXPECTED);
1131 assert_eq!(
1132 pick_delegated(&other, "aud-a").unwrap(),
1133 EXPECTED,
1134 "insertion order must not change the pick"
1135 );
1136 }
1137 }
1138
1139 #[test]
1140 fn no_token_for_the_requested_audience_fails_closed() {
1141 let extensions = extensions_from(vec![delegated(
1144 "alice",
1145 "https://billing.example.com",
1146 DelegationMode::OnBehalfOfUser,
1147 "BILLING-TOKEN",
1148 )]);
1149
1150 let err = pick_delegated(&extensions, "https://attacker.example.com")
1151 .expect_err("no matching audience means no token");
1152
1153 let message = err.to_string();
1154 assert!(matches!(err, HostError::Credential { .. }), "{message}");
1155 assert!(
1156 message.contains("attacker.example.com"),
1157 "the error should name the audience that had no token: {message}"
1158 );
1159 assert!(
1160 !message.contains("BILLING-TOKEN"),
1161 "the error must not name the token it declined to substitute: {message}"
1162 );
1163 }
1164
1165 #[test]
1166 fn the_audience_filter_is_applied_before_the_mode_preference() {
1167 let extensions = extensions_from(vec![
1172 delegated(
1173 "alice",
1174 "https://other.example.com",
1175 DelegationMode::OnBehalfOfUser,
1176 "OTHER-AUDIENCE-USER-TOKEN",
1177 ),
1178 delegated(
1179 "gw",
1180 "https://billing.example.com",
1181 DelegationMode::AsThisWorkload,
1182 "BILLING-GATEWAY-TOKEN",
1183 ),
1184 ]);
1185
1186 assert_eq!(
1187 pick_delegated(&extensions, "https://billing.example.com").unwrap(),
1188 "BILLING-GATEWAY-TOKEN"
1189 );
1190 }
1191
1192 #[test]
1193 fn within_one_audience_the_user_token_still_wins() {
1194 let extensions = extensions_from(vec![
1199 delegated(
1200 "aaa-gw",
1201 "aud",
1202 DelegationMode::AsThisWorkload,
1203 "GATEWAY-TOKEN",
1204 ),
1205 delegated(
1206 "zzz-user",
1207 "aud",
1208 DelegationMode::OnBehalfOfUser,
1209 "USER-TOKEN",
1210 ),
1211 ]);
1212
1213 assert_eq!(pick_delegated(&extensions, "aud").unwrap(), "USER-TOKEN");
1214 }
1215
1216 #[test]
1217 fn a_key_whose_audience_disagrees_with_its_token_is_not_a_match() {
1218 let (key, _) = delegated("alice", "aud-key", DelegationMode::OnBehalfOfUser, "T");
1222 let token = RawDelegatedToken::new(
1223 "MISMATCHED-TOKEN",
1224 "Authorization",
1225 "aud-token",
1226 vec![],
1227 chrono::Utc::now(),
1228 );
1229 let extensions = extensions_from(vec![(key, token)]);
1230
1231 for audience in ["aud-key", "aud-token"] {
1232 assert!(
1233 pick_delegated(&extensions, audience).is_err(),
1234 "a self-inconsistent token must not satisfy '{audience}'"
1235 );
1236 }
1237 }
1238
1239 #[test]
1240 fn an_unscoped_delegation_accepts_any_audience() {
1241 let tokens = vec![
1246 delegated("alice", "aud-a", DelegationMode::OnBehalfOfUser, "A"),
1247 delegated("bob", "aud-b", DelegationMode::OnBehalfOfUser, "B"),
1248 ];
1249
1250 for _ in 0..50 {
1251 let mut task = serde_json::json!({
1252 "task_type": "load_and_run_hook",
1253 "payload": { "target_name": "billing", "target_type": "tool" },
1254 });
1255 attach_credential(
1256 &mut task,
1257 "token_delegate",
1258 &extensions_from(tokens.clone()),
1259 &caps(&[CAP_READ_DELEGATED]),
1260 )
1261 .expect("an unscoped delegation is still served");
1262 assert_eq!(task[CREDENTIAL_FIELD]["delegated"]["token"], "A");
1263 }
1264 }
1265
1266 #[test]
1267 fn the_mode_rank_puts_the_user_first_and_unknown_modes_last() {
1268 assert!(
1270 mode_rank(&DelegationMode::OnBehalfOfUser) < mode_rank(&DelegationMode::AsThisWorkload)
1271 );
1272 }
1273
1274 #[test]
1275 fn the_requested_audience_is_read_from_the_payload() {
1276 assert_eq!(
1277 requested_audience(&delegate_task("https://billing.example.com")).as_deref(),
1278 Some("https://billing.example.com")
1279 );
1280 assert_eq!(requested_audience(&task()), None);
1282 assert_eq!(
1283 requested_audience(&serde_json::json!({ "payload": { "target_audience": 7 } })),
1284 None
1285 );
1286 assert_eq!(
1287 requested_audience(&serde_json::json!({ "payload": {} })),
1288 None
1289 );
1290 }
1291
1292 #[test]
1293 fn the_credential_field_name_matches_the_worker_contract() {
1294 assert_eq!(
1295 CREDENTIAL_FIELD, "credential",
1296 "the worker reads task_data['credential']"
1297 );
1298 }
1299}