From f47bb2a294e81f51970868f666476095a7ebf761 Mon Sep 17 00:00:00 2001 From: Marijn van der Werf Date: Sun, 22 Feb 2026 22:22:06 +0100 Subject: [PATCH 1/9] Add OpenAI ChatGPT Plus OAuth device flow and provider UI --- interface/src/api/client.ts | 39 ++++ interface/src/routes/Settings.tsx | 128 +++++++++++ src/api/providers.rs | 230 ++++++++++++++++---- src/api/server.rs | 8 + src/config.rs | 24 ++- src/lib.rs | 1 + src/llm/manager.rs | 118 +++++++++-- src/llm/model.rs | 41 +++- src/main.rs | 24 ++- src/openai_auth.rs | 341 ++++++++++++++++++++++++++++++ 10 files changed, 883 insertions(+), 71 deletions(-) create mode 100644 src/openai_auth.rs diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 8a320a265..5fc4e9ca3 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -695,6 +695,15 @@ export interface ProviderModelTestResponse { sample: string | null; } +export interface OpenAiOAuthStartResponse { + success: boolean; + message: string; + device_auth_id: string | null; + user_code: string | null; + poll_interval_secs: number | null; + authorization_url: string | null; +} + // -- Model Types -- export interface ModelInfo { @@ -1153,6 +1162,36 @@ export const api = { } return response.json() as Promise; }, + startOpenAiOAuth: async () => { + const response = await fetch(`${API_BASE}/providers/openai/oauth/start`, { + method: "POST", + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, + completeOpenAiOAuth: async (params: { + deviceAuthId: string; + userCode: string; + pollIntervalSecs: number; + model: string; + }) => { + const response = await fetch(`${API_BASE}/providers/openai/oauth/complete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device_auth_id: params.deviceAuthId, + user_code: params.userCode, + poll_interval_secs: params.pollIntervalSecs, + model: params.model, + }), + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, removeProvider: async (provider: string) => { const response = await fetch(`${API_BASE}/providers/${encodeURIComponent(provider)}`, { method: "DELETE", diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index f7f13fb56..d14d4cbb1 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -236,6 +236,12 @@ export function Settings() { message: string; sample?: string | null; } | null>(null); + const [openAiOAuthSession, setOpenAiOAuthSession] = useState<{ + deviceAuthId: string; + userCode: string; + pollIntervalSecs: number; + authorizationUrl: string; + } | null>(null); const [message, setMessage] = useState<{ text: string; type: "success" | "error"; @@ -287,6 +293,17 @@ export function Settings() { mutationFn: ({ provider, apiKey, model }: { provider: string; apiKey: string; model: string }) => api.testProviderModel(provider, apiKey, model), }); + const startOpenAiOAuthMutation = useMutation({ + mutationFn: () => api.startOpenAiOAuth(), + }); + const completeOpenAiOAuthMutation = useMutation({ + mutationFn: (params: { + deviceAuthId: string; + userCode: string; + pollIntervalSecs: number; + model: string; + }) => api.completeOpenAiOAuth(params), + }); const removeMutation = useMutation({ mutationFn: (provider: string) => api.removeProvider(provider), @@ -347,12 +364,79 @@ export function Settings() { }); }; + const handleStartOpenAiOAuth = async () => { + if (!modelInput.trim()) { + setMessage({text: "Model cannot be empty", type: "error"}); + return; + } + + setMessage(null); + try { + const result = await startOpenAiOAuthMutation.mutateAsync(); + if ( + !result.success || + !result.device_auth_id || + !result.user_code || + !result.poll_interval_secs || + !result.authorization_url + ) { + setMessage({text: result.message || "Failed to start ChatGPT Plus sign-in", type: "error"}); + return; + } + + setOpenAiOAuthSession({ + deviceAuthId: result.device_auth_id, + userCode: result.user_code, + pollIntervalSecs: result.poll_interval_secs, + authorizationUrl: result.authorization_url, + }); + window.open(result.authorization_url, "_blank", "noopener,noreferrer"); + setMessage({text: "Sign-in started. Complete authorization in your browser, then click Complete sign-in.", type: "success"}); + } catch (error: any) { + setMessage({text: `Failed: ${error.message}`, type: "error"}); + } + }; + + const handleCompleteOpenAiOAuth = async () => { + if (!openAiOAuthSession || !modelInput.trim()) return; + setMessage(null); + + try { + const result = await completeOpenAiOAuthMutation.mutateAsync({ + deviceAuthId: openAiOAuthSession.deviceAuthId, + userCode: openAiOAuthSession.userCode, + pollIntervalSecs: openAiOAuthSession.pollIntervalSecs, + model: modelInput.trim(), + }); + + if (result.success) { + setEditingProvider(null); + setKeyInput(""); + setModelInput(""); + setTestedSignature(null); + setTestResult(null); + setOpenAiOAuthSession(null); + setMessage({text: result.message, type: "success"}); + queryClient.invalidateQueries({queryKey: ["providers"]}); + setTimeout(() => { + queryClient.invalidateQueries({queryKey: ["agents"]}); + queryClient.invalidateQueries({queryKey: ["overview"]}); + }, 3000); + } else { + setMessage({text: result.message, type: "error"}); + } + } catch (error: any) { + setMessage({text: `Failed: ${error.message}`, type: "error"}); + } + }; + const handleClose = () => { setEditingProvider(null); setKeyInput(""); setModelInput(""); setTestedSignature(null); setTestResult(null); + setOpenAiOAuthSession(null); }; const isConfigured = (providerId: string): boolean => { @@ -483,6 +567,8 @@ export function Settings() { {editingProvider === "ollama" ? `Enter your ${editingProviderData?.name} base URL. It will be saved to your instance config.` + : editingProvider === "openai" + ? "Enter an OpenAI API key or use ChatGPT Plus sign-in. The model below will be applied to routing." : `Enter your ${editingProviderData?.name} API key. It will be saved to your instance config.`} @@ -509,6 +595,48 @@ export function Settings() { }} provider={editingProvider ?? undefined} /> + {editingProvider === "openai" && ( +
+
+ Use your ChatGPT Plus account instead of an API key. +
+
+ + +
+ {openAiOAuthSession && ( +
+
+ User code: {openAiOAuthSession.userCode} +
+ + Open verification page + +
+ )} +
+ )}
+
)} + {openAiBrowserOAuthState && ( +
+ Browser OAuth state: {openAiBrowserOAuthState} +
+ )} )}
diff --git a/src/api/providers.rs b/src/api/providers.rs index a751d9981..c586a1232 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -1,13 +1,38 @@ use super::state::ApiState; +use anyhow::Context as _; use axum::Json; -use axum::extract::State; +use axum::extract::{Query, State}; use axum::http::StatusCode; +use axum::response::Html; +use reqwest::Url; use rig::agent::AgentBuilder; use rig::completion::{CompletionModel as _, Prompt as _}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; +use tokio::sync::RwLock; + +const OPENAI_BROWSER_OAUTH_SESSION_TTL_SECS: i64 = 15 * 60; + +static OPENAI_BROWSER_OAUTH_SESSIONS: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +#[derive(Clone, Debug)] +struct BrowserOAuthSession { + pkce_verifier: String, + redirect_uri: String, + model: String, + created_at: i64, + status: BrowserOAuthSessionStatus, +} + +#[derive(Clone, Debug)] +enum BrowserOAuthSessionStatus { + Pending, + Completed(String), + Failed(String), +} #[derive(Serialize)] pub(super) struct ProviderStatus { @@ -84,6 +109,41 @@ pub(super) struct OpenAiOAuthCompleteRequest { model: String, } +#[derive(Deserialize)] +pub(super) struct OpenAiOAuthBrowserStartRequest { + redirect_uri: String, + model: String, +} + +#[derive(Serialize)] +pub(super) struct OpenAiOAuthBrowserStartResponse { + success: bool, + message: String, + authorization_url: Option, + state: Option, +} + +#[derive(Deserialize)] +pub(super) struct OpenAiOAuthBrowserStatusRequest { + state: String, +} + +#[derive(Serialize)] +pub(super) struct OpenAiOAuthBrowserStatusResponse { + found: bool, + done: bool, + success: bool, + message: Option, +} + +#[derive(Deserialize)] +pub(super) struct OpenAiOAuthBrowserCallbackQuery { + code: Option, + state: Option, + error: Option, + error_description: Option, +} + fn provider_toml_key(provider: &str) -> Option<&'static str> { match provider { "anthropic" => Some("anthropic_key"), @@ -296,6 +356,65 @@ fn apply_model_routing(doc: &mut toml_edit::DocumentMut, model: &str) { } } +async fn prune_expired_browser_oauth_sessions() { + let cutoff = chrono::Utc::now().timestamp() - OPENAI_BROWSER_OAUTH_SESSION_TTL_SECS; + let mut sessions = OPENAI_BROWSER_OAUTH_SESSIONS.write().await; + sessions.retain(|_, session| session.created_at >= cutoff); +} + +fn browser_oauth_success_html() -> String { + r#" + + + + Spacebot OpenAI Sign-in + + + +
+

Sign-in complete

+

You can close this window and return to Spacebot settings.

+
+ + +"# + .to_string() +} + +fn browser_oauth_error_html(message: &str) -> String { + let escaped = message + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + format!( + r#" + + + + Spacebot OpenAI Sign-in + + + +
+

Sign-in failed

+

{}

+
+ +"#, + escaped + ) +} + pub(super) async fn get_providers( State(state): State>, ) -> Result, StatusCode> { @@ -547,6 +666,266 @@ pub(super) async fn complete_openai_oauth( })) } +pub(super) async fn start_openai_browser_oauth( + Json(request): Json, +) -> Result, StatusCode> { + if request.model.trim().is_empty() { + return Ok(Json(OpenAiOAuthBrowserStartResponse { + success: false, + message: "Model cannot be empty".to_string(), + authorization_url: None, + state: None, + })); + } + if !model_matches_provider("openai", &request.model) { + return Ok(Json(OpenAiOAuthBrowserStartResponse { + success: false, + message: format!( + "Model '{}' does not match provider 'openai'.", + request.model + ), + authorization_url: None, + state: None, + })); + } + + let redirect_uri = request.redirect_uri.trim(); + let parsed_redirect = match Url::parse(redirect_uri) { + Ok(parsed) => parsed, + Err(error) => { + return Ok(Json(OpenAiOAuthBrowserStartResponse { + success: false, + message: format!("Invalid redirect URI: {error}"), + authorization_url: None, + state: None, + })); + } + }; + if parsed_redirect.scheme() != "https" && parsed_redirect.scheme() != "http" { + return Ok(Json(OpenAiOAuthBrowserStartResponse { + success: false, + message: "Redirect URI must use http or https".to_string(), + authorization_url: None, + state: None, + })); + } + + prune_expired_browser_oauth_sessions().await; + let browser_authorization = crate::openai_auth::start_browser_authorization(redirect_uri); + let state_key = browser_authorization.state.clone(); + + OPENAI_BROWSER_OAUTH_SESSIONS.write().await.insert( + state_key.clone(), + BrowserOAuthSession { + pkce_verifier: browser_authorization.pkce_verifier, + redirect_uri: redirect_uri.to_string(), + model: request.model.trim().to_string(), + created_at: chrono::Utc::now().timestamp(), + status: BrowserOAuthSessionStatus::Pending, + }, + ); + + Ok(Json(OpenAiOAuthBrowserStartResponse { + success: true, + message: "OpenAI browser OAuth started".to_string(), + authorization_url: Some(browser_authorization.authorization_url), + state: Some(state_key), + })) +} + +pub(super) async fn openai_browser_oauth_status( + Query(request): Query, +) -> Result, StatusCode> { + prune_expired_browser_oauth_sessions().await; + if request.state.trim().is_empty() { + return Ok(Json(OpenAiOAuthBrowserStatusResponse { + found: false, + done: false, + success: false, + message: Some("Missing OAuth state".to_string()), + })); + } + + let sessions = OPENAI_BROWSER_OAUTH_SESSIONS.read().await; + let Some(session) = sessions.get(request.state.trim()) else { + return Ok(Json(OpenAiOAuthBrowserStatusResponse { + found: false, + done: false, + success: false, + message: None, + })); + }; + + let response = match &session.status { + BrowserOAuthSessionStatus::Pending => OpenAiOAuthBrowserStatusResponse { + found: true, + done: false, + success: false, + message: None, + }, + BrowserOAuthSessionStatus::Completed(message) => OpenAiOAuthBrowserStatusResponse { + found: true, + done: true, + success: true, + message: Some(message.clone()), + }, + BrowserOAuthSessionStatus::Failed(message) => OpenAiOAuthBrowserStatusResponse { + found: true, + done: true, + success: false, + message: Some(message.clone()), + }, + }; + Ok(Json(response)) +} + +pub(super) async fn openai_browser_oauth_callback( + State(state): State>, + Query(query): Query, +) -> Html { + prune_expired_browser_oauth_sessions().await; + + let Some(state_key) = query + .state + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + else { + return Html(browser_oauth_error_html("Missing OAuth state.")); + }; + + if let Some(error_code) = query.error.as_deref() { + let mut message = format!("OpenAI returned OAuth error: {}", error_code); + if let Some(description) = query.error_description.as_deref() { + message.push_str(&format!(" ({})", description)); + } + if let Some(session) = OPENAI_BROWSER_OAUTH_SESSIONS + .write() + .await + .get_mut(&state_key) + { + session.status = BrowserOAuthSessionStatus::Failed(message.clone()); + } + return Html(browser_oauth_error_html(&message)); + } + + let Some(code) = query + .code + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + let message = "OpenAI callback did not include an authorization code."; + if let Some(session) = OPENAI_BROWSER_OAUTH_SESSIONS + .write() + .await + .get_mut(&state_key) + { + session.status = BrowserOAuthSessionStatus::Failed(message.to_string()); + } + return Html(browser_oauth_error_html(message)); + }; + + let (pkce_verifier, redirect_uri, model) = { + let sessions = OPENAI_BROWSER_OAUTH_SESSIONS.read().await; + let Some(session) = sessions.get(&state_key) else { + return Html(browser_oauth_error_html( + "OAuth session expired or was not found. Start sign-in again.", + )); + }; + ( + session.pkce_verifier.clone(), + session.redirect_uri.clone(), + session.model.clone(), + ) + }; + + let credentials = match crate::openai_auth::exchange_browser_code( + code, + &redirect_uri, + &pkce_verifier, + ) + .await + { + Ok(credentials) => credentials, + Err(error) => { + let message = format!("Failed to exchange OpenAI authorization code: {error}"); + if let Some(session) = OPENAI_BROWSER_OAUTH_SESSIONS + .write() + .await + .get_mut(&state_key) + { + session.status = BrowserOAuthSessionStatus::Failed(message.clone()); + } + return Html(browser_oauth_error_html(&message)); + } + }; + + let persist_result = async { + let instance_dir = (**state.instance_dir.load()).clone(); + crate::openai_auth::save_credentials(&instance_dir, &credentials) + .context("failed to save OpenAI OAuth credentials")?; + + if let Some(llm_manager) = state.llm_manager.read().await.as_ref() { + llm_manager + .set_openai_oauth_credentials(credentials.clone()) + .await; + } + + let config_path = state.config_path.read().await.clone(); + let content = if config_path.exists() { + tokio::fs::read_to_string(&config_path) + .await + .context("failed to read config.toml")? + } else { + String::new() + }; + + let mut doc: toml_edit::DocumentMut = + content.parse().context("failed to parse config.toml")?; + apply_model_routing(&mut doc, &model); + tokio::fs::write(&config_path, doc.to_string()) + .await + .context("failed to write config.toml")?; + + state + .provider_setup_tx + .try_send(crate::ProviderSetupEvent::ProvidersConfigured) + .ok(); + + anyhow::Ok(()) + } + .await; + + match persist_result { + Ok(()) => { + if let Some(session) = OPENAI_BROWSER_OAUTH_SESSIONS + .write() + .await + .get_mut(&state_key) + { + session.status = BrowserOAuthSessionStatus::Completed(format!( + "OpenAI configured via browser OAuth. Model '{}' applied to defaults and default agent routing.", + model + )); + } + Html(browser_oauth_success_html()) + } + Err(error) => { + let message = format!("OAuth sign-in completed but finalization failed: {error}"); + if let Some(session) = OPENAI_BROWSER_OAUTH_SESSIONS + .write() + .await + .get_mut(&state_key) + { + session.status = BrowserOAuthSessionStatus::Failed(message.clone()); + } + Html(browser_oauth_error_html(&message)) + } + } +} + pub(super) async fn update_provider( State(state): State>, Json(request): Json, diff --git a/src/api/server.rs b/src/api/server.rs index 65d41233a..72dc8b1e7 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -131,6 +131,18 @@ pub async fn start_http_server( "/providers/openai/oauth/complete", post(providers::complete_openai_oauth), ) + .route( + "/providers/openai/oauth/browser/start", + post(providers::start_openai_browser_oauth), + ) + .route( + "/providers/openai/oauth/browser/status", + get(providers::openai_browser_oauth_status), + ) + .route( + "/providers/openai/oauth/browser/callback", + get(providers::openai_browser_oauth_callback), + ) .route("/providers/test", post(providers::test_provider_model)) .route("/providers/{provider}", delete(providers::delete_provider)) .route("/models", get(models::get_models)) @@ -204,7 +216,10 @@ async fn api_auth_middleware( }; let path = request.uri().path(); - if path == "/api/health" || path == "/health" { + if path == "/api/health" + || path == "/health" + || path.ends_with("/api/providers/openai/oauth/browser/callback") + { return next.run(request).await; } diff --git a/src/openai_auth.rs b/src/openai_auth.rs index e79cee873..510a75779 100644 --- a/src/openai_auth.rs +++ b/src/openai_auth.rs @@ -3,17 +3,21 @@ use anyhow::{Context as _, Result}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use rand::RngCore as _; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; use std::path::{Path, PathBuf}; use std::time::Duration; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize"; const DEVICE_CODE_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/usercode"; const DEVICE_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/token"; const OAUTH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; const DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback"; const AUTHORIZATION_URL: &str = "https://auth.openai.com/codex/device"; +const BROWSER_SCOPES: &str = "openid profile email offline_access"; const POLLING_SAFETY_MARGIN_MS: u64 = 3000; const DEVICE_FLOW_TIMEOUT_SECS: u64 = 5 * 60; @@ -126,6 +130,48 @@ pub struct DeviceAuthorization { pub authorization_url: String, } +/// Data needed to complete OpenAI browser OAuth. +#[derive(Debug, Clone, Serialize)] +pub struct BrowserAuthorization { + pub authorization_url: String, + pub state: String, + pub pkce_verifier: String, +} + +fn generate_random_urlsafe_string(bytes_len: usize) -> String { + let mut bytes = vec![0u8; bytes_len]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn generate_pkce() -> (String, String) { + let verifier = generate_random_urlsafe_string(64); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + (verifier, challenge) +} + +/// Build a browser-based OAuth authorization URL using PKCE. +pub fn start_browser_authorization(redirect_uri: &str) -> BrowserAuthorization { + let (pkce_verifier, pkce_challenge) = generate_pkce(); + let state = generate_random_urlsafe_string(32); + + let authorization_url = format!( + "{authorize}?response_type=code&client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}&code_challenge={challenge}&code_challenge_method=S256&id_token_add_organizations=true&codex_cli_simplified_flow=true&originator=spacebot&state={state}", + authorize = AUTHORIZE_URL, + client_id = urlencoding::encode(CLIENT_ID), + redirect_uri = urlencoding::encode(redirect_uri), + scope = urlencoding::encode(BROWSER_SCOPES), + challenge = urlencoding::encode(&pkce_challenge), + state = urlencoding::encode(&state), + ); + + BrowserAuthorization { + authorization_url, + state, + pkce_verifier, + } +} + fn parse_jwt_claims(token: &str) -> Option { let mut parts = token.split('.'); let _header = parts.next()?; @@ -303,6 +349,57 @@ pub async fn complete_device_authorization( }) } +/// Exchange an OAuth authorization code from browser flow for tokens. +pub async fn exchange_browser_code( + code: &str, + redirect_uri: &str, + pkce_verifier: &str, +) -> Result { + let client = reqwest::Client::new(); + let response = client + .post(OAUTH_TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", CLIENT_ID), + ("code_verifier", pkce_verifier), + ]) + .send() + .await + .context("failed to exchange OpenAI browser authorization code for tokens")?; + + let status = response.status(); + let body = response + .text() + .await + .context("failed to read OpenAI browser token exchange response")?; + + if !status.is_success() { + anyhow::bail!( + "OpenAI browser token exchange failed ({}): {}", + status, + body + ); + } + + let token_response: TokenResponse = serde_json::from_str(&body) + .context("failed to parse OpenAI browser token exchange response")?; + let account_id = extract_account_id(&token_response); + let refresh_token = token_response + .refresh_token + .context("OpenAI browser token response did not include refresh_token")?; + + Ok(OAuthCredentials { + access_token: token_response.access_token, + refresh_token, + expires_at: chrono::Utc::now().timestamp_millis() + + token_response.expires_in.unwrap_or(3600) * 1000, + account_id, + }) +} + /// Path to OpenAI OAuth credentials within the instance directory. pub fn credentials_path(instance_dir: &Path) -> PathBuf { instance_dir.join("openai_chatgpt_oauth.json") From a59220411786f5ba24f3d6227277fe2bcc930b8b Mon Sep 17 00:00:00 2001 From: Marijn van der Werf Date: Mon, 23 Feb 2026 00:23:23 +0100 Subject: [PATCH 3/9] Fix ChatGPT Plus browser OAuth callback flow and UI label --- interface/src/api/client.ts | 3 +- interface/src/routes/Settings.tsx | 6 +-- src/api/providers.rs | 76 +++++++++++++++++++++++-------- src/api/server.rs | 9 +--- src/openai_auth.rs | 2 +- 5 files changed, 63 insertions(+), 33 deletions(-) diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index be7f09710..f79c89559 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1206,12 +1206,11 @@ export const api = { } return response.json() as Promise; }, - startOpenAiOAuthBrowser: async (params: {redirectUri: string; model: string}) => { + startOpenAiOAuthBrowser: async (params: {model: string}) => { const response = await fetch(`${API_BASE}/providers/openai/oauth/browser/start`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - redirect_uri: params.redirectUri, model: params.model, }), }); diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index a2c2d8f59..d2dec17fb 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -449,9 +449,7 @@ export function Settings() { setMessage(null); try { - const redirectUri = `${window.location.origin}${BASE_PATH}/api/providers/openai/oauth/browser/callback`; const result = await startOpenAiBrowserOAuthMutation.mutateAsync({ - redirectUri, model: modelInput.trim(), }); if (!result.success || !result.authorization_url || !result.state) { @@ -684,7 +682,7 @@ export function Settings() { variant="outline" size="sm" > - Sign in with browser + Sign in with ChatGPT Plus - - -
- {openAiOAuthSession && ( -
-
- User code: {openAiOAuthSession.userCode} -
- - Open verification page - -
- )} - {openAiBrowserOAuthState && ( -
- Browser OAuth state: {openAiBrowserOAuthState} -
- )} - - )}
+
+ + + ); +} diff --git a/src/api/providers.rs b/src/api/providers.rs index f21b463a0..c505cc78f 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -47,6 +47,7 @@ struct BrowserOAuthCallbackServer { pub(super) struct ProviderStatus { anthropic: bool, openai: bool, + openai_chatgpt: bool, openrouter: bool, zhipu: bool, groq: bool, @@ -100,24 +101,6 @@ pub(super) struct ProviderModelTestResponse { sample: Option, } -#[derive(Serialize)] -pub(super) struct OpenAiOAuthStartResponse { - success: bool, - message: String, - device_auth_id: Option, - user_code: Option, - poll_interval_secs: Option, - authorization_url: Option, -} - -#[derive(Deserialize)] -pub(super) struct OpenAiOAuthCompleteRequest { - device_auth_id: String, - user_code: String, - poll_interval_secs: u64, - model: String, -} - #[derive(Deserialize)] pub(super) struct OpenAiOAuthBrowserStartRequest { model: String, @@ -486,6 +469,7 @@ pub(super) async fn get_providers( let ( anthropic, openai, + openai_chatgpt, openrouter, zhipu, groq, @@ -525,7 +509,8 @@ pub(super) async fn get_providers( ( has_value("anthropic_key", "ANTHROPIC_API_KEY"), - has_value("openai_key", "OPENAI_API_KEY") || openai_oauth_configured, + has_value("openai_key", "OPENAI_API_KEY"), + openai_oauth_configured, has_value("openrouter_key", "OPENROUTER_API_KEY"), has_value("zhipu_key", "ZHIPU_API_KEY"), has_value("groq_key", "GROQ_API_KEY"), @@ -547,7 +532,8 @@ pub(super) async fn get_providers( } else { ( std::env::var("ANTHROPIC_API_KEY").is_ok(), - std::env::var("OPENAI_API_KEY").is_ok() || openai_oauth_configured, + std::env::var("OPENAI_API_KEY").is_ok(), + openai_oauth_configured, std::env::var("OPENROUTER_API_KEY").is_ok(), std::env::var("ZHIPU_API_KEY").is_ok(), std::env::var("GROQ_API_KEY").is_ok(), @@ -570,6 +556,7 @@ pub(super) async fn get_providers( let providers = ProviderStatus { anthropic, openai, + openai_chatgpt, openrouter, zhipu, groq, @@ -589,6 +576,7 @@ pub(super) async fn get_providers( }; let has_any = providers.anthropic || providers.openai + || providers.openai_chatgpt || providers.openrouter || providers.zhipu || providers.groq @@ -609,124 +597,6 @@ pub(super) async fn get_providers( Ok(Json(ProvidersResponse { providers, has_any })) } -pub(super) async fn start_openai_oauth() -> Result, StatusCode> { - match crate::openai_auth::start_device_authorization().await { - Ok(auth) => Ok(Json(OpenAiOAuthStartResponse { - success: true, - message: "OpenAI device authorization started".to_string(), - device_auth_id: Some(auth.device_auth_id), - user_code: Some(auth.user_code), - poll_interval_secs: Some(auth.poll_interval_secs), - authorization_url: Some(auth.authorization_url), - })), - Err(error) => Ok(Json(OpenAiOAuthStartResponse { - success: false, - message: format!("Failed to start ChatGPT Plus sign-in: {error}"), - device_auth_id: None, - user_code: None, - poll_interval_secs: None, - authorization_url: None, - })), - } -} - -pub(super) async fn complete_openai_oauth( - State(state): State>, - Json(request): Json, -) -> Result, StatusCode> { - if request.device_auth_id.trim().is_empty() { - return Ok(Json(ProviderUpdateResponse { - success: false, - message: "Missing device_auth_id".to_string(), - })); - } - - if request.user_code.trim().is_empty() { - return Ok(Json(ProviderUpdateResponse { - success: false, - message: "Missing user_code".to_string(), - })); - } - - if request.model.trim().is_empty() { - return Ok(Json(ProviderUpdateResponse { - success: false, - message: "Model cannot be empty".to_string(), - })); - } - - let Some(chatgpt_model) = normalize_openai_chatgpt_model(&request.model) else { - return Ok(Json(ProviderUpdateResponse { - success: false, - message: format!( - "Model '{}' must use provider 'openai' or 'openai-chatgpt'.", - request.model - ), - })); - }; - - let credentials = match crate::openai_auth::complete_device_authorization( - &request.device_auth_id, - &request.user_code, - request.poll_interval_secs, - ) - .await - { - Ok(credentials) => credentials, - Err(error) => { - return Ok(Json(ProviderUpdateResponse { - success: false, - message: format!("Failed to complete ChatGPT Plus sign-in: {error}"), - })); - } - }; - - let instance_dir = (**state.instance_dir.load()).clone(); - if let Err(error) = crate::openai_auth::save_credentials(&instance_dir, &credentials) { - return Ok(Json(ProviderUpdateResponse { - success: false, - message: format!("Failed to save OpenAI OAuth credentials: {error}"), - })); - } - - if let Some(llm_manager) = state.llm_manager.read().await.as_ref() { - llm_manager - .set_openai_oauth_credentials(credentials.clone()) - .await; - } - - let config_path = state.config_path.read().await.clone(); - let content = if config_path.exists() { - tokio::fs::read_to_string(&config_path) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - } else { - String::new() - }; - - let mut doc: toml_edit::DocumentMut = content - .parse() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - apply_model_routing(&mut doc, &chatgpt_model); - - tokio::fs::write(&config_path, doc.to_string()) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - state - .provider_setup_tx - .try_send(crate::ProviderSetupEvent::ProvidersConfigured) - .ok(); - - Ok(Json(ProviderUpdateResponse { - success: true, - message: format!( - "OpenAI configured via ChatGPT Plus OAuth. Model '{}' applied to defaults and the default agent routing.", - chatgpt_model - ), - })) -} - pub(super) async fn start_openai_browser_oauth( State(state): State>, Json(request): Json, diff --git a/src/api/server.rs b/src/api/server.rs index a92196028..29bdaa847 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -123,14 +123,6 @@ pub async fn start_http_server( "/providers", get(providers::get_providers).put(providers::update_provider), ) - .route( - "/providers/openai/oauth/start", - post(providers::start_openai_oauth), - ) - .route( - "/providers/openai/oauth/complete", - post(providers::complete_openai_oauth), - ) .route( "/providers/openai/oauth/browser/start", post(providers::start_openai_browser_oauth), diff --git a/src/openai_auth.rs b/src/openai_auth.rs index 6c66c6553..bdf615f01 100644 --- a/src/openai_auth.rs +++ b/src/openai_auth.rs @@ -1,25 +1,17 @@ -//! OpenAI ChatGPT Plus OAuth device flow, token exchange, refresh, and storage. +//! OpenAI ChatGPT Plus OAuth browser flow, token exchange, refresh, and storage. use anyhow::{Context as _, Result}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use rand::RngCore as _; -use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use std::path::{Path, PathBuf}; -use std::time::Duration; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize"; -const DEVICE_CODE_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/usercode"; -const DEVICE_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/token"; const OAUTH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; -const DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback"; -const AUTHORIZATION_URL: &str = "https://auth.openai.com/codex/device"; const BROWSER_SCOPES: &str = "openid profile email offline_access"; -const POLLING_SAFETY_MARGIN_MS: u64 = 3000; -const DEVICE_FLOW_TIMEOUT_SECS: u64 = 5 * 60; /// Stored OpenAI OAuth credentials. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -82,19 +74,6 @@ impl OAuthCredentials { } } -#[derive(Debug, Deserialize)] -struct DeviceCodeResponse { - device_auth_id: String, - user_code: String, - interval: String, -} - -#[derive(Debug, Deserialize)] -struct DeviceTokenResponse { - authorization_code: String, - code_verifier: String, -} - #[derive(Debug, Deserialize)] struct TokenResponse { access_token: String, @@ -121,15 +100,6 @@ struct TokenOpenAiAuthClaims { chatgpt_account_id: Option, } -/// Data needed by the UI to finish OpenAI device auth. -#[derive(Debug, Clone, Serialize)] -pub struct DeviceAuthorization { - pub device_auth_id: String, - pub user_code: String, - pub poll_interval_secs: u64, - pub authorization_url: String, -} - /// Data needed to complete OpenAI browser OAuth. #[derive(Debug, Clone, Serialize)] pub struct BrowserAuthorization { @@ -206,149 +176,6 @@ fn extract_account_id(token_response: &TokenResponse) -> Option { .or_else(|| parse_jwt_claims(&token_response.access_token).and_then(from_claims)) } -/// Start OpenAI device authorization and return a user code + poll details. -pub async fn start_device_authorization() -> Result { - let client = reqwest::Client::new(); - let response = client - .post(DEVICE_CODE_URL) - .header("Content-Type", "application/json") - .header( - "User-Agent", - format!("spacebot/{}", env!("CARGO_PKG_VERSION")), - ) - .json(&serde_json::json!({ "client_id": CLIENT_ID })) - .send() - .await - .context("failed to start OpenAI device authorization")?; - - let status = response.status(); - let body = response - .text() - .await - .context("failed to read OpenAI device authorization response")?; - - if !status.is_success() { - anyhow::bail!("OpenAI device authorization failed ({}): {}", status, body); - } - - let parsed: DeviceCodeResponse = serde_json::from_str(&body) - .context("failed to parse OpenAI device authorization response")?; - let poll_interval_secs = parsed.interval.parse::().unwrap_or(5).max(1); - - Ok(DeviceAuthorization { - device_auth_id: parsed.device_auth_id, - user_code: parsed.user_code, - poll_interval_secs, - authorization_url: AUTHORIZATION_URL.to_string(), - }) -} - -async fn poll_device_authorization( - device_auth_id: &str, - user_code: &str, - poll_interval_secs: u64, -) -> Result { - let client = reqwest::Client::new(); - let start = tokio::time::Instant::now(); - let poll_delay = - Duration::from_secs(poll_interval_secs) + Duration::from_millis(POLLING_SAFETY_MARGIN_MS); - - loop { - if start.elapsed() > Duration::from_secs(DEVICE_FLOW_TIMEOUT_SECS) { - anyhow::bail!("OpenAI device authorization timed out"); - } - - let response = client - .post(DEVICE_TOKEN_URL) - .header("Content-Type", "application/json") - .header( - "User-Agent", - format!("spacebot/{}", env!("CARGO_PKG_VERSION")), - ) - .json(&serde_json::json!({ - "device_auth_id": device_auth_id, - "user_code": user_code, - })) - .send() - .await - .context("failed to poll OpenAI device authorization")?; - - let status = response.status(); - let body = response - .text() - .await - .context("failed to read OpenAI device authorization poll response")?; - - if status.is_success() { - let parsed: DeviceTokenResponse = serde_json::from_str(&body) - .context("failed to parse OpenAI device authorization poll response")?; - return Ok(parsed); - } - - if status == StatusCode::FORBIDDEN || status == StatusCode::NOT_FOUND { - tokio::time::sleep(poll_delay).await; - continue; - } - - anyhow::bail!( - "OpenAI device authorization polling failed ({}): {}", - status, - body - ); - } -} - -/// Complete OpenAI device authorization by polling and exchanging for OAuth tokens. -pub async fn complete_device_authorization( - device_auth_id: &str, - user_code: &str, - poll_interval_secs: u64, -) -> Result { - let device_token = poll_device_authorization(device_auth_id, user_code, poll_interval_secs) - .await - .context("failed to complete OpenAI device authorization")?; - - let client = reqwest::Client::new(); - let response = client - .post(OAUTH_TOKEN_URL) - .header("Content-Type", "application/x-www-form-urlencoded") - .form(&[ - ("grant_type", "authorization_code"), - ("code", device_token.authorization_code.as_str()), - ("redirect_uri", DEVICE_REDIRECT_URI), - ("client_id", CLIENT_ID), - ("code_verifier", device_token.code_verifier.as_str()), - ]) - .send() - .await - .context("failed to exchange OpenAI authorization code for tokens")?; - - let status = response.status(); - let body = response - .text() - .await - .context("failed to read OpenAI token exchange response")?; - - if !status.is_success() { - anyhow::bail!("OpenAI token exchange failed ({}): {}", status, body); - } - - let token_response: TokenResponse = - serde_json::from_str(&body).context("failed to parse OpenAI token exchange response")?; - let account_id = extract_account_id(&token_response); - let refresh_token = token_response - .refresh_token - .context("OpenAI token response did not include refresh_token")?; - - Ok(OAuthCredentials { - access_token: token_response.access_token, - refresh_token, - expires_at: chrono::Utc::now().timestamp_millis() - + token_response.expires_in.unwrap_or(3600) * 1000, - account_id, - }) -} - /// Exchange an OAuth authorization code from browser flow for tokens. pub async fn exchange_browser_code( code: &str, From 4b70fefa8ce0003ac9475e9679ff016037c86911 Mon Sep 17 00:00:00 2001 From: Marijn van der Werf Date: Mon, 23 Feb 2026 03:15:52 +0100 Subject: [PATCH 7/9] Simplify configured indicator --- interface/src/routes/Settings.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 9a49ced43..087a3d998 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -1567,8 +1567,9 @@ function ProviderCard({ provider, name, description, configured, defaultModel, o
{name} {configured && ( - - ● Configured + + )}
@@ -1609,8 +1610,9 @@ function ChatGptOAuthCard({ configured, defaultModel, isPolling, message, onSign
ChatGPT Plus (OAuth) {configured && ( - - ● Configured + + )}
From e65801938d344eadecc3aea42224c22681ef833b Mon Sep 17 00:00:00 2001 From: Marijn van der Werf Date: Mon, 23 Feb 2026 03:50:37 +0100 Subject: [PATCH 8/9] Move ChatGPT OAuth callback onto API server --- src/api/providers.rs | 118 +++++++++++++++++++++++-------------------- src/api/server.rs | 4 ++ 2 files changed, 68 insertions(+), 54 deletions(-) diff --git a/src/api/providers.rs b/src/api/providers.rs index c505cc78f..79e9ca72d 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -3,9 +3,9 @@ use super::state::ApiState; use anyhow::Context as _; use axum::Json; use axum::extract::{Query, State}; -use axum::http::StatusCode; -use axum::routing::get; +use axum::http::{HeaderMap, StatusCode}; use axum::response::Html; +use reqwest::Url; use rig::agent::AgentBuilder; use rig::completion::{CompletionModel as _, Prompt as _}; use serde::{Deserialize, Serialize}; @@ -14,14 +14,10 @@ use std::sync::{Arc, LazyLock}; use tokio::sync::RwLock; const OPENAI_BROWSER_OAUTH_SESSION_TTL_SECS: i64 = 15 * 60; -const OPENAI_BROWSER_OAUTH_CALLBACK_BIND: &str = "127.0.0.1:1455"; -const OPENAI_BROWSER_OAUTH_REDIRECT_URI: &str = "http://localhost:1455/auth/callback"; +const OPENAI_BROWSER_OAUTH_REDIRECT_PATH: &str = "/providers/openai/oauth/browser/callback"; static OPENAI_BROWSER_OAUTH_SESSIONS: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); -static OPENAI_BROWSER_OAUTH_CALLBACK_SERVER: LazyLock< - RwLock>, -> = LazyLock::new(|| RwLock::new(None)); #[derive(Clone, Debug)] struct BrowserOAuthSession { @@ -39,10 +35,6 @@ enum BrowserOAuthSessionStatus { Failed(String), } -struct BrowserOAuthCallbackServer { - join_handle: tokio::task::JoinHandle<()>, -} - #[derive(Serialize)] pub(super) struct ProviderStatus { anthropic: bool, @@ -367,43 +359,64 @@ async fn prune_expired_browser_oauth_sessions() { sessions.retain(|_, session| session.created_at >= cutoff); } -async fn ensure_openai_browser_oauth_callback_server(state: Arc) -> anyhow::Result<()> { - let mut callback_server = OPENAI_BROWSER_OAUTH_CALLBACK_SERVER.write().await; - if let Some(existing) = callback_server.as_ref() { - if existing.join_handle.is_finished() { - *callback_server = None; - } else { - return Ok(()); +fn resolve_browser_oauth_redirect_uri(headers: &HeaderMap) -> Option { + if let Some(origin) = header_value(headers, axum::http::header::ORIGIN.as_str()) { + if let Ok(origin_url) = Url::parse(origin) { + let origin = origin_url.origin().ascii_serialization(); + if origin != "null" { + return Some(format!("{origin}{OPENAI_BROWSER_OAUTH_REDIRECT_PATH}")); + } } } - let listener = tokio::net::TcpListener::bind(OPENAI_BROWSER_OAUTH_CALLBACK_BIND) - .await - .with_context(|| { - format!( - "failed to bind local OAuth callback listener on {}", - OPENAI_BROWSER_OAUTH_CALLBACK_BIND - ) - })?; - - let app = axum::Router::new() - .route("/auth/callback", get(openai_browser_oauth_callback)) - .with_state(state); - - let bind = OPENAI_BROWSER_OAUTH_CALLBACK_BIND.to_string(); - let join_handle = tokio::spawn(async move { - tracing::info!(bind = %bind, "OpenAI browser OAuth callback listener started"); - if let Err(error) = axum::serve(listener, app).await { - tracing::error!( - %error, - bind = %bind, - "OpenAI browser OAuth callback listener stopped" - ); - } - }); + if let (Some(proto), Some(host)) = ( + header_value(headers, "x-forwarded-proto"), + header_value(headers, "x-forwarded-host"), + ) { + let proto = first_header_value(proto); + let host = normalize_host(first_header_value(host)); + return Some(format!( + "{proto}://{host}{OPENAI_BROWSER_OAUTH_REDIRECT_PATH}" + )); + } + + if let Some(host) = header_value(headers, "host") { + let host = normalize_host(host); + let scheme = if is_local_host(&host) { "http" } else { "https" }; + return Some(format!( + "{scheme}://{host}{OPENAI_BROWSER_OAUTH_REDIRECT_PATH}" + )); + } + + None +} + +fn header_value(headers: &HeaderMap, name: impl AsRef) -> Option<&str> { + headers.get(name.as_ref()).and_then(|value| value.to_str().ok()) +} + +fn first_header_value(value: &str) -> &str { + value.split(',').next().map(str::trim).unwrap_or(value) +} + +fn normalize_host(host: &str) -> String { + let host = host.trim(); + let colon_count = host.matches(':').count(); + if colon_count > 1 && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + } +} - *callback_server = Some(BrowserOAuthCallbackServer { join_handle }); - Ok(()) +fn is_local_host(host: &str) -> bool { + let host = host + .trim_start_matches('[') + .trim_end_matches(']') + .split(':') + .next() + .unwrap_or(host); + matches!(host, "localhost" | "127.0.0.1" | "::1") } fn browser_oauth_success_html() -> String { @@ -598,7 +611,7 @@ pub(super) async fn get_providers( } pub(super) async fn start_openai_browser_oauth( - State(state): State>, + headers: HeaderMap, Json(request): Json, ) -> Result, StatusCode> { if request.model.trim().is_empty() { @@ -621,28 +634,25 @@ pub(super) async fn start_openai_browser_oauth( })); }; - if let Err(error) = ensure_openai_browser_oauth_callback_server(state.clone()).await { + let Some(redirect_uri) = resolve_browser_oauth_redirect_uri(&headers) else { return Ok(Json(OpenAiOAuthBrowserStartResponse { success: false, - message: format!( - "Failed to start local OAuth callback listener on {}: {}", - OPENAI_BROWSER_OAUTH_CALLBACK_BIND, error - ), + message: "Unable to determine OAuth callback URL. Check your Host/Origin headers." + .to_string(), authorization_url: None, state: None, })); - } + }; prune_expired_browser_oauth_sessions().await; - let browser_authorization = - crate::openai_auth::start_browser_authorization(OPENAI_BROWSER_OAUTH_REDIRECT_URI); + let browser_authorization = crate::openai_auth::start_browser_authorization(&redirect_uri); let state_key = browser_authorization.state.clone(); OPENAI_BROWSER_OAUTH_SESSIONS.write().await.insert( state_key.clone(), BrowserOAuthSession { pkce_verifier: browser_authorization.pkce_verifier, - redirect_uri: OPENAI_BROWSER_OAUTH_REDIRECT_URI.to_string(), + redirect_uri, model: chatgpt_model, created_at: chrono::Utc::now().timestamp(), status: BrowserOAuthSessionStatus::Pending, diff --git a/src/api/server.rs b/src/api/server.rs index 29bdaa847..31d2116dd 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -131,6 +131,10 @@ pub async fn start_http_server( "/providers/openai/oauth/browser/status", get(providers::openai_browser_oauth_status), ) + .route( + "/providers/openai/oauth/browser/callback", + get(providers::openai_browser_oauth_callback), + ) .route("/providers/test", post(providers::test_provider_model)) .route("/providers/{provider}", delete(providers::delete_provider)) .route("/models", get(models::get_models)) From 568f6b2bfb8110cd30356d0180b502c13629eeed Mon Sep 17 00:00:00 2001 From: Marijn van der Werf Date: Mon, 23 Feb 2026 21:21:42 +0100 Subject: [PATCH 9/9] Fix clippy collapsible if and format --- src/api/providers.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/api/providers.rs b/src/api/providers.rs index 79e9ca72d..8fb5e54d6 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -360,12 +360,12 @@ async fn prune_expired_browser_oauth_sessions() { } fn resolve_browser_oauth_redirect_uri(headers: &HeaderMap) -> Option { - if let Some(origin) = header_value(headers, axum::http::header::ORIGIN.as_str()) { - if let Ok(origin_url) = Url::parse(origin) { - let origin = origin_url.origin().ascii_serialization(); - if origin != "null" { - return Some(format!("{origin}{OPENAI_BROWSER_OAUTH_REDIRECT_PATH}")); - } + if let Some(origin) = header_value(headers, axum::http::header::ORIGIN.as_str()) + && let Ok(origin_url) = Url::parse(origin) + { + let origin = origin_url.origin().ascii_serialization(); + if origin != "null" { + return Some(format!("{origin}{OPENAI_BROWSER_OAUTH_REDIRECT_PATH}")); } } @@ -382,7 +382,11 @@ fn resolve_browser_oauth_redirect_uri(headers: &HeaderMap) -> Option { if let Some(host) = header_value(headers, "host") { let host = normalize_host(host); - let scheme = if is_local_host(&host) { "http" } else { "https" }; + let scheme = if is_local_host(&host) { + "http" + } else { + "https" + }; return Some(format!( "{scheme}://{host}{OPENAI_BROWSER_OAUTH_REDIRECT_PATH}" )); @@ -392,7 +396,9 @@ fn resolve_browser_oauth_redirect_uri(headers: &HeaderMap) -> Option { } fn header_value(headers: &HeaderMap, name: impl AsRef) -> Option<&str> { - headers.get(name.as_ref()).and_then(|value| value.to_str().ok()) + headers + .get(name.as_ref()) + .and_then(|value| value.to_str().ok()) } fn first_header_value(value: &str) -> &str {