mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
fix(ai): add OpenRouter OAuth provider flow (#2571)
This commit is contained in:
@@ -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> | 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<string>(isLocalRuntime ? defaultEndpointFor(slug) : '');
|
||||
const [phase, setPhase] = useState<'idle' | 'saving'>('idle');
|
||||
const [phase, setPhase] = useState<'idle' | 'saving' | 'oauth'>('idle');
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div
|
||||
role="dialog"
|
||||
@@ -632,6 +652,24 @@ const ProviderKeyDialog = ({
|
||||
{error ? <ProviderSetupErrorNotice error={error} /> : null}
|
||||
</div>
|
||||
|
||||
{oauthAction ? (
|
||||
<div className="mt-4 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/50 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
|
||||
Or
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
|
||||
Sign in with OpenRouter and import a user-controlled API key using PKCE.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleOAuth()}
|
||||
disabled={busy}
|
||||
className="mt-3 inline-flex items-center justify-center rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-medium text-stone-900 dark:text-neutral-100 hover:bg-stone-100 dark:hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{phase === 'oauth' ? 'Connecting...' : oauthAction.label}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -2041,10 +2079,107 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
// need to remember which label to attach to the upserted provider so the
|
||||
// chip can find it again. Cleared when the dialog closes.
|
||||
const [pendingLocalLabel, setPendingLocalLabel] = useState<string | null>(null);
|
||||
const openRouterOauthAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const updateRouting = (id: WorkloadId, next: ProviderRef) =>
|
||||
setDraft({ ...draft, routing: { ...draft.routing, [id]: next } });
|
||||
|
||||
const connectProvider = useCallback(
|
||||
async ({
|
||||
slug,
|
||||
localLabel = null,
|
||||
value,
|
||||
credentialMode,
|
||||
}: {
|
||||
slug: string;
|
||||
localLabel?: string | null;
|
||||
value: string;
|
||||
credentialMode: 'api_key' | 'oauth' | 'endpoint';
|
||||
}) => {
|
||||
const isLocalRuntime = credentialMode === 'endpoint';
|
||||
setBusyAction(`toggle-${localLabel ? localLabel.toLowerCase().replace(/\s/g, '') : slug}`);
|
||||
|
||||
try {
|
||||
const trimmed = value.trim();
|
||||
const endpoint = isLocalRuntime
|
||||
? (() => {
|
||||
const url = new URL(trimmed);
|
||||
if (!/^https?:$/.test(url.protocol)) {
|
||||
throw new Error('Endpoint must start with http:// or https://');
|
||||
}
|
||||
if (url.pathname === '' || url.pathname === '/') {
|
||||
url.pathname = '/v1';
|
||||
}
|
||||
return url.toString().replace(/\/$/, '');
|
||||
})()
|
||||
: defaultEndpointFor(slug);
|
||||
|
||||
const upserted: CloudProvider = {
|
||||
id: `p_${slug}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
slug,
|
||||
label: localLabel ?? BUILTIN_PROVIDER_META[slug]?.label ?? slug,
|
||||
endpoint,
|
||||
authStyle: authStyleForSlug(slug),
|
||||
maskedKey: maskKeyLabel(true),
|
||||
};
|
||||
|
||||
const priorWireProviders = saved.cloudProviders.map(p => ({
|
||||
id: p.id,
|
||||
slug: p.slug,
|
||||
label: p.label,
|
||||
endpoint: p.endpoint,
|
||||
auth_style: p.authStyle,
|
||||
}));
|
||||
|
||||
if (!isLocalRuntime && slug !== 'openhuman') {
|
||||
await setCloudProviderKey(slug, trimmed);
|
||||
} else if (isLocalRuntime && slug === 'ollama') {
|
||||
const baseUrl = endpoint.replace(/\/v1\/?$/, '');
|
||||
await openhumanUpdateLocalAiSettings({
|
||||
base_url: baseUrl,
|
||||
provider: 'ollama',
|
||||
runtime_enabled: true,
|
||||
opt_in_confirmed: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (slug !== 'openhuman') {
|
||||
const nextWireProviders = [
|
||||
...priorWireProviders.filter(p => p.slug !== slug),
|
||||
{
|
||||
id: upserted.id,
|
||||
slug: upserted.slug,
|
||||
label: upserted.label,
|
||||
endpoint: upserted.endpoint,
|
||||
auth_style: upserted.authStyle,
|
||||
},
|
||||
];
|
||||
await flushCloudProviders(nextWireProviders);
|
||||
try {
|
||||
await listProviderModels(slug);
|
||||
} catch (probeErr) {
|
||||
await flushCloudProviders(priorWireProviders).catch(() => {});
|
||||
if (!isLocalRuntime && slug !== 'openhuman') {
|
||||
await clearCloudProviderKey(slug).catch(() => {});
|
||||
}
|
||||
const msg = probeErr instanceof Error ? probeErr.message : String(probeErr);
|
||||
throw new Error(`Could not reach ${upserted.label}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
setDraft({
|
||||
...draft,
|
||||
cloudProviders: [...draft.cloudProviders.filter(p => p.slug !== slug), upserted],
|
||||
});
|
||||
setKeyDialogFor(null);
|
||||
setPendingLocalLabel(null);
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
},
|
||||
[draft, saved.cloudProviders, setDraft]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
loadOpenAICompatEndpointStatus()
|
||||
@@ -2532,120 +2667,43 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
slug={keyDialogFor}
|
||||
label={pendingLocalLabel ?? BUILTIN_PROVIDER_META[keyDialogFor]?.label ?? keyDialogFor}
|
||||
isLocalRuntime={Boolean(pendingLocalLabel)}
|
||||
oauthAction={
|
||||
keyDialogFor === 'openrouter' && !pendingLocalLabel
|
||||
? {
|
||||
label: 'Sign in with OpenRouter',
|
||||
onClick: async () => {
|
||||
const controller = new AbortController();
|
||||
openRouterOauthAbortRef.current = controller;
|
||||
try {
|
||||
const apiKey = await connectOpenRouterViaOAuth({ signal: controller.signal });
|
||||
await connectProvider({
|
||||
slug: 'openrouter',
|
||||
value: apiKey,
|
||||
credentialMode: 'oauth',
|
||||
});
|
||||
} finally {
|
||||
if (openRouterOauthAbortRef.current === controller) {
|
||||
openRouterOauthAbortRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
: null
|
||||
}
|
||||
onCancel={() => {
|
||||
openRouterOauthAbortRef.current?.abort();
|
||||
openRouterOauthAbortRef.current = null;
|
||||
setKeyDialogFor(null);
|
||||
setPendingLocalLabel(null);
|
||||
}}
|
||||
onSubmit={async value => {
|
||||
const slug = keyDialogFor;
|
||||
const localLabel = pendingLocalLabel;
|
||||
const isLocalRuntime = Boolean(localLabel);
|
||||
setBusyAction(
|
||||
`toggle-${localLabel ? localLabel.toLowerCase().replace(/\s/g, '') : slug}`
|
||||
);
|
||||
try {
|
||||
const trimmed = value.trim();
|
||||
// Normalize local-runtime endpoints so the cloud_providers entry
|
||||
// always carries the OpenAI-compatible `/v1` path that the
|
||||
// `list_configured_models` probe expects. Without this,
|
||||
// `http://host:11434` would pass validation, be stored verbatim,
|
||||
// and silently fail model discovery until the user manually
|
||||
// appended `/v1` — confusing because the UI would still mark the
|
||||
// provider connected (caught in review).
|
||||
const endpoint = isLocalRuntime
|
||||
? (() => {
|
||||
const url = new URL(trimmed); // throws on malformed → caught above
|
||||
if (!/^https?:$/.test(url.protocol)) {
|
||||
throw new Error('Endpoint must start with http:// or https://');
|
||||
}
|
||||
if (url.pathname === '' || url.pathname === '/') {
|
||||
url.pathname = '/v1';
|
||||
}
|
||||
return url.toString().replace(/\/$/, '');
|
||||
})()
|
||||
: defaultEndpointFor(slug);
|
||||
const upserted: CloudProvider = {
|
||||
id: `p_${slug}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
slug,
|
||||
label: localLabel ?? BUILTIN_PROVIDER_META[slug]?.label ?? slug,
|
||||
endpoint,
|
||||
authStyle: authStyleForSlug(slug),
|
||||
maskedKey: maskKeyLabel(true),
|
||||
};
|
||||
|
||||
// Snapshot the prior persisted cloud_providers list so we can
|
||||
// roll back to it if the live probe fails. `saved` reflects what
|
||||
// is currently on disk (the eager-flush effect keeps it in sync
|
||||
// with `draft`), so this is the right baseline to restore to.
|
||||
const priorWireProviders = saved.cloudProviders.map(p => ({
|
||||
id: p.id,
|
||||
slug: p.slug,
|
||||
label: p.label,
|
||||
endpoint: p.endpoint,
|
||||
auth_style: p.authStyle,
|
||||
}));
|
||||
|
||||
// Persist the credential / endpoint BEFORE the probe, so the
|
||||
// factory has everything it needs to actually answer it. Each
|
||||
// step short-circuits and surfaces its own error via throw —
|
||||
// ProviderKeyDialog.handleSave catches and keeps the dialog open
|
||||
// so the user can fix the value and retry.
|
||||
if (!isLocalRuntime && slug !== 'openhuman') {
|
||||
await setCloudProviderKey(slug, trimmed);
|
||||
} else if (isLocalRuntime && slug === 'ollama') {
|
||||
// The Rust Ollama branch reads `config.local_ai.base_url`
|
||||
// (not `cloud_providers[].endpoint`) when building the chat
|
||||
// provider — persist it eagerly so chat routing actually hits
|
||||
// the user-chosen host. Strip a trailing `/v1` since
|
||||
// `make_ollama_provider` appends `/v1` itself.
|
||||
const baseUrl = endpoint.replace(/\/v1\/?$/, '');
|
||||
await openhumanUpdateLocalAiSettings({
|
||||
base_url: baseUrl,
|
||||
provider: 'ollama',
|
||||
runtime_enabled: true,
|
||||
opt_in_confirmed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Live verification: flush the new cloud_providers list to disk
|
||||
// and call `/models` through the Rust controller. A reachable
|
||||
// endpoint + valid auth header is the strongest check we can
|
||||
// make without burning tokens. Skip the probe for the
|
||||
// OpenHuman backend (session JWT, no /models endpoint to hit).
|
||||
if (slug !== 'openhuman') {
|
||||
const nextWireProviders = [
|
||||
...priorWireProviders.filter(p => p.slug !== slug),
|
||||
{
|
||||
id: upserted.id,
|
||||
slug: upserted.slug,
|
||||
label: upserted.label,
|
||||
endpoint: upserted.endpoint,
|
||||
auth_style: upserted.authStyle,
|
||||
},
|
||||
];
|
||||
await flushCloudProviders(nextWireProviders);
|
||||
try {
|
||||
await listProviderModels(slug);
|
||||
} catch (probeErr) {
|
||||
// Roll back so the UI / on-disk state never reflects a
|
||||
// provider we couldn't actually reach. The user sees the
|
||||
// error in the dialog and the chip stays in the OFF state.
|
||||
await flushCloudProviders(priorWireProviders).catch(() => {});
|
||||
if (!isLocalRuntime && slug !== 'openhuman') {
|
||||
await clearCloudProviderKey(slug).catch(() => {});
|
||||
}
|
||||
const msg = probeErr instanceof Error ? probeErr.message : String(probeErr);
|
||||
throw new Error(`Could not reach ${upserted.label}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
setDraft({ ...draft, cloudProviders: [...draft.cloudProviders, upserted] });
|
||||
setKeyDialogFor(null);
|
||||
setPendingLocalLabel(null);
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
}}
|
||||
onSubmit={async value =>
|
||||
await connectProvider({
|
||||
slug: keyDialogFor,
|
||||
localLabel: pendingLocalLabel,
|
||||
value,
|
||||
credentialMode: pendingLocalLabel ? 'endpoint' : 'api_key',
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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(<AIPanel />);
|
||||
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(<AIPanel />);
|
||||
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: [] });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<LoopbackHandle | null>;
|
||||
openExternalUrl?: (url: string) => Promise<void>;
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string>((_, 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user