From d52abe55246f5456f5a09e1d036066a5f18cffd7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sun, 24 May 2026 10:01:58 -0700 Subject: [PATCH] fix(ai): add OpenRouter OAuth provider flow (#2571) --- .../components/settings/panels/AIPanel.tsx | 280 +++++++++++------- .../panels/__tests__/AIPanel.test.tsx | 43 +++ .../utils/__tests__/openrouterOAuth.test.ts | 98 ++++++ app/src/utils/openrouterOAuth.ts | 154 ++++++++++ 4 files changed, 464 insertions(+), 111 deletions(-) create mode 100644 app/src/utils/__tests__/openrouterOAuth.test.ts create mode 100644 app/src/utils/openrouterOAuth.ts diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index 8f369e8eb..5695b7ebc 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -36,6 +36,7 @@ import { type CreditTransaction, type TeamUsage, } from '../../../services/api/creditsApi'; +import { connectOpenRouterViaOAuth } from '../../../utils/openrouterOAuth'; import { type AuthStyle, openhumanUpdateLocalAiSettings, @@ -530,6 +531,7 @@ const ProviderKeyDialog = ({ slug, label, isLocalRuntime, + oauthAction, onCancel, onSubmit, }: { @@ -537,6 +539,7 @@ const ProviderKeyDialog = ({ label: string; /** When true, render an "Endpoint URL" field instead of API key. */ isLocalRuntime: boolean; + oauthAction?: { label: string; onClick: () => Promise | void } | null; onCancel: () => void; /** Returns the entered value. For local runtimes this is the endpoint URL; * for cloud providers it's the API key. */ @@ -544,7 +547,7 @@ const ProviderKeyDialog = ({ }) => { const { t } = useT(); const [value, setValue] = useState(isLocalRuntime ? defaultEndpointFor(slug) : ''); - const [phase, setPhase] = useState<'idle' | 'saving'>('idle'); + const [phase, setPhase] = useState<'idle' | 'saving' | 'oauth'>('idle'); const [error, setError] = useState(null); const busy = phase !== 'idle'; @@ -592,6 +595,23 @@ const ProviderKeyDialog = ({ } }; + const handleOAuth = async () => { + if (!oauthAction) return; + setError(null); + setPhase('oauth'); + try { + await oauthAction.onClick(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn('[ai-settings] provider oauth failed', { + slug, + summary: presentProviderSetupError(message).summary, + }); + setError(message); + setPhase('idle'); + } + }; + return (
: null}
+ {oauthAction ? ( +
+
+ Or +
+

+ Sign in with OpenRouter and import a user-controlled API key using PKCE. +

+ +
+ ) : null} +
diff --git a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx index 2f8a997e5..c0122e961 100644 --- a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx @@ -14,6 +14,7 @@ import { } from '../../../../services/api/aiSettingsApi'; import { creditsApi } from '../../../../services/api/creditsApi'; import { renderWithProviders } from '../../../../test/test-utils'; +import { connectOpenRouterViaOAuth } from '../../../../utils/openrouterOAuth'; // Lazy import so the typed mock is available to individual tests. import { openhumanUpdateLocalAiSettings as openhumanUpdateLocalAiSettingsMock } from '../../../../utils/tauriCommands/config'; import { @@ -89,6 +90,8 @@ vi.mock('../../../../utils/tauriCommands/config', async () => { }; }); +vi.mock('../../../../utils/openrouterOAuth', () => ({ connectOpenRouterViaOAuth: vi.fn() })); + const baseSettings = { cloudProviders: [ { @@ -204,6 +207,7 @@ describe('AIPanel', () => { vi.mocked(clearOpenAICompatEndpointKey).mockResolvedValue(undefined); vi.mocked(setCloudProviderKey).mockResolvedValue(undefined); vi.mocked(listProviderModels).mockResolvedValue([]); + vi.mocked(connectOpenRouterViaOAuth).mockResolvedValue('sk-or-oauth'); vi.mocked(openhumanHeartbeatSettingsGet).mockResolvedValue({ result: { settings: baseHeartbeatSettings }, logs: [], @@ -418,6 +422,45 @@ describe('AIPanel', () => { expect(screen.getByLabelText(/API key/i)).toBeInTheDocument(); }); + it('clicking the OpenRouter chip shows both API key entry and the OAuth button', async () => { + vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] }); + + renderWithProviders(); + await waitFor(() => + expect(screen.getByRole('switch', { name: /Connect OpenRouter/i })).toBeInTheDocument() + ); + + fireEvent.click(screen.getByRole('switch', { name: /Connect OpenRouter/i })); + + const dialog = await screen.findByRole('dialog', { name: /Connect OpenRouter/i }); + expect(within(dialog).getByLabelText(/API key/i)).toBeInTheDocument(); + expect( + within(dialog).getByRole('button', { name: /Sign in with OpenRouter/i }) + ).toBeInTheDocument(); + }); + + it('stores the OpenRouter OAuth key and enables the provider chip', async () => { + vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] }); + vi.mocked(connectOpenRouterViaOAuth).mockResolvedValue('sk-or-from-oauth'); + + renderWithProviders(); + await waitFor(() => + expect(screen.getByRole('switch', { name: /Connect OpenRouter/i })).toBeInTheDocument() + ); + + fireEvent.click(screen.getByRole('switch', { name: /Connect OpenRouter/i })); + const dialog = await screen.findByRole('dialog', { name: /Connect OpenRouter/i }); + fireEvent.click(within(dialog).getByRole('button', { name: /Sign in with OpenRouter/i })); + + await waitFor(() => expect(connectOpenRouterViaOAuth).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(setCloudProviderKey).toHaveBeenCalledWith('openrouter', 'sk-or-from-oauth') + ); + await waitFor(() => + expect(screen.getByRole('switch', { name: /Disconnect OpenRouter/i })).toBeInTheDocument() + ); + }); + it('clicking the Custom chip (when disabled) opens the CloudProviderEditor, not the key dialog', async () => { // Load with no custom provider → chip is off. vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] }); diff --git a/app/src/utils/__tests__/openrouterOAuth.test.ts b/app/src/utils/__tests__/openrouterOAuth.test.ts new file mode 100644 index 000000000..47bf4b154 --- /dev/null +++ b/app/src/utils/__tests__/openrouterOAuth.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { connectOpenRouterViaOAuth } from '../openrouterOAuth'; + +describe('connectOpenRouterViaOAuth', () => { + it('opens the OpenRouter auth URL and exchanges the callback code for an API key', async () => { + const openExternalUrl = vi.fn().mockResolvedValue(undefined); + const cancel = vi.fn().mockResolvedValue(undefined); + const startLoopbackListener = vi + .fn() + .mockResolvedValue({ + redirectUri: 'http://127.0.0.1:53824/auth?state=expected-state', + state: 'expected-state', + awaitCallback: vi + .fn() + .mockResolvedValue('http://127.0.0.1:53824/auth?state=expected-state&code=abc123'), + cancel, + }); + const fetchImpl = vi + .fn() + .mockResolvedValue({ ok: true, json: async () => ({ key: 'sk-or-via-oauth' }) }); + + const key = await connectOpenRouterViaOAuth({ + startLoopbackListener, + openExternalUrl, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(key).toBe('sk-or-via-oauth'); + expect(startLoopbackListener).toHaveBeenCalledWith({ port: 3000 }); + expect(openExternalUrl).toHaveBeenCalledTimes(1); + const authUrl = new URL(openExternalUrl.mock.calls[0][0]); + expect(authUrl.origin + authUrl.pathname).toBe('https://openrouter.ai/auth'); + expect(authUrl.searchParams.get('callback_url')).toBe( + 'http://localhost:3000/auth?state=expected-state' + ); + expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256'); + expect(authUrl.searchParams.get('code_challenge')).toBeTruthy(); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://openrouter.ai/api/v1/auth/keys', + expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' } }) + ); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('rejects when the loopback listener is unavailable', async () => { + await expect( + connectOpenRouterViaOAuth({ startLoopbackListener: vi.fn().mockResolvedValue(null) }) + ).rejects.toThrow('OpenRouter OAuth requires the desktop app'); + }); + + it('rejects when the callback state does not match the request', async () => { + const cancel = vi.fn().mockResolvedValue(undefined); + + await expect( + connectOpenRouterViaOAuth({ + startLoopbackListener: vi + .fn() + .mockResolvedValue({ + redirectUri: 'http://127.0.0.1:53824/auth?state=expected-state', + state: 'expected-state', + awaitCallback: vi + .fn() + .mockResolvedValue('http://127.0.0.1:53824/auth?state=wrong-state&code=abc123'), + cancel, + }), + openExternalUrl: vi.fn().mockResolvedValue(undefined), + fetchImpl: vi.fn() as unknown as typeof fetch, + }) + ).rejects.toThrow('OpenRouter OAuth callback state did not match the request.'); + + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('cancels the loopback listener when the OAuth flow is aborted', async () => { + const cancel = vi.fn().mockResolvedValue(undefined); + const controller = new AbortController(); + + const promise = connectOpenRouterViaOAuth({ + signal: controller.signal, + startLoopbackListener: vi + .fn() + .mockResolvedValue({ + redirectUri: 'http://127.0.0.1:3000/auth?state=expected-state', + state: 'expected-state', + awaitCallback: vi.fn().mockImplementation(() => new Promise(() => {})), + cancel, + }), + openExternalUrl: vi.fn().mockResolvedValue(undefined), + fetchImpl: vi.fn() as unknown as typeof fetch, + }); + + controller.abort(); + + await expect(promise).rejects.toThrow('OpenRouter OAuth was cancelled.'); + expect(cancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/utils/openrouterOAuth.ts b/app/src/utils/openrouterOAuth.ts new file mode 100644 index 000000000..a82116de9 --- /dev/null +++ b/app/src/utils/openrouterOAuth.ts @@ -0,0 +1,154 @@ +import { + type LoopbackHandle, + startLoopbackOauthListener, + type StartLoopbackOptions, +} from './loopbackOauthListener'; +import { openUrl } from './openUrl'; + +const OPENROUTER_AUTH_URL = 'https://openrouter.ai/auth'; +const OPENROUTER_TOKEN_URL = 'https://openrouter.ai/api/v1/auth/keys'; +const PKCE_METHOD = 'S256'; +const OPENROUTER_LOOPBACK_PORT = 3000; +const VERIFIER_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + +interface OpenRouterExchangeResponse { + key?: string; + error?: { message?: string } | string; +} + +export interface OpenRouterOAuthDeps { + startLoopbackListener?: (options?: StartLoopbackOptions) => Promise; + openExternalUrl?: (url: string) => Promise; + fetchImpl?: typeof fetch; + signal?: AbortSignal; +} + +function randomVerifier(length = 64): string { + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + return Array.from(bytes, value => VERIFIER_ALPHABET[value % VERIFIER_ALPHABET.length]).join(''); +} + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ''; + for (const value of bytes) { + binary += String.fromCharCode(value); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +async function createCodeChallenge(verifier: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return base64UrlEncode(new Uint8Array(digest)); +} + +function extractOAuthCode(callbackUrl: string, expectedState: string): string { + let parsed: URL; + try { + parsed = new URL(callbackUrl); + } catch { + throw new Error('OpenRouter OAuth returned an invalid callback URL.'); + } + + const actualState = parsed.searchParams.get('state'); + if (actualState !== expectedState) { + throw new Error('OpenRouter OAuth callback state did not match the request.'); + } + + const code = parsed.searchParams.get('code'); + if (!code) { + throw new Error('OpenRouter OAuth did not return an authorization code.'); + } + return code; +} + +async function exchangeCodeForKey( + code: string, + verifier: string, + fetchImpl: typeof fetch +): Promise { + const response = await fetchImpl(OPENROUTER_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: PKCE_METHOD }), + }); + + let body: OpenRouterExchangeResponse | null = null; + try { + body = (await response.json()) as OpenRouterExchangeResponse; + } catch { + body = null; + } + + if (!response.ok) { + const detail = + typeof body?.error === 'string' + ? body.error + : body?.error && typeof body.error === 'object' + ? body.error.message + : null; + throw new Error(detail || `OpenRouter key exchange failed (${response.status}).`); + } + + if (!body?.key || typeof body.key !== 'string') { + throw new Error('OpenRouter key exchange succeeded but no API key was returned.'); + } + + return body.key; +} + +function toOpenRouterCallbackUrl(redirectUri: string): string { + let parsed: URL; + try { + parsed = new URL(redirectUri); + } catch { + throw new Error('OpenRouter OAuth listener returned an invalid redirect URL.'); + } + + parsed.hostname = 'localhost'; + parsed.port = String(OPENROUTER_LOOPBACK_PORT); + return parsed.toString(); +} + +export async function connectOpenRouterViaOAuth(deps: OpenRouterOAuthDeps = {}): Promise { + const startLoopbackListener = deps.startLoopbackListener ?? startLoopbackOauthListener; + const openExternalUrl = deps.openExternalUrl ?? openUrl; + const fetchImpl = deps.fetchImpl ?? fetch; + const signal = deps.signal; + + const loopback = await startLoopbackListener({ port: OPENROUTER_LOOPBACK_PORT }); + if (!loopback) { + throw new Error('OpenRouter OAuth requires the desktop app. Use an API key instead.'); + } + + if (signal?.aborted) { + await loopback.cancel(); + throw new Error('OpenRouter OAuth was cancelled.'); + } + + const verifier = randomVerifier(); + const challenge = await createCodeChallenge(verifier); + const authUrl = new URL(OPENROUTER_AUTH_URL); + authUrl.searchParams.set('callback_url', toOpenRouterCallbackUrl(loopback.redirectUri)); + authUrl.searchParams.set('code_challenge', challenge); + authUrl.searchParams.set('code_challenge_method', PKCE_METHOD); + + try { + await openExternalUrl(authUrl.toString()); + const callbackUrl = await Promise.race([ + loopback.awaitCallback(), + new Promise((_, reject) => { + if (!signal) return; + const onAbort = () => { + signal.removeEventListener('abort', onAbort); + reject(new Error('OpenRouter OAuth was cancelled.')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }), + ]); + const code = extractOAuthCode(callbackUrl, loopback.state); + return await exchangeCodeForKey(code, verifier, fetchImpl); + } finally { + await loopback.cancel(); + } +}