From aa07a5cf7a93ea648d41cede1dca0df39839bb80 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 28 May 2026 13:36:44 -0700 Subject: [PATCH 1/5] feat(providers): add okta token exchange delegation flow --- crates/openshell-cli/src/run.rs | 2 + .../tests/provider_commands_integration.rs | 128 ++++++++++++++ crates/openshell-core/src/metadata.rs | 38 +++- crates/openshell-providers/src/profiles.rs | 56 ++++++ crates/openshell-server/src/auth/oidc.rs | 22 ++- crates/openshell-server/src/delegation.rs | 163 ++++++++++++++++++ crates/openshell-server/src/grpc/sandbox.rs | 102 ++++++++++- crates/openshell-server/src/lib.rs | 1 + crates/openshell-server/src/multiplex.rs | 13 ++ .../openshell-server/src/provider_refresh.rs | 146 +++++++++++++++- docs/get-started/tutorials/index.mdx | 5 + docs/get-started/tutorials/okta-obo.mdx | 145 ++++++++++++++++ proto/openshell.proto | 13 ++ providers/okta-obo.yaml | 39 +++++ 14 files changed, 858 insertions(+), 15 deletions(-) create mode 100644 crates/openshell-server/src/delegation.rs create mode 100644 docs/get-started/tutorials/okta-obo.mdx create mode 100644 providers/okta-obo.yaml diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 76f1214d3..4fef70ec2 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -5004,6 +5004,7 @@ fn provider_refresh_strategy(strategy: &str) -> Result { Ok(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials) } + "oauth2_token_exchange" => Ok(ProviderCredentialRefreshStrategy::Oauth2TokenExchange), "google_service_account_jwt" => { Ok(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt) } @@ -5058,6 +5059,7 @@ fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) - ProviderCredentialRefreshStrategy::External => "external", ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", + ProviderCredentialRefreshStrategy::Oauth2TokenExchange => "oauth2_token_exchange", ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index ed78c6659..200a449de 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1098,6 +1098,89 @@ async fn provider_refresh_cli_run_functions_wire_requests() { ); } +#[tokio::test] +async fn provider_refresh_cli_supports_oauth2_token_exchange_strategy() { + let ts = run_server().await; + + ts.state.profiles.lock().await.insert( + "okta-obo".to_string(), + ProviderProfile { + id: "okta-obo".to_string(), + display_name: "Okta OBO".to_string(), + credentials: vec![ProviderProfileCredential { + name: "OKTA_OBO_ACCESS_TOKEN".to_string(), + required: true, + refresh: Some(ProviderCredentialRefresh { + strategy: ProviderCredentialRefreshStrategy::Oauth2TokenExchange as i32, + token_url: "https://example.okta.com/oauth2/default/v1/token".to_string(), + material: vec![ + openshell_core::proto::ProviderCredentialRefreshMaterial { + name: "client_id".to_string(), + required: true, + ..Default::default() + }, + openshell_core::proto::ProviderCredentialRefreshMaterial { + name: "sandbox_id".to_string(), + required: true, + ..Default::default() + }, + openshell_core::proto::ProviderCredentialRefreshMaterial { + name: "audience".to_string(), + required: true, + ..Default::default() + }, + ], + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }, + ); + + run::provider_create( + &ts.endpoint, + "okta-obo-runtime", + "okta-obo", + false, + &[], + &[], + &ts.tls, + ) + .await + .expect("provider create"); + + run::provider_refresh_config( + &ts.endpoint, + run::ProviderRefreshConfigInput { + name: "okta-obo-runtime", + credential_key: "OKTA_OBO_ACCESS_TOKEN", + strategy: "oauth2_token_exchange", + material: &[ + "client_id=client-id".to_string(), + "sandbox_id=sandbox-123".to_string(), + "audience=api://downstream".to_string(), + "scope=api:access:read".to_string(), + ], + secret_material_keys: &["client_secret".to_string()], + credential_expires_at_ms: None, + }, + &ts.tls, + ) + .await + .expect("provider refresh configure"); + + let requests = ts.state.refresh_requests.lock().await.clone(); + assert_eq!( + requests, + vec![ProviderRefreshRequestLog::Configure { + provider_name: "okta-obo-runtime".to_string(), + credential_key: "OKTA_OBO_ACCESS_TOKEN".to_string(), + expires_at_ms: None, + }] + ); +} + #[tokio::test] async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() { let ts = run_server().await; @@ -1708,6 +1791,51 @@ endpoints: .expect_err("valid profiles should not be partially imported after local parse errors"); } +#[tokio::test] +async fn built_in_okta_obo_profile_is_available_via_provider_profile_api() { + let ts = run_server().await; + + let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) + .await + .expect("grpc client should connect"); + let profile = client + .get_provider_profile(openshell_core::proto::GetProviderProfileRequest { + id: "okta-obo".to_string(), + }) + .await + .expect("get provider profile") + .into_inner() + .profile + .expect("profile should exist"); + + assert_eq!(profile.id, "okta-obo"); + let credential = profile + .credentials + .iter() + .find(|credential| credential.name == "obo_access_token") + .expect("obo access token credential"); + let refresh = credential + .refresh + .as_ref() + .expect("obo credential should include refresh config"); + assert_eq!( + refresh.strategy, + ProviderCredentialRefreshStrategy::Oauth2TokenExchange as i32 + ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "sandbox_id" && material.required) + ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "audience" && material.required) + ); +} + #[tokio::test] async fn provider_profile_lint_from_directory_reports_parse_errors_without_importing() { let ts = run_server().await; diff --git a/crates/openshell-core/src/metadata.rs b/crates/openshell-core/src/metadata.rs index af26f73ae..b315b58a4 100644 --- a/crates/openshell-core/src/metadata.rs +++ b/crates/openshell-core/src/metadata.rs @@ -6,8 +6,9 @@ //! These traits provide uniform access to `ObjectMeta` fields across all resource types. use crate::proto::{ - InferenceRoute, ObjectForTest, Provider, Sandbox, SandboxStatus, ServiceEndpoint, SshSession, - StoredProviderCredentialRefreshState, StoredProviderProfile, + InferenceRoute, ObjectForTest, Provider, Sandbox, ServiceEndpoint, SshSession, + StoredProviderCredentialRefreshState, StoredProviderProfile, StoredSandboxDelegationBinding, + SandboxStatus, }; use std::collections::HashMap; @@ -188,6 +189,39 @@ impl GetResourceVersion for StoredProviderCredentialRefreshState { } } +// Implementations for StoredSandboxDelegationBinding +impl ObjectId for StoredSandboxDelegationBinding { + fn object_id(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.id.as_str()) + } +} + +impl ObjectName for StoredSandboxDelegationBinding { + fn object_name(&self) -> &str { + self.metadata.as_ref().map_or("", |m| m.name.as_str()) + } +} + +impl ObjectLabels for StoredSandboxDelegationBinding { + fn object_labels(&self) -> Option> { + self.metadata.as_ref().map(|m| m.labels.clone()) + } +} + +impl SetResourceVersion for StoredSandboxDelegationBinding { + fn set_resource_version(&mut self, version: u64) { + if let Some(meta) = self.metadata.as_mut() { + meta.resource_version = version; + } + } +} + +impl GetResourceVersion for StoredSandboxDelegationBinding { + fn get_resource_version(&self) -> u64 { + self.metadata.as_ref().map_or(0, |m| m.resource_version) + } +} + // Implementations for SshSession impl ObjectId for SshSession { fn object_id(&self) -> &str { diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 316624287..437c432ee 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -21,6 +21,7 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../../../providers/github.yaml"), include_str!("../../../providers/google-vertex-ai.yaml"), include_str!("../../../providers/nvidia.yaml"), + include_str!("../../../providers/okta-obo.yaml"), ]; #[derive(Debug, thiserror::Error)] @@ -530,6 +531,7 @@ pub fn provider_refresh_strategy_from_yaml(raw: &str) -> Option { Some(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials) } + "oauth2_token_exchange" => Some(ProviderCredentialRefreshStrategy::Oauth2TokenExchange), "google_service_account_jwt" => { Some(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt) } @@ -546,6 +548,7 @@ pub fn provider_refresh_strategy_to_yaml( ProviderCredentialRefreshStrategy::External => "external", ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", + ProviderCredentialRefreshStrategy::Oauth2TokenExchange => "oauth2_token_exchange", ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } @@ -1172,6 +1175,59 @@ mod tests { assert_eq!(proto.binaries.len(), 4); } + #[test] + fn okta_obo_profile_exposes_token_exchange_shape() { + let profile = get_default_profile("okta-obo").expect("okta-obo profile"); + let credential = profile + .credentials + .iter() + .find(|credential| credential.name == "obo_access_token") + .expect("okta-obo access token credential"); + let refresh = credential + .refresh + .as_ref() + .expect("okta-obo credential should be refreshable"); + + assert_eq!( + refresh.strategy, + openshell_core::proto::ProviderCredentialRefreshStrategy::Oauth2TokenExchange + ); + assert_eq!( + refresh.token_url, + "https://example.okta.com/oauth2/default/v1/token" + ); + + let material_names = refresh + .material + .iter() + .map(|material| material.name.as_str()) + .collect::>(); + assert_eq!( + material_names, + vec![ + "client_id", + "sandbox_id", + "audience", + "client_secret", + "scope" + ] + ); + assert!( + refresh + .material + .iter() + .find(|material| material.name == "sandbox_id") + .is_some_and(|material| material.required) + ); + assert!( + refresh + .material + .iter() + .find(|material| material.name == "audience") + .is_some_and(|material| material.required) + ); + } + #[test] fn credential_env_vars_are_deduplicated_in_profile_order() { let profile = get_default_profile("claude-code").expect("claude-code profile"); diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs index bf5490f2a..42c92c6b0 100644 --- a/crates/openshell-server/src/auth/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -109,6 +109,22 @@ pub struct OidcClaims { const STANDARD_OIDC_SCOPES: &[&str] = &["openid", "profile", "email", "offline_access"]; +/// Raw OIDC bearer token captured from the inbound request. +/// +/// Stored in request extensions only after OIDC authentication succeeds so +/// later handlers can persist or exchange the user token without reparsing the +/// header. +#[derive(Debug, Clone)] +pub struct RawBearerToken(pub String); + +/// Extract a bearer token from an `Authorization` header. +pub fn extract_bearer_token(headers: &http::HeaderMap) -> Option<&str> { + headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) +} + impl OidcClaims { /// Extract roles from the JWT claims using a dot-separated path. /// @@ -372,11 +388,7 @@ impl Authenticator for OidcAuthenticator { headers: &http::HeaderMap, _path: &str, ) -> Result, Status> { - let Some(token) = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - else { + let Some(token) = extract_bearer_token(headers) else { return Ok(None); }; diff --git a/crates/openshell-server/src/delegation.rs b/crates/openshell-server/src/delegation.rs new file mode 100644 index 000000000..7bed4e877 --- /dev/null +++ b/crates/openshell-server/src/delegation.rs @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Sandbox delegation bindings for on-behalf-of token exchange. +//! +//! Lane 3 needs a stable server-side record of which signed-in user created a +//! sandbox and which inbound bearer token was available at that time. This +//! module owns that persisted binding so later broker code can exchange the +//! user token for a delegated downstream token without storing long-lived +//! user material inside the sandbox itself. + +use crate::persistence::{ObjectType, Store, current_time_ms}; +use openshell_core::proto::{Sandbox, StoredSandboxDelegationBinding}; +use openshell_core::{ObjectId, ObjectName}; +use tonic::Status; + +impl ObjectType for StoredSandboxDelegationBinding { + fn object_type() -> &'static str { + "sandbox_delegation_binding" + } +} + +pub fn binding_name(sandbox_id: &str) -> String { + format!("sandbox-delegation-{sandbox_id}") +} + +pub fn new_binding( + sandbox: &Sandbox, + subject: &str, + display_name: Option<&str>, + identity_provider: &str, + access_token: &str, + scopes: &[String], +) -> Result { + let sandbox_id = sandbox.object_id().trim(); + let sandbox_name = sandbox.object_name().trim(); + if sandbox_id.is_empty() { + return Err(Status::internal("sandbox is missing metadata.id")); + } + if sandbox_name.is_empty() { + return Err(Status::internal("sandbox is missing metadata.name")); + } + if subject.trim().is_empty() { + return Err(Status::invalid_argument("delegation subject is required")); + } + if access_token.trim().is_empty() { + return Err(Status::invalid_argument( + "delegation access token is required", + )); + } + + let now_ms = current_time_ms(); + Ok(StoredSandboxDelegationBinding { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: uuid::Uuid::new_v4().to_string(), + name: binding_name(sandbox_id), + created_at_ms: now_ms, + labels: std::collections::HashMap::new(), + resource_version: 0, + }), + sandbox_id: sandbox_id.to_string(), + sandbox_name: sandbox_name.to_string(), + subject: subject.trim().to_string(), + display_name: display_name.unwrap_or_default().trim().to_string(), + identity_provider: identity_provider.trim().to_string(), + access_token: access_token.trim().to_string(), + scopes: scopes.to_vec(), + captured_at_ms: now_ms, + }) +} + +pub async fn put_binding( + store: &Store, + binding: &StoredSandboxDelegationBinding, +) -> Result<(), Status> { + store + .put_scoped_message(binding, &binding.sandbox_id) + .await + .map_err(|e| Status::internal(format!("persist sandbox delegation binding failed: {e}"))) +} + +#[cfg_attr(not(test), allow(dead_code))] +pub async fn get_binding( + store: &Store, + sandbox_id: &str, +) -> Result, Status> { + store + .get_message_by_name::(&binding_name(sandbox_id)) + .await + .map_err(|e| Status::internal(format!("fetch sandbox delegation binding failed: {e}"))) +} + +pub async fn delete_binding(store: &Store, sandbox_id: &str) -> Result { + store + .delete_by_name( + StoredSandboxDelegationBinding::object_type(), + &binding_name(sandbox_id), + ) + .await + .map_err(|e| Status::internal(format!("delete sandbox delegation binding failed: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Store; + + fn sandbox() -> Sandbox { + Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-123".to_string(), + name: "demo-sandbox".to_string(), + created_at_ms: 0, + labels: std::collections::HashMap::new(), + resource_version: 0, + }), + spec: None, + status: None, + phase: 0, + current_policy_version: 0, + } + } + + #[tokio::test] + async fn binding_round_trip_works() { + let store = Store::connect("sqlite::memory:") + .await + .expect("in-memory store"); + let sandbox = sandbox(); + let binding = new_binding( + &sandbox, + "user-123", + Some("alex"), + "oidc", + "token-value", + &["sandbox:write".to_string()], + ) + .expect("binding"); + + put_binding(&store, &binding) + .await + .expect("persist binding"); + let loaded = get_binding(&store, "sb-123") + .await + .expect("load binding") + .expect("binding present"); + assert_eq!(loaded.subject, "user-123"); + assert_eq!(loaded.sandbox_name, "demo-sandbox"); + assert_eq!(loaded.identity_provider, "oidc"); + assert_eq!(loaded.access_token, "token-value"); + + let deleted = delete_binding(&store, "sb-123") + .await + .expect("delete binding"); + assert!(deleted); + assert!( + get_binding(&store, "sb-123") + .await + .expect("load binding") + .is_none() + ); + } +} diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 198d5f04c..28ebc2d3d 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -10,6 +10,9 @@ #![allow(clippy::cast_possible_wrap)] // Intentional u32->i32 conversions for proto compat use crate::ServerState; +use crate::auth::identity::IdentityProvider; +use crate::auth::oidc::RawBearerToken; +use crate::auth::principal::Principal; use crate::persistence::{ObjectType, WriteCondition, generate_name}; use futures::future; use openshell_core::proto::{ @@ -119,6 +122,8 @@ async fn handle_create_sandbox_inner( ) -> Result, Status> { use crate::persistence::current_time_ms; + let principal = request.extensions().get::().cloned(); + let raw_bearer_token = request.extensions().get::().cloned(); let request = request.into_inner(); let spec = request .spec @@ -212,7 +217,37 @@ async fn handle_create_sandbox_inner( None => None, }; - let sandbox = state.compute.create_sandbox(sandbox, sandbox_token).await?; + let delegation_binding = match (principal.as_ref(), raw_bearer_token.as_ref()) { + (Some(Principal::User(user)), Some(raw)) + if user.identity.provider == IdentityProvider::Oidc => + { + Some(crate::delegation::new_binding( + &sandbox, + &user.identity.subject, + user.identity.display_name.as_deref(), + "oidc", + &raw.0, + &user.identity.scopes, + )?) + } + _ => None, + }; + + if let Some(binding) = delegation_binding.as_ref() { + crate::delegation::put_binding(state.store.as_ref(), binding).await?; + } + + let sandbox = match state.compute.create_sandbox(sandbox, sandbox_token).await { + Ok(sandbox) => sandbox, + Err(err) => { + if let Some(binding) = delegation_binding.as_ref() { + let _ = + crate::delegation::delete_binding(state.store.as_ref(), &binding.sandbox_id) + .await; + } + return Err(err); + } + }; info!( sandbox_id = %id, @@ -498,12 +533,21 @@ async fn handle_delete_sandbox_inner( .store .get_message_by_name::(&name) .await - .ok() - .flatten() - .map(|sandbox| sandbox.object_id().to_string()); + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .and_then(|sandbox| sandbox.metadata.map(|meta| meta.id)); + let deleted = state.compute.delete_sandbox(&name).await?; - if deleted && let Some(sandbox_id) = sandbox_id { - state.telemetry.end_sandbox_session(&sandbox_id); + if deleted && let Some(sandbox_id) = sandbox_id.as_deref() { + state.telemetry.end_sandbox_session(sandbox_id); + let deleted_binding = crate::delegation::delete_binding(state.store.as_ref(), sandbox_id) + .await + .unwrap_or(false); + debug!( + sandbox_name = %name, + sandbox_id, + deleted_binding, + "deleted sandbox delegation binding" + ); } info!(sandbox_name = %name, "DeleteSandbox request completed successfully"); Ok(Response::new(DeleteSandboxResponse { deleted })) @@ -1938,6 +1982,9 @@ async fn run_exec_with_russh( #[cfg(test)] mod tests { use super::*; + use crate::auth::identity::{Identity, IdentityProvider}; + use crate::auth::oidc::RawBearerToken; + use crate::auth::principal::{Principal, UserPrincipal}; use crate::grpc::test_support::test_server_state; use openshell_core::proto::datamodel::v1::ObjectMeta; use std::collections::HashMap; @@ -2589,6 +2636,49 @@ mod tests { assert!(err.message().contains("provider-b")); } + #[tokio::test] + async fn create_sandbox_persists_delegation_binding_for_oidc_user() { + let state = test_server_state().await; + let mut request = Request::new(CreateSandboxRequest { + name: "delegated".to_string(), + spec: Some(openshell_core::proto::SandboxSpec::default()), + labels: HashMap::new(), + }); + request + .extensions_mut() + .insert(Principal::User(UserPrincipal { + identity: Identity { + subject: "user-123".to_string(), + display_name: Some("alex".to_string()), + roles: vec!["openshell-user".to_string()], + scopes: vec!["sandbox:write".to_string()], + provider: IdentityProvider::Oidc, + }, + })); + request + .extensions_mut() + .insert(RawBearerToken("raw-access-token".to_string())); + + let response = handle_create_sandbox(&state, request) + .await + .expect("sandbox create succeeds") + .into_inner(); + let sandbox = response.sandbox.expect("sandbox present"); + let binding = crate::delegation::get_binding( + state.store.as_ref(), + sandbox.metadata.as_ref().expect("metadata").id.as_str(), + ) + .await + .expect("load binding") + .expect("binding present"); + + assert_eq!(binding.subject, "user-123"); + assert_eq!(binding.display_name, "alex"); + assert_eq!(binding.identity_provider, "oidc"); + assert_eq!(binding.access_token, "raw-access-token"); + assert_eq!(binding.scopes, vec!["sandbox:write".to_string()]); + } + #[tokio::test] async fn attach_sandbox_provider_rejects_credential_key_collisions() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index c25ba1cfd..7ad38952e 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -25,6 +25,7 @@ pub mod cli; mod compute; pub mod config_file; mod defaults; +mod delegation; mod grpc; mod http; mod inference; diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index e94326f98..cfb3de0f4 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -467,6 +467,19 @@ where } } + let raw_oidc_bearer = if let Principal::User(ref user) = principal { + if user.identity.provider == crate::auth::identity::IdentityProvider::Oidc { + oidc::extract_bearer_token(req.headers()).map(str::to_owned) + } else { + None + } + } else { + None + }; + if let Some(token) = raw_oidc_bearer { + req.extensions_mut().insert(oidc::RawBearerToken(token)); + } + req.extensions_mut().insert(principal); inner.ready().await?.call(req).await }) diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index 161daeb7f..160c90e3e 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -278,6 +278,7 @@ pub fn refresh_strategy_name(strategy: i32) -> &'static str { ProviderCredentialRefreshStrategy::External => "external", ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", + ProviderCredentialRefreshStrategy::Oauth2TokenExchange => "oauth2_token_exchange", ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", ProviderCredentialRefreshStrategy::Unspecified => "unspecified", } @@ -288,6 +289,7 @@ pub fn is_gateway_mintable_strategy(strategy: ProviderCredentialRefreshStrategy) strategy, ProviderCredentialRefreshStrategy::Oauth2RefreshToken | ProviderCredentialRefreshStrategy::Oauth2ClientCredentials + | ProviderCredentialRefreshStrategy::Oauth2TokenExchange | ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt ) } @@ -317,7 +319,7 @@ pub async fn refresh_provider_credential( "provider credential refresh started" ); - match mint_credential(&state).await { + match mint_credential(store, &state).await { Ok(minted) => { let now_ms = current_time_ms(); if let Err(err) = @@ -435,6 +437,7 @@ async fn apply_minted_credential( } async fn mint_credential( + store: &Store, state: &StoredProviderCredentialRefreshState, ) -> Result { let strategy = ProviderCredentialRefreshStrategy::try_from(state.strategy) @@ -446,6 +449,9 @@ async fn mint_credential( ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => { mint_oauth2_client_credentials(state).await } + ProviderCredentialRefreshStrategy::Oauth2TokenExchange => { + mint_oauth2_token_exchange(store, state).await + } ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => { mint_google_service_account_jwt(state).await } @@ -479,6 +485,51 @@ async fn mint_oauth2_refresh_token( request_token(&token_url, &form, state.max_lifetime_seconds).await } +async fn mint_oauth2_token_exchange( + store: &Store, + state: &StoredProviderCredentialRefreshState, +) -> Result { + let token_url = oauth2_token_url(state)?; + let client_id = required_material(&state.material, "client_id")?; + let sandbox_id = required_material(&state.material, "sandbox_id")?; + let binding = crate::delegation::get_binding(store, &sandbox_id) + .await? + .ok_or_else(|| { + Status::failed_precondition(format!( + "sandbox delegation binding not found for sandbox_id '{sandbox_id}'" + )) + })?; + + let mut form = vec![ + ( + "grant_type".to_string(), + "urn:ietf:params:oauth:grant-type:token-exchange".to_string(), + ), + ("client_id".to_string(), client_id), + ("subject_token".to_string(), binding.access_token), + ( + "subject_token_type".to_string(), + "urn:ietf:params:oauth:token-type:access_token".to_string(), + ), + ( + "requested_token_type".to_string(), + "urn:ietf:params:oauth:token-type:access_token".to_string(), + ), + ]; + if let Some(client_secret) = material_value(&state.material, &["client_secret"]) { + form.push(("client_secret".to_string(), client_secret)); + } + if let Some(audience) = material_value(&state.material, &["audience", "resource"]) { + form.push(("audience".to_string(), audience)); + } + let scope = refresh_scopes(state).join(" "); + if !scope.is_empty() { + form.push(("scope".to_string(), scope)); + } + + request_token(&token_url, &form, state.max_lifetime_seconds).await +} + async fn mint_oauth2_client_credentials( state: &StoredProviderCredentialRefreshState, ) -> Result { @@ -648,7 +699,7 @@ fn oauth2_token_url(state: &StoredProviderCredentialRefreshState) -> Result("my-obo") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored_provider.credentials.get("OKTA_ACCESS_TOKEN"), + Some(&"delegated-downstream-token".to_string()) + ); + } + #[tokio::test] async fn google_service_account_refresh_mints_and_persists_access_token() { let mock_server = MockServer::start().await; diff --git a/docs/get-started/tutorials/index.mdx b/docs/get-started/tutorials/index.mdx index 0d82509ad..b6c032013 100644 --- a/docs/get-started/tutorials/index.mdx +++ b/docs/get-started/tutorials/index.mdx @@ -27,6 +27,11 @@ Launch Claude Code in a sandbox, diagnose a policy denial, and iterate on a cust Configure a Providers v2 Microsoft Graph provider with gateway-managed OAuth2 refresh-token rotation. + + +Configure delegated Okta access on behalf of the logged-in OpenShell user with token exchange. + + Route inference through Ollama using cloud-hosted or local models, and verify it from a sandbox. diff --git a/docs/get-started/tutorials/okta-obo.mdx b/docs/get-started/tutorials/okta-obo.mdx new file mode 100644 index 000000000..c7a8f7a59 --- /dev/null +++ b/docs/get-started/tutorials/okta-obo.mdx @@ -0,0 +1,145 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Delegate Okta Access On Behalf of the Logged-in User" +sidebar-title: "Okta OBO Token Exchange" +slug: "get-started/tutorials/okta-obo" +description: "Configure the built-in Okta OBO provider profile so OpenShell can exchange a logged-in user's token for a delegated downstream token." +keywords: "Generative AI, Cybersecurity, Tutorial, Providers, Okta, OBO, RFC 8693, Token Exchange, Delegation" +--- + +Use the built-in `okta-obo` profile when a sandboxed workload must call a downstream API on behalf of the human who logged into OpenShell. The gateway keeps the inbound user token server-side, binds it to sandbox creation, and exchanges it for a short-lived delegated token using Okta token exchange. + +After completing this tutorial, you have: + +- A token-exchange service app in Okta for delegated access. +- A customized `okta-obo` provider profile that points at your Okta tenant. +- A sandbox whose attached provider can mint `OKTA_OBO_ACCESS_TOKEN` from the logged-in user's identity. + + +This tutorial covers the delegation lane. It assumes you already completed the gateway login lane and can log into the gateway with Okta before you create the sandbox. + + +## Prerequisites + +- A working OpenShell installation with an active gateway. +- Okta CLI login already working against your OpenShell gateway. +- An Okta custom authorization server. +- An Okta service app with the `Token Exchange` grant enabled. +- A downstream audience and scope that the delegated token should target. + +For the example commands below, set: + +| Variable | Value | +|---|---| +| `OKTA_OBO_CLIENT_ID` | Token-exchange service app client ID. | +| `OKTA_OBO_CLIENT_SECRET` | Token-exchange service app client secret. | +| `OKTA_OBO_AUDIENCE` | Downstream audience, such as `api://default`. | +| `OKTA_OBO_SCOPE` | Delegated scope, such as `api:access:read`. | + + +Treat the token-exchange service app secret like any other production credential. Do not commit it, paste it into source control, or leave it in shell history longer than needed. + + + + +## Create the Downstream Scope and Rule in Okta + +In your Okta custom authorization server: + +- create the downstream scope that the delegated token should carry +- create an access-policy rule for the service app with the `Token Exchange` grant enabled + +For a smoke test, a broad rule with `Any scopes` is fine. + +## Create a Tenant-Specific OBO Profile + +Export the built-in profile and update the token endpoint: + +```shell +openshell provider profile export okta-obo -o yaml > okta-obo.yaml +``` + +Replace: + +- `https://example.okta.com/oauth2/default/v1/token` + +with your real Okta token endpoint, then import the customized profile: + +```shell +openshell provider profile lint -f okta-obo.yaml +openshell provider profile import -f okta-obo.yaml +``` + +## Log In and Create the Sandbox + +Log into the gateway as the human user whose identity should be delegated: + +```shell +openshell gateway login +``` + +Create the sandbox after login so OpenShell can bind the sandbox to the authenticated user token: + +```shell +openshell sandbox create --name okta-obo-smoke +``` + +Fetch the sandbox metadata and record the sandbox ID: + +```shell +openshell sandbox get okta-obo-smoke +``` + +## Create the OBO Provider + +Create the provider from the imported profile: + +```shell +openshell provider create \ + --name okta-obo-runtime \ + --type okta-obo +``` + +## Configure Token Exchange + +Configure the delegated credential refresh: + +```shell +openshell provider refresh configure okta-obo-runtime \ + --credential-key OKTA_OBO_ACCESS_TOKEN \ + --strategy oauth2-token-exchange \ + --material client_id="$OKTA_OBO_CLIENT_ID" \ + --material client_secret="$OKTA_OBO_CLIENT_SECRET" \ + --material sandbox_id="" \ + --material audience="$OKTA_OBO_AUDIENCE" \ + --material scope="$OKTA_OBO_SCOPE" \ + --secret-material-key client_secret +``` + +Check refresh status: + +```shell +openshell provider refresh status okta-obo-runtime \ + --credential-key OKTA_OBO_ACCESS_TOKEN +``` + +## Attach the Provider + +Attach the OBO provider to the sandbox: + +```shell +openshell sandbox provider attach okta-obo-smoke okta-obo-runtime +``` + +## Verify the Delegated Token Path + +Exec into the sandbox and confirm the delegated credential placeholder exists: + +```shell +openshell sandbox exec --name okta-obo-smoke -- /bin/sh -lc 'printenv OKTA_OBO_ACCESS_TOKEN' +``` + +The value should be an OpenShell-managed placeholder rather than a raw token. OpenShell resolves it to the current delegated token when the workload uses the supported authorization path. + + diff --git a/proto/openshell.proto b/proto/openshell.proto index f9b64618b..55165b55b 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -904,6 +904,7 @@ enum ProviderCredentialRefreshStrategy { PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN = 3; PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS = 4; PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT = 5; + PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_TOKEN_EXCHANGE = 6; } message ProviderCredentialRefreshMaterial { @@ -959,6 +960,18 @@ message StoredProviderCredentialRefreshState { int64 max_lifetime_seconds = 16; } +message StoredSandboxDelegationBinding { + openshell.datamodel.v1.ObjectMeta metadata = 1; + string sandbox_id = 2; + string sandbox_name = 3; + string subject = 4; + string display_name = 5; + string identity_provider = 6; + string access_token = 7; + repeated string scopes = 8; + int64 captured_at_ms = 9; +} + message GetProviderRefreshStatusRequest { string provider = 1; string credential_key = 2; diff --git a/providers/okta-obo.yaml b/providers/okta-obo.yaml new file mode 100644 index 000000000..56f38951a --- /dev/null +++ b/providers/okta-obo.yaml @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: okta-obo +display_name: Okta OBO +description: Okta delegated access tokens minted on behalf of the authenticated OpenShell user +category: other +credentials: + - name: obo_access_token + description: Okta delegated access token for downstream APIs + env_vars: [OKTA_OBO_ACCESS_TOKEN] + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: oauth2_token_exchange + token_url: https://example.okta.com/oauth2/default/v1/token + refresh_before_seconds: 300 + max_lifetime_seconds: 3600 + material: + - name: client_id + description: Okta OIDC application client ID used for token exchange + required: true + - name: sandbox_id + description: OpenShell sandbox ID bound to the authenticated user token + required: true + - name: audience + description: Downstream Okta resource audience for the delegated token + required: true + - name: client_secret + description: Okta client secret for confidential token-exchange clients + required: false + secret: true + - name: scope + description: Space-delimited scopes requested for the delegated token + required: false +binaries: + - /usr/bin/curl + - /usr/local/bin/curl From 89e69c0df39434261f4995b26a72b8252ce5ecb1 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 28 May 2026 16:57:49 -0700 Subject: [PATCH 2/5] feat(auth): support live okta obo token exchange --- crates/openshell-cli/src/main.rs | 2 + crates/openshell-server/src/delegation.rs | 1 + crates/openshell-server/src/grpc/provider.rs | 29 ++++++++++++ .../openshell-server/src/provider_refresh.rs | 46 +++++++++++-------- 4 files changed, 59 insertions(+), 19 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 22af412b5..bd310c115 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -660,6 +660,7 @@ enum OutputFormat { enum CliProviderRefreshStrategy { Oauth2RefreshToken, Oauth2ClientCredentials, + Oauth2TokenExchange, GoogleServiceAccountJwt, } @@ -668,6 +669,7 @@ impl CliProviderRefreshStrategy { match self { Self::Oauth2RefreshToken => "oauth2_refresh_token", Self::Oauth2ClientCredentials => "oauth2_client_credentials", + Self::Oauth2TokenExchange => "oauth2_token_exchange", Self::GoogleServiceAccountJwt => "google_service_account_jwt", } } diff --git a/crates/openshell-server/src/delegation.rs b/crates/openshell-server/src/delegation.rs index 7bed4e877..eba87c783 100644 --- a/crates/openshell-server/src/delegation.rs +++ b/crates/openshell-server/src/delegation.rs @@ -24,6 +24,7 @@ pub fn binding_name(sandbox_id: &str) -> String { format!("sandbox-delegation-{sandbox_id}") } +#[allow(clippy::result_large_err)] pub fn new_binding( sandbox: &Sandbox, subject: &str, diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e6f0c2780..e868c65b7 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -8,6 +8,9 @@ use crate::persistence::{ ObjectId, ObjectLabels, ObjectName, ObjectType, Store, WriteCondition, generate_name, }; +use crate::auth::identity::IdentityProvider; +use crate::auth::oidc::RawBearerToken; +use crate::auth::principal::Principal; use openshell_core::proto::{Provider, Sandbox}; use openshell_core::telemetry::{ LifecycleOperation, ProviderProfile as TelemetryProviderProfile, TelemetryOutcome, @@ -1238,6 +1241,8 @@ pub(super) async fn handle_configure_provider_refresh( state: &Arc, request: Request, ) -> Result, Status> { + let principal = request.extensions().get::().cloned(); + let raw_bearer_token = request.extensions().get::().cloned(); let request = request.into_inner(); let provider_name = request.provider.trim(); let credential_key = request.credential_key.trim(); @@ -1379,6 +1384,7 @@ pub(super) async fn handle_configure_provider_refresh( credential_key, ) .await?; + let sandbox_id_for_binding = request.material.get("sandbox_id").cloned(); let expires_at_ms = request.expires_at_ms.unwrap_or_else(|| { existing_refresh_state .as_ref() @@ -1405,6 +1411,29 @@ pub(super) async fn handle_configure_provider_refresh( } crate::provider_refresh::put_refresh_state(state.store.as_ref(), &state_record).await?; + if strategy == ProviderCredentialRefreshStrategy::Oauth2TokenExchange + && let (Some(Principal::User(user)), Some(raw)) = + (principal.as_ref(), raw_bearer_token.as_ref()) + && user.identity.provider == IdentityProvider::Oidc + && let Some(sandbox_id) = sandbox_id_for_binding.as_deref().map(str::trim) + && !sandbox_id.is_empty() + && let Some(sandbox) = state + .store + .get_message::(sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + { + let binding = crate::delegation::new_binding( + &sandbox, + &user.identity.subject, + user.identity.display_name.as_deref(), + "oidc", + &raw.0, + &user.identity.scopes, + )?; + crate::delegation::put_binding(state.store.as_ref(), &binding).await?; + } + if let Some(expires_at_ms) = request.expires_at_ms { let updated = Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index 160c90e3e..4e72dec69 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -471,18 +471,19 @@ async fn mint_oauth2_refresh_token( let refresh_token = required_material(&state.material, "refresh_token")?; let mut form = vec![ ("grant_type".to_string(), "refresh_token".to_string()), - ("client_id".to_string(), client_id), ("refresh_token".to_string(), refresh_token), ]; - if let Some(client_secret) = material_value(&state.material, &["client_secret"]) { - form.push(("client_secret".to_string(), client_secret)); + let basic_auth = material_value(&state.material, &["client_secret"]) + .map(|client_secret| (client_id.clone(), client_secret)); + if basic_auth.is_none() { + form.push(("client_id".to_string(), client_id)); } let scope = refresh_scopes(state).join(" "); if !scope.is_empty() { form.push(("scope".to_string(), scope)); } - request_token(&token_url, &form, state.max_lifetime_seconds).await + request_token(&token_url, &form, basic_auth, state.max_lifetime_seconds).await } async fn mint_oauth2_token_exchange( @@ -505,7 +506,6 @@ async fn mint_oauth2_token_exchange( "grant_type".to_string(), "urn:ietf:params:oauth:grant-type:token-exchange".to_string(), ), - ("client_id".to_string(), client_id), ("subject_token".to_string(), binding.access_token), ( "subject_token_type".to_string(), @@ -516,8 +516,10 @@ async fn mint_oauth2_token_exchange( "urn:ietf:params:oauth:token-type:access_token".to_string(), ), ]; - if let Some(client_secret) = material_value(&state.material, &["client_secret"]) { - form.push(("client_secret".to_string(), client_secret)); + let basic_auth = material_value(&state.material, &["client_secret"]) + .map(|client_secret| (client_id.clone(), client_secret)); + if basic_auth.is_none() { + form.push(("client_id".to_string(), client_id)); } if let Some(audience) = material_value(&state.material, &["audience", "resource"]) { form.push(("audience".to_string(), audience)); @@ -527,7 +529,7 @@ async fn mint_oauth2_token_exchange( form.push(("scope".to_string(), scope)); } - request_token(&token_url, &form, state.max_lifetime_seconds).await + request_token(&token_url, &form, basic_auth, state.max_lifetime_seconds).await } async fn mint_oauth2_client_credentials( @@ -536,17 +538,14 @@ async fn mint_oauth2_client_credentials( let token_url = oauth2_token_url(state)?; let client_id = required_material(&state.material, "client_id")?; let client_secret = required_material(&state.material, "client_secret")?; - let mut form = vec![ - ("grant_type".to_string(), "client_credentials".to_string()), - ("client_id".to_string(), client_id), - ("client_secret".to_string(), client_secret), - ]; + let mut form = vec![("grant_type".to_string(), "client_credentials".to_string())]; + let basic_auth = Some((client_id, client_secret)); let scope = refresh_scopes(state).join(" "); if !scope.is_empty() { form.push(("scope".to_string(), scope)); } - request_token(&token_url, &form, state.max_lifetime_seconds).await + request_token(&token_url, &form, basic_auth, state.max_lifetime_seconds).await } async fn mint_google_service_account_jwt( @@ -592,12 +591,13 @@ async fn mint_google_service_account_jwt( ), ("assertion".to_string(), assertion), ]; - request_token(&token_url, &form, lifetime_secs).await + request_token(&token_url, &form, None, lifetime_secs).await } async fn request_token( token_url: &str, form: &[(String, String)], + basic_auth: Option<(String, String)>, max_lifetime_seconds: i64, ) -> Result { let parsed = reqwest::Url::parse(token_url) @@ -616,16 +616,24 @@ async fn request_token( .timeout(Duration::from_secs(30)) .build() .map_err(|e| Status::internal(format!("build refresh HTTP client failed: {e}")))?; - let response = client - .post(parsed) - .form(form) + let request = client.post(parsed).form(form); + let request = if let Some((client_id, client_secret)) = basic_auth { + request.basic_auth(client_id, Some(client_secret)) + } else { + request + }; + let response = request .send() .await .map_err(|e| Status::unavailable(format!("token endpoint request failed: {e}")))?; let status = response.status(); if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|_| "".to_string()); return Err(Status::failed_precondition(format!( - "token endpoint returned HTTP {status}" + "token endpoint returned HTTP {status}: {body}" ))); } let token = response From 796699e7a64920a45fc1a36212208ea9c640b24d Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Mon, 1 Jun 2026 20:42:43 -0700 Subject: [PATCH 3/5] fix(auth): align obo branch with current provider refresh behavior --- crates/openshell-server/src/grpc/provider.rs | 14 ++++++++++---- crates/openshell-server/src/provider_refresh.rs | 2 -- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e868c65b7..35b2d9c4d 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -5,12 +5,12 @@ #![allow(clippy::result_large_err)] // gRPC handlers return Result, Status> -use crate::persistence::{ - ObjectId, ObjectLabels, ObjectName, ObjectType, Store, WriteCondition, generate_name, -}; use crate::auth::identity::IdentityProvider; use crate::auth::oidc::RawBearerToken; use crate::auth::principal::Principal; +use crate::persistence::{ + ObjectId, ObjectLabels, ObjectName, ObjectType, Store, WriteCondition, generate_name, +}; use openshell_core::proto::{Provider, Sandbox}; use openshell_core::telemetry::{ LifecycleOperation, ProviderProfile as TelemetryProviderProfile, TelemetryOutcome, @@ -1847,7 +1847,13 @@ mod tests { .collect::>(); assert_eq!( ids, - vec!["claude-code", "github", "google-vertex-ai", "nvidia",] + vec![ + "claude-code", + "github", + "google-vertex-ai", + "nvidia", + "okta-obo", + ] ); let github = response diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index 4e72dec69..9d199c674 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -900,7 +900,6 @@ mod tests { Mock::given(method("POST")) .and(path("/token")) .and(body_string_contains("grant_type=client_credentials")) - .and(body_string_contains("client_id=client-id")) .and(body_string_contains( "scope=https%3A%2F%2Fgraph.microsoft.com%2F.default", )) @@ -1134,7 +1133,6 @@ mod tests { .and(body_string_contains( "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange", )) - .and(body_string_contains("client_id=client-id")) .and(body_string_contains("subject_token=user-access-token")) .and(body_string_contains( "subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token", From 3bb5bcf09d59ed3c69aadcc887fdecd2d92092d5 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 2 Jun 2026 15:58:54 -0700 Subject: [PATCH 4/5] fix(auth): align okta obo with provider refresh model --- .../tests/provider_commands_integration.rs | 8 +- crates/openshell-core/src/metadata.rs | 35 +--- crates/openshell-providers/src/profiles.rs | 6 +- crates/openshell-server/src/delegation.rs | 164 ------------------ crates/openshell-server/src/grpc/provider.rs | 70 +++++--- crates/openshell-server/src/grpc/sandbox.rs | 103 +---------- crates/openshell-server/src/lib.rs | 1 - .../openshell-server/src/provider_refresh.rs | 47 ++--- docs/get-started/tutorials/okta-obo.mdx | 27 ++- proto/openshell.proto | 12 -- providers/okta-obo.yaml | 7 +- 11 files changed, 80 insertions(+), 400 deletions(-) delete mode 100644 crates/openshell-server/src/delegation.rs diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 200a449de..5a5031d23 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1826,7 +1826,7 @@ async fn built_in_okta_obo_profile_is_available_via_provider_profile_api() { refresh .material .iter() - .any(|material| material.name == "sandbox_id" && material.required) + .any(|material| material.name == "client_id" && material.required) ); assert!( refresh @@ -1834,6 +1834,12 @@ async fn built_in_okta_obo_profile_is_available_via_provider_profile_api() { .iter() .any(|material| material.name == "audience" && material.required) ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "subject_token" && !material.required) + ); } #[tokio::test] diff --git a/crates/openshell-core/src/metadata.rs b/crates/openshell-core/src/metadata.rs index b315b58a4..e86bc17e2 100644 --- a/crates/openshell-core/src/metadata.rs +++ b/crates/openshell-core/src/metadata.rs @@ -7,7 +7,7 @@ use crate::proto::{ InferenceRoute, ObjectForTest, Provider, Sandbox, ServiceEndpoint, SshSession, - StoredProviderCredentialRefreshState, StoredProviderProfile, StoredSandboxDelegationBinding, + StoredProviderCredentialRefreshState, StoredProviderProfile, SandboxStatus, }; use std::collections::HashMap; @@ -189,39 +189,6 @@ impl GetResourceVersion for StoredProviderCredentialRefreshState { } } -// Implementations for StoredSandboxDelegationBinding -impl ObjectId for StoredSandboxDelegationBinding { - fn object_id(&self) -> &str { - self.metadata.as_ref().map_or("", |m| m.id.as_str()) - } -} - -impl ObjectName for StoredSandboxDelegationBinding { - fn object_name(&self) -> &str { - self.metadata.as_ref().map_or("", |m| m.name.as_str()) - } -} - -impl ObjectLabels for StoredSandboxDelegationBinding { - fn object_labels(&self) -> Option> { - self.metadata.as_ref().map(|m| m.labels.clone()) - } -} - -impl SetResourceVersion for StoredSandboxDelegationBinding { - fn set_resource_version(&mut self, version: u64) { - if let Some(meta) = self.metadata.as_mut() { - meta.resource_version = version; - } - } -} - -impl GetResourceVersion for StoredSandboxDelegationBinding { - fn get_resource_version(&self) -> u64 { - self.metadata.as_ref().map_or(0, |m| m.resource_version) - } -} - // Implementations for SshSession impl ObjectId for SshSession { fn object_id(&self) -> &str { diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 437c432ee..888042c69 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -1206,9 +1206,9 @@ mod tests { material_names, vec![ "client_id", - "sandbox_id", "audience", "client_secret", + "subject_token", "scope" ] ); @@ -1216,8 +1216,8 @@ mod tests { refresh .material .iter() - .find(|material| material.name == "sandbox_id") - .is_some_and(|material| material.required) + .find(|material| material.name == "subject_token") + .is_some_and(|material| !material.required && material.secret) ); assert!( refresh diff --git a/crates/openshell-server/src/delegation.rs b/crates/openshell-server/src/delegation.rs deleted file mode 100644 index eba87c783..000000000 --- a/crates/openshell-server/src/delegation.rs +++ /dev/null @@ -1,164 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Sandbox delegation bindings for on-behalf-of token exchange. -//! -//! Lane 3 needs a stable server-side record of which signed-in user created a -//! sandbox and which inbound bearer token was available at that time. This -//! module owns that persisted binding so later broker code can exchange the -//! user token for a delegated downstream token without storing long-lived -//! user material inside the sandbox itself. - -use crate::persistence::{ObjectType, Store, current_time_ms}; -use openshell_core::proto::{Sandbox, StoredSandboxDelegationBinding}; -use openshell_core::{ObjectId, ObjectName}; -use tonic::Status; - -impl ObjectType for StoredSandboxDelegationBinding { - fn object_type() -> &'static str { - "sandbox_delegation_binding" - } -} - -pub fn binding_name(sandbox_id: &str) -> String { - format!("sandbox-delegation-{sandbox_id}") -} - -#[allow(clippy::result_large_err)] -pub fn new_binding( - sandbox: &Sandbox, - subject: &str, - display_name: Option<&str>, - identity_provider: &str, - access_token: &str, - scopes: &[String], -) -> Result { - let sandbox_id = sandbox.object_id().trim(); - let sandbox_name = sandbox.object_name().trim(); - if sandbox_id.is_empty() { - return Err(Status::internal("sandbox is missing metadata.id")); - } - if sandbox_name.is_empty() { - return Err(Status::internal("sandbox is missing metadata.name")); - } - if subject.trim().is_empty() { - return Err(Status::invalid_argument("delegation subject is required")); - } - if access_token.trim().is_empty() { - return Err(Status::invalid_argument( - "delegation access token is required", - )); - } - - let now_ms = current_time_ms(); - Ok(StoredSandboxDelegationBinding { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: uuid::Uuid::new_v4().to_string(), - name: binding_name(sandbox_id), - created_at_ms: now_ms, - labels: std::collections::HashMap::new(), - resource_version: 0, - }), - sandbox_id: sandbox_id.to_string(), - sandbox_name: sandbox_name.to_string(), - subject: subject.trim().to_string(), - display_name: display_name.unwrap_or_default().trim().to_string(), - identity_provider: identity_provider.trim().to_string(), - access_token: access_token.trim().to_string(), - scopes: scopes.to_vec(), - captured_at_ms: now_ms, - }) -} - -pub async fn put_binding( - store: &Store, - binding: &StoredSandboxDelegationBinding, -) -> Result<(), Status> { - store - .put_scoped_message(binding, &binding.sandbox_id) - .await - .map_err(|e| Status::internal(format!("persist sandbox delegation binding failed: {e}"))) -} - -#[cfg_attr(not(test), allow(dead_code))] -pub async fn get_binding( - store: &Store, - sandbox_id: &str, -) -> Result, Status> { - store - .get_message_by_name::(&binding_name(sandbox_id)) - .await - .map_err(|e| Status::internal(format!("fetch sandbox delegation binding failed: {e}"))) -} - -pub async fn delete_binding(store: &Store, sandbox_id: &str) -> Result { - store - .delete_by_name( - StoredSandboxDelegationBinding::object_type(), - &binding_name(sandbox_id), - ) - .await - .map_err(|e| Status::internal(format!("delete sandbox delegation binding failed: {e}"))) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Store; - - fn sandbox() -> Sandbox { - Sandbox { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: "sb-123".to_string(), - name: "demo-sandbox".to_string(), - created_at_ms: 0, - labels: std::collections::HashMap::new(), - resource_version: 0, - }), - spec: None, - status: None, - phase: 0, - current_policy_version: 0, - } - } - - #[tokio::test] - async fn binding_round_trip_works() { - let store = Store::connect("sqlite::memory:") - .await - .expect("in-memory store"); - let sandbox = sandbox(); - let binding = new_binding( - &sandbox, - "user-123", - Some("alex"), - "oidc", - "token-value", - &["sandbox:write".to_string()], - ) - .expect("binding"); - - put_binding(&store, &binding) - .await - .expect("persist binding"); - let loaded = get_binding(&store, "sb-123") - .await - .expect("load binding") - .expect("binding present"); - assert_eq!(loaded.subject, "user-123"); - assert_eq!(loaded.sandbox_name, "demo-sandbox"); - assert_eq!(loaded.identity_provider, "oidc"); - assert_eq!(loaded.access_token, "token-value"); - - let deleted = delete_binding(&store, "sb-123") - .await - .expect("delete binding"); - assert!(deleted); - assert!( - get_binding(&store, "sb-123") - .await - .expect("load binding") - .is_none() - ); - } -} diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 35b2d9c4d..1f7b62495 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1384,7 +1384,48 @@ pub(super) async fn handle_configure_provider_refresh( credential_key, ) .await?; - let sandbox_id_for_binding = request.material.get("sandbox_id").cloned(); + let mut material = request.material; + let mut secret_material_keys = request.secret_material_keys; + if strategy == ProviderCredentialRefreshStrategy::Oauth2TokenExchange { + match (principal.as_ref(), raw_bearer_token.as_ref()) { + (Some(Principal::User(user)), Some(raw)) + if user.identity.provider == IdentityProvider::Oidc => + { + material.insert("subject_token".to_string(), raw.0.clone()); + if !secret_material_keys + .iter() + .any(|key| key == "subject_token") + { + secret_material_keys.push("subject_token".to_string()); + } + } + _ => { + if let Some(existing) = existing_refresh_state + .as_ref() + .and_then(|state| state.material.get("subject_token")) + { + material + .entry("subject_token".to_string()) + .or_insert_with(|| existing.clone()); + } else { + return Err(Status::failed_precondition( + "oauth2_token_exchange refresh requires an authenticated OIDC user bearer token during configuration", + )); + } + if existing_refresh_state.as_ref().is_some_and(|state| { + state + .secret_material_keys + .iter() + .any(|key| key == "subject_token") + }) && !secret_material_keys + .iter() + .any(|key| key == "subject_token") + { + secret_material_keys.push("subject_token".to_string()); + } + } + } + } let expires_at_ms = request.expires_at_ms.unwrap_or_else(|| { existing_refresh_state .as_ref() @@ -1396,8 +1437,8 @@ pub(super) async fn handle_configure_provider_refresh( credential_key, crate::provider_refresh::NewRefreshStateConfig { strategy, - material: request.material, - secret_material_keys: request.secret_material_keys, + material, + secret_material_keys, expires_at_ms, token_url, scopes, @@ -1411,29 +1452,6 @@ pub(super) async fn handle_configure_provider_refresh( } crate::provider_refresh::put_refresh_state(state.store.as_ref(), &state_record).await?; - if strategy == ProviderCredentialRefreshStrategy::Oauth2TokenExchange - && let (Some(Principal::User(user)), Some(raw)) = - (principal.as_ref(), raw_bearer_token.as_ref()) - && user.identity.provider == IdentityProvider::Oidc - && let Some(sandbox_id) = sandbox_id_for_binding.as_deref().map(str::trim) - && !sandbox_id.is_empty() - && let Some(sandbox) = state - .store - .get_message::(sandbox_id) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - { - let binding = crate::delegation::new_binding( - &sandbox, - &user.identity.subject, - user.identity.display_name.as_deref(), - "oidc", - &raw.0, - &user.identity.scopes, - )?; - crate::delegation::put_binding(state.store.as_ref(), &binding).await?; - } - if let Some(expires_at_ms) = request.expires_at_ms { let updated = Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 28ebc2d3d..06f67fb4c 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -10,9 +10,6 @@ #![allow(clippy::cast_possible_wrap)] // Intentional u32->i32 conversions for proto compat use crate::ServerState; -use crate::auth::identity::IdentityProvider; -use crate::auth::oidc::RawBearerToken; -use crate::auth::principal::Principal; use crate::persistence::{ObjectType, WriteCondition, generate_name}; use futures::future; use openshell_core::proto::{ @@ -122,8 +119,6 @@ async fn handle_create_sandbox_inner( ) -> Result, Status> { use crate::persistence::current_time_ms; - let principal = request.extensions().get::().cloned(); - let raw_bearer_token = request.extensions().get::().cloned(); let request = request.into_inner(); let spec = request .spec @@ -216,38 +211,7 @@ async fn handle_create_sandbox_inner( Some(Err(status)) => return Err(status), None => None, }; - - let delegation_binding = match (principal.as_ref(), raw_bearer_token.as_ref()) { - (Some(Principal::User(user)), Some(raw)) - if user.identity.provider == IdentityProvider::Oidc => - { - Some(crate::delegation::new_binding( - &sandbox, - &user.identity.subject, - user.identity.display_name.as_deref(), - "oidc", - &raw.0, - &user.identity.scopes, - )?) - } - _ => None, - }; - - if let Some(binding) = delegation_binding.as_ref() { - crate::delegation::put_binding(state.store.as_ref(), binding).await?; - } - - let sandbox = match state.compute.create_sandbox(sandbox, sandbox_token).await { - Ok(sandbox) => sandbox, - Err(err) => { - if let Some(binding) = delegation_binding.as_ref() { - let _ = - crate::delegation::delete_binding(state.store.as_ref(), &binding.sandbox_id) - .await; - } - return Err(err); - } - }; + let sandbox = state.compute.create_sandbox(sandbox, sandbox_token).await?; info!( sandbox_id = %id, @@ -529,26 +493,7 @@ async fn handle_delete_sandbox_inner( return Err(Status::invalid_argument("name is required")); } - let sandbox_id = state - .store - .get_message_by_name::(&name) - .await - .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .and_then(|sandbox| sandbox.metadata.map(|meta| meta.id)); - let deleted = state.compute.delete_sandbox(&name).await?; - if deleted && let Some(sandbox_id) = sandbox_id.as_deref() { - state.telemetry.end_sandbox_session(sandbox_id); - let deleted_binding = crate::delegation::delete_binding(state.store.as_ref(), sandbox_id) - .await - .unwrap_or(false); - debug!( - sandbox_name = %name, - sandbox_id, - deleted_binding, - "deleted sandbox delegation binding" - ); - } info!(sandbox_name = %name, "DeleteSandbox request completed successfully"); Ok(Response::new(DeleteSandboxResponse { deleted })) } @@ -1982,9 +1927,6 @@ async fn run_exec_with_russh( #[cfg(test)] mod tests { use super::*; - use crate::auth::identity::{Identity, IdentityProvider}; - use crate::auth::oidc::RawBearerToken; - use crate::auth::principal::{Principal, UserPrincipal}; use crate::grpc::test_support::test_server_state; use openshell_core::proto::datamodel::v1::ObjectMeta; use std::collections::HashMap; @@ -2636,49 +2578,6 @@ mod tests { assert!(err.message().contains("provider-b")); } - #[tokio::test] - async fn create_sandbox_persists_delegation_binding_for_oidc_user() { - let state = test_server_state().await; - let mut request = Request::new(CreateSandboxRequest { - name: "delegated".to_string(), - spec: Some(openshell_core::proto::SandboxSpec::default()), - labels: HashMap::new(), - }); - request - .extensions_mut() - .insert(Principal::User(UserPrincipal { - identity: Identity { - subject: "user-123".to_string(), - display_name: Some("alex".to_string()), - roles: vec!["openshell-user".to_string()], - scopes: vec!["sandbox:write".to_string()], - provider: IdentityProvider::Oidc, - }, - })); - request - .extensions_mut() - .insert(RawBearerToken("raw-access-token".to_string())); - - let response = handle_create_sandbox(&state, request) - .await - .expect("sandbox create succeeds") - .into_inner(); - let sandbox = response.sandbox.expect("sandbox present"); - let binding = crate::delegation::get_binding( - state.store.as_ref(), - sandbox.metadata.as_ref().expect("metadata").id.as_str(), - ) - .await - .expect("load binding") - .expect("binding present"); - - assert_eq!(binding.subject, "user-123"); - assert_eq!(binding.display_name, "alex"); - assert_eq!(binding.identity_provider, "oidc"); - assert_eq!(binding.access_token, "raw-access-token"); - assert_eq!(binding.scopes, vec!["sandbox:write".to_string()]); - } - #[tokio::test] async fn attach_sandbox_provider_rejects_credential_key_collisions() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 7ad38952e..c25ba1cfd 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -25,7 +25,6 @@ pub mod cli; mod compute; pub mod config_file; mod defaults; -mod delegation; mod grpc; mod http; mod inference; diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index 9d199c674..195acd9ae 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -450,7 +450,8 @@ async fn mint_credential( mint_oauth2_client_credentials(state).await } ProviderCredentialRefreshStrategy::Oauth2TokenExchange => { - mint_oauth2_token_exchange(store, state).await + let _ = store; + mint_oauth2_token_exchange(state).await } ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => { mint_google_service_account_jwt(state).await @@ -487,26 +488,18 @@ async fn mint_oauth2_refresh_token( } async fn mint_oauth2_token_exchange( - store: &Store, state: &StoredProviderCredentialRefreshState, ) -> Result { let token_url = oauth2_token_url(state)?; let client_id = required_material(&state.material, "client_id")?; - let sandbox_id = required_material(&state.material, "sandbox_id")?; - let binding = crate::delegation::get_binding(store, &sandbox_id) - .await? - .ok_or_else(|| { - Status::failed_precondition(format!( - "sandbox delegation binding not found for sandbox_id '{sandbox_id}'" - )) - })?; + let subject_token = required_material(&state.material, "subject_token")?; let mut form = vec![ ( "grant_type".to_string(), "urn:ietf:params:oauth:grant-type:token-exchange".to_string(), ), - ("subject_token".to_string(), binding.access_token), + ("subject_token".to_string(), subject_token), ( "subject_token_type".to_string(), "urn:ietf:params:oauth:token-type:access_token".to_string(), @@ -835,7 +828,6 @@ mod tests { refresh_provider_credential, refresh_state_name, refresh_strategy_name, run_refresh_worker_tick, seconds_until_ms, }; - use crate::delegation::{new_binding, put_binding}; use crate::persistence::Store; use openshell_core::ObjectId; use openshell_core::proto::datamodel::v1::ObjectMeta; @@ -1126,7 +1118,7 @@ mod tests { } #[tokio::test] - async fn oauth2_token_exchange_refresh_uses_sandbox_delegation_binding() { + async fn oauth2_token_exchange_refresh_uses_subject_token_material() { let mock_server = MockServer::start().await; Mock::given(method("POST")) .and(path("/token")) @@ -1151,28 +1143,6 @@ mod tests { .await; let store = test_store().await; - let sandbox = Sandbox { - metadata: Some(ObjectMeta { - id: "sandbox-obo".to_string(), - name: "obo".to_string(), - created_at_ms: 1_000_000, - labels: HashMap::new(), - resource_version: 0, - }), - spec: Some(SandboxSpec::default()), - ..Default::default() - }; - let binding = new_binding( - &sandbox, - "user-123", - Some("alex"), - "oidc", - "user-access-token", - &["sandbox:write".to_string()], - ) - .unwrap(); - put_binding(&store, &binding).await.unwrap(); - let provider = provider("my-obo", "okta"); store.put_message(&provider).await.unwrap(); let state = new_refresh_state( @@ -1183,11 +1153,14 @@ mod tests { material: HashMap::from([ ("client_id".to_string(), "client-id".to_string()), ("client_secret".to_string(), "client-secret".to_string()), - ("sandbox_id".to_string(), "sandbox-obo".to_string()), + ("subject_token".to_string(), "user-access-token".to_string()), ("audience".to_string(), "api://downstream".to_string()), ("scope".to_string(), "files.read".to_string()), ]), - secret_material_keys: vec!["client_secret".to_string()], + secret_material_keys: vec![ + "client_secret".to_string(), + "subject_token".to_string(), + ], expires_at_ms: 0, token_url: format!("{}/token", mock_server.uri()), scopes: Vec::new(), diff --git a/docs/get-started/tutorials/okta-obo.mdx b/docs/get-started/tutorials/okta-obo.mdx index c7a8f7a59..03062886c 100644 --- a/docs/get-started/tutorials/okta-obo.mdx +++ b/docs/get-started/tutorials/okta-obo.mdx @@ -8,13 +8,13 @@ description: "Configure the built-in Okta OBO provider profile so OpenShell can keywords: "Generative AI, Cybersecurity, Tutorial, Providers, Okta, OBO, RFC 8693, Token Exchange, Delegation" --- -Use the built-in `okta-obo` profile when a sandboxed workload must call a downstream API on behalf of the human who logged into OpenShell. The gateway keeps the inbound user token server-side, binds it to sandbox creation, and exchanges it for a short-lived delegated token using Okta token exchange. +Use the built-in `okta-obo` profile when a sandboxed workload must call a downstream API on behalf of the human who logged into OpenShell. During refresh configuration, the gateway captures the logged-in user's bearer token as provider refresh secret material and later exchanges it for a short-lived delegated token using Okta token exchange. After completing this tutorial, you have: - A token-exchange service app in Okta for delegated access. - A customized `okta-obo` provider profile that points at your Okta tenant. -- A sandbox whose attached provider can mint `OKTA_OBO_ACCESS_TOKEN` from the logged-in user's identity. +- An attached provider that can mint `OKTA_OBO_ACCESS_TOKEN` from the logged-in user's identity. This tutorial covers the delegation lane. It assumes you already completed the gateway login lane and can log into the gateway with Okta before you create the sandbox. @@ -71,7 +71,7 @@ openshell provider profile lint -f okta-obo.yaml openshell provider profile import -f okta-obo.yaml ``` -## Log In and Create the Sandbox +## Log In Log into the gateway as the human user whose identity should be delegated: @@ -79,18 +79,6 @@ Log into the gateway as the human user whose identity should be delegated: openshell gateway login ``` -Create the sandbox after login so OpenShell can bind the sandbox to the authenticated user token: - -```shell -openshell sandbox create --name okta-obo-smoke -``` - -Fetch the sandbox metadata and record the sandbox ID: - -```shell -openshell sandbox get okta-obo-smoke -``` - ## Create the OBO Provider Create the provider from the imported profile: @@ -111,7 +99,6 @@ openshell provider refresh configure okta-obo-runtime \ --strategy oauth2-token-exchange \ --material client_id="$OKTA_OBO_CLIENT_ID" \ --material client_secret="$OKTA_OBO_CLIENT_SECRET" \ - --material sandbox_id="" \ --material audience="$OKTA_OBO_AUDIENCE" \ --material scope="$OKTA_OBO_SCOPE" \ --secret-material-key client_secret @@ -124,7 +111,13 @@ openshell provider refresh status okta-obo-runtime \ --credential-key OKTA_OBO_ACCESS_TOKEN ``` -## Attach the Provider +## Create the Sandbox and Attach the Provider + +Create the sandbox after configuring the provider: + +```shell +openshell sandbox create --name okta-obo-smoke +``` Attach the OBO provider to the sandbox: diff --git a/proto/openshell.proto b/proto/openshell.proto index 55165b55b..66a99152b 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -960,18 +960,6 @@ message StoredProviderCredentialRefreshState { int64 max_lifetime_seconds = 16; } -message StoredSandboxDelegationBinding { - openshell.datamodel.v1.ObjectMeta metadata = 1; - string sandbox_id = 2; - string sandbox_name = 3; - string subject = 4; - string display_name = 5; - string identity_provider = 6; - string access_token = 7; - repeated string scopes = 8; - int64 captured_at_ms = 9; -} - message GetProviderRefreshStatusRequest { string provider = 1; string credential_key = 2; diff --git a/providers/okta-obo.yaml b/providers/okta-obo.yaml index 56f38951a..776d053f8 100644 --- a/providers/okta-obo.yaml +++ b/providers/okta-obo.yaml @@ -21,9 +21,6 @@ credentials: - name: client_id description: Okta OIDC application client ID used for token exchange required: true - - name: sandbox_id - description: OpenShell sandbox ID bound to the authenticated user token - required: true - name: audience description: Downstream Okta resource audience for the delegated token required: true @@ -31,6 +28,10 @@ credentials: description: Okta client secret for confidential token-exchange clients required: false secret: true + - name: subject_token + description: Authenticated user bearer token captured during refresh configuration + required: false + secret: true - name: scope description: Space-delimited scopes requested for the delegated token required: false From ef5a8cfddcbffcdbfdc506480ed07372e5bd83f0 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 3 Jun 2026 09:36:48 -0700 Subject: [PATCH 5/5] chore(okta-obo): resolve rebase cleanup --- .../tests/provider_commands_integration.rs | 14 +++++++--- crates/openshell-core/src/metadata.rs | 3 +-- crates/openshell-providers/src/profiles.rs | 26 ++++++++++++------- crates/openshell-server/src/telemetry.rs | 3 --- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 5a5031d23..6d00e1cdc 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1120,13 +1120,18 @@ async fn provider_refresh_cli_supports_oauth2_token_exchange_strategy() { ..Default::default() }, openshell_core::proto::ProviderCredentialRefreshMaterial { - name: "sandbox_id".to_string(), + name: "audience".to_string(), required: true, ..Default::default() }, openshell_core::proto::ProviderCredentialRefreshMaterial { - name: "audience".to_string(), - required: true, + name: "client_secret".to_string(), + secret: true, + ..Default::default() + }, + openshell_core::proto::ProviderCredentialRefreshMaterial { + name: "subject_token".to_string(), + secret: true, ..Default::default() }, ], @@ -1144,6 +1149,7 @@ async fn provider_refresh_cli_supports_oauth2_token_exchange_strategy() { "okta-obo", false, &[], + false, &[], &ts.tls, ) @@ -1158,8 +1164,8 @@ async fn provider_refresh_cli_supports_oauth2_token_exchange_strategy() { strategy: "oauth2_token_exchange", material: &[ "client_id=client-id".to_string(), - "sandbox_id=sandbox-123".to_string(), "audience=api://downstream".to_string(), + "subject_token=user-token".to_string(), "scope=api:access:read".to_string(), ], secret_material_keys: &["client_secret".to_string()], diff --git a/crates/openshell-core/src/metadata.rs b/crates/openshell-core/src/metadata.rs index e86bc17e2..af26f73ae 100644 --- a/crates/openshell-core/src/metadata.rs +++ b/crates/openshell-core/src/metadata.rs @@ -6,9 +6,8 @@ //! These traits provide uniform access to `ObjectMeta` fields across all resource types. use crate::proto::{ - InferenceRoute, ObjectForTest, Provider, Sandbox, ServiceEndpoint, SshSession, + InferenceRoute, ObjectForTest, Provider, Sandbox, SandboxStatus, ServiceEndpoint, SshSession, StoredProviderCredentialRefreshState, StoredProviderProfile, - SandboxStatus, }; use std::collections::HashMap; diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 888042c69..a9d41109f 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -373,6 +373,7 @@ impl CredentialRefreshProfile { self.strategy, ProviderCredentialRefreshStrategy::Oauth2RefreshToken | ProviderCredentialRefreshStrategy::Oauth2ClientCredentials + | ProviderCredentialRefreshStrategy::Oauth2TokenExchange | ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt ) } @@ -1277,19 +1278,24 @@ mod tests { #[test] fn refresh_bootstrap_requires_a_gateway_mintable_path_and_no_required_static_credentials() { - let optional_refresh_profile = parse_profile_yaml( - r" -id: optional-refresh -display_name: Optional Refresh + for (required, strategy) in [ + (false, "oauth2_refresh_token"), + (true, "oauth2_token_exchange"), + ] { + let profile = parse_profile_yaml(&format!( + r" +id: refresh-profile +display_name: Refresh Profile credentials: - name: access_token - required: false + required: {required} refresh: - strategy: oauth2_refresh_token -", - ) - .expect("profile"); - assert!(optional_refresh_profile.allows_gateway_refresh_bootstrap()); + strategy: {strategy} +" + )) + .expect("profile"); + assert!(profile.allows_gateway_refresh_bootstrap()); + } let mixed_required_profile = parse_profile_yaml( r" diff --git a/crates/openshell-server/src/telemetry.rs b/crates/openshell-server/src/telemetry.rs index 7de21154e..04529bcc4 100644 --- a/crates/openshell-server/src/telemetry.rs +++ b/crates/openshell-server/src/telemetry.rs @@ -21,8 +21,6 @@ impl TelemetryState { pub fn sandbox_session_disconnected(&self, _sandbox_id: &str) {} - pub fn end_sandbox_session(&self, _sandbox_id: &str) {} - pub fn record_network_activity(&self, sandbox_id: &str, summary: &NetworkActivitySummary) { if sandbox_id.is_empty() || !openshell_core::telemetry::enabled() { return; @@ -99,6 +97,5 @@ mod tests { let telemetry = TelemetryState::new(); telemetry.sandbox_session_connected("sb-1"); telemetry.sandbox_session_disconnected("sb-1"); - telemetry.end_sandbox_session("sb-1"); } }