diff --git a/Cargo.lock b/Cargo.lock index cc22fa5c6..1f8818db2 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 14a642c32..58ef0e58d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,6 +93,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..eb305aaf5 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 + +
+
{ type="button" onClick={onSkip} disabled={saving} - className="text-xs text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 dark:text-neutral-200 underline disabled:opacity-50"> + className="text-xs text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 underline disabled:opacity-50"> {t('onboarding.apiKeys.skipForNow')}
diff --git a/app/src/pages/onboarding/steps/__tests__/ApiKeysStep.test.tsx b/app/src/pages/onboarding/steps/__tests__/ApiKeysStep.test.tsx new file mode 100644 index 000000000..31fb03dc3 --- /dev/null +++ b/app/src/pages/onboarding/steps/__tests__/ApiKeysStep.test.tsx @@ -0,0 +1,167 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { setCloudProviderKey } from '../../../../services/api/aiSettingsApi'; +import { callCoreRpc } from '../../../../services/coreRpcClient'; +import { renderWithProviders } from '../../../../test/test-utils'; +import { openUrl } from '../../../../utils/openUrl'; +import { isTauri } from '../../../../utils/tauriCommands/common'; +import ApiKeysStep from '../ApiKeysStep'; + +vi.mock('../../../../services/coreRpcClient', () => ({ 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(); + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(openUrl).mockResolvedValue(undefined); + vi.mocked(setCloudProviderKey).mockResolvedValue(undefined); + }); + + it('shows connected badge when oauth status reports connected', async () => { + 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 () => { + 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 } }); + + 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(); + }); + + it('shows a desktop-only error without calling core outside Tauri', async () => { + vi.mocked(isTauri).mockReturnValue(false); + + renderWithProviders(); + + fireEvent.click(screen.getByTestId('onboarding-openai-oauth-connect')); + + expect( + await screen.findByText('ChatGPT sign-in is only available in the desktop app.') + ).toBeInTheDocument(); + expect(callCoreRpc).not.toHaveBeenCalled(); + expect(openUrl).not.toHaveBeenCalled(); + }); + + it('reports an oauth start failure when core omits authUrl', async () => { + vi.mocked(callCoreRpc) + .mockResolvedValueOnce({ result: { connected: false } }) + .mockResolvedValueOnce({ result: { authUrl: ' ' } }); + + renderWithProviders(); + + fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect')); + + expect( + await screen.findByText('Could not start ChatGPT sign-in. Try again or use an API key.') + ).toBeInTheDocument(); + expect(openUrl).not.toHaveBeenCalled(); + }); + + it('requires a pasted callback before completing oauth', async () => { + vi.mocked(callCoreRpc) + .mockResolvedValueOnce({ result: { connected: false } }) + .mockResolvedValueOnce({ + result: { authUrl: 'https://auth.openai.com/oauth/authorize?client_id=test' }, + }); + + renderWithProviders(); + + fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect')); + await screen.findByTestId('onboarding-openai-oauth-callback-input'); + fireEvent.click(screen.getByTestId('onboarding-openai-oauth-complete')); + + expect( + await screen.findByText('Paste the redirect URL from your browser after signing in.') + ).toBeInTheDocument(); + expect(callCoreRpc).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'openhuman.inference_openai_oauth_complete' }) + ); + }); + + it('reports an oauth completion failure and keeps the callback form visible', async () => { + vi.mocked(callCoreRpc) + .mockResolvedValueOnce({ result: { connected: false } }) + .mockResolvedValueOnce({ + result: { authUrl: 'https://auth.openai.com/oauth/authorize?client_id=test' }, + }) + .mockRejectedValueOnce(new Error('state mismatch')); + + renderWithProviders(); + + fireEvent.click(await screen.findByTestId('onboarding-openai-oauth-connect')); + 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=wrong' }, + }); + fireEvent.click(screen.getByTestId('onboarding-openai-oauth-complete')); + + expect( + await screen.findByText( + 'ChatGPT sign-in did not complete. Check the redirect URL and try again.' + ) + ).toBeInTheDocument(); + expect(screen.getByTestId('onboarding-openai-oauth-callback-input')).toBeInTheDocument(); + }); + + it('continues without saving API keys when oauth is already connected', async () => { + const onNext = vi.fn(); + vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { connected: true } }); + + renderWithProviders(); + + await screen.findByTestId('onboarding-openai-oauth-connected'); + fireEvent.click(screen.getByTestId('onboarding-next-button')); + + await waitFor(() => { + expect(onNext).toHaveBeenCalledTimes(1); + }); + expect(setCloudProviderKey).not.toHaveBeenCalled(); + }); +}); diff --git a/src/openhuman/inference/mod.rs b/src/openhuman/inference/mod.rs index 3848ed587..7a04d75ec 100644 --- a/src/openhuman/inference/mod.rs +++ b/src/openhuman/inference/mod.rs @@ -17,6 +17,7 @@ pub mod http; pub mod local; pub mod model_context; 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..a412bf7fe --- /dev/null +++ b/src/openhuman/inference/openai_oauth/flow.rs @@ -0,0 +1,327 @@ +//! OAuth start / complete / status for OpenAI Codex (ChatGPT subscription). + +use std::path::PathBuf; +use std::time::Duration; + +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; +const OAUTH_HTTP_TIMEOUT_SECS: u64 = 20; + +#[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.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 })) +} + +pub(super) 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() +} + +pub(super) async fn exchange_authorization_code( + config: &motosan_ai_oauth::OAuthConfig, + code: &str, + verifier: &str, + redirect_uri: &str, +) -> Result { + // Per RFC 6749 §4.1.3 the token request only requires grant_type, code, + // redirect_uri, code_verifier (PKCE), and client_id. `state` belongs to the + // authorization request / callback validation, not this exchange. + let mut params = vec![ + ("grant_type", "authorization_code"), + ("code", code), + ("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 client = reqwest::Client::builder() + .timeout(Duration::from_secs(OAUTH_HTTP_TIMEOUT_SECS)) + .build() + .map_err(|e| e.to_string())?; + + let resp = client + .post(config.token_url) + .header("Accept", "application/json") + .form(¶ms) + .send() + .await + .map_err(|e| { + log::warn!("{LOG_PREFIX} token exchange request failed: {e}"); + e.to_string() + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + log::warn!( + "{LOG_PREFIX} token exchange http_status={status} body_len={}", + body.len() + ); + 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..8a6e697a9 --- /dev/null +++ b/src/openhuman/inference/openai_oauth/flow_tests.rs @@ -0,0 +1,465 @@ +use super::flow::{build_authorize_url, exchange_authorization_code, parse_callback_input}; +use super::store::persist_openai_oauth_token; +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, AuthProfileKind, 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 crate::openhuman::inference::provider::factory::lookup_key_for_slug; +use chrono::{Duration, Utc}; +use motosan_ai_oauth::{OAuthConfig, StateStrategy, TokenBodyFormat}; +use tempfile::tempdir; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn test_config(tmp: &tempfile::TempDir) -> Config { + Config { + config_path: tmp.path().join("config.toml"), + ..Config::default() + } +} + +fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Runtime::new().unwrap() +} + +fn unsigned_jwt(payload: serde_json::Value) -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#); + let payload = URL_SAFE_NO_PAD.encode(payload.to_string()); + format!("{header}.{payload}.") +} + +fn test_oauth_config(token_url: &'static str) -> OAuthConfig { + OAuthConfig { + client_id: "client-id", + client_secret: Some("client-secret"), + auth_url: "https://auth.example.test/oauth/authorize", + token_url, + scopes: &["scope-a", "scope-b"], + redirect_port: Some(1455), + callback_path: "/auth/callback", + redirect_uri_host: "127.0.0.1", + token_body: TokenBodyFormat::Form, + extra_auth_params: &[("prompt", "consent")], + state_strategy: StateStrategy::Random, + } +} + +#[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 build_authorize_url_includes_codex_pkce_and_extra_params() { + let url = build_authorize_url( + &test_oauth_config("https://token.example.test/oauth/token"), + "challenge-123", + "state-123", + "http://127.0.0.1:1455/auth/callback", + ); + let parsed = reqwest::Url::parse(&url).unwrap(); + let pairs = parsed + .query_pairs() + .into_owned() + .collect::>(); + + assert_eq!( + pairs.get("client_id").map(String::as_str), + Some("client-id") + ); + assert_eq!(pairs.get("response_type").map(String::as_str), Some("code")); + assert_eq!( + pairs.get("scope").map(String::as_str), + Some("scope-a scope-b") + ); + assert_eq!(pairs.get("state").map(String::as_str), Some("state-123")); + assert_eq!( + pairs.get("code_challenge").map(String::as_str), + Some("challenge-123") + ); + assert_eq!( + pairs.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + assert_eq!(pairs.get("prompt").map(String::as_str), Some("consent")); +} + +#[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_accepts_raw_query_string() { + let (code, state) = parse_callback_input("code=abc%20123&state=xyz").unwrap(); + assert_eq!(code, "abc 123"); + 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 parse_callback_input_rejects_blank_invalid_and_missing_state() { + let blank = parse_callback_input(" ").unwrap_err(); + assert!(blank.contains("required")); + + let invalid = parse_callback_input("not-a-callback").unwrap_err(); + assert!(invalid.contains("invalid")); + + let missing_state = + parse_callback_input("http://127.0.0.1:1455/auth/callback?code=abc").unwrap_err(); + assert!(missing_state.contains("state")); +} + +#[test] +fn complete_openai_oauth_rejects_missing_pending_session() { + let tmp = tempdir().unwrap(); + let config = test_config(&tmp); + let err = runtime() + .block_on(complete_openai_oauth( + &config, + "http://127.0.0.1:1455/auth/callback?code=fake&state=state", + )) + .unwrap_err(); + assert!(err.contains("no pending OAuth session")); +} + +#[test] +fn complete_openai_oauth_rejects_expired_pending_session() { + let tmp = tempdir().unwrap(); + let config = test_config(&tmp); + std::fs::write( + tmp.path().join("openai-oauth-pending.json"), + serde_json::json!({ + "state": "state", + "verifier": "verifier", + "redirect_uri": "http://127.0.0.1:1455/auth/callback", + "created_at": 1_u64, + }) + .to_string(), + ) + .unwrap(); + + let err = runtime() + .block_on(complete_openai_oauth( + &config, + "http://127.0.0.1:1455/auth/callback?code=fake&state=state", + )) + .unwrap_err(); + assert!(err.contains("no pending OAuth session")); + assert!(!tmp.path().join("openai-oauth-pending.json").exists()); +} + +#[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 err = runtime() + .block_on(complete_openai_oauth(&config, &callback)) + .unwrap_err(); + assert!(err.contains("state mismatch")); +} + +#[tokio::test] +async fn exchange_authorization_code_parses_successful_token_response() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "access-token", + "refresh_token": "refresh-token", + "id_token": "id-token", + "expires_in": 3600, + }))) + .mount(&server) + .await; + let token_url: &'static str = Box::leak(format!("{}/token", server.uri()).into_boxed_str()); + + let token = exchange_authorization_code( + &test_oauth_config(token_url), + "code-123", + "verifier-123", + "http://127.0.0.1:1455/auth/callback", + ) + .await + .unwrap(); + + assert_eq!(token.access_token, "access-token"); + assert_eq!(token.refresh_token, "refresh-token"); + assert_eq!(token.id_token.as_deref(), Some("id-token")); + assert_eq!(token.expires_in, 3600); + assert!(token.issued_at > 0); +} + +#[tokio::test] +async fn exchange_authorization_code_reports_http_errors() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_string("bad auth code")) + .mount(&server) + .await; + let token_url: &'static str = Box::leak(format!("{}/token", server.uri()).into_boxed_str()); + + let err = exchange_authorization_code( + &test_oauth_config(token_url), + "code-123", + "verifier-123", + "http://127.0.0.1:1455/auth/callback", + ) + .await + .unwrap_err(); + + assert!(err.contains("HTTP 400")); + assert!(err.contains("bad auth code")); +} + +#[test] +fn persist_openai_oauth_token_stores_oauth_profile_with_metadata() { + let tmp = tempdir().unwrap(); + let config = test_config(&tmp); + let access_token = unsigned_jwt(serde_json::json!({ "sub": "acct_123" })); + let token = motosan_ai_oauth::Token { + access_token: access_token.clone(), + refresh_token: "refresh-token".into(), + id_token: Some("id-token".into()), + expires_in: 3600, + issued_at: 123, + }; + + let profile = persist_openai_oauth_token(&config, &token).unwrap(); + assert_eq!(profile.kind, AuthProfileKind::OAuth); + assert_eq!( + profile.metadata.get("account_id").map(String::as_str), + Some("acct_123") + ); + assert_eq!( + profile + .token_set + .as_ref() + .map(|set| set.access_token.as_str()), + Some(access_token.as_str()) + ); + assert_eq!( + profile + .token_set + .as_ref() + .and_then(|set| set.refresh_token.as_deref()), + Some("refresh-token") + ); + assert!(profile + .token_set + .as_ref() + .and_then(|set| set.expires_at) + .is_some()); + + let data = AuthProfilesStore::new(tmp.path(), false).load().unwrap(); + let stored = data.profiles.get(&profile.id).unwrap(); + assert_eq!(stored.id, profile.id); +} + +#[test] +fn openai_oauth_status_reports_token_profile_as_disconnected() { + let tmp = tempdir().unwrap(); + let config = test_config(&tmp); + let store = AuthProfilesStore::new(tmp.path(), false); + store + .upsert_profile( + AuthProfile::new_token( + OPENAI_PROVIDER_KEY, + OPENAI_OAUTH_PROFILE_NAME, + "sk-token-profile".to_string(), + ), + true, + ) + .unwrap(); + + let status = openai_oauth_status(&config).unwrap(); + assert!(!status.connected); + assert_eq!(status.auth_method.as_deref(), Some("token")); + assert!(status.profile_id.is_some()); +} + +#[test] +fn lookup_key_for_slug_prefers_api_key_over_oauth_for_openai() { + 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(); + + // The standard `lookup_key_for_slug` path resolves the API key first; the + // OAuth fallback only fires when no API key is present. + let token = lookup_key_for_slug("openai", &config).unwrap(); + assert_eq!(token, "sk-api-key"); +} + +#[test] +fn lookup_openai_bearer_token_uses_oauth_when_api_key_missing() { + 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 token = lookup_openai_bearer_token(&config).unwrap(); + assert_eq!(token.as_deref(), Some("oauth-access")); +} + +#[test] +fn lookup_key_for_slug_uses_legacy_openai_api_key_when_new_style_is_empty() { + 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: " ".into(), + refresh_token: None, + 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(); + store + .upsert_profile( + AuthProfile::new_token("openai", "default", "sk-legacy-key".to_string()), + true, + ) + .unwrap(); + + // Legacy bare-slug key resolves through the standard path's legacy + // fallback, ahead of the OAuth fallback. + let token = lookup_key_for_slug("openai", &config).unwrap(); + assert_eq!(token, "sk-legacy-key"); +} + +#[test] +fn lookup_openai_bearer_token_keeps_expired_token_when_refresh_fails_without_runtime() { + 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: "expired-access".into(), + refresh_token: Some("refresh".into()), + id_token: None, + expires_at: Some(Utc::now() - Duration::minutes(5)), + token_type: Some("Bearer".into()), + scope: None, + }, + ); + store.upsert_profile(oauth_profile, true).unwrap(); + + let token = lookup_openai_bearer_token(&config).unwrap(); + assert_eq!(token.as_deref(), Some("expired-access")); +} + +#[test] +fn lookup_openai_bearer_token_returns_none_without_profiles_or_access_token() { + let tmp = tempdir().unwrap(); + let config = test_config(&tmp); + assert_eq!(lookup_openai_bearer_token(&config).unwrap(), None); + + let store = AuthProfilesStore::new(tmp.path(), false); + let empty_oauth_profile = AuthProfile::new_oauth( + OPENAI_PROVIDER_KEY, + OPENAI_OAUTH_PROFILE_NAME, + TokenSet { + access_token: " ".into(), + refresh_token: None, + id_token: None, + expires_at: Some(Utc::now() - Duration::hours(1)), + token_type: Some("Bearer".into()), + scope: None, + }, + ); + store.upsert_profile(empty_oauth_profile, true).unwrap(); + + assert_eq!(lookup_openai_bearer_token(&config).unwrap(), None); +} + +#[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..f80eef6eb --- /dev/null +++ b/src/openhuman/inference/openai_oauth/store.rs @@ -0,0 +1,133 @@ +//! 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 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 refresh = refresh.to_string(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + // `block_in_place` lets the multi-thread runtime move other tasks off this + // worker before we synchronously drive the refresh future, avoiding a + // deadlock when this lookup is reached from inside an async caller. + return tokio::task::block_in_place(|| { + handle.block_on(motosan_ai_oauth::refresh(&cfg, &refresh)) + }) + .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) +} + +/// Look up the OpenAI bearer token sourced from the OAuth (ChatGPT +/// subscription) flow. Returns `Ok(None)` when no OAuth profile is present or +/// when the access token is empty. API-key fallback for the `openai` slug is +/// handled by the standard `lookup_key_for_slug` path — this function is +/// OAuth-only so the standard path's env/audit/metrics logic still runs. +pub fn lookup_openai_bearer_token(config: &Config) -> Result, String> { + 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(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()); + if let Err(e) = auth_profiles_store(config).upsert_profile(profile, true) { + log::warn!( + "{LOG_PREFIX} failed to persist refreshed token: {e}; \ + fresh access token will be lost on restart" + ); + } + } + 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..17aed2696 100644 --- a/src/openhuman/inference/ops.rs +++ b/src/openhuman/inference/ops.rs @@ -310,6 +310,79 @@ pub async fn inference_apply_preset(tier: &str) -> Result, Str )) } +pub async fn inference_openai_oauth_start(config: &Config) -> Result, String> { + debug!("{LOG_PREFIX} openai_oauth_start:start"); + let result = + crate::openhuman::inference::openai_oauth::start_openai_oauth(config).map(|start| { + RpcOutcome::single_log( + json!({ + "authUrl": start.auth_url, + "state": start.state, + "redirectUri": start.redirect_uri, + }), + "openai oauth authorize url ready", + ) + }); + match &result { + Ok(_) => debug!("{LOG_PREFIX} openai_oauth_start:ok"), + Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_start:error"), + } + result +} + +pub async fn inference_openai_oauth_complete( + config: &Config, + callback_url: &str, +) -> Result, String> { + debug!( + callback_len = callback_url.len(), + "{LOG_PREFIX} openai_oauth_complete:start" + ); + let result = + crate::openhuman::inference::openai_oauth::complete_openai_oauth(config, callback_url) + .await + .map(|payload| RpcOutcome::single_log(payload, "openai oauth connected")); + match &result { + Ok(_) => debug!("{LOG_PREFIX} openai_oauth_complete:ok"), + Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_complete:error"), + } + result +} + +pub async fn inference_openai_oauth_status(config: &Config) -> Result, String> { + debug!("{LOG_PREFIX} openai_oauth_status:start"); + let result = + crate::openhuman::inference::openai_oauth::openai_oauth_status(config).map(|status| { + RpcOutcome::single_log( + json!({ + "connected": status.connected, + "profileId": status.profile_id, + "expiresAt": status.expires_at, + "authMethod": status.auth_method, + }), + "openai oauth status", + ) + }); + match &result { + Ok(_) => debug!("{LOG_PREFIX} openai_oauth_status:ok"), + Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_status:error"), + } + result +} + +pub async fn inference_openai_oauth_disconnect( + config: &Config, +) -> Result, String> { + debug!("{LOG_PREFIX} openai_oauth_disconnect:start"); + let result = crate::openhuman::inference::openai_oauth::disconnect_openai_oauth(config) + .map(|payload| RpcOutcome::single_log(payload, "openai oauth disconnected")); + match &result { + Ok(_) => debug!("{LOG_PREFIX} openai_oauth_disconnect:ok"), + Err(err) => error!(error = %err, "{LOG_PREFIX} openai_oauth_disconnect:error"), + } + result +} + 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/ops_tests.rs b/src/openhuman/inference/ops_tests.rs index 7d76274b1..771ac9d95 100644 --- a/src/openhuman/inference/ops_tests.rs +++ b/src/openhuman/inference/ops_tests.rs @@ -1,11 +1,16 @@ use super::*; +use crate::openhuman::credentials::profiles::{AuthProfile, AuthProfilesStore, TokenSet}; +use crate::openhuman::inference::openai_oauth::{OPENAI_OAUTH_PROFILE_NAME, OPENAI_PROVIDER_KEY}; +use chrono::{Duration, Utc}; use tempfile::tempdir; fn disabled_config() -> (Config, tempfile::TempDir) { let tmp = tempdir().expect("tempdir"); - let mut config = Config::default(); - config.workspace_dir = tmp.path().join("workspace"); - config.config_path = tmp.path().join("config.toml"); + let mut config = Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..Config::default() + }; config.local_ai.runtime_enabled = false; config.local_ai.opt_in_confirmed = false; (config, tmp) @@ -109,3 +114,99 @@ async fn inference_presets_returns_recommended_tier() { assert!(outcome.value.get("recommended_tier").is_some()); assert!(outcome.value.get("presets").is_some()); } + +#[tokio::test] +async fn inference_openai_oauth_start_returns_authorize_payload() { + let (config, _tmp) = disabled_config(); + + let outcome = inference_openai_oauth_start(&config) + .await + .expect("oauth start"); + + assert!(outcome.value["authUrl"] + .as_str() + .unwrap() + .contains("auth.openai.com")); + assert_eq!( + outcome.value["redirectUri"].as_str(), + Some("http://127.0.0.1:1455/auth/callback") + ); + assert_eq!(outcome.logs, vec!["openai oauth authorize url ready"]); +} + +#[tokio::test] +async fn inference_openai_oauth_complete_surfaces_state_errors() { + let (config, _tmp) = disabled_config(); + let start = inference_openai_oauth_start(&config) + .await + .expect("oauth start"); + let state = start.value["state"].as_str().unwrap(); + let callback = format!("http://127.0.0.1:1455/auth/callback?code=fake&state=wrong-{state}"); + + let err = inference_openai_oauth_complete(&config, &callback) + .await + .expect_err("state mismatch should fail"); + + assert!(err.contains("state mismatch")); +} + +#[tokio::test] +async fn inference_openai_oauth_status_returns_connected_payload() { + let (config, tmp) = disabled_config(); + let store = AuthProfilesStore::new(tmp.path(), false); + store + .upsert_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: Some(Utc::now() + Duration::hours(1)), + token_type: Some("Bearer".into()), + scope: None, + }, + ), + true, + ) + .unwrap(); + + let outcome = inference_openai_oauth_status(&config) + .await + .expect("oauth status"); + + assert_eq!(outcome.value["connected"], true); + assert_eq!(outcome.value["authMethod"], "oauth"); + assert_eq!(outcome.logs, vec!["openai oauth status"]); +} + +#[tokio::test] +async fn inference_openai_oauth_disconnect_returns_removed_flag() { + let (config, tmp) = disabled_config(); + let store = AuthProfilesStore::new(tmp.path(), false); + store + .upsert_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, + }, + ), + true, + ) + .unwrap(); + + let outcome = inference_openai_oauth_disconnect(&config) + .await + .expect("oauth disconnect"); + + assert_eq!(outcome.value["disconnected"], true); + assert_eq!(outcome.logs, vec!["openai oauth disconnected"]); +} diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 3e961734a..c206b0cc1 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -609,12 +609,40 @@ pub fn lookup_key_for_slug(slug: &str, config: &Config) -> anyhow::Result { + log::debug!( + "[providers][chat-factory] auth lookup slug={} key_present=true (oauth)", + slug + ); + return Ok(token); + } + Ok(_) => {} + Err(e) => { + return Err(anyhow::anyhow!( + "[chat-factory] openai oauth lookup failed: {e}" + )); + } + } + } + log::debug!( - "[providers][chat-factory] auth lookup slug={} key_present={}", - slug, - !key.is_empty() + "[providers][chat-factory] auth lookup slug={} key_present=false", + slug ); - Ok(key) + Ok(String::new()) } /// Build an `OpenAiCompatibleProvider` with the given auth style. diff --git a/src/openhuman/inference/provider/factory_test.rs b/src/openhuman/inference/provider/factory_test.rs index 97c380a02..aef8420d8 100644 --- a/src/openhuman/inference/provider/factory_test.rs +++ b/src/openhuman/inference/provider/factory_test.rs @@ -605,6 +605,25 @@ fn verify_session_active_called_for_custom_provider_not_for_openhuman() { ); } +#[test] +fn lookup_key_for_slug_routes_openai_oauth_lookup_path() { + let tmp = TempDir::new().expect("tempdir"); + let config = config_in_tempdir(&tmp); + let auth = AuthService::new(tmp.path(), config.secrets.encrypt); + auth.store_provider_token( + "provider:openai", + "default", + "sk-openai", + Default::default(), + true, + ) + .expect("store openai token"); + + let token = lookup_key_for_slug("openai", &config).expect("lookup openai token"); + + assert_eq!(token, "sk-openai"); +} + // ── is_known_openhuman_tier ─────────────────────────────────────────────────── #[test] diff --git a/src/openhuman/inference/schemas.rs b/src/openhuman/inference/schemas.rs index 7253c4e86..70f70b9f6 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,41 @@ 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..6085cc588 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] @@ -36,6 +36,10 @@ fn inference_schema_function_names_are_stable() { assert!(functions.contains(&"presets")); assert!(functions.contains(&"apply_preset")); assert!(functions.contains(&"diagnostics")); + assert!(functions.contains(&"openai_oauth_start")); + assert!(functions.contains(&"openai_oauth_complete")); + assert!(functions.contains(&"openai_oauth_status")); + assert!(functions.contains(&"openai_oauth_disconnect")); assert!(functions.contains(&"prompt")); assert!(functions.contains(&"vision_prompt")); assert!(functions.contains(&"embed")); @@ -64,6 +68,45 @@ fn inference_chat_schema_requires_messages() { .any(|field| field.name == "messages" && field.required)); } +#[test] +fn inference_openai_oauth_schemas_are_registered_with_expected_shapes() { + let registered: Vec<&str> = all_registered_controllers() + .into_iter() + .map(|controller| controller.schema.function) + .collect(); + for function in [ + "openai_oauth_start", + "openai_oauth_complete", + "openai_oauth_status", + "openai_oauth_disconnect", + ] { + assert!(registered.contains(&function), "missing {function}"); + let schema = schemas(function); + assert_eq!(schema.namespace, "inference"); + assert_eq!(schema.function, function); + assert!(!schema.description.is_empty()); + assert!(!schema.outputs.is_empty()); + } + + let complete = schemas("openai_oauth_complete"); + assert_eq!(complete.inputs.len(), 1); + assert_eq!(complete.inputs[0].name, "callback_url"); + assert!(complete.inputs[0].required); + + assert!(schemas("openai_oauth_start").inputs.is_empty()); + assert!(schemas("openai_oauth_status").inputs.is_empty()); + assert!(schemas("openai_oauth_disconnect").inputs.is_empty()); +} + +#[tokio::test] +async fn inference_openai_oauth_complete_handler_rejects_invalid_params() { + let params = Map::from_iter([("callback_url".to_string(), Value::Bool(true))]); + let err = handle_inference_openai_oauth_complete(params) + .await + .expect_err("invalid params"); + assert!(err.contains("invalid params")); +} + #[test] fn inference_unknown_schema_panics() { let panic = std::panic::catch_unwind(|| schemas("no_such_function"));