From 537cfe87b829a0f1977fdd965b98171d903a3854 Mon Sep 17 00:00:00 2001 From: jt Date: Sun, 9 Aug 2026 00:20:34 -0700 Subject: [PATCH 1/2] Add OAuth 2.0 device authorization grant (RFC 8628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fourth grant type for testing APIs without registering a redirect URI: the provider hands out a short user code, LitePost opens the verification page in the browser and polls the token endpoint until the sign-in is approved. - New oauth2_device_flow command requests the device code, reports the user code to the UI via a flow-scoped oauth-device-prompt-{id} event (the command is still running while the user acts, so it cannot come back in the return value), opens the browser and polls. It reuses the PendingOAuthFlows watch channel, so the existing Cancel button works unchanged. - Poll state is decided by the response body, not the HTTP status: GitHub answers authorization_pending with 200 and a form body where the RFC says 400 and JSON. slow_down adds 5s to the interval per the RFC, and the provider-chosen expires_in bounds the wait (capped at 30 minutes). - verification_url is accepted alongside verification_uri — Google spells it that way — and verification_uri_complete is preferred when opening the browser so the code arrives pre-filled. - The user code panel renders from the flow store rather than component state, so a device sign-in survives switching tabs the same way the authorization code flow does. - OIDC discovery auto-fills the new Device Authorization URL field from device_authorization_endpoint. Co-Authored-By: Claude Fable 5 --- docs/authentication.md | 25 +- src-tauri/src/lib.rs | 1 + src-tauri/src/oauth.rs | 460 ++++++++++++++++++++++++++- src/components/OAuthConfigurator.tsx | 82 ++++- src/hooks/useOAuth2TokenActions.ts | 42 ++- src/services/oauth.ts | 19 +- src/store/oauthFlows.ts | 23 +- src/test/deviceCodeFlow.test.tsx | 143 +++++++++ src/test/oauthVariables.test.ts | 41 +++ src/test/oidcDiscovery.test.ts | 10 + src/types/index.ts | 4 +- src/utils/oidcDiscovery.ts | 5 +- 12 files changed, 831 insertions(+), 24 deletions(-) create mode 100644 src/test/deviceCodeFlow.test.tsx diff --git a/docs/authentication.md b/docs/authentication.md index e350262..b28a196 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -75,7 +75,7 @@ The key name and placement are fully configurable, so this works with APIs that ## OAuth 2.0 -LitePost supports three OAuth 2.0 grant types. Each grant type is suited to a different scenario -- choose the one that matches your API's requirements. +LitePost supports four OAuth 2.0 grant types. Each grant type is suited to a different scenario -- choose the one that matches your API's requirements. ### Common Fields @@ -177,6 +177,29 @@ grant_type=password&username=jane&password=s3cret&client_id=my-app&client_secret The Password Grant sends user credentials directly to the token endpoint. Only use this with trusted authorization servers over HTTPS. Many providers have deprecated this grant type in favor of Authorization Code with PKCE. ::: +### Device Code + +Use this grant type (RFC 8628, the "device flow") when you want a user sign-in without registering a redirect URI. There is no callback at all: the provider hands out a short code, you approve it in the browser, and LitePost polls until the token is ready. This makes it the quickest flow to set up against providers that support it -- GitHub, Microsoft Entra, Google, Auth0, and Okta among them. + +**Additional fields:** + +| Field | Description | +|--------------------------|-----------------------------------------------------------| +| Device Authorization URL | The provider's device authorization endpoint (e.g. GitHub's `https://github.com/login/device/code`). Auto-fill discovers it from `device_authorization_endpoint` when the provider advertises one. | + +**How it works:** + +1. Click **Get Access Token**. LitePost asks the Device Authorization URL for a device code. +2. LitePost shows the short user code (like `WDJB-MJHT`) with a copy button, and opens the provider's verification page in your browser. +3. Enter the code (some providers pre-fill it) and approve the sign-in. +4. LitePost polls the Token URL in the background -- honoring the provider's polling interval and `slow_down` responses -- and stores the access token the moment the approval lands. + +The code expires after a provider-chosen lifetime (typically 15 minutes); **Cancel** stops the wait early. Client Secret is usually not needed -- device flow clients are public clients. + +::: tip +Make sure the device flow is enabled for your OAuth app -- some providers (GitHub, Entra) require opting in per application before the device authorization endpoint will accept your client ID. +::: + ### Token Management Once a token is obtained through any OAuth flow, LitePost handles it as follows: diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5b06d36..b633d2e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -40,6 +40,7 @@ pub fn run() { streaming::cancel_stream, oauth::oauth2_token_exchange, oauth::oauth2_auth_code_flow, + oauth::oauth2_device_flow, oauth::oauth2_cancel_flow, oauth::oauth2_refresh, websocket::ws_connect, diff --git a/src-tauri/src/oauth.rs b/src-tauri/src/oauth.rs index 2cf4e2a..97afa5a 100644 --- a/src-tauri/src/oauth.rs +++ b/src-tauri/src/oauth.rs @@ -4,6 +4,7 @@ use rand::RngCore; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::HashMap; +use tauri::Emitter; use tauri::Url; use tauri_plugin_http::reqwest; use tauri_plugin_opener::OpenerExt; @@ -20,6 +21,13 @@ const TOKEN_CONTAINER_KEYS: &[&str] = &["data", "token", "result", "response"]; /// a password manager. Cancelling is the UI's job, not the timeout's. const AUTH_CALLBACK_TIMEOUT_SECS: u64 = 300; +/// RFC 8628 §3.2 defaults, used when the provider omits the fields. The expiry +/// ceiling also bounds a provider-supplied lifetime so an extravagant +/// `expires_in` cannot leave a poll loop running for hours. +const DEVICE_CODE_DEFAULT_EXPIRES_SECS: u64 = 900; +const DEVICE_CODE_MAX_EXPIRES_SECS: u64 = 1800; +const DEVICE_POLL_DEFAULT_INTERVAL_SECS: u64 = 5; + #[derive(Debug, Deserialize)] pub struct OAuth2TokenExchangeOptions { token_url: String, @@ -164,8 +172,12 @@ fn parse_optional_string_field( } } -fn parse_optional_expires_in(map: &Map) -> Result, String> { - match lookup_value(map, &["expires_in", "expiresIn"]) { +fn parse_optional_u64_field( + map: &Map, + aliases: &[&str], + field_name: &str, +) -> Result, String> { + match lookup_value(map, aliases) { None | Some(Value::Null) => Ok(None), Some(Value::Number(value)) => { if let Some(as_u64) = value.as_u64() { @@ -176,7 +188,7 @@ fn parse_optional_expires_in(map: &Map) -> Result, St return Ok(Some(as_f64 as u64)); } } - Err("expires_in must be a whole non-negative number".to_string()) + Err(format!("{} must be a whole non-negative number", field_name)) } Some(Value::String(value)) => { let trimmed = value.trim(); @@ -185,13 +197,17 @@ fn parse_optional_expires_in(map: &Map) -> Result, St } let parsed = trimmed .parse::() - .map_err(|_| "expires_in must be a whole non-negative number".to_string())?; + .map_err(|_| format!("{} must be a whole non-negative number", field_name))?; Ok(Some(parsed)) } - Some(_) => Err("expires_in must be a number or string".to_string()), + Some(_) => Err(format!("{} must be a number or string", field_name)), } } +fn parse_optional_expires_in(map: &Map) -> Result, String> { + parse_optional_u64_field(map, &["expires_in", "expiresIn"], "expires_in") +} + fn extract_oauth_error(map: &Map) -> Option { let error = lookup_value(map, &["error", "error_code"]).and_then(value_as_non_empty_string)?; let description = lookup_value( @@ -357,7 +373,141 @@ fn parse_oauth_error_body(body: &str, content_type: &str) -> Option { None } -async fn parse_oauth_token_response(res: reqwest::Response) -> Result { +/// The provider's answer to a device authorization request (RFC 8628 §3.2). +#[derive(Debug, Clone, PartialEq)] +struct DeviceAuthorization { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: Option, + expires_in: u64, + interval: u64, +} + +fn map_to_device_authorization(map: &Map) -> Result { + let device_code_aliases = &["device_code", "deviceCode"]; + + if lookup_value(map, device_code_aliases).is_none() { + if let Some(provider_error) = extract_oauth_error(map) { + return Err(format!("OAuth provider error: {}", provider_error)); + } + } + + Ok(DeviceAuthorization { + device_code: parse_required_string_field(map, device_code_aliases, "device_code")?, + user_code: parse_required_string_field(map, &["user_code", "userCode"], "user_code")?, + // Google answers with `verification_url`, despite RFC 8628 naming the + // field `verification_uri`. + verification_uri: parse_required_string_field( + map, + &[ + "verification_uri", + "verification_url", + "verificationUri", + "verificationUrl", + ], + "verification_uri", + )?, + verification_uri_complete: parse_optional_string_field( + map, + &[ + "verification_uri_complete", + "verification_url_complete", + "verificationUriComplete", + ], + "verification_uri_complete", + )?, + expires_in: parse_optional_expires_in(map)?.unwrap_or(DEVICE_CODE_DEFAULT_EXPIRES_SECS), + interval: parse_optional_u64_field(map, &["interval"], "interval")? + .unwrap_or(DEVICE_POLL_DEFAULT_INTERVAL_SECS), + }) +} + +fn parse_device_authorization_body( + body: &str, + content_type: &str, +) -> Result { + let trimmed = body.trim(); + if trimmed.is_empty() { + return Err("device authorization response body is empty".to_string()); + } + + let mut errors = Vec::new(); + + for format in detect_parse_order(trimmed, content_type) { + let result = match format { + TokenBodyFormat::Json => parse_json_map(trimmed), + TokenBodyFormat::Form => parse_form_map(trimmed), + } + .and_then(|map| map_to_device_authorization(&map)); + + match result { + Ok(device) => return Ok(device), + Err(error) => errors.push(error), + } + } + + Err(errors.join("; ")) +} + +/// What one round of polling the token endpoint told us. +#[derive(Debug)] +enum DevicePollOutcome { + Token(OAuth2TokenResponse), + /// The user has not approved yet — keep polling. + Pending, + /// The provider asked us to back off (RFC 8628 §3.5: add 5 seconds). + SlowDown, +} + +fn parse_device_poll_body(body: &str, content_type: &str) -> Result { + let trimmed = body.trim(); + if trimmed.is_empty() { + return Err("token response body is empty".to_string()); + } + + let mut errors = Vec::new(); + + for format in detect_parse_order(trimmed, content_type) { + let map = match format { + TokenBodyFormat::Json => parse_json_map(trimmed), + TokenBodyFormat::Form => parse_form_map(trimmed), + }; + let map = match map { + Ok(map) => map, + Err(error) => { + errors.push(error); + continue; + } + }; + + // The RFC reports poll state as an `error` with HTTP 400, but GitHub + // sends the same body with HTTP 200 — so the body, not the status, + // decides what happened. + let error_code = + lookup_value(&map, &["error", "error_code"]).and_then(value_as_non_empty_string); + match error_code.as_deref() { + Some("authorization_pending") => return Ok(DevicePollOutcome::Pending), + Some("slow_down") => return Ok(DevicePollOutcome::SlowDown), + Some(_) => { + let message = extract_oauth_error(&map) + .unwrap_or_else(|| "unknown provider error".to_string()); + return Err(format!("OAuth provider error: {}", message)); + } + None => {} + } + + match map_to_token_response(&map) { + Ok(token) => return Ok(DevicePollOutcome::Token(token)), + Err(error) => errors.push(error), + } + } + + Err(errors.join("; ")) +} + +/// Split a response into its lowercased content-type and body text. +async fn read_response_body(res: reqwest::Response) -> Result<(String, String), String> { let content_type = res .headers() .get("content-type") @@ -368,7 +518,13 @@ async fn parse_oauth_token_response(res: reqwest::Response) -> Result Result { + let (content_type, body) = read_response_body(res).await?; parse_oauth_token_body(&body, &content_type).map_err(|error| { let content_type_display = if content_type.is_empty() { @@ -641,6 +797,176 @@ pub async fn oauth2_cancel_flow( } } +#[derive(Debug, Deserialize)] +pub struct OAuth2DeviceFlowOptions { + device_auth_url: String, + token_url: String, + client_id: String, + client_secret: Option, + scope: Option, + /// Identifies this flow so the UI can cancel it and receive the user-code + /// event. Optional for the same reason as on the authorization code flow. + #[serde(default)] + flow_id: Option, +} + +/// What the UI must show while the poll loop waits: the code the user has to +/// enter, and where to enter it. +#[derive(Debug, Serialize, Clone)] +struct OAuth2DevicePromptPayload { + user_code: String, + verification_uri: String, + verification_uri_complete: Option, + expires_in: u64, +} + +#[tauri::command] +pub async fn oauth2_device_flow( + options: OAuth2DeviceFlowOptions, + app: tauri::AppHandle, + client_wrapper: tauri::State<'_, ClientWrapper>, + pending_flows: tauri::State<'_, PendingOAuthFlows>, +) -> Result { + let device_auth_url = required_field(options.device_auth_url, "device_auth_url")?; + let token_url = required_field(options.token_url, "token_url")?; + let client_id = required_field(options.client_id, "client_id")?; + let client_secret = normalize_optional_input(options.client_secret); + + let client = client_wrapper.get_or_init_client()?; + + let mut params = HashMap::new(); + params.insert("client_id".to_string(), client_id.clone()); + insert_optional_param(&mut params, "scope", options.scope); + if let Some(secret) = &client_secret { + params.insert("client_secret".to_string(), secret.clone()); + } + + let res = client + .post(&device_auth_url) + .header("accept", OAUTH_TOKEN_ACCEPT_HEADER) + .timeout(std::time::Duration::from_secs(30)) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Device authorization request failed: {}", e))?; + + if !res.status().is_success() { + return Err(oauth_http_error("Device authorization request failed", res).await); + } + + let (content_type, body) = read_response_body(res).await?; + let device = parse_device_authorization_body(&body, &content_type).map_err(|error| { + format!( + "Failed to parse device authorization response: {} (body preview: {})", + error, + oauth_body_preview(&body, 300) + ) + })?; + + // Hand the UI the code before opening the browser, so it is already on + // screen when the user lands on the verification page. + let flow_id = normalize_optional_input(options.flow_id); + if let Some(id) = &flow_id { + let _ = app.emit( + &format!("oauth-device-prompt-{}", id), + OAuth2DevicePromptPayload { + user_code: device.user_code.clone(), + verification_uri: device.verification_uri.clone(), + verification_uri_complete: device.verification_uri_complete.clone(), + expires_in: device.expires_in, + }, + ); + } + + // `verification_uri_complete` arrives with the code pre-filled; the plain + // URI asks the user to type it. The code stays visible in the app either + // way, so the user can check it matches what the page shows. + let open_url = device + .verification_uri_complete + .as_deref() + .unwrap_or(&device.verification_uri); + app.opener() + .open_url(open_url, None::<&str>) + .map_err(|e| format!("Failed to open browser: {}", e))?; + + let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false); + if let Some(id) = &flow_id { + pending_flows + .flows + .lock() + .map_err(|_| "Failed to register the authorization flow".to_string())? + .insert(id.clone(), cancel_tx); + } + + let poll = async { + let mut interval = device.interval.max(1); + loop { + tokio::time::sleep(std::time::Duration::from_secs(interval)).await; + + let mut poll_params = HashMap::new(); + poll_params.insert( + "grant_type".to_string(), + "urn:ietf:params:oauth:grant-type:device_code".to_string(), + ); + poll_params.insert("device_code".to_string(), device.device_code.clone()); + poll_params.insert("client_id".to_string(), client_id.clone()); + if let Some(secret) = &client_secret { + poll_params.insert("client_secret".to_string(), secret.clone()); + } + + let res = client + .post(&token_url) + .header("accept", OAUTH_TOKEN_ACCEPT_HEADER) + .timeout(std::time::Duration::from_secs(30)) + .form(&poll_params) + .send() + .await + .map_err(|e| format!("Token request failed: {}", e))?; + + let status = res.status(); + let (content_type, body) = read_response_body(res).await?; + + match parse_device_poll_body(&body, &content_type) { + Ok(DevicePollOutcome::Token(token)) => return Ok(token), + Ok(DevicePollOutcome::Pending) => {} + Ok(DevicePollOutcome::SlowDown) => interval += 5, + Err(error) => { + return Err(if status.is_success() { + format!( + "Failed to parse token response: {} (body preview: {})", + error, + oauth_body_preview(&body, 300) + ) + } else { + format!("Token request failed ({}): {}", status, error) + }); + } + } + } + }; + + let expires_in = device.expires_in.min(DEVICE_CODE_MAX_EXPIRES_SECS); + let outcome = tokio::select! { + result = tokio::time::timeout(std::time::Duration::from_secs(expires_in), poll) => match result { + Ok(inner) => inner, + Err(_) => Err(format!( + "The device code expired after {} minutes without the sign-in being approved. \ + Get a new token to start over with a fresh code.", + expires_in.div_ceil(60) + )), + }, + _ = cancel_rx.changed() => Err("Authorization cancelled".to_string()), + }; + + if let Some(id) = &flow_id { + if let Ok(mut flows) = pending_flows.flows.lock() { + flows.remove(id); + } + } + + outcome +} + async fn wait_for_callback(listener: tokio::net::TcpListener) -> Result<(String, String), String> { use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -859,4 +1185,124 @@ mod oauth_parser_tests { assert!(error.contains("invalid_grant")); assert!(error.contains("Code expired")); } + + #[test] + fn parses_device_authorization_json() { + let body = r#"{ + "device_code": "dev-abc", + "user_code": "WDJB-MJHT", + "verification_uri": "https://example.com/activate", + "verification_uri_complete": "https://example.com/activate?user_code=WDJB-MJHT", + "expires_in": 1800, + "interval": 10 + }"#; + let device = parse_device_authorization_body(body, "application/json").unwrap(); + + assert_eq!(device.device_code, "dev-abc"); + assert_eq!(device.user_code, "WDJB-MJHT"); + assert_eq!(device.verification_uri, "https://example.com/activate"); + assert_eq!( + device.verification_uri_complete.as_deref(), + Some("https://example.com/activate?user_code=WDJB-MJHT") + ); + assert_eq!(device.expires_in, 1800); + assert_eq!(device.interval, 10); + } + + #[test] + fn device_authorization_accepts_googles_verification_url_spelling() { + let body = r#"{ + "device_code": "dev-goog", + "user_code": "ABCD-EFGH", + "verification_url": "https://www.google.com/device", + "expires_in": 1800, + "interval": 5 + }"#; + let device = parse_device_authorization_body(body, "application/json").unwrap(); + + assert_eq!(device.verification_uri, "https://www.google.com/device"); + } + + #[test] + fn device_authorization_defaults_missing_expiry_and_interval() { + let body = r#"{"device_code":"d","user_code":"u","verification_uri":"https://x.test"}"#; + let device = parse_device_authorization_body(body, "application/json").unwrap(); + + assert_eq!(device.expires_in, DEVICE_CODE_DEFAULT_EXPIRES_SECS); + assert_eq!(device.interval, DEVICE_POLL_DEFAULT_INTERVAL_SECS); + } + + #[test] + fn device_authorization_parses_form_encoded_body() { + // GitHub answers form-encoded unless asked for JSON. + let body = "device_code=dc123&user_code=ABCD-1234&verification_uri=https%3A%2F%2Fgithub.com%2Flogin%2Fdevice&expires_in=899&interval=5"; + let device = + parse_device_authorization_body(body, "application/x-www-form-urlencoded").unwrap(); + + assert_eq!(device.device_code, "dc123"); + assert_eq!(device.user_code, "ABCD-1234"); + assert_eq!(device.verification_uri, "https://github.com/login/device"); + assert_eq!(device.expires_in, 899); + } + + #[test] + fn device_authorization_surfaces_provider_errors() { + let body = r#"{"error":"unauthorized_client","error_description":"Device flow not enabled"}"#; + let error = parse_device_authorization_body(body, "application/json").unwrap_err(); + + assert!(error.contains("unauthorized_client")); + assert!(error.contains("Device flow not enabled")); + } + + #[test] + fn device_poll_treats_pending_as_keep_going() { + let body = r#"{"error":"authorization_pending"}"#; + assert!(matches!( + parse_device_poll_body(body, "application/json"), + Ok(DevicePollOutcome::Pending) + )); + } + + #[test] + fn device_poll_treats_github_200_form_pending_as_keep_going() { + // GitHub reports poll state with HTTP 200 and a form body, so the parse + // must not depend on the status code or JSON. + let body = "error=authorization_pending&error_description=The+authorization+request+is+still+pending"; + assert!(matches!( + parse_device_poll_body(body, "application/x-www-form-urlencoded"), + Ok(DevicePollOutcome::Pending) + )); + } + + #[test] + fn device_poll_recognizes_slow_down() { + let body = r#"{"error":"slow_down"}"#; + assert!(matches!( + parse_device_poll_body(body, "application/json"), + Ok(DevicePollOutcome::SlowDown) + )); + } + + #[test] + fn device_poll_fails_on_denial() { + let body = r#"{"error":"access_denied","error_description":"User declined"}"#; + let error = parse_device_poll_body(body, "application/json").unwrap_err(); + + assert!(error.contains("access_denied")); + assert!(error.contains("User declined")); + } + + #[test] + fn device_poll_returns_the_token_when_approved() { + let body = r#"{"access_token":"tok-1","token_type":"bearer","expires_in":3600}"#; + let outcome = parse_device_poll_body(body, "application/json").unwrap(); + + match outcome { + DevicePollOutcome::Token(token) => { + assert_eq!(token.access_token, "tok-1"); + assert_eq!(token.expires_in, Some(3600)); + } + _ => panic!("expected a token"), + } + } } diff --git a/src/components/OAuthConfigurator.tsx b/src/components/OAuthConfigurator.tsx index df212bd..b010e22 100644 --- a/src/components/OAuthConfigurator.tsx +++ b/src/components/OAuthConfigurator.tsx @@ -5,6 +5,7 @@ import { Badge } from "@/components/ui/badge" import { Switch } from "@/components/ui/switch" import { Label } from "@/components/ui/label" import { OAuth2Config, OAuth2GrantType } from "@/types" +import { CopyButton } from "@/components/CopyButton" import { useThemeClass } from "@/hooks/useThemeClass" import { useOAuth2TokenActions } from "@/hooks/useOAuth2TokenActions" import { useEnvironmentStore } from "@/store/environments" @@ -25,6 +26,7 @@ const GRANT_TYPES: { value: OAuth2GrantType; label: string; description: string { value: 'authorization_code', label: 'Authorization Code', description: 'Redirect-based flow for user auth' }, { value: 'client_credentials', label: 'Client Credentials', description: 'Server-to-server auth' }, { value: 'password', label: 'Password', description: 'Direct username/password auth' }, + { value: 'device_code', label: 'Device Code', description: 'Enter a code in the browser — no redirect URI needed' }, ] /** A small section wrapper with a label and icon */ @@ -78,6 +80,7 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change, flowKey }: OAuthConf refreshToken, clearToken, cancelTokenRequest, + devicePrompt, isExpired, expiresIn, } = useOAuth2TokenActions({ oauth2, onOAuth2Change, flowKey }) @@ -107,6 +110,7 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change, flowKey }: OAuthConf const updates: Partial = {} if (discovery.authorizationEndpoint) updates.authUrl = discovery.authorizationEndpoint if (discovery.tokenEndpoint) updates.tokenUrl = discovery.tokenEndpoint + if (discovery.deviceAuthorizationEndpoint) updates.deviceAuthUrl = discovery.deviceAuthorizationEndpoint // Scope is deliberately NOT auto-filled for client credentials. The // discovery document's `scopes_supported` advertises what the identity @@ -129,6 +133,7 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change, flowKey }: OAuthConf const filled = [ updates.authUrl && 'authorization URL', updates.tokenUrl && 'token URL', + updates.deviceAuthUrl && 'device authorization URL', updates.scope && 'scope', ].filter(Boolean) setDiscoveryNote(`Filled ${filled.join(', ')}`) @@ -295,6 +300,38 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change, flowKey }: OAuthConf + ) : oauth2.grantType === 'device_code' ? ( + <> +
+ + updateField('deviceAuthUrl', e.target.value)} + className="font-mono text-[13px] bg-background/50" + /> + + + updateField('tokenUrl', e.target.value)} + className="font-mono text-[13px] bg-background/50" + /> + +
+ + updateField('scope', e.target.value)} + className="font-mono text-[13px] bg-background/50" + /> + + ) : (
@@ -441,6 +478,43 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change, flowKey }: OAuthConf )} + {/* + The device flow's waiting room: the Rust command is still polling the + provider, and nothing more happens until the user enters this code in + the browser. Rendered from store state so it survives tab switches, + exactly like the flow's spinner. + */} + {devicePrompt && isLoading && ( +
+

+ Enter this code at{' '} + + {devicePrompt.verificationUri} + +

+
+ + {devicePrompt.userCode} + + +
+

+ The verification page opened in your browser — waiting for you to approve. +

+
+ )} + {/* Error */} {tokenError && (
@@ -470,10 +544,10 @@ export function OAuthConfigurator({ oauth2, onOAuth2Change, flowKey }: OAuthConf {/* - Only the authorization code flow parks waiting on the browser, and it - can wait forever: if the redirect URI is not registered the provider - shows an error page and never redirects back, so nothing ever arrives - on the callback listener. + The authorization code and device code flows park waiting on the + browser, and can wait a long time: an unregistered redirect URI never + redirects back, and a device code nobody enters polls until it + expires. */} {cancelTokenRequest && (