diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 090097a20..fca170b17 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1033,6 +1033,52 @@ async fn provider_refresh_cli_run_functions_wire_requests() { ); } +#[tokio::test] +async fn okta_provider_refresh_cli_supports_runtime_refresh_shape() { + let ts = run_server().await; + + run::provider_create( + &ts.endpoint, + "okta-runtime", + "okta", + false, + &["OKTA_ACCESS_TOKEN=token".to_string()], + &[], + &ts.tls, + ) + .await + .expect("provider create"); + + run::provider_refresh_config( + &ts.endpoint, + run::ProviderRefreshConfigInput { + name: "okta-runtime", + credential_key: "OKTA_ACCESS_TOKEN", + strategy: "oauth2_refresh_token", + material: &[ + "client_id=client-123".to_string(), + "refresh_token=refresh-abc".to_string(), + "scope=okta.apps.read".to_string(), + ], + secret_material_keys: &["refresh_token".to_string()], + credential_expires_at_ms: Some(1_767_225_600_000), + }, + &ts.tls, + ) + .await + .expect("provider refresh configure"); + + let requests = ts.state.refresh_requests.lock().await.clone(); + assert!( + requests.contains(&ProviderRefreshRequestLog::Configure { + provider_name: "okta-runtime".to_string(), + credential_key: "OKTA_ACCESS_TOKEN".to_string(), + expires_at_ms: Some(1_767_225_600_000), + }), + "expected configure request for okta runtime provider" + ); +} + #[tokio::test] async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() { let ts = run_server().await; @@ -1533,6 +1579,40 @@ binaries: assert!(profile.binaries[0].harness); } +#[tokio::test] +async fn built_in_okta_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".to_string(), + }) + .await + .expect("get provider profile") + .into_inner() + .profile + .expect("profile should exist"); + + assert_eq!(profile.id, "okta"); + assert_eq!(profile.credentials.len(), 1); + assert_eq!(profile.credentials[0].env_vars, vec!["OKTA_ACCESS_TOKEN"]); + let refresh = profile.credentials[0] + .refresh + .as_ref() + .expect("okta profile should include refresh metadata"); + assert_eq!( + refresh.strategy, + ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32 + ); + assert_eq!( + refresh.token_url, + "https://example.okta.com/oauth2/default/v1/token" + ); +} + #[tokio::test] async fn provider_profile_import_from_directory_parse_error_prevents_partial_import() { let ts = run_server().await; diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 25c750e63..4d8944f62 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -20,6 +20,7 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../../../providers/claude-code.yaml"), include_str!("../../../providers/github.yaml"), include_str!("../../../providers/nvidia.yaml"), + include_str!("../../../providers/okta.yaml"), ]; #[derive(Debug, thiserror::Error)] @@ -1090,7 +1091,7 @@ pub fn get_default_profile(id: &str) -> Option<&'static ProviderTypeProfile> { #[cfg(test)] mod tests { - use openshell_core::proto::ProviderProfileCategory; + use openshell_core::proto::{ProviderCredentialRefreshStrategy, ProviderProfileCategory}; use super::{ DiscoveryProfile, ProfileError, ProviderTypeProfile, default_profiles, get_default_profile, @@ -1139,6 +1140,42 @@ mod tests { assert_eq!(proto.binaries.len(), 4); } + #[test] + fn okta_profile_exposes_refreshable_runtime_token_shape() { + let profile = get_default_profile("okta").expect("okta profile"); + let proto = profile.to_proto(); + + assert_eq!(proto.id, "okta"); + assert_eq!(proto.credentials.len(), 1); + let credential = &proto.credentials[0]; + assert_eq!(credential.name, "access_token"); + assert_eq!(credential.env_vars, vec!["OKTA_ACCESS_TOKEN"]); + + let refresh = credential.refresh.as_ref().expect("okta refresh metadata"); + assert_eq!( + refresh.strategy, + ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32 + ); + assert_eq!( + refresh.token_url, + "https://example.okta.com/oauth2/default/v1/token" + ); + assert!( + refresh + .material + .iter() + .any(|entry| { entry.name == "refresh_token" && entry.required && entry.secret }), + "okta profile should require a secret refresh token material entry" + ); + assert!( + refresh + .material + .iter() + .any(|entry| entry.name == "client_id" && entry.required), + "okta profile should require client_id refresh material" + ); + } + #[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-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 0fc657007..2a8da1b59 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -20,7 +20,7 @@ use std::os::unix::io::RawFd; use std::path::PathBuf; use std::process::Stdio; use tokio::process::{Child, Command}; -use tracing::{debug, warn}; +use tracing::debug; fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap) { for (key, value) in provider_env { diff --git a/crates/openshell-server/src/auth/authz.rs b/crates/openshell-server/src/auth/authz.rs index b4aa072d6..9271db542 100644 --- a/crates/openshell-server/src/auth/authz.rs +++ b/crates/openshell-server/src/auth/authz.rs @@ -23,6 +23,9 @@ const ADMIN_METHODS: &[&str] = &[ "/openshell.v1.OpenShell/CreateProvider", "/openshell.v1.OpenShell/UpdateProvider", "/openshell.v1.OpenShell/DeleteProvider", + "/openshell.v1.OpenShell/ImportProviderProfiles", + "/openshell.v1.OpenShell/LintProviderProfiles", + "/openshell.v1.OpenShell/DeleteProviderProfile", "/openshell.v1.OpenShell/ConfigureProviderRefresh", "/openshell.v1.OpenShell/RotateProviderCredential", "/openshell.v1.OpenShell/DeleteProviderRefresh", @@ -81,6 +84,14 @@ const SCOPED_METHODS: &[(&str, &str)] = &[ // provider:read ("/openshell.v1.OpenShell/GetProvider", "provider:read"), ("/openshell.v1.OpenShell/ListProviders", "provider:read"), + ( + "/openshell.v1.OpenShell/ListProviderProfiles", + "provider:read", + ), + ( + "/openshell.v1.OpenShell/GetProviderProfile", + "provider:read", + ), ( "/openshell.v1.OpenShell/GetProviderRefreshStatus", "provider:read", @@ -89,6 +100,18 @@ const SCOPED_METHODS: &[(&str, &str)] = &[ ("/openshell.v1.OpenShell/CreateProvider", "provider:write"), ("/openshell.v1.OpenShell/UpdateProvider", "provider:write"), ("/openshell.v1.OpenShell/DeleteProvider", "provider:write"), + ( + "/openshell.v1.OpenShell/ImportProviderProfiles", + "provider:write", + ), + ( + "/openshell.v1.OpenShell/LintProviderProfiles", + "provider:write", + ), + ( + "/openshell.v1.OpenShell/DeleteProviderProfile", + "provider:write", + ), ( "/openshell.v1.OpenShell/ConfigureProviderRefresh", "provider:write", @@ -563,6 +586,50 @@ mod tests { } } + #[test] + fn provider_profile_methods_require_provider_scopes_and_admin_for_writes() { + let policy = scoped_policy(); + let reader = identity_with_roles_and_scopes(&["openshell-user"], &["provider:read"]); + for method in [ + "/openshell.v1.OpenShell/ListProviderProfiles", + "/openshell.v1.OpenShell/GetProviderProfile", + ] { + assert!(policy.check(&reader, method).is_ok(), "{method}"); + } + + let writer_without_admin = + identity_with_roles_and_scopes(&["openshell-user"], &["provider:write"]); + let err = policy + .check( + &writer_without_admin, + "/openshell.v1.OpenShell/ImportProviderProfiles", + ) + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert!(err.message().contains("openshell-admin")); + + let admin_without_scope = + identity_with_roles_and_scopes(&["openshell-admin"], &["provider:read"]); + let err = policy + .check( + &admin_without_scope, + "/openshell.v1.OpenShell/DeleteProviderProfile", + ) + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert!(err.message().contains("provider:write")); + + let admin_writer = + identity_with_roles_and_scopes(&["openshell-admin"], &["provider:write"]); + for method in [ + "/openshell.v1.OpenShell/ImportProviderProfiles", + "/openshell.v1.OpenShell/LintProviderProfiles", + "/openshell.v1.OpenShell/DeleteProviderProfile", + ] { + assert!(policy.check(&admin_writer, method).is_ok(), "{method}"); + } + } + #[test] fn get_sandbox_config_requires_config_read_scope() { let policy = scoped_policy(); diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 3ddaae037..6698f7fea 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1617,7 +1617,7 @@ mod tests { .iter() .map(|profile| profile.id.as_str()) .collect::>(); - assert_eq!(ids, vec!["claude-code", "github", "nvidia"]); + assert_eq!(ids, vec!["claude-code", "github", "nvidia", "okta"]); let github = response .profiles diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index 161daeb7f..c874b8de3 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -840,7 +840,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", )) @@ -990,7 +989,6 @@ mod tests { Mock::given(method("POST")) .and(path("/token")) .and(body_string_contains("grant_type=refresh_token")) - .and(body_string_contains("client_id=client-id")) .and(body_string_contains("refresh_token=old-refresh-token")) .and(body_string_contains( "scope=https%3A%2F%2Fgraph.microsoft.com%2F.default", diff --git a/docs/get-started/tutorials/index.mdx b/docs/get-started/tutorials/index.mdx index 0d82509ad..1b033edfa 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 a Providers v2 Okta runtime provider with gateway-managed OAuth2 refresh-token rotation. + + Route inference through Ollama using cloud-hosted or local models, and verify it from a sandbox. diff --git a/docs/get-started/tutorials/okta-provider-refresh.mdx b/docs/get-started/tutorials/okta-provider-refresh.mdx new file mode 100644 index 000000000..699a07120 --- /dev/null +++ b/docs/get-started/tutorials/okta-provider-refresh.mdx @@ -0,0 +1,177 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Refresh Okta Runtime Credentials with Providers v2" +sidebar-title: "Okta Provider Refresh" +slug: "get-started/tutorials/okta-provider-refresh" +description: "Configure the built-in Okta provider profile with gateway-managed OAuth2 refresh-token rotation." +keywords: "Generative AI, Cybersecurity, Tutorial, Providers, Okta, OAuth2, Credential Refresh, Sandbox" +--- + +Use Providers v2 to keep Okta access tokens short lived while sandboxes continue to use the same `OKTA_ACCESS_TOKEN` placeholder. OpenShell stores the refresh material at the gateway, refreshes the access token before it expires, and supplies the latest token when a sandbox needs it. + +After completing this tutorial, you have: + +- The built-in `okta` provider profile available on the gateway. +- A provider instance configured with `oauth2-refresh-token`. +- A sandbox that can call an Okta-protected API through provider-owned policy. + + +This tutorial assumes you already have an Okta OAuth refresh token from a sign-in flow outside OpenShell. It does not cover gateway sign-in or delegated token exchange. + + + +If you also plan to use Okta for gateway sign-in, set that up separately. The runtime provider in this tutorial only needs the OAuth material required to refresh an API access token. + + +## Prerequisites + +- A working OpenShell installation with an active gateway. Complete the [Quickstart](/get-started/quickstart) before proceeding. +- An Okta custom authorization server and OAuth application that can mint refresh tokens. +- An Okta-protected API or resource server you want the sandbox to call. +- OAuth material from your initial sign-in flow: + +| Variable | Value | +|---|---| +| `OKTA_CLIENT_ID` | Okta OAuth application client ID. | +| `OKTA_ACCESS_TOKEN` | Current Okta access token. | +| `OKTA_REFRESH_TOKEN` | Okta OAuth refresh token. | +| `OKTA_ACCESS_TOKEN_EXPIRES_AT` | Absolute expiry for the current access token. | +| `OKTA_SCOPES` | Space-delimited scopes to request during refresh. | + +`OKTA_ACCESS_TOKEN_EXPIRES_AT` can be an RFC3339 timestamp such as `2026-01-01T00:00:00Z` or a Unix epoch millisecond timestamp. + + +Do not commit access tokens, refresh tokens, client secrets, or local `.env` files. The commands below pass token material to the gateway; they are not examples of values to store in source control. + + + +For most Okta runtime-provider setups, use a custom authorization server instead of the org authorization server. It is the safer default when you need custom scopes or refresh-token policies for your API. + + + + +## Enable Providers v2 + +Enable Providers v2 on the active gateway: + +```shell +openshell settings set --global --key providers_v2_enabled --value true --yes +``` + +## Inspect the Built-in Okta Profile + +Export the built-in profile to see what values it expects: + +```shell +openshell provider profile export okta -o yaml +``` + +The built-in profile includes: + +- `OKTA_ACCESS_TOKEN` as the sandbox credential. +- `oauth2_refresh_token` as the refresh strategy. +- `client_id`, `refresh_token`, and optional `client_secret` / `scope` as refresh material. + +For live use, replace `example.okta.com` with your Okta domain and import the result as a custom profile. + +## Create a Tenant-Specific Okta Profile + +Export the built-in profile and update the token endpoint and allowed host: + +```shell +openshell provider profile export okta -o yaml > okta-runtime.yaml +``` + +Replace: + +- `https://example.okta.com/oauth2/default/v1/token` +- `example.okta.com` + +with your real Okta domain, then import the customized profile: + +```shell +openshell provider profile lint -f okta-runtime.yaml +openshell provider profile import -f okta-runtime.yaml +``` + +## Create the Provider + +Create the provider with the current Okta access token: + +```shell +openshell provider create \ + --name okta-runtime \ + --type okta \ + --credential OKTA_ACCESS_TOKEN="$OKTA_ACCESS_TOKEN" +``` + +The current CLI requires an initial credential at provider creation time. You configure the refresh material in the next step. + +## Configure Refresh + +Configure gateway-managed OAuth2 refresh-token rotation: + +```shell +openshell provider refresh configure okta-runtime \ + --credential-key OKTA_ACCESS_TOKEN \ + --strategy oauth2-refresh-token \ + --material client_id="$OKTA_CLIENT_ID" \ + --material refresh_token="$OKTA_REFRESH_TOKEN" \ + --material scope="$OKTA_SCOPES" \ + --secret-material-key refresh_token \ + --credential-expires-at "$OKTA_ACCESS_TOKEN_EXPIRES_AT" +``` + +If your Okta application is confidential, include the client secret too: + +```shell +openshell provider refresh configure okta-runtime \ + --credential-key OKTA_ACCESS_TOKEN \ + --strategy oauth2-refresh-token \ + --material client_id="$OKTA_CLIENT_ID" \ + --material client_secret="$OKTA_CLIENT_SECRET" \ + --material refresh_token="$OKTA_REFRESH_TOKEN" \ + --material scope="$OKTA_SCOPES" \ + --secret-material-key client_secret \ + --secret-material-key refresh_token \ + --credential-expires-at "$OKTA_ACCESS_TOKEN_EXPIRES_AT" +``` + +Force the first refresh immediately: + +```shell +openshell provider refresh rotate okta-runtime \ + --credential-key OKTA_ACCESS_TOKEN +``` + +Check refresh status: + +```shell +openshell provider refresh status okta-runtime \ + --credential-key OKTA_ACCESS_TOKEN +``` + +## Launch a Sandbox + +Launch a sandbox with the Okta provider attached: + +```shell +openshell sandbox create \ + --name okta-runtime \ + --keep \ + --provider okta-runtime \ + --no-auto-providers \ + -- /bin/sh +``` + +The sandbox process receives `OKTA_ACCESS_TOKEN` as an OpenShell placeholder. When the process sends it in the authorization header, OpenShell resolves it to the latest gateway-managed token. + +## Common Okta Setup Mistakes + +- Using the Okta org authorization server when your API, scopes, or refresh-token policy were configured on a custom authorization server. +- Forgetting to request the scopes your resource server expects during the original Okta sign-in flow. +- Passing a refresh token that belongs to a different client ID than the one configured in provider refresh material. +- Expecting this tutorial to configure gateway sign-in. Gateway sign-in and runtime token refresh are separate setups. + + diff --git a/providers/okta.yaml b/providers/okta.yaml new file mode 100644 index 000000000..f3ff68a7c --- /dev/null +++ b/providers/okta.yaml @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: okta +display_name: Okta +description: Okta OAuth access tokens for runtime sandbox access +category: other +credentials: + - name: access_token + description: Okta OAuth access token + env_vars: [OKTA_ACCESS_TOKEN] + required: true + auth_style: bearer + header_name: authorization + refresh: + strategy: oauth2_refresh_token + 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 + required: true + - name: refresh_token + description: Okta OAuth refresh token + required: true + secret: true + - name: client_secret + description: Okta client secret for confidential applications + required: false + secret: true + - name: scope + description: Space-delimited scopes requested during refresh + required: false +endpoints: + - host: example.okta.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: + - /usr/bin/curl + - /usr/local/bin/curl