Skip to content

Commit bddd0d3

Browse files
committed
feat(delegation): RFC 8693 actor tokens + gateway/workload subjects.
Signed-off-by: Teryl Taylor <terylt@ibm.com>
1 parent baa9e17 commit bddd0d3

14 files changed

Lines changed: 1338 additions & 74 deletions

File tree

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ pub struct OAuthDelegatorConfig {
6262
/// deployments must leave this at the default (`false`).
6363
#[serde(default)]
6464
pub insecure_http: bool,
65+
66+
/// The `actor_token_type` we tell the IdP the RFC 8693
67+
/// `actor_token` is — a token-type URN. Defaults to
68+
/// `...:token-type:jwt` because the actor is almost always a
69+
/// JWT-SVID. Only consulted when the `DelegationPayload` carries a
70+
/// non-empty `actor_token` (attached upstream by the invoker from
71+
/// the inbound workload SVID); otherwise the exchange stays
72+
/// single-token and behaves exactly as before.
73+
#[serde(default = "default_actor_token_type")]
74+
pub actor_token_type: String,
6575
}
6676

6777
/// Where the gateway's OAuth client secret is loaded from. Three
@@ -88,6 +98,10 @@ fn default_subject_token_type() -> String {
8898
"urn:ietf:params:oauth:token-type:access_token".to_string()
8999
}
90100

101+
fn default_actor_token_type() -> String {
102+
"urn:ietf:params:oauth:token-type:jwt".to_string()
103+
}
104+
91105
fn default_timeout_seconds() -> u64 {
92106
5
93107
}
@@ -137,6 +151,10 @@ mod tests {
137151
assert_eq!(cfg.client_id, "gateway");
138152
assert_eq!(cfg.timeout_seconds, 5);
139153
assert_eq!(cfg.default_outbound_header, "Authorization");
154+
// actor_token_type defaults to the JWT token-type URN (the
155+
// actor is almost always a JWT-SVID); only used when the
156+
// payload carries a non-empty actor_token.
157+
assert_eq!(cfg.actor_token_type, "urn:ietf:params:oauth:token-type:jwt");
140158
}
141159

142160
#[test]

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

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
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;
4749
use zeroize::Zeroizing;
4850

4951
use cpex_core::context::PluginContext;
50-
use cpex_core::delegation::{DelegationPayload, TokenDelegateHook};
52+
use cpex_core::delegation::{DelegationPayload, DelegationSubject, TokenDelegateHook};
5153
use cpex_core::error::{PluginError, PluginViolation};
5254
use cpex_core::extensions::raw_credentials::{DelegationMode, RawDelegatedToken};
5355
use cpex_core::hooks::payload::Extensions;
@@ -60,6 +62,12 @@ use super::config::OAuthDelegatorConfig;
6062
/// `grant_type` in the form-encoded request body.
6163
const 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

Comments
 (0)