Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ enum OutputFormat {
enum CliProviderRefreshStrategy {
Oauth2RefreshToken,
Oauth2ClientCredentials,
Oauth2TokenExchange,
GoogleServiceAccountJwt,
}

Expand All @@ -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",
}
}
Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4317,6 +4317,7 @@ fn is_gateway_mintable_refresh_strategy(strategy: i32) -> bool {
ProviderCredentialRefreshStrategy::try_from(strategy),
Ok(ProviderCredentialRefreshStrategy::Oauth2RefreshToken
| ProviderCredentialRefreshStrategy::Oauth2ClientCredentials
| ProviderCredentialRefreshStrategy::Oauth2TokenExchange
| ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt)
)
}
Expand Down Expand Up @@ -4759,6 +4760,7 @@ fn provider_refresh_strategy(strategy: &str) -> Result<ProviderCredentialRefresh
"oauth2_client_credentials" => {
Ok(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials)
}
"oauth2_token_exchange" => Ok(ProviderCredentialRefreshStrategy::Oauth2TokenExchange),
"google_service_account_jwt" => {
Ok(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt)
}
Expand Down Expand Up @@ -4813,6 +4815,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",
}
Expand Down
128 changes: 128 additions & 0 deletions crates/openshell-cli/tests/provider_commands_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1033,6 +1033,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;
Expand Down Expand Up @@ -1564,6 +1647,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;
Expand Down
35 changes: 34 additions & 1 deletion crates/openshell-core/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

use crate::proto::{
InferenceRoute, ObjectForTest, Provider, Sandbox, ServiceEndpoint, SshSession,
StoredProviderCredentialRefreshState, StoredProviderProfile,
StoredProviderCredentialRefreshState, StoredProviderProfile, StoredSandboxDelegationBinding,
};
use std::collections::HashMap;

Expand Down Expand Up @@ -168,6 +168,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<HashMap<String, String>> {
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 {
Expand Down
56 changes: 56 additions & 0 deletions crates/openshell-providers/src/profiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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-obo.yaml"),
];

#[derive(Debug, thiserror::Error)]
Expand Down Expand Up @@ -498,6 +499,7 @@ pub fn provider_refresh_strategy_from_yaml(raw: &str) -> Option<ProviderCredenti
"oauth2_client_credentials" => {
Some(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials)
}
"oauth2_token_exchange" => Some(ProviderCredentialRefreshStrategy::Oauth2TokenExchange),
"google_service_account_jwt" => {
Some(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt)
}
Expand All @@ -514,6 +516,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",
}
Expand Down Expand Up @@ -1139,6 +1142,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::<Vec<_>>();
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");
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-sandbox/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>) {
for (key, value) in provider_env {
Expand Down
22 changes: 17 additions & 5 deletions crates/openshell-server/src/auth/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,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.
///
Expand Down Expand Up @@ -374,11 +390,7 @@ impl Authenticator for OidcAuthenticator {
headers: &http::HeaderMap,
_path: &str,
) -> Result<Option<Principal>, 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);
};

Expand Down
Loading
Loading