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
40 changes: 40 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,28 @@ 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,

/// The `client_assertion_type` used in leg 1 of a workload
/// delegation (`subject: caller_workload`), where the calling
/// agent authenticates by presenting its JWT-SVID as an RFC 7523
/// client assertion rather than a secret. Defaults to the
/// SPIFFE-specific URN from draft-ietf-oauth-spiffe-client-auth —
/// NOT the generic `...:jwt-bearer` — because that's what a SPIFFE
/// authorization server (e.g. Keycloak's SPIFFE provider) expects.
/// Only consulted on the `caller_workload` path; every other
/// subject authenticates with the client secret as before.
#[serde(default = "default_workload_assertion_type")]
pub workload_assertion_type: String,
}

/// Where the gateway's OAuth client secret is loaded from. Three
Expand All @@ -88,6 +110,14 @@ 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_workload_assertion_type() -> String {
"urn:ietf:params:oauth:client-assertion-type:jwt-spiffe".to_string()
}

fn default_timeout_seconds() -> u64 {
5
}
Expand Down Expand Up @@ -137,6 +167,16 @@ 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");
// workload_assertion_type defaults to the SPIFFE-specific
// client-assertion URN (leg 1 of a caller_workload delegation).
assert_eq!(
cfg.workload_assertion_type,
"urn:ietf:params:oauth:client-assertion-type:jwt-spiffe"
);
}

#[test]
Expand Down
198 changes: 188 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 All @@ -39,6 +41,7 @@
// scopes don't include all
// requested permissions

use std::borrow::Cow;
use std::sync::Arc;

use async_trait::async_trait;
Expand All @@ -47,7 +50,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 +63,13 @@ 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 `this_workload` (this
/// CPEX instance itself): there is no inbound credential to exchange,
/// and its 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 @@ -183,6 +193,85 @@ impl OAuthDelegator {
}
scopes.join(" ")
}

/// Leg 1 of a workload delegation (`subject: caller_workload`):
/// authenticate the calling agent by presenting its JWT-SVID as an
/// RFC 7523 client assertion, and return the IdP-issued base token.
///
/// There is no Basic auth and no `client_id` — the assertion *is*
/// the client credential, and the IdP resolves which client from
/// the SVID's `sub` (draft-ietf-oauth-spiffe-client-auth). The base
/// token this returns then becomes the `subject_token` of the
/// ordinary exchange (leg 2), which is where the downstream
/// audience/scope — the authority the agent itself lacks — is
/// actually granted. Splitting it this way is what keeps the
/// enforcement point, not the agent, as the holder of downstream authority.
///
/// Errors map to the same `delegation.*` violation codes the
/// exchange uses, so a failed leg 1 denies the whole delegation.
async fn mint_base_token(&self, svid: &str) -> Result<String, PluginViolation> {
let form = [
("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS),
(
"client_assertion_type",
self.typed.workload_assertion_type.as_str(),
),
("client_assertion", svid),
];

let response = match self
.http
.post(&self.typed.token_endpoint)
.form(&form)
.send()
.await
{
Ok(r) => r,
Err(e) if e.is_timeout() => {
return Err(PluginViolation::new(
"delegation.idp_timeout",
format!(
"workload client_assertion to {} timed out",
self.typed.token_endpoint
),
));
},
Err(e) => {
return Err(PluginViolation::new(
"delegation.idp_unreachable",
format!(
"workload client_assertion POST to {} failed: {e}",
self.typed.token_endpoint
),
));
},
};

let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
let reason = match serde_json::from_str::<TokenErrorResponse>(&body) {
Ok(err) => {
let mut reason = err.error.clone();
if let Some(desc) = err.error_description {
reason.push_str(": ");
reason.push_str(&desc);
}
reason
},
Err(_) => format!("IdP returned {status}: {body}"),
};
return Err(PluginViolation::new("delegation.idp_rejected", reason));
}

match response.json::<TokenExchangeResponse>().await {
Ok(parsed) => Ok(parsed.access_token),
Err(e) => Err(PluginViolation::new(
"delegation.bad_response",
format!("workload client_assertion response wasn't valid token JSON: {e}"),
)),
}
}
}

/// Subset of the RFC 8693 response we care about.
Expand Down Expand Up @@ -224,8 +313,23 @@ impl HookHandler<TokenDelegateHook> for OAuthDelegator {
_ext: &Extensions,
_ctx: &mut PluginContext,
) -> PluginResult<DelegationPayload> {
// `subject: this_workload` means *we* are the principal. There
// is no inbound credential to exchange — this instance'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_this_workload = *payload.subject() == DelegationSubject::ThisWorkload;

// `subject: caller_workload` means the calling agent acts as
// itself, and `bearer` is its JWT-SVID. An SVID is a *client
// credential*, not a `subject_token` — an authorization server
// won't accept it as an exchange subject — so leg 1 below trades
// it for an ordinary IdP token that the exchange (leg 2) can
// then scope down.
let is_workload = *payload.subject() == DelegationSubject::CallerWorkload;

let bearer = payload.bearer_token();
if bearer.is_empty() {
if bearer.is_empty() && !as_this_workload {
return PluginResult::deny(PluginViolation::new(
"delegation.bad_request",
"DelegationPayload carried an empty bearer_token — outbound \
Expand All @@ -243,17 +347,67 @@ 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),
];
// Leg 1 (workload only): the SVID in `bearer` authenticates the
// agent as a client; mint the IdP base token here and let the
// exchange below run on it. Every other subject exchanges its
// own `bearer` directly. `Cow` avoids cloning the (already
// borrowed) bearer on the non-workload path.
let subject_token: Cow<str> = if is_workload {
match self.mint_base_token(bearer).await {
Ok(token) => Cow::Owned(token),
Err(violation) => return PluginResult::deny(violation),
}
} else {
Cow::Borrowed(bearer)
};

// Build the form-encoded body: RFC 6749 §4.4 for this instance
// acting as itself, RFC 8693 §2.1 for every exchange on behalf
// of somebody else.
let mut form: Vec<(&str, &str)> = if as_this_workload {
vec![
("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS),
("audience", audience),
]
} else {
vec![
("grant_type", GRANT_TYPE_TOKEN_EXCHANGE),
// On the workload path this is the leg-1 base token, not
// the raw SVID; on every other path it's the caller's
// own bearer, unchanged.
("subject_token", subject_token.as_ref()),
("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
// this instance as principal *and* the calling agent recorded in
// `act` needs a real subject credential for this instance —
// i.e. its own SVID — rather than client_credentials.
//
// Also skipped on the workload path: there the workload *is* the
// subject (via the leg-1 base token), so there is no separate
// actor to record. `actor_token` belongs to the on-behalf-of
// shape (a user subject with the calling agent as actor).
let actor_token = payload.actor_token();
if !actor_token.is_empty() && !as_this_workload && !is_workload {
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 +539,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 +557,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. `ThisWorkload` 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::ThisWorkload => DelegationMode::AsThisWorkload,
// `DelegationSubject` is #[non_exhaustive]; User, Client and
// any future variant all describe a principal this instance 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