Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions builtins/plugins/delegator-oauth/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ pub struct OAuthDelegatorConfig {
/// deployments must leave this at the default (`false`).
#[serde(default)]
pub insecure_http: bool,

/// The `actor_token_type` we tell the IdP the RFC 8693
/// `actor_token` is — a token-type URN. Defaults to
/// `...:token-type:jwt` because the actor is almost always a
/// JWT-SVID. Only consulted when the `DelegationPayload` carries a
/// non-empty `actor_token` (attached upstream by the invoker from
/// the inbound workload SVID); otherwise the exchange stays
/// single-token and behaves exactly as before.
#[serde(default = "default_actor_token_type")]
pub actor_token_type: String,
}

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

fn default_actor_token_type() -> String {
"urn:ietf:params:oauth:token-type:jwt".to_string()
}

fn default_timeout_seconds() -> u64 {
5
}
Expand Down Expand Up @@ -137,6 +151,10 @@ mod tests {
assert_eq!(cfg.client_id, "gateway");
assert_eq!(cfg.timeout_seconds, 5);
assert_eq!(cfg.default_outbound_header, "Authorization");
// actor_token_type defaults to the JWT token-type URN (the
// actor is almost always a JWT-SVID); only used when the
// payload carries a non-empty actor_token.
assert_eq!(cfg.actor_token_type, "urn:ietf:params:oauth:token-type:jwt");
}

#[test]
Expand Down
87 changes: 77 additions & 10 deletions builtins/plugins/delegator-oauth/src/delegator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
// subject_token_type=<configured>
// audience=<target>
// scope=<space-separated requested scopes>
// actor_token=<workload SVID> (only if payload carries one)
// actor_token_type=<configured> (only if actor_token sent)
// 3. POST to the IdP's token endpoint with HTTP Basic auth
// (client_id / client_secret).
// 4. Parse the JSON response: `{ access_token, token_type,
Expand Down Expand Up @@ -47,7 +49,7 @@ use serde::Deserialize;
use zeroize::Zeroizing;

use cpex_core::context::PluginContext;
use cpex_core::delegation::{DelegationPayload, TokenDelegateHook};
use cpex_core::delegation::{DelegationPayload, DelegationSubject, TokenDelegateHook};
use cpex_core::error::{PluginError, PluginViolation};
use cpex_core::extensions::raw_credentials::{DelegationMode, RawDelegatedToken};
use cpex_core::hooks::payload::Extensions;
Expand All @@ -60,6 +62,12 @@ use super::config::OAuthDelegatorConfig;
/// `grant_type` in the form-encoded request body.
const GRANT_TYPE_TOKEN_EXCHANGE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";

/// RFC 6749 §4.4 client-credentials grant — "give me a token as
/// myself". Used when the delegation subject is the gateway: there is
/// no inbound credential to exchange, and the gateway's identity is
/// the OAuth client identity it already authenticates with.
const GRANT_TYPE_CLIENT_CREDENTIALS: &str = "client_credentials";

/// Default issued-token-type RFC 8693 returns. We don't rely on it
/// for behavior — it's reported back to operators in audit logs
/// only.
Expand Down Expand Up @@ -224,8 +232,15 @@ impl HookHandler<TokenDelegateHook> for OAuthDelegator {
_ext: &Extensions,
_ctx: &mut PluginContext,
) -> PluginResult<DelegationPayload> {
// `subject: gateway` means *we* are the principal. There is no
// inbound credential to exchange — the gateway's identity is
// its OAuth client identity, which it already proves via the
// Basic auth header below. The standard grant for "give me a
// token as myself" is client_credentials, not token exchange.
let as_gateway = *payload.subject() == DelegationSubject::Gateway;

let bearer = payload.bearer_token();
if bearer.is_empty() {
if bearer.is_empty() && !as_gateway {
return PluginResult::deny(PluginViolation::new(
"delegation.bad_request",
"DelegationPayload carried an empty bearer_token — outbound \
Expand All @@ -243,17 +258,45 @@ impl HookHandler<TokenDelegateHook> for OAuthDelegator {

let scope = Self::requested_scopes(payload);

// Build the form-encoded body. RFC 8693 §2.1.
let mut form: Vec<(&str, &str)> = vec![
("grant_type", GRANT_TYPE_TOKEN_EXCHANGE),
("subject_token", bearer),
("subject_token_type", &self.typed.subject_token_type),
("audience", audience),
];
// Build the form-encoded body: RFC 6749 §4.4 for the gateway
// acting as itself, RFC 8693 §2.1 for every exchange on behalf
// of somebody else.
let mut form: Vec<(&str, &str)> = if as_gateway {
vec![
("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS),
("audience", audience),
]
} else {
vec![
("grant_type", GRANT_TYPE_TOKEN_EXCHANGE),
("subject_token", bearer),
("subject_token_type", &self.typed.subject_token_type),
("audience", audience),
]
};
if !scope.is_empty() {
form.push(("scope", &scope));
}

// RFC 8693 §2.1 actor_token. Present only when the invoker
// attached one (sourced from the inbound SVID in
// `RawCredentialsExtension[CallerWorkload]`). Including it
// makes the IdP mint a token carrying `act` = actor alongside
// `sub` = subject — the delegation is recorded in the token
// itself. Absent, the exchange stays single-token.
//
// Skipped entirely under client_credentials: `actor_token` is
// a token-exchange parameter and has no meaning in RFC 6749
// §4.4, so sending it would be malformed. A route that wants
// the gateway as principal *and* the calling agent recorded in
// `act` needs a real subject credential for the gateway —
// i.e. its own SVID — rather than client_credentials.
let actor_token = payload.actor_token();
if !actor_token.is_empty() && !as_gateway {
form.push(("actor_token", actor_token));
form.push(("actor_token_type", &self.typed.actor_token_type));
}

// POST to the IdP. Basic auth carries our client credentials.
let response = match self
.http
Expand Down Expand Up @@ -385,7 +428,7 @@ impl HookHandler<TokenDelegateHook> for OAuthDelegator {

let mut updated = payload.clone();
updated.delegated_token = Some(token);
updated.delegation_mode = Some(DelegationMode::OnBehalfOfUser);
updated.delegation_mode = Some(mode_for_subject(payload.subject()));
updated.minted_at = Some(Utc::now());
if let Some(issued) = parsed.issued_token_type {
updated.metadata.insert(
Expand All @@ -403,6 +446,30 @@ impl HookHandler<TokenDelegateHook> for OAuthDelegator {
}
}

/// Who the minted token speaks for, derived from the exchange's
/// subject rather than declared independently of it.
///
/// A `CallerWorkload` subject means no user was in the picture — the
/// *calling agent* exchanged its own SPIFFE JWT-SVID, so the
/// resulting credential speaks for that agent. `Gateway` means we
/// are the principal. Everything else (a user token, an OAuth client
/// token) is the ordinary on-behalf-of shape.
///
/// This matters beyond bookkeeping: `apply_to_extensions` keys the
/// delegated-token cache off the mode, so calling a workload-subject
/// exchange `OnBehalfOfUser` would file the token under a user
/// identity that never participated.
fn mode_for_subject(subject: &DelegationSubject) -> DelegationMode {
match subject {
DelegationSubject::CallerWorkload => DelegationMode::AsCallerWorkload,
DelegationSubject::Gateway => DelegationMode::AsGateway,
// `DelegationSubject` is #[non_exhaustive]; User, Client and
// any future variant all describe a principal the gateway is
// acting *for*, so on-behalf-of stays the safe default.
_ => DelegationMode::OnBehalfOfUser,
}
}

// Silence unused-import warning when only a subset of these is
// reached in any given config path. Kept as a single place so the
// crate's surface is visible at a glance.
Expand Down
Loading