Skip to content
Merged
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
7 changes: 5 additions & 2 deletions .agents/skills/openshell-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ Use an `http://` endpoint only for trusted local port-forwarding or a protected
openshell status
```

Confirm the gateway is reachable and shows a version.
Confirm the gateway is reachable, authentication is valid or not required, and
the output shows a version. `Status: Connected` only proves the public health
endpoint is reachable; inspect the separate `Authentication` line before
running protected commands.

### Step 3: Create a sandbox

Expand Down Expand Up @@ -615,7 +618,7 @@ $ openshell sandbox upload --help
| Task | Command |
|------|---------|
| Register local port-forwarded gateway | `openshell gateway add http://127.0.0.1:8080 --local --name local` |
| Check gateway health | `openshell status` |
| Check gateway health and authentication | `openshell status` |
| List/switch gateways | `openshell gateway select [name]` |
| Connect directly to a gateway | `openshell --gateway-endpoint <url> status` |
| Create sandbox (interactive) | `openshell sandbox create` |
Expand Down
5 changes: 4 additions & 1 deletion .agents/skills/openshell-cli/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,10 @@ package-managed or Helm gateways, use `systemctl`, `journalctl`, `kubectl`, and

### `openshell status`

Show server connectivity and version for the active gateway.
Show server connectivity, authentication status, and version for the active
gateway. Connectivity uses the public health RPC; authentication is checked
with the protected gateway-info capability query and can fail while the gateway
remains connected.

---

Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

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

15 changes: 15 additions & 0 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,21 @@ Supported auth modes:
| Cloudflare JWT | Edge-authenticated deployments where Cloudflare Access supplies identity. |
| OIDC | Bearer-token auth for users, with browser PKCE or client credentials login. |

The CLI persists the scopes requested during OIDC login in gateway metadata and
reuses them when refreshing an access token. This preserves the intended API
resource selection for identity providers that bind access-token audiences to
OAuth scopes.

Gateway health and user authentication are separate probes. `OpenShell.Health`
remains unauthenticated so deployment and load-balancer health checks do not
depend on user credentials. The CLI uses the existing, side-effect-free
`OpenShell.GetGatewayInfo` capability query as its protected authentication
probe. `Unauthenticated` means the credentials were rejected, while
`PermissionDenied` proves authentication succeeded before the caller failed
the capability query's admin authorization check. The CLI combines the health
and capability results so a reachable gateway with an expired or rejected
token is reported as connected but unauthenticated.

Sandbox supervisor RPCs authenticate with explicit sandbox credentials; mTLS
does not grant sandbox identity. Kubernetes deployments use the
gateway-minted JWT bootstrap path: the supervisor starts with a projected
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ openshell-bootstrap = { path = "../openshell-bootstrap" }
openshell-core = { path = "../openshell-core", default-features = false }
openshell-policy = { path = "../openshell-policy" }
openshell-providers = { path = "../openshell-providers" }
openshell-sdk = { path = "../openshell-sdk" }
openshell-tui = { path = "../openshell-tui" }
serde = { workspace = true }
serde_json = { workspace = true }
Expand Down
8 changes: 7 additions & 1 deletion crates/openshell-cli/src/completers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,13 @@ async fn completion_grpc_client(
Some("oidc") => {
if let Some(bundle) = load_oidc_token(gateway_name) {
if is_token_expired(&bundle) {
match oidc_refresh_token(&bundle, tls_opts.gateway_insecure).await {
match oidc_refresh_token(
&bundle,
meta.oidc_scopes.as_deref(),
tls_opts.gateway_insecure,
)
.await
{
Ok(refreshed) => {
let _ = store_oidc_token(gateway_name, &refreshed);
tls_opts.oidc_token = Some(refreshed.access_token);
Expand Down
55 changes: 47 additions & 8 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,19 +157,30 @@ fn resolve_gateway_name(gateway_flag: &Option<String>) -> Option<String> {
/// and setting it on `TlsOptions`. For OIDC, automatically refreshes the token
/// if it's near expiry.
fn apply_auth(tls: &mut TlsOptions, gateway_name: &str) {
let Some(meta) = get_gateway_metadata(gateway_name) else {
return;
};
let _ = apply_auth_with_status(tls, gateway_name);
}

/// Apply stored authentication and return a user-facing preparation failure,
/// if the CLI already knows the credentials cannot be used.
fn apply_auth_with_status(tls: &mut TlsOptions, gateway_name: &str) -> Option<String> {
let meta = get_gateway_metadata(gateway_name)?;
match meta.auth_mode.as_deref() {
Some("cloudflare_jwt") => {
if let Some(token) = load_edge_token(gateway_name) {
tls.edge_token = Some(token);
None
} else {
Some(format!(
"edge credentials are missing; run `openshell gateway login {gateway_name}`"
))
}
}
Some("oidc") => {
let Some(bundle) = openshell_bootstrap::oidc_token::load_oidc_token(gateway_name)
else {
return;
return Some(format!(
"OIDC credentials are missing; run `openshell gateway login {gateway_name}`"
));
};
if openshell_bootstrap::oidc_token::is_token_expired(&bundle) {
let insecure = std::env::var("OPENSHELL_GATEWAY_INSECURE")
Expand All @@ -178,7 +189,11 @@ fn apply_auth(tls: &mut TlsOptions, gateway_name: &str) {
// so the async refresh can run within the sync apply_auth call.
match tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(
openshell_cli::oidc_auth::oidc_refresh_token(&bundle, insecure),
openshell_cli::oidc_auth::oidc_refresh_token(
&bundle,
meta.oidc_scopes.as_deref(),
insecure,
),
)
}) {
Ok(refreshed) => {
Expand All @@ -187,19 +202,24 @@ fn apply_auth(tls: &mut TlsOptions, gateway_name: &str) {
&refreshed,
);
tls.oidc_token = Some(refreshed.access_token);
None
}
Err(e) => {
tracing::warn!("OIDC token refresh failed: {e}");
// Use the expired token anyway — server will reject it
// with a clear error prompting re-login.
tls.oidc_token = Some(bundle.access_token);
Some(format!(
"OIDC token refresh failed; run `openshell gateway login {gateway_name}`"
))
}
}
} else {
tls.oidc_token = Some(bundle.access_token);
None
}
}
_ => {}
_ => None,
}
}

Expand Down Expand Up @@ -2253,8 +2273,8 @@ async fn main() -> Result<()> {
Some(Commands::Status) => {
if let Ok(ctx) = resolve_gateway(&cli.gateway, &cli.gateway_endpoint) {
let mut tls = tls.with_gateway_name(&ctx.name);
apply_auth(&mut tls, &ctx.name);
run::gateway_status(&ctx.name, &ctx.endpoint, &tls).await?;
let auth_error = apply_auth_with_status(&mut tls, &ctx.name);
run::gateway_status(&ctx.name, &ctx.endpoint, &tls, auth_error.as_deref()).await?;
} else {
println!("{}", "Gateway Status".cyan().bold());
println!();
Expand Down Expand Up @@ -4150,6 +4170,25 @@ mod tests {
});
}

#[test]
fn apply_auth_reports_missing_edge_credentials() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
store_gateway_metadata(
"edge-gateway",
&edge_metadata("edge-gateway", "https://gw.example.com"),
)
.unwrap();

let mut tls = TlsOptions::default();
let error = apply_auth_with_status(&mut tls, "edge-gateway")
.expect("missing credentials should be reported");

assert!(error.contains("edge credentials are missing"));
assert!(!tls.is_bearer_auth());
});
}

/// Verify the flag names the TUI uses to build its `ProxyCommand` are
/// accepted by the `SshProxy` subcommand and land in the right fields.
/// This catches drift when CLI flags are renamed or restructured.
Expand Down
75 changes: 58 additions & 17 deletions crates/openshell-cli/src/oidc_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +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 openshell_sdk::oidc::RefreshTokenInput;
use serde::Deserialize;
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
Expand Down Expand Up @@ -227,10 +228,14 @@ pub async fn oidc_client_credentials_flow(

/// Refresh an OIDC token using the `refresh_token` grant.
///
/// Reuses the configured login scopes when supplied so providers can select
/// the same API resource for the refreshed access token.
///
/// 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`).
pub async fn oidc_refresh_token(
bundle: &OidcTokenBundle,
scopes: Option<&str>,
insecure: bool,
) -> Result<OidcTokenBundle> {
let refresh_token = bundle.refresh_token.as_deref().ok_or_else(|| {
Expand All @@ -239,24 +244,37 @@ pub async fn oidc_refresh_token(
)
})?;

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)
let scopes = scopes.map_or_else(Vec::new, |scopes| {
scopes.split_whitespace().map(str::to_owned).collect()
});
let input = RefreshTokenInput::new(refresh_token, &bundle.issuer, &bundle.client_id)
.with_scopes(scopes)
.with_insecure(insecure);
let refreshed = openshell_sdk::oidc::refresh_token(&input)
.await
.map_err(|e| miette::miette!("token refresh failed: {e}"))?;
.map_err(miette::Report::new)?;

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(bundle_from_refresh_output(
bundle,
refreshed.access_token,
refreshed.refresh_token,
refreshed.expires_at,
))
}

fn bundle_from_refresh_output(
previous: &OidcTokenBundle,
access_token: String,
refresh_token: Option<String>,
expires_at: Option<u64>,
) -> OidcTokenBundle {
OidcTokenBundle {
access_token,
refresh_token: refresh_token.or_else(|| previous.refresh_token.clone()),
expires_at,
issuer: previous.issuer.clone(),
client_id: previous.client_id.clone(),
}
Ok(refreshed)
}

/// Ensure we have a valid OIDC token for the given gateway, refreshing if needed.
Expand All @@ -279,7 +297,10 @@ pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Resu
gateway = gateway_name,
"OIDC token expired, attempting refresh"
);
let refreshed = oidc_refresh_token(&bundle, insecure).await?;
let scopes = openshell_bootstrap::load_gateway_metadata(gateway_name)
.ok()
.and_then(|metadata| metadata.oidc_scopes);
let refreshed = oidc_refresh_token(&bundle, scopes.as_deref(), insecure).await?;
openshell_bootstrap::oidc_token::store_oidc_token(gateway_name, &refreshed)?;
Ok(refreshed.access_token)
}
Expand Down Expand Up @@ -531,4 +552,24 @@ mod tests {
assert_eq!(bundle.client_id, "my-client");
assert!(bundle.expires_at.is_some());
}

#[test]
fn refresh_output_preserves_omitted_refresh_token() {
let previous = OidcTokenBundle {
access_token: "expired-access".to_string(),
refresh_token: Some("refresh-secret".to_string()),
expires_at: Some(0),
issuer: "https://issuer.example".to_string(),
client_id: "client-id".to_string(),
};

let refreshed =
bundle_from_refresh_output(&previous, "refreshed-access".to_string(), None, Some(300));

assert_eq!(refreshed.access_token, "refreshed-access");
assert_eq!(refreshed.refresh_token, previous.refresh_token);
assert_eq!(refreshed.issuer, previous.issuer);
assert_eq!(refreshed.client_id, previous.client_id);
assert_eq!(refreshed.expires_at, Some(300));
}
}
Loading
Loading