Skip to content

Commit 556de9d

Browse files
committed
feat(provider): add ability to request token exchange instead of client credentials as OAuth grant_type
Signed-off-by: Gordon Sim <gsim@redhat.com>
1 parent 48a7d09 commit 556de9d

39 files changed

Lines changed: 3446 additions & 293 deletions

.agents/skills/debug-openshell-cluster/SKILL.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,14 +180,20 @@ even when local Helm values disable TLS.
180180

181181
If `server.providerTokenGrants.spiffe.enabled=true`, the gateway should still
182182
render `[openshell.gateway.gateway_jwt]` and mount the `sandbox-jwt` Secret.
183-
SPIRE is used only by sandbox pods for dynamic provider token grants. Verify
184-
that SPIRE is installed, the CSI driver is available, and the Kubernetes driver
185-
config includes `provider_spiffe_workload_api_socket_path`:
183+
SPIRE is used by both the gateway and sandbox supervisors for dynamic provider
184+
token grants. The gateway pod must mount the `spiffe-workload-api` CSI volume
185+
and set `OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`; sandbox pods must
186+
receive the matching Workload API socket from the Kubernetes driver config.
187+
The gateway verifies supervisor JWT-SVIDs from JWT bundles fetched through this
188+
Workload API socket, not from the SPIRE OIDC discovery endpoint.
189+
Verify that SPIRE is installed, the CSI driver is available, and the Kubernetes
190+
driver config includes `provider_spiffe_workload_api_socket_path`:
186191

187192
```bash
188193
helm -n openshell get values openshell | grep -E 'providerTokenGrants|workloadApiSocketPath'
189194
kubectl get pods -A | grep -E 'spire|spiffe'
190195
kubectl -n openshell get configmap openshell-config -o yaml | grep provider_spiffe_workload_api_socket_path
196+
kubectl -n openshell get pod -l app.kubernetes.io/name=helm-chart -o jsonpath="{.items[*].spec.containers[*].env[?(@.name==\"OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET\")].value}{\"\n\"}"
191197
```
192198

193199
Sandbox pods using provider token grants should have an

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ sha2 = "0.10"
8888
rand = "0.9"
8989
jsonwebtoken = "9"
9090
getrandom = "0.3"
91-
spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "tracing"] }
91+
spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "jwt-verify-rust-crypto", "tracing"] }
9292

9393
# Filesystem embedding
9494
include_dir = "0.7"

architecture/sandbox.md

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,29 @@ when policy allows the target endpoint. Secrets must not be logged in OCSF or
8080
plain tracing output.
8181

8282
Provider profiles can also declare dynamic token grants. For matching HTTP
83-
endpoints, the supervisor obtains a SPIFFE JWT-SVID from the local Workload API,
84-
exchanges it for an OAuth2 access token, caches the token, and injects it as an
85-
`Authorization: Bearer` header before forwarding the request. Token grant
86-
endpoints are HTTPS-only except for loopback and Kubernetes service DNS hosts,
87-
and returned access tokens must be bearer-compatible before they are cached or
88-
injected. Token response lifetimes are capped and cached with an expiry margin
89-
unless a profile supplies an explicit cache TTL override.
83+
endpoints, the supervisor obtains or exchanges OAuth2 access tokens, caches
84+
them, and injects them before forwarding the request. `client_credentials`
85+
grants use the supervisor SPIFFE JWT-SVID directly as the client assertion.
86+
`token_exchange` grants ask the gateway to broker an intermediate token using a
87+
stored provider subject credential and the gateway's own SPIFFE JWT-SVID; the
88+
supervisor then exchanges that intermediate token for the final upstream token
89+
using its own JWT-SVID. The gateway validates that its own JWT-SVID has the
90+
requested audience, a SPIFFE subject, and a non-expired `exp` claim when
91+
present. It also validates that the stored subject credential is declared by the
92+
provider profile, and that the supervisor JWT-SVID is a well-formed
93+
three-segment JWT with a SPIFFE subject in the same trust domain as the gateway
94+
SVID. The gateway verifies the supervisor JWT-SVID signature with JWT bundles
95+
fetched from its SPIFFE Workload API. Token grant endpoints are HTTPS-only
96+
except for loopback and Kubernetes service DNS hosts, and returned access tokens
97+
must be bearer-compatible before they are cached or injected. Token response
98+
lifetimes are capped and cached with an expiry margin unless a profile supplies
99+
an explicit cache TTL override. Cache entries are scoped by the sandbox provider
100+
environment revision so provider credential updates miss the old token cache
101+
without changing endpoint matching semantics. Gateway-brokered intermediate
102+
tokens are cached separately by provider resource version, supervisor SPIFFE
103+
subject, and gateway SPIFFE subject, and their cache lifetime is capped by the
104+
intermediate token response, stored subject-token expiry, and supervisor SVID
105+
expiry.
90106

91107
## Connect and Logs
92108

crates/openshell-cli/src/main.rs

Lines changed: 165 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,7 @@ impl From<CliEditor> for openshell_cli::ssh::Editor {
716716
#[derive(Subcommand, Debug)]
717717
enum ProviderCommands {
718718
/// Create a provider config.
719-
#[command(group = clap::ArgGroup::new("cred_source").required(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
719+
#[command(group = clap::ArgGroup::new("cred_source").required(true).multiple(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials", "from_oidc_token"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
720720
Create {
721721
/// Provider name.
722722
#[arg(long)]
@@ -744,8 +744,12 @@ enum ProviderCommands {
744744
#[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "credentials", "runtime_credentials"])]
745745
from_gcloud_adc: bool,
746746

747+
/// Store the active gateway OIDC access token as the named provider credential.
748+
#[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "from_gcloud_adc", "runtime_credentials"])]
749+
from_oidc_token: bool,
750+
747751
/// Create a provider whose required credentials are resolved at runtime by the gateway/sandbox.
748-
#[arg(long, conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc"])]
752+
#[arg(long, conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc", "from_oidc_token"])]
749753
runtime_credentials: bool,
750754

751755
/// Provider config key/value pair.
@@ -805,9 +809,13 @@ enum ProviderCommands {
805809
name: String,
806810

807811
/// Re-discover credentials from existing local state (e.g. env vars, config files).
808-
#[arg(long, conflicts_with = "credentials")]
812+
#[arg(long, conflicts_with_all = ["credentials", "from_oidc_token"])]
809813
from_existing: bool,
810814

815+
/// Store the active gateway OIDC access token as the named provider credential.
816+
#[arg(long, conflicts_with = "from_existing")]
817+
from_oidc_token: bool,
818+
811819
/// Provider credential pair (`KEY=VALUE`) or env lookup key (`KEY`).
812820
#[arg(
813821
long = "credential",
@@ -2845,20 +2853,44 @@ async fn main() -> Result<()> {
28452853
from_existing,
28462854
credentials,
28472855
from_gcloud_adc,
2856+
from_oidc_token,
28482857
runtime_credentials,
28492858
config,
28502859
} => {
2851-
run::provider_create_with_options(
2852-
endpoint,
2853-
&name,
2854-
provider_type.as_str(),
2860+
let selected_sources = [
28552861
from_existing,
2856-
&credentials,
28572862
from_gcloud_adc,
2863+
from_oidc_token,
28582864
runtime_credentials,
2859-
&config,
2860-
&tls,
2861-
)
2865+
]
2866+
.into_iter()
2867+
.filter(|selected| *selected)
2868+
.count();
2869+
if selected_sources > 1 {
2870+
return Err(miette::miette!(
2871+
"--from-existing, --from-gcloud-adc, --from-oidc-token, and --runtime-credentials are mutually exclusive"
2872+
));
2873+
}
2874+
let credential_source = if from_existing {
2875+
run::ProviderCreateCredentialSource::Existing
2876+
} else if from_gcloud_adc {
2877+
run::ProviderCreateCredentialSource::GcloudAdc
2878+
} else if from_oidc_token {
2879+
run::ProviderCreateCredentialSource::OidcToken
2880+
} else if runtime_credentials {
2881+
run::ProviderCreateCredentialSource::Runtime
2882+
} else {
2883+
run::ProviderCreateCredentialSource::ExplicitCredentials
2884+
};
2885+
run::provider_create_with_options(run::ProviderCreateOptions {
2886+
server: endpoint,
2887+
name: &name,
2888+
provider_type: provider_type.as_str(),
2889+
credentials: &credentials,
2890+
credential_source,
2891+
config: &config,
2892+
tls: &tls,
2893+
})
28622894
.await?;
28632895
}
28642896
ProviderCommands::Refresh(command) => match command {
@@ -2957,19 +2989,21 @@ async fn main() -> Result<()> {
29572989
ProviderCommands::Update {
29582990
name,
29592991
from_existing,
2992+
from_oidc_token,
29602993
credentials,
29612994
config,
29622995
credential_expires_at,
29632996
} => {
2964-
run::provider_update(
2965-
endpoint,
2966-
&name,
2997+
run::provider_update(run::ProviderUpdateOptions {
2998+
server: endpoint,
2999+
name: &name,
29673000
from_existing,
2968-
&credentials,
2969-
&config,
2970-
&credential_expires_at,
2971-
&tls,
2972-
)
3001+
from_oidc_token,
3002+
credentials: &credentials,
3003+
config: &config,
3004+
credential_expires_at: &credential_expires_at,
3005+
tls: &tls,
3006+
})
29733007
.await?;
29743008
}
29753009
ProviderCommands::Delete { names } => {
@@ -4050,6 +4084,68 @@ mod tests {
40504084
}
40514085
}
40524086

4087+
#[test]
4088+
fn provider_create_accepts_from_oidc_token_destination_credential() {
4089+
let cli = Cli::try_parse_from([
4090+
"openshell",
4091+
"provider",
4092+
"create",
4093+
"--name",
4094+
"custom-api",
4095+
"--type",
4096+
"custom-api",
4097+
"--from-oidc-token",
4098+
"--credential",
4099+
"user_oidc_token",
4100+
])
4101+
.expect("provider create should parse from oidc token");
4102+
4103+
match cli.command {
4104+
Some(Commands::Provider {
4105+
command:
4106+
Some(ProviderCommands::Create {
4107+
name,
4108+
provider_type,
4109+
from_oidc_token,
4110+
credentials,
4111+
..
4112+
}),
4113+
}) => {
4114+
assert_eq!(name, "custom-api");
4115+
assert_eq!(provider_type, "custom-api");
4116+
assert!(from_oidc_token);
4117+
assert_eq!(credentials, vec!["user_oidc_token"]);
4118+
}
4119+
other => panic!("expected provider create command, got: {other:?}"),
4120+
}
4121+
}
4122+
4123+
#[test]
4124+
fn provider_create_accepts_from_oidc_token_without_credential() {
4125+
let cli = Cli::try_parse_from([
4126+
"openshell",
4127+
"provider",
4128+
"create",
4129+
"--name",
4130+
"custom-api",
4131+
"--type",
4132+
"custom-api",
4133+
"--from-oidc-token",
4134+
])
4135+
.expect("provider create should parse inferred oidc token destination");
4136+
4137+
assert!(matches!(
4138+
cli.command,
4139+
Some(Commands::Provider {
4140+
command: Some(ProviderCommands::Create {
4141+
from_oidc_token: true,
4142+
credentials,
4143+
..
4144+
})
4145+
}) if credentials.is_empty()
4146+
));
4147+
}
4148+
40534149
#[test]
40544150
fn provider_create_rejects_from_gcloud_adc_with_from_existing() {
40554151
let err = Cli::try_parse_from([
@@ -4210,6 +4306,56 @@ mod tests {
42104306
));
42114307
}
42124308

4309+
#[test]
4310+
fn provider_update_accepts_from_oidc_token_destination_credential() {
4311+
let cli = Cli::try_parse_from([
4312+
"openshell",
4313+
"provider",
4314+
"update",
4315+
"custom-api",
4316+
"--from-oidc-token",
4317+
"--credential",
4318+
"user_oidc_token",
4319+
])
4320+
.expect("provider update should parse from oidc token");
4321+
4322+
assert!(matches!(
4323+
cli.command,
4324+
Some(Commands::Provider {
4325+
command: Some(ProviderCommands::Update {
4326+
name,
4327+
from_oidc_token: true,
4328+
credentials,
4329+
..
4330+
})
4331+
}) if name == "custom-api" && credentials == vec!["user_oidc_token"]
4332+
));
4333+
}
4334+
4335+
#[test]
4336+
fn provider_update_accepts_from_oidc_token_without_credential() {
4337+
let cli = Cli::try_parse_from([
4338+
"openshell",
4339+
"provider",
4340+
"update",
4341+
"custom-api",
4342+
"--from-oidc-token",
4343+
])
4344+
.expect("provider update should parse inferred oidc token destination");
4345+
4346+
assert!(matches!(
4347+
cli.command,
4348+
Some(Commands::Provider {
4349+
command: Some(ProviderCommands::Update {
4350+
name,
4351+
from_oidc_token: true,
4352+
credentials,
4353+
..
4354+
})
4355+
}) if name == "custom-api" && credentials.is_empty()
4356+
));
4357+
}
4358+
42134359
#[test]
42144360
fn provider_refresh_config_accepts_rfc3339_credential_expiry() {
42154361
let cli = Cli::try_parse_from([

0 commit comments

Comments
 (0)