@@ -403,7 +403,8 @@ impl HookHandler<TokenDelegateHook> for OAuthDelegator {
403403 // actor to record. `actor_token` belongs to the on-behalf-of
404404 // shape (a user subject with the calling agent as actor).
405405 let actor_token = payload. actor_token ( ) ;
406- if !actor_token. is_empty ( ) && !as_this_workload && !is_workload {
406+ let actor_requested = !actor_token. is_empty ( ) && !as_this_workload && !is_workload;
407+ if actor_requested {
407408 form. push ( ( "actor_token" , actor_token) ) ;
408409 form. push ( ( "actor_token_type" , & self . typed . actor_token_type ) ) ;
409410 }
@@ -529,6 +530,23 @@ impl HookHandler<TokenDelegateHook> for OAuthDelegator {
529530 } ;
530531 let expires_at = Utc :: now ( ) + chrono:: Duration :: seconds ( ttl_secs) ;
531532
533+ // Best-effort interop check. We asked the IdP to record the calling
534+ // agent in `act` (RFC 8693 delegation semantics). If the minted token
535+ // is a JWT that carries no `act`, the IdP did impersonation instead —
536+ // it accepted the exchange but silently dropped the actor (Keycloak's
537+ // Standard Token Exchange behaves this way). The scoped token is still
538+ // valid and returned; we only surface the gap so it isn't a silent
539+ // no-op the policy author never notices.
540+ if actor_requested && jwt_payload_omits_act ( & parsed. access_token ) {
541+ tracing:: warn!(
542+ target: "cpex::delegation" ,
543+ token_endpoint = %self . typed. token_endpoint,
544+ "actor was requested (RFC 8693 actor_token) but the minted token carries no `act` claim; \
545+ the token service may implement impersonation only (e.g. Keycloak Standard Token Exchange) \
546+ and ignored the actor — the acting agent will not appear downstream",
547+ ) ;
548+ }
549+
532550 let token = RawDelegatedToken :: new (
533551 parsed. access_token ,
534552 self . typed . default_outbound_header . clone ( ) ,
@@ -581,6 +599,30 @@ fn mode_for_subject(subject: &DelegationSubject) -> DelegationMode {
581599 }
582600}
583601
602+ /// Best-effort: does `access_token` decode as a JWT whose payload has no
603+ /// `act` claim? Returns `true` only when we can *positively* see a JWT
604+ /// payload object that lacks `act`. Anything we can't inspect — an opaque
605+ /// token, a non-base64url segment, a non-JSON payload — returns `false`,
606+ /// so a caller using this to warn never fires on a token it couldn't read.
607+ ///
608+ /// The signature is deliberately not verified: this token just came back
609+ /// from our own trusted IdP roundtrip, and we're only reading a claim to
610+ /// decide whether to log, not making a trust decision.
611+ fn jwt_payload_omits_act ( access_token : & str ) -> bool {
612+ use base64:: Engine as _;
613+ // JWT is `header.payload.signature`; the claims are the middle segment.
614+ let Some ( payload_b64) = access_token. split ( '.' ) . nth ( 1 ) else {
615+ return false ;
616+ } ;
617+ let Ok ( bytes) = base64:: engine:: general_purpose:: URL_SAFE_NO_PAD . decode ( payload_b64) else {
618+ return false ;
619+ } ;
620+ let Ok ( claims) = serde_json:: from_slice :: < serde_json:: Value > ( & bytes) else {
621+ return false ;
622+ } ;
623+ claims. is_object ( ) && claims. get ( "act" ) . is_none ( )
624+ }
625+
584626// Silence unused-import warning when only a subset of these is
585627// reached in any given config path. Kept as a single place so the
586628// crate's surface is visible at a glance.
@@ -646,3 +688,48 @@ mod scheme_tests {
646688 assert ! ( err. contains( "must use https" ) ) ;
647689 }
648690}
691+
692+ #[ cfg( test) ]
693+ mod act_claim_tests {
694+ use super :: jwt_payload_omits_act;
695+ use base64:: Engine as _;
696+
697+ // Build a `header.payload.sig` JWT string from a payload JSON literal.
698+ fn jwt ( payload : & str ) -> String {
699+ let b = |s : & str | base64:: engine:: general_purpose:: URL_SAFE_NO_PAD . encode ( s. as_bytes ( ) ) ;
700+ format ! ( "{}.{}.{}" , b( r#"{"alg":"none"}"# ) , b( payload) , "sig" )
701+ }
702+
703+ #[ test]
704+ fn payload_with_act_is_not_flagged ( ) {
705+ // Delegation honored: `act` present → no warning.
706+ let token = jwt ( r#"{"sub":"user","act":{"sub":"agent"}}"# ) ;
707+ assert ! ( !jwt_payload_omits_act( & token) ) ;
708+ }
709+
710+ #[ test]
711+ fn payload_without_act_is_flagged ( ) {
712+ // Impersonation: subject only, no `act` → this is the case we warn on.
713+ let token = jwt ( r#"{"sub":"user","aud":"workday-api"}"# ) ;
714+ assert ! ( jwt_payload_omits_act( & token) ) ;
715+ }
716+
717+ #[ test]
718+ fn opaque_token_is_not_flagged ( ) {
719+ // Not a JWT (no dots): we can't inspect it, so never warn.
720+ assert ! ( !jwt_payload_omits_act( "opaque-reference-token" ) ) ;
721+ }
722+
723+ #[ test]
724+ fn non_base64_payload_is_not_flagged ( ) {
725+ // Right shape, but the middle segment isn't valid base64url.
726+ assert ! ( !jwt_payload_omits_act( "aaa.!!!not-base64!!!.sig" ) ) ;
727+ }
728+
729+ #[ test]
730+ fn non_json_payload_is_not_flagged ( ) {
731+ // Decodes as base64url but isn't JSON claims — can't tell, don't warn.
732+ let b = base64:: engine:: general_purpose:: URL_SAFE_NO_PAD . encode ( b"not json" ) ;
733+ assert ! ( !jwt_payload_omits_act( & format!( "aaa.{b}.sig" ) ) ) ;
734+ }
735+ }
0 commit comments