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 <cursoragent@cursor.com>
This commit is contained in:
Ghost Scripter
2026-05-20 04:11:19 +05:30
co-authored by Cursor
parent 6a83409a4c
commit e5398e7e0f
14 changed files with 1003 additions and 9 deletions
Generated
+53
View File
@@ -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"
+1
View File
@@ -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 }
+145 -8
View File
@@ -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<string | null>(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) => {
</div>
<div className="mt-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label
htmlFor="onboarding-openai-key"
className="text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('onboarding.apiKeys.openaiLabel')}
</label>
<div className="flex flex-col gap-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/40 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-medium text-stone-700 dark:text-neutral-200">
{t('onboarding.apiKeys.openaiLabel')}
</span>
{oauthConnected ? (
<span
data-testid="onboarding-openai-oauth-connected"
className="text-xs font-medium text-sage-700 dark:text-sage-300">
{OPENAI_OAUTH_CONNECTED_LABEL}
</span>
) : null}
</div>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
Use ChatGPT Plus/Pro (subscription) or an OpenAI API key not both required.
</p>
<button
type="button"
data-testid="onboarding-openai-oauth-connect"
disabled={oauthBusy || oauthConnected || saving}
onClick={() => void handleOpenAiOAuthStart()}
className="rounded-lg border border-primary-500 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-sm font-medium text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-500/20 disabled:opacity-50">
{oauthBusy ? 'Opening sign-in…' : OPENAI_OAUTH_CONNECT_LABEL}
</button>
{oauthAwaitingCallback && !oauthConnected ? (
<div className="flex flex-col gap-1.5">
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
{OPENAI_OAUTH_CALLBACK_HINT}
</p>
<input
data-testid="onboarding-openai-oauth-callback-input"
type="text"
autoComplete="off"
spellCheck={false}
placeholder={OPENAI_OAUTH_CALLBACK_PLACEHOLDER}
value={oauthCallbackUrl}
onChange={e => {
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"
/>
<button
type="button"
data-testid="onboarding-openai-oauth-complete"
disabled={oauthBusy || saving}
onClick={() => void handleOpenAiOAuthComplete()}
className="self-start text-xs font-medium text-primary-600 dark:text-primary-400 underline disabled:opacity-50">
Finish ChatGPT sign-in
</button>
</div>
) : null}
<div className="relative flex items-center gap-2 py-1">
<div className="h-px flex-1 bg-stone-200 dark:bg-neutral-700" />
<span className="text-[10px] uppercase tracking-wide text-stone-400 dark:text-neutral-500">
or API key
</span>
<div className="h-px flex-1 bg-stone-200 dark:bg-neutral-700" />
</div>
<input
id="onboarding-openai-key"
data-testid="onboarding-api-keys-openai-input"
@@ -0,0 +1,74 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
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();
});
it('shows connected badge when oauth status reports connected', async () => {
const { callCoreRpc } = await import('../../../../services/coreRpcClient');
vi.mocked(callCoreRpc).mockResolvedValueOnce({ result: { connected: true } });
renderWithProviders(<ApiKeysStep onNext={vi.fn()} onSkip={vi.fn()} />);
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(<ApiKeysStep onNext={vi.fn()} onSkip={vi.fn()} />);
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();
});
});
+1
View File
@@ -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;
@@ -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()
}
@@ -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<String>,
pub expires_at: Option<DateTime<Utc>>,
pub auth_method: Option<String>,
}
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<Option<PendingOAuth>, 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<OpenAiOAuthStartResult, String> {
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<String> = None;
let mut state: Option<String> = 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<serde_json::Value, String> {
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<OpenAiOAuthStatusResult, String> {
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<serde_json::Value, String> {
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<motosan_ai_oauth::Token, String> {
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(&params)
.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<String>,
#[serde(default)]
id_token: Option<String>,
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()
}
@@ -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);
}
@@ -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};
@@ -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<AuthProfile, String> {
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<Token, String> {
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<String> {
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<Option<String>, 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()))
}
}
+42
View File
@@ -310,6 +310,48 @@ pub async fn inference_apply_preset(tier: &str) -> Result<RpcOutcome<Value>, Str
))
}
pub async fn inference_openai_oauth_start(config: &Config) -> Result<RpcOutcome<Value>, 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<RpcOutcome<Value>, 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<RpcOutcome<Value>, 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<RpcOutcome<Value>, 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<RpcOutcome<Value>, String> {
debug!("{LOG_PREFIX} diagnostics:start");
let service = local_runtime::global(config);
@@ -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<String> {
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);
+94
View File
@@ -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<ControllerSchema> {
vec![
schemas("status"),
@@ -132,6 +138,10 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
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<RegisteredController> {
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<String, Value>) -> ControllerFuture
})
}
fn handle_inference_openai_oauth_start(_params: Map<String, Value>) -> 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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let payload = deserialize_params::<InferenceOpenAiOAuthCompleteParams>(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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
+1 -1
View File
@@ -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]