diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index 68ecc7749..c7c442217 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -180,14 +180,20 @@ even when local Helm values disable TLS. If `server.providerTokenGrants.spiffe.enabled=true`, the gateway should still render `[openshell.gateway.gateway_jwt]` and mount the `sandbox-jwt` Secret. -SPIRE is used only by sandbox pods for dynamic provider token grants. Verify -that SPIRE is installed, the CSI driver is available, and the Kubernetes driver -config includes `provider_spiffe_workload_api_socket_path`: +SPIRE is used by both the gateway and sandbox supervisors for dynamic provider +token grants. The gateway pod must mount the `spiffe-workload-api` CSI volume +and set `OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`; sandbox pods must +receive the matching Workload API socket from the Kubernetes driver config. +The gateway verifies supervisor JWT-SVIDs from JWT bundles fetched through this +Workload API socket, not from the SPIRE OIDC discovery endpoint. +Verify that SPIRE is installed, the CSI driver is available, and the Kubernetes +driver config includes `provider_spiffe_workload_api_socket_path`: ```bash helm -n openshell get values openshell | grep -E 'providerTokenGrants|workloadApiSocketPath' kubectl get pods -A | grep -E 'spire|spiffe' kubectl -n openshell get configmap openshell-config -o yaml | grep provider_spiffe_workload_api_socket_path +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\"}" ``` Sandbox pods using provider token grants should have an diff --git a/Cargo.lock b/Cargo.lock index f693acd66..71a2a6ec1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2550,10 +2550,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "base64 0.22.1", + "ed25519-dalek", "getrandom 0.2.17", + "hmac", "js-sys", + "p256", + "p384", + "rand 0.8.6", + "rsa 0.9.10", "serde", "serde_json", + "sha2 0.10.9", "signature 2.2.0", ] @@ -3673,6 +3680,7 @@ dependencies = [ "arc-swap", "async-trait", "axum", + "base64 0.22.1", "bytes", "clap", "futures", @@ -3719,6 +3727,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "spiffe", "sqlx", "tempfile", "thiserror 2.0.18", @@ -5541,6 +5550,7 @@ dependencies = [ "fastrand", "futures", "hyper-util", + "jsonwebtoken 10.3.0", "log", "prost", "prost-types", diff --git a/Cargo.toml b/Cargo.toml index 86025646a..8215f0a7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,7 +88,7 @@ sha2 = "0.10" rand = "0.9" jsonwebtoken = "9" getrandom = "0.3" -spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "tracing"] } +spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "jwt-verify-rust-crypto", "tracing"] } # Filesystem embedding include_dir = "0.7" diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 2552304e1..59248f96d 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -82,13 +82,29 @@ proxy (e.g. Go's `cloud.google.com/go/compute/metadata`). Secrets must not be logged in OCSF or plain tracing output. Provider profiles can also declare dynamic token grants. For matching HTTP -endpoints, the supervisor obtains a SPIFFE JWT-SVID from the local Workload API, -exchanges it for an OAuth2 access token, caches the token, and injects it as an -`Authorization: Bearer` header before forwarding the request. Token grant -endpoints are HTTPS-only except for loopback and Kubernetes service DNS hosts, -and returned access tokens must be bearer-compatible before they are cached or -injected. Token response lifetimes are capped and cached with an expiry margin -unless a profile supplies an explicit cache TTL override. +endpoints, the supervisor obtains or exchanges OAuth2 access tokens, caches +them, and injects them before forwarding the request. `client_credentials` +grants use the supervisor SPIFFE JWT-SVID directly as the client assertion. +`token_exchange` grants ask the gateway to broker an intermediate token using a +stored provider subject credential and the gateway's own SPIFFE JWT-SVID; the +supervisor then exchanges that intermediate token for the final upstream token +using its own JWT-SVID. The gateway validates that its own JWT-SVID has the +requested audience, a SPIFFE subject, and a non-expired `exp` claim when +present. It also validates that the stored subject credential is declared by the +provider profile, and that the supervisor JWT-SVID is a well-formed +three-segment JWT with a SPIFFE subject in the same trust domain as the gateway +SVID. The gateway verifies the supervisor JWT-SVID signature with JWT bundles +fetched from its SPIFFE Workload API. Token grant endpoints are HTTPS-only +except for loopback and Kubernetes service DNS hosts, and returned access tokens +must be bearer-compatible before they are cached or injected. Token response +lifetimes are capped and cached with an expiry margin unless a profile supplies +an explicit cache TTL override. Cache entries are scoped by the sandbox provider +environment revision so provider credential updates miss the old token cache +without changing endpoint matching semantics. Gateway-brokered intermediate +tokens are cached separately by provider resource version, supervisor SPIFFE +subject, and gateway SPIFFE subject, and their cache lifetime is capped by the +intermediate token response, stored subject-token expiry, and supervisor SVID +expiry. ## Connect and Logs diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index fbed00e1a..18d7d9abf 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -747,7 +747,7 @@ impl From for openshell_cli::ssh::Editor { #[derive(Subcommand, Debug)] enum ProviderCommands { /// Create a provider config. - #[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")] + #[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")] Create { /// Provider name. #[arg(long)] @@ -775,8 +775,12 @@ enum ProviderCommands { #[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "credentials", "runtime_credentials"])] from_gcloud_adc: bool, + /// Store the active gateway OIDC access token as the named provider credential. + #[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "from_gcloud_adc", "runtime_credentials"])] + from_oidc_token: bool, + /// Create a provider whose required credentials are resolved at runtime by the gateway/sandbox. - #[arg(long, conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc"])] + #[arg(long, conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc", "from_oidc_token"])] runtime_credentials: bool, /// Provider config key/value pair. @@ -836,9 +840,13 @@ enum ProviderCommands { name: String, /// Re-discover credentials from existing local state (e.g. env vars, config files). - #[arg(long, conflicts_with = "credentials")] + #[arg(long, conflicts_with_all = ["credentials", "from_oidc_token"])] from_existing: bool, + /// Store the active gateway OIDC access token as the named provider credential. + #[arg(long, conflicts_with = "from_existing")] + from_oidc_token: bool, + /// Provider credential pair (`KEY=VALUE`) or env lookup key (`KEY`). #[arg( long = "credential", @@ -2880,20 +2888,44 @@ async fn main() -> Result<()> { from_existing, credentials, from_gcloud_adc, + from_oidc_token, runtime_credentials, config, } => { - run::provider_create_with_options( - endpoint, - &name, - provider_type.as_str(), + let selected_sources = [ from_existing, - &credentials, from_gcloud_adc, + from_oidc_token, runtime_credentials, - &config, - &tls, - ) + ] + .into_iter() + .filter(|selected| *selected) + .count(); + if selected_sources > 1 { + return Err(miette::miette!( + "--from-existing, --from-gcloud-adc, --from-oidc-token, and --runtime-credentials are mutually exclusive" + )); + } + let credential_source = if from_existing { + run::ProviderCreateCredentialSource::Existing + } else if from_gcloud_adc { + run::ProviderCreateCredentialSource::GcloudAdc + } else if from_oidc_token { + run::ProviderCreateCredentialSource::OidcToken + } else if runtime_credentials { + run::ProviderCreateCredentialSource::Runtime + } else { + run::ProviderCreateCredentialSource::ExplicitCredentials + }; + run::provider_create_with_options(run::ProviderCreateOptions { + server: endpoint, + name: &name, + provider_type: provider_type.as_str(), + credentials: &credentials, + credential_source, + config: &config, + tls: &tls, + }) .await?; } ProviderCommands::Refresh(command) => match command { @@ -2992,19 +3024,21 @@ async fn main() -> Result<()> { ProviderCommands::Update { name, from_existing, + from_oidc_token, credentials, config, credential_expires_at, } => { - run::provider_update( - endpoint, - &name, + run::provider_update(run::ProviderUpdateOptions { + server: endpoint, + name: &name, from_existing, - &credentials, - &config, - &credential_expires_at, - &tls, - ) + from_oidc_token, + credentials: &credentials, + config: &config, + credential_expires_at: &credential_expires_at, + tls: &tls, + }) .await?; } ProviderCommands::Delete { names } => { @@ -4106,6 +4140,68 @@ mod tests { } } + #[test] + fn provider_create_accepts_from_oidc_token_destination_credential() { + let cli = Cli::try_parse_from([ + "openshell", + "provider", + "create", + "--name", + "custom-api", + "--type", + "custom-api", + "--from-oidc-token", + "--credential", + "user_oidc_token", + ]) + .expect("provider create should parse from oidc token"); + + match cli.command { + Some(Commands::Provider { + command: + Some(ProviderCommands::Create { + name, + provider_type, + from_oidc_token, + credentials, + .. + }), + }) => { + assert_eq!(name, "custom-api"); + assert_eq!(provider_type, "custom-api"); + assert!(from_oidc_token); + assert_eq!(credentials, vec!["user_oidc_token"]); + } + other => panic!("expected provider create command, got: {other:?}"), + } + } + + #[test] + fn provider_create_accepts_from_oidc_token_without_credential() { + let cli = Cli::try_parse_from([ + "openshell", + "provider", + "create", + "--name", + "custom-api", + "--type", + "custom-api", + "--from-oidc-token", + ]) + .expect("provider create should parse inferred oidc token destination"); + + assert!(matches!( + cli.command, + Some(Commands::Provider { + command: Some(ProviderCommands::Create { + from_oidc_token: true, + credentials, + .. + }) + }) if credentials.is_empty() + )); + } + #[test] fn provider_create_rejects_from_gcloud_adc_with_from_existing() { let err = Cli::try_parse_from([ @@ -4266,6 +4362,56 @@ mod tests { )); } + #[test] + fn provider_update_accepts_from_oidc_token_destination_credential() { + let cli = Cli::try_parse_from([ + "openshell", + "provider", + "update", + "custom-api", + "--from-oidc-token", + "--credential", + "user_oidc_token", + ]) + .expect("provider update should parse from oidc token"); + + assert!(matches!( + cli.command, + Some(Commands::Provider { + command: Some(ProviderCommands::Update { + name, + from_oidc_token: true, + credentials, + .. + }) + }) if name == "custom-api" && credentials == vec!["user_oidc_token"] + )); + } + + #[test] + fn provider_update_accepts_from_oidc_token_without_credential() { + let cli = Cli::try_parse_from([ + "openshell", + "provider", + "update", + "custom-api", + "--from-oidc-token", + ]) + .expect("provider update should parse inferred oidc token destination"); + + assert!(matches!( + cli.command, + Some(Commands::Provider { + command: Some(ProviderCommands::Update { + name, + from_oidc_token: true, + credentials, + .. + }) + }) if name == "custom-api" && credentials.is_empty() + )); + } + #[test] fn provider_refresh_config_accepts_rfc3339_credential_expiry() { let cli = Cli::try_parse_from([ diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs index 379a53112..63981bd28 100644 --- a/crates/openshell-cli/src/oidc_auth.rs +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -259,10 +259,11 @@ pub async fn oidc_refresh_token( Ok(refreshed) } -/// Ensure we have a valid OIDC token for the given gateway, refreshing if needed. -/// -/// Returns the access token string. -pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Result { +/// Ensure we have a valid OIDC token bundle for the given gateway, refreshing if needed. +pub async fn ensure_valid_oidc_token_bundle( + gateway_name: &str, + insecure: bool, +) -> Result { let bundle = openshell_bootstrap::oidc_token::load_oidc_token(gateway_name).ok_or_else(|| { miette::miette!( @@ -272,7 +273,7 @@ pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Resu })?; if !openshell_bootstrap::oidc_token::is_token_expired(&bundle) { - return Ok(bundle.access_token); + return Ok(bundle); } debug!( @@ -281,7 +282,16 @@ pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Resu ); let refreshed = oidc_refresh_token(&bundle, insecure).await?; openshell_bootstrap::oidc_token::store_oidc_token(gateway_name, &refreshed)?; - Ok(refreshed.access_token) + Ok(refreshed) +} + +/// Ensure we have a valid OIDC token for the given gateway, refreshing if needed. +/// +/// Returns the access token string. +pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Result { + Ok(ensure_valid_oidc_token_bundle(gateway_name, insecure) + .await? + .access_token) } // ── Helpers ────────────────────────────────────────────────────────── diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 1c3fd8a82..b8deef387 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -47,13 +47,13 @@ use openshell_core::proto::{ ListProviderProfilesRequest, ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, ListServicesRequest, PlatformEvent, PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshStatus, - ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic, - ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, - RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy, - SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetClusterInferenceRequest, - SettingScope, SettingValue, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, - UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, - exec_sandbox_event, setting_value, tcp_forward_init, + ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrantType, ProviderProfile, + ProviderProfileDiagnostic, ProviderProfileImportItem, RejectDraftChunkRequest, + ResourceRequirements, RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, + SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, ServiceEndpointResponse, + SetClusterInferenceRequest, SettingScope, SettingValue, TcpForwardFrame, TcpForwardInit, + TcpRelayTarget, UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, + WatchSandboxRequest, exec_sandbox_event, setting_value, tcp_forward_init, }; use openshell_core::settings::{self, SettingValueKind}; use openshell_core::{ObjectId, ObjectName}; @@ -4512,6 +4512,135 @@ fn missing_credentials_error(provider_type: &str) -> miette::Report { ) } +async fn provider_credential_from_oidc_token( + credentials: &[String], + profile: Option<&ProviderProfile>, + tls: &TlsOptions, +) -> Result<(HashMap, HashMap)> { + let credential_key = oidc_subject_credential_key(credentials, profile)?; + + let gateway_name = tls.gateway_name().ok_or_else(|| { + miette::miette!("--from-oidc-token requires an active named OIDC gateway") + })?; + let bundle = + crate::oidc_auth::ensure_valid_oidc_token_bundle(gateway_name, tls.gateway_insecure) + .await + .map_err(|err| { + miette::miette!( + "failed to load or refresh OIDC token for gateway '{gateway_name}' while preparing provider credential: {err}" + ) + })?; + + let mut credential_map = HashMap::new(); + credential_map.insert(credential_key.clone(), bundle.access_token); + + let mut credential_expires_at_ms = HashMap::new(); + if let Some(expires_at) = bundle.expires_at { + let expires_at_ms = expires_at + .checked_mul(1000) + .and_then(|value| i64::try_from(value).ok()) + .ok_or_else(|| miette::miette!("stored OIDC token expiry is out of range"))?; + credential_expires_at_ms.insert(credential_key, expires_at_ms); + } + + Ok((credential_map, credential_expires_at_ms)) +} + +fn oidc_subject_credential_key( + credentials: &[String], + profile: Option<&ProviderProfile>, +) -> Result { + if credentials.len() > 1 { + return Err(miette::miette!( + "--from-oidc-token accepts at most one --credential KEY destination" + )); + } + + if let Some(credential) = credentials.first() { + let credential = credential.trim(); + if credential.is_empty() || credential.contains('=') { + return Err(miette::miette!( + "--from-oidc-token requires --credential KEY without an inline value" + )); + } + if let Some(profile) = profile { + ensure_profile_declares_subject_credential(profile, credential)?; + } + return Ok(credential.to_string()); + } + + let Some(profile) = profile else { + return Err(miette::miette!( + "--from-oidc-token requires --credential KEY when the provider profile is unavailable" + )); + }; + + infer_oidc_subject_credential_from_profile(profile) +} + +fn ensure_profile_declares_subject_credential( + profile: &ProviderProfile, + credential: &str, +) -> Result<()> { + let matches = token_exchange_subject_credentials(profile); + if matches.iter().any(|candidate| candidate == credential) { + return Ok(()); + } + + if matches.is_empty() { + return Err(miette::miette!( + "provider profile '{}' does not declare a token_exchange subject credential", + profile.id + )); + } + + Err(miette::miette!( + "credential '{credential}' is not a token_exchange subject credential in provider profile '{}'; expected {}", + profile.id, + matches.join(", ") + )) +} + +fn infer_oidc_subject_credential_from_profile(profile: &ProviderProfile) -> Result { + let matches = token_exchange_subject_credentials(profile); + match matches.as_slice() { + [credential] => Ok(credential.clone()), + [] => Err(miette::miette!( + "provider profile '{}' does not declare a token_exchange subject credential; pass --credential KEY explicitly or use a token_exchange profile", + profile.id + )), + _ => Err(miette::miette!( + "provider profile '{}' declares multiple token_exchange subject credentials ({}); pass --credential KEY", + profile.id, + matches.join(", ") + )), + } +} + +fn token_exchange_subject_credentials(profile: &ProviderProfile) -> Vec { + let mut matches = Vec::new(); + for credential in &profile.credentials { + let Some(token_grant) = credential.token_grant.as_ref() else { + continue; + }; + if ProviderCredentialTokenGrantType::try_from(token_grant.grant_type).ok() + != Some(ProviderCredentialTokenGrantType::TokenExchange) + { + continue; + } + let Some(subject_token) = token_grant.subject_token.as_ref() else { + continue; + }; + if subject_token.source != "provider_credential" || subject_token.credential.is_empty() { + continue; + } + if !matches.contains(&subject_token.credential) { + matches.push(subject_token.credential.clone()); + } + } + matches +} + #[allow(clippy::too_many_arguments)] pub async fn provider_create( server: &str, @@ -4523,40 +4652,71 @@ pub async fn provider_create( config: &[String], tls: &TlsOptions, ) -> Result<()> { - provider_create_with_options( + let credential_source = match (from_existing, from_gcloud_adc) { + (true, true) => { + return Err(miette::miette!( + "--from-gcloud-adc cannot be combined with --from-existing, --from-oidc-token, or --credential; it also cannot be combined with --runtime-credentials" + )); + } + (true, false) => ProviderCreateCredentialSource::Existing, + (false, true) => ProviderCreateCredentialSource::GcloudAdc, + (false, false) => ProviderCreateCredentialSource::ExplicitCredentials, + }; + provider_create_with_options(ProviderCreateOptions { server, name, provider_type, - from_existing, credentials, - from_gcloud_adc, - false, + credential_source, config, tls, - ) + }) .await } -#[allow(clippy::too_many_arguments)] -pub async fn provider_create_with_options( - server: &str, - name: &str, - provider_type: &str, - from_existing: bool, - credentials: &[String], - from_gcloud_adc: bool, - runtime_credentials: bool, - config: &[String], - tls: &TlsOptions, -) -> Result<()> { - if from_gcloud_adc && (from_existing || !credentials.is_empty() || runtime_credentials) { +pub struct ProviderCreateOptions<'a> { + pub server: &'a str, + pub name: &'a str, + pub provider_type: &'a str, + pub credentials: &'a [String], + pub credential_source: ProviderCreateCredentialSource, + pub config: &'a [String], + pub tls: &'a TlsOptions, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProviderCreateCredentialSource { + ExplicitCredentials, + Existing, + GcloudAdc, + OidcToken, + Runtime, +} + +pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> Result<()> { + let ProviderCreateOptions { + server, + name, + provider_type, + credentials, + credential_source, + config, + tls, + } = options; + + let from_existing = credential_source == ProviderCreateCredentialSource::Existing; + let from_gcloud_adc = credential_source == ProviderCreateCredentialSource::GcloudAdc; + let from_oidc_token = credential_source == ProviderCreateCredentialSource::OidcToken; + let runtime_credentials = credential_source == ProviderCreateCredentialSource::Runtime; + + if from_gcloud_adc && !credentials.is_empty() { return Err(miette::miette!( - "--from-gcloud-adc cannot be combined with --from-existing or --credential; it also cannot be combined with --runtime-credentials" + "--from-gcloud-adc cannot be combined with --from-existing, --from-oidc-token, or --credential; it also cannot be combined with --runtime-credentials" )); } - if from_existing && (!credentials.is_empty() || runtime_credentials) { + if from_existing && !credentials.is_empty() { return Err(miette::miette!( - "--from-existing cannot be combined with --credential or --runtime-credentials" + "--from-existing cannot be combined with --credential" )); } if runtime_credentials && !credentials.is_empty() { @@ -4624,7 +4784,17 @@ pub async fn provider_create_with_options( None }; - let mut credential_map = parse_credential_pairs(credentials)?; + let oidc_profile = if from_oidc_token { + Some(fetch_provider_profile(&mut client, &provider_type).await?) + } else { + None + }; + + let (mut credential_map, oidc_credential_expires_at_ms) = if from_oidc_token { + provider_credential_from_oidc_token(credentials, oidc_profile.as_ref(), tls).await? + } else { + (parse_credential_pairs(credentials)?, HashMap::new()) + }; let mut config_map = parse_key_value_pairs(config, "--config")?; if from_existing { @@ -4694,7 +4864,7 @@ pub async fn provider_create_with_options( r#type: provider_type.clone(), credentials: credential_map, config: config_map, - credential_expires_at_ms: HashMap::new(), + credential_expires_at_ms: oidc_credential_expires_at_ms, }), }) .await @@ -5563,26 +5733,65 @@ fn truncate_display(value: &str, max_width: usize) -> String { truncated } -pub async fn provider_update( - server: &str, - name: &str, - from_existing: bool, - credentials: &[String], - config: &[String], - credential_expires_at: &[String], - tls: &TlsOptions, -) -> Result<()> { +pub struct ProviderUpdateOptions<'a> { + pub server: &'a str, + pub name: &'a str, + pub from_existing: bool, + pub from_oidc_token: bool, + pub credentials: &'a [String], + pub config: &'a [String], + pub credential_expires_at: &'a [String], + pub tls: &'a TlsOptions, +} + +pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> { + let ProviderUpdateOptions { + server, + name, + from_existing, + from_oidc_token, + credentials, + config, + credential_expires_at, + tls, + } = options; + if from_existing && !credentials.is_empty() { return Err(miette::miette!( "--from-existing cannot be combined with --credential" )); } + if from_existing && from_oidc_token { + return Err(miette::miette!( + "--from-existing cannot be combined with --from-oidc-token" + )); + } let mut client = grpc_client(server, tls).await?; - let mut credential_map = parse_credential_pairs(credentials)?; + let oidc_profile = if from_oidc_token { + let existing = client + .get_provider(GetProviderRequest { + name: name.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider '{name}' not found"))?; + Some(fetch_provider_profile(&mut client, &existing.r#type).await?) + } else { + None + }; + + let (mut credential_map, oidc_credential_expires_at_ms) = if from_oidc_token { + provider_credential_from_oidc_token(credentials, oidc_profile.as_ref(), tls).await? + } else { + (parse_credential_pairs(credentials)?, HashMap::new()) + }; let mut config_map = parse_key_value_pairs(config, "--config")?; - let credential_expires_at_ms = parse_credential_expiry_pairs(credential_expires_at)?; + let mut credential_expires_at_ms = parse_credential_expiry_pairs(credential_expires_at)?; + credential_expires_at_ms.extend(oidc_credential_expires_at_ms); if from_existing { // Fetch the existing provider to discover its type for credential lookup. diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 7bf8612b4..7531dd906 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -17,7 +17,8 @@ use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, @@ -201,6 +202,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, request: tonic::Request, diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 28a4e6c9c..5e5639001 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -13,10 +13,11 @@ use openshell_cli::{ }; use openshell_core::proto::{ CreateProviderRequest, CreateSshSessionRequest, CreateSshSessionResponse, - DeleteProviderRequest, DeleteProviderResponse, ExecSandboxEvent, ExecSandboxInput, - ExecSandboxRequest, GetProviderRequest, HealthRequest, HealthResponse, ListProvidersRequest, - ListProvidersResponse, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, - ServiceStatus, UpdateProviderRequest, + DeleteProviderRequest, DeleteProviderResponse, ExchangeProviderSubjectTokenRequest, + ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, + GetProviderRequest, HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, + ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, ServiceStatus, + UpdateProviderRequest, open_shell_server::{OpenShell, OpenShellServer}, }; use tempfile::tempdir; @@ -178,6 +179,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 5a6e53eb1..59ccafffb 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -14,7 +14,8 @@ use openshell_core::proto::{ CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, @@ -22,11 +23,12 @@ use openshell_core::proto::{ HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, ListSandboxProvidersRequest, ListSandboxProvidersResponse, ListSandboxesRequest, ListSandboxesResponse, Provider, ProviderCredentialRefresh, ProviderCredentialRefreshStatus, - ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileCredential, - ProviderProfileDiscovery, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, - RotateProviderCredentialRequest, RotateProviderCredentialResponse, Sandbox, SandboxResponse, - SandboxStreamEvent, ServiceStatus, SettingValue, SupervisorMessage, UpdateProviderRequest, - WatchSandboxRequest, setting_value, + ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, + ProviderCredentialTokenGrantSubjectToken, ProviderCredentialTokenGrantType, ProviderProfile, + ProviderProfileCredential, ProviderProfileDiscovery, ProviderResponse, RevokeSshSessionRequest, + RevokeSshSessionResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, + Sandbox, SandboxResponse, SandboxStreamEvent, ServiceStatus, SettingValue, SupervisorMessage, + UpdateProviderRequest, WatchSandboxRequest, setting_value, }; use openshell_core::{ObjectId, ObjectName}; use std::collections::HashMap; @@ -332,6 +334,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, request: tonic::Request, @@ -554,8 +563,8 @@ impl OpenShell for TestOpenShell { &self, request: tonic::Request, ) -> Result, Status> { + let request = request.into_inner(); let provider = request - .into_inner() .provider .ok_or_else(|| Status::invalid_argument("provider is required"))?; @@ -608,7 +617,7 @@ impl OpenShell for TestOpenShell { config: merge(existing.config, provider.config), credential_expires_at_ms: merge_expiry( existing.credential_expires_at_ms, - provider.credential_expires_at_ms, + request.credential_expires_at_ms, ), }; let updated_name = updated.object_name().to_string(); @@ -1047,6 +1056,95 @@ async fn enable_providers_v2(ts: &TestServer) { ); } +async fn register_oidc_token_exchange_profile(ts: &TestServer) { + ts.state.profiles.lock().await.insert( + "oidc-token-exchange".to_string(), + ProviderProfile { + id: "oidc-token-exchange".to_string(), + display_name: "OIDC Token Exchange".to_string(), + credentials: vec![ + ProviderProfileCredential { + name: "user_oidc_token".to_string(), + required: true, + ..Default::default() + }, + ProviderProfileCredential { + name: "api_token".to_string(), + required: true, + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + token_endpoint: "https://issuer.example.com/token".to_string(), + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "user_oidc_token".to_string(), + subject_token_type: "urn:ietf:params:oauth:token-type:access_token" + .to_string(), + }), + ..Default::default() + }), + ..Default::default() + }, + ], + ..Default::default() + }, + ); +} + +async fn register_ambiguous_oidc_token_exchange_profile(ts: &TestServer) { + ts.state.profiles.lock().await.insert( + "ambiguous-oidc-token-exchange".to_string(), + ProviderProfile { + id: "ambiguous-oidc-token-exchange".to_string(), + display_name: "Ambiguous OIDC Token Exchange".to_string(), + credentials: vec![ + ProviderProfileCredential { + name: "user_oidc_token".to_string(), + required: true, + ..Default::default() + }, + ProviderProfileCredential { + name: "admin_oidc_token".to_string(), + required: true, + ..Default::default() + }, + ProviderProfileCredential { + name: "api_token".to_string(), + required: true, + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + token_endpoint: "https://issuer.example.com/token".to_string(), + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "user_oidc_token".to_string(), + subject_token_type: "urn:ietf:params:oauth:token-type:access_token" + .to_string(), + }), + ..Default::default() + }), + ..Default::default() + }, + ProviderProfileCredential { + name: "admin_api_token".to_string(), + required: true, + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + token_endpoint: "https://issuer.example.com/token".to_string(), + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "admin_oidc_token".to_string(), + subject_token_type: "urn:ietf:params:oauth:token-type:access_token" + .to_string(), + }), + ..Default::default() + }), + ..Default::default() + }, + ], + ..Default::default() + }, + ); +} + #[tokio::test] async fn provider_cli_run_functions_support_full_crud_flow() { let ts = run_server().await; @@ -1071,15 +1169,16 @@ async fn provider_cli_run_functions_support_full_crud_flow() { .await .expect("provider list"); - run::provider_update( - &ts.endpoint, - "my-claude", - false, - &["API_KEY=rotated".to_string()], - &["profile=prod".to_string()], - &[], - &ts.tls, - ) + run::provider_update(run::ProviderUpdateOptions { + server: &ts.endpoint, + name: "my-claude", + from_existing: false, + from_oidc_token: false, + credentials: &["API_KEY=rotated".to_string()], + config: &["profile=prod".to_string()], + credential_expires_at: &[], + tls: &ts.tls, + }) .await .expect("provider update"); @@ -1088,6 +1187,205 @@ async fn provider_cli_run_functions_support_full_crud_flow() { .expect("provider delete"); } +#[tokio::test] +async fn provider_create_from_oidc_token_stores_active_gateway_token() { + let ts = run_server().await; + register_oidc_token_exchange_profile(&ts).await; + let xdg_dir = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]); + let gateway_name = "oidc-gateway"; + openshell_bootstrap::oidc_token::store_oidc_token( + gateway_name, + &openshell_bootstrap::oidc_token::OidcTokenBundle { + access_token: "user-access-token".to_string(), + refresh_token: Some("user-refresh-token".to_string()), + expires_at: Some(1_893_456_000), + issuer: "https://issuer.example.com".to_string(), + client_id: "openshell-cli".to_string(), + }, + ) + .expect("store oidc token"); + let tls = ts.tls.with_gateway_name(gateway_name); + + run::provider_create_with_options(run::ProviderCreateOptions { + server: &ts.endpoint, + name: "custom-api", + provider_type: "oidc-token-exchange", + credentials: &[], + credential_source: run::ProviderCreateCredentialSource::OidcToken, + config: &[], + tls: &tls, + }) + .await + .expect("provider create from oidc token"); + + let provider = ts + .state + .providers + .lock() + .await + .get("custom-api") + .cloned() + .expect("provider"); + assert_eq!( + provider.credentials.get("user_oidc_token"), + Some(&"user-access-token".to_string()) + ); + assert_eq!( + provider.credential_expires_at_ms.get("user_oidc_token"), + Some(&1_893_456_000_000) + ); +} + +#[tokio::test] +async fn provider_update_from_oidc_token_replaces_subject_token() { + let ts = run_server().await; + register_oidc_token_exchange_profile(&ts).await; + ts.state.providers.lock().await.insert( + "custom-api".to_string(), + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "provider-id".to_string(), + name: "custom-api".to_string(), + ..Default::default() + }), + r#type: "oidc-token-exchange".to_string(), + credentials: std::iter::once(("user_oidc_token".to_string(), "old-token".to_string())) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + }, + ); + let xdg_dir = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]); + let gateway_name = "oidc-gateway"; + openshell_bootstrap::oidc_token::store_oidc_token( + gateway_name, + &openshell_bootstrap::oidc_token::OidcTokenBundle { + access_token: "new-user-access-token".to_string(), + refresh_token: None, + expires_at: Some(1_893_456_300), + issuer: "https://issuer.example.com".to_string(), + client_id: "openshell-cli".to_string(), + }, + ) + .expect("store oidc token"); + let tls = ts.tls.with_gateway_name(gateway_name); + + run::provider_update(run::ProviderUpdateOptions { + server: &ts.endpoint, + name: "custom-api", + from_existing: false, + from_oidc_token: true, + credentials: &[], + config: &[], + credential_expires_at: &[], + tls: &tls, + }) + .await + .expect("provider update from oidc token"); + + let provider = ts + .state + .providers + .lock() + .await + .get("custom-api") + .cloned() + .expect("provider"); + assert_eq!( + provider.credentials.get("user_oidc_token"), + Some(&"new-user-access-token".to_string()) + ); + assert_eq!( + provider.credential_expires_at_ms.get("user_oidc_token"), + Some(&1_893_456_300_000) + ); +} + +#[tokio::test] +async fn provider_create_from_oidc_token_rejects_non_subject_credential() { + let ts = run_server().await; + register_oidc_token_exchange_profile(&ts).await; + + let err = run::provider_create_with_options(run::ProviderCreateOptions { + server: &ts.endpoint, + name: "custom-api", + provider_type: "oidc-token-exchange", + credentials: &["api_token".to_string()], + credential_source: run::ProviderCreateCredentialSource::OidcToken, + config: &[], + tls: &ts.tls, + }) + .await + .expect_err("wrong subject credential should fail"); + + let message = err.to_string(); + assert!(message.contains("is not a token_exchange subject credential")); + assert!(message.contains("user_oidc_token")); +} + +#[tokio::test] +async fn provider_create_from_oidc_token_requires_credential_for_ambiguous_profile() { + let ts = run_server().await; + register_ambiguous_oidc_token_exchange_profile(&ts).await; + + let err = run::provider_create_with_options(run::ProviderCreateOptions { + server: &ts.endpoint, + name: "custom-api", + provider_type: "ambiguous-oidc-token-exchange", + credentials: &[], + credential_source: run::ProviderCreateCredentialSource::OidcToken, + config: &[], + tls: &ts.tls, + }) + .await + .expect_err("ambiguous subject credential should fail"); + + let message = err.to_string(); + assert!(message.contains("declares multiple token_exchange subject credentials")); + assert!(message.contains("user_oidc_token")); + assert!(message.contains("admin_oidc_token")); + assert!(message.contains("--credential KEY")); +} + +#[tokio::test] +async fn provider_create_from_oidc_token_reports_expired_token_without_refresh_token() { + let ts = run_server().await; + register_oidc_token_exchange_profile(&ts).await; + let xdg_dir = tempfile::tempdir().unwrap(); + let _xdg = EnvVarGuard::set(&[("XDG_CONFIG_HOME", xdg_dir.path().to_str().unwrap())]); + let gateway_name = "oidc-gateway"; + openshell_bootstrap::oidc_token::store_oidc_token( + gateway_name, + &openshell_bootstrap::oidc_token::OidcTokenBundle { + access_token: "expired-user-access-token".to_string(), + refresh_token: None, + expires_at: Some(1), + issuer: "https://issuer.example.com".to_string(), + client_id: "openshell-cli".to_string(), + }, + ) + .expect("store oidc token"); + let tls = ts.tls.with_gateway_name(gateway_name); + + let err = run::provider_create_with_options(run::ProviderCreateOptions { + server: &ts.endpoint, + name: "custom-api", + provider_type: "oidc-token-exchange", + credentials: &[], + credential_source: run::ProviderCreateCredentialSource::OidcToken, + config: &[], + tls: &tls, + }) + .await + .expect_err("expired oidc token without refresh token should fail"); + + let message = err.to_string(); + assert!(message.contains("failed to load or refresh OIDC token")); + assert!(message.contains("no refresh token available")); +} + #[tokio::test] async fn provider_list_profiles_cli_uses_profile_browsing_rpc() { let ts = run_server().await; @@ -1255,17 +1553,15 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() }, ); - run::provider_create_with_options( - &ts.endpoint, - "custom-refresh-provider", - "custom-refresh", - false, - &[], - false, - true, - &[], - &ts.tls, - ) + run::provider_create_with_options(run::ProviderCreateOptions { + server: &ts.endpoint, + name: "custom-refresh-provider", + provider_type: "custom-refresh", + credentials: &[], + credential_source: run::ProviderCreateCredentialSource::Runtime, + config: &[], + tls: &ts.tls, + }) .await .expect("provider create"); @@ -1759,9 +2055,18 @@ async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled() ); let _env = EnvVarGuard::set(&[("CUSTOM_UPDATE_DISCOVERY_API_KEY", "updated-profile-secret")]); - run::provider_update(&ts.endpoint, "custom-update", true, &[], &[], &[], &ts.tls) - .await - .expect("profile-backed provider update --from-existing"); + run::provider_update(run::ProviderUpdateOptions { + server: &ts.endpoint, + name: "custom-update", + from_existing: true, + from_oidc_token: false, + credentials: &[], + config: &[], + credential_expires_at: &[], + tls: &ts.tls, + }) + .await + .expect("profile-backed provider update --from-existing"); let provider = ts .state @@ -2047,8 +2352,9 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_from_existing() { .expect_err("from-gcloud-adc and from-existing should be mutually exclusive"); assert!( - err.to_string() - .contains("--from-gcloud-adc cannot be combined with --from-existing or --credential"), + err.to_string().contains( + "--from-gcloud-adc cannot be combined with --from-existing, --from-oidc-token, or --credential" + ), "unexpected error: {err}" ); assert!(ts.state.providers.lock().await.is_empty()); @@ -2072,8 +2378,9 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_credentials() { .expect_err("from-gcloud-adc and credentials should be mutually exclusive"); assert!( - err.to_string() - .contains("--from-gcloud-adc cannot be combined with --from-existing or --credential"), + err.to_string().contains( + "--from-gcloud-adc cannot be combined with --from-existing, --from-oidc-token, or --credential" + ), "unexpected error: {err}" ); assert!(ts.state.providers.lock().await.is_empty()); diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 207386b84..85c5a1f40 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -14,7 +14,8 @@ use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, @@ -236,6 +237,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index d4052ff68..ddc5101cd 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -14,7 +14,8 @@ use openshell_core::proto::{ AttachSandboxProviderRequest, AttachSandboxProviderResponse, CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - DetachSandboxProviderRequest, DetachSandboxProviderResponse, ExecSandboxEvent, + DetachSandboxProviderRequest, DetachSandboxProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, @@ -220,6 +221,13 @@ impl OpenShell for TestOpenShell { )) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 9e4411b2a..d4ff9a332 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -72,6 +72,9 @@ pub const TLS_KEY_MOUNT_PATH: &str = "/etc/openshell/tls/client/tls.key"; /// Container-side mount path for the per-sandbox JWT token. pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt"; +/// Container-side directory where the provider SPIFFE Workload API socket is mounted. +pub const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = "/spiffe-workload-api"; + /// Return the XDG state path for a driver's sandbox JWT token file. /// /// The resulting path is `$XDG_STATE_HOME/openshell/[/]//sandbox.jwt`. diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 96158a1d1..682fdeb2d 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -23,12 +23,12 @@ use std::sync::{Arc, OnceLock, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::proto::{ - DenialSummary, GetDraftPolicyRequest, GetInferenceBundleRequest, GetInferenceBundleResponse, - GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, IssueSandboxTokenRequest, - NetworkActivitySummary, PolicyChunk, PolicySource, PolicyStatus, RefreshSandboxTokenRequest, - ReportPolicyStatusRequest, SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, - SubmitPolicyAnalysisResponse, UpdateConfigRequest, inference_client::InferenceClient, - open_shell_client::OpenShellClient, + DenialSummary, ExchangeProviderSubjectTokenRequest, GetDraftPolicyRequest, + GetInferenceBundleRequest, GetInferenceBundleResponse, GetSandboxConfigRequest, + GetSandboxProviderEnvironmentRequest, IssueSandboxTokenRequest, NetworkActivitySummary, + PolicyChunk, PolicySource, PolicyStatus, RefreshSandboxTokenRequest, ReportPolicyStatusRequest, + SandboxPolicy as ProtoSandboxPolicy, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, + UpdateConfigRequest, inference_client::InferenceClient, open_shell_client::OpenShellClient, }; use crate::sandbox_env; use miette::{IntoDiagnostic, Result, WrapErr}; @@ -690,6 +690,55 @@ pub async fn fetch_provider_environment( }) } +pub async fn exchange_provider_subject_token( + endpoint: &str, + sandbox_id: &str, + provider: &str, + credential_key: &str, + supervisor_jwt_svid: &str, +) -> Result { + debug!( + endpoint = %endpoint, + sandbox_id = %sandbox_id, + provider = %provider, + credential_key = %credential_key, + "Exchanging provider subject token through gateway" + ); + + let mut client = connect(endpoint).await?; + let response = client + .exchange_provider_subject_token(ExchangeProviderSubjectTokenRequest { + sandbox_id: sandbox_id.to_string(), + provider: provider.to_string(), + credential_key: credential_key.to_string(), + supervisor_jwt_svid: supervisor_jwt_svid.to_string(), + }) + .await + .map_err(provider_subject_token_exchange_status)?; + let inner = response.into_inner(); + Ok(ProviderSubjectTokenExchangeResult { + access_token: inner.access_token, + expires_in: inner.expires_in, + token_type: inner.token_type, + }) +} + +fn provider_subject_token_exchange_status(status: Status) -> miette::Report { + let message = status.message(); + if message.is_empty() { + miette::miette!( + "gateway ExchangeProviderSubjectToken failed with status {}", + status.code() + ) + } else { + miette::miette!( + "gateway ExchangeProviderSubjectToken failed with status {}: {}", + status.code(), + message + ) + } +} + /// A reusable gRPC client for the `OpenShell` service. /// /// Wraps a tonic channel connected once and reused for policy polling @@ -720,6 +769,12 @@ pub struct ProviderEnvironmentResult { pub dynamic_credentials: HashMap, } +pub struct ProviderSubjectTokenExchangeResult { + pub access_token: String, + pub expires_in: i64, + pub token_type: String, +} + impl CachedOpenShellClient { pub async fn connect(endpoint: &str) -> Result { debug!(endpoint = %endpoint, "Connecting openshell gRPC client for policy polling"); diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 321296369..527198943 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -34,6 +34,7 @@ pub mod provider_credentials; pub mod sandbox_env; pub mod secrets; pub mod settings; +pub mod spiffe; pub mod telemetry; pub mod time; diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-core/src/sandbox_env.rs index b457a4a8e..3850287af 100644 --- a/crates/openshell-core/src/sandbox_env.rs +++ b/crates/openshell-core/src/sandbox_env.rs @@ -71,3 +71,9 @@ pub const K8S_SA_TOKEN_FILE: &str = "OPENSHELL_K8S_SA_TOKEN_FILE"; /// exchanges without using SPIFFE for gateway authentication. pub const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET: &str = "OPENSHELL_PROVIDER_SPIFFE_WORKLOAD_API_SOCKET"; + +/// Filesystem path to the gateway's SPIFFE Workload API UNIX socket. +/// +/// When set, the gateway can fetch its own JWT-SVID for provider token exchange +/// client assertions. +pub const GATEWAY_SPIFFE_WORKLOAD_API_SOCKET: &str = "OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET"; diff --git a/crates/openshell-core/src/spiffe.rs b/crates/openshell-core/src/spiffe.rs new file mode 100644 index 000000000..843dbbff6 --- /dev/null +++ b/crates/openshell-core/src/spiffe.rs @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared SPIFFE helpers used by the gateway and sandbox supervisor. + +use std::path::Path; + +use base64::Engine as _; +use serde::Deserialize; + +/// SPIFFE JWT-SVID claims used by `OpenShell` token exchange flows. +#[derive(Debug, Clone, Deserialize)] +pub struct SpiffeJwtClaims { + pub iss: String, + pub sub: String, + pub aud: AudienceClaim, + #[serde(default)] + pub exp: i64, +} + +/// JWT `aud` claim representation accepted by SPIFFE JWT-SVIDs. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum AudienceClaim { + One(String), + Many(Vec), +} + +impl AudienceClaim { + pub fn contains(&self, expected: &str) -> bool { + match self { + Self::One(value) => value == expected, + Self::Many(values) => values.iter().any(|value| value == expected), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum JwtSvidParseError { + #[error("invalid JWT-SVID format")] + Format, + #[error("invalid JWT-SVID payload encoding")] + PayloadEncoding, + #[error("invalid JWT-SVID payload")] + Payload, +} + +/// Convert a path to a SPIFFE Workload API endpoint URL. +/// +/// If the path already has a scheme (`unix:` or `tcp:`), use it as-is. +/// Otherwise, assume it is a Unix socket path and prepend `unix:`. +pub fn workload_api_endpoint(path: &Path) -> String { + let path = path.to_string_lossy(); + if path.starts_with("unix:") || path.starts_with("tcp:") { + path.into_owned() + } else { + format!("unix:{path}") + } +} + +pub fn parse_unverified_jwt_svid_claims(token: &str) -> Result { + let segments = token.split('.').collect::>(); + if segments.len() != 3 || segments.iter().any(|segment| segment.is_empty()) { + return Err(JwtSvidParseError::Format); + } + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(segments[1]) + .map_err(|_| JwtSvidParseError::PayloadEncoding)?; + serde_json::from_slice::(&decoded).map_err(|_| JwtSvidParseError::Payload) +} + +pub fn trust_domain(subject: &str) -> Option<&str> { + let rest = subject.strip_prefix("spiffe://")?; + let (trust_domain, _) = rest.split_once('/').unwrap_or((rest, "")); + (!trust_domain.is_empty()).then_some(trust_domain) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unsigned_svid_fixture(issuer: &str, subject: &str, audience: serde_json::Value) -> String { + let header = serde_json::json!({ "alg": "RS256", "kid": "test-key" }); + let payload = serde_json::json!({ + "iss": issuer, + "sub": subject, + "aud": audience, + "exp": 4_102_444_800_i64 + }); + let encoded_header = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&header).expect("serialize header")); + let encoded_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).expect("serialize payload")); + format!("{encoded_header}.{encoded_payload}.signature") + } + + #[test] + fn workload_api_endpoint_preserves_explicit_scheme() { + assert_eq!( + workload_api_endpoint(Path::new("unix:/run/spire/agent.sock")), + "unix:/run/spire/agent.sock" + ); + assert_eq!( + workload_api_endpoint(Path::new("tcp:127.0.0.1:8081")), + "tcp:127.0.0.1:8081" + ); + } + + #[test] + fn workload_api_endpoint_defaults_to_unix_socket() { + assert_eq!( + workload_api_endpoint(Path::new("/run/spire/agent.sock")), + "unix:/run/spire/agent.sock" + ); + } + + #[test] + fn parse_unverified_jwt_svid_claims_accepts_string_audience() { + let token = unsigned_svid_fixture( + "https://spiffe.example.test", + "spiffe://openshell/openshell/sandbox/sb-a", + serde_json::json!("https://auth.example.com"), + ); + + let claims = parse_unverified_jwt_svid_claims(&token).expect("valid claims"); + + assert_eq!(claims.iss, "https://spiffe.example.test"); + assert_eq!(claims.sub, "spiffe://openshell/openshell/sandbox/sb-a"); + assert!(claims.aud.contains("https://auth.example.com")); + assert!(!claims.aud.contains("https://other.example.com")); + } + + #[test] + fn parse_unverified_jwt_svid_claims_accepts_array_audience() { + let token = unsigned_svid_fixture( + "https://spiffe.example.test", + "spiffe://openshell/openshell/sandbox/sb-a", + serde_json::json!(["https://auth.example.com", "https://other.example.com"]), + ); + + let claims = parse_unverified_jwt_svid_claims(&token).expect("valid claims"); + + assert!(claims.aud.contains("https://auth.example.com")); + assert!(claims.aud.contains("https://other.example.com")); + } + + #[test] + fn parse_unverified_jwt_svid_claims_rejects_truncated_jwt() { + assert!(matches!( + parse_unverified_jwt_svid_claims("header.payload"), + Err(JwtSvidParseError::Format) + )); + } + + #[test] + fn parse_unverified_jwt_svid_claims_rejects_empty_jwt_segments() { + assert!(matches!( + parse_unverified_jwt_svid_claims("header..signature"), + Err(JwtSvidParseError::Format) + )); + } + + #[test] + fn trust_domain_extracts_domain_from_spiffe_id() { + assert_eq!( + trust_domain("spiffe://openshell/openshell/sandbox/sb-a"), + Some("openshell") + ); + assert_eq!(trust_domain("spiffe://openshell"), Some("openshell")); + assert_eq!(trust_domain("not-a-spiffe-id"), None); + assert_eq!(trust_domain("spiffe:///empty"), None); + } +} diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index 0e29f52dd..610d34782 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -126,6 +126,9 @@ pub struct PodmanComputeConfig { /// `template.driver_config`. #[serde(default)] pub enable_bind_mounts: bool, + /// Host path to a SPIFFE Workload API Unix socket exposed to sandbox + /// supervisors for provider token exchange client assertions. + pub provider_spiffe_workload_api_socket: Option, /// Health check interval in seconds for sandbox containers. /// /// Podman runs the health check command at this interval to determine @@ -261,6 +264,7 @@ impl Default for PodmanComputeConfig { guest_tls_key: None, sandbox_pids_limit: DEFAULT_SANDBOX_PIDS_LIMIT, enable_bind_mounts: false, + provider_spiffe_workload_api_socket: None, health_check_interval_secs: DEFAULT_HEALTH_CHECK_INTERVAL_SECS, } } @@ -284,6 +288,10 @@ impl std::fmt::Debug for PodmanComputeConfig { .field("guest_tls_key", &self.guest_tls_key) .field("sandbox_pids_limit", &self.sandbox_pids_limit) .field("enable_bind_mounts", &self.enable_bind_mounts) + .field( + "provider_spiffe_workload_api_socket", + &self.provider_spiffe_workload_api_socket, + ) .field( "health_check_interval_secs", &self.health_check_interval_secs, diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 16814784a..f2886a5d3 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -56,6 +56,8 @@ const TLS_CA_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CA_MOUNT_PATH; const TLS_CERT_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CERT_MOUNT_PATH; const TLS_KEY_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_KEY_MOUNT_PATH; const SANDBOX_TOKEN_MOUNT_PATH: &str = openshell_core::driver_utils::SANDBOX_TOKEN_MOUNT_PATH; +const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = + openshell_core::driver_utils::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR; /// Directory inside sandbox containers where the supervisor binary is mounted. const SUPERVISOR_MOUNT_DIR: &str = openshell_core::driver_utils::SUPERVISOR_CONTAINER_DIR; @@ -406,6 +408,13 @@ fn build_env( ); } + if let Some(socket_path) = provider_spiffe_workload_api_socket_env_value(config) { + env.insert( + openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET.into(), + socket_path, + ); + } + env.remove(openshell_core::sandbox_env::SANDBOX_TOKEN); env.remove(openshell_core::sandbox_env::SANDBOX_TOKEN_FILE); @@ -1013,6 +1022,18 @@ pub fn build_container_spec_with_token_and_gpu_devices( options: ro, }); } + if let Some(path) = provider_spiffe_workload_api_socket_mount_source(config) { + let mut ro = vec!["ro".into(), "rbind".into()]; + if is_selinux_enabled() { + ro.push("z".into()); + } + m.push(Mount { + kind: "bind".into(), + source: path.display().to_string(), + destination: PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR.into(), + options: ro, + }); + } m.extend(user_mounts.mounts); m }, @@ -1029,6 +1050,33 @@ pub fn build_container_spec_with_token_and_gpu_devices( Ok(serde_json::to_value(container_spec).expect("ContainerSpec serialization cannot fail")) } +fn provider_spiffe_workload_api_socket_env_value(config: &PodmanComputeConfig) -> Option { + let host_path = config.provider_spiffe_workload_api_socket.as_ref()?; + let raw = host_path.to_str()?; + if raw.starts_with("tcp:") { + return Some(raw.to_string()); + } + let host_path = raw + .strip_prefix("unix:") + .map_or(host_path.as_path(), Path::new); + let file_name = host_path.file_name()?.to_str()?; + Some(format!( + "{PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR}/{file_name}" + )) +} + +fn provider_spiffe_workload_api_socket_mount_source(config: &PodmanComputeConfig) -> Option<&Path> { + let host_path = config.provider_spiffe_workload_api_socket.as_ref()?; + let raw = host_path.to_str()?; + if raw.starts_with("tcp:") { + return None; + } + let host_path = raw + .strip_prefix("unix:") + .map_or(host_path.as_path(), Path::new); + host_path.parent() +} + fn hostadd_entries(config: &PodmanComputeConfig) -> Vec { let host_gateway_ip = config.host_gateway_ip.trim(); if host_gateway_ip.is_empty() { @@ -2234,6 +2282,33 @@ mod tests { })); } + #[test] + fn container_spec_includes_provider_spiffe_socket_when_configured() { + let sandbox = test_sandbox("spiffe-id", "spiffe-name"); + let mut config = test_config(); + config.provider_spiffe_workload_api_socket = + Some(std::path::PathBuf::from("/host/spire-agent.sock")); + + let spec = build_container_spec(&sandbox, &config); + + let env_map = spec["env"].as_object().expect("env should be an object"); + assert_eq!( + env_map + .get(openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET) + .and_then(|v| v.as_str()), + Some("/spiffe-workload-api/spire-agent.sock"), + ); + + let mounts = spec["mounts"] + .as_array() + .expect("mounts should be an array"); + assert!(mounts.iter().any(|m| { + m["type"].as_str() == Some("bind") + && m["source"].as_str() == Some("/host") + && m["destination"].as_str() == Some(PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR) + })); + } + #[test] fn container_spec_omits_tls_without_config() { let sandbox = test_sandbox("notls-id", "notls-name"); diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index fe647d523..ec8bb3893 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -8,7 +8,8 @@ use openshell_core::proto::{ GraphqlOperation, L7Allow, L7DenyRule, L7QueryMatcher, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, - ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileCategory, + ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrantSubjectToken, + ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, ProviderProfileDiscovery, }; use serde::ser::SerializeStruct; @@ -100,6 +101,13 @@ pub struct CredentialProfile { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct TokenGrantProfile { + #[serde( + default = "default_token_grant_type", + deserialize_with = "deserialize_token_grant_type", + serialize_with = "serialize_token_grant_type", + skip_serializing_if = "is_client_credentials_grant" + )] + pub grant_type: ProviderCredentialTokenGrantType, pub token_endpoint: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub audience: String, @@ -113,6 +121,18 @@ pub struct TokenGrantProfile { pub cache_ttl_seconds: i64, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub audience_overrides: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_token: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub requested_token_type: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct TokenGrantSubjectTokenProfile { + pub source: String, + pub credential: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub subject_token_type: String, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] @@ -538,6 +558,26 @@ fn default_refresh_strategy() -> ProviderCredentialRefreshStrategy { ProviderCredentialRefreshStrategy::Unspecified } +fn default_token_grant_type() -> ProviderCredentialTokenGrantType { + ProviderCredentialTokenGrantType::ClientCredentials +} + +fn effective_token_grant_type( + grant_type: ProviderCredentialTokenGrantType, +) -> ProviderCredentialTokenGrantType { + match grant_type { + ProviderCredentialTokenGrantType::Unspecified => { + ProviderCredentialTokenGrantType::ClientCredentials + } + other => other, + } +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_client_credentials_grant(value: &ProviderCredentialTokenGrantType) -> bool { + effective_token_grant_type(*value) == ProviderCredentialTokenGrantType::ClientCredentials +} + fn deserialize_category<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -580,6 +620,28 @@ where serializer.serialize_str(provider_refresh_strategy_to_yaml(*strategy)) } +fn deserialize_token_grant_type<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let raw = String::deserialize(deserializer)?; + provider_token_grant_type_from_yaml(&raw) + .ok_or_else(|| de::Error::custom(format!("unsupported provider token grant type: {raw}"))) +} + +#[allow(clippy::trivially_copy_pass_by_ref)] +fn serialize_token_grant_type( + grant_type: &ProviderCredentialTokenGrantType, + serializer: S, +) -> Result +where + S: Serializer, +{ + serializer.serialize_str(provider_token_grant_type_to_yaml(*grant_type)) +} + #[must_use] pub fn provider_profile_category_from_yaml(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() { @@ -638,6 +700,26 @@ pub fn provider_refresh_strategy_to_yaml( } } +#[must_use] +pub fn provider_token_grant_type_from_yaml(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() { + "" | "client_credentials" => Some(ProviderCredentialTokenGrantType::ClientCredentials), + "token_exchange" => Some(ProviderCredentialTokenGrantType::TokenExchange), + _ => None, + } +} + +#[must_use] +pub fn provider_token_grant_type_to_yaml( + grant_type: ProviderCredentialTokenGrantType, +) -> &'static str { + match grant_type { + ProviderCredentialTokenGrantType::TokenExchange => "token_exchange", + ProviderCredentialTokenGrantType::ClientCredentials + | ProviderCredentialTokenGrantType::Unspecified => "client_credentials", + } +} + fn credential_refresh_from_proto(refresh: &ProviderCredentialRefresh) -> CredentialRefreshProfile { CredentialRefreshProfile { strategy: ProviderCredentialRefreshStrategy::try_from(refresh.strategy) @@ -683,6 +765,10 @@ fn token_grant_from_proto( token_grant: &openshell_core::proto::ProviderCredentialTokenGrant, ) -> TokenGrantProfile { TokenGrantProfile { + grant_type: effective_token_grant_type( + ProviderCredentialTokenGrantType::try_from(token_grant.grant_type) + .unwrap_or(ProviderCredentialTokenGrantType::ClientCredentials), + ), token_endpoint: token_grant.token_endpoint.clone(), audience: token_grant.audience.clone(), jwt_svid_audience: token_grant.jwt_svid_audience.clone(), @@ -694,6 +780,11 @@ fn token_grant_from_proto( .iter() .map(token_grant_audience_override_from_proto) .collect(), + subject_token: token_grant + .subject_token + .as_ref() + .map(token_grant_subject_token_from_proto), + requested_token_type: token_grant.requested_token_type.clone(), } } @@ -701,6 +792,7 @@ fn token_grant_to_proto( token_grant: &TokenGrantProfile, ) -> openshell_core::proto::ProviderCredentialTokenGrant { openshell_core::proto::ProviderCredentialTokenGrant { + grant_type: token_grant.grant_type as i32, token_endpoint: token_grant.token_endpoint.clone(), audience: token_grant.audience.clone(), jwt_svid_audience: token_grant.jwt_svid_audience.clone(), @@ -712,6 +804,31 @@ fn token_grant_to_proto( .iter() .map(token_grant_audience_override_to_proto) .collect(), + subject_token: token_grant + .subject_token + .as_ref() + .map(token_grant_subject_token_to_proto), + requested_token_type: token_grant.requested_token_type.clone(), + } +} + +fn token_grant_subject_token_from_proto( + subject_token: &ProviderCredentialTokenGrantSubjectToken, +) -> TokenGrantSubjectTokenProfile { + TokenGrantSubjectTokenProfile { + source: subject_token.source.clone(), + credential: subject_token.credential.clone(), + subject_token_type: subject_token.subject_token_type.clone(), + } +} + +fn token_grant_subject_token_to_proto( + subject_token: &TokenGrantSubjectTokenProfile, +) -> ProviderCredentialTokenGrantSubjectToken { + ProviderCredentialTokenGrantSubjectToken { + source: subject_token.source.clone(), + credential: subject_token.credential.clone(), + subject_token_type: subject_token.subject_token_type.clone(), } } @@ -1245,6 +1362,12 @@ pub fn validate_profile_set( message, )); } + diagnostics.extend(validate_token_grant_subject_token( + source, + profile_id, + credential, + &credential_names, + )); diagnostics.extend(validate_token_grant_audience_overrides( source, profile_id, @@ -1320,6 +1443,75 @@ struct TokenGrantOverrideBinding { score: u32, } +fn validate_token_grant_subject_token( + source: &str, + profile_id: &str, + credential: &CredentialProfile, + credential_names: &HashSet, +) -> Vec { + let Some(token_grant) = credential.token_grant.as_ref() else { + return Vec::new(); + }; + let grant_type = effective_token_grant_type(token_grant.grant_type); + let mut diagnostics = Vec::new(); + + match grant_type { + ProviderCredentialTokenGrantType::ClientCredentials => { + if token_grant.subject_token.is_some() { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token", + "subject_token is only valid for token_exchange grants", + )); + } + } + ProviderCredentialTokenGrantType::TokenExchange => { + let Some(subject_token) = token_grant.subject_token.as_ref() else { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token", + "token_exchange grants require subject_token", + )); + return diagnostics; + }; + + let source_value = subject_token.source.trim(); + if source_value != "provider_credential" { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token.source", + "subject_token.source must be provider_credential", + )); + } + + let subject_credential = subject_token.credential.trim(); + if subject_credential.is_empty() { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token.credential", + "subject_token.credential is required", + )); + } else if !credential_names.contains(subject_credential) { + diagnostics.push(ProfileValidationDiagnostic::error( + source, + profile_id, + "credentials.token_grant.subject_token.credential", + format!("unknown subject token credential: {subject_credential}"), + )); + } + } + ProviderCredentialTokenGrantType::Unspecified => { + unreachable!("effective_token_grant_type must normalize unspecified token grant type") + } + } + + diagnostics +} + fn validate_token_grant_audience_overrides( source: &str, profile_id: &str, @@ -1663,7 +1855,7 @@ pub fn get_default_profile(id: &str) -> Option<&'static ProviderTypeProfile> { #[cfg(test)] mod tests { - use openshell_core::proto::ProviderProfileCategory; + use openshell_core::proto::{ProviderCredentialTokenGrantType, ProviderProfileCategory}; use super::{ DiscoveryProfile, ProfileError, ProviderTypeProfile, default_profiles, get_default_profile, @@ -2087,6 +2279,166 @@ credentials: ); } + #[test] + fn token_exchange_grant_round_trips_through_proto_and_yaml() { + let profile = parse_profile_yaml( + r" +id: keycloak-token-exchange +display_name: Keycloak Token Exchange +credentials: + - name: USER_OIDC_TOKEN + required: true + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token + subject_token: + source: provider_credential + credential: USER_OIDC_TOKEN + subject_token_type: urn:ietf:params:oauth:token-type:access_token + jwt_svid_audience: https://keycloak.example.com/realms/openshell + client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-bearer + audience: https://graph.example.com + scopes: [graph.read] + requested_token_type: urn:ietf:params:oauth:token-type:access_token +", + ) + .expect("profile should parse"); + + let diagnostics = + validate_profile_set(&[("keycloak-token-exchange.yaml".to_string(), profile.clone())]); + assert!( + diagnostics.is_empty(), + "unexpected diagnostics: {diagnostics:?}" + ); + + let token_grant = profile.credentials[1] + .token_grant + .as_ref() + .expect("token grant should parse"); + assert_eq!( + token_grant.grant_type, + ProviderCredentialTokenGrantType::TokenExchange + ); + assert_eq!( + token_grant + .subject_token + .as_ref() + .map(|subject| subject.credential.as_str()), + Some("USER_OIDC_TOKEN") + ); + + let from_proto = ProviderTypeProfile::from_proto(&profile.to_proto()); + assert_eq!( + from_proto.credentials[1].token_grant, + profile.credentials[1].token_grant + ); + + let exported = profile_to_yaml(&from_proto).expect("yaml"); + assert!(exported.contains("grant_type: token_exchange")); + assert!(exported.contains("subject_token:")); + let reparsed = parse_profile_yaml(&exported).expect("re-parse"); + assert_eq!( + reparsed.credentials[1].token_grant, + profile.credentials[1].token_grant + ); + } + + #[test] + fn validate_profile_set_rejects_token_exchange_without_subject_token() { + let profile = parse_profile_yaml( + r" +id: missing-subject-token +display_name: Missing Subject Token +credentials: + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("missing.yaml".to_string(), profile)]); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| diagnostic.field == "credentials.token_grant.subject_token") + .expect("expected subject_token diagnostic"); + assert_eq!( + diagnostic.message, + "token_exchange grants require subject_token" + ); + } + + #[test] + fn validate_profile_set_rejects_token_exchange_unknown_subject_credential() { + let profile = parse_profile_yaml( + r" +id: unknown-subject-token +display_name: Unknown Subject Token +credentials: + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token + subject_token: + source: provider_credential + credential: USER_OIDC_TOKEN +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("unknown.yaml".to_string(), profile)]); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| { + diagnostic.field == "credentials.token_grant.subject_token.credential" + }) + .expect("expected subject token credential diagnostic"); + assert!( + diagnostic + .message + .contains("unknown subject token credential: USER_OIDC_TOKEN") + ); + } + + #[test] + fn validate_profile_set_rejects_subject_token_on_client_credentials_grant() { + let profile = parse_profile_yaml( + r" +id: misplaced-subject-token +display_name: Misplaced Subject Token +credentials: + - name: USER_OIDC_TOKEN + - name: access_token + auth_style: bearer + header_name: Authorization + token_grant: + token_endpoint: https://keycloak.example.com/realms/openshell/protocol/openid-connect/token + subject_token: + source: provider_credential + credential: USER_OIDC_TOKEN +", + ) + .expect("profile should parse"); + + let diagnostics = validate_profile_set(&[("misplaced.yaml".to_string(), profile)]); + let diagnostic = diagnostics + .iter() + .find(|diagnostic| diagnostic.field == "credentials.token_grant.subject_token") + .expect("expected subject_token diagnostic"); + assert_eq!( + diagnostic.message, + "subject_token is only valid for token_exchange grants" + ); + } + #[test] fn validate_profile_set_rejects_plain_http_token_endpoint() { for token_endpoint in [ diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 39a26b14e..9770f1009 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -81,9 +81,11 @@ tokio-stream = { workspace = true } sqlx = { workspace = true } reqwest = { workspace = true } uuid = { workspace = true } +base64 = { workspace = true } hmac = "0.12" sha2 = { workspace = true } jsonwebtoken = { workspace = true } +spiffe = { workspace = true } async-trait = "0.1" url = { workspace = true } glob = { workspace = true } diff --git a/crates/openshell-server/src/auth/sandbox_methods.rs b/crates/openshell-server/src/auth/sandbox_methods.rs index 76d5e1324..1ea54a438 100644 --- a/crates/openshell-server/src/auth/sandbox_methods.rs +++ b/crates/openshell-server/src/auth/sandbox_methods.rs @@ -32,6 +32,9 @@ mod tests { assert!(is_sandbox_callable( "/openshell.inference.v1.Inference/GetInferenceBundle" )); + assert!(is_sandbox_callable( + "/openshell.v1.OpenShell/ExchangeProviderSubjectToken" + )); } #[test] diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index fe2eb331c..86db72883 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -19,7 +19,8 @@ use openshell_core::proto::{ DeleteProviderProfileResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, DeleteServiceRequest, DeleteServiceResponse, DetachSandboxProviderRequest, - DetachSandboxProviderResponse, EditDraftChunkRequest, EditDraftChunkResponse, ExecSandboxEvent, + DetachSandboxProviderResponse, EditDraftChunkRequest, EditDraftChunkResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, ExposeServiceRequest, GatewayMessage, GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, GetDraftPolicyResponse, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderProfileRequest, @@ -503,6 +504,14 @@ impl OpenShell for OpenShellService { policy::handle_get_sandbox_provider_environment(&self.state, request).await } + #[rpc_auth(auth = "sandbox")] + async fn exchange_provider_subject_token( + &self, + request: Request, + ) -> Result, Status> { + provider::handle_exchange_provider_subject_token(&self.state, request).await + } + #[rpc_auth(auth = "dual", scope = "config:write", role = "admin")] async fn update_config( &self, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index d5a5f5c90..262f73719 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -16,6 +16,8 @@ use openshell_core::telemetry::{ LifecycleOperation, ProviderProfile as TelemetryProviderProfile, TelemetryOutcome, }; use prost::Message; +use std::error::Error as StdError; + use tonic::Status; use tracing::warn; @@ -1131,22 +1133,127 @@ use openshell_core::proto::{ ConfigureProviderRefreshRequest, ConfigureProviderRefreshResponse, CreateProviderRequest, DeleteProviderProfileRequest, DeleteProviderProfileResponse, DeleteProviderRefreshRequest, DeleteProviderRefreshResponse, DeleteProviderRequest, DeleteProviderResponse, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, GetProviderProfileRequest, GetProviderRefreshStatusRequest, GetProviderRefreshStatusResponse, GetProviderRequest, ImportProviderProfilesRequest, ImportProviderProfilesResponse, LintProviderProfilesRequest, LintProviderProfilesResponse, ListProviderProfilesRequest, ListProviderProfilesResponse, ListProvidersRequest, ListProvidersResponse, - ProviderCredentialRefreshStrategy, ProviderProfileDiagnostic, ProviderProfileImportItem, - ProviderProfileResponse, ProviderResponse, RotateProviderCredentialRequest, - RotateProviderCredentialResponse, StoredProviderProfile, UpdateProviderProfilesRequest, - UpdateProviderProfilesResponse, UpdateProviderRequest, + ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrantType, ProviderProfileDiagnostic, + ProviderProfileImportItem, ProviderProfileResponse, ProviderResponse, + RotateProviderCredentialRequest, RotateProviderCredentialResponse, StoredProviderProfile, + UpdateProviderProfilesRequest, UpdateProviderProfilesResponse, UpdateProviderRequest, +}; +use openshell_core::spiffe::{ + JwtSvidParseError, SpiffeJwtClaims, parse_unverified_jwt_svid_claims, + trust_domain as spiffe_trust_domain, workload_api_endpoint, }; use openshell_providers::{ CredentialRefreshProfile, ProfileValidationDiagnostic, ProviderTypeProfile, default_profiles, get_default_profile, normalize_profile_id, normalize_provider_type, validate_profile_set, }; -use std::sync::Arc; +use serde::Deserialize; +use std::sync::{Arc, LazyLock, RwLock}; use tonic::{Request, Response}; +const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; +const DEFAULT_CLIENT_ASSERTION_TYPE: &str = + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; +const DEFAULT_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token"; +const MAX_OAUTH_ERROR_FIELD_LEN: usize = 256; +const DEFAULT_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS: i64 = 300; +const MAX_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS: i64 = 3600; +const INTERMEDIATE_TOKEN_CACHE_EXPIRY_SKEW_SECONDS: i64 = 30; +const MAX_INTERMEDIATE_TOKEN_CACHE_ENTRIES: usize = 1024; + +static TOKEN_EXCHANGE_HTTP_CLIENT: LazyLock> = + LazyLock::new(|| { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .connect_timeout(std::time::Duration::from_secs(30)) + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|err| { + format!("provider token exchange HTTP client configuration failed: {err}") + }) + }); +static INTERMEDIATE_TOKEN_CACHE: LazyLock = + LazyLock::new(IntermediateTokenCache::new); + +fn token_exchange_http_client() -> Result<&'static reqwest::Client, Status> { + TOKEN_EXCHANGE_HTTP_CLIENT + .as_ref() + .map_err(|err| Status::internal(err.clone())) +} + +#[derive(Clone)] +struct CachedIntermediateToken { + access_token: String, + token_type: String, + expires_at_ms: i64, +} + +struct IntermediateTokenCache { + tokens: Arc>>, +} + +impl IntermediateTokenCache { + fn new() -> Self { + Self { + tokens: Arc::new(RwLock::new(std::collections::HashMap::new())), + } + } + + fn get(&self, key: &str) -> Option { + let now_ms = crate::persistence::current_time_ms(); + let tokens = self.tokens.read().ok()?; + let cached = tokens.get(key)?; + if cached.expires_at_ms <= now_ms { + return None; + } + Some(TokenExchangeResponseBody { + access_token: cached.access_token.clone(), + expires_in: cached.expires_at_ms.saturating_sub(now_ms) / 1000, + token_type: cached.token_type.clone(), + }) + } + + fn set(&self, key: String, token: &TokenExchangeResponseBody, expires_at_ms: i64) { + if let Ok(mut tokens) = self.tokens.write() { + let now_ms = crate::persistence::current_time_ms(); + tokens.retain(|_, cached| cached.expires_at_ms > now_ms); + if tokens.len() >= MAX_INTERMEDIATE_TOKEN_CACHE_ENTRIES + && let Some(evict_key) = tokens.keys().next().cloned() + { + tokens.remove(&evict_key); + } + tokens.insert( + key, + CachedIntermediateToken { + access_token: token.access_token.clone(), + token_type: token.token_type.clone(), + expires_at_ms, + }, + ); + } + } +} + +#[derive(Debug, Deserialize)] +struct TokenExchangeResponseBody { + access_token: String, + #[serde(default)] + expires_in: i64, + #[serde(default)] + token_type: String, +} + +#[derive(Debug, Deserialize)] +struct OAuthErrorResponse { + error: Option, + error_description: Option, +} + pub(super) async fn handle_create_provider( state: &Arc, request: Request, @@ -1927,6 +2034,610 @@ pub(super) async fn handle_update_provider( } } +pub(super) async fn handle_exchange_provider_subject_token( + state: &Arc, + request: Request, +) -> Result, Status> { + let req = request.get_ref().clone(); + let principal = crate::auth::guard::enforce_sandbox_scope(&request, &req.sandbox_id)?; + crate::auth::guard::ensure_sandbox_principal_scope(&principal, &req.sandbox_id)?; + drop(request); + + if req.provider.trim().is_empty() { + return Err(Status::invalid_argument("provider is required")); + } + if req.credential_key.trim().is_empty() { + return Err(Status::invalid_argument("credential_key is required")); + } + if req.supervisor_jwt_svid.trim().is_empty() { + return Err(Status::invalid_argument("supervisor_jwt_svid is required")); + } + + let sandbox = state + .store + .get_message::(&req.sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .ok_or_else(|| Status::not_found("sandbox not found"))?; + let spec = sandbox + .spec + .as_ref() + .ok_or_else(|| Status::internal("sandbox has no spec"))?; + if !spec + .providers + .iter() + .any(|provider| provider == &req.provider) + { + return Err(Status::permission_denied( + "provider is not attached to this sandbox", + )); + } + + let provider = state + .store + .get_message_by_name::(&req.provider) + .await + .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? + .ok_or_else(|| Status::not_found("provider not found"))?; + let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); + let profile = get_provider_type_profile(state.store.as_ref(), profile_id) + .await? + .ok_or_else(|| Status::failed_precondition("provider profile not found"))?; + let profile_proto = profile.to_proto(); + let credential = profile_proto + .credentials + .iter() + .find(|credential| credential.name == req.credential_key) + .ok_or_else(|| { + Status::failed_precondition("credential not declared by provider profile") + })?; + let token_grant = credential + .token_grant + .as_ref() + .ok_or_else(|| Status::failed_precondition("credential does not declare token_grant"))?; + let grant_type = ProviderCredentialTokenGrantType::try_from(token_grant.grant_type) + .unwrap_or(ProviderCredentialTokenGrantType::ClientCredentials); + if grant_type != ProviderCredentialTokenGrantType::TokenExchange { + return Err(Status::failed_precondition( + "credential token_grant is not token_exchange", + )); + } + let subject_token = token_grant + .subject_token + .as_ref() + .ok_or_else(|| Status::failed_precondition("token_exchange subject_token is missing"))?; + if subject_token.source != "provider_credential" { + return Err(Status::failed_precondition( + "unsupported subject_token source", + )); + } + if !profile_proto + .credentials + .iter() + .any(|credential| credential.name == subject_token.credential) + { + return Err(Status::failed_precondition( + "subject token credential not declared by provider profile", + )); + } + let stored_subject_token = provider + .credentials + .get(&subject_token.credential) + .filter(|value| !value.is_empty()) + .ok_or_else(|| Status::failed_precondition("subject token credential is not configured"))?; + ensure_subject_token_credential_not_expired(&provider, &subject_token.credential)?; + + let jwt_svid_audience = + effective_jwt_svid_audience(&token_grant.token_endpoint, &token_grant.jwt_svid_audience); + let gateway_jwt_svid = fetch_gateway_jwt_svid(&jwt_svid_audience).await?; + let gateway_claims = parse_unverified_spiffe_claims(&gateway_jwt_svid)?; + validate_gateway_jwt_svid_claims(&gateway_claims, &jwt_svid_audience)?; + let supervisor_claims = validate_supervisor_jwt_svid( + &req.supervisor_jwt_svid, + &gateway_claims, + &jwt_svid_audience, + ) + .await?; + + let intermediate_cache_key = intermediate_token_cache_key(IntermediateTokenCacheKeyInput { + provider: &provider, + dynamic_credential: &req.credential_key, + subject_credential: &subject_token.credential, + token_endpoint: &token_grant.token_endpoint, + client_assertion_type: effective_client_assertion_type(&token_grant.client_assertion_type), + subject_token_type: effective_token_type(&subject_token.subject_token_type), + audience: &supervisor_claims.sub, + requested_token_type: effective_token_type(&token_grant.requested_token_type), + supervisor_subject: &supervisor_claims.sub, + gateway_subject: &gateway_claims.sub, + }); + if let Some(cached) = INTERMEDIATE_TOKEN_CACHE.get(&intermediate_cache_key) { + return Ok(Response::new(ExchangeProviderSubjectTokenResponse { + access_token: cached.access_token, + expires_in: cached.expires_in, + token_type: cached.token_type, + })); + } + + let token_response = perform_intermediate_token_exchange( + &token_grant.token_endpoint, + &gateway_jwt_svid, + &token_grant.client_assertion_type, + stored_subject_token, + &subject_token.subject_token_type, + &supervisor_claims.sub, + &token_grant.requested_token_type, + ) + .await + .inspect_err(|status| { + warn!( + sandbox_id = %req.sandbox_id, + provider = %req.provider, + credential_key = %req.credential_key, + subject_credential = %subject_token.credential, + client_assertion_type = %effective_client_assertion_type(&token_grant.client_assertion_type), + gateway_svid_issuer = %gateway_claims.iss, + gateway_svid_subject = %gateway_claims.sub, + gateway_svid_audience = ?gateway_claims.aud, + supervisor_svid_issuer = %supervisor_claims.iss, + supervisor_svid_subject = %supervisor_claims.sub, + supervisor_svid_audience = ?supervisor_claims.aud, + status = ?status.code(), + error = %status.message(), + "intermediate provider token exchange failed" + ); + })?; + let cache_expires_at_ms = intermediate_token_cache_expires_at_ms( + &token_response, + token_grant.cache_ttl_seconds, + provider_credential_expires_at_ms(&provider, &subject_token.credential), + supervisor_claims.exp, + ); + if cache_expires_at_ms > crate::persistence::current_time_ms() { + INTERMEDIATE_TOKEN_CACHE.set(intermediate_cache_key, &token_response, cache_expires_at_ms); + } + + Ok(Response::new(ExchangeProviderSubjectTokenResponse { + access_token: token_response.access_token, + expires_in: token_response.expires_in, + token_type: token_response.token_type, + })) +} + +fn ensure_subject_token_credential_not_expired( + provider: &Provider, + credential_key: &str, +) -> Result<(), Status> { + let expires_at_ms = provider_credential_expires_at_ms(provider, credential_key); + if expires_at_ms > 0 && expires_at_ms <= crate::persistence::current_time_ms() { + return Err(Status::failed_precondition( + "subject token credential has expired", + )); + } + Ok(()) +} + +fn provider_credential_expires_at_ms(provider: &Provider, credential_key: &str) -> i64 { + provider + .credential_expires_at_ms + .get(credential_key) + .copied() + .unwrap_or_default() +} + +struct IntermediateTokenCacheKeyInput<'a> { + provider: &'a Provider, + dynamic_credential: &'a str, + subject_credential: &'a str, + token_endpoint: &'a str, + client_assertion_type: &'a str, + subject_token_type: &'a str, + audience: &'a str, + requested_token_type: &'a str, + supervisor_subject: &'a str, + gateway_subject: &'a str, +} + +fn intermediate_token_cache_key(input: IntermediateTokenCacheKeyInput<'_>) -> String { + let provider_id = input + .provider + .metadata + .as_ref() + .map(|metadata| metadata.id.as_str()) + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| input.provider.object_name()); + let provider_resource_version = input + .provider + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version); + format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + provider_id, + provider_resource_version, + input.dynamic_credential, + input.subject_credential, + input.token_endpoint, + input.client_assertion_type, + input.subject_token_type, + input.audience, + input.requested_token_type, + input.supervisor_subject, + input.gateway_subject + ) +} + +fn intermediate_token_cache_expires_at_ms( + token: &TokenExchangeResponseBody, + cache_ttl_seconds: i64, + subject_token_expires_at_ms: i64, + supervisor_svid_exp_seconds: i64, +) -> i64 { + let now_ms = crate::persistence::current_time_ms(); + let mut ttl_seconds = if token.expires_in > 0 { + token + .expires_in + .min(MAX_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS) + } else { + DEFAULT_INTERMEDIATE_TOKEN_CACHE_TTL_SECONDS + }; + if cache_ttl_seconds > 0 { + ttl_seconds = ttl_seconds.min(cache_ttl_seconds); + } + ttl_seconds = ttl_seconds + .saturating_sub(INTERMEDIATE_TOKEN_CACHE_EXPIRY_SKEW_SECONDS) + .max(1); + let mut expires_at_ms = now_ms.saturating_add(ttl_seconds.saturating_mul(1000)); + expires_at_ms = cap_cache_expiry_ms(expires_at_ms, jwt_exp_ms(&token.access_token)); + expires_at_ms = cap_cache_expiry_ms(expires_at_ms, Some(subject_token_expires_at_ms)); + expires_at_ms = cap_cache_expiry_ms( + expires_at_ms, + (supervisor_svid_exp_seconds > 0).then(|| supervisor_svid_exp_seconds.saturating_mul(1000)), + ); + expires_at_ms +} + +fn cap_cache_expiry_ms(current_expires_at_ms: i64, cap_expires_at_ms: Option) -> i64 { + let Some(cap_expires_at_ms) = cap_expires_at_ms.filter(|value| *value > 0) else { + return current_expires_at_ms; + }; + current_expires_at_ms.min( + cap_expires_at_ms + .saturating_sub(INTERMEDIATE_TOKEN_CACHE_EXPIRY_SKEW_SECONDS.saturating_mul(1000)), + ) +} + +fn jwt_exp_ms(token: &str) -> Option { + use base64::Engine as _; + let payload = token.split('.').nth(1)?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .ok()?; + let claims = serde_json::from_slice::(&decoded).ok()?; + claims + .get("exp")? + .as_i64() + .map(|exp| exp.saturating_mul(1000)) +} + +async fn fetch_gateway_jwt_svid(audience: &str) -> Result { + let socket_path = + std::env::var(openshell_core::sandbox_env::GATEWAY_SPIFFE_WORKLOAD_API_SOCKET) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + Status::failed_precondition(format!( + "{} is required for provider token exchange", + openshell_core::sandbox_env::GATEWAY_SPIFFE_WORKLOAD_API_SOCKET + )) + })?; + let endpoint = workload_api_endpoint(std::path::Path::new(&socket_path)); + let client = spiffe::WorkloadApiClient::connect_to(&endpoint) + .await + .map_err(|e| { + Status::failed_precondition(format!("SPIFFE Workload API unavailable: {e}")) + })?; + client + .fetch_jwt_token([audience], None) + .await + .map_err(|e| Status::failed_precondition(format!("failed to fetch gateway JWT-SVID: {e}"))) +} + +fn validate_gateway_jwt_svid_claims( + claims: &SpiffeJwtClaims, + expected_audience: &str, +) -> Result<(), Status> { + if !claims.aud.contains(expected_audience) { + return Err(Status::failed_precondition( + "gateway SVID audience does not match token grant audience", + )); + } + if spiffe_trust_domain(&claims.sub).is_none() { + return Err(Status::failed_precondition( + "gateway SVID subject is not a SPIFFE ID", + )); + } + if claims.exp > 0 && claims.exp.saturating_mul(1000) <= crate::persistence::current_time_ms() { + return Err(Status::failed_precondition("gateway SVID has expired")); + } + Ok(()) +} + +async fn validate_supervisor_jwt_svid( + token: &str, + gateway_claims: &SpiffeJwtClaims, + expected_audience: &str, +) -> Result { + let unverified = parse_unverified_spiffe_claims(token)?; + if unverified.iss != gateway_claims.iss { + return Err(Status::permission_denied( + "supervisor SVID issuer does not match gateway SVID issuer", + )); + } + if !unverified.aud.contains(expected_audience) { + return Err(Status::permission_denied( + "supervisor SVID audience does not match token grant audience", + )); + } + let supervisor_trust_domain = spiffe_trust_domain(&unverified.sub) + .ok_or_else(|| Status::permission_denied("supervisor SVID subject is not a SPIFFE ID"))?; + let gateway_trust_domain = spiffe_trust_domain(&gateway_claims.sub) + .ok_or_else(|| Status::failed_precondition("gateway SVID subject is not a SPIFFE ID"))?; + if supervisor_trust_domain != gateway_trust_domain { + return Err(Status::permission_denied( + "supervisor SVID trust domain does not match gateway SVID trust domain", + )); + } + + let socket_path = + std::env::var(openshell_core::sandbox_env::GATEWAY_SPIFFE_WORKLOAD_API_SOCKET) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + Status::failed_precondition(format!( + "{} is required for supervisor JWT-SVID validation", + openshell_core::sandbox_env::GATEWAY_SPIFFE_WORKLOAD_API_SOCKET + )) + })?; + let endpoint = workload_api_endpoint(std::path::Path::new(&socket_path)); + let client = spiffe::WorkloadApiClient::connect_to(&endpoint) + .await + .map_err(|e| { + Status::failed_precondition(format!("SPIFFE Workload API unavailable: {e}")) + })?; + let bundles = client + .fetch_jwt_bundles() + .await + .map_err(|e| Status::internal(format!("SPIFFE JWT bundle fetch failed: {e}")))?; + spiffe::JwtSvid::parse_and_validate(token, &bundles, &[expected_audience]) + .map_err(|e| Status::permission_denied(format!("invalid supervisor JWT-SVID: {e}")))?; + Ok(unverified) +} + +fn format_error_chain(prefix: &str, error: &dyn StdError) -> String { + let mut message = format!("{prefix}: {error}"); + let mut source = error.source(); + while let Some(err) = source { + message.push_str(": "); + message.push_str(&err.to_string()); + source = err.source(); + } + message +} + +fn parse_unverified_spiffe_claims(token: &str) -> Result { + parse_unverified_jwt_svid_claims(token).map_err(jwt_svid_parse_error_status) +} + +fn jwt_svid_parse_error_status(error: JwtSvidParseError) -> Status { + Status::permission_denied(error.to_string()) +} + +async fn perform_intermediate_token_exchange( + token_endpoint: &str, + gateway_jwt_svid: &str, + client_assertion_type: &str, + subject_token: &str, + subject_token_type: &str, + audience: &str, + requested_token_type: &str, +) -> Result { + let token_endpoint_url = parse_token_endpoint_url(token_endpoint)?; + let client_assertion_type = effective_client_assertion_type(client_assertion_type); + let subject_token_type = effective_token_type(subject_token_type); + let requested_token_type = effective_token_type(requested_token_type); + let form_params = [ + ("grant_type", TOKEN_EXCHANGE_GRANT_TYPE), + ("client_assertion_type", client_assertion_type), + ("client_assertion", gateway_jwt_svid), + ("subject_token", subject_token), + ("subject_token_type", subject_token_type), + ("audience", audience), + ("requested_token_type", requested_token_type), + ]; + + let response = token_exchange_http_client()? + .post(token_endpoint_url) + .form(&form_params) + .send() + .await + .map_err(|e| { + Status::internal(format_error_chain( + "provider token exchange request failed", + &e, + )) + })?; + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(Status::failed_precondition(token_exchange_failure_message( + status, &body, + ))); + } + let body = response + .json::() + .await + .map_err(|e| { + Status::internal(format!( + "provider token exchange response parse failed: {e}" + )) + })?; + validate_oauth_access_token(&body.access_token)?; + Ok(body) +} + +fn parse_token_endpoint_url(token_endpoint: &str) -> Result { + let url = reqwest::Url::parse(token_endpoint) + .map_err(|_| Status::invalid_argument("token_endpoint must be an absolute URL"))?; + if token_endpoint_transport_allowed(&url) { + return Ok(url); + } + Err(Status::invalid_argument( + "token_endpoint must use https, except http for loopback or in-cluster service hosts", + )) +} + +fn token_endpoint_transport_allowed(url: &reqwest::Url) -> bool { + match url.scheme() { + "https" => true, + "http" => url + .host_str() + .is_some_and(|host| is_loopback_host(host) || is_kubernetes_service_host(host)), + _ => false, + } +} + +fn is_loopback_host(host: &str) -> bool { + let host = host.trim_matches(['[', ']']); + if host.eq_ignore_ascii_case("localhost") { + return true; + } + match host.parse::() { + Ok(std::net::IpAddr::V4(v4)) => v4.is_loopback(), + Ok(std::net::IpAddr::V6(v6)) => { + v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) + } + Err(_) => false, + } +} + +fn is_kubernetes_service_host(host: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + let labels = host.split('.').collect::>(); + let is_service_name = labels.len() == 3 && labels[2] == "svc"; + let is_cluster_local_service = + labels.len() == 5 && labels[2] == "svc" && labels[3] == "cluster" && labels[4] == "local"; + (is_service_name || is_cluster_local_service) && labels.iter().all(|label| !label.is_empty()) +} + +fn effective_client_assertion_type(client_assertion_type: &str) -> &str { + if client_assertion_type.trim().is_empty() { + DEFAULT_CLIENT_ASSERTION_TYPE + } else { + client_assertion_type + } +} + +fn effective_token_type(token_type: &str) -> &str { + if token_type.trim().is_empty() { + DEFAULT_TOKEN_TYPE + } else { + token_type + } +} + +fn effective_jwt_svid_audience(token_endpoint: &str, jwt_svid_audience: &str) -> String { + if !jwt_svid_audience.trim().is_empty() { + return jwt_svid_audience.to_string(); + } + derive_issuer_from_token_endpoint(token_endpoint) +} + +fn derive_issuer_from_token_endpoint(token_endpoint: &str) -> String { + if let Some(realms_idx) = token_endpoint.find("/realms/") { + let after_realms = &token_endpoint[realms_idx + "/realms/".len()..]; + if let Some(slash_idx) = after_realms.find('/') { + let realm_end = realms_idx + "/realms/".len() + slash_idx; + return token_endpoint[..realm_end].to_string(); + } + } + token_endpoint.to_string() +} + +fn validate_oauth_access_token(token: &str) -> Result<(), Status> { + if token.is_empty() || !is_token68(token) { + return Err(Status::internal( + "provider token exchange returned a malformed access token", + )); + } + Ok(()) +} + +fn is_token68(token: &str) -> bool { + let mut padding_started = false; + let mut saw_value = false; + for byte in token.bytes() { + if byte == b'=' { + padding_started = true; + continue; + } + if padding_started || !is_token68_value_byte(byte) { + return false; + } + saw_value = true; + } + saw_value +} + +fn is_token68_value_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/') +} + +fn token_exchange_failure_message(status: reqwest::StatusCode, body: &str) -> String { + let Ok(error_response) = serde_json::from_str::(body) else { + return format!("provider token exchange failed with status {status}"); + }; + let error = error_response + .error + .as_deref() + .map(sanitize_oauth_error_field) + .filter(|value| !value.is_empty()); + let description = error_response + .error_description + .as_deref() + .map(sanitize_oauth_error_field) + .filter(|value| !value.is_empty()); + match (error, description) { + (Some(error), Some(description)) => { + format!( + "provider token exchange failed with status {status}: error={error}; error_description={description}" + ) + } + (Some(error), None) => { + format!("provider token exchange failed with status {status}: error={error}") + } + (None, Some(description)) => { + format!( + "provider token exchange failed with status {status}: error_description={description}" + ) + } + (None, None) => format!("provider token exchange failed with status {status}"), + } +} + +fn sanitize_oauth_error_field(value: &str) -> String { + value + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .take(MAX_OAUTH_ERROR_FIELD_LEN) + .collect::() + .trim() + .to_string() +} + pub(super) async fn handle_get_provider_refresh_status( state: &Arc, request: Request, @@ -2336,6 +3047,7 @@ fn telemetry_provider_profile(provider_type: &str) -> TelemetryProviderProfile { #[cfg(test)] mod tests { use super::*; + use crate::auth::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; use crate::grpc::test_support::test_server_state; use crate::grpc::{MAX_MAP_KEY_LEN, MAX_PROVIDER_TYPE_LEN}; use crate::persistence::test_store; @@ -2344,13 +3056,17 @@ mod tests { L7Allow, L7Rule, LintProviderProfilesRequest, ListProviderProfilesRequest, NetworkBinary, NetworkEndpoint, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, ProviderCredentialTokenGrant, ProviderCredentialTokenGrantAudienceOverride, + ProviderCredentialTokenGrantSubjectToken, ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, ProviderProfileImportItem, Sandbox, SandboxSpec, StoredProviderProfile, UpdateProviderProfilesRequest, }; + use openshell_core::spiffe::AudienceClaim; use openshell_core::{ObjectId, ObjectName}; use std::collections::HashMap; use tonic::{Code, Request}; + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; #[test] fn env_key_validation_accepts_valid_keys() { @@ -2448,6 +3164,9 @@ mod tests { }, ) .collect(), + grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32, + subject_token: None, + requested_token_type: String::new(), }), }; let profile = ProviderProfile { @@ -3116,8 +3835,610 @@ mod tests { scopes: vec!["read".to_string()], cache_ttl_seconds: 300, audience_overrides: Vec::new(), + grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32, + subject_token: None, + requested_token_type: String::new(), + }), + } + } + + fn token_exchange_credential( + name: &str, + subject_credential: &str, + ) -> ProviderProfileCredential { + let mut credential = token_grant_credential(name); + let token_grant = credential + .token_grant + .as_mut() + .expect("token grant credential"); + token_grant.grant_type = ProviderCredentialTokenGrantType::TokenExchange as i32; + token_grant.subject_token = Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: subject_credential.to_string(), + subject_token_type: "urn:ietf:params:oauth:token-type:access_token".to_string(), + }); + credential + } + + async fn import_token_exchange_profile( + state: &Arc, + id: &str, + dynamic_credential: &str, + subject_credential: &str, + ) { + let mut profile = custom_profile(id); + profile.credentials = vec![ + token_exchange_credential(dynamic_credential, subject_credential), + static_credential(subject_credential, subject_credential, false), + ]; + profile.endpoints = vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + path: "/v1/**".to_string(), + protocol: "rest".to_string(), + ..Default::default() + }]; + let response = handle_import_provider_profiles( + state, + Request::new(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: format!("{id}.yaml"), + }], }), + ) + .await + .unwrap() + .into_inner(); + assert!( + response.imported, + "profile import failed: {:?}", + response.diagnostics + ); + } + + async fn store_token_exchange_profile_with_undeclared_subject( + state: &Arc, + id: &str, + dynamic_credential: &str, + subject_credential: &str, + ) { + let mut profile = custom_profile(id); + profile.credentials = vec![token_exchange_credential( + dynamic_credential, + subject_credential, + )]; + profile.endpoints = vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + path: "/v1/**".to_string(), + protocol: "rest".to_string(), + ..Default::default() + }]; + state + .store + .put_message(&stored_provider_profile(profile)) + .await + .unwrap(); + } + + fn sandbox_principal(sandbox_id: &str) -> Principal { + Principal::Sandbox(SandboxPrincipal { + sandbox_id: sandbox_id.to_string(), + source: SandboxIdentitySource::BootstrapJwt { + issuer: "openshell-gateway:test".to_string(), + }, + trust_domain: Some("openshell".to_string()), + }) + } + + fn with_sandbox_principal(mut request: Request, sandbox_id: &str) -> Request { + request + .extensions_mut() + .insert(sandbox_principal(sandbox_id)); + request + } + + fn unsigned_svid_fixture(issuer: &str, subject: &str, audience: serde_json::Value) -> String { + use base64::Engine as _; + let header = serde_json::json!({ "alg": "RS256", "kid": "test-key" }); + let payload = serde_json::json!({ + "iss": issuer, + "sub": subject, + "aud": audience, + "exp": 4_102_444_800_i64 + }); + let encoded_header = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&header).expect("serialize header")); + let encoded_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).expect("serialize payload")); + format!("{encoded_header}.{encoded_payload}.signature") + } + + fn gateway_spiffe_claims(trust_domain: &str) -> SpiffeJwtClaims { + SpiffeJwtClaims { + iss: "https://spiffe.example.test".to_string(), + sub: format!("spiffe://{trust_domain}/openshell/gateway"), + aud: AudienceClaim::One("https://auth.example.com".to_string()), + exp: 4_102_444_800, + } + } + + #[test] + fn parse_unverified_spiffe_claims_rejects_truncated_jwt() { + let err = parse_unverified_spiffe_claims("header.payload") + .expect_err("truncated JWT-SVID must fail"); + + assert_eq!(err.code(), Code::PermissionDenied); + assert!(err.message().contains("format")); + } + + #[test] + fn parse_unverified_spiffe_claims_rejects_empty_jwt_segments() { + let err = parse_unverified_spiffe_claims("header..signature") + .expect_err("empty payload segment must fail"); + + assert_eq!(err.code(), Code::PermissionDenied); + assert!(err.message().contains("format")); + } + + #[test] + fn gateway_svid_validation_requires_expected_audience() { + let claims = gateway_spiffe_claims("openshell"); + + let err = validate_gateway_jwt_svid_claims(&claims, "https://other.example.com") + .expect_err("wrong gateway SVID audience must fail"); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("audience")); + } + + #[test] + fn gateway_svid_validation_requires_spiffe_subject() { + let mut claims = gateway_spiffe_claims("openshell"); + claims.sub = "not-a-spiffe-id".to_string(); + + let err = validate_gateway_jwt_svid_claims(&claims, "https://auth.example.com") + .expect_err("non-SPIFFE gateway SVID subject must fail"); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("not a SPIFFE ID")); + } + + #[test] + fn gateway_svid_validation_rejects_expired_claims() { + let mut claims = gateway_spiffe_claims("openshell"); + claims.exp = (crate::persistence::current_time_ms() / 1000).saturating_sub(1); + + let err = validate_gateway_jwt_svid_claims(&claims, "https://auth.example.com") + .expect_err("expired gateway SVID must fail"); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("expired")); + } + + #[tokio::test] + async fn supervisor_svid_validation_rejects_non_spiffe_subject_before_bundle_fetch() { + let token = unsigned_svid_fixture( + "https://spiffe.example.test", + "not-a-spiffe-id", + serde_json::json!("https://auth.example.com"), + ); + + let err = validate_supervisor_jwt_svid( + &token, + &gateway_spiffe_claims("openshell"), + "https://auth.example.com", + ) + .await + .expect_err("non-SPIFFE subject must fail"); + + assert_eq!(err.code(), Code::PermissionDenied); + assert!(err.message().contains("not a SPIFFE ID")); + } + + #[tokio::test] + async fn supervisor_svid_validation_rejects_wrong_trust_domain_before_bundle_fetch() { + let token = unsigned_svid_fixture( + "https://spiffe.example.test", + "spiffe://other-domain/openshell/sandbox/sb-a", + serde_json::json!("https://auth.example.com"), + ); + + let err = validate_supervisor_jwt_svid( + &token, + &gateway_spiffe_claims("openshell"), + "https://auth.example.com", + ) + .await + .expect_err("wrong trust domain must fail"); + + assert_eq!(err.code(), Code::PermissionDenied); + assert!(err.message().contains("trust domain")); + } + + #[tokio::test] + async fn supervisor_svid_validation_rejects_wrong_audience_before_bundle_fetch() { + let token = unsigned_svid_fixture( + "https://spiffe.example.test", + "spiffe://openshell/openshell/sandbox/sb-a", + serde_json::json!(["https://other.example.com"]), + ); + + let err = validate_supervisor_jwt_svid( + &token, + &gateway_spiffe_claims("openshell"), + "https://auth.example.com", + ) + .await + .expect_err("wrong audience must fail"); + + assert_eq!(err.code(), Code::PermissionDenied); + assert!(err.message().contains("audience")); + } + + #[tokio::test] + async fn supervisor_svid_validation_accepts_arbitrary_path_before_bundle_fetch() { + let token = unsigned_svid_fixture( + "https://spiffe.example.test", + "spiffe://openshell/custom/install/specific/supervisor", + serde_json::json!("https://auth.example.com"), + ); + + let err = validate_supervisor_jwt_svid( + &token, + &gateway_spiffe_claims("openshell"), + "https://auth.example.com", + ) + .await + .expect_err("matching trust domain should reach bundle validation"); + + assert_ne!(err.code(), Code::PermissionDenied); + assert!( + err.message() + .contains("OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET") + || err.message().contains("SPIFFE Workload API") + ); + } + + #[tokio::test] + async fn intermediate_token_exchange_posts_expected_form_and_parses_response() { + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .and(body_string_contains( + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange", + )) + .and(body_string_contains( + "client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer", + )) + .and(body_string_contains("client_assertion=gateway-jwt-svid")) + .and(body_string_contains("subject_token=stored-user-token")) + .and(body_string_contains( + "subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token", + )) + .and(body_string_contains( + "audience=spiffe%3A%2F%2Fopenshell%2Fopenshell%2Fsandbox%2Fsb-a", + )) + .and(body_string_contains( + "requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "intermediate-token", + "expires_in": 120, + "token_type": "Bearer" + }))) + .expect(1) + .mount(&mock_server) + .await; + let token_endpoint = format!("{}/token", mock_server.uri()); + + let response = perform_intermediate_token_exchange( + &token_endpoint, + "gateway-jwt-svid", + "", + "stored-user-token", + "", + "spiffe://openshell/openshell/sandbox/sb-a", + "", + ) + .await + .expect("intermediate token exchange should succeed"); + + assert_eq!(response.access_token, "intermediate-token"); + assert_eq!(response.expires_in, 120); + assert_eq!(response.token_type, "Bearer"); + } + + #[test] + fn intermediate_token_cache_key_varies_by_provider_revision_and_supervisor_subject() { + let mut provider = provider_with_values("cached-provider", "token-exchange"); + { + let metadata = provider.metadata.as_mut().expect("provider metadata"); + metadata.id = "provider-id".to_string(); + metadata.resource_version = 1; } + + let base = intermediate_token_cache_key(IntermediateTokenCacheKeyInput { + provider: &provider, + dynamic_credential: "access_token", + subject_credential: "USER_OIDC_TOKEN", + token_endpoint: "https://auth.example.com/token", + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + subject_token_type: DEFAULT_TOKEN_TYPE, + audience: "spiffe://openshell/sandbox/a", + requested_token_type: DEFAULT_TOKEN_TYPE, + supervisor_subject: "spiffe://openshell/sandbox/a", + gateway_subject: "spiffe://openshell/gateway", + }); + + provider + .metadata + .as_mut() + .expect("provider metadata") + .resource_version = 2; + let changed_revision = intermediate_token_cache_key(IntermediateTokenCacheKeyInput { + provider: &provider, + dynamic_credential: "access_token", + subject_credential: "USER_OIDC_TOKEN", + token_endpoint: "https://auth.example.com/token", + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + subject_token_type: DEFAULT_TOKEN_TYPE, + audience: "spiffe://openshell/sandbox/a", + requested_token_type: DEFAULT_TOKEN_TYPE, + supervisor_subject: "spiffe://openshell/sandbox/a", + gateway_subject: "spiffe://openshell/gateway", + }); + provider + .metadata + .as_mut() + .expect("provider metadata") + .resource_version = 1; + let changed_supervisor = intermediate_token_cache_key(IntermediateTokenCacheKeyInput { + provider: &provider, + dynamic_credential: "access_token", + subject_credential: "USER_OIDC_TOKEN", + token_endpoint: "https://auth.example.com/token", + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + subject_token_type: DEFAULT_TOKEN_TYPE, + audience: "spiffe://openshell/sandbox/b", + requested_token_type: DEFAULT_TOKEN_TYPE, + supervisor_subject: "spiffe://openshell/sandbox/b", + gateway_subject: "spiffe://openshell/gateway", + }); + + assert_ne!(base, changed_revision); + assert_ne!(base, changed_supervisor); + } + + #[test] + fn intermediate_token_cache_expiry_is_capped_by_subject_and_supervisor_expiry() { + let now_ms = crate::persistence::current_time_ms(); + let subject_expires_at_ms = now_ms + 45_000; + let supervisor_exp_seconds = (now_ms + 120_000) / 1000; + let token = TokenExchangeResponseBody { + access_token: "opaque-token".to_string(), + expires_in: 600, + token_type: "Bearer".to_string(), + }; + + let expires_at_ms = intermediate_token_cache_expires_at_ms( + &token, + 300, + subject_expires_at_ms, + supervisor_exp_seconds, + ); + + assert!(expires_at_ms <= subject_expires_at_ms - 30_000); + assert!(expires_at_ms > now_ms); + } + + #[test] + fn intermediate_token_cache_returns_remaining_ttl() { + let cache = IntermediateTokenCache::new(); + let token = TokenExchangeResponseBody { + access_token: "cached-token".to_string(), + expires_in: 300, + token_type: "Bearer".to_string(), + }; + cache.set( + "cache-key".to_string(), + &token, + crate::persistence::current_time_ms() + 60_000, + ); + + let cached = cache.get("cache-key").expect("cache hit"); + + assert_eq!(cached.access_token, "cached-token"); + assert_eq!(cached.token_type, "Bearer"); + assert!((1..=60).contains(&cached.expires_in)); + } + + #[test] + fn intermediate_token_cache_prunes_expired_entries_on_set() { + let cache = IntermediateTokenCache::new(); + let token = TokenExchangeResponseBody { + access_token: "cached-token".to_string(), + expires_in: 300, + token_type: "Bearer".to_string(), + }; + cache.set( + "expired-key".to_string(), + &token, + crate::persistence::current_time_ms() - 1_000, + ); + cache.set( + "fresh-key".to_string(), + &token, + crate::persistence::current_time_ms() + 60_000, + ); + + assert!(cache.get("expired-key").is_none()); + assert!(cache.get("fresh-key").is_some()); + assert_eq!(cache.tokens.read().expect("cache lock").len(), 1); + } + + #[test] + fn jwt_exp_ms_reads_unverified_exp_claim() { + use base64::Engine as _; + let payload = serde_json::json!({ "exp": 12345 }); + let encoded_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).expect("serialize payload")); + let token = format!("header.{encoded_payload}.signature"); + + assert_eq!(jwt_exp_ms(&token), Some(12_345_000)); + } + + #[tokio::test] + async fn exchange_provider_subject_token_rejects_expired_subject_credential() { + let state = test_server_state().await; + let store = state.store.as_ref(); + let sandbox_id = "sb-token-exchange-expired"; + let provider_name = "keycloak-user"; + let provider_type = "keycloak-user-token-exchange"; + let subject_credential = "USER_OIDC_TOKEN"; + + import_token_exchange_profile(&state, provider_type, "access_token", subject_credential) + .await; + create_provider_record( + store, + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: provider_name.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + }), + r#type: provider_type.to_string(), + credentials: HashMap::from([( + subject_credential.to_string(), + "stored-user-token".to_string(), + )]), + config: HashMap::new(), + credential_expires_at_ms: HashMap::from([( + subject_credential.to_string(), + crate::persistence::current_time_ms() - 1_000, + )]), + }, + ) + .await + .unwrap(); + store + .put_message(&Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: sandbox_id.to_string(), + name: sandbox_id.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + }), + spec: Some(SandboxSpec { + providers: vec![provider_name.to_string()], + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + + let err = handle_exchange_provider_subject_token( + &state, + with_sandbox_principal( + Request::new(ExchangeProviderSubjectTokenRequest { + sandbox_id: sandbox_id.to_string(), + provider: provider_name.to_string(), + credential_key: "access_token".to_string(), + supervisor_jwt_svid: "header.payload.signature".to_string(), + }), + sandbox_id, + ), + ) + .await + .expect_err("expired subject credential must be rejected"); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message() + .contains("subject token credential has expired") + ); + } + + #[tokio::test] + async fn exchange_provider_subject_token_rejects_undeclared_subject_credential() { + let state = test_server_state().await; + let store = state.store.as_ref(); + let sandbox_id = "sb-token-exchange-undeclared-subject"; + let provider_name = "keycloak-user-undeclared-subject"; + let provider_type = "keycloak-user-token-exchange-undeclared-subject"; + let subject_credential = "USER_OIDC_TOKEN"; + + store_token_exchange_profile_with_undeclared_subject( + &state, + provider_type, + "access_token", + subject_credential, + ) + .await; + create_provider_record( + store, + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: provider_name.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + }), + r#type: provider_type.to_string(), + credentials: HashMap::from([( + subject_credential.to_string(), + "stored-user-token".to_string(), + )]), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + }, + ) + .await + .unwrap(); + store + .put_message(&Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: sandbox_id.to_string(), + name: sandbox_id.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + }), + spec: Some(SandboxSpec { + providers: vec![provider_name.to_string()], + ..Default::default() + }), + ..Default::default() + }) + .await + .unwrap(); + + let err = handle_exchange_provider_subject_token( + &state, + with_sandbox_principal( + Request::new(ExchangeProviderSubjectTokenRequest { + sandbox_id: sandbox_id.to_string(), + provider: provider_name.to_string(), + credential_key: "access_token".to_string(), + supervisor_jwt_svid: "header.payload.signature".to_string(), + }), + sandbox_id, + ), + ) + .await + .expect_err("undeclared subject credential must be rejected"); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!( + err.message() + .contains("subject token credential not declared") + ); } #[tokio::test] diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 3934c8af4..f277f2335 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -16,8 +16,9 @@ use hyper_util::{ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, CreateSshSessionRequest, CreateSshSessionResponse, DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, - ExecSandboxEvent, ExecSandboxInput, ExecSandboxRequest, GatewayMessage, - GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, + ExchangeProviderSubjectTokenRequest, ExchangeProviderSubjectTokenResponse, ExecSandboxEvent, + ExecSandboxInput, ExecSandboxRequest, GatewayMessage, GetGatewayConfigRequest, + GetGatewayConfigResponse, GetProviderRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, HealthRequest, HealthResponse, IssueSandboxTokenRequest, IssueSandboxTokenResponse, ListProvidersRequest, @@ -184,6 +185,13 @@ impl OpenShell for TestOpenShell { Ok(Response::new(RevokeSshSessionResponse::default())) } + async fn exchange_provider_subject_token( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn create_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index bd94d151e..77862f129 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -211,6 +211,12 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + async fn exchange_provider_subject_token( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } async fn create_provider( &self, _: tonic::Request, diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 830e3461b..10b02bda2 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -1448,20 +1448,7 @@ network_policies: let config = crate::l7::parse_l7_config(&endpoint_config.unwrap()).unwrap(); let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; - let fixture = match resolver_response { - Ok(token) => { - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( - provider_key, - token, - ) - } - Err(error) => { - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure( - provider_key, - error, - ) - } - }; + let fixture = token_grant_fixture(provider_key, resolver_response, false); let ctx = L7EvalContext { host: "api.example.test".into(), port: 8080, @@ -1478,6 +1465,24 @@ network_policies: (config, tunnel_engine, ctx, fixture) } + fn rest_token_exchange_relay_context( + resolver_response: std::result::Result<&str, &str>, + ) -> ( + L7EndpointConfig, + TunnelPolicyEngine, + L7EvalContext, + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, + ) { + let (config, tunnel_engine, mut ctx, _) = + rest_token_grant_relay_context(Ok("unused-token")); + let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; + let fixture = token_grant_fixture(provider_key, resolver_response, true); + ctx.dynamic_credentials = Some(fixture.dynamic_credentials()); + ctx.token_grant_resolver = Some(fixture.resolver()); + + (config, tunnel_engine, ctx, fixture) + } + fn passthrough_token_grant_relay_context( resolver_response: std::result::Result<&str, &str>, ) -> ( @@ -1491,20 +1496,7 @@ network_policies: .generation_guard(engine.current_generation()) .unwrap(); let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; - let fixture = match resolver_response { - Ok(token) => { - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( - provider_key, - token, - ) - } - Err(error) => { - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure( - provider_key, - error, - ) - } - }; + let fixture = token_grant_fixture(provider_key, resolver_response, false); let ctx = L7EvalContext { host: "api.example.test".into(), port: 8080, @@ -1521,6 +1513,56 @@ network_policies: (generation_guard, ctx, fixture) } + fn passthrough_token_exchange_relay_context( + resolver_response: std::result::Result<&str, &str>, + ) -> ( + PolicyGenerationGuard, + L7EvalContext, + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, + ) { + let (generation_guard, mut ctx, _) = + passthrough_token_grant_relay_context(Ok("unused-token")); + let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; + let fixture = token_grant_fixture(provider_key, resolver_response, true); + ctx.dynamic_credentials = Some(fixture.dynamic_credentials()); + ctx.token_grant_resolver = Some(fixture.resolver()); + + (generation_guard, ctx, fixture) + } + + fn token_grant_fixture( + provider_key: &str, + resolver_response: std::result::Result<&str, &str>, + token_exchange: bool, + ) -> crate::l7::token_grant_injection::test_support::TokenGrantTestFixture { + match (resolver_response, token_exchange) { + (Ok(token), false) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( + provider_key, + token, + ) + } + (Ok(token), true) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success_token_exchange( + provider_key, + token, + ) + } + (Err(error), false) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure( + provider_key, + error, + ) + } + (Err(error), true) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure_token_exchange( + provider_key, + error, + ) + } + } + } + fn authorization_header_count(headers: &str) -> usize { headers .lines() @@ -1627,6 +1669,71 @@ network_policies: fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn l7_rest_relay_injects_token_exchange_authorization_header() { + let (config, tunnel_engine, ctx, fixture) = + rest_token_exchange_relay_context(Ok("grant-token")); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut upstream_request = [0u8; 1024]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("request should reach upstream") + .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + + assert!(upstream_request.starts_with("GET /v1/projects HTTP/1.1\r\n")); + assert!(upstream_request.contains("Authorization: Bearer grant-token\r\n")); + assert!(!upstream_request.contains("stale-token")); + assert_eq!(authorization_header_count(&upstream_request), 1); + + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + #[tokio::test] async fn l7_rest_relay_token_grant_failure_does_not_forward_request() { let (config, tunnel_engine, ctx, fixture) = @@ -1679,6 +1786,60 @@ network_policies: fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn l7_rest_relay_token_exchange_failure_does_not_forward_request() { + let (config, tunnel_engine, ctx, fixture) = + rest_token_exchange_relay_context(Err("oauth unavailable")); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("bad gateway response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("502 Bad Gateway")); + + let mut upstream_request = [0u8; 128]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("upstream should close without forwarded data") + .unwrap(); + assert_eq!(n, 0, "unauthenticated request must not reach upstream"); + + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + #[tokio::test] async fn passthrough_relay_injects_token_grant_authorization_header() { let (generation_guard, ctx, fixture) = @@ -1741,6 +1902,70 @@ network_policies: fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn passthrough_relay_injects_token_exchange_authorization_header() { + let (generation_guard, ctx, fixture) = + passthrough_token_exchange_relay_context(Ok("grant-token")); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + ) + .await + }); + + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut upstream_request = [0u8; 1024]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("request should reach upstream") + .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + + assert!(upstream_request.starts_with("GET /v1/projects HTTP/1.1\r\n")); + assert!(upstream_request.contains("Authorization: Bearer grant-token\r\n")); + assert!(!upstream_request.contains("stale-token")); + assert_eq!(authorization_header_count(&upstream_request), 1); + + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + #[tokio::test] async fn passthrough_relay_token_grant_failure_returns_bad_gateway_without_forwarding() { let (generation_guard, ctx, fixture) = diff --git a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs index 0d7c18e99..14f01f0b1 100644 --- a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs +++ b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs @@ -26,6 +26,9 @@ pub struct TokenGrantRequest<'a> { pub audience: &'a str, pub scopes: &'a [String], pub cache_ttl_seconds: i64, + pub grant_type: i32, + pub subject_token_type: &'a str, + pub requested_token_type: &'a str, } pub trait TokenGrantResolver: Send + Sync { @@ -45,13 +48,18 @@ impl TokenGrantResolver for SpiffeTokenGrantResolver { ) -> Pin> + Send + 'a>> { Box::pin(async move { crate::token_grant::obtain_provider_token( - request.provider_key, - request.token_endpoint, - request.jwt_svid_audience, - request.client_assertion_type, - request.audience, - request.scopes, - request.cache_ttl_seconds, + crate::token_grant::ObtainProviderTokenRequest { + provider_name: request.provider_key, + token_endpoint: request.token_endpoint, + jwt_svid_audience: request.jwt_svid_audience, + client_assertion_type: request.client_assertion_type, + audience: request.audience, + scopes: request.scopes, + cache_ttl_override: request.cache_ttl_seconds, + grant_type: request.grant_type, + subject_token_type: request.subject_token_type, + requested_token_type: request.requested_token_type, + }, ) .await }) @@ -127,7 +135,7 @@ pub async fn inject_if_needed(req: L7Request, ctx: &L7EvalContext) -> Result( audience: &token_grant.audience, scopes: &token_grant.scopes, cache_ttl_seconds: token_grant.cache_ttl_seconds, + grant_type: token_grant.grant_type, + subject_token_type: token_grant + .subject_token + .as_ref() + .map_or("", |subject| subject.subject_token_type.as_str()), + requested_token_type: &token_grant.requested_token_type, } } @@ -377,7 +391,10 @@ fn inject_header(raw_header: &[u8], header_name: &str, header_value: &str) -> Re #[cfg(test)] pub mod test_support { use super::*; - use openshell_core::proto::{ProviderCredentialTokenGrant, ProviderProfileCredential}; + use openshell_core::proto::{ + ProviderCredentialTokenGrant, ProviderCredentialTokenGrantSubjectToken, + ProviderCredentialTokenGrantType, ProviderProfileCredential, + }; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -395,6 +412,9 @@ pub mod test_support { audience: String, scopes: Vec, cache_ttl_seconds: i64, + grant_type: i32, + subject_token_type: String, + requested_token_type: String, } pub struct TokenGrantTestFixture { @@ -408,11 +428,27 @@ pub mod test_support { Self::new(key, Ok(token)) } + pub fn success_token_exchange(key: &str, token: &str) -> Self { + Self::new_with_grant(key, Ok(token), token_exchange_grant()) + } + pub fn failure(key: &str, error: &str) -> Self { Self::new(key, Err(error)) } + pub fn failure_token_exchange(key: &str, error: &str) -> Self { + Self::new_with_grant(key, Err(error), token_exchange_grant()) + } + fn new(key: &str, response: std::result::Result<&str, &str>) -> Self { + Self::new_with_grant(key, response, token_grant()) + } + + fn new_with_grant( + key: &str, + response: std::result::Result<&str, &str>, + token_grant: ProviderCredentialTokenGrant, + ) -> Self { let requests = Arc::new(Mutex::new(Vec::new())); let resolver = Arc::new(FakeTokenGrantResolver { requests: requests.clone(), @@ -426,7 +462,7 @@ pub mod test_support { name: "access_token".to_string(), auth_style: "bearer".to_string(), header_name: "Authorization".to_string(), - token_grant: Some(token_grant()), + token_grant: Some(token_grant), ..Default::default() }, ); @@ -466,6 +502,44 @@ pub mod test_support { assert_eq!(request.audience, "api://example"); assert_eq!(request.scopes, ["read"]); assert_eq!(request.cache_ttl_seconds, 300); + assert_eq!( + request.grant_type, + ProviderCredentialTokenGrantType::ClientCredentials as i32 + ); + assert!(request.subject_token_type.is_empty()); + assert!(request.requested_token_type.is_empty()); + } + + pub fn assert_one_token_exchange_request(&self, expected_provider_key: &str) { + let requests = self + .requests + .lock() + .expect("fake token grant requests lock poisoned"); + assert_eq!(requests.len(), 1); + + let request = &requests[0]; + assert_eq!(request.provider_key, expected_provider_key); + assert_eq!(request.token_endpoint, "https://auth.example.com/token"); + assert_eq!(request.jwt_svid_audience, "https://auth.example.com"); + assert_eq!( + request.client_assertion_type, + "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" + ); + assert_eq!(request.audience, "api://example"); + assert_eq!(request.scopes, ["read"]); + assert_eq!(request.cache_ttl_seconds, 300); + assert_eq!( + request.grant_type, + ProviderCredentialTokenGrantType::TokenExchange as i32 + ); + assert_eq!( + request.subject_token_type, + "urn:ietf:params:oauth:token-type:access_token" + ); + assert_eq!( + request.requested_token_type, + "urn:ietf:params:oauth:token-type:access_token" + ); } } @@ -479,6 +553,24 @@ pub mod test_support { scopes: vec!["read".to_string()], cache_ttl_seconds: 300, audience_overrides: Vec::new(), + grant_type: ProviderCredentialTokenGrantType::ClientCredentials as i32, + subject_token: None, + requested_token_type: String::new(), + } + } + + fn token_exchange_grant() -> ProviderCredentialTokenGrant { + ProviderCredentialTokenGrant { + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" + .to_string(), + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "user_oidc_token".to_string(), + subject_token_type: "urn:ietf:params:oauth:token-type:access_token".to_string(), + }), + requested_token_type: "urn:ietf:params:oauth:token-type:access_token".to_string(), + ..token_grant() } } @@ -495,6 +587,9 @@ pub mod test_support { audience: request.audience.to_string(), scopes: request.scopes.to_vec(), cache_ttl_seconds: request.cache_ttl_seconds, + grant_type: request.grant_type, + subject_token_type: request.subject_token_type.to_string(), + requested_token_type: request.requested_token_type.to_string(), }; Box::pin(async move { self.requests @@ -531,6 +626,12 @@ mod tests { 443, "/repos/owner/repo" )); + assert!(dynamic_credential_key_matches( + "api.example.com\t443\t/repos/**\trev:42\tgithub:access_token", + "api.example.com", + 443, + "/repos/owner/repo" + )); assert!(!dynamic_credential_key_matches( key, "uploads.example.com", @@ -754,6 +855,45 @@ mod tests { fixture.assert_one_request("api.example.com\t443\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn inject_if_needed_passes_token_exchange_grant_to_resolver() { + let fixture = TokenGrantTestFixture::success_token_exchange( + "api.example.com\t443\t/v1/**\tprovider:access_token", + "grant-token", + ); + + let ctx = L7EvalContext { + host: "api.example.com".into(), + port: 443, + policy_name: "api".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: Some(fixture.dynamic_credentials()), + token_grant_resolver: Some(fixture.resolver()), + }; + let req = L7Request { + action: "GET".to_string(), + target: "/v1/projects".to_string(), + query_params: std::collections::HashMap::new(), + raw_header: b"GET /v1/projects HTTP/1.1\r\nHost: api.example.com\r\n\r\n".to_vec(), + body_length: BodyLength::None, + }; + + let rewritten = inject_if_needed(req, &ctx) + .await + .expect("fake token exchange grant should inject"); + let rewritten = + String::from_utf8(rewritten.raw_header).expect("rewritten request should be UTF-8"); + + assert!(rewritten.contains("Authorization: Bearer grant-token\r\n")); + fixture.assert_one_token_exchange_request( + "api.example.com\t443\t/v1/**\tprovider:access_token", + ); + } + #[tokio::test] async fn inject_if_needed_rejects_malformed_resolver_token() { let fixture = TokenGrantTestFixture::success( diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index a559a57e6..c995b86ab 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -16,5 +16,4 @@ pub mod policy_local; pub mod procfs; pub mod proxy; pub mod run; -mod spiffe_endpoint; mod token_grant; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 691382469..8010428a1 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -12,7 +12,7 @@ use openshell_core::activity::{ActivitySender, try_record_activity}; use openshell_core::denial::DenialEvent; use openshell_core::net::{is_always_blocked_ip, is_internal_ip, is_link_local_ip}; use openshell_core::policy::ProxyPolicy; -use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_core::provider_credentials::{ProviderCredentialSnapshot, ProviderCredentialState}; use openshell_core::secrets::{SecretResolver, rewrite_header_line_checked}; use openshell_ocsf::{ ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, @@ -53,6 +53,27 @@ const HOST_GATEWAY_ALIASES: &[&str] = &[ "host.docker.internal", ]; +fn revision_scoped_dynamic_credentials( + snapshot: &ProviderCredentialSnapshot, +) -> std::collections::HashMap { + snapshot + .dynamic_credentials + .iter() + .map(|(key, credential)| { + let scoped_key = key.rsplit_once('\t').map_or_else( + || format!("rev:{}\t{key}", snapshot.revision), + |(endpoint_selector, provider_credential)| { + format!( + "{endpoint_selector}\trev:{}\t{provider_credential}", + snapshot.revision + ) + }, + ); + (scoped_key, credential.clone()) + }) + .collect() +} + /// Cloud instance metadata IPs that are NEVER exempted from SSRF blocking, /// even when they coincidentally match a host-gateway alias resolution. /// This list covers the well-known IMDS endpoints across major cloud providers. @@ -252,9 +273,9 @@ impl ProxyHandle { .as_ref() .and_then(ProviderCredentialState::resolver); let dynamic_credentials = provider_credentials.as_ref().map(|state| { - Arc::new(std::sync::RwLock::new( - state.snapshot().dynamic_credentials.clone(), - )) + Arc::new(std::sync::RwLock::new(revision_scoped_dynamic_credentials( + &state.snapshot(), + ))) }); let dtx = denial_tx.clone(); let atx = activity_tx.clone(); @@ -4386,6 +4407,29 @@ network_policies: ); } + #[test] + fn revision_scoped_dynamic_credentials_preserves_endpoint_selector_and_adds_revision() { + let mut dynamic_credentials = std::collections::HashMap::new(); + dynamic_credentials.insert( + "api.example.test\t443\t/v1/**\tprovider:access_token".to_string(), + openshell_core::proto::ProviderProfileCredential { + name: "access_token".to_string(), + ..Default::default() + }, + ); + let snapshot = ProviderCredentialSnapshot { + revision: 42, + child_env: std::collections::HashMap::new(), + dynamic_credentials, + }; + + let scoped = revision_scoped_dynamic_credentials(&snapshot); + + assert!( + scoped.contains_key("api.example.test\t443\t/v1/**\trev:42\tprovider:access_token") + ); + } + #[test] fn connect_activity_is_skipped_when_l7_will_count_the_request() { let (tx, mut rx) = mpsc::channel(4); @@ -4556,20 +4600,7 @@ network_policies: crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, ) { let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; - let fixture = match resolver_response { - Ok(token) => { - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( - provider_key, - token, - ) - } - Err(error) => { - crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure( - provider_key, - error, - ) - } - }; + let fixture = forward_token_grant_fixture(provider_key, resolver_response, false); let ctx = crate::l7::relay::L7EvalContext { host: "api.example.test".into(), port: 8080, @@ -4586,6 +4617,54 @@ network_policies: (ctx, fixture) } + fn forward_token_exchange_context( + resolver_response: std::result::Result<&str, &str>, + ) -> ( + crate::l7::relay::L7EvalContext, + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture, + ) { + let (mut ctx, _) = forward_token_grant_context(Ok("unused-token")); + let provider_key = "api.example.test\t8080\t/v1/**\tprovider:access_token"; + let fixture = forward_token_grant_fixture(provider_key, resolver_response, true); + ctx.dynamic_credentials = Some(fixture.dynamic_credentials()); + ctx.token_grant_resolver = Some(fixture.resolver()); + + (ctx, fixture) + } + + fn forward_token_grant_fixture( + provider_key: &str, + resolver_response: std::result::Result<&str, &str>, + token_exchange: bool, + ) -> crate::l7::token_grant_injection::test_support::TokenGrantTestFixture { + match (resolver_response, token_exchange) { + (Ok(token), false) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success( + provider_key, + token, + ) + } + (Ok(token), true) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::success_token_exchange( + provider_key, + token, + ) + } + (Err(error), false) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure( + provider_key, + error, + ) + } + (Err(error), true) => { + crate::l7::token_grant_injection::test_support::TokenGrantTestFixture::failure_token_exchange( + provider_key, + error, + ) + } + } + } + fn authorization_header_count(headers: &str) -> usize { headers .lines() @@ -6939,6 +7018,28 @@ network_policies: fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn forward_proxy_injects_token_exchange_before_rewriting_request() { + let (ctx, fixture) = forward_token_exchange_context(Ok("grant-token")); + let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n".to_vec(); + + let with_token = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx) + .await + .expect("forward token exchange should inject"); + let rewritten = + rewrite_forward_request(&with_token, with_token.len(), "/v1/projects", None, false) + .expect("forward request should rewrite"); + let rewritten = String::from_utf8_lossy(&rewritten); + + assert!(rewritten.starts_with("GET /v1/projects HTTP/1.1\r\n")); + assert!(rewritten.contains("Authorization: Bearer grant-token\r\n")); + assert!(!rewritten.contains("stale-token")); + assert_eq!(authorization_header_count(&rewritten), 1); + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + #[tokio::test] async fn forward_proxy_token_grant_failure_returns_error_before_rewrite() { let (ctx, fixture) = forward_token_grant_context(Err("oauth unavailable")); @@ -6953,6 +7054,22 @@ network_policies: fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn forward_proxy_token_exchange_failure_returns_error_before_rewrite() { + let (ctx, fixture) = forward_token_exchange_context(Err("oauth unavailable")); + let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nConnection: close\r\n\r\n".to_vec(); + + let err = inject_token_grant_for_forward_request("GET", "/v1/projects", raw, &ctx) + .await + .expect_err("forward token exchange failure should stop request rewriting"); + + assert!(err.to_string().contains("Token grant failed")); + assert!(err.to_string().contains("oauth unavailable")); + fixture.assert_one_token_exchange_request( + "api.example.test\t8080\t/v1/**\tprovider:access_token", + ); + } + #[test] fn test_rewrite_get_request() { let raw = diff --git a/crates/openshell-supervisor-network/src/spiffe_endpoint.rs b/crates/openshell-supervisor-network/src/spiffe_endpoint.rs deleted file mode 100644 index 449462627..000000000 --- a/crates/openshell-supervisor-network/src/spiffe_endpoint.rs +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::path::Path; - -/// Convert a path to a SPIFFE Workload API endpoint URL. -/// -/// If the path already has a scheme (`unix:` or `tcp:`), use it as-is. -/// Otherwise, assume it is a Unix socket path and prepend `unix:`. -pub fn workload_api_endpoint(path: &Path) -> String { - let path = path.to_string_lossy(); - if path.starts_with("unix:") || path.starts_with("tcp:") { - path.into_owned() - } else { - format!("unix:{path}") - } -} diff --git a/crates/openshell-supervisor-network/src/token_grant.rs b/crates/openshell-supervisor-network/src/token_grant.rs index 03e9bfb39..f8d09e60b 100644 --- a/crates/openshell-supervisor-network/src/token_grant.rs +++ b/crates/openshell-supervisor-network/src/token_grant.rs @@ -39,6 +39,7 @@ use std::sync::{Arc, LazyLock, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use miette::{IntoDiagnostic, Result, WrapErr}; +use openshell_core::proto::ProviderCredentialTokenGrantType; use openshell_core::sandbox_env; use serde::Deserialize; use spiffe::WorkloadApiClient; @@ -60,6 +61,7 @@ const TOKEN_CACHE_EXPIRY_SKEW_SECONDS: i64 = 30; const MAX_TOKEN_EXPIRES_IN_SECONDS: i64 = 3600; const DEFAULT_CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; +const DEFAULT_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token"; /// `OAuth2` token response from the authorization server. #[derive(Debug, Clone, Deserialize)] @@ -153,41 +155,87 @@ impl TokenCache { /// - JWT-SVID fetch fails /// - Token service request fails /// - Token response is invalid -pub async fn obtain_provider_token( - provider_name: &str, - token_endpoint: &str, - jwt_svid_audience: &str, - client_assertion_type: &str, - audience: &str, - scopes: &[String], - cache_ttl_override: i64, -) -> Result { +pub struct ObtainProviderTokenRequest<'a> { + pub provider_name: &'a str, + pub token_endpoint: &'a str, + pub jwt_svid_audience: &'a str, + pub client_assertion_type: &'a str, + pub audience: &'a str, + pub scopes: &'a [String], + pub cache_ttl_override: i64, + pub grant_type: i32, + pub subject_token_type: &'a str, + pub requested_token_type: &'a str, +} + +pub async fn obtain_provider_token(request: ObtainProviderTokenRequest<'_>) -> Result { + let grant_type = ProviderCredentialTokenGrantType::try_from(request.grant_type) + .unwrap_or(ProviderCredentialTokenGrantType::ClientCredentials); obtain_provider_token_with_grant( ObtainProviderTokenInput { cache: &TOKEN_CACHE, - provider_name, - token_endpoint, - jwt_svid_audience, - client_assertion_type, - audience, - scopes, - cache_ttl_override, + provider_name: request.provider_name, + token_endpoint: request.token_endpoint, + jwt_svid_audience: request.jwt_svid_audience, + client_assertion_type: request.client_assertion_type, + audience: request.audience, + scopes: request.scopes, + cache_ttl_override: request.cache_ttl_override, + grant_type, + requested_token_type: request.requested_token_type, }, |jwt_audience| async move { // Fetch JWT-SVID with authorization server as audience // For RFC 7523, the JWT assertion's aud claim identifies the issuer/realm let jwt_svid = fetch_jwt_svid_for_token_grant(&jwt_audience).await?; - // Perform OAuth2 JWT client assertion grant - // The audience parameter in the token request specifies the resource server - perform_token_grant( - token_endpoint, - &jwt_svid, - client_assertion_type, - audience, - scopes, - ) - .await + match grant_type { + ProviderCredentialTokenGrantType::ClientCredentials + | ProviderCredentialTokenGrantType::Unspecified => { + // Perform OAuth2 JWT client assertion grant. The audience + // parameter in the token request specifies the resource server. + perform_token_grant( + request.token_endpoint, + &jwt_svid, + request.client_assertion_type, + request.audience, + request.scopes, + ) + .await + } + ProviderCredentialTokenGrantType::TokenExchange => { + let (provider, credential_key) = + parse_provider_credential_key(request.provider_name)?; + let endpoint = supervisor_gateway_endpoint_from_env()?; + let sandbox_id = supervisor_sandbox_id_from_env()?; + let intermediate = + openshell_core::grpc_client::exchange_provider_subject_token( + &endpoint, + &sandbox_id, + provider, + credential_key, + &jwt_svid, + ) + .await + .map_err(|err| { + miette::miette!( + "gateway intermediate provider token exchange failed: {err}" + ) + })?; + validate_access_token(&intermediate.access_token)?; + perform_token_exchange( + request.token_endpoint, + &jwt_svid, + request.client_assertion_type, + &intermediate.access_token, + request.subject_token_type, + request.audience, + request.scopes, + request.requested_token_type, + ) + .await + } + } }, ) .await @@ -202,6 +250,8 @@ struct ObtainProviderTokenInput<'a> { audience: &'a str, scopes: &'a [String], cache_ttl_override: i64, + grant_type: ProviderCredentialTokenGrantType, + requested_token_type: &'a str, } async fn obtain_provider_token_with_grant( @@ -216,14 +266,16 @@ where // For Keycloak: https://auth.example.com/realms/openshell/protocol/openid-connect/token // -> https://auth.example.com/realms/openshell let jwt_audience = effective_jwt_svid_audience(input.token_endpoint, input.jwt_svid_audience); - let cache_key = token_cache_key( - input.provider_name, - input.token_endpoint, - &jwt_audience, - effective_client_assertion_type(input.client_assertion_type), - input.audience, - input.scopes, - ); + let cache_key = token_cache_key(TokenCacheKeyInput { + provider_name: input.provider_name, + token_endpoint: input.token_endpoint, + jwt_svid_audience: &jwt_audience, + client_assertion_type: effective_client_assertion_type(input.client_assertion_type), + audience: input.audience, + scopes: input.scopes, + grant_type: input.grant_type, + requested_token_type: effective_token_type(input.requested_token_type), + }); // Check cache first if let Some(cached) = input.cache.get(&cache_key) { @@ -256,7 +308,7 @@ async fn fetch_jwt_svid_for_token_grant(audience: &str) -> Result { let socket_path = provider_spiffe_workload_api_socket_from_env()?; let endpoint = - crate::spiffe_endpoint::workload_api_endpoint(std::path::Path::new(&socket_path)); + openshell_core::spiffe::workload_api_endpoint(std::path::Path::new(&socket_path)); // Connect to SPIRE agent let client = WorkloadApiClient::connect_to(&endpoint) @@ -359,6 +411,74 @@ async fn perform_token_grant( Ok(token_response) } +#[allow(clippy::too_many_arguments)] +async fn perform_token_exchange( + token_endpoint: &str, + jwt_svid: &str, + client_assertion_type: &str, + subject_token: &str, + subject_token_type: &str, + audience: &str, + scopes: &[String], + requested_token_type: &str, +) -> Result { + let token_endpoint_url = parse_token_endpoint_url(token_endpoint)?; + let client_assertion_type = effective_client_assertion_type(client_assertion_type); + let subject_token_type = effective_token_type(subject_token_type); + let requested_token_type = effective_token_type(requested_token_type); + let mut form_params = vec![ + ( + "grant_type", + "urn:ietf:params:oauth:grant-type:token-exchange", + ), + ("client_assertion_type", client_assertion_type), + ("client_assertion", jwt_svid), + ("subject_token", subject_token), + ("subject_token_type", subject_token_type), + ("requested_token_type", requested_token_type), + ]; + + let audience_param; + if !audience.is_empty() { + audience_param = audience.to_string(); + form_params.push(("audience", &audience_param)); + } + + let scope_param; + if !scopes.is_empty() { + scope_param = scopes.join(" "); + form_params.push(("scope", &scope_param)); + } + + let response = TOKEN_GRANT_HTTP_CLIENT + .post(token_endpoint_url) + .form(&form_params) + .send() + .await + .into_diagnostic() + .wrap_err_with(|| format!("failed to POST token exchange to {token_endpoint}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(miette::miette!( + "{}", + token_grant_failure_message(status, &body) + )); + } + + let token_response = response + .json::() + .await + .into_diagnostic() + .wrap_err("failed to parse token exchange response as JSON")?; + validate_access_token(&token_response.access_token)?; + Ok(token_response) +} + fn parse_token_endpoint_url(token_endpoint: &str) -> Result { let url = reqwest::Url::parse(token_endpoint) .into_diagnostic() @@ -491,22 +611,59 @@ fn effective_client_assertion_type(client_assertion_type: &str) -> &str { } } -fn token_cache_key( - provider_name: &str, - token_endpoint: &str, - jwt_svid_audience: &str, - client_assertion_type: &str, - audience: &str, - scopes: &[String], -) -> String { +fn effective_token_type(token_type: &str) -> &str { + if token_type.trim().is_empty() { + DEFAULT_TOKEN_TYPE + } else { + token_type + } +} + +fn supervisor_gateway_endpoint_from_env() -> Result { + std::env::var(sandbox_env::ENDPOINT) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| miette::miette!("{} not set", sandbox_env::ENDPOINT)) +} + +fn supervisor_sandbox_id_from_env() -> Result { + std::env::var(sandbox_env::SANDBOX_ID) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| miette::miette!("{} not set", sandbox_env::SANDBOX_ID)) +} + +fn parse_provider_credential_key(key: &str) -> Result<(&str, &str)> { + let provider_and_credential = key + .rsplit_once('\t') + .map_or(key, |(_, provider_and_credential)| provider_and_credential); + provider_and_credential.split_once(':').ok_or_else(|| { + miette::miette!("dynamic token grant key is missing provider credential identity") + }) +} + +struct TokenCacheKeyInput<'a> { + provider_name: &'a str, + token_endpoint: &'a str, + jwt_svid_audience: &'a str, + client_assertion_type: &'a str, + audience: &'a str, + scopes: &'a [String], + grant_type: ProviderCredentialTokenGrantType, + requested_token_type: &'a str, +} + +fn token_cache_key(input: TokenCacheKeyInput<'_>) -> String { format!( - "{}\t{}\t{}\t{}\t{}\t{}", - provider_name, - token_endpoint, - jwt_svid_audience, - client_assertion_type, - audience, - scopes.join(" ") + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + input.provider_name, + input.token_endpoint, + input.jwt_svid_audience, + input.client_assertion_type, + input.audience, + input.scopes.join(" "), + input.grant_type as i32, + input.requested_token_type ) } @@ -778,6 +935,8 @@ mod tests { audience: input.audience, scopes: input.scopes, cache_ttl_override: input.cache_ttl_override, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: "", }, move |_| { let grant_calls = input.grant_calls.clone(); @@ -814,6 +973,8 @@ mod tests { audience, scopes, cache_ttl_override, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: "", }, |_| async { Err(miette::miette!("grant should not be called on cache hit")) }, ) @@ -916,44 +1077,99 @@ mod tests { #[test] fn token_cache_key_varies_by_resource_audience_and_scopes() { - let base = token_cache_key( - "alpha.default.svc.cluster.local\t80\t\tprovider:access_token", - "https://auth.example.com/realms/openshell/protocol/openid-connect/token", - "https://auth.example.com/realms/openshell", - DEFAULT_CLIENT_ASSERTION_TYPE, - "alpha", - &["alpha".to_string()], - ); - let different_audience = token_cache_key( - "alpha.default.svc.cluster.local\t80\t\tprovider:access_token", - "https://auth.example.com/realms/openshell/protocol/openid-connect/token", - "https://auth.example.com/realms/openshell", - DEFAULT_CLIENT_ASSERTION_TYPE, - "delta", - &["alpha".to_string()], - ); - let different_scopes = token_cache_key( - "alpha.default.svc.cluster.local\t80\t\tprovider:access_token", - "https://auth.example.com/realms/openshell/protocol/openid-connect/token", - "https://auth.example.com/realms/openshell", - DEFAULT_CLIENT_ASSERTION_TYPE, - "alpha", - &["delta".to_string()], - ); - let different_assertion_type = token_cache_key( - "alpha.default.svc.cluster.local\t80\t\tprovider:access_token", - "https://auth.example.com/realms/openshell/protocol/openid-connect/token", - "https://auth.example.com/realms/openshell", - "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", - "alpha", - &["alpha".to_string()], - ); + let provider_name = "alpha.default.svc.cluster.local\t80\t\tprovider:access_token"; + let token_endpoint = + "https://auth.example.com/realms/openshell/protocol/openid-connect/token"; + let jwt_svid_audience = "https://auth.example.com/realms/openshell"; + let alpha_scopes = ["alpha".to_string()]; + let delta_scopes = ["delta".to_string()]; + let base = token_cache_key(TokenCacheKeyInput { + provider_name, + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "alpha", + scopes: &alpha_scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: DEFAULT_TOKEN_TYPE, + }); + let different_audience = token_cache_key(TokenCacheKeyInput { + provider_name, + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "delta", + scopes: &alpha_scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: DEFAULT_TOKEN_TYPE, + }); + let different_scopes = token_cache_key(TokenCacheKeyInput { + provider_name, + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "alpha", + scopes: &delta_scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: DEFAULT_TOKEN_TYPE, + }); + let different_assertion_type = token_cache_key(TokenCacheKeyInput { + provider_name, + token_endpoint, + jwt_svid_audience, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe", + audience: "alpha", + scopes: &alpha_scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: DEFAULT_TOKEN_TYPE, + }); assert_ne!(base, different_audience); assert_ne!(base, different_scopes); assert_ne!(base, different_assertion_type); } + #[test] + fn token_cache_key_varies_by_provider_env_revision_prefix() { + let token_endpoint = + "https://auth.example.com/realms/openshell/protocol/openid-connect/token"; + let jwt_svid_audience = "https://auth.example.com/realms/openshell"; + let scopes = ["alpha".to_string()]; + let revision_one = token_cache_key(TokenCacheKeyInput { + provider_name: "api.example.test\t443\t/v1/**\trev:1\tprovider:access_token", + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "alpha", + scopes: &scopes, + grant_type: ProviderCredentialTokenGrantType::TokenExchange, + requested_token_type: DEFAULT_TOKEN_TYPE, + }); + let revision_two = token_cache_key(TokenCacheKeyInput { + provider_name: "api.example.test\t443\t/v1/**\trev:2\tprovider:access_token", + token_endpoint, + jwt_svid_audience, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, + audience: "alpha", + scopes: &scopes, + grant_type: ProviderCredentialTokenGrantType::TokenExchange, + requested_token_type: DEFAULT_TOKEN_TYPE, + }); + + assert_ne!(revision_one, revision_two); + } + + #[test] + fn provider_credential_key_parser_ignores_revision_segment() { + assert_eq!( + parse_provider_credential_key( + "api.example.test\t443\t/v1/**\trev:42\tprovider:access_token" + ) + .expect("parse provider credential key"), + ("provider", "access_token") + ); + } + #[test] fn token_cache_ttl_uses_override_without_endpoint_skew() { assert_eq!(token_cache_ttl_seconds(120, 10), 120); @@ -1077,14 +1293,16 @@ mod tests { let jwt_svid_audience = "https://auth.example.com"; let audience = "api://resource"; - let cache_key = token_cache_key( + let cache_key = token_cache_key(TokenCacheKeyInput { provider_name, token_endpoint, jwt_svid_audience, - DEFAULT_CLIENT_ASSERTION_TYPE, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, audience, - &scopes, - ); + scopes: &scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: DEFAULT_TOKEN_TYPE, + }); cache.set( cache_key, "expired-token".to_string(), @@ -1128,6 +1346,8 @@ mod tests { audience, scopes: &scopes, cache_ttl_override: 0, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: "", }, |_| async { Ok(TokenResponse { @@ -1141,14 +1361,16 @@ mod tests { .await .expect_err("malformed access token should fail before caching"); - let cache_key = token_cache_key( + let cache_key = token_cache_key(TokenCacheKeyInput { provider_name, token_endpoint, jwt_svid_audience, - DEFAULT_CLIENT_ASSERTION_TYPE, + client_assertion_type: DEFAULT_CLIENT_ASSERTION_TYPE, audience, - &scopes, - ); + scopes: &scopes, + grant_type: ProviderCredentialTokenGrantType::ClientCredentials, + requested_token_type: DEFAULT_TOKEN_TYPE, + }); assert_eq!( err.to_string(), diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index 9f9fe1822..a2c067907 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -9,14 +9,14 @@ use crate::managed_children; #[cfg(target_os = "linux")] use crate::netns::NetworkNamespace; use crate::sandbox; -use miette::{IntoDiagnostic, Result}; +use miette::{IntoDiagnostic, Result, WrapErr}; use nix::sys::signal::{self, Signal}; use nix::unistd::{Group, Pid, User}; use openshell_core::policy::{NetworkMode, SandboxPolicy}; use std::collections::HashMap; use std::ffi::CString; #[cfg(target_os = "linux")] -use std::os::fd::{AsRawFd, OwnedFd, RawFd}; +use std::os::fd::RawFd; #[cfg(target_os = "linux")] use std::os::unix::ffi::OsStrExt; #[cfg(any(test, unix))] @@ -25,6 +25,8 @@ use std::path::PathBuf; use std::process::Stdio; #[cfg(target_os = "linux")] use std::sync::OnceLock; +#[cfg(target_os = "linux")] +use std::sync::mpsc; use tokio::process::{Child, Command}; use tracing::debug; @@ -155,19 +157,22 @@ fn parse_pids_max(contents: &str) -> RuntimePidLimitStatus { } } -// Pins the pre-seccomp child mount namespace where supervisor identity sockets -// are shadowed. Children enter it with setns before dropping privileges. +// Pins the mount target used to hide supervisor identity sockets from child +// processes. Each child creates a private mount namespace and shadows the +// target before dropping privileges. #[cfg(target_os = "linux")] static SUPERVISOR_IDENTITY_MOUNT_NS: OnceLock> = OnceLock::new(); #[cfg(target_os = "linux")] pub struct SupervisorIdentityMountNamespace { - fd: OwnedFd, + spawn_tx: mpsc::Sender, } #[cfg(target_os = "linux")] type SupervisorIdentityNsRef = &'static SupervisorIdentityMountNamespace; +#[cfg(target_os = "linux")] +type SupervisorIdentitySpawnJob = Box; #[cfg(target_os = "linux")] impl SupervisorIdentityMountNamespace { @@ -176,13 +181,9 @@ impl SupervisorIdentityMountNamespace { return Ok(None); }; Ok(Some(Self { - fd: create_supervisor_identity_mount_namespace(&target)?, + spawn_tx: start_supervisor_identity_spawn_worker(target)?, })) } - - pub fn enter_for_child(&self) -> std::io::Result<()> { - set_mount_namespace(self.fd.as_raw_fd()) - } } #[cfg(target_os = "linux")] @@ -213,6 +214,100 @@ pub fn supervisor_identity_mount_from_env() -> Result std::io::Result { + let namespace = supervisor_identity_mount_from_env() + .map_err(|err| std::io::Error::other(err.to_string()))?; + let Some(namespace) = namespace else { + return cmd.spawn(); + }; + namespace.spawn_tokio_command(cmd) +} + +#[cfg(target_os = "linux")] +pub fn spawn_std_command_with_supervisor_identity_namespace( + mut cmd: std::process::Command, +) -> std::io::Result { + let namespace = supervisor_identity_mount_from_env() + .map_err(|err| std::io::Error::other(err.to_string()))?; + let Some(namespace) = namespace else { + return cmd.spawn(); + }; + namespace.spawn_std_command(cmd) +} + +#[cfg(target_os = "linux")] +impl SupervisorIdentityMountNamespace { + fn spawn_tokio_command(&self, mut cmd: Command) -> std::io::Result { + let (result_tx, result_rx) = mpsc::channel(); + let handle = tokio::runtime::Handle::current(); + self.spawn_tx + .send(Box::new(move || { + let _guard = handle.enter(); + let _ = result_tx.send(cmd.spawn()); + })) + .map_err(|_| std::io::Error::other("supervisor identity spawn worker stopped"))?; + result_rx + .recv() + .map_err(|_| std::io::Error::other("supervisor identity spawn worker dropped result"))? + } + + fn spawn_std_command( + &self, + mut cmd: std::process::Command, + ) -> std::io::Result { + let (result_tx, result_rx) = mpsc::channel(); + self.spawn_tx + .send(Box::new(move || { + let _ = result_tx.send(cmd.spawn()); + })) + .map_err(|_| std::io::Error::other("supervisor identity spawn worker stopped"))?; + result_rx + .recv() + .map_err(|_| std::io::Error::other("supervisor identity spawn worker dropped result"))? + } +} + +#[cfg(target_os = "linux")] +fn start_supervisor_identity_spawn_worker( + target: PathBuf, +) -> Result> { + let (spawn_tx, spawn_rx) = mpsc::channel::(); + let (ready_tx, ready_rx) = mpsc::channel::>(); + std::thread::Builder::new() + .name("openshell-identity-spawn".into()) + .spawn(move || { + let setup = (|| -> std::io::Result<()> { + private_mount_namespace()?; + let target = + cstring_path(&target).map_err(|err| std::io::Error::other(err.to_string()))?; + mount_empty_tmpfs(&target) + })(); + let ready = match &setup { + Ok(()) => Ok(()), + Err(err) => Err(std::io::Error::new( + err.kind(), + format!("supervisor identity setup failed: {err}"), + )), + }; + let _ = ready_tx.send(ready); + if setup.is_err() { + return; + } + while let Ok(job) = spawn_rx.recv() { + job(); + } + }) + .map_err(|err| miette::miette!("failed to spawn supervisor identity worker: {err}"))?; + ready_rx + .recv() + .map_err(|err| miette::miette!("supervisor identity worker did not start: {err}"))? + .map_err(|err| miette::miette!("{err}"))?; + Ok(spawn_tx) +} + #[cfg(target_os = "linux")] fn supervisor_identity_socket_path_from_env() -> Option<(&'static str, String)> { std::env::var(openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET) @@ -233,10 +328,7 @@ fn supervisor_identity_mount_target(socket_path: &str) -> Result return Ok(None); } if trimmed.starts_with("tcp:") { - return Err(miette::miette!( - "{} must be a UNIX socket path so sandbox child processes can hide it", - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET - )); + return Ok(None); } let path = trimmed.strip_prefix("unix:").unwrap_or(trimmed); let path = Path::new(path); @@ -279,52 +371,12 @@ fn cstring_path(path: &Path) -> Result { .map_err(|_| miette::miette!("path contains an interior NUL byte: {}", path.display())) } -#[cfg(target_os = "linux")] -fn create_supervisor_identity_mount_namespace(target: &Path) -> Result { - let original_ns = open_current_mount_namespace() - .map_err(|err| miette::miette!("failed to open original mount namespace: {err}"))?; - - private_mount_namespace() - .map_err(|err| miette::miette!("failed to create supervisor identity namespace: {err}"))?; - - let target = cstring_path(target)?; - let result = (|| -> Result { - mount_empty_tmpfs(&target).map_err(|err| { - miette::miette!("failed to hide supervisor identity mount from child namespace: {err}") - })?; - open_current_mount_namespace() - .map_err(|err| miette::miette!("failed to open sanitized mount namespace: {err}")) - })(); - - set_mount_namespace(original_ns.as_raw_fd()).map_err(|restore_err| { - let result_msg = result.as_ref().err().map_or_else( - || "sanitized namespace was created".to_string(), - ToString::to_string, - ); - miette::miette!( - "failed to restore original mount namespace after supervisor identity isolation setup: \ - {restore_err}; setup result: {result_msg}" - ) - })?; - - result -} - -#[cfg(target_os = "linux")] -fn open_current_mount_namespace() -> std::io::Result { - let file = std::fs::File::open("/proc/thread-self/ns/mnt")?; - Ok(file.into()) -} - #[cfg(target_os = "linux")] fn private_mount_namespace() -> std::io::Result<()> { #[allow(unsafe_code)] let rc = unsafe { libc::unshare(libc::CLONE_NEWNS) }; if rc != 0 { - return Err(std::io::Error::other(format!( - "failed to create private mount namespace: {}", - std::io::Error::last_os_error() - ))); + return Err(std::io::Error::last_os_error()); } #[allow(unsafe_code)] @@ -339,23 +391,7 @@ fn private_mount_namespace() -> std::io::Result<()> { ) }; if rc != 0 { - return Err(std::io::Error::other(format!( - "failed to mark mount namespace private: {}", - std::io::Error::last_os_error() - ))); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn set_mount_namespace(fd: RawFd) -> std::io::Result<()> { - #[allow(unsafe_code)] - let rc = unsafe { libc::setns(fd, libc::CLONE_NEWNS) }; - if rc != 0 { - return Err(std::io::Error::other(format!( - "failed to enter mount namespace: {}", - std::io::Error::last_os_error() - ))); + return Err(std::io::Error::last_os_error()); } Ok(()) } @@ -375,10 +411,7 @@ fn mount_empty_tmpfs(target: &CString) -> std::io::Result<()> { ) }; if rc != 0 { - return Err(std::io::Error::other(format!( - "failed to hide supervisor identity mount from child process: {}", - std::io::Error::last_os_error() - ))); + return Err(std::io::Error::last_os_error()); } Ok(()) } @@ -520,11 +553,6 @@ impl ProcessHandle { #[cfg(target_os = "linux")] let prepared_sandbox = sandbox::linux::prepare(policy, workdir) .map_err(|err| miette::miette!("Failed to prepare sandbox: {err}"))?; - #[cfg(target_os = "linux")] - let supervisor_identity_mount = supervisor_identity_mount_from_env().map_err(|err| { - miette::miette!("Failed to prepare supervisor identity isolation: {err}") - })?; - // Set up process group for signal handling (non-interactive mode only). // In interactive mode, we inherit the parent's process group to maintain // proper terminal control for shells and interactive programs. @@ -544,19 +572,17 @@ impl ProcessHandle { libc::setpgid(0, 0); } - // Enter network namespace before applying other restrictions + // Enter network namespace before applying other restrictions. if let Some(fd) = netns_fd { let result = libc::setns(fd, libc::CLONE_NEWNET); if result != 0 { - return Err(std::io::Error::last_os_error()); + return Err(std::io::Error::other(format!( + "failed to enter network namespace: {}", + std::io::Error::last_os_error() + ))); } } - #[cfg(target_os = "linux")] - if let Some(mount) = supervisor_identity_mount { - mount.enter_for_child()?; - } - // Drop privileges. initgroups/setgid/setuid need access to // /etc/group and /etc/passwd which would be blocked if // Landlock were already enforced. @@ -579,7 +605,15 @@ impl ProcessHandle { } } - let child = cmd.spawn().into_diagnostic()?; + #[cfg(target_os = "linux")] + let child = spawn_command_with_supervisor_identity_namespace(cmd) + .into_diagnostic() + .wrap_err("failed to spawn sandbox entrypoint process")?; + #[cfg(not(target_os = "linux"))] + let child = cmd + .spawn() + .into_diagnostic() + .wrap_err("failed to spawn sandbox entrypoint process")?; let pid = child.id().unwrap_or(0); managed_children::register(pid); @@ -1514,7 +1548,11 @@ mod tests { #[test] fn supervisor_identity_mount_target_rejects_unhideable_endpoints() { - assert!(supervisor_identity_mount_target("tcp:127.0.0.1:8081").is_err()); + assert_eq!( + supervisor_identity_mount_target("tcp:127.0.0.1:8081") + .expect("tcp endpoint should not require mount hiding"), + None + ); assert!(supervisor_identity_mount_target("spiffe-workload-api/spire-agent.sock").is_err()); assert!(supervisor_identity_mount_target("/spire-agent.sock").is_err()); } diff --git a/crates/openshell-supervisor-process/src/run.rs b/crates/openshell-supervisor-process/src/run.rs index 5a5c203a2..0b3921701 100644 --- a/crates/openshell-supervisor-process/src/run.rs +++ b/crates/openshell-supervisor-process/src/run.rs @@ -85,10 +85,18 @@ pub async fn run_process( // the flag stays at its default (false) and no skill is installed. install_initial_agent_skill(sandbox_id, openshell_endpoint).await; + // Provider token grants may mount supervisor-only identity sockets such as + // the SPIFFE Workload API. Prepare the child mount namespace that hides + // those mounts before supervisor seccomp hardening removes the needed + // namespace syscalls. + #[cfg(target_os = "linux")] + crate::process::prepare_supervisor_identity_mount_namespace_from_env()?; + // Install the supervisor seccomp prelude before spawning any workload-side // tasks. By this point the orchestrator has finished privileged startup - // helpers (network namespace setup, nftables probes via run_networking), - // and the SSH listener and entrypoint child have not been exposed yet. + // helpers (network namespace setup, identity mount namespace setup, + // nftables probes via run_networking), and the SSH listener and entrypoint + // child have not been exposed yet. crate::sandbox::apply_supervisor_startup_hardening()?; // Spawn the bypass detection monitor. It tails dmesg for nftables LOG @@ -370,7 +378,9 @@ pub async fn run_process( handle.wait().await }; - let status = result.into_diagnostic()?; + let status = result + .into_diagnostic() + .map_err(|err| err.wrap_err("failed to wait for sandbox entrypoint process"))?; ocsf_emit!( ProcessActivityBuilder::new(ocsf_ctx()) diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 955ec780c..1ba18d145 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -815,9 +815,12 @@ fn spawn_pty_shell( netns_fd, #[cfg(target_os = "linux")] prepared_sandbox, - )?; + ); } + #[cfg(target_os = "linux")] + let mut child = crate::process::spawn_std_command_with_supervisor_identity_namespace(cmd)?; + #[cfg(not(target_os = "linux"))] let mut child = cmd.spawn()?; #[cfg(target_os = "linux")] let child_pid = child.id(); @@ -963,9 +966,12 @@ fn spawn_pipe_exec( netns_fd, #[cfg(target_os = "linux")] prepared_sandbox, - )?; + ); } + #[cfg(target_os = "linux")] + let mut child = crate::process::spawn_std_command_with_supervisor_identity_namespace(cmd)?; + #[cfg(not(target_os = "linux"))] let mut child = cmd.spawn()?; #[cfg(target_os = "linux")] let child_pid = child.id(); @@ -1100,16 +1106,11 @@ mod unsafe_pty { slave_fd: RawFd, netns_fd: Option, #[cfg(target_os = "linux")] prepared: crate::sandbox::linux::PreparedSandbox, - ) -> anyhow::Result<()> { + ) { // Wrap in Option so we can .take() it out of the FnMut closure. // pre_exec is only called once (after fork, before exec). #[cfg(target_os = "linux")] let mut prepared = Some(prepared); - #[cfg(target_os = "linux")] - let supervisor_identity_mount = crate::process::supervisor_identity_mount_from_env() - .map_err(|err| { - anyhow::anyhow!("failed to prepare supervisor identity isolation: {err}") - })?; unsafe { cmd.pre_exec(move || { setsid().map_err(|err| std::io::Error::other(err.to_string()))?; @@ -1119,13 +1120,10 @@ mod unsafe_pty { netns_fd, &policy, #[cfg(target_os = "linux")] - supervisor_identity_mount, - #[cfg(target_os = "linux")] prepared.take(), ) }); } - Ok(()) } /// Pre-exec hook for pipe-based (non-PTY) exec. @@ -1145,35 +1143,24 @@ mod unsafe_pty { _workdir: Option, netns_fd: Option, #[cfg(target_os = "linux")] prepared: crate::sandbox::linux::PreparedSandbox, - ) -> anyhow::Result<()> { + ) { #[cfg(target_os = "linux")] let mut prepared = Some(prepared); - #[cfg(target_os = "linux")] - let supervisor_identity_mount = crate::process::supervisor_identity_mount_from_env() - .map_err(|err| { - anyhow::anyhow!("failed to prepare supervisor identity isolation: {err}") - })?; unsafe { cmd.pre_exec(move || { enter_netns_and_sandbox( netns_fd, &policy, #[cfg(target_os = "linux")] - supervisor_identity_mount, - #[cfg(target_os = "linux")] prepared.take(), ) }); } - Ok(()) } fn enter_netns_and_sandbox( netns_fd: Option, policy: &SandboxPolicy, - #[cfg(target_os = "linux")] supervisor_identity_mount: Option< - &crate::process::SupervisorIdentityMountNamespace, - >, #[cfg(target_os = "linux")] prepared: Option, ) -> std::io::Result<()> { // Enter network namespace before dropping privileges. @@ -1192,11 +1179,6 @@ mod unsafe_pty { #[cfg(not(target_os = "linux"))] let _ = netns_fd; - #[cfg(target_os = "linux")] - if let Some(mount) = supervisor_identity_mount { - mount.enter_for_child()?; - } - // Drop privileges. initgroups/setgid/setuid need /etc/group and // /etc/passwd which would be blocked if Landlock were already enforced. drop_privileges(policy).map_err(|err| std::io::Error::other(err.to_string()))?; @@ -1582,8 +1564,7 @@ mod tests { None, ) .expect("prepare should succeed in test environment"), - ) - .expect("install pre_exec should succeed"); + ); let output = cmd .spawn() diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index e6d539592..c1528f617 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -125,15 +125,20 @@ JWT signing Secret. ## SPIFFE/SPIRE provider token grants -Set `server.providerTokenGrants.spiffe.enabled=true` to let sandbox supervisors -use SPIFFE JWT-SVIDs for dynamic provider token grants. The chart keeps -supervisor-to-gateway authentication on gateway-minted sandbox JWTs and passes -the SPIFFE Workload API socket path to the Kubernetes driver so sandbox pods can -mount the SPIFFE CSI socket. +Set `server.providerTokenGrants.spiffe.enabled=true` to let the gateway and +sandbox supervisors use SPIFFE JWT-SVIDs for dynamic provider token grants. The +chart keeps supervisor-to-gateway authentication on gateway-minted sandbox JWTs, +mounts the SPIFFE CSI socket into the gateway pod, exports +`OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`, and passes the socket path to +the Kubernetes driver so sandbox pods can mount the same socket. For local development, uncomment the SPIRE Helm releases in `skaffold.yaml` and add `ci/values-spire.yaml` to the OpenShell release values files. +The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the +SPIFFE Workload API, so this path does not require access to the SPIRE OIDC +discovery endpoint or its TLS CA. + ## Values | Key | Type | Default | Description | @@ -211,8 +216,8 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | server.oidc.rolesClaim | string | `""` | Dot-separated path to the roles array in the JWT claims. Keycloak: "realm_access.roles", Entra ID: "roles", Okta: "groups". | | server.oidc.scopesClaim | string | `""` | Dot-separated path to the scopes array in the JWT claims. | | server.oidc.userRole | string | `""` | Role name for standard user access. | -| server.providerTokenGrants.spiffe.enabled | bool | `false` | Mount the SPIFFE Workload API socket into sandbox pods for dynamic provider token grants. | -| server.providerTokenGrants.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | Path to the SPIFFE Workload API socket mounted into sandbox pods. | +| server.providerTokenGrants.spiffe.enabled | bool | `false` | Mount the SPIFFE Workload API socket into gateway and sandbox pods for dynamic provider token grants. | +| server.providerTokenGrants.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | Path to the SPIFFE Workload API socket mounted into gateway and sandbox pods. | | server.sandboxImage | string | `"ghcr.io/nvidia/openshell-community/sandboxes/base:latest"` | Default sandbox image used when requests do not specify one. | | server.sandboxImagePullPolicy | string | `""` | Kubernetes imagePullPolicy for sandbox pods. Empty = Kubernetes default (Always for :latest, IfNotPresent otherwise). Set to "Always" for dev clusters so new images are picked up without manual eviction. | | server.sandboxImagePullSecrets | list | `[]` | Image pull secrets attached to sandbox pods. Referenced Secrets must exist in the sandbox namespace. | diff --git a/deploy/helm/openshell/README.md.gotmpl b/deploy/helm/openshell/README.md.gotmpl index e246ca67b..66eac0c63 100644 --- a/deploy/helm/openshell/README.md.gotmpl +++ b/deploy/helm/openshell/README.md.gotmpl @@ -125,14 +125,19 @@ JWT signing Secret. ## SPIFFE/SPIRE provider token grants -Set `server.providerTokenGrants.spiffe.enabled=true` to let sandbox supervisors -use SPIFFE JWT-SVIDs for dynamic provider token grants. The chart keeps -supervisor-to-gateway authentication on gateway-minted sandbox JWTs and passes -the SPIFFE Workload API socket path to the Kubernetes driver so sandbox pods can -mount the SPIFFE CSI socket. +Set `server.providerTokenGrants.spiffe.enabled=true` to let the gateway and +sandbox supervisors use SPIFFE JWT-SVIDs for dynamic provider token grants. The +chart keeps supervisor-to-gateway authentication on gateway-minted sandbox JWTs, +mounts the SPIFFE CSI socket into the gateway pod, exports +`OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`, and passes the socket path to +the Kubernetes driver so sandbox pods can mount the same socket. For local development, uncomment the SPIRE Helm releases in `skaffold.yaml` and add `ci/values-spire.yaml` to the OpenShell release values files. +The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the +SPIFFE Workload API, so this path does not require access to the SPIRE OIDC +discovery endpoint or its TLS CA. + {{ template "chart.valuesSection" . }} {{ template "helm-docs.versionFooter" . }} diff --git a/deploy/helm/openshell/templates/_gateway-workload.tpl b/deploy/helm/openshell/templates/_gateway-workload.tpl index 5931047e5..b54112a64 100644 --- a/deploy/helm/openshell/templates/_gateway-workload.tpl +++ b/deploy/helm/openshell/templates/_gateway-workload.tpl @@ -60,7 +60,7 @@ spec: # All gateway settings live in the ConfigMap-backed TOML file # mounted at /etc/openshell/gateway.toml. The only env var below # is a process-level setting consumed by libraries outside - # gateway code (currently just SSL_CERT_FILE for OIDC issuer TLS). + # gateway code (currently SSL_CERT_FILE for OIDC issuer TLS). {{- if and .Values.server.oidc.issuer .Values.server.oidc.caConfigMapName }} # OIDC issuer custom-CA: rustls/reqwest read SSL_CERT_FILE for # outbound TLS verification. This is a process-level env var @@ -69,6 +69,10 @@ spec: - name: SSL_CERT_FILE value: /etc/openshell-tls/oidc-ca/ca.crt {{- end }} + {{- if .Values.server.providerTokenGrants.spiffe.enabled }} + - name: OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET + value: {{ .Values.server.providerTokenGrants.spiffe.workloadApiSocketPath | quote }} + {{- end }} volumeMounts: {{- if eq (include "openshell.workloadKind" .) "statefulset" }} - name: openshell-data @@ -95,6 +99,11 @@ spec: mountPath: /etc/openshell-tls/oidc-ca readOnly: true {{- end }} + {{- if .Values.server.providerTokenGrants.spiffe.enabled }} + - name: spiffe-workload-api + mountPath: {{ dir .Values.server.providerTokenGrants.spiffe.workloadApiSocketPath | quote }} + readOnly: true + {{- end }} ports: - name: grpc containerPort: {{ .Values.service.port }} @@ -162,6 +171,12 @@ spec: configMap: name: {{ .Values.server.oidc.caConfigMapName }} {{- end }} + {{- if .Values.server.providerTokenGrants.spiffe.enabled }} + - name: spiffe-workload-api + csi: + driver: csi.spiffe.io + readOnly: true + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 4 }} diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index c2708a20f..b7f0f9ce4 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -399,11 +399,39 @@ tests: path: data["gateway.toml"] pattern: '\[openshell\.gateway\.spiffe\]' - - it: keeps the gateway sandbox JWT secret mounted when provider SPIFFE grants are enabled + - it: mounts the gateway SPIFFE socket while keeping sandbox JWT auth set: server.providerTokenGrants.spiffe.enabled: true template: templates/statefulset.yaml asserts: - - matchRegex: - path: spec.template.spec.volumes[1].name - pattern: '^sandbox-jwt$' + - contains: + path: spec.template.spec.containers[0].env + content: + name: OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET + value: /spiffe-workload-api/spire-agent.sock + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: sandbox-jwt + mountPath: /etc/openshell-jwt + readOnly: true + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: spiffe-workload-api + mountPath: /spiffe-workload-api + readOnly: true + - contains: + path: spec.template.spec.volumes + content: + name: sandbox-jwt + secret: + defaultMode: 256 + secretName: openshell-jwt-keys + - contains: + path: spec.template.spec.volumes + content: + name: spiffe-workload-api + csi: + driver: csi.spiffe.io + readOnly: true diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index d7ff8b257..51f8adeff 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -255,15 +255,15 @@ server: # (owner-read only). Override to 0440 or 0444 if the container UID # does not match the volume file owner. secretDefaultMode: "" - # Dynamic provider token grants. When SPIFFE is enabled here, sandbox - # supervisors mount the SPIFFE Workload API socket so provider profiles can - # exchange JWT-SVIDs for upstream access tokens. Supervisor-to-gateway - # authentication still uses gateway-minted sandbox JWTs. + # Dynamic provider token grants. When SPIFFE is enabled here, both the + # gateway and sandbox supervisors mount the SPIFFE Workload API socket so + # token-exchange profiles can use gateway- and sandbox-scoped JWT-SVIDs. + # Supervisor-to-gateway authentication still uses gateway-minted sandbox JWTs. providerTokenGrants: spiffe: - # -- Mount the SPIFFE Workload API socket into sandbox pods for dynamic provider token grants. + # -- Mount the SPIFFE Workload API socket into gateway and sandbox pods for dynamic provider token grants. enabled: false - # -- Path to the SPIFFE Workload API socket mounted into sandbox pods. + # -- Path to the SPIFFE Workload API socket mounted into gateway and sandbox pods. workloadApiSocketPath: /spiffe-workload-api/spire-agent.sock # OIDC (OpenID Connect) configuration for JWT-based authentication. # When issuer is set, the server validates Bearer tokens on gRPC requests. diff --git a/docs/kubernetes/access-control.mdx b/docs/kubernetes/access-control.mdx index 8824b6de1..23126197a 100644 --- a/docs/kubernetes/access-control.mdx +++ b/docs/kubernetes/access-control.mdx @@ -23,9 +23,11 @@ For how the CLI resolves gateways and stores credentials, refer to [Gateway Auth Kubernetes sandbox supervisors authenticate back to the gateway as sandbox workloads. By default, the gateway mints its own sandbox JWTs and Kubernetes sandboxes bootstrap them with a projected ServiceAccount token. -Dynamic provider token grants can use SPIFFE without changing supervisor-to-gateway authentication. Set `server.providerTokenGrants.spiffe.enabled=true` to mount the SPIFFE CSI Workload API socket into sandbox pods while keeping the projected ServiceAccount token bootstrap and gateway-minted sandbox JWT path. +Dynamic provider token grants can use SPIFFE without changing supervisor-to-gateway authentication. Set `server.providerTokenGrants.spiffe.enabled=true` to mount the SPIFFE CSI Workload API socket into gateway and sandbox pods while keeping the projected ServiceAccount token bootstrap and gateway-minted sandbox JWT path. -Provider token grants require a SPIFFE implementation such as SPIRE and a `ClusterSPIFFEID` that assigns per-sandbox IDs from the pod's `openshell.io/sandbox-id` annotation. Provider profiles with `token_grant` metadata cause the sandbox supervisor to request JWT-SVIDs and exchange them for upstream OAuth2 access tokens. +Provider token grants require a SPIFFE implementation such as SPIRE and identities for the gateway and sandbox pods. The repository's local SPIRE overlay assigns sandbox IDs from the pod's `openshell.io/sandbox-id` annotation, but the gateway validation path only requires the supervisor SVID to be valid and in the same SPIFFE trust domain as the gateway SVID. Provider profiles with `token_grant` metadata cause the sandbox supervisor to request JWT-SVIDs and exchange them for upstream OAuth2 access tokens. Token-exchange profiles also require a gateway SPIFFE identity because the gateway brokers the intermediate token exchange with its own JWT-SVID. + +The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the SPIFFE Workload API, so intermediate token exchange does not require gateway access to the SPIRE OIDC discovery endpoint or its TLS CA. ## OIDC User Authentication @@ -74,6 +76,8 @@ helm upgrade openshell \ Both `adminRole` and `userRole` must be set, or both must be empty. Setting only one is not supported. +OIDC RBAC is method-level authorization. It controls which API operations a caller can perform, but provider and sandbox records are not owned by individual OIDC subjects. In shared clusters, treat provider credentials as gateway-wide resources and use separate gateways or external tenancy controls when users must not see or attach each other's providers and sandboxes. + ### Provider-specific rolesClaim paths | Provider | rolesClaim value | diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index ff4542136..238502995 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -196,6 +196,14 @@ sa_token_ttl_secs = 3600 provider_spiffe_workload_api_socket_path = "/spiffe-workload-api/spire-agent.sock" ``` +For token-exchange provider profiles, the gateway also needs access to its own +SPIFFE Workload API socket. In Helm deployments, set +`server.providerTokenGrants.spiffe.enabled=true`; the chart mounts the socket +into the gateway pod and sets `OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`. +The gateway verifies supervisor JWT-SVIDs with JWT bundles fetched from the +SPIFFE Workload API, so this validation path does not require gateway access to +the SPIRE OIDC discovery endpoint or its TLS CA. + ### Docker Sandboxes run as containers on a local bridge network. The supervisor binary is bind-mounted from the host (no in-cluster image pull required); guest mTLS material is supplied as host paths. diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 647a196a5..09bf07f38 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -60,6 +60,33 @@ openshell provider create --name my-api --type generic --credential API_KEY This looks up the current value of `$API_KEY` in your shell and stores it. +### From the Current OIDC Login + +Profile-backed token-exchange providers can store the current gateway OIDC +access token as their subject credential: + +```shell +openshell provider create \ + --name custom-api \ + --type custom-api \ + --from-oidc-token +``` + +OpenShell infers the destination credential from the provider profile when the +profile has exactly one `token_grant.subject_token.credential`. If the profile +has more than one, pass `--credential `. Refresh the stored subject token +later with: + +```shell +openshell provider update custom-api --from-oidc-token +``` + +This copies the current OIDC access token and expiry from the active gateway +login. This requires an active named gateway that was registered for OIDC. If +the stored gateway access token is expired and a refresh token is available, the +CLI refreshes it before storing the provider credential. It does not store the +OIDC refresh token in the provider. + Provider profile metadata is available for known provider types. Provider profile network policy is gateway opt-in: diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 896ac641e..abddb2b0a 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -168,6 +168,12 @@ category: data inference_capable: false credentials: + - name: user_oidc_token + description: User OIDC token used as a token-exchange subject token + env_vars: [CUSTOM_API_USER_OIDC_TOKEN] + required: true + auth_style: bearer + - name: api_token description: API access token env_vars: [CUSTOM_API_TOKEN] @@ -200,17 +206,23 @@ credentials: required: true secret: true - # Optional dynamic credential. The sandbox supervisor requests a - # SPIFFE JWT-SVID, exchanges it at token_endpoint, caches the returned - # access token, and injects it according to auth_style/header_name for - # matching endpoint traffic. + # Optional dynamic credential. The sandbox supervisor resolves this on + # demand for matching endpoint traffic, caches the returned access token, + # and injects it according to auth_style/header_name. token_grant: + # Accepted values: client_credentials, token_exchange. + grant_type: token_exchange token_endpoint: https://login.example.com/realms/custom/protocol/openid-connect/token audience: api://custom-api jwt_svid_audience: https://login.example.com/realms/custom - client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-bearer + client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-spiffe scopes: [api.read, api.write] cache_ttl_seconds: 300 + requested_token_type: urn:ietf:params:oauth:token-type:access_token + subject_token: + source: provider_credential + credential: user_oidc_token + subject_token_type: urn:ietf:params:oauth:token-type:access_token audience_overrides: - host: api.example.com port: 443 @@ -315,7 +327,14 @@ OpenShell keeps token endpoints profile-owned. Refresh material cannot override ### Dynamic Token Grants -`token_grant` belongs to one credential declaration. When a sandbox with the provider attached sends HTTP traffic to a matching profile endpoint, the supervisor requests a SPIFFE JWT-SVID from the local Workload API, exchanges it at `token_endpoint`, caches the returned access token, and injects it before forwarding the request upstream. Use `auth_style: bearer` to inject `Authorization: Bearer `, or `auth_style: header` with `header_name` to inject the raw access token into a custom header. Token grants do not support `query` or `path` placement. +`token_grant` belongs to one credential declaration. When a sandbox with the provider attached sends HTTP traffic to a matching profile endpoint, the supervisor resolves the dynamic credential, caches the returned access token, and injects it before forwarding the request upstream. Use `auth_style: bearer` to inject `Authorization: Bearer `, or `auth_style: header` with `header_name` to inject the raw access token into a custom header. Token grants do not support `query` or `path` placement. + +OpenShell supports two dynamic grant types: + +| Grant type | Behavior | +|---|---| +| `client_credentials` | The supervisor requests a SPIFFE JWT-SVID from the local Workload API and sends it directly to `token_endpoint` as the OAuth2 client assertion. This is the default when `grant_type` is omitted. | +| `token_exchange` | The supervisor first asks the gateway for an intermediate token. The request includes the supervisor JWT-SVID; the gateway verifies it, uses the SVID subject as the intermediate token audience, and exchanges the stored subject credential at the same `token_endpoint` using the gateway's own JWT-SVID as the client assertion. The supervisor then exchanges that intermediate token for the final upstream token using its own JWT-SVID as the client assertion. | Create provider instances for token-grant-only profiles with `--runtime-credentials`. This records an empty provider instance and makes the runtime-resolved credential source explicit: @@ -326,19 +345,39 @@ openshell provider create \ --runtime-credentials ``` +For `token_exchange` profiles, the provider also stores the user subject token referenced by `token_grant.subject_token.credential`. Create or update that provider credential from the current gateway OIDC login with `--from-oidc-token`. This requires an active named gateway that was registered for OIDC. The CLI copies the current OIDC access token and its expiry into the provider. If the stored gateway access token is expired and a refresh token is available, the CLI refreshes it first. OpenShell does not store the OIDC refresh token in the provider. When the stored subject-token credential expires, the gateway rejects intermediate token exchange until the provider is updated with a fresh token. + +```shell +openshell provider create \ + --name custom-api \ + --type custom-api \ + --from-oidc-token + +openshell provider update custom-api \ + --from-oidc-token +``` + +OpenShell infers the destination credential when the provider profile has exactly one `token_grant.subject_token.credential`. If a profile declares more than one token-exchange subject credential, pass `--credential ` to choose one. + Token grant fields: | Field | Required | Behavior | |---|---|---| +| `grant_type` | No | `client_credentials` or `token_exchange`. Defaults to `client_credentials` for backward compatibility. | | `token_endpoint` | Yes | OAuth2 token endpoint that accepts a SPIFFE JWT-SVID client assertion. Use `https://` unless the endpoint is loopback or a Kubernetes service DNS name such as `token-issuer.default.svc.cluster.local`. | -| `audience` | No | Resource audience requested from the token service. | +| `audience` | No | Resource audience requested from the token service. For `token_exchange`, this is the final exchange audience; the gateway intermediate exchange always uses the verified supervisor SVID subject as its audience. | | `jwt_svid_audience` | No | Audience used when requesting the JWT-SVID. When omitted, OpenShell derives an issuer-style audience from Keycloak token endpoint paths or falls back to the full token endpoint URL. | -| `client_assertion_type` | No | OAuth2 `client_assertion_type` form value. Defaults to RFC 7523 `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. Set a provider-specific value, such as `urn:ietf:params:oauth:client-assertion-type:jwt-spiffe`, only when the token issuer explicitly requires it. | +| `client_assertion_type` | No | OAuth2 `client_assertion_type` form value. Defaults to RFC 7523 `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. Set `urn:ietf:params:oauth:client-assertion-type:jwt-spiffe` when the token issuer expects the SPIFFE assertion type. | | `scopes` | No | OAuth2 scopes sent as a space-separated `scope` parameter. | | `cache_ttl_seconds` | No | Token cache TTL override. When omitted or `0`, OpenShell uses the token response `expires_in` with a 30-second safety margin and one-hour cap, or five minutes minus the margin if the response does not include an expiry. | -| `audience_overrides` | No | Endpoint-specific `audience` and `scopes` overrides selected by host, port, and path. | +| `requested_token_type` | No | RFC 8693 `requested_token_type` sent during token exchange. Defaults to `urn:ietf:params:oauth:token-type:access_token`. | +| `subject_token` | Required for `token_exchange` | Subject-token source used for the gateway-brokered intermediate exchange. Phase one supports `source: provider_credential`, where `credential` names another credential declared in the same profile. | +| `subject_token.subject_token_type` | No | RFC 8693 `subject_token_type` for the stored subject token. Defaults to `urn:ietf:params:oauth:token-type:access_token`. | +| `audience_overrides` | No | Endpoint-specific final-exchange `audience` and `scopes` overrides selected by host, port, and path. These overrides do not affect the gateway intermediate exchange. | + +Token grants require the sandbox supervisor to have access to a SPIFFE Workload API socket. `token_exchange` also requires the gateway to have its own Workload API socket so it can present a gateway JWT-SVID during the intermediate exchange. They apply to HTTP traffic that the proxy can inspect. Endpoints with `tls: skip` bypass TLS termination and cannot receive dynamic token grant injection for HTTPS traffic. The token service must return a token value that is safe for HTTP header placement; malformed values are rejected before caching or header injection. -Token grants require the sandbox supervisor to have access to a SPIFFE Workload API socket. They apply to HTTP traffic that the proxy can inspect. Endpoints with `tls: skip` bypass TLS termination and cannot receive dynamic token grant injection for HTTPS traffic. The token service must return a token value that is safe for HTTP header placement; malformed values are rejected before caching or header injection. +The gateway only brokers an intermediate token for a sandbox principal, and only when the requested provider is attached to that sandbox. It verifies the supervisor JWT-SVID issuer, audience, signature, and SPIFFE trust domain against the gateway's own JWT-SVID, then uses the verified supervisor SVID subject as the intermediate-token audience. ## Provider Instances diff --git a/e2e/rust/Cargo.lock b/e2e/rust/Cargo.lock index 953449c57..3cade135b 100644 --- a/e2e/rust/Cargo.lock +++ b/e2e/rust/Cargo.lock @@ -8,12 +8,72 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.22.1" @@ -78,12 +138,28 @@ dependencies = [ "serde_repr", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -109,6 +185,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -158,6 +240,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -234,6 +328,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -259,6 +366,25 @@ dependencies = [ "wasip3", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -341,6 +467,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -367,6 +494,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -538,6 +678,32 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -577,12 +743,24 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.1.1" @@ -594,6 +772,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -612,6 +824,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "jsonwebtoken", "prost", "rand", "serde_json", @@ -619,6 +832,10 @@ dependencies = [ "sha2", "tempfile", "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tower", ] [[package]] @@ -644,12 +861,42 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -665,6 +912,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -695,9 +948,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.13.5" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -705,9 +958,9 @@ dependencies = [ [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools", @@ -775,6 +1028,20 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -788,6 +1055,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.23" @@ -894,6 +1167,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -904,6 +1183,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -943,6 +1234,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -987,6 +1284,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1025,6 +1352,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1038,6 +1376,71 @@ dependencies = [ "tokio", ] +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -1051,9 +1454,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1087,6 +1502,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1144,6 +1565,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1206,13 +1672,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -1224,6 +1699,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -1231,58 +1722,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 083c622df..4d502c2e6 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -62,6 +62,11 @@ name = "podman_gateway_resume" path = "tests/podman_gateway_resume.rs" required-features = ["e2e-podman"] +[[test]] +name = "provider_token_exchange" +path = "tests/provider_token_exchange.rs" +required-features = ["e2e-podman"] + [[test]] name = "vm_gateway_resume" path = "tests/vm_gateway_resume.rs" @@ -110,14 +115,19 @@ futures-util = "0.3" http-body-util = "0.1" hyper = { version = "1", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } -prost = "0.13" +jsonwebtoken = "9" +prost = "0.14" tokio = { version = "1.43", features = ["full"] } +tokio-stream = { version = "0.1", features = ["net"] } tempfile = "3" sha1 = "0.10" sha2 = "0.10" hex = "0.4" rand = "0.9" serde_json = "1" +tonic = { version = "0.14", features = ["transport"] } +tonic-prost = "0.14" +tower = "0.5" [lints.rust] unsafe_code = "warn" diff --git a/e2e/rust/e2e-podman.sh b/e2e/rust/e2e-podman.sh index 5f325d0d2..324440960 100755 --- a/e2e/rust/e2e-podman.sh +++ b/e2e/rust/e2e-podman.sh @@ -12,6 +12,10 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" E2E_TEST="${OPENSHELL_E2E_PODMAN_TEST:-}" E2E_FEATURES="${OPENSHELL_E2E_PODMAN_FEATURES:-e2e-podman}" +if [ "${E2E_TEST}" = "provider_token_exchange" ]; then + export OPENSHELL_E2E_SPIFFE_FIXTURE="${OPENSHELL_E2E_SPIFFE_FIXTURE:-1}" +fi + cargo build -p openshell-cli --features openshell-core/dev-settings TEST_ARGS=( diff --git a/e2e/rust/src/harness/output.rs b/e2e/rust/src/harness/output.rs index c1c926e6e..28022043f 100644 --- a/e2e/rust/src/harness/output.rs +++ b/e2e/rust/src/harness/output.rs @@ -16,7 +16,7 @@ pub fn strip_ansi(s: &str) -> String { // Consume the `[` and everything up to the terminating letter. if chars.peek() == Some(&'[') { chars.next(); // consume '[' - // Consume parameter bytes (digits, ';') and the final byte. + // Consume parameter bytes (digits, ';') and the final byte. for c in chars.by_ref() { if c.is_ascii_alphabetic() { break; diff --git a/e2e/rust/src/harness/port.rs b/e2e/rust/src/harness/port.rs index 70f454991..e6c82ceb5 100644 --- a/e2e/rust/src/harness/port.rs +++ b/e2e/rust/src/harness/port.rs @@ -51,10 +51,7 @@ pub async fn wait_for_port(host: &str, port: u16, max_wait: Duration) -> Result< pub fn find_free_port() -> u16 { let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)).expect("bind to port 0"); - listener - .local_addr() - .expect("local_addr after bind") - .port() + listener.local_addr().expect("local_addr after bind").port() } #[cfg(test)] diff --git a/e2e/rust/src/harness/sandbox.rs b/e2e/rust/src/harness/sandbox.rs index af71a38ee..b569f6972 100644 --- a/e2e/rust/src/harness/sandbox.rs +++ b/e2e/rust/src/harness/sandbox.rs @@ -20,8 +20,7 @@ use super::output::{extract_field, strip_ansi}; /// The CLI prints `Created sandbox: ` (current format). Falls back to /// `Name: ` for compatibility with older output formats. fn extract_sandbox_name(output: &str) -> Option { - extract_field(output, "Created sandbox") - .or_else(|| extract_field(output, "Name")) + extract_field(output, "Created sandbox").or_else(|| extract_field(output, "Name")) } /// Default timeout for waiting for a sandbox to become ready. @@ -76,9 +75,7 @@ impl SandboxGuard { let output = timeout(SANDBOX_READY_TIMEOUT, cmd.output()) .await - .map_err(|_| { - format!("sandbox create timed out after {SANDBOX_READY_TIMEOUT:?}") - })? + .map_err(|_| format!("sandbox create timed out after {SANDBOX_READY_TIMEOUT:?}"))? .map_err(|e| format!("failed to spawn openshell: {e}"))?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); @@ -123,16 +120,29 @@ impl SandboxGuard { /// Returns an error if the process exits prematurely, the ready marker is /// not seen within [`SANDBOX_READY_TIMEOUT`], or the sandbox name cannot /// be parsed. - pub async fn create_keep( + pub async fn create_keep(command: &[&str], ready_marker: &str) -> Result { + Self::create_keep_with_args(&[], command, ready_marker).await + } + + /// Create a sandbox with `--keep`, additional `sandbox create` arguments, + /// and a long-lived background command. + /// + /// # Errors + /// + /// Returns an error if the process exits prematurely, the ready marker is + /// not seen within [`SANDBOX_READY_TIMEOUT`], or the sandbox name cannot + /// be parsed. + pub async fn create_keep_with_args( + create_args: &[&str], command: &[&str], ready_marker: &str, ) -> Result { let mut cmd = openshell_cmd(); - cmd.arg("sandbox") - .arg("create") - .arg("--keep") - .arg("--") - .args(command); + cmd.arg("sandbox").arg("create"); + for arg in create_args { + cmd.arg(arg); + } + cmd.arg("--keep").arg("--").args(command); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = cmd @@ -238,9 +248,7 @@ impl SandboxGuard { let output = timeout(SANDBOX_READY_TIMEOUT, cmd.output()) .await .map_err(|_| { - format!( - "sandbox create --upload timed out after {SANDBOX_READY_TIMEOUT:?}" - ) + format!("sandbox create --upload timed out after {SANDBOX_READY_TIMEOUT:?}") })? .map_err(|e| format!("failed to spawn openshell: {e}"))?; @@ -412,11 +420,7 @@ impl SandboxGuard { /// # Errors /// /// Returns an error if the download command fails. - pub async fn download( - &self, - sandbox_path: &str, - local_dest: &str, - ) -> Result { + pub async fn download(&self, sandbox_path: &str, local_dest: &str) -> Result { let mut cmd = openshell_cmd(); cmd.arg("sandbox") .arg("download") diff --git a/e2e/rust/tests/bypass_detection.rs b/e2e/rust/tests/bypass_detection.rs index 698fd346d..56415a554 100644 --- a/e2e/rust/tests/bypass_detection.rs +++ b/e2e/rust/tests/bypass_detection.rs @@ -58,16 +58,10 @@ async fn bypass_attempt_is_rejected_fast() { .create_output .lines() .find(|l| l.contains("bypass_result")) - .unwrap_or_else(|| { - panic!( - "no bypass_result JSON in output:\n{}", - guard.create_output - ) - }); + .unwrap_or_else(|| panic!("no bypass_result JSON in output:\n{}", guard.create_output)); - let parsed: serde_json::Value = serde_json::from_str(json_line.trim()).unwrap_or_else(|e| { - panic!("failed to parse JSON '{json_line}': {e}") - }); + let parsed: serde_json::Value = serde_json::from_str(json_line.trim()) + .unwrap_or_else(|e| panic!("failed to parse JSON '{json_line}': {e}")); let result = parsed["bypass_result"].as_str().unwrap(); let elapsed_ms = parsed["elapsed_ms"].as_u64().unwrap(); diff --git a/e2e/rust/tests/cf_auth_smoke.rs b/e2e/rust/tests/cf_auth_smoke.rs index 29d436227..34fa1be02 100644 --- a/e2e/rust/tests/cf_auth_smoke.rs +++ b/e2e/rust/tests/cf_auth_smoke.rs @@ -136,8 +136,10 @@ async fn gateway_login_help_is_recognized() { let clean = strip_ansi(&output); // Should mention authenticating or Cloudflare assert!( - clean.to_lowercase().contains("authenticat") || clean.to_lowercase().contains("cloudflare") - || clean.to_lowercase().contains("login") || clean.to_lowercase().contains("browser"), + clean.to_lowercase().contains("authenticat") + || clean.to_lowercase().contains("cloudflare") + || clean.to_lowercase().contains("login") + || clean.to_lowercase().contains("browser"), "expected auth-related text in gateway login --help:\n{clean}" ); } @@ -210,10 +212,7 @@ async fn gateway_add_creates_cf_metadata() { ); // Verify the gateway was set as active. - let active_path = tmpdir - .path() - .join("openshell") - .join("active_gateway"); + let active_path = tmpdir.path().join("openshell").join("active_gateway"); assert!( active_path.exists(), "active_gateway file should exist at {}", @@ -241,18 +240,11 @@ async fn gateway_add_derives_name_from_hostname() { let (output, code) = run_with_config( tmpdir.path(), - &[ - "gateway", - "add", - "https://my-special-gateway.brevlab.com", - ], + &["gateway", "add", "https://my-special-gateway.brevlab.com"], ) .await; - assert_eq!( - code, 0, - "gateway add should exit 0:\n{output}" - ); + assert_eq!(code, 0, "gateway add should exit 0:\n{output}"); // The derived name should be the hostname. let metadata_path = tmpdir @@ -344,10 +336,7 @@ async fn gateway_add_rejects_duplicate_name() { ], ) .await; - assert_ne!( - code, 0, - "duplicate gateway add should fail:\n{output}" - ); + assert_ne!(code, 0, "duplicate gateway add should fail:\n{output}"); let clean = strip_ansi(&output); assert!( @@ -363,18 +352,9 @@ async fn gateway_add_rejects_duplicate_name() { /// `ssh://` endpoint with `--local` should fail. #[tokio::test] async fn gateway_add_ssh_url_conflicts_with_local() { - let (output, code) = run_isolated(&[ - "gateway", - "add", - "ssh://user@host:8080", - "--local", - ]) - .await; + let (output, code) = run_isolated(&["gateway", "add", "ssh://user@host:8080", "--local"]).await; - assert_ne!( - code, 0, - "ssh:// with --local should fail:\n{output}" - ); + assert_ne!(code, 0, "ssh:// with --local should fail:\n{output}"); } /// `ssh://` endpoint with `--remote` should fail (redundant). @@ -389,26 +369,15 @@ async fn gateway_add_ssh_url_conflicts_with_remote() { ]) .await; - assert_ne!( - code, 0, - "ssh:// with --remote should fail:\n{output}" - ); + assert_ne!(code, 0, "ssh:// with --remote should fail:\n{output}"); } /// `ssh://` endpoint without a port should fail. #[tokio::test] async fn gateway_add_ssh_url_requires_port() { - let (output, code) = run_isolated(&[ - "gateway", - "add", - "ssh://user@host", - ]) - .await; + let (output, code) = run_isolated(&["gateway", "add", "ssh://user@host"]).await; - assert_ne!( - code, 0, - "ssh:// without port should fail:\n{output}" - ); + assert_ne!(code, 0, "ssh:// without port should fail:\n{output}"); let clean = strip_ansi(&output); assert!( diff --git a/e2e/rust/tests/cli_smoke.rs b/e2e/rust/tests/cli_smoke.rs index b88f59af2..fe3d78b14 100644 --- a/e2e/rust/tests/cli_smoke.rs +++ b/e2e/rust/tests/cli_smoke.rs @@ -364,13 +364,7 @@ async fn gateway_add_can_shadow_system_gateway_with_user_registration() { let (add_output, add_code) = run_with_config( config_dir.path(), Some(system_dir.path()), - &[ - "gateway", - "add", - "http://127.0.0.1:17671", - "--name", - "beta", - ], + &["gateway", "add", "http://127.0.0.1:17671", "--name", "beta"], ) .await; assert_eq!( diff --git a/e2e/rust/tests/custom_image.rs b/e2e/rust/tests/custom_image.rs index ff3cda86a..fa905bbf1 100644 --- a/e2e/rust/tests/custom_image.rs +++ b/e2e/rust/tests/custom_image.rs @@ -52,15 +52,10 @@ async fn sandbox_from_custom_dockerfile() { // Step 2: Create a sandbox from the Dockerfile. let dockerfile_str = dockerfile_path.to_str().expect("Dockerfile path is UTF-8"); - let mut guard = SandboxGuard::create(&[ - "--from", - dockerfile_str, - "--", - "cat", - "/etc/marker.txt", - ]) - .await - .expect("sandbox create from Dockerfile"); + let mut guard = + SandboxGuard::create(&["--from", dockerfile_str, "--", "cat", "/etc/marker.txt"]) + .await + .expect("sandbox create from Dockerfile"); // Step 3: Verify the marker file content appears in the output. let clean_output = strip_ansi(&guard.create_output); diff --git a/e2e/rust/tests/edge_tunnel_e2e.rs b/e2e/rust/tests/edge_tunnel_e2e.rs index e39edc3ea..80ee4a6ed 100644 --- a/e2e/rust/tests/edge_tunnel_e2e.rs +++ b/e2e/rust/tests/edge_tunnel_e2e.rs @@ -26,9 +26,7 @@ use openshell_e2e::harness::output::strip_ansi; /// Run `openshell ` using the system's configured gateway. async fn run_cli(args: &[&str]) -> (String, i32) { let mut cmd = openshell_cmd(); - cmd.args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); let output = cmd.output().await.expect("spawn openshell"); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); @@ -164,20 +162,16 @@ async fn ws_tunnel_status_through_edge_proxy() { // Extract the gateway endpoint from the info output. // The format varies, but it should contain a URL-like string. - let endpoint = info_clean - .lines() - .find_map(|line| { - if line.to_lowercase().contains("endpoint") - || line.to_lowercase().contains("gateway") - { - // Try to extract a URL from the line - line.split_whitespace() - .find(|word| word.starts_with("http://") || word.starts_with("https://")) - .map(String::from) - } else { - None - } - }); + let endpoint = info_clean.lines().find_map(|line| { + if line.to_lowercase().contains("endpoint") || line.to_lowercase().contains("gateway") { + // Try to extract a URL from the line + line.split_whitespace() + .find(|word| word.starts_with("http://") || word.starts_with("https://")) + .map(String::from) + } else { + None + } + }); let Some(endpoint) = endpoint else { eprintln!( @@ -208,12 +202,8 @@ async fn ws_tunnel_status_through_edge_proxy() { "dummy-test-jwt", ); - let (output, code) = run_cli_with_config(tmpdir.path(), &[ - "--gateway", - "edge-tunnel-test", - "status", - ]) - .await; + let (output, code) = + run_cli_with_config(tmpdir.path(), &["--gateway", "edge-tunnel-test", "status"]).await; let clean = strip_ansi(&output); assert_eq!( diff --git a/e2e/rust/tests/live_policy_update.rs b/e2e/rust/tests/live_policy_update.rs index c60b29548..4374302f4 100644 --- a/e2e/rust/tests/live_policy_update.rs +++ b/e2e/rust/tests/live_policy_update.rs @@ -169,8 +169,7 @@ fn extract_version(output: &str) -> Option { /// Extract the policy hash from `policy get` output. fn extract_hash(output: &str) -> Option { - extract_field(output, "Hash") - .or_else(|| extract_field(output, "Policy hash")) + extract_field(output, "Hash").or_else(|| extract_field(output, "Policy hash")) } /// Check that a version number appears in `policy list` output as a @@ -203,8 +202,7 @@ fn list_output_contains_version(output: &str, version: u32) -> bool { async fn live_policy_update_round_trip() { // --- Write two distinct policy files --- let policy_a = write_policy(&["api.anthropic.com"]).expect("write policy A"); - let policy_b = - write_policy(&["api.anthropic.com", "example.com"]).expect("write policy B"); + let policy_b = write_policy(&["api.anthropic.com", "example.com"]).expect("write policy B"); let policy_a_path = policy_a .path() @@ -218,16 +216,21 @@ async fn live_policy_update_round_trip() { .to_string(); // --- Create a long-running sandbox --- - let mut guard = SandboxGuard::create_keep( - &["sh", "-c", "echo Ready && sleep infinity"], - "Ready", - ) - .await - .expect("create keep sandbox"); + let mut guard = + SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready") + .await + .expect("create keep sandbox"); // --- Set initial policy A --- let r = run_cli(&[ - "policy", "set", &guard.name, "--policy", &policy_a_path, "--wait", "--timeout", "120", + "policy", + "set", + &guard.name, + "--policy", + &policy_a_path, + "--wait", + "--timeout", + "120", ]) .await; assert!( @@ -244,8 +247,12 @@ async fn live_policy_update_round_trip() { r.exit_code, r.output ); - let initial_version = extract_version(&r.output) - .unwrap_or_else(|| panic!("could not parse version from policy get output:\n{}", r.output)); + let initial_version = extract_version(&r.output).unwrap_or_else(|| { + panic!( + "could not parse version from policy get output:\n{}", + r.output + ) + }); assert!( initial_version >= 1, "initial policy version should be >= 1, got {initial_version}" @@ -255,7 +262,14 @@ async fn live_policy_update_round_trip() { // --- Push same policy A again -> should be idempotent --- let r = run_cli(&[ - "policy", "set", &guard.name, "--policy", &policy_a_path, "--wait", "--timeout", "120", + "policy", + "set", + &guard.name, + "--policy", + &policy_a_path, + "--wait", + "--timeout", + "120", ]) .await; assert!( @@ -265,7 +279,11 @@ async fn live_policy_update_round_trip() { ); let r = run_cli(&["policy", "get", &guard.name]).await; - assert!(r.success, "policy get after repeat should succeed:\n{}", r.output); + assert!( + r.success, + "policy get after repeat should succeed:\n{}", + r.output + ); let repeat_version = extract_version(&r.output) .unwrap_or_else(|| panic!("could not parse version after repeat:\n{}", r.output)); @@ -280,7 +298,14 @@ async fn live_policy_update_round_trip() { // --- Push policy B -> should create new version --- let r = run_cli(&[ - "policy", "set", &guard.name, "--policy", &policy_b_path, "--wait", "--timeout", "120", + "policy", + "set", + &guard.name, + "--policy", + &policy_b_path, + "--wait", + "--timeout", + "120", ]) .await; assert!( @@ -290,7 +315,11 @@ async fn live_policy_update_round_trip() { ); let r = run_cli(&["policy", "get", &guard.name]).await; - assert!(r.success, "policy get after B should succeed:\n{}", r.output); + assert!( + r.success, + "policy get after B should succeed:\n{}", + r.output + ); let new_version = extract_version(&r.output) .unwrap_or_else(|| panic!("could not parse version after B:\n{}", r.output)); @@ -305,7 +334,14 @@ async fn live_policy_update_round_trip() { // --- Push policy B again -> idempotent --- let r = run_cli(&[ - "policy", "set", &guard.name, "--policy", &policy_b_path, "--wait", "--timeout", "120", + "policy", + "set", + &guard.name, + "--policy", + &policy_b_path, + "--wait", + "--timeout", + "120", ]) .await; assert!( @@ -315,7 +351,11 @@ async fn live_policy_update_round_trip() { ); let r = run_cli(&["policy", "get", &guard.name]).await; - assert!(r.success, "policy get after B repeat should succeed:\n{}", r.output); + assert!( + r.success, + "policy get after B repeat should succeed:\n{}", + r.output + ); let repeat_b_version = extract_version(&r.output) .unwrap_or_else(|| panic!("could not parse version after B repeat:\n{}", r.output)); @@ -370,16 +410,21 @@ async fn live_policy_update_from_empty_network_policies() { .to_string(); // Create sandbox with empty network policy. - let mut guard = SandboxGuard::create_keep( - &["sh", "-c", "echo Ready && sleep infinity"], - "Ready", - ) - .await - .expect("create keep sandbox"); + let mut guard = + SandboxGuard::create_keep(&["sh", "-c", "echo Ready && sleep infinity"], "Ready") + .await + .expect("create keep sandbox"); // Set initial empty policy. let r = run_cli(&[ - "policy", "set", &guard.name, "--policy", &empty_path, "--wait", "--timeout", "120", + "policy", + "set", + &guard.name, + "--policy", + &empty_path, + "--wait", + "--timeout", + "120", ]) .await; assert!( @@ -389,14 +434,25 @@ async fn live_policy_update_from_empty_network_policies() { ); let r = run_cli(&["policy", "get", &guard.name]).await; - assert!(r.success, "policy get (empty) should succeed:\n{}", r.output); + assert!( + r.success, + "policy get (empty) should succeed:\n{}", + r.output + ); let initial_version = extract_version(&r.output) .unwrap_or_else(|| panic!("could not parse version from empty policy:\n{}", r.output)); // Push policy with network rules. let r = run_cli(&[ - "policy", "set", &guard.name, "--policy", &full_path, "--wait", "--timeout", "120", + "policy", + "set", + &guard.name, + "--policy", + &full_path, + "--wait", + "--timeout", + "120", ]) .await; assert!( diff --git a/e2e/rust/tests/podman_gateway_resume.rs b/e2e/rust/tests/podman_gateway_resume.rs index fea2fab3e..260020253 100644 --- a/e2e/rust/tests/podman_gateway_resume.rs +++ b/e2e/rust/tests/podman_gateway_resume.rs @@ -14,7 +14,9 @@ use std::time::Duration; -use openshell_e2e::harness::cli::{sandbox_names, wait_for_healthy, wait_for_sandbox_exec_contains}; +use openshell_e2e::harness::cli::{ + sandbox_names, wait_for_healthy, wait_for_sandbox_exec_contains, +}; use openshell_e2e::harness::gateway::ManagedGateway; use openshell_e2e::harness::sandbox::SandboxGuard; diff --git a/e2e/rust/tests/port_forward.rs b/e2e/rust/tests/port_forward.rs index b198eeb94..89d9d86e1 100644 --- a/e2e/rust/tests/port_forward.rs +++ b/e2e/rust/tests/port_forward.rs @@ -58,17 +58,14 @@ async fn port_forward_echo() { // --------------------------------------------------------------- // Step 1 — Create a sandbox with the echo server running. // --------------------------------------------------------------- - let mut guard = - SandboxGuard::create_keep(&["python3", "-c", &script], "echo-server-ready") - .await - .expect("sandbox create with echo server"); + let mut guard = SandboxGuard::create_keep(&["python3", "-c", &script], "echo-server-ready") + .await + .expect("sandbox create with echo server"); // --------------------------------------------------------------- // Step 2 — Start port forwarding in the background. // --------------------------------------------------------------- - let mut forward_child = guard - .spawn_forward(port) - .expect("spawn port forward"); + let mut forward_child = guard.spawn_forward(port).expect("spawn port forward"); // Wait for the local port to accept connections. wait_for_port("127.0.0.1", port, Duration::from_secs(30)) diff --git a/e2e/rust/tests/provider_token_exchange.rs b/e2e/rust/tests/provider_token_exchange.rs new file mode 100644 index 000000000..b9db9b541 --- /dev/null +++ b/e2e/rust/tests/provider_token_exchange.rs @@ -0,0 +1,742 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(feature = "e2e-podman")] + +use std::collections::HashMap; +use std::convert::Infallible; +use std::fs; +use std::io::Write as _; +use std::net::{Ipv4Addr, SocketAddr}; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use futures_util::future::BoxFuture; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use openshell_e2e::harness::binary::openshell_cmd; +use openshell_e2e::harness::port::find_free_port; +use openshell_e2e::harness::sandbox::SandboxGuard; +use serde_json::json; +use tempfile::NamedTempFile; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, UnixListener}; +use tokio::process::Command; +use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream, UnixListenerStream}; +use tonic::body::Body as TonicBody; +use tonic::codegen::{Body, http}; +use tonic::{Request, Response, Status}; + +const TRUST_DOMAIN: &str = "openshell-e2e.test"; +const ISSUER: &str = "https://spiffe.openshell-e2e.test"; +const KEY_ID: &str = "openshell-e2e-test-key"; +const USER_SUBJECT_TOKEN: &str = "stored-user-token"; +const INTERMEDIATE_TOKEN: &str = "intermediate-token"; +const FINAL_ACCESS_TOKEN: &str = "final-access-token"; +const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token"; +const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe"; + +const TEST_RSA_PRIVATE_KEY: &str = r"-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvCoZ0mVHpCHsF +zeeqw2caNIe/eb4BQUccFPhZfRnF7sCfyB84zTBmuwG2umRBdjFnVsfIIZRp2HcD +OESrRYYiE1RGfjBXImGVg2Wtza0HYhL1sLyX1eaEefylxoilmApAgWDh9p36h8J2 +s5YHwyXPTttx4DpdWDnxju1iNmwoIB8uVE/5amWgbNvlETMBOcB1RxDHtnVy+xJz +jjjrzK4Qz9WsUTHAvngdi4Yyxvci+yKpjYTg5+UWxmAN6iW522TpLe32MDb5Ug1d +trBvvepWmdQ6CBwPhBHCt/sMoSJAYSO4RKeBnBjeLQBXFTxaOv5iTGIsRTX3K471 +epHp3cT5AgMBAAECggEASQlRv/4nZN5SgsH/K8v7zb3kdHsmUly8AJYpaCGgauvr +uN/mUyueyga2uNl+MqhQBef6VWHZjO6y/gdw86v/Q2GgVQebQQhKAnpAp2w+Ceoc +siKMFqi8VkOWLU+xPbM6d97kH3TpRxt1g1T8wYFmWeF0BEiE4eUJzGaQW14M9BJ+ +G0QxmP/zjX9cNpVeApKTjBWKiH4CXG3DuI3pJ93VOMpUlOsrdLXvKGTze0e01itr +MX/MHHTE+VXB4FB+/zKSA4c36egi676OSXrGC/GDmM8ntJ4CUGeD5uZsMSADiAUn +iccv5iGRWVMIKxUS5Q4k0jy8uWuK+QVP4Y6cQWYArwKBgQDhuSNORBNpIGRfsKGN +iJo/h+qinz6pEIpa3D3oVl7rpkyvgIyaTwfXvC1vfdS9V5VIel2gV2Cx0OrI8yrr +nQu1JuNV/rLmtvqX321fgBLRdoiqF3pAy1gbmdUz1elerAIYL578gXQ6jg1bbdic +kJpn0MsoDUJGwvJnXcgLqG7q3wKBgQDGhRIa4oJsj1vqICc8zt8YsCAcot3vjWLH +588X7JdBGOWJdWxfdmGXQRn5Zw9UhMQnYa3uyTBPeVcXopThlPotYeuFhLSU856T +IJzfpzCJzC4zIQayoyvJFrKe7N70iUQ986dewYy9oxQhHvFKd/qe4ylbzZJXpthX +eWEuuBSjJwKBgGkqXt6qLPj/1IQYwUw15tfOtW0LEKCoSi3HCzjidNsJ4hSqqdeD +Fr5WuDyHvcRxt+XKzTBVRYHTOnBhiw+3XasK8UQxpJyFh/+WY1jpTNs2hLnqslTZ +6LUDWSgLc+1d6qPmHAa9Ma/OWz7L0O4xGR9hUiXY95YMYe/y668yzGq1AoGBAJyU +Gsqfu7U6gYmxoKEine6QBFPx1dD7GF2KJdq93jMXGvyHZFoLOkAdtgnz0rCcI0bY +kWKUxwj4MMxQjNM8OPMQl75xBCmz2XA8Od9htDQLmqjzNKAzePabc3lMZTJFDlE6 +29kuGf79IIRbLn/JECDAFT/2baW60Ep2T0OVJ5njAoGAfaCaQ4aVgjI027q7Y5qP +KfNSI8uuA8PLqmUY30I9KFWzN6VDLu00eKa90F4w3CeWRRQWXW1+007tTz3V1mNw +20A24Fi3HGQmXc7NyuLDODTJsWBICuOemCnRkvcxIlxb+ec7jp+XRmzDwKkzSnVN +pM2zFU8SeVkvHKlEuoHaP0s= +-----END PRIVATE KEY-----"; + +const TEST_RSA_MODULUS_HEX: &str = concat!( + "af0a86749951e9087b05cde7aac3671a3487bf79be0141471c14f8597d19c5eec09fc81f38cd3066bb01b6ba", + "644176316756c7c8219469d877033844ab4586221354467e30572261958365adcdad076212f5b0bc97d5e684", + "79fca5c688a5980a408160e1f69dfa87c276b39607c325cf4edb71e03a5d5839f18eed62366c28201f2e54", + "4ff96a65a06cdbe511330139c0754710c7b67572fb12738e38ebccae10cfd5ac5131c0be781d8b8632c6", + "f722fb22a98d84e0e7e516c6600dea25b9db64e92dedf63036f9520d5db6b06fbdea5699d43a081c0f84", + "11c2b7fb0ca122406123b844a7819c18de2d0057153c5a3afe624c622c4535f72b8ef57a91e9ddc4f9" +); + +#[derive(Clone, PartialEq, prost::Message)] +struct JwtsvidRequest { + #[prost(string, repeated, tag = "1")] + audience: Vec, + #[prost(string, tag = "2")] + spiffe_id: String, +} + +#[derive(Clone, PartialEq, prost::Message)] +struct JwtsvidResponse { + #[prost(message, repeated, tag = "1")] + svids: Vec, +} + +#[derive(Clone, PartialEq, prost::Message)] +struct Jwtsvid { + #[prost(string, tag = "1")] + spiffe_id: String, + #[prost(string, tag = "2")] + svid: String, + #[prost(string, tag = "3")] + hint: String, +} + +#[derive(Clone, PartialEq, prost::Message)] +struct JwtBundlesRequest {} + +#[derive(Clone, PartialEq, prost::Message)] +struct JwtBundlesResponse { + #[prost(map = "string, bytes", tag = "1")] + bundles: HashMap>, +} + +#[derive(Clone)] +struct SpiffeWorkloadApi { + subject: Arc, + jwks: Arc>, + encoding_key: Arc, +} + +impl SpiffeWorkloadApi { + fn jwt_svid(&self, audience: Vec) -> Result { + let now = unix_timestamp(); + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(KEY_ID.to_string()); + let claims = json!({ + "iss": ISSUER, + "sub": self.subject.as_ref(), + "aud": audience, + "iat": now, + "exp": now + 3600, + }); + jsonwebtoken::encode(&header, &claims, &self.encoding_key) + .map_err(|err| Status::internal(format!("sign JWT-SVID: {err}"))) + } +} + +#[derive(Clone)] +struct SpiffeWorkloadApiServer { + inner: Arc, +} + +impl SpiffeWorkloadApiServer { + fn new(inner: SpiffeWorkloadApi) -> Self { + Self { + inner: Arc::new(inner), + } + } +} + +impl tower::Service> for SpiffeWorkloadApiServer +where + B: Body + Send + 'static, + B::Error: Into> + Send + 'static, +{ + type Response = http::Response; + type Error = Infallible; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/SpiffeWorkloadAPI/FetchJWTSVID" => { + #[derive(Clone)] + struct FetchJwtsvidSvc(Arc); + impl tonic::server::UnaryService for FetchJwtsvidSvc { + type Response = JwtsvidResponse; + type Future = BoxFuture<'static, Result, Status>>; + + fn call(&mut self, request: Request) -> Self::Future { + let inner = Arc::clone(&self.0); + Box::pin(async move { + let request = request.into_inner(); + let svid = inner.jwt_svid(request.audience)?; + Ok(Response::new(JwtsvidResponse { + svids: vec![Jwtsvid { + spiffe_id: inner.subject.to_string(), + svid, + hint: String::new(), + }], + })) + }) + } + } + + let inner = Arc::clone(&self.inner); + Box::pin(async move { + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec); + Ok(grpc.unary(FetchJwtsvidSvc(inner), req).await) + }) + } + "/SpiffeWorkloadAPI/FetchJWTBundles" => { + #[derive(Clone)] + struct FetchJwtBundlesSvc(Arc); + impl tonic::server::ServerStreamingService for FetchJwtBundlesSvc { + type Response = JwtBundlesResponse; + type ResponseStream = ReceiverStream>; + type Future = + BoxFuture<'static, Result, Status>>; + + fn call(&mut self, _request: Request) -> Self::Future { + let inner = Arc::clone(&self.0); + Box::pin(async move { + let mut bundles = HashMap::new(); + bundles.insert(TRUST_DOMAIN.to_string(), inner.jwks.as_ref().clone()); + let (tx, rx) = tokio::sync::mpsc::channel(1); + tx.send(Ok(JwtBundlesResponse { bundles })) + .await + .map_err(|err| Status::internal(format!("send bundle: {err}")))?; + Ok(Response::new(ReceiverStream::new(rx))) + }) + } + } + + let inner = Arc::clone(&self.inner); + Box::pin(async move { + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec); + Ok(grpc.server_streaming(FetchJwtBundlesSvc(inner), req).await) + }) + } + _ => Box::pin(async move { + let mut response = http::Response::new(TonicBody::empty()); + response.headers_mut().insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + response.headers_mut().insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }), + } + } +} + +impl tonic::server::NamedService for SpiffeWorkloadApiServer { + const NAME: &'static str = "SpiffeWorkloadAPI"; +} + +struct FixtureHandle { + task: tokio::task::JoinHandle<()>, +} + +impl Drop for FixtureHandle { + fn drop(&mut self) { + self.task.abort(); + } +} + +fn unix_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_secs() + .try_into() + .expect("timestamp should fit i64") +} + +fn jwks() -> Vec { + let modulus = hex::decode(TEST_RSA_MODULUS_HEX).expect("valid test RSA modulus hex"); + let n = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(modulus); + let e = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0x01, 0x00, 0x01]); + serde_json::to_vec(&json!({ + "keys": [{ + "kty": "RSA", + "kid": KEY_ID, + "use": "sig", + "alg": "RS256", + "n": n, + "e": e, + }] + })) + .expect("JWKS should serialize") +} + +async fn start_spiffe_workload_api(path: &Path, subject: &str) -> FixtureHandle { + let api = SpiffeWorkloadApi { + subject: Arc::::from(subject), + jwks: Arc::new(jwks()), + encoding_key: Arc::new( + EncodingKey::from_rsa_pem(TEST_RSA_PRIVATE_KEY.as_bytes()) + .expect("test RSA key should parse"), + ), + }; + let endpoint = path.to_string_lossy(); + if endpoint.starts_with("tcp:") { + let listen = std::env::var("OPENSHELL_E2E_PROVIDER_SPIFFE_LISTEN") + .expect("OPENSHELL_E2E_PROVIDER_SPIFFE_LISTEN must be set for TCP SPIFFE fixture"); + let listener = TcpListener::bind(&listen) + .await + .expect("bind TCP SPIFFE Workload API fixture"); + let incoming = TcpListenerStream::new(listener); + let task = tokio::spawn(async move { + let result = tonic::transport::Server::builder() + .add_service(SpiffeWorkloadApiServer::new(api)) + .serve_with_incoming(incoming) + .await; + if let Err(err) = result { + eprintln!("SPIFFE Workload API fixture failed: {err}"); + } + }); + return FixtureHandle { task }; + } + let _ = fs::remove_file(path); + let listener = UnixListener::bind(path).expect("bind SPIFFE Workload API socket"); + let mut permissions = fs::metadata(path) + .expect("stat SPIFFE Workload API socket") + .permissions(); + permissions.set_mode(0o777); + fs::set_permissions(path, permissions).expect("chmod SPIFFE Workload API socket"); + let incoming = UnixListenerStream::new(listener); + let task = tokio::spawn(async move { + let result = tonic::transport::Server::builder() + .add_service(SpiffeWorkloadApiServer::new(api)) + .serve_with_incoming(incoming) + .await; + if let Err(err) = result { + eprintln!("SPIFFE Workload API fixture failed: {err}"); + } + }); + FixtureHandle { task } +} + +async fn start_gateway_token_endpoint(port: u16) -> FixtureHandle { + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))) + .await + .expect("bind gateway token endpoint"); + let task = tokio::spawn(async move { + loop { + let Ok((mut stream, _peer)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = vec![0_u8; 8192]; + let n = stream.read(&mut buf).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..n]); + let access_token = if request.starts_with("POST /token ") + && request.contains("subject_token=stored-user-token") + && request.contains("client_assertion=") + { + Some(INTERMEDIATE_TOKEN) + } else if request.starts_with("POST /token ") + && request.contains("subject_token=intermediate-token") + && request.contains("client_assertion=") + { + Some(FINAL_ACCESS_TOKEN) + } else { + None + }; + let (status, body) = if let Some(access_token) = access_token { + ( + "HTTP/1.1 200 OK", + json!({ + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 300 + }) + .to_string(), + ) + } else { + ( + "HTTP/1.1 400 Bad Request", + json!({"error": "unexpected_token_exchange"}).to_string(), + ) + }; + let response = format!( + "{status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + FixtureHandle { task } +} + +async fn start_protected_target(port: u16) -> FixtureHandle { + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, port))) + .await + .expect("bind protected target"); + let task = tokio::spawn(async move { + loop { + let Ok((mut stream, _peer)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = vec![0_u8; 8192]; + let n = stream.read(&mut buf).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..n]); + let ok = request.lines().any(|line| { + line.eq_ignore_ascii_case(&format!( + "authorization: Bearer {FINAL_ACCESS_TOKEN}" + )) + }); + let (status, body) = if ok { + ("HTTP/1.1 200 OK", "token-exchange-ok") + } else { + ("HTTP/1.1 401 Unauthorized", "missing-final-token") + }; + let response = format!( + "{status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + FixtureHandle { task } +} + +async fn run_cli(args: &[&str]) -> Result { + let output = openshell_cmd() + .args(args) + .output() + .await + .map_err(|err| format!("spawn openshell: {err}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}{stderr}"); + if output.status.success() { + Ok(combined) + } else { + Err(format!( + "openshell {:?} failed with {:?}:\n{combined}", + args, + output.status.code() + )) + } +} + +async fn run_cli_ignore_error(args: &[&str]) { + let _ = openshell_cmd().args(args).output().await; +} + +fn write_profile(profile_type: &str, token_port: u16, target_port: u16) -> NamedTempFile { + let token_endpoint = format!("http://127.0.0.1:{token_port}/token"); + let mut file = tempfile::Builder::new() + .suffix(".yaml") + .tempfile() + .expect("create provider profile temp file"); + let profile = format!( + r#"id: {profile_type} +display_name: Podman token exchange e2e +description: Podman e2e provider profile for two-stage token exchange +category: other +credentials: + - name: subject_token + description: Stored user subject token + required: true + - name: access_token + description: Access token obtained through token exchange + required: false + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: {token_endpoint} + audience: final-audience + jwt_svid_audience: {token_endpoint} + client_assertion_type: {CLIENT_ASSERTION_TYPE} + requested_token_type: {TOKEN_TYPE_ACCESS_TOKEN} + cache_ttl_seconds: 30 + subject_token: + source: provider_credential + credential: subject_token + subject_token_type: {TOKEN_TYPE_ACCESS_TOKEN} +endpoints: + - host: host.openshell.internal + port: {target_port} + protocol: rest + tls: none + access: read-write + enforcement: enforce + allowed_ips: + - 10.0.0.0/8 + - 172.0.0.0/8 + - 192.168.0.0/16 +binaries: + - /usr/bin/curl + - /usr/local/bin/curl +"# + ); + file.write_all(profile.as_bytes()) + .expect("write provider profile"); + file.flush().expect("flush provider profile"); + file +} + +fn sandbox_script(token_port: u16) -> String { + let _ = token_port; + r#"set -eu +echo token-server-ready +while true; do sleep 60; done +"# + .to_string() +} + +fn container_token_endpoint_script() -> String { + format!( + r#" +import json +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs + +PORT = int(sys.argv[1]) + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path != "/token": + self.send_response(404) + self.end_headers() + return + length = int(self.headers.get("content-length", "0")) + params = parse_qs(self.rfile.read(length).decode()) + subject_token = params.get("subject_token", [""])[0] + client_assertion = params.get("client_assertion", [""])[0] + if subject_token == "{USER_SUBJECT_TOKEN}" and client_assertion: + access_token = "{INTERMEDIATE_TOKEN}" + elif subject_token == "{INTERMEDIATE_TOKEN}" and client_assertion: + access_token = "{FINAL_ACCESS_TOKEN}" + else: + self.send_response(400) + body = json.dumps({{"error": "unexpected_token_exchange"}}).encode() + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + body = json.dumps({{ + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 300, + }}).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + return + +HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() +"# + ) +} + +async fn start_container_token_endpoint(sandbox_name: &str, token_port: u16) -> Result<(), String> { + let socket = std::env::var("OPENSHELL_PODMAN_SOCKET") + .map_err(|_| "OPENSHELL_PODMAN_SOCKET must be set by e2e-podman.sh".to_string())?; + let container_name = format!("openshell-sandbox-{sandbox_name}"); + let mut cmd = Command::new("podman"); + cmd.arg("--url") + .arg(format!("unix://{socket}")) + .arg("exec") + .arg("-d") + .arg(&container_name) + .arg("python3") + .arg("-c") + .arg(container_token_endpoint_script()) + .arg(token_port.to_string()); + apply_podman_config_env(&mut cmd); + let output = cmd + .output() + .await + .map_err(|err| format!("spawn podman exec token endpoint: {err}"))?; + if !output.status.success() { + return Err(format!( + "podman exec token endpoint failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + + for _ in 0..20 { + if container_loopback_port_ready(&container_name, token_port).await { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + Err(format!( + "container token endpoint did not become ready on 127.0.0.1:{token_port}" + )) +} + +fn apply_podman_config_env(cmd: &mut Command) { + if std::env::var_os("OPENSHELL_E2E_CONTAINER_ENGINE_UNSET_XDG_CONFIG_HOME").is_some() { + cmd.env_remove("XDG_CONFIG_HOME"); + } else if let Some(value) = std::env::var_os("OPENSHELL_E2E_CONTAINER_ENGINE_XDG_CONFIG_HOME") { + cmd.env("XDG_CONFIG_HOME", value); + } +} + +async fn container_loopback_port_ready(container_name: &str, token_port: u16) -> bool { + let Ok(socket) = std::env::var("OPENSHELL_PODMAN_SOCKET") else { + return false; + }; + let probe = format!( + "import socket; s=socket.create_connection(('127.0.0.1', {token_port}), 1); s.close()" + ); + let mut cmd = Command::new("podman"); + cmd.arg("--url") + .arg(format!("unix://{socket}")) + .arg("exec") + .arg(container_name) + .arg("python3") + .arg("-c") + .arg(probe); + apply_podman_config_env(&mut cmd); + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + cmd.status() + .await + .map(|status| status.success()) + .unwrap_or(false) +} + +async fn sandbox_exec_curl(sandbox_name: &str, target_port: u16) -> Result { + let url = format!("http://host.openshell.internal:{target_port}/resource"); + for _ in 0..20 { + let output = openshell_cmd() + .args([ + "sandbox", + "exec", + "--name", + sandbox_name, + "--no-tty", + "--", + "curl", + "-fsS", + &url, + ]) + .output() + .await + .map_err(|err| format!("spawn openshell sandbox exec: {err}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}{stderr}"); + if output.status.success() { + return Ok(combined); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + Err(format!("curl to protected target did not succeed at {url}")) +} + +#[tokio::test] +async fn podman_provider_token_exchange_injects_bearer_header() { + let gateway_socket = PathBuf::from( + std::env::var("OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET") + .expect("OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET must be set by e2e-podman.sh"), + ); + let provider_socket = PathBuf::from( + std::env::var("OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET") + .expect("OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET must be set by e2e-podman.sh"), + ); + + let profile_type = format!("podman-token-exchange-e2e-{}", std::process::id()); + let provider_name = format!("podman-token-exchange-e2e-{}", std::process::id()); + let token_port = find_free_port(); + let target_port = find_free_port(); + let token_endpoint = format!("http://127.0.0.1:{token_port}/token"); + let gateway_subject = format!("spiffe://{TRUST_DOMAIN}/openshell/gateway"); + let supervisor_subject = format!("spiffe://{TRUST_DOMAIN}/openshell/sandbox/e2e"); + + let _gateway_spiffe = start_spiffe_workload_api(&gateway_socket, &gateway_subject).await; + let _provider_spiffe = start_spiffe_workload_api(&provider_socket, &supervisor_subject).await; + let _gateway_token = start_gateway_token_endpoint(token_port).await; + let _target = start_protected_target(target_port).await; + + run_cli(&[ + "settings", + "set", + "--global", + "--key", + "providers_v2_enabled", + "--value", + "true", + "--yes", + ]) + .await + .expect("enable providers v2"); + + run_cli_ignore_error(&["provider", "delete", &provider_name, "--yes"]).await; + run_cli_ignore_error(&["provider", "profile", "delete", &profile_type, "--yes"]).await; + + let profile = write_profile(&profile_type, token_port, target_port); + let profile_path = profile + .path() + .to_str() + .expect("profile path should be UTF-8"); + run_cli(&["provider", "profile", "import", "-f", profile_path]) + .await + .expect("import provider profile"); + run_cli(&[ + "provider", + "create", + "--name", + &provider_name, + "--type", + &profile_type, + "--credential", + &format!("subject_token={USER_SUBJECT_TOKEN}"), + ]) + .await + .expect("create provider"); + + let script = sandbox_script(token_port); + let mut sandbox = SandboxGuard::create_keep_with_args( + &["--provider", &provider_name], + &["sh", "-lc", &script], + "token-server-ready", + ) + .await + .unwrap_or_else(|err| { + panic!( + "sandbox should complete token exchange against {token_endpoint} and protected target port {target_port}:\n{err}" + ) + }); + start_container_token_endpoint(&sandbox.name, token_port) + .await + .expect("start container token endpoint"); + let curl_output = sandbox_exec_curl(&sandbox.name, target_port) + .await + .expect("curl protected target from kept sandbox"); + + run_cli_ignore_error(&["provider", "delete", &provider_name, "--yes"]).await; + run_cli_ignore_error(&["provider", "profile", "delete", &profile_type, "--yes"]).await; + sandbox.cleanup().await; + + assert!( + curl_output.contains("token-exchange-ok"), + "protected target should receive the final exchanged bearer token:\n{}", + curl_output + ); +} diff --git a/e2e/rust/tests/sandbox_lifecycle.rs b/e2e/rust/tests/sandbox_lifecycle.rs index 4caad8737..01e89422e 100644 --- a/e2e/rust/tests/sandbox_lifecycle.rs +++ b/e2e/rust/tests/sandbox_lifecycle.rs @@ -70,8 +70,7 @@ async fn delete_sandbox(name: &str) { #[tokio::test] async fn sandbox_create_keeps_sandbox_after_tty_command_by_default() { let mut cmd = openshell_tty_cmd(&["sandbox", "create", "--", "echo", "OK"]); - cmd.stdout(Stdio::piped()) - .stderr(Stdio::piped()); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); let output = cmd.output().await.expect("spawn openshell sandbox create"); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); @@ -83,9 +82,13 @@ async fn sandbox_create_keeps_sandbox_after_tty_command_by_default() { "sandbox create should succeed (exit {:?}):\n{combined}", output.status.code() ); - assert!(combined.contains("OK"), "expected command output in:\n{combined}"); + assert!( + combined.contains("OK"), + "expected command output in:\n{combined}" + ); - let sandbox_name = extract_sandbox_name(&combined).expect("sandbox name should be present in output"); + let sandbox_name = + extract_sandbox_name(&combined).expect("sandbox name should be present in output"); for _ in 0..20 { if sandbox_list_names().await.contains(&sandbox_name) { @@ -113,7 +116,10 @@ async fn sandbox_create_with_no_keep_cleans_up_after_tty_command() { "sandbox create should succeed (exit {:?}):\n{combined}", output.status.code() ); - assert!(combined.contains("OK"), "expected command output in:\n{combined}"); + assert!( + combined.contains("OK"), + "expected command output in:\n{combined}" + ); let sandbox_name = extract_sandbox_name(&combined).expect("sandbox name should be present in output"); diff --git a/e2e/rust/tests/settings_management.rs b/e2e/rust/tests/settings_management.rs index 69cb7cf16..fa1f292ad 100644 --- a/e2e/rust/tests/settings_management.rs +++ b/e2e/rust/tests/settings_management.rs @@ -44,14 +44,7 @@ impl GlobalSettingCleanup { impl Drop for GlobalSettingCleanup { fn drop(&mut self) { let _ = std::process::Command::new(openshell_bin()) - .args([ - "settings", - "delete", - "--global", - "--key", - self.key, - "--yes", - ]) + .args(["settings", "delete", "--global", "--key", self.key, "--yes"]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); @@ -122,20 +115,12 @@ async fn settings_global_override_round_trip() { .unwrap_or_else(std::sync::PoisonError::into_inner); let _global_cleanup = GlobalSettingCleanup::new(TEST_KEY); - let cleanup_before = run_cli(&[ - "settings", - "delete", - "--global", - "--key", - TEST_KEY, - "--yes", - ]) - .await; + let cleanup_before = + run_cli(&["settings", "delete", "--global", "--key", TEST_KEY, "--yes"]).await; assert!( cleanup_before.success, "initial global setting cleanup should succeed (exit {:?}):\n{}", - cleanup_before.exit_code, - cleanup_before.clean_output + cleanup_before.exit_code, cleanup_before.clean_output ); let mut guard = @@ -147,47 +132,52 @@ async fn settings_global_override_round_trip() { assert!( initial.success, "settings get should succeed (exit {:?}):\n{}", - initial.exit_code, - initial.clean_output + initial.exit_code, initial.clean_output ); assert_setting_line_with_scope(&initial.clean_output, TEST_KEY, "", "unset"); let set_sandbox = run_cli(&[ - "settings", "set", &guard.name, "--key", TEST_KEY, "--value", "true", + "settings", + "set", + &guard.name, + "--key", + TEST_KEY, + "--value", + "true", ]) .await; assert!( set_sandbox.success, "sandbox setting set should succeed (exit {:?}):\n{}", - set_sandbox.exit_code, - set_sandbox.clean_output + set_sandbox.exit_code, set_sandbox.clean_output ); // Wait for the gateway to reflect the setting change. The setting is stored // server-side, so `settings get` reads it immediately. Poll to ensure the // config_revision has been updated (visible in the output). - wait_for_setting_value(&guard.name, TEST_KEY, "true", "sandbox", Duration::from_secs(10)) - .await; + wait_for_setting_value( + &guard.name, + TEST_KEY, + "true", + "sandbox", + Duration::from_secs(10), + ) + .await; let after_sandbox_set = run_cli(&["settings", "get", &guard.name]).await; assert!( after_sandbox_set.success, "settings get after sandbox set should succeed (exit {:?}):\n{}", - after_sandbox_set.exit_code, - after_sandbox_set.clean_output + after_sandbox_set.exit_code, after_sandbox_set.clean_output ); assert_setting_line_with_scope(&after_sandbox_set.clean_output, TEST_KEY, "true", "sandbox"); // Sandbox-scoped delete should succeed when not globally managed. - let sandbox_delete = run_cli(&[ - "settings", "delete", &guard.name, "--key", TEST_KEY, - ]) - .await; + let sandbox_delete = run_cli(&["settings", "delete", &guard.name, "--key", TEST_KEY]).await; assert!( sandbox_delete.success, "sandbox setting delete should succeed (exit {:?}):\n{}", - sandbox_delete.exit_code, - sandbox_delete.clean_output + sandbox_delete.exit_code, sandbox_delete.clean_output ); assert!( sandbox_delete @@ -213,10 +203,20 @@ async fn settings_global_override_round_trip() { // Re-set at sandbox scope so we can test global override next. let re_set = run_cli(&[ - "settings", "set", &guard.name, "--key", TEST_KEY, "--value", "true", + "settings", + "set", + &guard.name, + "--key", + TEST_KEY, + "--value", + "true", ]) .await; - assert!(re_set.success, "re-set should succeed:\n{}", re_set.clean_output); + assert!( + re_set.success, + "re-set should succeed:\n{}", + re_set.clean_output + ); let set_global = run_cli(&[ "settings", "set", "--global", "--key", TEST_KEY, "--value", "false", "--yes", @@ -225,8 +225,7 @@ async fn settings_global_override_round_trip() { assert!( set_global.success, "global setting set should succeed (exit {:?}):\n{}", - set_global.exit_code, - set_global.clean_output + set_global.exit_code, set_global.clean_output ); assert!( set_global @@ -237,7 +236,13 @@ async fn settings_global_override_round_trip() { ); let blocked_sandbox_set = run_cli(&[ - "settings", "set", &guard.name, "--key", TEST_KEY, "--value", "true", + "settings", + "set", + &guard.name, + "--key", + TEST_KEY, + "--value", + "true", ]) .await; assert!( @@ -252,10 +257,8 @@ async fn settings_global_override_round_trip() { ); // Sandbox-scoped delete should also be blocked while globally managed. - let blocked_sandbox_delete = run_cli(&[ - "settings", "delete", &guard.name, "--key", TEST_KEY, - ]) - .await; + let blocked_sandbox_delete = + run_cli(&["settings", "delete", &guard.name, "--key", TEST_KEY]).await; assert!( !blocked_sandbox_delete.success, "sandbox delete should fail while key is global-managed:\n{}", @@ -266,25 +269,16 @@ async fn settings_global_override_round_trip() { assert!( global_get.success, "global settings get should succeed (exit {:?}):\n{}", - global_get.exit_code, - global_get.clean_output + global_get.exit_code, global_get.clean_output ); assert_setting_line(&global_get.clean_output, TEST_KEY, "false"); - let delete_global = run_cli(&[ - "settings", - "delete", - "--global", - "--key", - TEST_KEY, - "--yes", - ]) - .await; + let delete_global = + run_cli(&["settings", "delete", "--global", "--key", TEST_KEY, "--yes"]).await; assert!( delete_global.success, "global settings delete should succeed (exit {:?}):\n{}", - delete_global.exit_code, - delete_global.clean_output + delete_global.exit_code, delete_global.clean_output ); assert!( delete_global @@ -298,30 +292,38 @@ async fn settings_global_override_round_trip() { assert!( global_after_delete.success, "global settings get after delete should succeed (exit {:?}):\n{}", - global_after_delete.exit_code, - global_after_delete.clean_output + global_after_delete.exit_code, global_after_delete.clean_output ); assert_setting_line(&global_after_delete.clean_output, TEST_KEY, ""); let sandbox_set_after_delete = run_cli(&[ - "settings", "set", &guard.name, "--key", TEST_KEY, "--value", "false", + "settings", + "set", + &guard.name, + "--key", + TEST_KEY, + "--value", + "false", ]) .await; assert!( sandbox_set_after_delete.success, "sandbox setting set after global delete should succeed (exit {:?}):\n{}", - sandbox_set_after_delete.exit_code, - sandbox_set_after_delete.clean_output + sandbox_set_after_delete.exit_code, sandbox_set_after_delete.clean_output ); let sandbox_after_delete = run_cli(&["settings", "get", &guard.name]).await; assert!( sandbox_after_delete.success, "settings get after global delete should succeed (exit {:?}):\n{}", - sandbox_after_delete.exit_code, - sandbox_after_delete.clean_output + sandbox_after_delete.exit_code, sandbox_after_delete.clean_output + ); + assert_setting_line_with_scope( + &sandbox_after_delete.clean_output, + TEST_KEY, + "false", + "sandbox", ); - assert_setting_line_with_scope(&sandbox_after_delete.clean_output, TEST_KEY, "false", "sandbox"); guard.cleanup().await; } diff --git a/e2e/rust/tests/upload_create.rs b/e2e/rust/tests/upload_create.rs index b1284cc15..5cc396484 100644 --- a/e2e/rust/tests/upload_create.rs +++ b/e2e/rust/tests/upload_create.rs @@ -35,13 +35,10 @@ async fn create_with_upload_directory_preserves_source_basename() { // The command reads the marker file — if upload worked, its content // appears in the output. - let mut guard = SandboxGuard::create_with_upload( - upload_str, - "/sandbox/data", - &["cat", remote_marker], - ) - .await - .expect("sandbox create --upload"); + let mut guard = + SandboxGuard::create_with_upload(upload_str, "/sandbox/data", &["cat", remote_marker]) + .await + .expect("sandbox create --upload"); let clean = strip_ansi(&guard.create_output); assert!( @@ -70,7 +67,11 @@ async fn create_with_multiple_uploads() { let mut guard = SandboxGuard::create_with_uploads( &[(spec_a, "/sandbox/alpha"), (spec_b, "/sandbox/beta")], - &["sh", "-c", "cat /sandbox/alpha/alpha/a.txt /sandbox/beta/beta/b.txt"], + &[ + "sh", + "-c", + "cat /sandbox/alpha/alpha/a.txt /sandbox/beta/beta/b.txt", + ], ) .await .expect("sandbox create with multiple --upload flags"); @@ -97,13 +98,10 @@ async fn create_with_upload_single_file() { let file_str = file_path.to_str().expect("file path is UTF-8"); - let mut guard = SandboxGuard::create_with_upload( - file_str, - "/sandbox", - &["cat", "/sandbox/config.txt"], - ) - .await - .expect("sandbox create --upload single file"); + let mut guard = + SandboxGuard::create_with_upload(file_str, "/sandbox", &["cat", "/sandbox/config.txt"]) + .await + .expect("sandbox create --upload single file"); let clean = strip_ansi(&guard.create_output); assert!( diff --git a/e2e/rust/tests/user_namespaces.rs b/e2e/rust/tests/user_namespaces.rs index 5e9996817..53f2c6ec1 100644 --- a/e2e/rust/tests/user_namespaces.rs +++ b/e2e/rust/tests/user_namespaces.rs @@ -48,14 +48,24 @@ async fn set_user_namespaces(enable: bool) -> Result<(), String> { }; kubectl(&[ - "set", "env", "statefulset/openshell", - "-n", "openshell", env_arg, - ]).await?; + "set", + "env", + "statefulset/openshell", + "-n", + "openshell", + env_arg, + ]) + .await?; kubectl(&[ - "rollout", "status", "statefulset/openshell", - "-n", "openshell", "--timeout=120s", - ]).await?; + "rollout", + "status", + "statefulset/openshell", + "-n", + "openshell", + "--timeout=120s", + ]) + .await?; // Give the gateway time to fully initialize after rollout. tokio::time::sleep(Duration::from_secs(5)).await; @@ -84,16 +94,25 @@ async fn wait_for_sandbox(name: &str, timeout_secs: u64) -> Result<(), String> { let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); while tokio::time::Instant::now() < deadline { if let Ok(n) = kubectl(&[ - "get", "sandbox", name, "-n", "openshell", - "-o", "jsonpath={.metadata.name}", - ]).await { + "get", + "sandbox", + name, + "-n", + "openshell", + "-o", + "jsonpath={.metadata.name}", + ]) + .await + { if !n.trim().is_empty() { return Ok(()); } } tokio::time::sleep(Duration::from_secs(2)).await; } - Err(format!("sandbox {name} did not appear within {timeout_secs}s")) + Err(format!( + "sandbox {name} did not appear within {timeout_secs}s" + )) } /// Find a sandbox pod by its sandbox CRD name. The CRD controller creates a @@ -102,16 +121,25 @@ async fn wait_for_sandbox_pod(name: &str, timeout_secs: u64) -> Result<(), Strin let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); while tokio::time::Instant::now() < deadline { if let Ok(n) = kubectl(&[ - "get", "pod", name, "-n", "openshell", - "-o", "jsonpath={.metadata.name}", - ]).await { + "get", + "pod", + name, + "-n", + "openshell", + "-o", + "jsonpath={.metadata.name}", + ]) + .await + { if !n.trim().is_empty() { return Ok(()); } } tokio::time::sleep(Duration::from_secs(2)).await; } - Err(format!("sandbox pod {name} did not appear within {timeout_secs}s")) + Err(format!( + "sandbox pod {name} did not appear within {timeout_secs}s" + )) } // Disabled by default — not reachable from any project-controlled cluster @@ -144,9 +172,13 @@ async fn sandbox_pod_spec_has_user_namespace_fields() { // ready in DinD environments, so we spawn the CLI and inspect the pod // spec independently. let mut cmd = openshell_cmd(); - cmd.arg("sandbox").arg("create") - .arg("--name").arg(&sandbox_name) - .arg("--").arg("sleep").arg("infinity"); + cmd.arg("sandbox") + .arg("create") + .arg("--name") + .arg(&sandbox_name) + .arg("--") + .arg("sleep") + .arg("infinity"); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = cmd.spawn().expect("failed to spawn openshell create"); @@ -168,15 +200,27 @@ async fn sandbox_pod_spec_has_user_namespace_fields() { // Inspect the pod spec for hostUsers. let host_users = kubectl(&[ - "get", "pod", &sandbox_name, "-n", "openshell", - "-o", "jsonpath={.spec.hostUsers}", - ]).await; + "get", + "pod", + &sandbox_name, + "-n", + "openshell", + "-o", + "jsonpath={.spec.hostUsers}", + ]) + .await; // Inspect capabilities on the agent container. let caps = kubectl(&[ - "get", "pod", &sandbox_name, "-n", "openshell", - "-o", "jsonpath={.spec.containers[?(@.name=='agent')].securityContext.capabilities.add}", - ]).await; + "get", + "pod", + &sandbox_name, + "-n", + "openshell", + "-o", + "jsonpath={.spec.containers[?(@.name=='agent')].securityContext.capabilities.add}", + ]) + .await; // Clean up. stop_child(&mut child).await; @@ -186,7 +230,8 @@ async fn sandbox_pod_spec_has_user_namespace_fields() { // Assert hostUsers is false. let host_users_val = host_users.expect("failed to get hostUsers from pod spec"); assert_eq!( - host_users_val.trim(), "false", + host_users_val.trim(), + "false", "sandbox pod must have spec.hostUsers=false when user namespaces are enabled" ); diff --git a/e2e/rust/tests/vm_gateway_resume.rs b/e2e/rust/tests/vm_gateway_resume.rs index 3bff91df7..b2ec6c8b5 100644 --- a/e2e/rust/tests/vm_gateway_resume.rs +++ b/e2e/rust/tests/vm_gateway_resume.rs @@ -11,7 +11,9 @@ use std::time::Duration; -use openshell_e2e::harness::cli::{sandbox_names, wait_for_healthy, wait_for_sandbox_exec_contains}; +use openshell_e2e::harness::cli::{ + sandbox_names, wait_for_healthy, wait_for_sandbox_exec_contains, +}; use openshell_e2e::harness::gateway::ManagedGateway; use openshell_e2e::harness::sandbox::SandboxGuard; diff --git a/e2e/with-podman-gateway.sh b/e2e/with-podman-gateway.sh index 77b4c1324..c1e83660b 100755 --- a/e2e/with-podman-gateway.sh +++ b/e2e/with-podman-gateway.sh @@ -82,6 +82,15 @@ podman_cmd() { WORKDIR_PARENT="${TMPDIR:-/tmp}" WORKDIR_PARENT="${WORKDIR_PARENT%/}" WORKDIR="$(mktemp -d "${WORKDIR_PARENT}/openshell-e2e-podman.XXXXXX")" +if [ "${OPENSHELL_E2E_SPIFFE_FIXTURE:-0}" = "1" ]; then + mkdir -p "${WORKDIR}/spiffe" + export OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET="${OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET:-${WORKDIR}/spiffe/gateway.sock}" + if [ -z "${OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET:-}" ]; then + OPENSHELL_E2E_PROVIDER_SPIFFE_PORT="$(e2e_pick_port)" + export OPENSHELL_E2E_PROVIDER_SPIFFE_LISTEN="0.0.0.0:${OPENSHELL_E2E_PROVIDER_SPIFFE_PORT}" + export OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET="tcp:169.254.1.2:${OPENSHELL_E2E_PROVIDER_SPIFFE_PORT}" + fi +fi GATEWAY_BIN="" CLI_BIN="" GATEWAY_PID="" @@ -421,6 +430,9 @@ cp "${ROOT}/deploy/rpm/gateway.toml.default" "${GATEWAY_CONFIG}" printf 'guest_tls_cert = %s\n' "$(toml_string "${PKI_DIR}/client/tls.crt")" printf 'guest_tls_key = %s\n' "$(toml_string "${PKI_DIR}/client/tls.key")" printf 'enable_bind_mounts = true\n' + if [ -n "${OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET:-}" ]; then + printf 'provider_spiffe_workload_api_socket = %s\n' "$(toml_string "${OPENSHELL_E2E_PROVIDER_SPIFFE_SOCKET}")" + fi # The in-process Podman driver reads `socket_path` from TOML only — the # OPENSHELL_PODMAN_SOCKET env var is honoured by the standalone driver # binary, not the in-process driver used here. Pin the socket to the one @@ -453,6 +465,7 @@ e2e_export_gateway_restart_metadata \ OPENSHELL_SUPERVISOR_IMAGE="${SUPERVISOR_IMAGE}" \ OPENSHELL_NETWORK_NAME="${PODMAN_NETWORK_NAME}" \ +OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET="${OPENSHELL_E2E_GATEWAY_SPIFFE_SOCKET:-}" \ "${GATEWAY_BIN}" "${GATEWAY_ARGS[@]}" >"${GATEWAY_LOG}" 2>&1 & GATEWAY_PID=$! printf '%s\n' "${GATEWAY_PID}" >"${GATEWAY_PID_FILE}" diff --git a/examples/spiffe-token-exchange-demo/README.md b/examples/spiffe-token-exchange-demo/README.md new file mode 100644 index 000000000..92d790bd7 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/README.md @@ -0,0 +1,221 @@ +# SPIFFE Token Exchange Demo + +This example validates provider dynamic token exchange using SPIFFE JWT-SVIDs. +It runs alongside `examples/spiffe-token-grant-demo` but exercises the +`token_exchange` grant type instead of `client_credentials`. + +The demo deploys three in-cluster workloads: + +| Workload | Purpose | +|---|---| +| `token-exchange-issuer` | Issues a demo user subject token, performs the gateway intermediate token exchange, and performs the supervisor final token exchange | +| `alpha-exchange` | Requires a final bearer token with audience and scope `alpha` | +| `beta-exchange` | Requires a final bearer token with audience and scope `beta` | + +The OpenShell provider profile in `provider-profile.yaml` declares a stored +`subject_token` credential and a runtime `access_token` credential with +`token_grant.grant_type: token_exchange`. + +The profile declares exact Kubernetes service hostnames for `alpha-exchange` +and `beta-exchange`. It intentionally does not set `allowed_ips`, because +cluster service CIDRs vary across Kubernetes installations. + +When a sandbox curls `alpha-exchange` or `beta-exchange`: + +1. The supervisor fetches its SPIFFE JWT-SVID. +2. The supervisor asks the gateway for an intermediate token. +3. The gateway verifies the supervisor SVID, fetches its own gateway JWT-SVID, + and exchanges the stored provider `subject_token` at `token-exchange-issuer`. + The requested intermediate audience is the supervisor SPIFFE ID. +4. The supervisor exchanges the intermediate token at the same token endpoint + for the final alpha/beta access token. +5. The supervisor injects that final token into the outbound HTTP request. + +## Prerequisites + +- A Kubernetes OpenShell dev cluster. +- SPIRE enabled for provider token grants and gateway token exchange. +- Gateway and supervisor access to SPIRE OIDC/JWKS discovery. +- OpenShell configured with the Kubernetes ServiceAccount supervisor bootstrap + path. +- `providers_v2_enabled=true` on the target gateway. +- Local `curl`, `python3`, `openssl`, `nc`, `kubectl`, and `openshell`. +- A registered and logged-in CLI gateway. The script uses `GATEWAY_NAME`, then + `OPENSHELL_GATEWAY`, then the active OpenShell gateway selection. + +For the Helm dev environment, deploy with the SPIRE releases and +`ci/values-spire.yaml` enabled in `deploy/helm/openshell/skaffold.yaml`. + +The demo assumes these SPIFFE ID prefixes: + +| Identity | Prefix | +|---|---| +| Gateway | `spiffe://openshell.local/ns/openshell/sa/` | +| Supervisor | `spiffe://openshell.local/openshell/sandbox/` | + +Override `GATEWAY_TRUST_DOMAIN_PREFIX` or `SUPERVISOR_TRUST_DOMAIN_PREFIX` in +`k8s/workloads.yaml` if your development cluster uses different SPIFFE IDs. + +The demo issuer fetches SPIRE JWKS from the in-cluster OIDC discovery service +to verify JWT-SVID signatures. The issuer pod runs a `spiffe-helper` sidecar +that writes the SPIFFE bundle into a shared volume. The Node issuer uses that +bundle as `SPIRE_JWKS_CA_FILE` when fetching JWKS over HTTPS. + +## Kubeconfig And Mise + +The repository `mise.toml` sets `KUBECONFIG` to the repo-local `kubeconfig` +when your shell activates the OpenShell directory. If you are testing against a +different cluster, run these commands from outside the repository and pass the +target kubeconfig explicitly. + +```bash +export OPENSHELL_REPO=/path/to/OpenShell +export DEMO_KUBECONFIG=/path/to/your/kubeconfig +export OPENSHELL_GATEWAY=local +``` + +## Deploy Workloads + +From a directory outside the repository: + +```bash +ACCESS_TOKEN_SECRET="$(openssl rand -hex 32)" +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n default create secret generic openshell-spiffe-token-exchange-demo \ + --from-literal=access-token-secret="$ACCESS_TOKEN_SECRET" \ + --dry-run=client \ + -o yaml | KUBECONFIG="$DEMO_KUBECONFIG" kubectl apply -f - +KUBECONFIG="$DEMO_KUBECONFIG" kubectl apply -k "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/k8s" +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n default rollout restart deployment/token-exchange-issuer deployment/alpha-exchange deployment/beta-exchange +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n default rollout status deployment/token-exchange-issuer --timeout=180s +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n default rollout status deployment/alpha-exchange --timeout=180s +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n default rollout status deployment/beta-exchange --timeout=180s +``` + +## Register Provider And Test + +Port-forward the local gateway in one terminal: + +```bash +KUBECONFIG="$DEMO_KUBECONFIG" kubectl port-forward -n openshell svc/openshell 8097:8080 +``` + +Copy the Helm-generated TLS client bundle into the CLI config used for this +demo. This uses the same gateway name as `OPENSHELL_GATEWAY`. + +```bash +mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateways/${OPENSHELL_GATEWAY}/mtls" +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n openshell get secret openshell-client-tls \ + -o jsonpath='{.data.ca\.crt}' | base64 -d > "${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateways/${OPENSHELL_GATEWAY}/mtls/ca.crt" +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n openshell get secret openshell-client-tls \ + -o jsonpath='{.data.tls\.crt}' | base64 -d > "${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateways/${OPENSHELL_GATEWAY}/mtls/tls.crt" +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n openshell get secret openshell-client-tls \ + -o jsonpath='{.data.tls\.key}' | base64 -d > "${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateways/${OPENSHELL_GATEWAY}/mtls/tls.key" +``` + +Port-forward the token exchange issuer in another terminal and fetch a demo +subject token: + +```bash +KUBECONFIG="$DEMO_KUBECONFIG" kubectl port-forward -n default svc/token-exchange-issuer 18080:80 +SUBJECT_TOKEN="$( + curl -fsS http://127.0.0.1:18080/demo-subject-token | + python3 -c 'import json, sys; print(json.load(sys.stdin)["access_token"])' +)" +``` + +Then run: + +```bash +export GATEWAY=https://127.0.0.1:8097 + +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" settings set \ + --global --key providers_v2_enabled --value true --yes + +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" provider profile import \ + -f "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/provider-profile.yaml" + +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" provider create \ + --name spiffe-token-exchange-demo \ + --type spiffe-token-exchange-demo \ + --credential "subject_token=${SUBJECT_TOKEN}" + +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" sandbox create \ + --name spiffe-token-exchange-demo \ + --provider spiffe-token-exchange-demo \ + --keep \ + --no-tty \ + -- echo "sandbox ready" + +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" sandbox exec \ + --name spiffe-token-exchange-demo \ + --no-tty \ + -- curl -sS http://alpha-exchange.default.svc.cluster.local/ + +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" sandbox exec \ + --name spiffe-token-exchange-demo \ + --no-tty \ + -- curl -sS http://beta-exchange.default.svc.cluster.local/ +``` + +Expected output includes the demo user as the token subject and the sandbox +SPIFFE ID as the authorized party/client: + +```text +alpha called with path /: + sub: demo-user + aud: alpha, account + scope: alpha profile email + azp: spiffe://openshell.local/openshell/sandbox/ + client_id: spiffe://openshell.local/openshell/sandbox/ + +beta called with path /: + sub: demo-user + aud: beta, account + scope: beta profile email + azp: spiffe://openshell.local/openshell/sandbox/ + client_id: spiffe://openshell.local/openshell/sandbox/ +``` + +The token issuer logs both token exchange phases: + +```bash +KUBECONFIG="$DEMO_KUBECONFIG" kubectl -n default logs deployment/token-exchange-issuer --tail=40 +``` + +Example log lines: + +```text +issued intermediate token for user=demo-user audience=spiffe://openshell.local/openshell/sandbox/ +issued final token for user=demo-user audience=alpha client=spiffe://openshell.local/openshell/sandbox/ +issued final token for user=demo-user audience=beta client=spiffe://openshell.local/openshell/sandbox/ +``` + +## Automated Demo + +`demo.sh` applies the workloads, fetches a demo subject token, registers the +provider profile, creates a sandbox, curls alpha/beta, and deletes the sandbox +with `openshell` on exit. It leaves the Kubernetes demo workloads in place and +prints diagnostics only when the run fails. + +```bash +cd /tmp +KUBECONFIG="$DEMO_KUBECONFIG" bash "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/demo.sh" +``` + +The script reuses your normal OpenShell CLI config so it can load the stored +OIDC token for `OPENSHELL_GATEWAY`. If you set `ISOLATED_CONFIG=1`, register +and log in to the gateway in that isolated config before running the demo. + +## Cleanup + +Delete the sandbox through OpenShell: + +```bash +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" sandbox delete spiffe-token-exchange-demo +``` + +Delete the demo workloads with Kubernetes: + +```bash +KUBECONFIG="$DEMO_KUBECONFIG" kubectl delete -k "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/k8s" +``` diff --git a/examples/spiffe-token-exchange-demo/demo.sh b/examples/spiffe-token-exchange-demo/demo.sh new file mode 100755 index 000000000..fd597c068 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/demo.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROFILE_FILE="${SCRIPT_DIR}/provider-profile.yaml" +K8S_DIR="${SCRIPT_DIR}/k8s" + +SANDBOX_NAME="${SANDBOX_NAME:-spiffe-token-exchange-demo}" +PROVIDER_NAME="${PROVIDER_NAME:-spiffe-token-exchange-demo}" +PROFILE_ID="${PROFILE_ID:-spiffe-token-exchange-demo}" +PORT_FORWARD_PORT="${PORT_FORWARD_PORT:-8097}" +TOKEN_ISSUER_PORT="${TOKEN_ISSUER_PORT:-18080}" +GATEWAY_ENDPOINT="${GATEWAY_ENDPOINT:-https://127.0.0.1:${PORT_FORWARD_PORT}}" +KEEP_SANDBOX="${KEEP_SANDBOX:-0}" +ISOLATED_CONFIG="${ISOLATED_CONFIG:-0}" +ACCESS_TOKEN_SECRET="${ACCESS_TOKEN_SECRET:-$(openssl rand -hex 32)}" + +TEMP_CONFIG_HOME="" +if [[ "$ISOLATED_CONFIG" == "1" ]]; then + TEMP_CONFIG_HOME="$(mktemp -d)" + export XDG_CONFIG_HOME="$TEMP_CONFIG_HOME" +fi + +default_gateway_name() { + if [[ -n "${GATEWAY_NAME:-}" ]]; then + printf "%s\n" "$GATEWAY_NAME" + return + fi + if [[ -n "${OPENSHELL_GATEWAY:-}" ]]; then + printf "%s\n" "$OPENSHELL_GATEWAY" + return + fi + + local config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + if [[ -s "${config_home}/openshell/active_gateway" ]]; then + head -n1 "${config_home}/openshell/active_gateway" + return + fi + if [[ -s /etc/openshell/active_gateway ]]; then + head -n1 /etc/openshell/active_gateway + return + fi + + printf "k8s\n" +} + +GATEWAY_NAME="$(default_gateway_name)" + +PF_PID="" +TOKEN_PF_PID="" + +dump_diagnostics() { + set +e + + printf "\n=== diagnostics: openshell sandbox logs ===\n" >&2 + "${OS[@]}" logs "$SANDBOX_NAME" -n 120 --source sandbox >&2 + + printf "\n=== diagnostics: gateway logs ===\n" >&2 + kubectl -n openshell logs -l app.kubernetes.io/name=openshell,app.kubernetes.io/instance=openshell \ + --tail=120 --prefix=true >&2 + + printf "\n=== diagnostics: token exchange issuer logs ===\n" >&2 + kubectl -n default logs -l app=token-exchange-issuer --tail=120 --prefix=true >&2 + + printf "\n=== diagnostics: alpha logs ===\n" >&2 + kubectl -n default logs -l app=alpha-exchange --tail=60 --prefix=true >&2 + + printf "\n=== diagnostics: beta logs ===\n" >&2 + kubectl -n default logs -l app=beta-exchange --tail=60 --prefix=true >&2 + + printf "\n=== diagnostics: gateway port-forward log ===\n" >&2 + sed 's/^/gateway-port-forward> /' /tmp/openshell-spiffe-token-exchange-demo-gateway-port-forward.log >&2 + + printf "\n=== diagnostics: token issuer port-forward log ===\n" >&2 + sed 's/^/issuer-port-forward> /' /tmp/openshell-spiffe-token-exchange-demo-issuer-port-forward.log >&2 +} + +cleanup() { + if [[ "$KEEP_SANDBOX" != "1" ]]; then + openshell --gateway "$GATEWAY_NAME" --gateway-endpoint "$GATEWAY_ENDPOINT" sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true + fi + if [[ -n "$PF_PID" ]]; then + kill "$PF_PID" >/dev/null 2>&1 || true + fi + if [[ -n "$TOKEN_PF_PID" ]]; then + kill "$TOKEN_PF_PID" >/dev/null 2>&1 || true + fi + if [[ -n "$TEMP_CONFIG_HOME" ]]; then + rm -rf "$TEMP_CONFIG_HOME" + fi +} + +on_exit() { + local status="$1" + if [[ "$status" -ne 0 ]]; then + dump_diagnostics || true + fi + cleanup + exit "$status" +} +trap 'on_exit $?' EXIT + +run() { + printf "\n$ %s\n" "$*" + "$@" +} + +wait_for_port() { + local port="$1" + local label="$2" + for _ in $(seq 1 60); do + if nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + printf "%s port-forward did not become ready\n" "$label" >&2 + exit 1 +} + +assert_contains() { + local haystack="$1" + local needle="$2" + if [[ "$haystack" != *"$needle"* ]]; then + printf "expected output to contain: %s\n" "$needle" >&2 + printf "actual output:\n%s\n" "$haystack" >&2 + exit 1 + fi +} + +install_gateway_tls_bundle() { + local config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + local tls_dir="${config_home}/openshell/gateways/${GATEWAY_NAME}/mtls" + mkdir -p "$tls_dir" + kubectl -n openshell get secret openshell-client-tls \ + -o jsonpath='{.data.ca\.crt}' | base64 -d >"${tls_dir}/ca.crt" + kubectl -n openshell get secret openshell-client-tls \ + -o jsonpath='{.data.tls\.crt}' | base64 -d >"${tls_dir}/tls.crt" + kubectl -n openshell get secret openshell-client-tls \ + -o jsonpath='{.data.tls\.key}' | base64 -d >"${tls_dir}/tls.key" +} + +subject_token_from_json() { + python3 -c 'import json, sys; print(json.load(sys.stdin)["access_token"])' +} + +sandbox_curl_until() { + local label="$1" + local url="$2" + local expected="$3" + local output="" + + for attempt in $(seq 1 12); do + printf "\n$ openshell sandbox exec %s curl (attempt %s)\n" "$label" "$attempt" + if output=$("${OS[@]}" sandbox exec --name "$SANDBOX_NAME" --no-tty -- curl -sS --max-time 10 "$url" 2>&1); then + printf "%s\n" "$output" + if [[ "$output" == *"$expected"* ]]; then + SANDBOX_CURL_OUTPUT="$output" + return 0 + fi + else + printf "%s\n" "$output" + fi + sleep 2 + done + + printf "timed out waiting for %s to return expected output\n" "$label" >&2 + printf "last output:\n%s\n" "$output" >&2 + exit 1 +} + +OS=(openshell --gateway "$GATEWAY_NAME" --gateway-endpoint "$GATEWAY_ENDPOINT") + +printf "Using OpenShell gateway '%s' at %s\n" "$GATEWAY_NAME" "$GATEWAY_ENDPOINT" + +printf "\n$ kubectl -n default create secret generic openshell-spiffe-token-exchange-demo --from-literal=access-token-secret=*** --dry-run=client -o yaml | kubectl apply -f -\n" +kubectl -n default create secret generic openshell-spiffe-token-exchange-demo \ + --from-literal=access-token-secret="$ACCESS_TOKEN_SECRET" \ + --dry-run=client \ + -o yaml | kubectl apply -f - + +run kubectl apply -k "$K8S_DIR" +run kubectl -n default rollout restart deployment/token-exchange-issuer deployment/alpha-exchange deployment/beta-exchange +run kubectl -n default rollout status deployment/token-exchange-issuer --timeout=180s +run kubectl -n default rollout status deployment/alpha-exchange --timeout=180s +run kubectl -n default rollout status deployment/beta-exchange --timeout=180s + +kubectl -n openshell port-forward svc/openshell "${PORT_FORWARD_PORT}:8080" >/tmp/openshell-spiffe-token-exchange-demo-gateway-port-forward.log 2>&1 & +PF_PID=$! +wait_for_port "$PORT_FORWARD_PORT" "gateway" +install_gateway_tls_bundle + +kubectl -n default port-forward svc/token-exchange-issuer "${TOKEN_ISSUER_PORT}:80" >/tmp/openshell-spiffe-token-exchange-demo-issuer-port-forward.log 2>&1 & +TOKEN_PF_PID=$! +wait_for_port "$TOKEN_ISSUER_PORT" "token issuer" + +SUBJECT_TOKEN="$(curl -fsS "http://127.0.0.1:${TOKEN_ISSUER_PORT}/demo-subject-token" | subject_token_from_json)" + +"${OS[@]}" sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true +"${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true +"${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true + +run "${OS[@]}" settings set --global --key providers_v2_enabled --value true --yes +run "${OS[@]}" provider profile lint -f "$PROFILE_FILE" +run "${OS[@]}" provider profile import -f "$PROFILE_FILE" +run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --credential "subject_token=${SUBJECT_TOKEN}" +run "${OS[@]}" sandbox create --name "$SANDBOX_NAME" --provider "$PROVIDER_NAME" --keep --no-tty -- echo "sandbox ready" + +sandbox_curl_until "alpha" "http://alpha-exchange.default.svc.cluster.local/" "alpha called with path /:" +ALPHA_OUTPUT="$SANDBOX_CURL_OUTPUT" +assert_contains "$ALPHA_OUTPUT" "alpha called with path /:" +assert_contains "$ALPHA_OUTPUT" "sub: demo-user" +assert_contains "$ALPHA_OUTPUT" "aud: alpha, account" +assert_contains "$ALPHA_OUTPUT" "scope: alpha profile email" +assert_contains "$ALPHA_OUTPUT" "azp: spiffe://openshell.local/openshell/sandbox/" + +sandbox_curl_until "beta" "http://beta-exchange.default.svc.cluster.local/" "beta called with path /:" +BETA_OUTPUT="$SANDBOX_CURL_OUTPUT" +assert_contains "$BETA_OUTPUT" "beta called with path /:" +assert_contains "$BETA_OUTPUT" "sub: demo-user" +assert_contains "$BETA_OUTPUT" "aud: beta, account" +assert_contains "$BETA_OUTPUT" "scope: beta profile email" +assert_contains "$BETA_OUTPUT" "azp: spiffe://openshell.local/openshell/sandbox/" + +printf "\nSPIFFE token exchange demo succeeded.\n" diff --git a/examples/spiffe-token-exchange-demo/k8s/kustomization.yaml b/examples/spiffe-token-exchange-demo/k8s/kustomization.yaml new file mode 100644 index 000000000..d4cdcb80a --- /dev/null +++ b/examples/spiffe-token-exchange-demo/k8s/kustomization.yaml @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +generatorOptions: + disableNameSuffixHash: true + +configMapGenerator: + - name: openshell-spiffe-token-exchange-demo-scripts + files: + - token-issuer.js + - protected-service.js + - name: openshell-spiffe-token-exchange-demo-helper + files: + - spiffe-helper.conf + +resources: + - workloads.yaml diff --git a/examples/spiffe-token-exchange-demo/k8s/protected-service.js b/examples/spiffe-token-exchange-demo/k8s/protected-service.js new file mode 100644 index 000000000..263601c70 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/k8s/protected-service.js @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const http = require("http"); +const crypto = require("crypto"); + +const PORT = Number(process.env.PORT || 8080); +const SERVICE_NAME = process.env.SERVICE_NAME || "alpha"; +const EXPECTED_AUDIENCE = process.env.EXPECTED_AUDIENCE || SERVICE_NAME; +const EXPECTED_SCOPE = process.env.EXPECTED_SCOPE || SERVICE_NAME; +const ACCESS_TOKEN_ISSUER = + process.env.ACCESS_TOKEN_ISSUER || "http://token-exchange-issuer.default.svc.cluster.local"; +const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET; + +if (!ACCESS_TOKEN_SECRET) { + throw new Error("ACCESS_TOKEN_SECRET is required"); +} + +function b64urlDecode(value) { + const padded = `${value}${"=".repeat((4 - (value.length % 4)) % 4)}`; + return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64"); +} + +function b64urlEncode(value) { + return Buffer.from(value) + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +function parseJwt(jwt) { + const parts = jwt.split("."); + if (parts.length !== 3) { + throw new Error("JWT must contain three segments"); + } + return { + payload: JSON.parse(b64urlDecode(parts[1]).toString("utf8")), + signingInput: `${parts[0]}.${parts[1]}`, + signature: parts[2], + }; +} + +function verifyAccessToken(jwt) { + const parsed = parseJwt(jwt); + const expected = b64urlEncode( + crypto.createHmac("sha256", ACCESS_TOKEN_SECRET).update(parsed.signingInput).digest(), + ); + if ( + parsed.signature.length !== expected.length || + !crypto.timingSafeEqual(Buffer.from(parsed.signature), Buffer.from(expected)) + ) { + throw new Error("access token signature validation failed"); + } + + const now = Math.floor(Date.now() / 1000); + if (parsed.payload.exp && parsed.payload.exp <= now) { + throw new Error("access token expired"); + } + if (parsed.payload.iss !== ACCESS_TOKEN_ISSUER) { + throw new Error(`unexpected access token issuer ${parsed.payload.iss}`); + } + if (parsed.payload.demo_token_use !== "final") { + throw new Error("expected final token"); + } + const aud = Array.isArray(parsed.payload.aud) ? parsed.payload.aud : [parsed.payload.aud]; + if (!aud.includes(EXPECTED_AUDIENCE)) { + throw new Error(`access token audience did not include ${EXPECTED_AUDIENCE}`); + } + const scopes = String(parsed.payload.scope || "").split(/\s+/).filter(Boolean); + if (!scopes.includes(EXPECTED_SCOPE)) { + throw new Error(`access token scope did not include ${EXPECTED_SCOPE}`); + } + return parsed.payload; +} + +function text(res, status, body) { + res.writeHead(status, { + "content-type": "text/plain", + "content-length": Buffer.byteLength(body), + }); + res.end(body); +} + +http + .createServer((req, res) => { + try { + if (req.url === "/healthz") { + return text(res, 200, "ok\n"); + } + const auth = req.headers.authorization || ""; + const token = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : ""; + if (!token) { + console.warn(`${SERVICE_NAME} rejected request path=${req.url} reason=missing_bearer_token`); + return text(res, 401, `${SERVICE_NAME} missing bearer token\n`); + } + const claims = verifyAccessToken(token); + const aud = Array.isArray(claims.aud) ? claims.aud.join(", ") : claims.aud; + console.log( + `${SERVICE_NAME} accepted request path=${req.url} sub=${claims.sub} aud="${aud}" scope="${claims.scope}" client_id=${claims.client_id}`, + ); + return text( + res, + 200, + `${SERVICE_NAME} called with path ${req.url}:\n` + + ` sub: ${claims.sub}\n` + + ` aud: ${aud}\n` + + ` iss: ${claims.iss}\n` + + ` scope: ${claims.scope}\n` + + ` azp: ${claims.azp}\n` + + ` client_id: ${claims.client_id}\n`, + ); + } catch (error) { + console.warn(`${SERVICE_NAME} rejected request path=${req.url} reason="${error.message}"`); + return text(res, 403, `${SERVICE_NAME} rejected token: ${error.message}\n`); + } + }) + .listen(PORT, "0.0.0.0", () => { + console.log(`${SERVICE_NAME} listening on ${PORT}`); + }); diff --git a/examples/spiffe-token-exchange-demo/k8s/spiffe-helper.conf b/examples/spiffe-token-exchange-demo/k8s/spiffe-helper.conf new file mode 100644 index 000000000..93987d00c --- /dev/null +++ b/examples/spiffe-token-exchange-demo/k8s/spiffe-helper.conf @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +agent_address = "/run/spire/sockets/spire-agent.sock" +cert_dir = "/target" +svid_file_name = "svid.pem" +svid_key_file_name = "key.pem" +svid_bundle_file_name = "bundle.pem" diff --git a/examples/spiffe-token-exchange-demo/k8s/token-issuer.js b/examples/spiffe-token-exchange-demo/k8s/token-issuer.js new file mode 100644 index 000000000..e4cc22af5 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/k8s/token-issuer.js @@ -0,0 +1,333 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const http = require("http"); +const https = require("https"); +const crypto = require("crypto"); +const fs = require("fs"); + +const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; +const JWT_SPIFFE_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe"; + +const PORT = Number(process.env.PORT || 8080); +const JWKS_URI = + process.env.SPIRE_JWKS_URI || + "https://spire-spiffe-oidc-discovery-provider.spire.svc.cluster.local/keys"; +const SPIRE_ISSUER = + process.env.SPIRE_ISSUER || + "https://spire-spiffe-oidc-discovery-provider.spire.svc.cluster.local"; +const JWT_SVID_AUDIENCE = + process.env.JWT_SVID_AUDIENCE || "http://token-exchange-issuer.default.svc.cluster.local"; +const SUPERVISOR_TRUST_DOMAIN_PREFIX = + process.env.SUPERVISOR_TRUST_DOMAIN_PREFIX || "spiffe://openshell.local/openshell/sandbox/"; +const GATEWAY_TRUST_DOMAIN_PREFIX = + process.env.GATEWAY_TRUST_DOMAIN_PREFIX || "spiffe://openshell.local/ns/openshell/sa/"; +const ACCESS_TOKEN_ISSUER = + process.env.ACCESS_TOKEN_ISSUER || "http://token-exchange-issuer.default.svc.cluster.local"; +const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET; +const DEMO_USER_SUBJECT = process.env.DEMO_USER_SUBJECT || "demo-user"; +const SPIRE_JWKS_CA_FILE = process.env.SPIRE_JWKS_CA_FILE || ""; + +if (!ACCESS_TOKEN_SECRET) { + throw new Error("ACCESS_TOKEN_SECRET is required"); +} + +let cachedJwks; +let cachedJwksAt = 0; + +function b64urlDecode(value) { + const padded = `${value}${"=".repeat((4 - (value.length % 4)) % 4)}`; + return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64"); +} + +function b64urlEncode(value) { + return Buffer.from(value) + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +function parseJwt(jwt) { + const parts = jwt.split("."); + if (parts.length !== 3) { + throw new Error("JWT must contain three segments"); + } + return { + header: JSON.parse(b64urlDecode(parts[0]).toString("utf8")), + payload: JSON.parse(b64urlDecode(parts[1]).toString("utf8")), + signingInput: `${parts[0]}.${parts[1]}`, + signature: b64urlDecode(parts[2]), + signatureB64: parts[2], + }; +} + +async function jwks() { + const now = Date.now(); + if (cachedJwks && now - cachedJwksAt < 60000) { + return cachedJwks; + } + cachedJwks = await fetchJson(JWKS_URI); + cachedJwksAt = now; + return cachedJwks; +} + +function fetchJson(url) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const isHttps = parsed.protocol === "https:"; + const client = isHttps ? https : http; + const options = {}; + if (isHttps && SPIRE_JWKS_CA_FILE) { + options.ca = fs.readFileSync(SPIRE_JWKS_CA_FILE); + } + + const req = client.get(parsed, options, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + if (res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error(`JWKS fetch failed with HTTP ${res.statusCode}: ${body}`)); + return; + } + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + }); + req.on("error", reject); + req.setTimeout(10000, () => req.destroy(new Error("JWKS fetch timed out"))); + }); +} + +function hasAudience(payload, expected) { + const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + return aud.includes(expected); +} + +async function verifyJwtSvid(jwt, subjectPrefix) { + const parsed = parseJwt(jwt); + if (parsed.header.alg !== "RS256") { + throw new Error(`unsupported JWT-SVID alg ${parsed.header.alg}`); + } + + const keys = await jwks(); + const jwk = keys.keys.find((key) => key.kid === parsed.header.kid); + if (!jwk) { + throw new Error(`no JWKS key for kid ${parsed.header.kid}`); + } + + const verifier = crypto.createVerify("RSA-SHA256"); + verifier.update(parsed.signingInput); + verifier.end(); + const publicKey = crypto.createPublicKey({ key: jwk, format: "jwk" }); + if (!verifier.verify(publicKey, parsed.signature)) { + throw new Error("JWT-SVID signature validation failed"); + } + + const now = Math.floor(Date.now() / 1000); + if (parsed.payload.exp && parsed.payload.exp <= now) { + throw new Error("JWT-SVID expired"); + } + if (parsed.payload.nbf && parsed.payload.nbf > now + 30) { + throw new Error("JWT-SVID not active yet"); + } + if (parsed.payload.iss !== SPIRE_ISSUER) { + throw new Error(`unexpected JWT-SVID issuer ${parsed.payload.iss}`); + } + if (!hasAudience(parsed.payload, JWT_SVID_AUDIENCE)) { + throw new Error(`JWT-SVID audience did not include ${JWT_SVID_AUDIENCE}`); + } + if (!String(parsed.payload.sub || "").startsWith(subjectPrefix)) { + throw new Error(`JWT-SVID subject did not start with ${subjectPrefix}`); + } + return parsed.payload; +} + +function signAccessToken(payload) { + const header = b64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const body = b64urlEncode(JSON.stringify(payload)); + const signingInput = `${header}.${body}`; + const signature = crypto + .createHmac("sha256", ACCESS_TOKEN_SECRET) + .update(signingInput) + .digest(); + return `${signingInput}.${b64urlEncode(signature)}`; +} + +function verifyAccessToken(jwt, tokenUse) { + const parsed = parseJwt(jwt); + const expected = b64urlEncode( + crypto.createHmac("sha256", ACCESS_TOKEN_SECRET).update(parsed.signingInput).digest(), + ); + if ( + parsed.signatureB64.length !== expected.length || + !crypto.timingSafeEqual(Buffer.from(parsed.signatureB64), Buffer.from(expected)) + ) { + throw new Error("token signature validation failed"); + } + + const now = Math.floor(Date.now() / 1000); + if (parsed.payload.exp && parsed.payload.exp <= now) { + throw new Error("token expired"); + } + if (parsed.payload.iss !== ACCESS_TOKEN_ISSUER) { + throw new Error(`unexpected token issuer ${parsed.payload.iss}`); + } + if (parsed.payload.demo_token_use !== tokenUse) { + throw new Error(`expected ${tokenUse} token`); + } + return parsed.payload; +} + +function issueDemoSubjectToken() { + const now = Math.floor(Date.now() / 1000); + return signAccessToken({ + iss: ACCESS_TOKEN_ISSUER, + sub: DEMO_USER_SUBJECT, + aud: ["openshell-gateway", "account"], + scope: "openid profile email", + demo_token_use: "user_subject", + iat: now, + exp: now + 1800, + }); +} + +function json(res, status, body) { + const payload = JSON.stringify(body); + res.writeHead(status, { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + }); + res.end(payload); +} + +async function bodyText(req) { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + if (Buffer.concat(chunks).length > 1024 * 1024) { + throw new Error("request body too large"); + } + } + return Buffer.concat(chunks).toString("utf8"); +} + +async function handleTokenExchange(req, res) { + const params = new URLSearchParams(await bodyText(req)); + if (params.get("grant_type") !== TOKEN_EXCHANGE_GRANT_TYPE) { + return json(res, 400, { error: "unsupported_grant_type" }); + } + if (params.get("client_assertion_type") !== JWT_SPIFFE_ASSERTION_TYPE) { + return json(res, 400, { error: "unsupported_client_assertion_type" }); + } + + const jwtSvid = params.get("client_assertion"); + if (!jwtSvid) { + return json(res, 400, { error: "missing_client_assertion" }); + } + const subjectToken = params.get("subject_token"); + if (!subjectToken) { + return json(res, 400, { error: "missing_subject_token" }); + } + + const audience = params.get("audience") || ""; + const requestedScopes = (params.get("scope") || "").split(/\s+/).filter(Boolean); + const now = Math.floor(Date.now() / 1000); + + const userToken = (() => { + try { + return verifyAccessToken(subjectToken, "user_subject"); + } catch (_error) { + return null; + } + })(); + + if (userToken) { + const gatewaySvid = await verifyJwtSvid(jwtSvid, GATEWAY_TRUST_DOMAIN_PREFIX); + if (!audience.startsWith(SUPERVISOR_TRUST_DOMAIN_PREFIX)) { + return json(res, 400, { error: "unsupported_intermediate_audience", audience }); + } + const intermediateToken = signAccessToken({ + iss: ACCESS_TOKEN_ISSUER, + sub: userToken.sub, + aud: [audience], + scope: userToken.scope || "openid profile email", + azp: gatewaySvid.sub, + client_id: gatewaySvid.sub, + demo_token_use: "intermediate", + iat: now, + exp: now + 300, + }); + console.log(`issued intermediate token for user=${userToken.sub} audience=${audience}`); + return json(res, 200, { + access_token: intermediateToken, + token_type: "Bearer", + expires_in: 300, + }); + } + + const supervisorSvid = await verifyJwtSvid(jwtSvid, SUPERVISOR_TRUST_DOMAIN_PREFIX); + const intermediateToken = verifyAccessToken(subjectToken, "intermediate"); + if (!hasAudience(intermediateToken, supervisorSvid.sub)) { + return json(res, 403, { error: "intermediate_token_audience_mismatch" }); + } + if (!["alpha", "beta"].includes(audience)) { + return json(res, 400, { error: "unsupported_audience", audience }); + } + if (!requestedScopes.includes(audience)) { + return json(res, 403, { error: "missing_matching_scope" }); + } + + const accessToken = signAccessToken({ + iss: ACCESS_TOKEN_ISSUER, + sub: intermediateToken.sub, + aud: [audience, "account"], + scope: `${requestedScopes.join(" ")} profile email`, + azp: supervisorSvid.sub, + client_id: supervisorSvid.sub, + demo_token_use: "final", + iat: now, + exp: now + 300, + }); + + console.log( + `issued final token for user=${intermediateToken.sub} audience=${audience} client=${supervisorSvid.sub}`, + ); + return json(res, 200, { + access_token: accessToken, + token_type: "Bearer", + expires_in: 300, + scope: `${requestedScopes.join(" ")} profile email`, + }); +} + +http + .createServer(async (req, res) => { + try { + if (req.url === "/healthz") { + res.writeHead(200, { "content-type": "text/plain" }); + return res.end("ok\n"); + } + if (req.method === "GET" && req.url === "/demo-subject-token") { + return json(res, 200, { + access_token: issueDemoSubjectToken(), + token_type: "Bearer", + expires_in: 1800, + }); + } + if (req.method === "POST" && req.url === "/token") { + return await handleTokenExchange(req, res); + } + return json(res, 404, { error: "not_found" }); + } catch (error) { + console.error(error); + return json(res, 500, { error: "server_error", message: error.message }); + } + }) + .listen(PORT, "0.0.0.0", () => { + console.log(`token exchange issuer listening on ${PORT}`); + }); diff --git a/examples/spiffe-token-exchange-demo/k8s/workloads.yaml b/examples/spiffe-token-exchange-demo/k8s/workloads.yaml new file mode 100644 index 000000000..2bb163921 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/k8s/workloads.yaml @@ -0,0 +1,230 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: token-exchange-issuer + namespace: default + labels: + app: token-exchange-issuer +spec: + replicas: 1 + selector: + matchLabels: + app: token-exchange-issuer + template: + metadata: + labels: + app: token-exchange-issuer + spec: + containers: + - name: token-exchange-issuer + image: node:22-alpine + imagePullPolicy: IfNotPresent + command: ["node", "/demo/token-issuer.js"] + ports: + - name: http + containerPort: 8080 + env: + - name: ACCESS_TOKEN_SECRET + valueFrom: + secretKeyRef: + name: openshell-spiffe-token-exchange-demo + key: access-token-secret + - name: ACCESS_TOKEN_ISSUER + value: http://token-exchange-issuer.default.svc.cluster.local + - name: SPIRE_JWKS_URI + value: https://spire-spiffe-oidc-discovery-provider.spire.svc.cluster.local/keys + - name: SPIRE_JWKS_CA_FILE + value: /etc/x509/spiffe-bundle/bundle.pem + - name: SPIRE_ISSUER + value: https://spire-spiffe-oidc-discovery-provider.spire.svc.cluster.local + - name: JWT_SVID_AUDIENCE + value: http://token-exchange-issuer.default.svc.cluster.local + - name: SUPERVISOR_TRUST_DOMAIN_PREFIX + value: spiffe://openshell.local/openshell/sandbox/ + - name: GATEWAY_TRUST_DOMAIN_PREFIX + value: spiffe://openshell.local/ns/openshell/sa/ + - name: DEMO_USER_SUBJECT + value: demo-user + readinessProbe: + httpGet: + path: /healthz + port: http + volumeMounts: + - name: scripts + mountPath: /demo + readOnly: true + - name: spiffe-bundle + mountPath: /etc/x509/spiffe-bundle + readOnly: true + - name: spiffe-helper + image: ghcr.io/spiffe/spiffe-helper:0.11.0 + imagePullPolicy: IfNotPresent + args: ["-config", "/etc/spiffe-helper/spiffe-helper.conf"] + volumeMounts: + - name: spiffe-socket + mountPath: /run/spire/sockets + readOnly: true + - name: spiffe-bundle + mountPath: /target + - name: helper-config + mountPath: /etc/spiffe-helper + readOnly: true + volumes: + - name: scripts + configMap: + name: openshell-spiffe-token-exchange-demo-scripts + - name: helper-config + configMap: + name: openshell-spiffe-token-exchange-demo-helper + - name: spiffe-socket + csi: + driver: csi.spiffe.io + readOnly: true + - name: spiffe-bundle + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: token-exchange-issuer + namespace: default +spec: + selector: + app: token-exchange-issuer + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: alpha-exchange + namespace: default + labels: + app: alpha-exchange +spec: + replicas: 1 + selector: + matchLabels: + app: alpha-exchange + template: + metadata: + labels: + app: alpha-exchange + spec: + containers: + - name: alpha-exchange + image: node:22-alpine + imagePullPolicy: IfNotPresent + command: ["node", "/demo/protected-service.js"] + ports: + - name: http + containerPort: 8080 + env: + - name: SERVICE_NAME + value: alpha + - name: EXPECTED_AUDIENCE + value: alpha + - name: EXPECTED_SCOPE + value: alpha + - name: ACCESS_TOKEN_SECRET + valueFrom: + secretKeyRef: + name: openshell-spiffe-token-exchange-demo + key: access-token-secret + - name: ACCESS_TOKEN_ISSUER + value: http://token-exchange-issuer.default.svc.cluster.local + readinessProbe: + httpGet: + path: /healthz + port: http + volumeMounts: + - name: scripts + mountPath: /demo + readOnly: true + volumes: + - name: scripts + configMap: + name: openshell-spiffe-token-exchange-demo-scripts +--- +apiVersion: v1 +kind: Service +metadata: + name: alpha-exchange + namespace: default +spec: + selector: + app: alpha-exchange + ports: + - name: http + port: 80 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: beta-exchange + namespace: default + labels: + app: beta-exchange +spec: + replicas: 1 + selector: + matchLabels: + app: beta-exchange + template: + metadata: + labels: + app: beta-exchange + spec: + containers: + - name: beta-exchange + image: node:22-alpine + imagePullPolicy: IfNotPresent + command: ["node", "/demo/protected-service.js"] + ports: + - name: http + containerPort: 8080 + env: + - name: SERVICE_NAME + value: beta + - name: EXPECTED_AUDIENCE + value: beta + - name: EXPECTED_SCOPE + value: beta + - name: ACCESS_TOKEN_SECRET + valueFrom: + secretKeyRef: + name: openshell-spiffe-token-exchange-demo + key: access-token-secret + - name: ACCESS_TOKEN_ISSUER + value: http://token-exchange-issuer.default.svc.cluster.local + readinessProbe: + httpGet: + path: /healthz + port: http + volumeMounts: + - name: scripts + mountPath: /demo + readOnly: true + volumes: + - name: scripts + configMap: + name: openshell-spiffe-token-exchange-demo-scripts +--- +apiVersion: v1 +kind: Service +metadata: + name: beta-exchange + namespace: default +spec: + selector: + app: beta-exchange + ports: + - name: http + port: 80 + targetPort: http diff --git a/examples/spiffe-token-exchange-demo/provider-profile.yaml b/examples/spiffe-token-exchange-demo/provider-profile.yaml new file mode 100644 index 000000000..0c751ed00 --- /dev/null +++ b/examples/spiffe-token-exchange-demo/provider-profile.yaml @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: spiffe-token-exchange-demo +display_name: SPIFFE token exchange demo +description: Dynamic token exchange for alpha/beta demo services using stored user subject tokens and SPIFFE JWT-SVID authentication +category: other +credentials: + - name: subject_token + description: Demo user subject token stored on the provider for token exchange + required: true + - name: access_token + description: Access token obtained via RFC 8693 token exchange + required: false + auth_style: bearer + header_name: Authorization + token_grant: + grant_type: token_exchange + token_endpoint: http://token-exchange-issuer.default.svc.cluster.local/token + audience: demo-default + jwt_svid_audience: http://token-exchange-issuer.default.svc.cluster.local + client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-spiffe + scopes: [demo] + cache_ttl_seconds: 60 + subject_token: + source: provider_credential + credential: subject_token + subject_token_type: urn:ietf:params:oauth:token-type:access_token + audience_overrides: + - host: alpha-exchange.default.svc.cluster.local + port: 80 + audience: alpha + scopes: [alpha] + - host: beta-exchange.default.svc.cluster.local + port: 80 + audience: beta + scopes: [beta] +endpoints: + - host: alpha-exchange.default.svc.cluster.local + port: 80 + protocol: rest + tls: none + access: read-write + enforcement: enforce + - host: beta-exchange.default.svc.cluster.local + port: 80 + protocol: rest + tls: none + access: read-write + enforcement: enforce +binaries: + - /usr/bin/curl + - /usr/local/bin/curl diff --git a/proto/openshell.proto b/proto/openshell.proto index bf803e864..ff04503a1 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -157,6 +157,11 @@ service OpenShell { rpc GetSandboxProviderEnvironment(GetSandboxProviderEnvironmentRequest) returns (GetSandboxProviderEnvironmentResponse); + // Exchange a stored provider subject token for an intermediate token scoped + // to the calling supervisor's SPIFFE identity. + rpc ExchangeProviderSubjectToken(ExchangeProviderSubjectTokenRequest) + returns (ExchangeProviderSubjectTokenResponse); + // Fetch recent sandbox logs (one-shot). rpc GetSandboxLogs(GetSandboxLogsRequest) returns (GetSandboxLogsResponse); @@ -927,6 +932,25 @@ message ProviderCredentialTokenGrantAudienceOverride { // Provider credential token grant configuration. // When present, the credential is obtained dynamically via OAuth2 grant when needed. +enum ProviderCredentialTokenGrantType { + PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_UNSPECIFIED = 0; + PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_CLIENT_CREDENTIALS = 1; + PROVIDER_CREDENTIAL_TOKEN_GRANT_TYPE_TOKEN_EXCHANGE = 2; +} + +message ProviderCredentialTokenGrantSubjectToken { + // Source for the token exchange subject token. Phase one supports + // "provider_credential". + string source = 1; + + // Provider credential key that stores the subject token. + string credential = 2; + + // OAuth2 subject_token_type. If omitted, OpenShell uses + // urn:ietf:params:oauth:token-type:access_token. + string subject_token_type = 3; +} + message ProviderCredentialTokenGrant { // OAuth2 token endpoint URL (e.g., https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token) string token_endpoint = 1; @@ -951,6 +975,17 @@ message ProviderCredentialTokenGrant { // Optional: OAuth2 client_assertion_type value. If omitted, OpenShell uses // urn:ietf:params:oauth:client-assertion-type:jwt-bearer. string client_assertion_type = 7; + + // Grant type. If omitted/unspecified, OpenShell treats this as client_credentials + // for backwards compatibility. + ProviderCredentialTokenGrantType grant_type = 8; + + // Subject token metadata for token_exchange grants. + ProviderCredentialTokenGrantSubjectToken subject_token = 9; + + // OAuth2 requested_token_type. If omitted for token_exchange, OpenShell uses + // urn:ietf:params:oauth:token-type:access_token. + string requested_token_type = 10; } // Provider credential declaration. @@ -1191,6 +1226,27 @@ message GetSandboxProviderEnvironmentResponse { map dynamic_credentials = 4; } +message ExchangeProviderSubjectTokenRequest { + // The sandbox ID. Must match the authenticated sandbox principal. + string sandbox_id = 1; + + // Attached provider record holding the configured subject token credential. + string provider = 2; + + // Provider profile credential that declares the token_exchange grant. + string credential_key = 3; + + // Supervisor JWT-SVID. The gateway verifies this and uses its `sub` claim + // as the requested audience for the intermediate token. + string supervisor_jwt_svid = 4; +} + +message ExchangeProviderSubjectTokenResponse { + string access_token = 1; + int64 expires_in = 2; + string token_type = 3; +} + // --------------------------------------------------------------------------- // Policy update messages // ---------------------------------------------------------------------------