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-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..6d00e1cdc 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1098,6 +1098,95 @@ 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: "audience".to_string(), + required: true, + ..Default::default() + }, + openshell_core::proto::ProviderCredentialRefreshMaterial { + name: "client_secret".to_string(), + secret: true, + ..Default::default() + }, + openshell_core::proto::ProviderCredentialRefreshMaterial { + name: "subject_token".to_string(), + secret: true, + ..Default::default() + }, + ], + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }, + ); + + run::provider_create( + &ts.endpoint, + "okta-obo-runtime", + "okta-obo", + false, + &[], + 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(), + "audience=api://downstream".to_string(), + "subject_token=user-token".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 +1797,57 @@ 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 == "client_id" && material.required) + ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "audience" && material.required) + ); + assert!( + refresh + .material + .iter() + .any(|material| material.name == "subject_token" && !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-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 316624287..a9d41109f 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)] @@ -372,6 +373,7 @@ impl CredentialRefreshProfile { self.strategy, ProviderCredentialRefreshStrategy::Oauth2RefreshToken | ProviderCredentialRefreshStrategy::Oauth2ClientCredentials + | ProviderCredentialRefreshStrategy::Oauth2TokenExchange | ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt ) } @@ -530,6 +532,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 +549,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 +1176,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", + "audience", + "client_secret", + "subject_token", + "scope" + ] + ); + assert!( + refresh + .material + .iter() + .find(|material| material.name == "subject_token") + .is_some_and(|material| !material.required && material.secret) + ); + 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"); @@ -1221,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/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/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e6f0c2780..1f7b62495 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -5,6 +5,9 @@ #![allow(clippy::result_large_err)] // gRPC handlers return Result, Status> +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, }; @@ -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,48 @@ pub(super) async fn handle_configure_provider_refresh( credential_key, ) .await?; + 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() @@ -1390,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, @@ -1818,7 +1865,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/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 198d5f04c..06f67fb4c 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -211,7 +211,6 @@ async fn handle_create_sandbox_inner( Some(Err(status)) => return Err(status), None => None, }; - let sandbox = state.compute.create_sandbox(sandbox, sandbox_token).await?; info!( @@ -494,17 +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 - .ok() - .flatten() - .map(|sandbox| sandbox.object_id().to_string()); let deleted = state.compute.delete_sandbox(&name).await?; - if deleted && let Some(sandbox_id) = sandbox_id { - state.telemetry.end_sandbox_session(&sandbox_id); - } info!(sandbox_name = %name, "DeleteSandbox request completed successfully"); Ok(Response::new(DeleteSandboxResponse { deleted })) } 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..195acd9ae 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,10 @@ async fn mint_credential( ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => { mint_oauth2_client_credentials(state).await } + ProviderCredentialRefreshStrategy::Oauth2TokenExchange => { + let _ = store; + mint_oauth2_token_exchange(state).await + } ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => { mint_google_service_account_jwt(state).await } @@ -465,37 +472,73 @@ 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_client_credentials( +async fn mint_oauth2_token_exchange( state: &StoredProviderCredentialRefreshState, ) -> Result { 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 subject_token = required_material(&state.material, "subject_token")?; + let mut form = vec![ - ("grant_type".to_string(), "client_credentials".to_string()), - ("client_id".to_string(), client_id), - ("client_secret".to_string(), client_secret), + ( + "grant_type".to_string(), + "urn:ietf:params:oauth:grant-type:token-exchange".to_string(), + ), + ("subject_token".to_string(), subject_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(), + ), ]; + 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)); + } + let scope = refresh_scopes(state).join(" "); + if !scope.is_empty() { + form.push(("scope".to_string(), scope)); + } + + request_token(&token_url, &form, basic_auth, state.max_lifetime_seconds).await +} + +async fn mint_oauth2_client_credentials( + state: &StoredProviderCredentialRefreshState, +) -> Result { + 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())]; + 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( @@ -541,12 +584,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) @@ -565,16 +609,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 @@ -648,7 +700,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/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"); } } 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..03062886c --- /dev/null +++ b/docs/get-started/tutorials/okta-obo.mdx @@ -0,0 +1,138 @@ +--- +# 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. 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. +- 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. + + +## 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 + +Log into the gateway as the human user whose identity should be delegated: + +```shell +openshell gateway login +``` + +## 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 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 +``` + +## 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: + +```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..66a99152b 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 { diff --git a/providers/okta-obo.yaml b/providers/okta-obo.yaml new file mode 100644 index 000000000..776d053f8 --- /dev/null +++ b/providers/okta-obo.yaml @@ -0,0 +1,40 @@ +# 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: 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: 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 +binaries: + - /usr/bin/curl + - /usr/local/bin/curl