Skip to content

Commit e6f319c

Browse files
authored
feat(sdk): add openshell-sdk crate (#1862)
* feat(sdk): add openshell-sdk crate Additive extraction of the shared async gRPC client core (transport, TLS, OIDC single-flight refresh, edge tunnel, high-level sandbox surface, raw escape hatch) as a new workspace crate. No existing consumers yet; CLI/TUI migration follows in a separate PR. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * refactor(sdk,core): reuse shared JWT exp decoder for refresh deadlines Extract the signature-unverified JWT exp decode out of openshell-core/grpc_client.rs into openshell_core::jwt::parse_exp_secs, and have the openshell-sdk refresh path reuse it to derive a proactive refresh deadline from a bearer JWT when the caller does not advertise expires_at. Addresses review feedback to reuse pre-existing logic rather than reimplement JWT expiry handling per client. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(sdk): harden refresh single-flight and redact tokens in Debug Addresses review feedback on the openshell-sdk refresh path. - Make the single-flight cleanup cancellation-safe. The in-flight slot is now cleared by the shared refresh computation itself (epoch-guarded) rather than the leader's post-await code. Previously, if the leader future was dropped (e.g. an FFI caller cancelling its promise) after a follower drove the refresh to completion, the completed future was stranded in the slot and later refresh_now() calls re-joined it, pinning the client to a stale or already-rejected token. Adds a regression test that cancels the leader and asserts the next refresh starts a fresh attempt. - Redact bearer secrets from Debug. RefreshedToken and the oidc RefreshTokenInput/RefreshTokenOutput now use manual Debug impls that omit the access/refresh token fields via finish_non_exhaustive, matching the house style (e.g. SecretResolver, SandboxJwtIssuer). Prevents a stray {:?} or a containing struct's derived Debug from writing tokens to logs. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(sdk): fail refresh when the new token can't be encoded as metadata store_bearer now returns an error instead of silently keeping the previous bearer value. The TokenSource commits the refreshed token to its state before the client writes it into the interceptor slot, so a silent drop left the interceptor on the old (expiring) token with no path back to a refresh. Surfacing the error fails the call loudly instead. Adds a unit test covering a token that can't be encoded as gRPC metadata. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(sdk): refresh OIDC tokens on raw routes and harden rotation Raw gRPC access never triggered OIDC refresh: a client that only used raw_grpc/raw_inference kept sending the initial bearer until it expired, with no proactive or reactive refresh. Add raw_grpc_fresh and raw_inference_fresh accessors that refresh before returning the client, plus force_refresh for reactive recovery after an Unauthenticated raw RPC. Guard the single-flight refresh commit against a concurrent replace(). The in-flight attempt now records the generation it started from and skips its write when an external replace() has advanced it, so timer or callback driven rotation is no longer clobbered by a slower refresh. Remove TokenSource::snapshot(): it returned an empty string under write contention and had no consumer on the CLI/TUI path. Tests read committed state directly instead. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * docs(sdk): rewrite crate README for consumers Recast the openshell-sdk README as a usable crate README rather than an RFC excerpt. Drop the Responsibilities/Non-responsibilities/Consumers scope-boundary sections and the mTLS migration rationale, folding the useful facts (explicit token, no disk/name resolution, Refresh trait, SdkError mapping) into the intro, a new Auth and refresh section, and Public surface. Remove the dead relative RFC link and status-label prose so the doc renders cleanly wherever it is published. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(sdk): address review feedback on OIDC refresh and transport Apply Drew's review notes on PR #1862: - Drop unused `rustls-pemfile` dependency and move `tokio-stream` to dev-dependencies (only used by tests). - Guard OIDC `expires_at` against u64 overflow with `saturating_add`. - Fix stale `#[non_exhaustive]` rationale in `AuthConfig` (the struct `Oidc` variant it described as future already ships). - Stop double-wrapping refresh errors: store the bare refresh-error text so the single `SdkError::auth` wrap happens once at await. - Strip stale CLI porting breadcrumbs from `build_channel` docs, keeping the branch table. - Treat proactive token refresh as best-effort: a transient failure falls through to the request instead of failing an RPC whose current token is still valid, with a regression test. - Collapse `exec`'s inline auth retry into the shared `unary` helper. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> * fix(sdk): preserve transient/terminal distinction in refresh errors The refresh single-flight collapsed both `RefreshError::Transient` and `RefreshError::Terminal` into a stringified `SdkError::Auth`, so consumers (CLI, TUI, future language bindings) had no machine-readable way to tell a retryable IdP blip from a dead session that needs re-authentication. Carry the `RefreshError` through the shared outcome (kept `Clone` for `Shared`) instead of its rendered text, and map it at the await site to a new `retryable` flag on `SdkError::Auth`. Add `SdkError::auth_retryable` and a `SdkError::retryable()` accessor; transient refresh failures report `true`, every other error `false`. Add classification tests. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com> --------- Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
1 parent 83003e8 commit e6f319c

19 files changed

Lines changed: 3786 additions & 10 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: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/openshell-core/src/grpc_client.rs

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -444,16 +444,7 @@ fn compute_refresh_delay(slot: &TokenSlot) -> Duration {
444444
/// Returns the expiry in milliseconds since the Unix epoch, or `None` if
445445
/// the token is not a parseable JWT.
446446
fn parse_jwt_exp_ms(jwt: &str) -> Option<i64> {
447-
use base64::Engine;
448-
let mut parts = jwt.splitn(3, '.');
449-
let _header = parts.next()?;
450-
let payload_b64 = parts.next()?;
451-
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
452-
.decode(payload_b64)
453-
.ok()?;
454-
let value: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
455-
let exp_secs = value.get("exp")?.as_i64()?;
456-
exp_secs.checked_mul(1000)
447+
crate::jwt::parse_exp_secs(jwt)?.checked_mul(1000)
457448
}
458449

459450
#[cfg(test)]

crates/openshell-core/src/jwt.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Minimal, signature-unverified JWT inspection shared by gateway clients.
5+
//!
6+
//! Used only for client-side refresh scheduling (deciding when a bearer is
7+
//! near expiry). It never verifies the signature and must not be used for
8+
//! any authorization decision. Both the sandbox-side
9+
//! [`crate::grpc_client`] and the user-facing `openshell-sdk` refresh path
10+
//! derive token expiry from here so the decode lives in one place.
11+
12+
/// Decode the numeric `exp` claim (Unix seconds) from a JWT payload without
13+
/// verifying the signature.
14+
///
15+
/// Returns `None` when `token` is not a parseable JWT or has no integer `exp`
16+
/// claim. A leading `Bearer ` prefix is tolerated so callers can pass either a
17+
/// raw token or an `authorization` header value.
18+
#[must_use]
19+
pub fn parse_exp_secs(token: &str) -> Option<i64> {
20+
use base64::Engine;
21+
let raw = token.strip_prefix("Bearer ").unwrap_or(token);
22+
let mut parts = raw.splitn(3, '.');
23+
let _header = parts.next()?;
24+
let payload_b64 = parts.next()?;
25+
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
26+
.decode(payload_b64)
27+
.ok()?;
28+
let value: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
29+
value.get("exp")?.as_i64()
30+
}
31+
32+
#[cfg(test)]
33+
mod tests {
34+
use super::*;
35+
use base64::Engine as _;
36+
37+
fn jwt_with_payload(payload: &serde_json::Value) -> String {
38+
let b64 = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
39+
let header = b64(br#"{"alg":"none","typ":"JWT"}"#);
40+
let body = b64(serde_json::to_vec(payload).unwrap().as_slice());
41+
format!("{header}.{body}.")
42+
}
43+
44+
#[test]
45+
fn reads_integer_exp() {
46+
let token = jwt_with_payload(&serde_json::json!({ "exp": 1_900_000_000_i64 }));
47+
assert_eq!(parse_exp_secs(&token), Some(1_900_000_000));
48+
}
49+
50+
#[test]
51+
fn tolerates_bearer_prefix() {
52+
let token = jwt_with_payload(&serde_json::json!({ "exp": 42 }));
53+
assert_eq!(parse_exp_secs(&format!("Bearer {token}")), Some(42));
54+
}
55+
56+
#[test]
57+
fn none_for_missing_exp_or_non_jwt() {
58+
assert_eq!(
59+
parse_exp_secs(&jwt_with_payload(&serde_json::json!({ "sub": "x" }))),
60+
None
61+
);
62+
assert_eq!(parse_exp_secs("not-a-jwt"), None);
63+
assert_eq!(parse_exp_secs(""), None);
64+
}
65+
}

crates/openshell-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub mod gpu;
2222
pub mod grpc_client;
2323
pub mod image;
2424
pub mod inference;
25+
pub mod jwt;
2526
pub mod metadata;
2627
pub mod net;
2728
pub mod paths;

crates/openshell-sdk/Cargo.toml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
[package]
5+
name = "openshell-sdk"
6+
description = "Shared async Rust client for OpenShell gateways"
7+
version.workspace = true
8+
edition.workspace = true
9+
rust-version.workspace = true
10+
license.workspace = true
11+
repository.workspace = true
12+
13+
[dependencies]
14+
openshell-core = { path = "../openshell-core" }
15+
async-trait = "0.1"
16+
futures = { workspace = true }
17+
hyper = { workspace = true }
18+
hyper-util = { workspace = true }
19+
miette = { workspace = true }
20+
oauth2 = "5"
21+
reqwest = { workspace = true }
22+
rustls = { workspace = true }
23+
serde = { workspace = true }
24+
thiserror = { workspace = true }
25+
tokio = { workspace = true }
26+
tokio-rustls = { workspace = true }
27+
tokio-tungstenite = { workspace = true }
28+
tonic = { workspace = true, features = ["tls-native-roots"] }
29+
tower = { workspace = true }
30+
tracing = { workspace = true }
31+
32+
[dev-dependencies]
33+
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
34+
tokio-stream = { workspace = true }
35+
36+
[lints]
37+
workspace = true

crates/openshell-sdk/README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# openshell-sdk
2+
3+
`openshell-sdk` is the shared async Rust client for OpenShell gateways. It owns
4+
gRPC channel setup, TLS, OIDC refresh, and the Cloudflare Access tunnel so the
5+
CLI, the TUI, and language bindings share one client implementation. Callers
6+
pass an explicit bearer token; the SDK does no filesystem access and no
7+
gateway-name resolution.
8+
9+
## Two layers
10+
11+
- `OpenShellClient` — the curated, sandbox-focused surface: health, sandbox
12+
CRUD, readiness/deletion waits, and non-streaming exec.
13+
- `raw` — direct access to the generated tonic clients for RPCs the curated
14+
surface doesn't yet cover (inference, providers, policy, logs, settings, SSH,
15+
forwarding).
16+
17+
## Auth and refresh
18+
19+
The curated surface drives OIDC refresh automatically: proactively before a
20+
request and reactively on `Unauthenticated`. Refreshes are single-flight, so
21+
only one is in flight at a time.
22+
23+
The plain `raw_grpc`/`raw_inference` accessors do not refresh; they return a
24+
client bound to the current token. When a refresher is wired, use
25+
`raw_grpc_fresh`/`raw_inference_fresh` to refresh before the call, and
26+
`force_refresh` to recover after a raw RPC returns `Unauthenticated`.
27+
28+
The SDK consumes a `Refresh` trait that the caller implements; it does not run
29+
the OIDC browser flow itself.
30+
31+
## Transport modes
32+
33+
- Plaintext (local development)
34+
- Server-authenticated TLS (system roots, or a pinned private CA via `ca_cert`)
35+
- OIDC bearer over HTTPS (gateways behind an OAuth2/OIDC IdP)
36+
- Cloudflare Access tunnel (hosted gateways)
37+
- Insecure TLS (development/debug; certificate verification disabled)
38+
39+
mTLS (client certificates) is not supported.
40+
41+
## Public surface
42+
43+
`OpenShellClient::connect(ClientConfig)` returns a connected client exposing
44+
`health`, `create_sandbox`, `get_sandbox`, `list_sandboxes`, `delete_sandbox`,
45+
`wait_ready`, `wait_deleted`, and `exec`. Curated types (`SandboxSpec`,
46+
`SandboxRef`, `Health`, `ListOptions`, `ExecOptions`, `SandboxPhase`) use
47+
SDK-shaped enums rather than raw proto integers. Failures map to a typed
48+
`SdkError` with a discriminable kind.
49+
50+
## Modules
51+
52+
| Module | Purpose |
53+
|---|---|
54+
| `client` | High-level `OpenShellClient` and the curated sandbox surface. |
55+
| `config` | `ClientConfig`, `AuthConfig`. |
56+
| `transport` | Channel construction, TLS resolution, request interceptors. |
57+
| `auth` | `EdgeAuthInterceptor` for bearer-token attachment. |
58+
| `oidc` | OIDC token handling at the transport layer. |
59+
| `refresh` | `Refresh` trait and single-flight refresh coalescing. |
60+
| `edge_tunnel` | Cloudflare Access tunnel dialer. |
61+
| `error` | `SdkError` taxonomy. |
62+
| `types` | Curated request/response types and proto conversions. |
63+
| `raw` | Escape hatch re-exporting the generated tonic clients. |
64+
65+
## Notes
66+
67+
- Async-only. Tonic is async-native; callers needing a blocking call can wrap
68+
with their own runtime.
69+
- The curated surface will grow as more RPCs graduate from `raw`.

crates/openshell-sdk/src/auth.rs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Bearer-token authentication interceptor for outgoing gRPC requests.
5+
6+
use crate::error::{Result, SdkError};
7+
use std::sync::{Arc, RwLock};
8+
use tonic::metadata::{Ascii, MetadataValue};
9+
10+
/// Shared, mutable OIDC bearer header.
11+
///
12+
/// The interceptor reads it on every request and a [`crate::TokenSource`]
13+
/// overwrites it in place on refresh, so rotation propagates to an
14+
/// already-connected client without rebuilding the channel. Mirrors the slot
15+
/// pattern in `openshell-core`'s gRPC client.
16+
pub type BearerSlot = Arc<RwLock<Option<MetadataValue<Ascii>>>>;
17+
18+
/// Build the `authorization` header value for an OIDC bearer token.
19+
pub fn bearer_metadata(token: &str) -> Result<MetadataValue<Ascii>> {
20+
format!("Bearer {token}")
21+
.parse()
22+
.map_err(|_| SdkError::auth("invalid OIDC token value"))
23+
}
24+
25+
/// Interceptor that injects authentication headers into every outgoing gRPC request.
26+
///
27+
/// Supports OIDC Bearer tokens (standard `authorization` header) and
28+
/// Cloudflare Access tokens (custom headers). When no token is set, acts
29+
/// as a no-op. OIDC takes precedence over edge tokens.
30+
///
31+
/// The OIDC bearer is held in a shared [`BearerSlot`] so token refresh can
32+
/// update it live; edge tokens are captured once at construction (the edge
33+
/// tunnel binds the credential at handshake time).
34+
#[derive(Clone)]
35+
pub struct EdgeAuthInterceptor {
36+
bearer: Option<BearerSlot>,
37+
header_value: Option<MetadataValue<Ascii>>,
38+
cookie_value: Option<MetadataValue<Ascii>>,
39+
}
40+
41+
impl EdgeAuthInterceptor {
42+
/// Create an interceptor from optional token strings.
43+
///
44+
/// OIDC bearer token takes precedence over edge token. Returns a no-op
45+
/// interceptor when neither token is provided.
46+
pub fn new(oidc_token: Option<&str>, edge_token: Option<&str>) -> Result<Self> {
47+
if let Some(token) = oidc_token {
48+
let bearer = bearer_metadata(token)?;
49+
return Ok(Self {
50+
bearer: Some(Arc::new(RwLock::new(Some(bearer)))),
51+
header_value: None,
52+
cookie_value: None,
53+
});
54+
}
55+
56+
let (header_value, cookie_value) = match edge_token {
57+
Some(t) => {
58+
let hv: MetadataValue<Ascii> = t
59+
.parse()
60+
.map_err(|_| SdkError::auth("invalid edge token value"))?;
61+
let cv: MetadataValue<Ascii> = format!("CF_Authorization={t}")
62+
.parse()
63+
.map_err(|_| SdkError::auth("invalid edge token value for cookie"))?;
64+
(Some(hv), Some(cv))
65+
}
66+
None => (None, None),
67+
};
68+
Ok(Self {
69+
bearer: None,
70+
header_value,
71+
cookie_value,
72+
})
73+
}
74+
75+
/// No-op interceptor that passes requests through without modification.
76+
pub fn noop() -> Self {
77+
Self {
78+
bearer: None,
79+
header_value: None,
80+
cookie_value: None,
81+
}
82+
}
83+
84+
/// Handle to the live OIDC bearer slot, if this interceptor carries one.
85+
///
86+
/// The high-level client uses this to overwrite the token in place after
87+
/// a refresh. `None` for edge-token and no-op interceptors.
88+
pub fn bearer_slot(&self) -> Option<BearerSlot> {
89+
self.bearer.clone()
90+
}
91+
}
92+
93+
impl tonic::service::Interceptor for EdgeAuthInterceptor {
94+
fn call(
95+
&mut self,
96+
mut req: tonic::Request<()>,
97+
) -> std::result::Result<tonic::Request<()>, tonic::Status> {
98+
if let Some(slot) = &self.bearer
99+
&& let Some(val) = slot.read().ok().and_then(|g| g.clone())
100+
{
101+
req.metadata_mut().insert("authorization", val);
102+
}
103+
if let Some(ref val) = self.header_value {
104+
req.metadata_mut()
105+
.insert("cf-access-jwt-assertion", val.clone());
106+
}
107+
if let Some(ref val) = self.cookie_value {
108+
req.metadata_mut().insert("cookie", val.clone());
109+
}
110+
Ok(req)
111+
}
112+
}

0 commit comments

Comments
 (0)