From 62a1dab6eafa1b23108f784c8420452477d46b65 Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Tue, 18 Aug 2026 18:21:36 -0400 Subject: [PATCH 1/4] feat(cli): support OIDC device authorization grant for headless login Closes #2793 Add OAuth 2.0 Device Authorization Grant (RFC 8628) support to the OpenShell CLI's OIDC login flow. When running in a headless environment (OPENSHELL_NO_BROWSER=1) without a client secret configured, the CLI now uses the device code flow instead of the browser-based PKCE flow. The device code flow: - Requests a device code and user code from the IdP's device authorization endpoint - Displays a verification URL and user code to the user - Polls the token endpoint until the user completes authorization or the code expires - Supports slow_down responses per RFC 8628 by increasing the polling interval This implementation: - Extends OidcDiscovery to optionally capture device_authorization_endpoint - Adds oidc_device_code_flow function with proper error handling for all RFC 8628 error codes - Updates gateway add and gateway login to dispatch to device flow when browser is suppressed - Adds comprehensive unit tests for device flow structs and response parsing - Updates gateway authentication documentation to describe the device code fallback Signed-off-by: Jesse Jaggars --- crates/openshell-cli/src/commands/gateway.rs | 29 ++ crates/openshell-cli/src/oidc_auth.rs | 306 ++++++++++++++++++- docs/reference/gateway-auth.mdx | 2 + 3 files changed, 333 insertions(+), 4 deletions(-) diff --git a/crates/openshell-cli/src/commands/gateway.rs b/crates/openshell-cli/src/commands/gateway.rs index 10097065b..7a3ebf01f 100644 --- a/crates/openshell-cli/src/commands/gateway.rs +++ b/crates/openshell-cli/src/commands/gateway.rs @@ -905,6 +905,26 @@ pub async fn gateway_add( false } } + } else if is_browser_suppressed() { + match crate::oidc_auth::oidc_device_code_flow( + issuer, + oidc_client_id, + oidc_audience, + oidc_scopes, + gateway_insecure, + ) + .await + { + Ok(bundle) => { + openshell_bootstrap::oidc_token::store_oidc_token(name, &bundle)?; + eprintln!("{} Authenticated via device code", "✓".green().bold()); + true + } + Err(e) => { + eprintln!("{} Authentication failed: {e}", "!".yellow()); + false + } + } } else { match crate::oidc_auth::oidc_browser_auth_flow( issuer, @@ -1129,6 +1149,15 @@ pub async fn gateway_login(name: &str, gateway_insecure: bool) -> Result<()> { gateway_insecure, ) .await? + } else if is_browser_suppressed() { + crate::oidc_auth::oidc_device_code_flow( + issuer, + client_id, + audience, + scopes, + gateway_insecure, + ) + .await? } else { crate::oidc_auth::oidc_browser_auth_flow( issuer, diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs index f8ac20372..d0478a880 100644 --- a/crates/openshell-cli/src/oidc_auth.rs +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -3,10 +3,10 @@ //! OIDC authentication flows for CLI gateway login. //! -//! Implements Authorization Code + PKCE (interactive browser flow) and -//! Client Credentials (CI/automation) `OAuth2` grant types against a -//! Keycloak-compatible OIDC provider. - +//! Implements Authorization Code + PKCE (interactive browser flow), +//! Device Authorization Grant (headless flow), and Client Credentials +//! (CI/automation) `OAuth2` grant types against a Keycloak-compatible +//! OIDC provider. use bytes::Bytes; use http_body_util::Full; use hyper::service::service_fn; @@ -37,6 +37,31 @@ struct OidcDiscovery { issuer: String, authorization_endpoint: String, token_endpoint: String, + device_authorization_endpoint: Option, +} + +/// Device authorization response from the provider. +#[derive(Debug, Deserialize)] +struct DeviceAuthorizationResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: Option, + expires_in: u64, + #[serde(default = "default_interval")] + interval: u64, +} + +fn default_interval() -> u64 { + 5 +} + +/// Device token polling error responses (RFC 8628). +#[derive(Debug, Deserialize)] +struct DeviceTokenErrorResponse { + error: String, + #[serde(default)] + error_description: String, } /// Discover OIDC endpoints from the issuer's well-known configuration. @@ -245,6 +270,163 @@ pub async fn oidc_client_credentials_flow( )) } +/// Run the OIDC Device Authorization Grant flow (RFC 8628). +/// +/// Prompts the user to visit a verification URL and enter a code on any device +/// with a browser. Polls the token endpoint until the user completes authorization +/// or the device code expires. +pub async fn oidc_device_code_flow( + issuer: &str, + client_id: &str, + audience: Option<&str>, + scopes: Option<&str>, + insecure: bool, +) -> Result { + let discovery = discover(issuer, insecure).await?; + + let device_auth_endpoint = discovery.device_authorization_endpoint.as_deref().ok_or_else(|| { + miette::miette!( + "The OIDC provider does not advertise a device_authorization_endpoint.\n\ + Enable the device authorization grant on this client, or use client credentials for headless automation." + ) + })?; + + // Step 1: Request device and user codes + let http = http_client(insecure); + let scopes_param = build_scopes(scopes) + .iter() + .map(|s| s.to_string()) + .collect::>() + .join(" "); + + let mut form_params = vec![("client_id", client_id), ("scope", &scopes_param)]; + + // Add audience if present + let audience_str; + if let Some(aud) = audience { + audience_str = aud.to_string(); + form_params.push(("audience", &audience_str)); + } + + let device_auth_resp = http + .post(device_auth_endpoint) + .form(&form_params) + .send() + .await + .into_diagnostic()?; + + if !device_auth_resp.status().is_success() { + let status = device_auth_resp.status(); + let body = device_auth_resp.text().await.unwrap_or_default(); + return Err(miette::miette!( + "Device authorization request failed (status {status}): {body}" + )); + } + + let device_auth: DeviceAuthorizationResponse = + device_auth_resp.json().await.into_diagnostic()?; + + // Step 2: Display instructions to the user + eprintln!(); + eprintln!(" To authenticate, visit:"); + if let Some(uri_complete) = &device_auth.verification_uri_complete { + eprintln!(" {uri_complete}"); + } else { + eprintln!(" {}", device_auth.verification_uri); + eprintln!(); + eprintln!(" And enter this code:"); + eprintln!(" {}", device_auth.user_code); + } + eprintln!(); + eprintln!(" Waiting for authorization..."); + + // Step 3: Poll the token endpoint + let start_time = std::time::Instant::now(); + let expires_duration = Duration::from_secs(device_auth.expires_in); + let mut poll_interval = Duration::from_secs(device_auth.interval); + + loop { + if start_time.elapsed() >= expires_duration { + return Err(miette::miette!( + "Device code expired after {} seconds. Please try again.", + device_auth.expires_in + )); + } + + tokio::time::sleep(poll_interval).await; + + let token_params = vec![ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("device_code", &device_auth.device_code), + ("client_id", client_id), + ]; + + let poll_resp = http + .post(&discovery.token_endpoint) + .form(&token_params) + .send() + .await + .into_diagnostic()?; + + let status = poll_resp.status(); + + if status.is_success() { + // Success! Parse the token response + let token_response: serde_json::Value = poll_resp.json().await.into_diagnostic()?; + + return Ok(bundle_from_device_token_response( + &token_response, + issuer, + client_id, + )); + } + + // Parse error response + let error_resp: DeviceTokenErrorResponse = match poll_resp.json().await { + Ok(e) => e, + Err(_) => { + return Err(miette::miette!( + "Token polling failed with status {status} and unparseable response" + )); + } + }; + + match error_resp.error.as_str() { + "authorization_pending" => { + // Keep polling + debug!("Device authorization pending, continuing to poll"); + } + "slow_down" => { + // Increase polling interval per RFC 8628 + poll_interval += Duration::from_secs(5); + debug!( + "Received slow_down, increasing interval to {:?}", + poll_interval + ); + } + "access_denied" => { + return Err(miette::miette!( + "Authorization was denied by the user or administrator" + )); + } + "expired_token" => { + return Err(miette::miette!("Device code expired. Please try again.")); + } + _ => { + let desc = if error_resp.error_description.is_empty() { + String::new() + } else { + format!(": {}", error_resp.error_description) + }; + return Err(miette::miette!( + "Device authorization failed: {}{desc}", + error_resp.error + )); + } + } + } +} + /// Refresh an OIDC token using the `refresh_token` grant. /// /// Reuses the configured login scopes when supplied so providers can select @@ -345,6 +527,29 @@ fn bundle_from_oauth2_response( } } +fn bundle_from_device_token_response( + resp: &serde_json::Value, + issuer: &str, + client_id: &str, +) -> OidcTokenBundle { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let access_token = resp["access_token"].as_str().unwrap_or("").to_string(); + let refresh_token = resp["refresh_token"].as_str().map(String::from); + let expires_in = resp["expires_in"].as_u64(); + + OidcTokenBundle { + access_token, + refresh_token, + expires_at: expires_in.map(|ei| now + ei), + issuer: issuer.to_string(), + client_id: client_id.to_string(), + } +} + /// Percent-decode a URL query parameter value. fn percent_decode(s: &str) -> String { let mut out = Vec::with_capacity(s.len()); @@ -611,4 +816,97 @@ mod tests { assert_eq!(refreshed.client_id, previous.client_id); assert_eq!(refreshed.expires_at, Some(300)); } + + #[test] + fn discovery_missing_device_endpoint_is_optional() { + let discovery_json = "{\"issuer\":\"https://issuer.example\",\"authorization_endpoint\":\"https://issuer.example/auth\",\"token_endpoint\":\"https://issuer.example/token\"}"; + let discovery: OidcDiscovery = serde_json::from_str(discovery_json).unwrap(); + assert!(discovery.device_authorization_endpoint.is_none()); + assert_eq!(discovery.issuer, "https://issuer.example"); + } + + #[test] + fn discovery_with_device_endpoint_is_captured() { + let discovery_json = "{\"issuer\":\"https://issuer.example\",\"authorization_endpoint\":\"https://issuer.example/auth\",\"token_endpoint\":\"https://issuer.example/token\",\"device_authorization_endpoint\":\"https://issuer.example/device\"}"; + let discovery: OidcDiscovery = serde_json::from_str(discovery_json).unwrap(); + assert_eq!( + discovery.device_authorization_endpoint.as_deref(), + Some("https://issuer.example/device") + ); + } + + #[test] + fn device_auth_response_parses_minimal() { + let json = "{\"device_code\":\"GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS\",\"user_code\":\"WDJB-MJHT\",\"verification_uri\":\"https://example.com/device\",\"expires_in\":1800}"; + let resp: DeviceAuthorizationResponse = serde_json::from_str(json).unwrap(); + assert_eq!( + resp.device_code, + "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS" + ); + assert_eq!(resp.user_code, "WDJB-MJHT"); + assert_eq!(resp.verification_uri, "https://example.com/device"); + assert_eq!(resp.expires_in, 1800); + assert_eq!(resp.interval, 5); + assert!(resp.verification_uri_complete.is_none()); + } + + #[test] + fn device_auth_response_parses_complete() { + let json = "{\"device_code\":\"test-device-code\",\"user_code\":\"TEST-CODE\",\"verification_uri\":\"https://example.com/device\",\"verification_uri_complete\":\"https://example.com/device?user_code=TEST-CODE\",\"expires_in\":900,\"interval\":10}"; + let resp: DeviceAuthorizationResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.interval, 10); + assert_eq!( + resp.verification_uri_complete.as_deref(), + Some("https://example.com/device?user_code=TEST-CODE") + ); + } + + #[test] + fn device_token_error_response_parses() { + let json = "{\"error\":\"authorization_pending\",\"error_description\":\"User has not authorized yet\"}"; + let resp: DeviceTokenErrorResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.error, "authorization_pending"); + assert_eq!(resp.error_description, "User has not authorized yet"); + } + + #[test] + fn device_token_error_response_defaults_empty_description() { + let json = "{\"error\":\"slow_down\"}"; + let resp: DeviceTokenErrorResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.error, "slow_down"); + assert_eq!(resp.error_description, ""); + } + + #[test] + fn bundle_from_device_token_response_complete() { + let json = serde_json::json!({ + "access_token": "device-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "device-refresh-token" + }); + let bundle = + bundle_from_device_token_response(&json, "https://issuer.example", "test-client"); + assert_eq!(bundle.access_token, "device-access-token"); + assert_eq!( + bundle.refresh_token.as_deref(), + Some("device-refresh-token") + ); + assert_eq!(bundle.issuer, "https://issuer.example"); + assert_eq!(bundle.client_id, "test-client"); + assert!(bundle.expires_at.is_some()); + } + + #[test] + fn bundle_from_device_token_response_minimal() { + let json = serde_json::json!({ + "access_token": "device-access-only", + "token_type": "Bearer" + }); + let bundle = + bundle_from_device_token_response(&json, "https://issuer.example", "test-client"); + assert_eq!(bundle.access_token, "device-access-only"); + assert!(bundle.refresh_token.is_none()); + assert!(bundle.expires_at.is_none()); + } } diff --git a/docs/reference/gateway-auth.mdx b/docs/reference/gateway-auth.mdx index 311038480..c89f50ec9 100644 --- a/docs/reference/gateway-auth.mdx +++ b/docs/reference/gateway-auth.mdx @@ -124,6 +124,8 @@ When you register or log in to an OIDC gateway, the CLI uses the Authorization C The connection flow: +When the browser cannot be opened (such as in a headless environment or when `OPENSHELL_NO_BROWSER=1` is set), and `OPENSHELL_OIDC_CLIENT_SECRET` is not configured, the CLI falls back to the Device Authorization Grant (RFC 8628). This flow prompts the user to visit a verification URL on any device with a browser and enter a displayed code. The CLI polls the token endpoint until the user completes authorization. This requires the OIDC client to have the device authorization grant enabled on the identity provider. + 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 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. From 2427c8d2f0cd0fedb1a7beb11d9076724f35a566 Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Tue, 18 Aug 2026 19:08:11 -0400 Subject: [PATCH 2/4] fix(cli): validate OIDC device token responses Signed-off-by: Jesse Jaggars --- crates/openshell-cli/src/oidc_auth.rs | 92 +++++++++++++++++---------- docs/reference/gateway-auth.mdx | 2 +- 2 files changed, 59 insertions(+), 35 deletions(-) diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs index d0478a880..7b9beb886 100644 --- a/crates/openshell-cli/src/oidc_auth.rs +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -14,7 +14,7 @@ use hyper::{Method, Response, StatusCode}; use hyper_util::rt::{TokioExecutor, TokioIo}; use hyper_util::server::conn::auto::Builder; use miette::{IntoDiagnostic, Result}; -use oauth2::basic::BasicClient; +use oauth2::basic::{BasicClient, BasicTokenResponse}; use oauth2::{ AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenResponse, TokenUrl, @@ -371,14 +371,14 @@ pub async fn oidc_device_code_flow( let status = poll_resp.status(); if status.is_success() { - // Success! Parse the token response - let token_response: serde_json::Value = poll_resp.json().await.into_diagnostic()?; + // A successful HTTP status is not sufficient: require a valid OAuth + // token response before persisting credentials. + let token_response: BasicTokenResponse = poll_resp + .json() + .await + .map_err(|error| miette::miette!("invalid device token response: {error}"))?; - return Ok(bundle_from_device_token_response( - &token_response, - issuer, - client_id, - )); + return bundle_from_device_token_response(&token_response, issuer, client_id); } // Parse error response @@ -509,7 +509,7 @@ pub async fn ensure_valid_oidc_token(gateway_name: &str, insecure: bool) -> Resu // ── Helpers ────────────────────────────────────────────────────────── fn bundle_from_oauth2_response( - resp: &oauth2::basic::BasicTokenResponse, + resp: &BasicTokenResponse, issuer: &str, client_id: &str, ) -> OidcTokenBundle { @@ -528,26 +528,17 @@ fn bundle_from_oauth2_response( } fn bundle_from_device_token_response( - resp: &serde_json::Value, + resp: &BasicTokenResponse, issuer: &str, client_id: &str, -) -> OidcTokenBundle { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let access_token = resp["access_token"].as_str().unwrap_or("").to_string(); - let refresh_token = resp["refresh_token"].as_str().map(String::from); - let expires_in = resp["expires_in"].as_u64(); - - OidcTokenBundle { - access_token, - refresh_token, - expires_at: expires_in.map(|ei| now + ei), - issuer: issuer.to_string(), - client_id: client_id.to_string(), +) -> Result { + if resp.access_token().secret().trim().is_empty() { + return Err(miette::miette!( + "invalid device token response: access_token is empty" + )); } + + Ok(bundle_from_oauth2_response(resp, issuer, client_id)) } /// Percent-decode a URL query parameter value. @@ -878,15 +869,17 @@ mod tests { } #[test] - fn bundle_from_device_token_response_complete() { - let json = serde_json::json!({ + fn device_token_response_complete_is_typed() { + let response: BasicTokenResponse = serde_json::from_value(serde_json::json!({ "access_token": "device-access-token", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "device-refresh-token" - }); + })) + .unwrap(); let bundle = - bundle_from_device_token_response(&json, "https://issuer.example", "test-client"); + bundle_from_device_token_response(&response, "https://issuer.example", "test-client") + .unwrap(); assert_eq!(bundle.access_token, "device-access-token"); assert_eq!( bundle.refresh_token.as_deref(), @@ -898,15 +891,46 @@ mod tests { } #[test] - fn bundle_from_device_token_response_minimal() { - let json = serde_json::json!({ + fn device_token_response_minimal_is_typed() { + let response: BasicTokenResponse = serde_json::from_value(serde_json::json!({ "access_token": "device-access-only", "token_type": "Bearer" - }); + })) + .unwrap(); let bundle = - bundle_from_device_token_response(&json, "https://issuer.example", "test-client"); + bundle_from_device_token_response(&response, "https://issuer.example", "test-client") + .unwrap(); assert_eq!(bundle.access_token, "device-access-only"); assert!(bundle.refresh_token.is_none()); assert!(bundle.expires_at.is_none()); } + + #[test] + fn device_token_response_requires_access_token() { + let result = serde_json::from_value::(serde_json::json!({ + "token_type": "Bearer" + })); + assert!(result.is_err()); + } + + #[test] + fn device_token_response_requires_token_type() { + let result = serde_json::from_value::(serde_json::json!({ + "access_token": "device-access-token" + })); + assert!(result.is_err()); + } + + #[test] + fn device_token_response_rejects_empty_access_token() { + let response: BasicTokenResponse = serde_json::from_value(serde_json::json!({ + "access_token": "", + "token_type": "Bearer" + })) + .unwrap(); + + let result = + bundle_from_device_token_response(&response, "https://issuer.example", "test-client"); + assert!(result.is_err()); + } } diff --git a/docs/reference/gateway-auth.mdx b/docs/reference/gateway-auth.mdx index c89f50ec9..a7ef78a1d 100644 --- a/docs/reference/gateway-auth.mdx +++ b/docs/reference/gateway-auth.mdx @@ -124,7 +124,7 @@ When you register or log in to an OIDC gateway, the CLI uses the Authorization C The connection flow: -When the browser cannot be opened (such as in a headless environment or when `OPENSHELL_NO_BROWSER=1` is set), and `OPENSHELL_OIDC_CLIENT_SECRET` is not configured, the CLI falls back to the Device Authorization Grant (RFC 8628). This flow prompts the user to visit a verification URL on any device with a browser and enter a displayed code. The CLI polls the token endpoint until the user completes authorization. This requires the OIDC client to have the device authorization grant enabled on the identity provider. +For a headless environment, set `OPENSHELL_NO_BROWSER=1` before registering or logging in to the gateway. When this variable is set and `OPENSHELL_OIDC_CLIENT_SECRET` is not configured, the CLI uses the Device Authorization Grant (RFC 8628). This flow prompts the user to visit a verification URL on any device with a browser and enter a displayed code. The CLI polls the token endpoint until the user completes authorization. This requires the OIDC client to have the device authorization grant enabled on the identity provider. 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 with the OIDC scopes saved in the gateway metadata. From 4a474ecc231b33552769260e3765079429601cba Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Tue, 18 Aug 2026 22:44:54 -0400 Subject: [PATCH 3/4] fix(cli): add PKCE to OIDC device flow Signed-off-by: Jesse Jaggars --- crates/openshell-cli/src/oidc_auth.rs | 116 +++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 13 deletions(-) diff --git a/crates/openshell-cli/src/oidc_auth.rs b/crates/openshell-cli/src/oidc_auth.rs index 7b9beb886..2a5c41a5f 100644 --- a/crates/openshell-cli/src/oidc_auth.rs +++ b/crates/openshell-cli/src/oidc_auth.rs @@ -135,6 +135,41 @@ fn interactive_authorization_params( params } +fn device_authorization_form( + client_id: &str, + scopes: &str, + audience: Option<&str>, + code_challenge: &str, + code_challenge_method: &str, +) -> Vec<(&'static str, String)> { + let mut params = vec![ + ("client_id", client_id.to_string()), + ("scope", scopes.to_string()), + ("code_challenge", code_challenge.to_string()), + ("code_challenge_method", code_challenge_method.to_string()), + ]; + if let Some(audience) = audience { + params.push(("audience", audience.to_string())); + } + params +} + +fn device_token_form( + client_id: &str, + device_code: &str, + code_verifier: &str, +) -> Vec<(&'static str, String)> { + vec![ + ( + "grant_type", + "urn:ietf:params:oauth:grant-type:device_code".to_string(), + ), + ("device_code", device_code.to_string()), + ("client_id", client_id.to_string()), + ("code_verifier", code_verifier.to_string()), + ] +} + /// Run the OIDC Authorization Code + PKCE browser flow. /// /// Opens the user's browser to the Keycloak login page and waits for @@ -299,14 +334,16 @@ pub async fn oidc_device_code_flow( .collect::>() .join(" "); - let mut form_params = vec![("client_id", client_id), ("scope", &scopes_param)]; - - // Add audience if present - let audience_str; - if let Some(aud) = audience { - audience_str = aud.to_string(); - form_params.push(("audience", &audience_str)); - } + // Use PKCE for the device flow as well as the browser flow. Keycloak + // requires these parameters when the public client enforces S256 PKCE. + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + let form_params = device_authorization_form( + client_id, + &scopes_param, + audience, + pkce_challenge.as_str(), + pkce_challenge.method().as_str(), + ); let device_auth_resp = http .post(device_auth_endpoint) @@ -355,11 +392,8 @@ pub async fn oidc_device_code_flow( tokio::time::sleep(poll_interval).await; - let token_params = vec![ - ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), - ("device_code", &device_auth.device_code), - ("client_id", client_id), - ]; + let token_params = + device_token_form(client_id, &device_auth.device_code, pkce_verifier.secret()); let poll_resp = http .post(&discovery.token_endpoint) @@ -772,6 +806,62 @@ mod tests { assert!(interactive_authorization_params(None, false).is_empty()); } + #[test] + fn device_authorization_form_includes_pkce_and_audience() { + let params = device_authorization_form( + "openshell-cli", + "openid profile", + Some("openshell-api"), + "test-challenge", + "S256", + ); + let params: std::collections::HashMap<_, _> = params.into_iter().collect(); + + assert_eq!( + params.get("client_id").map(String::as_str), + Some("openshell-cli") + ); + assert_eq!( + params.get("scope").map(String::as_str), + Some("openid profile") + ); + assert_eq!( + params.get("audience").map(String::as_str), + Some("openshell-api") + ); + assert_eq!( + params.get("code_challenge").map(String::as_str), + Some("test-challenge") + ); + assert_eq!( + params.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + } + + #[test] + fn device_token_form_includes_pkce_verifier() { + let params = device_token_form("openshell-cli", "device-code", "test-verifier"); + let params: std::collections::HashMap<_, _> = params.into_iter().collect(); + + assert_eq!( + params.get("grant_type").map(String::as_str), + Some("urn:ietf:params:oauth:grant-type:device_code") + ); + assert_eq!( + params.get("device_code").map(String::as_str), + Some("device-code") + ); + assert_eq!( + params.get("client_id").map(String::as_str), + Some("openshell-cli") + ); + assert_eq!( + params.get("code_verifier").map(String::as_str), + Some("test-verifier") + ); + } + #[test] fn bundle_from_response_sets_fields() { use oauth2::basic::BasicTokenResponse; From 7b93ed168a73818c03d15fc4406ad11db956082c Mon Sep 17 00:00:00 2001 From: Jesse Jaggars Date: Wed, 19 Aug 2026 06:44:03 -0400 Subject: [PATCH 4/4] docs(cli): document PKCE device flow Signed-off-by: Jesse Jaggars --- architecture/gateway.md | 2 +- docs/kubernetes/ingress.mdx | 2 +- docs/reference/gateway-auth.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index db8f2508e..29155466d 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -174,7 +174,7 @@ Supported auth modes: | Plaintext | Local development or a trusted reverse proxy boundary. | | Unauthenticated local users | Trusted Kubernetes dev or fully trusted proxy deployments only. | | Cloudflare JWT | Edge-authenticated deployments where Cloudflare Access supplies identity. | -| OIDC | Bearer-token auth for users, with browser PKCE or client credentials login. | +| OIDC | Bearer-token auth for users, with browser or device-code PKCE and 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 diff --git a/docs/kubernetes/ingress.mdx b/docs/kubernetes/ingress.mdx index 66178bc3a..51284465d 100644 --- a/docs/kubernetes/ingress.mdx +++ b/docs/kubernetes/ingress.mdx @@ -91,7 +91,7 @@ Envoy Gateway only terminates TLS here — it does not perform OIDC. Do not enab Because Envoy terminates TLS, the OpenShell gateway never sees a client certificate, so client mTLS cannot provide identity on this path. Use OIDC bearer tokens for client identity instead. -For headless agents and CI, the CLI obtains the token via the OAuth2 client-credentials grant (no browser): set `OPENSHELL_OIDC_CLIENT_SECRET` to the OAuth client secret before running `openshell gateway add`. The client id comes from `--oidc-client-id` (default `openshell-cli`); pass it explicitly if your IdP client uses a different id. Interactive human users get the browser-based Authorization Code + PKCE flow by default. +For an interactive login on a headless machine, set `OPENSHELL_NO_BROWSER=1` before running `openshell gateway add`. The CLI uses the Device Authorization Grant with S256 PKCE and prompts the user to approve the login from another browser. For unattended agents and CI, set `OPENSHELL_OIDC_CLIENT_SECRET` to use the OAuth2 client-credentials grant instead. The client id comes from `--oidc-client-id` (default `openshell-cli`); pass it explicitly if your identity provider uses a different id. Interactive users with a local browser get the Authorization Code flow with PKCE by default. ### Provide a TLS certificate diff --git a/docs/reference/gateway-auth.mdx b/docs/reference/gateway-auth.mdx index a7ef78a1d..e5dcb65ad 100644 --- a/docs/reference/gateway-auth.mdx +++ b/docs/reference/gateway-auth.mdx @@ -124,7 +124,7 @@ When you register or log in to an OIDC gateway, the CLI uses the Authorization C The connection flow: -For a headless environment, set `OPENSHELL_NO_BROWSER=1` before registering or logging in to the gateway. When this variable is set and `OPENSHELL_OIDC_CLIENT_SECRET` is not configured, the CLI uses the Device Authorization Grant (RFC 8628). This flow prompts the user to visit a verification URL on any device with a browser and enter a displayed code. The CLI polls the token endpoint until the user completes authorization. This requires the OIDC client to have the device authorization grant enabled on the identity provider. +For a headless environment, set `OPENSHELL_NO_BROWSER=1` before registering or logging in to the gateway. When this variable is set and `OPENSHELL_OIDC_CLIENT_SECRET` is not configured, the CLI uses the Device Authorization Grant (RFC 8628) with S256 PKCE. This flow prompts the user to visit a verification URL on any device with a browser and enter a displayed code. The CLI polls the token endpoint until the user completes authorization. This requires the OIDC client to have the device authorization grant enabled on the identity provider. 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 with the OIDC scopes saved in the gateway metadata.