Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files
| `crates/openshell-bootstrap/` | Gateway metadata | Gateway registration metadata, auth token storage, mTLS bundle storage |
| `crates/openshell-ocsf/` | OCSF logging | OCSF v1.7.0 event types, builders, shorthand/JSONL formatters, tracing layers |
| `crates/openshell-core/` | Shared core | Common types, configuration, error handling |
| `crates/openshell-sdk/` | Shared client SDK | Async Rust gateway client (gRPC transport, TLS, OIDC refresh, edge tunnel); consumed by CLI, TUI, and `@openshell/sdk` |
| `crates/openshell-providers/` | Provider management | Credential provider backends |
| `crates/openshell-tui/` | Terminal UI | Ratatui-based dashboard for monitoring |
| `crates/openshell-driver-kubernetes/` | Kubernetes compute driver | In-process `ComputeDriver` backend for K8s sandbox pods |
Expand Down
30 changes: 27 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 1 addition & 5 deletions crates/openshell-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ openshell-core = { path = "../openshell-core", default-features = false }
openshell-policy = { path = "../openshell-policy" }
openshell-providers = { path = "../openshell-providers" }
openshell-prover = { path = "../openshell-prover" }
openshell-sdk = { path = "../openshell-sdk" }
openshell-tui = { path = "../openshell-tui" }
serde = { workspace = true }
serde_json = { workspace = true }
Expand Down Expand Up @@ -49,8 +50,6 @@ hyper-util = { workspace = true }
hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "http2", "tls12", "logging", "ring", "webpki-tokio"] }
rustls = { workspace = true }
rustls-pemfile = { workspace = true }
tokio-rustls = { workspace = true }
tower = { workspace = true }
reqwest = { workspace = true }

# Error handling
Expand All @@ -66,9 +65,6 @@ tempfile = "3"
oauth2 = "5"
base64 = { workspace = true }

# WebSocket (Cloudflare tunnel proxy)
tokio-tungstenite = { workspace = true }

# Streams
futures = { workspace = true }
tokio-stream = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-cli/src/completers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ use openshell_bootstrap::edge_token::load_edge_token;
use openshell_bootstrap::oidc_token::{is_token_expired, load_oidc_token, store_oidc_token};
use openshell_bootstrap::{list_gateways, load_active_gateway, load_gateway_metadata};
use openshell_core::ObjectName;
use openshell_core::auth::EdgeAuthInterceptor;
use openshell_core::proto::open_shell_client::OpenShellClient;
use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest};
use openshell_sdk::EdgeAuthInterceptor;
use tonic::service::interceptor::InterceptedService;
use tonic::transport::Channel;

Expand Down
1 change: 0 additions & 1 deletion crates/openshell-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()

pub mod auth;
pub mod completers;
pub mod edge_tunnel;
pub mod oidc_auth;
pub mod output;
pub(crate) mod policy_update;
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2970,7 +2970,7 @@ async fn main() -> Result<()> {
let mut tls = tls.with_gateway_name(&ctx.name);
apply_auth(&mut tls, &ctx.name);
let channel = openshell_cli::tls::build_channel(&ctx.endpoint, &tls).await?;
let interceptor = openshell_core::auth::EdgeAuthInterceptor::new(
let interceptor = openshell_sdk::EdgeAuthInterceptor::new(
tls.oidc_token.as_deref(),
tls.edge_token.as_deref(),
)?;
Expand Down
87 changes: 20 additions & 67 deletions crates/openshell-cli/src/oidc_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ use miette::{IntoDiagnostic, Result};
use oauth2::basic::BasicClient;
use oauth2::{
AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge,
RedirectUrl, RefreshToken, Scope, TokenResponse, TokenUrl,
RedirectUrl, Scope, TokenResponse, TokenUrl,
};
use openshell_bootstrap::oidc_token::OidcTokenBundle;
use serde::Deserialize;
use openshell_sdk::oidc::{RefreshTokenInput, discover, http_client, refresh_token};
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use std::time::Duration;
Expand All @@ -30,50 +30,6 @@ use tracing::debug;

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

/// OIDC discovery document (subset of fields we need).
#[derive(Debug, Deserialize)]
struct OidcDiscovery {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
}

/// Discover OIDC endpoints from the issuer's well-known configuration.
///
/// Validates that the discovery document's `issuer` field matches the
/// configured issuer URL to prevent SSRF or misdirection.
async fn discover(issuer: &str, insecure: bool) -> Result<OidcDiscovery> {
let normalized_issuer = issuer.trim_end_matches('/');
let url = format!("{normalized_issuer}/.well-known/openid-configuration");
let client = http_client(insecure);
let resp: OidcDiscovery = client
.get(&url)
.send()
.await
.into_diagnostic()?
.json()
.await
.into_diagnostic()?;

let discovered_issuer = resp.issuer.trim_end_matches('/');
if discovered_issuer != normalized_issuer {
return Err(miette::miette!(
"OIDC discovery issuer mismatch: expected '{}', got '{}'",
normalized_issuer,
discovered_issuer
));
}
Ok(resp)
}

fn http_client(insecure: bool) -> reqwest::Client {
let mut builder = reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none());
if insecure {
builder = builder.danger_accept_invalid_certs(true);
}
builder.build().expect("failed to build HTTP client")
}

fn build_scopes(scopes: Option<&str>) -> Vec<Scope> {
let mut result = vec![Scope::new("openid".to_string())];
if let Some(s) = scopes {
Expand Down Expand Up @@ -227,36 +183,33 @@ pub async fn oidc_client_credentials_flow(

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

let discovery = discover(&bundle.issuer, insecure).await?;

let client = BasicClient::new(ClientId::new(bundle.client_id.clone()))
.set_token_uri(TokenUrl::new(discovery.token_endpoint).into_diagnostic()?);

let http = http_client(insecure);
let token_response = client
.exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
.request_async(&http)
.await
.map_err(|e| miette::miette!("token refresh failed: {e}"))?;

let mut refreshed =
bundle_from_oauth2_response(&token_response, &bundle.issuer, &bundle.client_id);
if refreshed.refresh_token.is_none() {
refreshed.refresh_token.clone_from(&bundle.refresh_token);
}
Ok(refreshed)
let input =
RefreshTokenInput::new(refresh, &bundle.issuer, &bundle.client_id).with_insecure(insecure);
let output = refresh_token(&input).await.into_diagnostic()?;

Ok(OidcTokenBundle {
access_token: output.access_token,
refresh_token: output
.refresh_token
.or_else(|| bundle.refresh_token.clone()),
expires_at: output.expires_at,
issuer: bundle.issuer.clone(),
client_id: bundle.client_id.clone(),
})
}

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