Skip to content

Commit 6e6e3db

Browse files
committed
warn when actor requested but token omits act
Signed-off-by: Teryl Taylor <terylt@ibm.com>
1 parent 47b7b05 commit 6e6e3db

7 files changed

Lines changed: 532 additions & 44 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

builtins/plugins/delegator-oauth/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ serde = { workspace = true }
5858
serde_json = { workspace = true }
5959
tokio = { workspace = true }
6060
chrono = { workspace = true }
61+
tracing = { workspace = true }
62+
63+
# `base64` decodes the minted token's JWT payload for a best-effort,
64+
# read-only interop check (did the IdP honor the RFC 8693 `actor_token`
65+
# and emit an `act` claim). We never verify the signature here — the
66+
# token is already trusted, having just come from our own IdP roundtrip.
67+
base64 = "0.22"
6168

6269
# Secret-clearing wrapper for client credentials in memory.
6370
zeroize = { version = "1.8", features = ["zeroize_derive"] }

builtins/plugins/delegator-oauth/src/delegator.rs

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)