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 Cargo.lock

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

33 changes: 33 additions & 0 deletions crates/openshell-bootstrap/src/oidc_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ pub struct OidcTokenBundle {

/// OIDC client ID used to obtain the token.
pub client_id: String,

/// Whether the token came from the `client_credentials` grant, so it can
/// be renewed by repeating that grant. Absent in older bundles.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub client_credentials: bool,
}

/// Path to the stored OIDC token bundle for a gateway.
Expand Down Expand Up @@ -171,13 +176,39 @@ mod tests {
expires_at: None,
issuer: "https://issuer.example.com".to_string(),
client_id: "openshell-cli".to_string(),
client_credentials: false,
};
assert!(store_oidc_token("../escape", &bundle).is_err());
assert!(load_oidc_token("../escape").is_none());
assert!(remove_oidc_token("../escape").is_err());
});
}

#[test]
fn client_credentials_marker_is_optional_on_disk() {
let old: OidcTokenBundle = serde_json::from_str(
r#"{"access_token":"t","issuer":"https://issuer","client_id":"c"}"#,
)
.unwrap();
assert!(!old.client_credentials);
assert!(
!serde_json::to_string(&old)
.unwrap()
.contains("client_credentials")
);

let marked = OidcTokenBundle {
client_credentials: true,
..old
};
let json = serde_json::to_string(&marked).unwrap();
assert!(
serde_json::from_str::<OidcTokenBundle>(&json)
.unwrap()
.client_credentials
);
}

#[test]
fn expiry_checks_distinguish_refresh_window_from_actual_expiry() {
let now = std::time::SystemTime::now()
Expand All @@ -190,6 +221,7 @@ mod tests {
expires_at: Some(now + 10),
issuer: "https://issuer.example.com".to_string(),
client_id: "openshell-cli".to_string(),
client_credentials: false,
};

assert!(is_token_expired(&bundle));
Expand Down Expand Up @@ -220,6 +252,7 @@ mod tests {
expires_at: None,
issuer: "https://issuer.example.com".to_string(),
client_id: "openshell-cli".to_string(),
client_credentials: false,
};
store_oidc_token("alpha", &bundle).unwrap();

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 @@ -102,3 +102,4 @@ serde_json = { workspace = true }
temp-env = "0.3"
tokio-stream = { workspace = true }
url = { workspace = true }
wiremock = "0.6"
5 changes: 3 additions & 2 deletions crates/openshell-cli/src/completers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use openshell_core::proto::{ListProvidersRequest, ListSandboxesRequest, ListWork
use tonic::service::interceptor::InterceptedService;
use tonic::transport::Channel;

use crate::oidc_auth::oidc_refresh_token;
use crate::oidc_auth::oidc_renew_token;
use crate::tls::{TlsOptions, build_channel};

/// Complete gateway names from local metadata files (no network call).
Expand Down Expand Up @@ -142,8 +142,9 @@ 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(
match oidc_renew_token(
&bundle,
meta.oidc_audience.as_deref(),
meta.oidc_scopes.as_deref(),
tls_opts.gateway_insecure,
)
Expand Down
3 changes: 2 additions & 1 deletion crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,9 @@ fn apply_auth_with_status(tls: &mut TlsOptions, gateway_name: &str) -> Option<St
// 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(
openshell_cli::oidc_auth::oidc_renew_token(
&bundle,
meta.oidc_audience.as_deref(),
meta.oidc_scopes.as_deref(),
insecure,
),
Expand Down
160 changes: 147 additions & 13 deletions crates/openshell-cli/src/oidc_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ use tokio::sync::oneshot;
use tracing::debug;

const AUTH_TIMEOUT: Duration = Duration::from_mins(2);
const CLIENT_SECRET_ENV: &str = "OPENSHELL_OIDC_CLIENT_SECRET";

/// OIDC discovery document (subset of fields we need).
#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -264,17 +265,18 @@ pub async fn oidc_browser_auth_flow(

/// Run the OIDC Client Credentials flow (for CI/automation).
///
/// Reads `OPENSHELL_OIDC_CLIENT_SECRET` from the environment.
/// Reads `OPENSHELL_OIDC_CLIENT_SECRET` from the environment. The secret is
/// never stored; the returned bundle is marked so it can be renewed later.
pub async fn oidc_client_credentials_flow(
issuer: &str,
client_id: &str,
audience: Option<&str>,
scopes: Option<&str>,
insecure: bool,
) -> Result<OidcTokenBundle> {
let client_secret = std::env::var("OPENSHELL_OIDC_CLIENT_SECRET").map_err(|_| {
let client_secret = std::env::var(CLIENT_SECRET_ENV).map_err(|_| {
miette::miette!(
"OPENSHELL_OIDC_CLIENT_SECRET environment variable is required for client credentials flow"
"{CLIENT_SECRET_ENV} environment variable is required for client credentials flow"
)
})?;

Expand All @@ -299,11 +301,10 @@ pub async fn oidc_client_credentials_flow(
.await
.map_err(|e| miette::miette!("client credentials token exchange failed: {e}"))?;

Ok(bundle_from_oauth2_response(
&token_response,
issuer,
client_id,
))
Ok(OidcTokenBundle {
client_credentials: true,
..bundle_from_oauth2_response(&token_response, issuer, client_id)
})
}

/// Run the OIDC Device Authorization Grant flow (RFC 8628).
Expand Down Expand Up @@ -469,7 +470,7 @@ pub async fn oidc_device_code_flow(
///
/// 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(
async fn oidc_refresh_token(
bundle: &OidcTokenBundle,
scopes: Option<&str>,
insecure: bool,
Expand Down Expand Up @@ -510,9 +511,39 @@ fn bundle_from_refresh_output(
expires_at,
issuer: previous.issuer.clone(),
client_id: previous.client_id.clone(),
client_credentials: previous.client_credentials,
}
}

/// Renew a stored OIDC token.
///
/// Uses the refresh token when there is one. A client credentials token has
/// none, so it is renewed by repeating that grant, which needs
/// `OPENSHELL_OIDC_CLIENT_SECRET` in the environment.
pub async fn oidc_renew_token(
bundle: &OidcTokenBundle,
audience: Option<&str>,
scopes: Option<&str>,
insecure: bool,
) -> Result<OidcTokenBundle> {
if bundle.refresh_token.is_some() || !bundle.client_credentials {
return oidc_refresh_token(bundle, scopes, insecure).await;
}
if std::env::var_os(CLIENT_SECRET_ENV).is_none() {
return Err(miette::miette!(
"token was issued via client credentials; set {CLIENT_SECRET_ENV} to renew it, or run: openshell gateway login"
));
}
oidc_client_credentials_flow(
&bundle.issuer,
&bundle.client_id,
audience,
scopes,
insecure,
)
.await
}

/// Ensure we have a valid OIDC token bundle for the given gateway, refreshing if needed.
pub async fn ensure_valid_oidc_token_bundle(
gateway_name: &str,
Expand All @@ -534,10 +565,14 @@ pub async fn ensure_valid_oidc_token_bundle(
gateway = gateway_name,
"OIDC token expired, attempting refresh"
);
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?;
let metadata = openshell_bootstrap::load_gateway_metadata(gateway_name).ok();
let refreshed = oidc_renew_token(
&bundle,
metadata.as_ref().and_then(|m| m.oidc_audience.as_deref()),
metadata.as_ref().and_then(|m| m.oidc_scopes.as_deref()),
insecure,
)
.await?;
openshell_bootstrap::oidc_token::store_oidc_token(gateway_name, &refreshed)?;
Ok(refreshed)
}
Expand Down Expand Up @@ -569,6 +604,7 @@ fn bundle_from_oauth2_response(
expires_at: resp.expires_in().map(|ei| now + ei.as_secs()),
issuer: issuer.to_string(),
client_id: client_id.to_string(),
client_credentials: false,
}
}

Expand Down Expand Up @@ -730,6 +766,103 @@ fn html_response(status: StatusCode, message: &str) -> Response<Full<Bytes>> {
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn expired_bundle(issuer: &str, client_credentials: bool) -> OidcTokenBundle {
OidcTokenBundle {
access_token: "expired-access".to_string(),
refresh_token: None,
expires_at: Some(0),
issuer: issuer.to_string(),
client_id: "client-id".to_string(),
client_credentials,
}
}

#[test]
fn expired_client_credentials_bundle_renews_without_refresh_token() {
let rt = tokio::runtime::Runtime::new().unwrap();
let renewed = temp_env::with_var(CLIENT_SECRET_ENV, Some("s3cret"), || {
rt.block_on(async {
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!("{issuer}/authorize"),
"token_endpoint": format!("{issuer}/token"),
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/token"))
.and(body_string_contains("grant_type=client_credentials"))
.and(body_string_contains("client_id=client-id"))
.and(body_string_contains("client_secret=s3cret"))
.and(body_string_contains("audience=openshell-api"))
.and(body_string_contains("scope=custom"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "renewed-access",
"token_type": "bearer",
"expires_in": 300,
})))
.expect(1)
.mount(&server)
.await;

oidc_renew_token(
&expired_bundle(&issuer, true),
Some("openshell-api"),
Some("custom"),
false,
)
.await
.unwrap()
})
});

assert_eq!(renewed.access_token, "renewed-access");
assert!(renewed.client_credentials);
assert!(renewed.refresh_token.is_none());
assert!(renewed.expires_at.is_some_and(|at| at > 0));
assert!(!serde_json::to_string(&renewed).unwrap().contains("s3cret"));
}

#[test]
fn client_credentials_renewal_without_secret_names_the_secret() {
let rt = tokio::runtime::Runtime::new().unwrap();
let err = temp_env::with_var_unset(CLIENT_SECRET_ENV, || {
rt.block_on(oidc_renew_token(
&expired_bundle("https://issuer.example", true),
None,
None,
false,
))
.unwrap_err()
});

let message = err.to_string();
assert!(message.contains(CLIENT_SECRET_ENV), "{message}");
assert!(!message.contains("no refresh token"), "{message}");
}

#[test]
fn interactive_bundle_without_refresh_token_keeps_refresh_error() {
let rt = tokio::runtime::Runtime::new().unwrap();
let err = temp_env::with_var(CLIENT_SECRET_ENV, Some("s3cret"), || {
rt.block_on(oidc_renew_token(
&expired_bundle("https://issuer.example", false),
None,
None,
false,
))
.unwrap_err()
});

assert!(err.to_string().contains("no refresh token"), "{err}");
}

#[test]
fn http_client_secure_rejects_self_signed() {
Expand Down Expand Up @@ -897,6 +1030,7 @@ mod tests {
expires_at: Some(0),
issuer: "https://issuer.example".to_string(),
client_id: "client-id".to_string(),
client_credentials: false,
};

let refreshed =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3468,6 +3468,7 @@ fn write_oidc_test_credentials(
expires_at: Some(expires_at),
issuer: issuer.to_string(),
client_id: "openshell-cli".to_string(),
client_credentials: false,
})
.unwrap(),
)
Expand Down
2 changes: 1 addition & 1 deletion docs/how-it-works/gateways/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ openshell gateway add https://gateway.example.com \
--oidc-audience openshell-cli
```

When you register or log in to an OIDC gateway, the CLI uses the Authorization Code flow with PKCE. It opens a browser, receives the authorization code on a localhost callback, exchanges the code for tokens, and stores the token bundle under the gateway credential directory. After `openshell gateway logout`, the next browser login asks the identity provider for a fresh login prompt so you can choose a different browser user instead of silently reusing the previous session. If `OPENSHELL_OIDC_CLIENT_SECRET` is set, the CLI uses the client credentials flow instead. Use that mode for CI and other non-interactive automation.
When you register or log in to an OIDC gateway, the CLI uses the Authorization Code flow with PKCE. It opens a browser, receives the authorization code on a localhost callback, exchanges the code for tokens, and stores the token bundle under the gateway credential directory. After `openshell gateway logout`, the next browser login asks the identity provider for a fresh login prompt so you can choose a different browser user instead of silently reusing the previous session. If `OPENSHELL_OIDC_CLIENT_SECRET` is set, the CLI uses the client credentials flow instead. Use that mode for CI and other non-interactive automation. The CLI renews an expired client credentials token by repeating the grant, so keep the variable set for as long as the commands run.

Official Python, TypeScript, and Go SDKs can perform renewable client-credentials
authentication directly. Configure the service account at the identity provider
Expand Down
Loading