Skip to content

Commit d1668e2

Browse files
committed
feat(sdk): extract openshell-sdk crate
Per RFC 0005, lift the gRPC client, TLS, OIDC, edge-tunnel, and refresh plumbing out of openshell-cli into a new openshell-sdk crate. CLI and TUI now consume the SDK; openshell-cli/src/{tls.rs,oidc_auth.rs} shrink to thin wrappers over the SDK's transport and OIDC modules. - New crate openshell-sdk exposes a typed gRPC client, TLS resolver, OidcRefresher with single-flight semantics, edge-tunnel dialer, and a Sandbox-API surface that mirrors the existing CLI behavior. - crates/openshell-core/src/auth.rs moves into the SDK as auth.rs. - crates/openshell-cli/src/edge_tunnel.rs moves into the SDK as edge_tunnel.rs. Tests: 3 unit + 10 mock-gateway integration tests in openshell-sdk. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
1 parent fb83d1a commit d1668e2

26 files changed

Lines changed: 2573 additions & 303 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
3737
| `crates/openshell-bootstrap/` | Gateway metadata | Gateway registration metadata, auth token storage, mTLS bundle storage |
3838
| `crates/openshell-ocsf/` | OCSF logging | OCSF v1.7.0 event types, builders, shorthand/JSONL formatters, tracing layers |
3939
| `crates/openshell-core/` | Shared core | Common types, configuration, error handling |
40+
| `crates/openshell-sdk/` | Shared client SDK | Async Rust gateway client (gRPC transport, TLS, OIDC refresh, edge tunnel); consumed by CLI, TUI, and `@openshell/sdk` |
4041
| `crates/openshell-providers/` | Provider management | Credential provider backends |
4142
| `crates/openshell-tui/` | Terminal UI | Ratatui-based dashboard for monitoring |
4243
| `crates/openshell-driver-kubernetes/` | Kubernetes compute driver | In-process `ComputeDriver` backend for K8s sandbox pods |

Cargo.lock

Lines changed: 27 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/openshell-cli/Cargo.toml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ openshell-core = { path = "../openshell-core", default-features = false }
2020
openshell-policy = { path = "../openshell-policy" }
2121
openshell-providers = { path = "../openshell-providers" }
2222
openshell-prover = { path = "../openshell-prover" }
23+
openshell-sdk = { path = "../openshell-sdk" }
2324
openshell-tui = { path = "../openshell-tui" }
2425
serde = { workspace = true }
2526
serde_json = { workspace = true }
@@ -49,8 +50,6 @@ hyper-util = { workspace = true }
4950
hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "http2", "tls12", "logging", "ring", "webpki-tokio"] }
5051
rustls = { workspace = true }
5152
rustls-pemfile = { workspace = true }
52-
tokio-rustls = { workspace = true }
53-
tower = { workspace = true }
5453
reqwest = { workspace = true }
5554

5655
# Error handling
@@ -66,9 +65,6 @@ tempfile = "3"
6665
oauth2 = "5"
6766
base64 = { workspace = true }
6867

69-
# WebSocket (Cloudflare tunnel proxy)
70-
tokio-tungstenite = { workspace = true }
71-
7268
# Streams
7369
futures = { workspace = true }
7470
tokio-stream = { workspace = true }

crates/openshell-cli/src/completers.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ use openshell_bootstrap::edge_token::load_edge_token;
99
use openshell_bootstrap::oidc_token::{is_token_expired, load_oidc_token, store_oidc_token};
1010
use openshell_bootstrap::{list_gateways, load_active_gateway, load_gateway_metadata};
1111
use openshell_core::ObjectName;
12-
use openshell_core::auth::EdgeAuthInterceptor;
1312
use openshell_core::proto::open_shell_client::OpenShellClient;
1413
use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest};
14+
use openshell_sdk::EdgeAuthInterceptor;
1515
use tonic::service::interceptor::InterceptedService;
1616
use tonic::transport::Channel;
1717

crates/openshell-cli/src/lib.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()
1010

1111
pub mod auth;
1212
pub mod completers;
13-
pub mod edge_tunnel;
1413
pub mod oidc_auth;
1514
pub mod output;
1615
pub(crate) mod policy_update;

crates/openshell-cli/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2962,7 +2962,7 @@ async fn main() -> Result<()> {
29622962
let mut tls = tls.with_gateway_name(&ctx.name);
29632963
apply_auth(&mut tls, &ctx.name);
29642964
let channel = openshell_cli::tls::build_channel(&ctx.endpoint, &tls).await?;
2965-
let interceptor = openshell_core::auth::EdgeAuthInterceptor::new(
2965+
let interceptor = openshell_sdk::EdgeAuthInterceptor::new(
29662966
tls.oidc_token.as_deref(),
29672967
tls.edge_token.as_deref(),
29682968
)?;

crates/openshell-cli/src/oidc_auth.rs

Lines changed: 20 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ use miette::{IntoDiagnostic, Result};
1717
use oauth2::basic::BasicClient;
1818
use oauth2::{
1919
AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge,
20-
RedirectUrl, RefreshToken, Scope, TokenResponse, TokenUrl,
20+
RedirectUrl, Scope, TokenResponse, TokenUrl,
2121
};
2222
use openshell_bootstrap::oidc_token::OidcTokenBundle;
23-
use serde::Deserialize;
23+
use openshell_sdk::oidc::{RefreshTokenInput, discover, http_client, refresh_token};
2424
use std::convert::Infallible;
2525
use std::sync::{Arc, Mutex};
2626
use std::time::Duration;
@@ -30,50 +30,6 @@ use tracing::debug;
3030

3131
const AUTH_TIMEOUT: Duration = Duration::from_secs(120);
3232

33-
/// OIDC discovery document (subset of fields we need).
34-
#[derive(Debug, Deserialize)]
35-
struct OidcDiscovery {
36-
issuer: String,
37-
authorization_endpoint: String,
38-
token_endpoint: String,
39-
}
40-
41-
/// Discover OIDC endpoints from the issuer's well-known configuration.
42-
///
43-
/// Validates that the discovery document's `issuer` field matches the
44-
/// configured issuer URL to prevent SSRF or misdirection.
45-
async fn discover(issuer: &str, insecure: bool) -> Result<OidcDiscovery> {
46-
let normalized_issuer = issuer.trim_end_matches('/');
47-
let url = format!("{normalized_issuer}/.well-known/openid-configuration");
48-
let client = http_client(insecure);
49-
let resp: OidcDiscovery = client
50-
.get(&url)
51-
.send()
52-
.await
53-
.into_diagnostic()?
54-
.json()
55-
.await
56-
.into_diagnostic()?;
57-
58-
let discovered_issuer = resp.issuer.trim_end_matches('/');
59-
if discovered_issuer != normalized_issuer {
60-
return Err(miette::miette!(
61-
"OIDC discovery issuer mismatch: expected '{}', got '{}'",
62-
normalized_issuer,
63-
discovered_issuer
64-
));
65-
}
66-
Ok(resp)
67-
}
68-
69-
fn http_client(insecure: bool) -> reqwest::Client {
70-
let mut builder = reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none());
71-
if insecure {
72-
builder = builder.danger_accept_invalid_certs(true);
73-
}
74-
builder.build().expect("failed to build HTTP client")
75-
}
76-
7733
fn build_scopes(scopes: Option<&str>) -> Vec<Scope> {
7834
let mut result = vec![Scope::new("openid".to_string())];
7935
if let Some(s) = scopes {
@@ -227,36 +183,33 @@ pub async fn oidc_client_credentials_flow(
227183

228184
/// Refresh an OIDC token using the `refresh_token` grant.
229185
///
230-
/// Preserves the existing refresh token if the server does not return a new
231-
/// one (per OAuth 2.0 spec, the refresh response may omit `refresh_token`).
186+
/// Wraps [`openshell_sdk::oidc::refresh_token`] with the CLI's
187+
/// [`OidcTokenBundle`] storage shape. Preserves the existing refresh
188+
/// token when the server omits one (per OAuth 2.0 the refresh response
189+
/// is allowed to leave `refresh_token` out).
232190
pub async fn oidc_refresh_token(
233191
bundle: &OidcTokenBundle,
234192
insecure: bool,
235193
) -> Result<OidcTokenBundle> {
236-
let refresh_token = bundle.refresh_token.as_deref().ok_or_else(|| {
194+
let refresh = bundle.refresh_token.as_deref().ok_or_else(|| {
237195
miette::miette!(
238196
"no refresh token available — re-authenticate with: openshell gateway login"
239197
)
240198
})?;
241199

242-
let discovery = discover(&bundle.issuer, insecure).await?;
243-
244-
let client = BasicClient::new(ClientId::new(bundle.client_id.clone()))
245-
.set_token_uri(TokenUrl::new(discovery.token_endpoint).into_diagnostic()?);
246-
247-
let http = http_client(insecure);
248-
let token_response = client
249-
.exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
250-
.request_async(&http)
251-
.await
252-
.map_err(|e| miette::miette!("token refresh failed: {e}"))?;
253-
254-
let mut refreshed =
255-
bundle_from_oauth2_response(&token_response, &bundle.issuer, &bundle.client_id);
256-
if refreshed.refresh_token.is_none() {
257-
refreshed.refresh_token.clone_from(&bundle.refresh_token);
258-
}
259-
Ok(refreshed)
200+
let input =
201+
RefreshTokenInput::new(refresh, &bundle.issuer, &bundle.client_id).with_insecure(insecure);
202+
let output = refresh_token(&input).await.into_diagnostic()?;
203+
204+
Ok(OidcTokenBundle {
205+
access_token: output.access_token,
206+
refresh_token: output
207+
.refresh_token
208+
.or_else(|| bundle.refresh_token.clone()),
209+
expires_at: output.expires_at,
210+
issuer: bundle.issuer.clone(),
211+
client_id: bundle.client_id.clone(),
212+
})
260213
}
261214

262215
/// Ensure we have a valid OIDC token for the given gateway, refreshing if needed.

0 commit comments

Comments
 (0)