1717// subject_token_type=<configured>
1818// audience=<target>
1919// scope=<space-separated requested scopes>
20+ // actor_token=<workload SVID> (only if payload carries one)
21+ // actor_token_type=<configured> (only if actor_token sent)
2022// 3. POST to the IdP's token endpoint with HTTP Basic auth
2123// (client_id / client_secret).
2224// 4. Parse the JSON response: `{ access_token, token_type,
@@ -47,7 +49,7 @@ use serde::Deserialize;
4749use zeroize:: Zeroizing ;
4850
4951use cpex_core:: context:: PluginContext ;
50- use cpex_core:: delegation:: { DelegationPayload , TokenDelegateHook } ;
52+ use cpex_core:: delegation:: { DelegationPayload , DelegationSubject , TokenDelegateHook } ;
5153use cpex_core:: error:: { PluginError , PluginViolation } ;
5254use cpex_core:: extensions:: raw_credentials:: { DelegationMode , RawDelegatedToken } ;
5355use cpex_core:: hooks:: payload:: Extensions ;
@@ -60,6 +62,12 @@ use super::config::OAuthDelegatorConfig;
6062/// `grant_type` in the form-encoded request body.
6163const GRANT_TYPE_TOKEN_EXCHANGE : & str = "urn:ietf:params:oauth:grant-type:token-exchange" ;
6264
65+ /// RFC 6749 §4.4 client-credentials grant — "give me a token as
66+ /// myself". Used when the delegation subject is the gateway: there is
67+ /// no inbound credential to exchange, and the gateway's identity is
68+ /// the OAuth client identity it already authenticates with.
69+ const GRANT_TYPE_CLIENT_CREDENTIALS : & str = "client_credentials" ;
70+
6371/// Default issued-token-type RFC 8693 returns. We don't rely on it
6472/// for behavior — it's reported back to operators in audit logs
6573/// only.
@@ -224,8 +232,15 @@ impl HookHandler<TokenDelegateHook> for OAuthDelegator {
224232 _ext : & Extensions ,
225233 _ctx : & mut PluginContext ,
226234 ) -> PluginResult < DelegationPayload > {
235+ // `subject: gateway` means *we* are the principal. There is no
236+ // inbound credential to exchange — the gateway's identity is
237+ // its OAuth client identity, which it already proves via the
238+ // Basic auth header below. The standard grant for "give me a
239+ // token as myself" is client_credentials, not token exchange.
240+ let as_gateway = * payload. subject ( ) == DelegationSubject :: Gateway ;
241+
227242 let bearer = payload. bearer_token ( ) ;
228- if bearer. is_empty ( ) {
243+ if bearer. is_empty ( ) && !as_gateway {
229244 return PluginResult :: deny ( PluginViolation :: new (
230245 "delegation.bad_request" ,
231246 "DelegationPayload carried an empty bearer_token — outbound \
@@ -243,17 +258,45 @@ impl HookHandler<TokenDelegateHook> for OAuthDelegator {
243258
244259 let scope = Self :: requested_scopes ( payload) ;
245260
246- // Build the form-encoded body. RFC 8693 §2.1.
247- let mut form: Vec < ( & str , & str ) > = vec ! [
248- ( "grant_type" , GRANT_TYPE_TOKEN_EXCHANGE ) ,
249- ( "subject_token" , bearer) ,
250- ( "subject_token_type" , & self . typed. subject_token_type) ,
251- ( "audience" , audience) ,
252- ] ;
261+ // Build the form-encoded body: RFC 6749 §4.4 for the gateway
262+ // acting as itself, RFC 8693 §2.1 for every exchange on behalf
263+ // of somebody else.
264+ let mut form: Vec < ( & str , & str ) > = if as_gateway {
265+ vec ! [
266+ ( "grant_type" , GRANT_TYPE_CLIENT_CREDENTIALS ) ,
267+ ( "audience" , audience) ,
268+ ]
269+ } else {
270+ vec ! [
271+ ( "grant_type" , GRANT_TYPE_TOKEN_EXCHANGE ) ,
272+ ( "subject_token" , bearer) ,
273+ ( "subject_token_type" , & self . typed. subject_token_type) ,
274+ ( "audience" , audience) ,
275+ ]
276+ } ;
253277 if !scope. is_empty ( ) {
254278 form. push ( ( "scope" , & scope) ) ;
255279 }
256280
281+ // RFC 8693 §2.1 actor_token. Present only when the invoker
282+ // attached one (sourced from the inbound SVID in
283+ // `RawCredentialsExtension[CallerWorkload]`). Including it
284+ // makes the IdP mint a token carrying `act` = actor alongside
285+ // `sub` = subject — the delegation is recorded in the token
286+ // itself. Absent, the exchange stays single-token.
287+ //
288+ // Skipped entirely under client_credentials: `actor_token` is
289+ // a token-exchange parameter and has no meaning in RFC 6749
290+ // §4.4, so sending it would be malformed. A route that wants
291+ // the gateway as principal *and* the calling agent recorded in
292+ // `act` needs a real subject credential for the gateway —
293+ // i.e. its own SVID — rather than client_credentials.
294+ let actor_token = payload. actor_token ( ) ;
295+ if !actor_token. is_empty ( ) && !as_gateway {
296+ form. push ( ( "actor_token" , actor_token) ) ;
297+ form. push ( ( "actor_token_type" , & self . typed . actor_token_type ) ) ;
298+ }
299+
257300 // POST to the IdP. Basic auth carries our client credentials.
258301 let response = match self
259302 . http
@@ -385,7 +428,7 @@ impl HookHandler<TokenDelegateHook> for OAuthDelegator {
385428
386429 let mut updated = payload. clone ( ) ;
387430 updated. delegated_token = Some ( token) ;
388- updated. delegation_mode = Some ( DelegationMode :: OnBehalfOfUser ) ;
431+ updated. delegation_mode = Some ( mode_for_subject ( payload . subject ( ) ) ) ;
389432 updated. minted_at = Some ( Utc :: now ( ) ) ;
390433 if let Some ( issued) = parsed. issued_token_type {
391434 updated. metadata . insert (
@@ -403,6 +446,30 @@ impl HookHandler<TokenDelegateHook> for OAuthDelegator {
403446 }
404447}
405448
449+ /// Who the minted token speaks for, derived from the exchange's
450+ /// subject rather than declared independently of it.
451+ ///
452+ /// A `CallerWorkload` subject means no user was in the picture — the
453+ /// *calling agent* exchanged its own SPIFFE JWT-SVID, so the
454+ /// resulting credential speaks for that agent. `Gateway` means we
455+ /// are the principal. Everything else (a user token, an OAuth client
456+ /// token) is the ordinary on-behalf-of shape.
457+ ///
458+ /// This matters beyond bookkeeping: `apply_to_extensions` keys the
459+ /// delegated-token cache off the mode, so calling a workload-subject
460+ /// exchange `OnBehalfOfUser` would file the token under a user
461+ /// identity that never participated.
462+ fn mode_for_subject ( subject : & DelegationSubject ) -> DelegationMode {
463+ match subject {
464+ DelegationSubject :: CallerWorkload => DelegationMode :: AsCallerWorkload ,
465+ DelegationSubject :: Gateway => DelegationMode :: AsGateway ,
466+ // `DelegationSubject` is #[non_exhaustive]; User, Client and
467+ // any future variant all describe a principal the gateway is
468+ // acting *for*, so on-behalf-of stays the safe default.
469+ _ => DelegationMode :: OnBehalfOfUser ,
470+ }
471+ }
472+
406473// Silence unused-import warning when only a subset of these is
407474// reached in any given config path. Kept as a single place so the
408475// crate's surface is visible at a glance.
0 commit comments