From e5398e7e0f5086172d1eb0f6a2abe6f0180b6046 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Wed, 20 May 2026 04:11:19 +0530 Subject: [PATCH] feat(inference): ChatGPT OAuth for OpenAI cloud provider (#1953) Add Codex-style PKCE OAuth for the openai provider slug, wire bearer lookup and JSON-RPC controllers, and surface sign-in during onboarding API keys step. Co-authored-by: Cursor --- Cargo.lock | 53 +++ Cargo.toml | 1 + .../pages/onboarding/steps/ApiKeysStep.tsx | 153 ++++++++- .../steps/__tests__/ApiKeysStep.test.tsx | 74 ++++ src/openhuman/inference/mod.rs | 1 + .../inference/openai_oauth/config.rs | 11 + src/openhuman/inference/openai_oauth/flow.rs | 317 ++++++++++++++++++ .../inference/openai_oauth/flow_tests.rs | 113 +++++++ src/openhuman/inference/openai_oauth/mod.rs | 14 + src/openhuman/inference/openai_oauth/store.rs | 131 ++++++++ src/openhuman/inference/ops.rs | 42 +++ src/openhuman/inference/provider/factory.rs | 6 + src/openhuman/inference/schemas.rs | 94 ++++++ src/openhuman/inference/schemas_tests.rs | 2 +- 14 files changed, 1003 insertions(+), 9 deletions(-) create mode 100644 app/src/pages/onboarding/steps/__tests__/ApiKeysStep.test.tsx create mode 100644 src/openhuman/inference/openai_oauth/config.rs create mode 100644 src/openhuman/inference/openai_oauth/flow.rs create mode 100644 src/openhuman/inference/openai_oauth/flow_tests.rs create mode 100644 src/openhuman/inference/openai_oauth/mod.rs create mode 100644 src/openhuman/inference/openai_oauth/store.rs diff --git a/Cargo.lock b/Cargo.lock index 3cb13cdea..83c1fb6a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3064,9 +3064,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -4352,6 +4354,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "motosan-ai-oauth" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16994a67367076b08479af83ca05503c4d423fc6631f849fb92fa787956ad557" +dependencies = [ + "base64 0.22.1", + "percent-encoding", + "rand 0.9.4", + "reqwest 0.12.28", + "serde", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -4982,6 +5000,7 @@ dependencies = [ "log", "mail-parser", "matrix-sdk", + "motosan-ai-oauth", "nu-ansi-term 0.46.0", "objc2 0.6.4", "objc2-contacts", @@ -6134,6 +6153,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", @@ -6147,6 +6167,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "mime_guess", "native-tls", "percent-encoding", @@ -7420,6 +7441,27 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys 0.8.7", + "libc", +] + [[package]] name = "tap" version = "1.0.1" @@ -9061,6 +9103,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 152863b97..e14023fe4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,7 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["f tracing-appender = "0.2" prometheus = { version = "0.14", default-features = false } urlencoding = "2.1" +motosan-ai-oauth = { version = "0.2", features = ["codex"] } thiserror = "2.0" ring = "0.17" prost = { version = "0.14", default-features = false } diff --git a/app/src/pages/onboarding/steps/ApiKeysStep.tsx b/app/src/pages/onboarding/steps/ApiKeysStep.tsx index 5458e5ee3..dc6bb0ea1 100644 --- a/app/src/pages/onboarding/steps/ApiKeysStep.tsx +++ b/app/src/pages/onboarding/steps/ApiKeysStep.tsx @@ -1,7 +1,10 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { setCloudProviderKey } from '../../../services/api/aiSettingsApi'; +import { callCoreRpc } from '../../../services/coreRpcClient'; +import { openUrl } from '../../../utils/openUrl'; +import { isTauri } from '../../../utils/tauriCommands/common'; import OnboardingNextButton from '../components/OnboardingNextButton'; interface ApiKeysStepProps { @@ -9,17 +12,98 @@ interface ApiKeysStepProps { onSkip: () => void; } +type OpenAiOAuthStatus = { connected: boolean; authMethod?: string | null }; + +const OPENAI_OAUTH_CONNECTED_LABEL = 'Connected with ChatGPT'; +const OPENAI_OAUTH_CONNECT_LABEL = 'Sign in with ChatGPT'; +const OPENAI_OAUTH_CALLBACK_HINT = + 'After signing in, paste the full redirect URL from your browser (starts with http://127.0.0.1:1455/).'; +const OPENAI_OAUTH_CALLBACK_PLACEHOLDER = 'http://127.0.0.1:1455/auth/callback?code=...&state=...'; + const ApiKeysStep = ({ onNext, onSkip }: ApiKeysStepProps) => { const { t } = useT(); const [openai, setOpenai] = useState(''); const [anthropic, setAnthropic] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [oauthConnected, setOauthConnected] = useState(false); + const [oauthBusy, setOauthBusy] = useState(false); + const [oauthAwaitingCallback, setOauthAwaitingCallback] = useState(false); + const [oauthCallbackUrl, setOauthCallbackUrl] = useState(''); + + const refreshOAuthStatus = useCallback(async () => { + if (!isTauri()) { + return; + } + try { + const res = await callCoreRpc<{ result: OpenAiOAuthStatus }>({ + method: 'openhuman.inference_openai_oauth_status', + params: {}, + }); + setOauthConnected(Boolean(res?.result?.connected)); + } catch (err) { + console.debug('[onboarding:api-keys] oauth status check failed', err); + } + }, []); + + useEffect(() => { + void refreshOAuthStatus(); + }, [refreshOAuthStatus]); + + const handleOpenAiOAuthStart = async () => { + if (!isTauri()) { + setError('ChatGPT sign-in is only available in the desktop app.'); + return; + } + setOauthBusy(true); + setError(null); + try { + const res = await callCoreRpc<{ result: { authUrl: string } }>({ + method: 'openhuman.inference_openai_oauth_start', + params: {}, + }); + const authUrl = res?.result?.authUrl?.trim(); + if (!authUrl) { + throw new Error('missing authUrl'); + } + setOauthAwaitingCallback(true); + await openUrl(authUrl); + } catch (err) { + console.warn('[onboarding:api-keys] oauth start failed', err); + setError('Could not start ChatGPT sign-in. Try again or use an API key.'); + } finally { + setOauthBusy(false); + } + }; + + const handleOpenAiOAuthComplete = async () => { + const callback = oauthCallbackUrl.trim(); + if (!callback) { + setError('Paste the redirect URL from your browser after signing in.'); + return; + } + setOauthBusy(true); + setError(null); + try { + await callCoreRpc({ + method: 'openhuman.inference_openai_oauth_complete', + params: { callback_url: callback }, + }); + setOauthCallbackUrl(''); + setOauthAwaitingCallback(false); + setOauthConnected(true); + } catch (err) { + console.warn('[onboarding:api-keys] oauth complete failed', err); + setError('ChatGPT sign-in did not complete. Check the redirect URL and try again.'); + } finally { + setOauthBusy(false); + } + }; const handleSave = async () => { const trimmedOpenai = openai.trim(); const trimmedAnthropic = anthropic.trim(); - if (!trimmedOpenai && !trimmedAnthropic) { + if (!trimmedOpenai && !trimmedAnthropic && !oauthConnected) { onSkip(); return; } @@ -56,12 +140,65 @@ const ApiKeysStep = ({ onNext, onSkip }: ApiKeysStepProps) => {
-
- +
+
+ + {t('onboarding.apiKeys.openaiLabel')} + + {oauthConnected ? ( + + {OPENAI_OAUTH_CONNECTED_LABEL} + + ) : null} +
+

+ Use ChatGPT Plus/Pro (subscription) or an OpenAI API key — not both required. +

+ + {oauthAwaitingCallback && !oauthConnected ? ( +
+

+ {OPENAI_OAUTH_CALLBACK_HINT} +

+ { + setOauthCallbackUrl(e.target.value); + setError(null); + }} + className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-xs text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500" + /> + +
+ ) : null} +
+
+ + or API key + +
+
({ callCoreRpc: vi.fn() })); + +vi.mock('../../../../utils/openUrl', () => ({ openUrl: vi.fn().mockResolvedValue(undefined) })); + +vi.mock('../../../../utils/tauriCommands/common', () => ({ isTauri: vi.fn(() => true) })); + +vi.mock('../../../../services/api/aiSettingsApi', () => ({ + setCloudProviderKey: vi.fn().mockResolvedValue(undefined), +})); + +describe('ApiKeysStep OpenAI OAuth', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows connected badge when oauth status reports connected', async () => { + const { callCoreRpc } = await import('../../../../services/coreRpcClient'); + vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { connected: true } }); + + renderWithProviders(); + + expect(await screen.findByTestId('onboarding-openai-oauth-connected')).toBeInTheDocument(); + expect(screen.getByText('Connected with ChatGPT')).toBeInTheDocument(); + }); + + it('starts oauth and accepts pasted callback URL', async () => { + const { callCoreRpc } = await import('../../../../services/coreRpcClient'); + vi.mocked(callCoreRpc) + .mockResolvedValueOnce({ result: { connected: false } }) + .mockResolvedValueOnce({ + result: { + authUrl: 'https://auth.openai.com/oauth/authorize?client_id=test', + state: 'state-1', + redirectUri: 'http://127.0.0.1:1455/auth/callback', + }, + }) + .mockResolvedValueOnce({ result: { connected: true } }); + + const { openUrl } = await import('../../../../utils/openUrl'); + + renderWithProviders(); + + fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect')); + + await waitFor(() => { + expect(openUrl).toHaveBeenCalledWith( + 'https://auth.openai.com/oauth/authorize?client_id=test' + ); + }); + + const input = await screen.findByTestId('onboarding-openai-oauth-callback-input'); + fireEvent.change(input, { + target: { value: 'http://127.0.0.1:1455/auth/callback?code=abc&state=state-1' }, + }); + fireEvent.click(screen.getByTestId('onboarding-openai-oauth-complete')); + + await waitFor(() => { + expect(callCoreRpc).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'openhuman.inference_openai_oauth_complete', + params: { callback_url: 'http://127.0.0.1:1455/auth/callback?code=abc&state=state-1' }, + }) + ); + }); + + expect(await screen.findByTestId('onboarding-openai-oauth-connected')).toBeInTheDocument(); + }); +}); diff --git a/src/openhuman/inference/mod.rs b/src/openhuman/inference/mod.rs index 7fb5549e0..916ae7c5e 100644 --- a/src/openhuman/inference/mod.rs +++ b/src/openhuman/inference/mod.rs @@ -16,6 +16,7 @@ pub mod device; pub mod http; pub mod local; pub mod model_ids; +pub mod openai_oauth; pub mod ops; pub mod parse; pub mod paths; diff --git a/src/openhuman/inference/openai_oauth/config.rs b/src/openhuman/inference/openai_oauth/config.rs new file mode 100644 index 000000000..21f6d8aaf --- /dev/null +++ b/src/openhuman/inference/openai_oauth/config.rs @@ -0,0 +1,11 @@ +//! OpenAI Codex (ChatGPT subscription) OAuth endpoints and client registration. + +use motosan_ai_oauth::providers::codex::codex; +use motosan_ai_oauth::OAuthConfig; + +/// Loopback redirect registered with the Codex public OAuth app. +pub const REDIRECT_URI: &str = "http://127.0.0.1:1455/auth/callback"; + +pub fn codex_oauth_config() -> OAuthConfig { + codex() +} diff --git a/src/openhuman/inference/openai_oauth/flow.rs b/src/openhuman/inference/openai_oauth/flow.rs new file mode 100644 index 000000000..e7bf88de4 --- /dev/null +++ b/src/openhuman/inference/openai_oauth/flow.rs @@ -0,0 +1,317 @@ +//! OAuth start / complete / status for OpenAI Codex (ChatGPT subscription). + +use std::path::PathBuf; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, Utc}; +use motosan_ai_oauth::StateStrategy; +use rand::RngExt as _; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::openhuman::config::Config; +use crate::openhuman::credentials::state_dir_from_config; + +use super::config::{codex_oauth_config, REDIRECT_URI}; +use super::store::{persist_openai_oauth_token, OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY}; + +const LOG_PREFIX: &str = "[inference][openai-oauth]"; +const PENDING_FILENAME: &str = "openai-oauth-pending.json"; +const PENDING_TTL_SECS: u64 = 600; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PendingOAuth { + state: String, + verifier: String, + redirect_uri: String, + created_at: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OpenAiOAuthStartResult { + pub auth_url: String, + pub state: String, + pub redirect_uri: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OpenAiOAuthStatusResult { + pub connected: bool, + pub profile_id: Option, + pub expires_at: Option>, + pub auth_method: Option, +} + +fn pending_path(config: &Config) -> PathBuf { + state_dir_from_config(config).join(PENDING_FILENAME) +} + +fn generate_pkce() -> (String, String) { + let mut bytes = [0u8; 64]; + rand::rng().fill(&mut bytes); + let verifier = URL_SAFE_NO_PAD.encode(bytes); + let hash = Sha256::digest(verifier.as_bytes()); + let challenge = URL_SAFE_NO_PAD.encode(hash); + (verifier, challenge) +} + +fn random_state() -> String { + let mut state_bytes = [0u8; 16]; + rand::rng().fill(&mut state_bytes); + URL_SAFE_NO_PAD.encode(state_bytes) +} + +fn write_pending(config: &Config, pending: &PendingOAuth) -> Result<(), String> { + let path = pending_path(config); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let json = serde_json::to_vec_pretty(pending).map_err(|e| e.to_string())?; + std::fs::write(&path, json).map_err(|e| e.to_string())?; + log::debug!("{LOG_PREFIX} pending session written"); + Ok(()) +} + +fn read_pending(config: &Config) -> Result, String> { + let path = pending_path(config); + if !path.exists() { + return Ok(None); + } + let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; + if bytes.is_empty() { + return Ok(None); + } + let pending: PendingOAuth = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + let now = unix_now_secs(); + if now.saturating_sub(pending.created_at) > PENDING_TTL_SECS { + let _ = std::fs::remove_file(&path); + return Ok(None); + } + Ok(Some(pending)) +} + +fn clear_pending(config: &Config) { + let path = pending_path(config); + if path.exists() { + let _ = std::fs::remove_file(path); + } +} + +pub fn start_openai_oauth(config: &Config) -> Result { + let oauth_cfg = codex_oauth_config(); + let (verifier, challenge) = generate_pkce(); + let state = match oauth_cfg.state_strategy { + StateStrategy::Random => random_state(), + StateStrategy::EqualsVerifier => verifier.clone(), + }; + + let pending = PendingOAuth { + state: state.clone(), + verifier, + redirect_uri: REDIRECT_URI.to_string(), + created_at: unix_now_secs(), + }; + write_pending(config, &pending)?; + + let auth_url = build_authorize_url(&oauth_cfg, &challenge, &state, REDIRECT_URI); + log::info!("{LOG_PREFIX} oauth start state_len={}", state.len()); + + Ok(OpenAiOAuthStartResult { + auth_url, + state, + redirect_uri: REDIRECT_URI.to_string(), + }) +} + +pub fn parse_callback_input(input: &str) -> Result<(String, String), String> { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("callback URL is required".to_string()); + } + + let query = if let Ok(parsed) = url::Url::parse(trimmed) { + parsed.query().unwrap_or("").to_string() + } else if trimmed.contains('=') { + trimmed.to_string() + } else { + return Err("invalid callback URL".to_string()); + }; + + let mut code: Option = None; + let mut state: Option = None; + for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { + match key.as_ref() { + "code" if !value.is_empty() => code = Some(value.into_owned()), + "state" if !value.is_empty() => state = Some(value.into_owned()), + _ => {} + } + } + + let code = code.ok_or_else(|| "callback URL missing code parameter".to_string())?; + let state = state.ok_or_else(|| "callback URL missing state parameter".to_string())?; + Ok((code, state)) +} + +pub async fn complete_openai_oauth( + config: &Config, + callback_input: &str, +) -> Result { + let pending = read_pending(config)? + .ok_or_else(|| "no pending OAuth session; call openai_oauth_start first".to_string())?; + + let (code, returned_state) = parse_callback_input(callback_input)?; + if returned_state != pending.state { + clear_pending(config); + return Err("OAuth state mismatch — try connecting again".to_string()); + } + + let oauth_cfg = codex_oauth_config(); + let token = exchange_authorization_code( + &oauth_cfg, + &code, + &pending.state, + &pending.verifier, + &pending.redirect_uri, + ) + .await?; + + clear_pending(config); + let profile = persist_openai_oauth_token(config, &token)?; + log::info!("{LOG_PREFIX} oauth complete profile_id={}", profile.id); + + Ok(serde_json::json!({ + "connected": true, + "profileId": profile.id, + "provider": OPENAI_PROVIDER_KEY, + "authMethod": "oauth", + })) +} + +pub fn openai_oauth_status(config: &Config) -> Result { + use crate::openhuman::credentials::profiles::AuthProfileKind; + use crate::openhuman::credentials::AuthService; + + let auth = AuthService::from_config(config); + let profile = auth + .get_profile(OPENAI_PROVIDER_KEY, Some(OPENAI_OAUTH_PROFILE_NAME)) + .map_err(|e| e.to_string())?; + + let Some(profile) = profile else { + return Ok(OpenAiOAuthStatusResult { + connected: false, + profile_id: None, + expires_at: None, + auth_method: None, + }); + }; + + if profile.kind != AuthProfileKind::OAuth { + return Ok(OpenAiOAuthStatusResult { + connected: false, + profile_id: Some(profile.id), + expires_at: None, + auth_method: Some("token".to_string()), + }); + } + + Ok(OpenAiOAuthStatusResult { + connected: true, + profile_id: Some(profile.id), + expires_at: profile.token_set.as_ref().and_then(|t| t.expires_at), + auth_method: Some("oauth".to_string()), + }) +} + +pub fn disconnect_openai_oauth(config: &Config) -> Result { + use crate::openhuman::credentials::AuthService; + + let auth = AuthService::from_config(config); + let removed = auth + .remove_profile(OPENAI_PROVIDER_KEY, OPENAI_OAUTH_PROFILE_NAME) + .map_err(|e| e.to_string())?; + clear_pending(config); + Ok(serde_json::json!({ "disconnected": removed })) +} + +fn build_authorize_url( + config: &motosan_ai_oauth::OAuthConfig, + challenge: &str, + state: &str, + redirect_uri: &str, +) -> String { + let mut url = reqwest::Url::parse(config.auth_url).expect("auth_url must be valid"); + { + let mut q = url.query_pairs_mut(); + q.append_pair("client_id", config.client_id) + .append_pair("response_type", "code") + .append_pair("redirect_uri", redirect_uri) + .append_pair("scope", &config.scopes.join(" ")) + .append_pair("state", state) + .append_pair("code_challenge", challenge) + .append_pair("code_challenge_method", "S256"); + for (k, v) in config.extra_auth_params { + q.append_pair(k, v); + } + } + url.to_string() +} + +async fn exchange_authorization_code( + config: &motosan_ai_oauth::OAuthConfig, + code: &str, + state: &str, + verifier: &str, + redirect_uri: &str, +) -> Result { + let mut params = vec![ + ("grant_type", "authorization_code"), + ("code", code), + ("state", state), + ("redirect_uri", redirect_uri), + ("code_verifier", verifier), + ("client_id", config.client_id), + ]; + if let Some(secret) = config.client_secret { + params.push(("client_secret", secret)); + } + + let resp = reqwest::Client::new() + .post(config.token_url) + .header("Accept", "application/json") + .form(¶ms) + .send() + .await + .map_err(|e| e.to_string())?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("HTTP {status}: {body}")); + } + + #[derive(serde::Deserialize)] + struct RawTokenResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + id_token: Option, + expires_in: u64, + } + + let raw: RawTokenResponse = resp.json().await.map_err(|e| e.to_string())?; + Ok(motosan_ai_oauth::Token { + access_token: raw.access_token, + refresh_token: raw.refresh_token.unwrap_or_default(), + id_token: raw.id_token, + expires_in: raw.expires_in, + issued_at: unix_now_secs(), + }) +} + +fn unix_now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/src/openhuman/inference/openai_oauth/flow_tests.rs b/src/openhuman/inference/openai_oauth/flow_tests.rs new file mode 100644 index 000000000..cb4c0816f --- /dev/null +++ b/src/openhuman/inference/openai_oauth/flow_tests.rs @@ -0,0 +1,113 @@ +use super::flow::parse_callback_input; +use super::{ + complete_openai_oauth, disconnect_openai_oauth, openai_oauth_status, start_openai_oauth, +}; +use crate::openhuman::config::Config; +use crate::openhuman::credentials::profiles::{AuthProfile, AuthProfilesStore, TokenSet}; +use crate::openhuman::inference::openai_oauth::lookup_openai_bearer_token; +use crate::openhuman::inference::openai_oauth::store::{ + OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY, +}; +use chrono::{Duration, Utc}; +use tempfile::tempdir; + +fn test_config(tmp: &tempfile::TempDir) -> Config { + let mut config = Config::default(); + config.config_path = tmp.path().join("config.toml"); + config +} + +#[test] +fn start_openai_oauth_returns_authorize_url() { + let tmp = tempdir().unwrap(); + let config = test_config(&tmp); + + let start = start_openai_oauth(&config).unwrap(); + assert!(start.auth_url.contains("auth.openai.com")); + assert!(start.auth_url.contains("code_challenge=")); + assert_eq!(start.redirect_uri, "http://127.0.0.1:1455/auth/callback"); + assert!(!start.state.is_empty()); + assert!(!openai_oauth_status(&config).unwrap().connected); +} + +#[test] +fn parse_callback_input_accepts_full_redirect_url() { + let url = "http://127.0.0.1:1455/auth/callback?code=abc&state=xyz"; + let (code, state) = parse_callback_input(url).unwrap(); + assert_eq!(code, "abc"); + assert_eq!(state, "xyz"); +} + +#[test] +fn parse_callback_input_rejects_missing_code() { + let err = parse_callback_input("http://127.0.0.1:1455/auth/callback?state=xyz").unwrap_err(); + assert!(err.contains("code")); +} + +#[test] +fn complete_openai_oauth_rejects_state_mismatch() { + let tmp = tempdir().unwrap(); + let config = test_config(&tmp); + let start = start_openai_oauth(&config).unwrap(); + let callback = format!( + "http://127.0.0.1:1455/auth/callback?code=fake&state=not-{}", + start.state + ); + let rt = tokio::runtime::Runtime::new().unwrap(); + let err = rt + .block_on(complete_openai_oauth(&config, &callback)) + .unwrap_err(); + assert!(err.contains("state mismatch")); +} + +#[test] +fn lookup_openai_bearer_token_prefers_api_key_over_oauth() { + let tmp = tempdir().unwrap(); + let config = test_config(&tmp); + let store = AuthProfilesStore::new(tmp.path(), false); + + let oauth_profile = AuthProfile::new_oauth( + OPENAI_PROVIDER_KEY, + OPENAI_OAUTH_PROFILE_NAME, + TokenSet { + access_token: "oauth-access".into(), + refresh_token: Some("refresh".into()), + id_token: None, + expires_at: Some(Utc::now() + Duration::hours(1)), + token_type: Some("Bearer".into()), + scope: None, + }, + ); + store.upsert_profile(oauth_profile, true).unwrap(); + + let api_profile = + AuthProfile::new_token("provider:openai", "default", "sk-api-key".to_string()); + store.upsert_profile(api_profile, true).unwrap(); + + let token = lookup_openai_bearer_token(&config).unwrap(); + assert_eq!(token.as_deref(), Some("sk-api-key")); +} + +#[test] +fn disconnect_openai_oauth_clears_profile() { + let tmp = tempdir().unwrap(); + let config = test_config(&tmp); + let store = AuthProfilesStore::new(tmp.path(), false); + let profile = AuthProfile::new_oauth( + OPENAI_PROVIDER_KEY, + OPENAI_OAUTH_PROFILE_NAME, + TokenSet { + access_token: "oauth-access".into(), + refresh_token: None, + id_token: None, + expires_at: None, + token_type: Some("Bearer".into()), + scope: None, + }, + ); + store.upsert_profile(profile, true).unwrap(); + assert!(openai_oauth_status(&config).unwrap().connected); + + disconnect_openai_oauth(&config).unwrap(); + assert!(!openai_oauth_status(&config).unwrap().connected); +} diff --git a/src/openhuman/inference/openai_oauth/mod.rs b/src/openhuman/inference/openai_oauth/mod.rs new file mode 100644 index 000000000..21b87da1e --- /dev/null +++ b/src/openhuman/inference/openai_oauth/mod.rs @@ -0,0 +1,14 @@ +//! ChatGPT / OpenAI Codex subscription OAuth for the `openai` cloud provider slug. + +mod config; +mod flow; +mod store; + +#[cfg(test)] +#[path = "flow_tests.rs"] +mod tests; + +pub use flow::{ + complete_openai_oauth, disconnect_openai_oauth, openai_oauth_status, start_openai_oauth, +}; +pub use store::{lookup_openai_bearer_token, OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY}; diff --git a/src/openhuman/inference/openai_oauth/store.rs b/src/openhuman/inference/openai_oauth/store.rs new file mode 100644 index 000000000..ec2ebb6fe --- /dev/null +++ b/src/openhuman/inference/openai_oauth/store.rs @@ -0,0 +1,131 @@ +//! Persist and resolve OpenAI OAuth tokens for the `openai` cloud provider slug. + +use base64::Engine; +use chrono::{Duration, Utc}; +use motosan_ai_oauth::Token; + +use crate::openhuman::config::Config; +use crate::openhuman::credentials::profiles::{AuthProfile, AuthProfilesStore, TokenSet}; +use crate::openhuman::credentials::{state_dir_from_config, AuthService}; +use crate::openhuman::inference::provider::factory::auth_key_for_slug; + +use super::config::codex_oauth_config; + +const LOG_PREFIX: &str = "[inference][openai-oauth][store]"; + +pub const OPENAI_PROVIDER_KEY: &str = "provider:openai"; +pub const OPENAI_OAUTH_PROFILE_NAME: &str = "oauth"; + +fn token_set_from_codex(token: &Token) -> TokenSet { + let expires_at = + (token.expires_in > 0).then(|| Utc::now() + Duration::seconds(token.expires_in as i64)); + TokenSet { + access_token: token.access_token.clone(), + refresh_token: (!token.refresh_token.is_empty()).then(|| token.refresh_token.clone()), + id_token: token.id_token.clone(), + expires_at, + token_type: Some("Bearer".to_string()), + scope: None, + } +} + +pub fn persist_openai_oauth_token(config: &Config, token: &Token) -> Result { + let mut profile = AuthProfile::new_oauth( + OPENAI_PROVIDER_KEY, + OPENAI_OAUTH_PROFILE_NAME, + token_set_from_codex(token), + ); + if let Some(account_id) = extract_account_id_from_access_token(&token.access_token) { + profile + .metadata + .insert("account_id".to_string(), account_id); + } + + let store = auth_profiles_store(config); + store + .upsert_profile(profile.clone(), true) + .map_err(|e| e.to_string())?; + Ok(profile) +} + +fn auth_profiles_store(config: &Config) -> AuthProfilesStore { + AuthProfilesStore::new(&state_dir_from_config(config), config.secrets.encrypt) +} + +fn try_refresh_oauth_token(refresh: &str) -> Result { + let cfg = codex_oauth_config(); + let fut = motosan_ai_oauth::refresh(&cfg, refresh); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + return handle.block_on(fut).map_err(|e| e.to_string()); + } + Err("tokio runtime required to refresh openai oauth token".to_string()) +} + +fn extract_account_id_from_access_token(access_token: &str) -> Option { + let payload = access_token.split('.').nth(1)?; + let padded = match payload.len() % 4 { + 0 => payload.to_string(), + n => format!("{}{}", payload, "=".repeat(4 - n)), + }; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(padded.as_bytes()) + .or_else(|_| base64::engine::general_purpose::STANDARD.decode(padded.as_bytes())) + .ok()?; + let json: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + json.get("sub") + .or_else(|| json.get("account_id")) + .and_then(|v| v.as_str()) + .map(str::to_string) +} + +pub fn lookup_openai_bearer_token(config: &Config) -> Result, String> { + let auth = AuthService::from_config(config); + let slug = "openai"; + let new_key = auth_key_for_slug(slug); + + if let Ok(Some(k)) = auth.get_provider_bearer_token(&new_key, None) { + if !k.is_empty() { + return Ok(Some(k)); + } + } + if let Ok(Some(k)) = auth.get_provider_bearer_token(slug, None) { + if !k.is_empty() { + return Ok(Some(k)); + } + } + + let profile = auth + .get_profile(OPENAI_PROVIDER_KEY, Some(OPENAI_OAUTH_PROFILE_NAME)) + .map_err(|e| e.to_string())?; + let Some(mut profile) = profile else { + return Ok(None); + }; + let Some(mut token_set) = profile.token_set.clone() else { + return Ok(None); + }; + + let skew = Duration::minutes(2); + if token_set.is_expiring_within(std::time::Duration::from_secs( + skew.num_seconds().unsigned_abs(), + )) { + if let Some(refresh) = token_set.refresh_token.clone() { + match try_refresh_oauth_token(&refresh) { + Ok(fresh) => { + token_set = token_set_from_codex(&fresh); + profile.token_set = Some(token_set.clone()); + let _ = auth_profiles_store(config).upsert_profile(profile, true); + } + Err(e) => { + log::warn!("{LOG_PREFIX} oauth refresh failed: {e}"); + } + } + } + } + + let access = token_set.access_token.trim(); + if access.is_empty() { + Ok(None) + } else { + Ok(Some(access.to_string())) + } +} diff --git a/src/openhuman/inference/ops.rs b/src/openhuman/inference/ops.rs index 06459931c..31916886c 100644 --- a/src/openhuman/inference/ops.rs +++ b/src/openhuman/inference/ops.rs @@ -310,6 +310,48 @@ pub async fn inference_apply_preset(tier: &str) -> Result, Str )) } +pub async fn inference_openai_oauth_start(config: &Config) -> Result, String> { + let start = crate::openhuman::inference::openai_oauth::start_openai_oauth(config)?; + Ok(RpcOutcome::single_log( + json!({ + "authUrl": start.auth_url, + "state": start.state, + "redirectUri": start.redirect_uri, + }), + "openai oauth authorize url ready", + )) +} + +pub async fn inference_openai_oauth_complete( + config: &Config, + callback_url: &str, +) -> Result, String> { + let result = + crate::openhuman::inference::openai_oauth::complete_openai_oauth(config, callback_url) + .await?; + Ok(RpcOutcome::single_log(result, "openai oauth connected")) +} + +pub async fn inference_openai_oauth_status(config: &Config) -> Result, String> { + let status = crate::openhuman::inference::openai_oauth::openai_oauth_status(config)?; + Ok(RpcOutcome::single_log( + json!({ + "connected": status.connected, + "profileId": status.profile_id, + "expiresAt": status.expires_at, + "authMethod": status.auth_method, + }), + "openai oauth status", + )) +} + +pub async fn inference_openai_oauth_disconnect( + config: &Config, +) -> Result, String> { + let result = crate::openhuman::inference::openai_oauth::disconnect_openai_oauth(config)?; + Ok(RpcOutcome::single_log(result, "openai oauth disconnected")) +} + pub async fn inference_diagnostics(config: &Config) -> Result, String> { debug!("{LOG_PREFIX} diagnostics:start"); let service = local_runtime::global(config); diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 3ec225e30..0fdc121c7 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -413,6 +413,12 @@ fn make_cloud_provider_by_slug( /// "no auth", which surfaces an authentication error at first call rather than /// at factory build time. pub fn lookup_key_for_slug(slug: &str, config: &Config) -> anyhow::Result { + if slug == "openai" { + return crate::openhuman::inference::openai_oauth::lookup_openai_bearer_token(config) + .map_err(|e| anyhow::anyhow!("[chat-factory] openai auth lookup failed: {e}")) + .map(|opt| opt.unwrap_or_default()); + } + let auth = AuthService::from_config(config); // Try new-style key first. let new_key = auth_key_for_slug(slug); diff --git a/src/openhuman/inference/schemas.rs b/src/openhuman/inference/schemas.rs index 7253c4e86..2109d5e91 100644 --- a/src/openhuman/inference/schemas.rs +++ b/src/openhuman/inference/schemas.rs @@ -121,6 +121,12 @@ struct InferenceApplyPresetParams { tier: String, } +#[derive(Debug, Deserialize)] +struct InferenceOpenAiOAuthCompleteParams { + #[serde(alias = "callbackUrl")] + callback_url: String, +} + pub fn all_controller_schemas() -> Vec { vec![ schemas("status"), @@ -132,6 +138,10 @@ pub fn all_controller_schemas() -> Vec { schemas("presets"), schemas("apply_preset"), schemas("diagnostics"), + schemas("openai_oauth_start"), + schemas("openai_oauth_complete"), + schemas("openai_oauth_status"), + schemas("openai_oauth_disconnect"), schemas("summarize"), schemas("prompt"), schemas("vision_prompt"), @@ -180,6 +190,22 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("diagnostics"), handler: handle_inference_diagnostics, }, + RegisteredController { + schema: schemas("openai_oauth_start"), + handler: handle_inference_openai_oauth_start, + }, + RegisteredController { + schema: schemas("openai_oauth_complete"), + handler: handle_inference_openai_oauth_complete, + }, + RegisteredController { + schema: schemas("openai_oauth_status"), + handler: handle_inference_openai_oauth_status, + }, + RegisteredController { + schema: schemas("openai_oauth_disconnect"), + handler: handle_inference_openai_oauth_disconnect, + }, RegisteredController { schema: schemas("summarize"), handler: handle_inference_summarize, @@ -313,6 +339,37 @@ pub fn schemas(function: &str) -> ControllerSchema { via `issues`.", )], }, + "openai_oauth_start" => ControllerSchema { + namespace: "inference", + function: "openai_oauth_start", + description: "Begin ChatGPT/Codex OAuth (PKCE) for the openai cloud provider.", + inputs: vec![], + outputs: vec![json_output("result", "OAuth start payload with authUrl.")], + }, + "openai_oauth_complete" => ControllerSchema { + namespace: "inference", + function: "openai_oauth_complete", + description: "Complete ChatGPT/Codex OAuth using the browser callback URL.", + inputs: vec![required_string( + "callback_url", + "Redirect URL after sign-in (http://127.0.0.1:1455/auth/callback?...).", + )], + outputs: vec![json_output("result", "OAuth completion payload.")], + }, + "openai_oauth_status" => ControllerSchema { + namespace: "inference", + function: "openai_oauth_status", + description: "Whether ChatGPT OAuth credentials are stored for openai.", + inputs: vec![], + outputs: vec![json_output("status", "OAuth connection status.")], + }, + "openai_oauth_disconnect" => ControllerSchema { + namespace: "inference", + function: "openai_oauth_disconnect", + description: "Remove stored ChatGPT OAuth credentials.", + inputs: vec![], + outputs: vec![json_output("result", "Disconnect result.")], + }, "summarize" => ControllerSchema { namespace: "inference", function: "summarize", @@ -613,6 +670,43 @@ fn handle_inference_apply_preset(params: Map) -> ControllerFuture }) } +fn handle_inference_openai_oauth_start(_params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + to_json(crate::openhuman::inference::rpc::inference_openai_oauth_start(&config).await?) + }) +} + +fn handle_inference_openai_oauth_complete(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let payload = deserialize_params::(params)?; + to_json( + crate::openhuman::inference::rpc::inference_openai_oauth_complete( + &config, + payload.callback_url.trim(), + ) + .await?, + ) + }) +} + +fn handle_inference_openai_oauth_status(_params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + to_json(crate::openhuman::inference::rpc::inference_openai_oauth_status(&config).await?) + }) +} + +fn handle_inference_openai_oauth_disconnect(_params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + to_json( + crate::openhuman::inference::rpc::inference_openai_oauth_disconnect(&config).await?, + ) + }) +} + fn handle_inference_diagnostics(_params: Map) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; diff --git a/src/openhuman/inference/schemas_tests.rs b/src/openhuman/inference/schemas_tests.rs index 576504701..d1581d605 100644 --- a/src/openhuman/inference/schemas_tests.rs +++ b/src/openhuman/inference/schemas_tests.rs @@ -5,7 +5,7 @@ fn inference_catalog_counts_match_and_nonempty() { let declared = all_controller_schemas(); let registered = all_registered_controllers(); assert_eq!(declared.len(), registered.len()); - assert!(declared.len() >= 16); + assert!(declared.len() >= 20); } #[test]