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
65 changes: 65 additions & 0 deletions crates/openshell-bootstrap/src/oidc_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ pub fn oidc_token_path(gateway_name: &str) -> Result<PathBuf> {
Ok(user_gateway_dir(gateway_name)?.join("oidc_token.json"))
}

/// Path to the one-shot marker that asks the next browser login to prompt.
pub fn oidc_login_prompt_required_path(gateway_name: &str) -> Result<PathBuf> {
Ok(user_gateway_dir(gateway_name)?.join("oidc_login_prompt_required"))
}

/// Store an OIDC token bundle for a gateway.
pub fn store_oidc_token(gateway_name: &str, bundle: &OidcTokenBundle) -> Result<()> {
let path = oidc_token_path(gateway_name)?;
Expand All @@ -50,6 +55,7 @@ pub fn store_oidc_token(gateway_name: &str, bundle: &OidcTokenBundle) -> Result<
.into_diagnostic()
.wrap_err_with(|| format!("failed to write OIDC token to {}", path.display()))?;
set_file_owner_only(&path)?;
clear_oidc_login_prompt(gateway_name)?;
Ok(())
}

Expand All @@ -76,6 +82,33 @@ pub fn remove_oidc_token(gateway_name: &str) -> Result<()> {
Ok(())
}

/// Mark the next interactive OIDC login as requiring a fresh `IdP` prompt.
pub fn request_oidc_login_prompt(gateway_name: &str) -> Result<()> {
let path = oidc_login_prompt_required_path(gateway_name)?;
ensure_parent_dir_restricted(&path)?;
std::fs::write(&path, b"1\n")
.into_diagnostic()
.wrap_err_with(|| format!("failed to write {}", path.display()))?;
set_file_owner_only(&path)?;
Ok(())
}

/// Return whether the next interactive OIDC login should request a fresh prompt.
pub fn oidc_login_prompt_required(gateway_name: &str) -> bool {
oidc_login_prompt_required_path(gateway_name).is_ok_and(|path| path.exists())
}

/// Clear the one-shot fresh-login marker for a gateway.
pub fn clear_oidc_login_prompt(gateway_name: &str) -> Result<()> {
let path = oidc_login_prompt_required_path(gateway_name)?;
if path.exists() {
std::fs::remove_file(&path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to remove {}", path.display()))?;
}
Ok(())
}

/// Check if the stored access token is expired or near expiry.
///
/// Returns `true` if the token expires within the next 30 seconds.
Expand Down Expand Up @@ -129,4 +162,36 @@ mod tests {
assert!(remove_oidc_token("../escape").is_err());
});
}

#[test]
fn oidc_login_prompt_marker_is_per_gateway() {
let tmp = tempfile::tempdir().unwrap();
with_tmp_xdg(tmp.path(), || {
assert!(!oidc_login_prompt_required("alpha"));
assert!(!oidc_login_prompt_required("beta"));

request_oidc_login_prompt("alpha").unwrap();

assert!(oidc_login_prompt_required("alpha"));
assert!(!oidc_login_prompt_required("beta"));

let bundle = OidcTokenBundle {
access_token: "token".to_string(),
refresh_token: None,
expires_at: None,
issuer: "https://issuer.example.com".to_string(),
client_id: "openshell-cli".to_string(),
};
store_oidc_token("alpha", &bundle).unwrap();

assert!(!oidc_login_prompt_required("alpha"));

request_oidc_login_prompt("alpha").unwrap();
assert!(oidc_login_prompt_required("alpha"));

clear_oidc_login_prompt("alpha").unwrap();

assert!(!oidc_login_prompt_required("alpha"));
});
}
}
9 changes: 9 additions & 0 deletions crates/openshell-cli/src/commands/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,7 @@ pub async fn gateway_add(
oidc_audience,
oidc_scopes,
gateway_insecure,
false,
)
.await
{
Expand Down Expand Up @@ -1116,6 +1117,8 @@ pub async fn gateway_login(name: &str, gateway_insecure: bool) -> Result<()> {
.unwrap_or("openshell-cli");
let audience = metadata.oidc_audience.as_deref();
let scopes = metadata.oidc_scopes.as_deref();
let force_fresh_login =
openshell_bootstrap::oidc_token::oidc_login_prompt_required(name);

let bundle = if std::env::var("OPENSHELL_OIDC_CLIENT_SECRET").is_ok() {
crate::oidc_auth::oidc_client_credentials_flow(
Expand All @@ -1133,12 +1136,14 @@ pub async fn gateway_login(name: &str, gateway_insecure: bool) -> Result<()> {
audience,
scopes,
gateway_insecure,
force_fresh_login,
)
.await?
};

let username = jwt_preferred_username(&bundle.access_token);
openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?;
openshell_bootstrap::oidc_token::clear_oidc_login_prompt(name)?;

if let Some(user) = username {
eprintln!(
Expand Down Expand Up @@ -1184,6 +1189,7 @@ pub fn gateway_logout(name: &str) -> Result<()> {
match metadata.auth_mode.as_deref() {
Some("oidc") => {
openshell_bootstrap::oidc_token::remove_oidc_token(name)?;
openshell_bootstrap::oidc_token::request_oidc_login_prompt(name)?;
}
Some("cloudflare_jwt") => {
openshell_bootstrap::edge_token::remove_edge_token(name)?;
Expand Down Expand Up @@ -1430,6 +1436,9 @@ fn remove_gateway_registration(name: &str) {
if let Err(err) = openshell_bootstrap::oidc_token::remove_oidc_token(name) {
tracing::debug!("failed to remove oidc token: {err}");
}
if let Err(err) = openshell_bootstrap::oidc_token::clear_oidc_login_prompt(name) {
tracing::debug!("failed to clear oidc login prompt marker: {err}");
}
if let Err(err) = remove_gateway_metadata(name) {
tracing::debug!("failed to remove gateway metadata: {err}");
}
Expand Down
7 changes: 5 additions & 2 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1172,7 +1172,8 @@ enum GatewayCommands {
/// Authenticate with an edge-authenticated or OIDC gateway.
///
/// Opens a browser for the edge proxy's login flow and stores the
/// token locally. Use this to re-authenticate when a token expires.
/// token locally. After `gateway logout`, OIDC browser login requests a
/// fresh identity-provider prompt so you can switch users.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Login {
/// Gateway name (defaults to the active gateway).
Expand All @@ -1183,7 +1184,9 @@ enum GatewayCommands {
/// Clear stored authentication credentials for a gateway.
///
/// Removes the locally stored OIDC token or edge token so subsequent
/// commands require re-authentication via `gateway login`.
/// commands require re-authentication via `gateway login`. For OIDC
/// gateways, the next browser login asks the identity provider for a fresh
/// login instead of silently reusing an existing browser session.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Logout {
/// Gateway name (defaults to the active gateway).
Expand Down
47 changes: 43 additions & 4 deletions crates/openshell-cli/src/oidc_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ fn build_ci_scopes(scopes: Option<&str>) -> Vec<Scope> {
.collect()
}

fn interactive_authorization_params(
audience: Option<&str>,
force_fresh_login: bool,
) -> Vec<(&'static str, String)> {
let mut params = Vec::new();
if force_fresh_login {
params.push(("prompt", "login".to_string()));
}
if let Some(aud) = audience {
params.push(("audience", aud.to_string()));
}
params
}

/// Run the OIDC Authorization Code + PKCE browser flow.
///
/// Opens the user's browser to the Keycloak login page and waits for
Expand All @@ -106,6 +120,7 @@ pub async fn oidc_browser_auth_flow(
audience: Option<&str>,
scopes: Option<&str>,
insecure: bool,
force_fresh_login: bool,
) -> Result<OidcTokenBundle> {
let discovery = discover(issuer, insecure).await?;

Expand All @@ -130,10 +145,14 @@ pub async fn oidc_browser_auth_flow(

let (mut auth_url, csrf_token) = auth_request.url();

// Append audience parameter for providers like Entra ID where the API
// audience differs from the client ID.
if let Some(aud) = audience {
auth_url.query_pairs_mut().append_pair("audience", aud);
// After `gateway logout`, ask the IdP for a fresh login prompt so the user
// can switch browser identity. Ordinary repeated logins may reuse SSO.
let params = interactive_authorization_params(audience, force_fresh_login);
{
let mut query = auth_url.query_pairs_mut();
for (key, value) in &params {
query.append_pair(key, value);
}
}

let (tx, rx) = oneshot::channel::<String>();
Expand Down Expand Up @@ -537,6 +556,26 @@ mod tests {
assert!(scopes.is_empty());
}

#[test]
fn interactive_authorization_params_force_fresh_login() {
assert_eq!(
interactive_authorization_params(Some("api://openshell"), true),
vec![
("prompt", "login".to_string()),
("audience", "api://openshell".to_string()),
]
);
assert_eq!(
interactive_authorization_params(None, true),
vec![("prompt", "login".to_string())]
);
assert_eq!(
interactive_authorization_params(Some("api://openshell"), false),
vec![("audience", "api://openshell".to_string())]
);
assert!(interactive_authorization_params(None, false).is_empty());
}

#[test]
fn bundle_from_response_sets_fields() {
use oauth2::basic::BasicTokenResponse;
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/gateway-auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,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. 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 connection flow:

Expand Down
Loading