From 20dbdfa71d4b2bb5e805da285b89c1ba058694a8 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 28 May 2026 13:31:50 -0700 Subject: [PATCH 1/4] feat(providers): add okta runtime provider profile --- .../tests/provider_commands_integration.rs | 77 +++++++ crates/openshell-providers/src/profiles.rs | 35 ++- crates/openshell-server/src/auth/authz.rs | 201 +++++++++++++++++- docs/get-started/tutorials/index.mdx | 5 + .../tutorials/okta-provider-refresh.mdx | 162 ++++++++++++++ providers/okta.yaml | 43 ++++ 6 files changed, 513 insertions(+), 10 deletions(-) create mode 100644 docs/get-started/tutorials/okta-provider-refresh.mdx create mode 100644 providers/okta.yaml diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index ed78c6659..4a6c3145b 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1098,6 +1098,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; @@ -1677,6 +1723,37 @@ 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 316624287..a5d8831d8 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.yaml"), ]; #[derive(Debug, thiserror::Error)] @@ -1123,7 +1124,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, @@ -1172,6 +1173,38 @@ 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-server/src/auth/authz.rs b/crates/openshell-server/src/auth/authz.rs index 1c04b0976..13a42f1a4 100644 --- a/crates/openshell-server/src/auth/authz.rs +++ b/crates/openshell-server/src/auth/authz.rs @@ -13,12 +13,153 @@ //! authorization is a gateway concern. use super::identity::Identity; -use super::method_authz::{self, Role}; use tonic::Status; use tracing::debug; - +/// gRPC methods that require the admin role. +/// All other authenticated methods require the user role. +const ADMIN_METHODS: &[&str] = &[ + // Provider management + "/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", + // Global config and policy + "/openshell.v1.OpenShell/UpdateConfig", + // Draft policy approvals + "/openshell.v1.OpenShell/ApproveDraftChunk", + "/openshell.v1.OpenShell/ApproveAllDraftChunks", + "/openshell.v1.OpenShell/RejectDraftChunk", + "/openshell.v1.OpenShell/EditDraftChunk", + "/openshell.v1.OpenShell/UndoDraftChunk", + "/openshell.v1.OpenShell/ClearDraftChunks", + // Cluster inference write + "/openshell.inference.v1.Inference/SetClusterInference", +]; + +/// Exhaustive mapping of Bearer-authenticated gRPC methods to required scopes. +/// Methods not listed here require `openshell:all` when scope enforcement is enabled. +const SCOPED_METHODS: &[(&str, &str)] = &[ + // sandbox:read + ("/openshell.v1.OpenShell/GetSandbox", "sandbox:read"), + ("/openshell.v1.OpenShell/ListSandboxes", "sandbox:read"), + ( + "/openshell.v1.OpenShell/ListSandboxProviders", + "sandbox:read", + ), + ("/openshell.v1.OpenShell/WatchSandbox", "sandbox:read"), + ("/openshell.v1.OpenShell/GetSandboxLogs", "sandbox:read"), + ("/openshell.v1.OpenShell/GetService", "sandbox:read"), + ("/openshell.v1.OpenShell/ListServices", "sandbox:read"), + ( + "/openshell.v1.OpenShell/GetSandboxPolicyStatus", + "sandbox:read", + ), + ( + "/openshell.v1.OpenShell/ListSandboxPolicies", + "sandbox:read", + ), + // sandbox:write + ("/openshell.v1.OpenShell/CreateSandbox", "sandbox:write"), + ("/openshell.v1.OpenShell/DeleteSandbox", "sandbox:write"), + ("/openshell.v1.OpenShell/ExecSandbox", "sandbox:write"), + ("/openshell.v1.OpenShell/ForwardTcp", "sandbox:write"), + ("/openshell.v1.OpenShell/CreateSshSession", "sandbox:write"), + ("/openshell.v1.OpenShell/RevokeSshSession", "sandbox:write"), + ("/openshell.v1.OpenShell/ExposeService", "sandbox:write"), + ("/openshell.v1.OpenShell/DeleteService", "sandbox:write"), + ( + "/openshell.v1.OpenShell/AttachSandboxProvider", + "sandbox:write", + ), + ( + "/openshell.v1.OpenShell/DetachSandboxProvider", + "sandbox:write", + ), + // 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", + ), + // provider:write + ("/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", + ), + ( + "/openshell.v1.OpenShell/RotateProviderCredential", + "provider:write", + ), + ( + "/openshell.v1.OpenShell/DeleteProviderRefresh", + "provider:write", + ), + // config:read + ("/openshell.v1.OpenShell/GetGatewayConfig", "config:read"), + ("/openshell.v1.OpenShell/GetSandboxConfig", "config:read"), + ("/openshell.v1.OpenShell/GetDraftPolicy", "config:read"), + ("/openshell.v1.OpenShell/GetDraftHistory", "config:read"), + // config:write + ("/openshell.v1.OpenShell/UpdateConfig", "config:write"), + ("/openshell.v1.OpenShell/ApproveDraftChunk", "config:write"), + ( + "/openshell.v1.OpenShell/ApproveAllDraftChunks", + "config:write", + ), + ("/openshell.v1.OpenShell/RejectDraftChunk", "config:write"), + ("/openshell.v1.OpenShell/EditDraftChunk", "config:write"), + ("/openshell.v1.OpenShell/UndoDraftChunk", "config:write"), + ("/openshell.v1.OpenShell/ClearDraftChunks", "config:write"), + // inference:read + ( + "/openshell.inference.v1.Inference/GetClusterInference", + "inference:read", + ), + // inference:write + ( + "/openshell.inference.v1.Inference/SetClusterInference", + "inference:write", + ), +]; const SCOPE_ALL: &str = "openshell:all"; +fn required_role_is_admin(method: &str) -> bool { + ADMIN_METHODS.contains(&method) +} + +fn required_scope(method: &str) -> &'static str { + SCOPED_METHODS + .iter() + .find_map(|(candidate, scope)| (*candidate == method).then_some(*scope)) + .unwrap_or(SCOPE_ALL) +} + /// Authorization policy configuration. /// /// Supports two modes: @@ -64,12 +205,10 @@ impl AuthzPolicy { /// (authentication-only mode for providers like GitHub). #[allow(clippy::result_large_err)] pub fn check(&self, identity: &Identity, method: &str) -> Result<(), Status> { - let required = match method_authz::required_role(method) { - Some(Role::Admin) => &self.admin_role, - // Default to user role for unknown methods, matching the - // pre-annotation behavior. The exhaustiveness test ensures - // every real RPC has an explicit declaration. - Some(Role::User) | None => &self.user_role, + let required = if required_role_is_admin(method) { + &self.admin_role + } else { + &self.user_role }; // Empty role name = skip role check for this level (auth-only mode). @@ -108,7 +247,7 @@ impl AuthzPolicy { return Ok(()); } - let required_scope = method_authz::required_scope(method).unwrap_or(SCOPE_ALL); + let required_scope = required_scope(method); if identity.scopes.iter().any(|s| s == required_scope) { return Ok(()); @@ -450,6 +589,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/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..178a0a4db --- /dev/null +++ b/docs/get-started/tutorials/okta-provider-refresh.mdx @@ -0,0 +1,162 @@ +--- +# 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 receive a stable `OKTA_ACCESS_TOKEN` placeholder. OpenShell stores the non-injectable refresh material at the gateway, refreshes the Okta access token before it expires, updates the provider record, and injects the current credential into newly launched sandbox processes. + +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 targets the runtime credential lane. It assumes you already have an Okta OAuth refresh token from a standards-compliant sign-in flow outside OpenShell. It does not cover delegated token exchange or workforce gateway login. + + +## 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. + + + + +## Enable Providers v2 + +Enable provider profile policy composition 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 inspect its credential and policy shape: + +```shell +openshell provider profile export okta -o yaml +``` + +The built-in profile defines: + +- `OKTA_ACCESS_TOKEN` as the injected credential. +- `oauth2_refresh_token` as the refresh strategy. +- `client_id`, `refresh_token`, and optional `client_secret` / `scope` as refresh material. + +For live use, update the exported YAML to replace `example.okta.com` with your Okta domain and import it as a custom profile override. + +## 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. Refresh material is configured separately and is not injected into the sandbox. + +## 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. If the provider policy allows the destination API, the proxy resolves that placeholder to the current gateway-managed access token when the process sends it in the authorization header. + + 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 From 462f19ff1478682e17929f07ce2e597aadb39345 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Mon, 1 Jun 2026 14:53:44 -0700 Subject: [PATCH 2/4] docs(okta): polish runtime provider tutorial --- .../tutorials/okta-provider-refresh.mdx | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/get-started/tutorials/okta-provider-refresh.mdx b/docs/get-started/tutorials/okta-provider-refresh.mdx index 178a0a4db..699a07120 100644 --- a/docs/get-started/tutorials/okta-provider-refresh.mdx +++ b/docs/get-started/tutorials/okta-provider-refresh.mdx @@ -8,7 +8,7 @@ description: "Configure the built-in Okta provider profile with gateway-managed keywords: "Generative AI, Cybersecurity, Tutorial, Providers, Okta, OAuth2, Credential Refresh, Sandbox" --- -Use Providers v2 to keep Okta access tokens short lived while sandboxes receive a stable `OKTA_ACCESS_TOKEN` placeholder. OpenShell stores the non-injectable refresh material at the gateway, refreshes the Okta access token before it expires, updates the provider record, and injects the current credential into newly launched sandbox processes. +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: @@ -17,7 +17,11 @@ After completing this tutorial, you have: - A sandbox that can call an Okta-protected API through provider-owned policy. -This tutorial targets the runtime credential lane. It assumes you already have an Okta OAuth refresh token from a standards-compliant sign-in flow outside OpenShell. It does not cover delegated token exchange or workforce gateway login. +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 @@ -41,11 +45,15 @@ This tutorial targets the runtime credential lane. It assumes you already have a 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 provider profile policy composition on the active gateway: +Enable Providers v2 on the active gateway: ```shell openshell settings set --global --key providers_v2_enabled --value true --yes @@ -53,19 +61,19 @@ openshell settings set --global --key providers_v2_enabled --value true --yes ## Inspect the Built-in Okta Profile -Export the built-in profile to inspect its credential and policy shape: +Export the built-in profile to see what values it expects: ```shell openshell provider profile export okta -o yaml ``` -The built-in profile defines: +The built-in profile includes: -- `OKTA_ACCESS_TOKEN` as the injected credential. +- `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, update the exported YAML to replace `example.okta.com` with your Okta domain and import it as a custom profile override. +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 @@ -98,7 +106,7 @@ openshell provider create \ --credential OKTA_ACCESS_TOKEN="$OKTA_ACCESS_TOKEN" ``` -The current CLI requires an initial credential at provider creation time. Refresh material is configured separately and is not injected into the sandbox. +The current CLI requires an initial credential at provider creation time. You configure the refresh material in the next step. ## Configure Refresh @@ -157,6 +165,13 @@ openshell sandbox create \ -- /bin/sh ``` -The sandbox process receives `OKTA_ACCESS_TOKEN` as an OpenShell placeholder. If the provider policy allows the destination API, the proxy resolves that placeholder to the current gateway-managed access token when the process sends it in the authorization header. +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. From 10b5010079d6f67d9b2d873a347268eae86a78f4 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Mon, 1 Jun 2026 20:42:25 -0700 Subject: [PATCH 3/4] fix(okta): align runtime provider branch with current refresh behavior --- .../tests/provider_commands_integration.rs | 5 ++++- crates/openshell-providers/src/profiles.rs | 12 ++++++++---- crates/openshell-server/src/auth/authz.rs | 5 ++++- crates/openshell-server/src/grpc/provider.rs | 8 +++++++- crates/openshell-server/src/provider_refresh.rs | 2 -- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 4a6c3145b..0ed9d18a4 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1751,7 +1751,10 @@ async fn built_in_okta_profile_is_available_via_provider_profile_api() { refresh.strategy, ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32 ); - assert_eq!(refresh.token_url, "https://example.okta.com/oauth2/default/v1/token"); + assert_eq!( + refresh.token_url, + "https://example.okta.com/oauth2/default/v1/token" + ); } #[tokio::test] diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index a5d8831d8..9462025bc 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -1194,13 +1194,17 @@ mod tests { "https://example.okta.com/oauth2/default/v1/token" ); assert!( - refresh.material.iter().any(|entry| { - entry.name == "refresh_token" && entry.required && entry.secret - }), + 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), + refresh + .material + .iter() + .any(|entry| entry.name == "client_id" && entry.required), "okta profile should require client_id refresh material" ); } diff --git a/crates/openshell-server/src/auth/authz.rs b/crates/openshell-server/src/auth/authz.rs index 13a42f1a4..ba1530cf1 100644 --- a/crates/openshell-server/src/auth/authz.rs +++ b/crates/openshell-server/src/auth/authz.rs @@ -87,7 +87,10 @@ const SCOPED_METHODS: &[(&str, &str)] = &[ "/openshell.v1.OpenShell/ListProviderProfiles", "provider:read", ), - ("/openshell.v1.OpenShell/GetProviderProfile", "provider:read"), + ( + "/openshell.v1.OpenShell/GetProviderProfile", + "provider:read", + ), ( "/openshell.v1.OpenShell/GetProviderRefreshStatus", "provider:read", diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index e6f0c2780..7d1c75e22 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1818,7 +1818,13 @@ mod tests { .collect::>(); assert_eq!( ids, - vec!["claude-code", "github", "google-vertex-ai", "nvidia",] + vec![ + "claude-code", + "github", + "google-vertex-ai", + "nvidia", + "okta", + ] ); let github = response 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", From b5e0bc3e0a112a0fe783b019560a78b79b63a4b9 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 3 Jun 2026 09:36:48 -0700 Subject: [PATCH 4/4] chore(okta-runtime): resolve rebase cleanup --- .../tests/provider_commands_integration.rs | 1 + crates/openshell-server/src/auth/authz.rs | 159 +----------------- 2 files changed, 8 insertions(+), 152 deletions(-) diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 0ed9d18a4..45ad6d917 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1108,6 +1108,7 @@ async fn okta_provider_refresh_cli_supports_runtime_refresh_shape() { "okta", false, &["OKTA_ACCESS_TOKEN=token".to_string()], + false, &[], &ts.tls, ) diff --git a/crates/openshell-server/src/auth/authz.rs b/crates/openshell-server/src/auth/authz.rs index ba1530cf1..96999116d 100644 --- a/crates/openshell-server/src/auth/authz.rs +++ b/crates/openshell-server/src/auth/authz.rs @@ -13,155 +13,11 @@ //! authorization is a gateway concern. use super::identity::Identity; +use super::method_authz::{self, Role}; use tonic::Status; use tracing::debug; -/// gRPC methods that require the admin role. -/// All other authenticated methods require the user role. -const ADMIN_METHODS: &[&str] = &[ - // Provider management - "/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", - // Global config and policy - "/openshell.v1.OpenShell/UpdateConfig", - // Draft policy approvals - "/openshell.v1.OpenShell/ApproveDraftChunk", - "/openshell.v1.OpenShell/ApproveAllDraftChunks", - "/openshell.v1.OpenShell/RejectDraftChunk", - "/openshell.v1.OpenShell/EditDraftChunk", - "/openshell.v1.OpenShell/UndoDraftChunk", - "/openshell.v1.OpenShell/ClearDraftChunks", - // Cluster inference write - "/openshell.inference.v1.Inference/SetClusterInference", -]; - -/// Exhaustive mapping of Bearer-authenticated gRPC methods to required scopes. -/// Methods not listed here require `openshell:all` when scope enforcement is enabled. -const SCOPED_METHODS: &[(&str, &str)] = &[ - // sandbox:read - ("/openshell.v1.OpenShell/GetSandbox", "sandbox:read"), - ("/openshell.v1.OpenShell/ListSandboxes", "sandbox:read"), - ( - "/openshell.v1.OpenShell/ListSandboxProviders", - "sandbox:read", - ), - ("/openshell.v1.OpenShell/WatchSandbox", "sandbox:read"), - ("/openshell.v1.OpenShell/GetSandboxLogs", "sandbox:read"), - ("/openshell.v1.OpenShell/GetService", "sandbox:read"), - ("/openshell.v1.OpenShell/ListServices", "sandbox:read"), - ( - "/openshell.v1.OpenShell/GetSandboxPolicyStatus", - "sandbox:read", - ), - ( - "/openshell.v1.OpenShell/ListSandboxPolicies", - "sandbox:read", - ), - // sandbox:write - ("/openshell.v1.OpenShell/CreateSandbox", "sandbox:write"), - ("/openshell.v1.OpenShell/DeleteSandbox", "sandbox:write"), - ("/openshell.v1.OpenShell/ExecSandbox", "sandbox:write"), - ("/openshell.v1.OpenShell/ForwardTcp", "sandbox:write"), - ("/openshell.v1.OpenShell/CreateSshSession", "sandbox:write"), - ("/openshell.v1.OpenShell/RevokeSshSession", "sandbox:write"), - ("/openshell.v1.OpenShell/ExposeService", "sandbox:write"), - ("/openshell.v1.OpenShell/DeleteService", "sandbox:write"), - ( - "/openshell.v1.OpenShell/AttachSandboxProvider", - "sandbox:write", - ), - ( - "/openshell.v1.OpenShell/DetachSandboxProvider", - "sandbox:write", - ), - // 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", - ), - // provider:write - ("/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", - ), - ( - "/openshell.v1.OpenShell/RotateProviderCredential", - "provider:write", - ), - ( - "/openshell.v1.OpenShell/DeleteProviderRefresh", - "provider:write", - ), - // config:read - ("/openshell.v1.OpenShell/GetGatewayConfig", "config:read"), - ("/openshell.v1.OpenShell/GetSandboxConfig", "config:read"), - ("/openshell.v1.OpenShell/GetDraftPolicy", "config:read"), - ("/openshell.v1.OpenShell/GetDraftHistory", "config:read"), - // config:write - ("/openshell.v1.OpenShell/UpdateConfig", "config:write"), - ("/openshell.v1.OpenShell/ApproveDraftChunk", "config:write"), - ( - "/openshell.v1.OpenShell/ApproveAllDraftChunks", - "config:write", - ), - ("/openshell.v1.OpenShell/RejectDraftChunk", "config:write"), - ("/openshell.v1.OpenShell/EditDraftChunk", "config:write"), - ("/openshell.v1.OpenShell/UndoDraftChunk", "config:write"), - ("/openshell.v1.OpenShell/ClearDraftChunks", "config:write"), - // inference:read - ( - "/openshell.inference.v1.Inference/GetClusterInference", - "inference:read", - ), - // inference:write - ( - "/openshell.inference.v1.Inference/SetClusterInference", - "inference:write", - ), -]; -const SCOPE_ALL: &str = "openshell:all"; - -fn required_role_is_admin(method: &str) -> bool { - ADMIN_METHODS.contains(&method) -} -fn required_scope(method: &str) -> &'static str { - SCOPED_METHODS - .iter() - .find_map(|(candidate, scope)| (*candidate == method).then_some(*scope)) - .unwrap_or(SCOPE_ALL) -} +const SCOPE_ALL: &str = "openshell:all"; /// Authorization policy configuration. /// @@ -208,10 +64,9 @@ impl AuthzPolicy { /// (authentication-only mode for providers like GitHub). #[allow(clippy::result_large_err)] pub fn check(&self, identity: &Identity, method: &str) -> Result<(), Status> { - let required = if required_role_is_admin(method) { - &self.admin_role - } else { - &self.user_role + let required = match method_authz::required_role(method) { + Some(Role::Admin) => &self.admin_role, + Some(Role::User) | None => &self.user_role, }; // Empty role name = skip role check for this level (auth-only mode). @@ -250,7 +105,7 @@ impl AuthzPolicy { return Ok(()); } - let required_scope = required_scope(method); + let required_scope = method_authz::required_scope(method).unwrap_or(SCOPE_ALL); if identity.scopes.iter().any(|s| s == required_scope) { return Ok(()); @@ -599,6 +454,7 @@ mod tests { for method in [ "/openshell.v1.OpenShell/ListProviderProfiles", "/openshell.v1.OpenShell/GetProviderProfile", + "/openshell.v1.OpenShell/LintProviderProfiles", ] { assert!(policy.check(&reader, method).is_ok(), "{method}"); } @@ -629,7 +485,6 @@ mod tests { 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}");