diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index a97b6a7af2..a36a372df7 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -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 @@ -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 status` | | Create sandbox (interactive) | `openshell sandbox create` | diff --git a/.agents/skills/openshell-cli/cli-reference.md b/.agents/skills/openshell-cli/cli-reference.md index 1135fe630d..8095660817 100644 --- a/.agents/skills/openshell-cli/cli-reference.md +++ b/.agents/skills/openshell-cli/cli-reference.md @@ -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. --- diff --git a/Cargo.lock b/Cargo.lock index 268da3982d..21c81cc945 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3855,6 +3855,7 @@ dependencies = [ "openshell-core", "openshell-policy", "openshell-providers", + "openshell-sdk", "openshell-tui", "owo-colors", "prost-types", @@ -4143,6 +4144,7 @@ dependencies = [ "reqwest 0.12.28", "rustls 0.23.38", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "tokio-rustls 0.26.4", @@ -4151,6 +4153,7 @@ dependencies = [ "tonic", "tower 0.5.3", "tracing", + "wiremock", ] [[package]] diff --git a/architecture/gateway.md b/architecture/gateway.md index cca6599281..c8b323ea13 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -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 diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index 1cbfd7d474..d7b8fd502c 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -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 } diff --git a/crates/openshell-cli/src/completers.rs b/crates/openshell-cli/src/completers.rs index 572d601eaa..9d3b88d594 100644 --- a/crates/openshell-cli/src/completers.rs +++ b/crates/openshell-cli/src/completers.rs @@ -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); diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index df4e983e5b..a44b0eafa8 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -157,19 +157,30 @@ fn resolve_gateway_name(gateway_flag: &Option) -> Option { /// 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 { + 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") @@ -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) => { @@ -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, } } @@ -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!(); @@ -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. diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs index 379a531127..2aacdb0c9c 100644 --- a/crates/openshell-cli/src/oidc_auth.rs +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -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}; @@ -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 { let refresh_token = bundle.refresh_token.as_deref().ok_or_else(|| { @@ -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, + expires_at: Option, +) -> 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. @@ -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) } @@ -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)); + } } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index f9a7035226..a2ad725a4c 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -124,14 +124,16 @@ struct ComputeDriverCapabilitiesView { /// Show gateway status. #[allow(clippy::branches_sharing_code)] -pub async fn gateway_status(gateway_name: &str, server: &str, tls: &TlsOptions) -> Result<()> { +pub async fn gateway_status( + gateway_name: &str, + server: &str, + tls: &TlsOptions, + auth_preparation_error: Option<&str>, +) -> Result<()> { println!("{}", "Server Status".cyan().bold()); println!(); println!(" {} {}", "Gateway:".dimmed(), gateway_name); println!(" {} {}", "Server:".dimmed(), server); - if tls.is_bearer_auth() { - println!(" {} Edge (bearer token)", "Auth:".dimmed()); - } // Try to connect and get health match grpc_client(server, tls).await { @@ -139,9 +141,29 @@ pub async fn gateway_status(gateway_name: &str, server: &str, tls: &TlsOptions) Ok(response) => { let health = response.into_inner(); println!(" {} {}", "Status:".dimmed(), "Connected".green()); + let authentication = match auth_preparation_error { + Some(error) => GatewayAuthenticationState::Failed(error.to_string()), + None => gateway_authentication_state( + client + .get_gateway_info(GetGatewayInfoRequest {}) + .await + .map(|_| ()), + tls, + server, + ), + }; + print_gateway_authentication_state(&authentication); println!(" {} {}", "Version:".dimmed(), health.version); } Err(e) => { + let authentication = auth_preparation_error.map_or_else( + || { + GatewayAuthenticationState::Unverified( + "gRPC health check failed".to_string(), + ) + }, + |error| GatewayAuthenticationState::Failed(error.to_string()), + ); if let Some(status) = http_health_check(server, tls).await? { if status.is_success() { println!(" {} {}", "Status:".dimmed(), "Connected (HTTP)".yellow()); @@ -156,9 +178,14 @@ pub async fn gateway_status(gateway_name: &str, server: &str, tls: &TlsOptions) println!(" {} {}", "Status:".dimmed(), "Error".red()); println!(" {} {}", "Error:".dimmed(), e); } + print_gateway_authentication_state(&authentication); } }, Err(e) => { + let authentication = auth_preparation_error.map_or_else( + || GatewayAuthenticationState::Unverified("gateway unreachable".to_string()), + |error| GatewayAuthenticationState::Failed(error.to_string()), + ); if let Some(status) = http_health_check(server, tls).await? { if status.is_success() { println!(" {} {}", "Status:".dimmed(), "Connected (HTTP)".yellow()); @@ -173,12 +200,99 @@ pub async fn gateway_status(gateway_name: &str, server: &str, tls: &TlsOptions) println!(" {} {}", "Status:".dimmed(), "Disconnected".red()); println!(" {} {}", "Error:".dimmed(), e); } + print_gateway_authentication_state(&authentication); } } Ok(()) } +#[derive(Debug, Clone, PartialEq, Eq)] +enum GatewayAuthenticationState { + Authenticated(&'static str), + NotRequired(&'static str), + Failed(String), + Unverified(String), +} + +fn gateway_authentication_state( + result: std::result::Result<(), Status>, + tls: &TlsOptions, + server: &str, +) -> GatewayAuthenticationState { + match result { + Ok(()) if tls.oidc_token.is_some() => GatewayAuthenticationState::Authenticated("OIDC"), + Ok(()) if tls.edge_token.is_some() => GatewayAuthenticationState::Authenticated("edge"), + Ok(()) if server.starts_with("https://") => { + GatewayAuthenticationState::Authenticated("mTLS transport") + } + Ok(()) => GatewayAuthenticationState::NotRequired("gateway"), + Err(status) if status.code() == Code::Unauthenticated => { + GatewayAuthenticationState::Failed(status.message().to_string()) + } + Err(status) if status.code() == Code::PermissionDenied => { + let provider = if tls.oidc_token.is_some() { + "OIDC; authorization denied" + } else if tls.edge_token.is_some() { + "edge; authorization denied" + } else if server.starts_with("https://") { + "mTLS transport; authorization denied" + } else { + "authorization denied" + }; + GatewayAuthenticationState::Authenticated(provider) + } + Err(status) if status.code() == Code::Unimplemented && tls.edge_token.is_some() => { + GatewayAuthenticationState::Authenticated("edge") + } + Err(status) + if status.code() == Code::Unimplemented + && !tls.is_bearer_auth() + && server.starts_with("http://") => + { + GatewayAuthenticationState::NotRequired("gateway") + } + Err(status) + if status.code() == Code::Unimplemented + && !tls.is_bearer_auth() + && server.starts_with("https://") => + { + GatewayAuthenticationState::Authenticated("mTLS transport") + } + Err(status) if status.code() == Code::Unimplemented => { + GatewayAuthenticationState::Unverified( + "gateway does not support authentication checks".to_string(), + ) + } + Err(status) => GatewayAuthenticationState::Unverified(status.message().to_string()), + } +} + +fn print_gateway_authentication_state(state: &GatewayAuthenticationState) { + match state { + GatewayAuthenticationState::Authenticated(provider) => println!( + " {} {} ({provider})", + "Authentication:".dimmed(), + "Authenticated".green() + ), + GatewayAuthenticationState::NotRequired(mode) => println!( + " {} {} ({mode})", + "Authentication:".dimmed(), + "Not required".green() + ), + GatewayAuthenticationState::Failed(error) => println!( + " {} {} ({error})", + "Authentication:".dimmed(), + "Failed".red() + ), + GatewayAuthenticationState::Unverified(reason) => println!( + " {} {} ({reason})", + "Authentication:".dimmed(), + "Unverified".yellow() + ), + } +} + fn gateway_service_status_name(status: i32) -> &'static str { match ServiceStatus::try_from(status) { Ok(ServiceStatus::Healthy) => "healthy", @@ -7993,11 +8107,12 @@ fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String #[cfg(test)] mod tests { use super::{ - ComputeDriverCapabilitiesView, ComputeDriverInfoView, GatewayInfoView, PolicyGetView, - ProvisioningStep, TlsOptions, build_sandbox_resource_limits, - dockerfile_sources_supported_for_gateway, format_endpoint, format_gateway_select_header, - format_gateway_select_items, format_provider_attachment_table, gateway_add, - gateway_auth_label, gateway_env_override_warning, gateway_info_to_json, + ComputeDriverCapabilitiesView, ComputeDriverInfoView, GatewayAuthenticationState, + GatewayInfoView, PolicyGetView, ProvisioningStep, TlsOptions, + build_sandbox_resource_limits, dockerfile_sources_supported_for_gateway, format_endpoint, + format_gateway_select_header, format_gateway_select_items, + format_provider_attachment_table, gateway_add, gateway_auth_label, + gateway_authentication_state, gateway_env_override_warning, gateway_info_to_json, gateway_remote_label, gateway_select_with, gateway_to_json, gateway_type_label, git_sync_files, http_health_check, import_local_package_mtls_bundle, inferred_provider_type, mtls_certs_exist_for_gateway, package_managed_tls_dirs, @@ -8036,6 +8151,77 @@ mod tests { datamodel::v1::ObjectMeta, }; + #[test] + fn gateway_status_reports_authenticated_oidc_probe() { + let mut tls = TlsOptions::default(); + tls.oidc_token = Some("token".to_string()); + + let state = gateway_authentication_state(Ok(()), &tls, "https://gateway.example.com"); + + assert_eq!(state, GatewayAuthenticationState::Authenticated("OIDC")); + } + + #[test] + fn gateway_status_treats_authorization_denial_as_authenticated() { + let mut tls = TlsOptions::default(); + tls.oidc_token = Some("token".to_string()); + + let state = gateway_authentication_state( + Err(Status::permission_denied("role 'openshell-admin' required")), + &tls, + "https://gateway.example.com", + ); + + assert_eq!( + state, + GatewayAuthenticationState::Authenticated("OIDC; authorization denied") + ); + } + + #[test] + fn gateway_status_reports_rejected_bearer_token() { + let mut tls = TlsOptions::default(); + tls.oidc_token = Some("expired".to_string()); + + let state = gateway_authentication_state( + Err(Status::unauthenticated("invalid token: ExpiredSignature")), + &tls, + "https://gateway.example.com", + ); + + assert_eq!( + state, + GatewayAuthenticationState::Failed("invalid token: ExpiredSignature".to_string()) + ); + } + + #[test] + fn gateway_status_marks_oidc_unverified_on_older_gateway() { + let mut tls = TlsOptions::default(); + tls.oidc_token = Some("token".to_string()); + + let state = gateway_authentication_state( + Err(Status::unimplemented("unknown service")), + &tls, + "https://gateway.example.com", + ); + + assert_eq!( + state, + GatewayAuthenticationState::Unverified( + "gateway does not support authentication checks".to_string() + ) + ); + } + + #[test] + fn gateway_status_reports_local_plaintext_auth_not_required() { + let state = + gateway_authentication_state(Ok(()), &TlsOptions::default(), "http://127.0.0.1:8080"); + + assert_eq!(state, GatewayAuthenticationState::NotRequired("gateway")); + } + #[test] fn policy_revision_json_includes_revision_provenance() { let revision = SandboxPolicyRevision { diff --git a/crates/openshell-sdk/Cargo.toml b/crates/openshell-sdk/Cargo.toml index 364cfd8d56..a1016baec0 100644 --- a/crates/openshell-sdk/Cargo.toml +++ b/crates/openshell-sdk/Cargo.toml @@ -30,8 +30,10 @@ tower = { workspace = true } tracing = { workspace = true } [dev-dependencies] +serde_json = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tokio-stream = { workspace = true } +wiremock = "0.6" [lints] workspace = true diff --git a/crates/openshell-sdk/README.md b/crates/openshell-sdk/README.md index c7039fe9cb..93afc5359e 100644 --- a/crates/openshell-sdk/README.md +++ b/crates/openshell-sdk/README.md @@ -26,7 +26,9 @@ client bound to the current token. When a refresher is wired, use `force_refresh` to recover after a raw RPC returns `Unauthenticated`. The SDK consumes a `Refresh` trait that the caller implements; it does not run -the OIDC browser flow itself. +the OIDC browser flow itself. Its non-interactive refresh-token exchange also +accepts scopes that identity providers may require to select the API resource +for the refreshed access token. ## Transport modes diff --git a/crates/openshell-sdk/src/config.rs b/crates/openshell-sdk/src/config.rs index 94cfc28c0b..27a7411734 100644 --- a/crates/openshell-sdk/src/config.rs +++ b/crates/openshell-sdk/src/config.rs @@ -4,8 +4,9 @@ //! Public input types for the SDK: how callers describe a gateway and the //! credentials used to talk to it. //! -//! The CLI keeps its own filesystem-aware `TlsOptions` for plumbing; it -//! converts to a `ClientConfig` at the moment of dialing the gateway. +//! The CLI keeps its own filesystem-aware `TlsOptions` for transport plumbing +//! and reuses the SDK's OIDC refresh primitive. Other consumers describe the +//! complete transport with [`ClientConfig`]. use crate::refresh::Refresh; use std::sync::Arc; diff --git a/crates/openshell-sdk/src/oidc.rs b/crates/openshell-sdk/src/oidc.rs index 3f9366ad5e..3f59c25cd0 100644 --- a/crates/openshell-sdk/src/oidc.rs +++ b/crates/openshell-sdk/src/oidc.rs @@ -8,7 +8,7 @@ use crate::error::{Result, SdkError}; use oauth2::basic::BasicClient; -use oauth2::{ClientId, RefreshToken, TokenResponse, TokenUrl}; +use oauth2::{ClientId, RefreshToken, Scope, TokenResponse, TokenUrl}; use serde::Deserialize; /// OIDC discovery document (subset of fields callers consume). @@ -30,6 +30,9 @@ pub struct RefreshTokenInput { pub refresh_token: String, pub issuer: String, pub client_id: String, + /// Scopes to resend with the refresh request. Some identity providers use + /// these to select the API resource for the refreshed access token. + pub scopes: Vec, pub insecure: bool, } @@ -39,6 +42,7 @@ impl std::fmt::Debug for RefreshTokenInput { f.debug_struct("RefreshTokenInput") .field("issuer", &self.issuer) .field("client_id", &self.client_id) + .field("scopes", &self.scopes) .field("insecure", &self.insecure) .finish_non_exhaustive() } @@ -54,10 +58,22 @@ impl RefreshTokenInput { refresh_token: refresh_token.into(), issuer: issuer.into(), client_id: client_id.into(), + scopes: Vec::new(), insecure: false, } } + /// Set the scopes to resend with the refresh-token grant. + #[must_use] + pub fn with_scopes(mut self, scopes: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.scopes = scopes.into_iter().map(Into::into).collect(); + self + } + #[must_use] pub fn with_insecure(mut self, insecure: bool) -> Self { self.insecure = insecure; @@ -132,9 +148,11 @@ pub fn http_client(insecure: bool) -> reqwest::Client { /// Refresh an OIDC access token using the `refresh_token` grant. /// -/// The caller is responsible for preserving the prior refresh token when -/// the output's `refresh_token` is `None` — per OAuth 2.0 the server may -/// omit it from the refresh response. +/// The request resends any configured scopes so providers that use scopes to +/// select an API resource mint the correct access token. The caller is +/// responsible for preserving the prior refresh token when the output's +/// `refresh_token` is `None` — per OAuth 2.0 the server may omit it from the +/// refresh response. pub async fn refresh_token(input: &RefreshTokenInput) -> Result { let discovery = discover(&input.issuer, input.insecure).await?; @@ -143,9 +161,14 @@ pub async fn refresh_token(input: &RefreshTokenInput) -> Result Refr #[cfg(test)] mod tests { use super::*; + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; #[test] fn debug_redacts_tokens() { - let input = RefreshTokenInput::new("refresh-secret", "https://idp", "cli"); + let input = RefreshTokenInput::new("refresh-secret", "https://idp", "cli") + .with_scopes(["openid", "api://cli/access_as_user"]); let rendered = format!("{input:?}"); assert!(!rendered.contains("refresh-secret")); assert!(rendered.contains("cli")); @@ -187,4 +213,44 @@ mod tests { assert!(!rendered.contains("refresh-secret")); assert!(rendered.contains("has_refresh_token")); } + + #[tokio::test] + async fn refresh_sends_configured_scopes() { + let server = MockServer::start().await; + let issuer = server.uri(); + + Mock::given(method("GET")) + .and(path("/.well-known/openid-configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "issuer": issuer, + "authorization_endpoint": format!("{}/authorize", server.uri()), + "token_endpoint": format!("{}/token", server.uri()), + }))) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("POST")) + .and(path("/token")) + .and(body_string_contains("scope=")) + .and(body_string_contains("access_as_user")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "refreshed-access", + "token_type": "bearer", + "expires_in": 300, + }))) + .expect(1) + .mount(&server) + .await; + + let input = RefreshTokenInput::new("refresh-secret", &issuer, "client-id") + .with_scopes(["openid", "api://client-id/access_as_user"]); + let refreshed = refresh_token(&input) + .await + .expect("configured scopes should be sent on refresh"); + + assert_eq!(refreshed.access_token, "refreshed-access"); + assert!(refreshed.refresh_token.is_none()); + assert!(refreshed.expires_at.is_some()); + } } diff --git a/docs/reference/gateway-auth.mdx b/docs/reference/gateway-auth.mdx index 94aca659ee..b278f0f403 100644 --- a/docs/reference/gateway-auth.mdx +++ b/docs/reference/gateway-auth.mdx @@ -20,6 +20,12 @@ When any CLI command needs to talk to the gateway, it resolves the target throug The CLI loads gateway metadata from `~/.config/openshell/gateways//metadata.json` first and falls back to `/etc/openshell/gateways//metadata.json` when no user entry exists. +## Authentication Status + +`openshell status` reports gateway reachability and authentication separately. The public health RPC determines whether the gateway is connected and supplies its version. The CLI then calls the existing protected gateway-info capability query to verify the configured credentials. An authorization denial still confirms that the gateway authenticated the caller. + +For example, an expired bearer token can produce `Status: Connected` and `Authentication: Failed`. This means the gateway is healthy but protected commands cannot use the current credentials. Run `openshell gateway login ` to re-authenticate. When a gateway predates the gateway-info capability query, the CLI reports `Authentication: Unverified` rather than treating a successful public health check as proof of authentication. + ## Authentication Modes The CLI uses one of these authentication modes depending on the gateway's configuration. @@ -119,7 +125,7 @@ When you register or log in to an OIDC gateway, the CLI uses the Authorization C The connection flow: 1. The CLI loads the stored OIDC token bundle. -2. If the access token is expired and a refresh token is available, the CLI refreshes it. +2. If the access token is expired and a refresh token is available, the CLI refreshes it with the OIDC scopes saved in the gateway metadata. 3. The CLI connects to the gateway and attaches `authorization: Bearer ` metadata to each gRPC request. 4. The gateway validates the JWT signature, issuer, audience, expiration, and key ID against the issuer's JWKS. 5. The gateway extracts roles and optional scopes from the configured claim paths. diff --git a/docs/sandboxes/manage-gateways.mdx b/docs/sandboxes/manage-gateways.mdx index 443a65a750..91cfdfad28 100644 --- a/docs/sandboxes/manage-gateways.mdx +++ b/docs/sandboxes/manage-gateways.mdx @@ -113,13 +113,19 @@ openshell status -g staging ## Inspect Gateway Status -Use `openshell status` for a live gateway check. It verifies that the CLI can -reach the running gateway, then reports health and the gateway version: +Use `openshell status` for a live gateway check. It reports gateway +reachability, authentication, and the gateway version independently: ```shell openshell status ``` +`Status: Connected` means the public health endpoint responded. +`Authentication: Authenticated` confirms the configured credentials also +passed the gateway authentication layer. If authentication fails while status +remains connected, run `openshell gateway login ` to refresh the stored +credentials. + Use `openshell gateway info` when you need elevated runtime details such as initialized compute drivers and driver-reported capability versions: