diff --git a/crates/openshell-bootstrap/src/oidc_token.rs b/crates/openshell-bootstrap/src/oidc_token.rs index 3fa0730956..f21a115c80 100644 --- a/crates/openshell-bootstrap/src/oidc_token.rs +++ b/crates/openshell-bootstrap/src/oidc_token.rs @@ -39,6 +39,11 @@ pub fn oidc_token_path(gateway_name: &str) -> Result { 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 { + 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)?; @@ -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(()) } @@ -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. @@ -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")); + }); + } } diff --git a/crates/openshell-cli/src/commands/gateway.rs b/crates/openshell-cli/src/commands/gateway.rs index acf87a1466..10097065b9 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -912,6 +912,7 @@ pub async fn gateway_add( oidc_audience, oidc_scopes, gateway_insecure, + false, ) .await { @@ -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( @@ -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!( @@ -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)?; @@ -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}"); } diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 7cefd3669a..dee3e0e573 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -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). @@ -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). diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs index 2aacdb0c9c..f8ac203724 100644 --- a/crates/openshell-cli/src/oidc_auth.rs +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -96,6 +96,20 @@ fn build_ci_scopes(scopes: Option<&str>) -> Vec { .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 @@ -106,6 +120,7 @@ pub async fn oidc_browser_auth_flow( audience: Option<&str>, scopes: Option<&str>, insecure: bool, + force_fresh_login: bool, ) -> Result { let discovery = discover(issuer, insecure).await?; @@ -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 ¶ms { + query.append_pair(key, value); + } } let (tx, rx) = oneshot::channel::(); @@ -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; diff --git a/docs/reference/gateway-auth.mdx b/docs/reference/gateway-auth.mdx index 1d318f109f..311038480c 100644 --- a/docs/reference/gateway-auth.mdx +++ b/docs/reference/gateway-auth.mdx @@ -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: