revert(assistant): drop the ChatGPT relay, open-source apps keep the loopback sign-in

OpenAI offers a fixed web redirect for Sign in with ChatGPT only to selected
commercial partners; open-source apps sign in through the 127.0.0.1 loopback
callback. The relay could never be switched on, so it goes again.
This commit is contained in:
Maurice
2026-10-21 22:48:55 +02:00
parent 7bb5784913
commit d1ee2c05f4
12 changed files with 67 additions and 391 deletions
@@ -5,7 +5,6 @@ import { CHATGPT_USAGE_URL, providerLabel } from '../../Assistant/providerCatalo
import { fs } from '../../shared/DialogShell';
import { INPUT } from '../../shared/dialogParts';
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingsHint } from '../settingsKit';
import { rememberChatgptFlow } from './aiConnectModel';
import type { ConnectError, useChatgptConnect } from './useAiConnect';
type ChatgptConnect = ReturnType<typeof useChatgptConnect>;
@@ -98,12 +97,9 @@ export default function ChatGptPasteStep({
<div className="flex">
<a
href={flow?.authorizeUrl}
// A sign-in that comes back by itself leaves this tab; one to paste from opens beside it.
{...(flow?.returns ? {} : { target: '_blank', rel: 'noopener noreferrer' })}
onClick={() => {
if (flow?.returns) rememberChatgptFlow(flow.flowId);
markOpened();
}}
target="_blank"
rel="noopener noreferrer"
onClick={markOpened}
aria-disabled={!flow}
className={`${SETTINGS_BUTTON_PRIMARY} ${flow ? '' : 'pointer-events-none opacity-50'}`}
style={fs(13, 'body')}
@@ -113,58 +109,56 @@ export default function ChatGptPasteStep({
</a>
</div>
</Step>
{!flow?.returns && (
<Step n={2} text={t('assistant.connect.chatgptStep2')}>
<div className="flex gap-2">
<input
id={inputId}
ref={inputRef}
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
dir="ltr"
value={pasted}
onChange={(e) => change(e.target.value)}
onPaste={(e) => {
if (paste(e.clipboardData.getData('text'))) e.preventDefault();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void submit();
}
}}
placeholder="http://127.0.0.1:1455/auth/callback?code=…"
aria-label={t('assistant.connect.chatgptPaste')}
className={`${INPUT} font-mono`}
/>
{pasted.trim() ? (
<button
type="button"
onClick={() => void submit()}
disabled={busy || !flow}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('assistant.connect.save')}
</button>
) : (
<button
type="button"
onClick={pasteFromClipboard}
disabled={!flow}
className={`${SETTINGS_BUTTON} whitespace-nowrap`}
style={fs(13, 'body')}
>
<ClipboardPaste size={14} strokeWidth={2} />
{t('assistant.connect.chatgptPaste')}
</button>
)}
</div>
</Step>
)}
<Step n={2} text={t('assistant.connect.chatgptStep2')}>
<div className="flex gap-2">
<input
id={inputId}
ref={inputRef}
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
dir="ltr"
value={pasted}
onChange={(e) => change(e.target.value)}
onPaste={(e) => {
if (paste(e.clipboardData.getData('text'))) e.preventDefault();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void submit();
}
}}
placeholder="http://127.0.0.1:1455/auth/callback?code=…"
aria-label={t('assistant.connect.chatgptPaste')}
className={`${INPUT} font-mono`}
/>
{pasted.trim() ? (
<button
type="button"
onClick={() => void submit()}
disabled={busy || !flow}
className={SETTINGS_BUTTON_PRIMARY}
style={fs(13, 'body')}
>
{busy && <Loader2 size={14} className="animate-spin" />}
{t('assistant.connect.save')}
</button>
) : (
<button
type="button"
onClick={pasteFromClipboard}
disabled={!flow}
className={`${SETTINGS_BUTTON} whitespace-nowrap`}
style={fs(13, 'body')}
>
<ClipboardPaste size={14} strokeWidth={2} />
{t('assistant.connect.chatgptPaste')}
</button>
)}
</div>
</Step>
</ol>
{error && (
<ConnectErrorNote error={error} provider={providerLabel('chatgpt', '')} onOtherProvider={onOtherProvider} />
@@ -71,28 +71,6 @@ export function rememberOpenrouterFlow(flowId: string): void {
}
}
const CHATGPT_FLOW_KEY = 'trek.ai.chatgptFlow';
/** Keeps the ChatGPT sign-in that left for OpenAI and comes back by itself, for the page it returns to. */
export function rememberChatgptFlow(flowId: string): void {
try {
sessionStorage.setItem(CHATGPT_FLOW_KEY, flowId);
} catch {
// Storage refused (private mode): the return then reads as an expired sign-in.
}
}
/** The ChatGPT sign-in this tab started, read once. */
export function takeChatgptFlow(): string | null {
try {
const flowId = sessionStorage.getItem(CHATGPT_FLOW_KEY);
sessionStorage.removeItem(CHATGPT_FLOW_KEY);
return flowId;
} catch {
return null;
}
}
/** The flow the OpenRouter sign-in started, read once. */
export function takeOpenrouterFlow(): string | null {
try {
@@ -17,15 +17,9 @@ const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/aiConnectionsRepo', () => ({ aiConnectionsRepo: repo }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const {
useChatgptConnect,
useChatgptReturn,
useCustomConnect,
useKeyConnect,
useOpenrouterReturn,
useOpenrouterStart,
} = await import('./useAiConnect');
const { rememberChatgptFlow, rememberOpenrouterFlow } = await import('./aiConnectModel');
const { useChatgptConnect, useCustomConnect, useKeyConnect, useOpenrouterReturn, useOpenrouterStart } =
await import('./useAiConnect');
const { rememberOpenrouterFlow } = await import('./aiConnectModel');
function wrapperAt(url = '/settings') {
return ({ children }: { children: ReactNode }) =>
@@ -256,26 +250,3 @@ describe('OpenRouter', () => {
expect(repo.openrouterFinish).not.toHaveBeenCalled();
});
});
describe('useChatgptReturn', () => {
it('FE-HOOK-AI-CONNECT-030: a sign-in back through the relay finishes once, as the address it came back to', async () => {
rememberChatgptFlow(FLOW.flowId);
const onConnected = vi.fn();
const { result } = renderHook(
() => {
useChatgptReturn(onConnected);
return useLocation();
},
{ wrapper: wrapperAt('/settings?tab=integrations&ai=chatgpt&code=c1&state=s1.aA') }
);
await waitFor(() => expect(onConnected).toHaveBeenCalledTimes(1));
// The address as the browser holds it; the server checks it as it checks a pasted one.
expect(repo.chatgptFinish).toHaveBeenCalledWith({ flowId: FLOW.flowId, pastedUrl: window.location.href });
expect(result.current.search).toBe('?tab=integrations');
vi.clearAllMocks();
renderHook(() => useChatgptReturn(onConnected), { wrapper: wrapperAt('/settings?ai=chatgpt&code=again') });
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
expect(repo.chatgptFinish).not.toHaveBeenCalled();
});
});
@@ -5,14 +5,7 @@ import { useTranslation } from '../../../i18n';
import { aiConnectionsRepo } from '../../../repo/aiConnectionsRepo';
import { detectKeyKind, isChatgptCallback, providerLabel } from '../../Assistant/providerCatalog';
import { useToast } from '../../shared/Toast';
import {
connectErrorKey,
httpHostOf,
refusalCode,
rememberOpenrouterFlow,
takeChatgptFlow,
takeOpenrouterFlow,
} from './aiConnectModel';
import { connectErrorKey, httpHostOf, refusalCode, rememberOpenrouterFlow, takeOpenrouterFlow } from './aiConnectModel';
/**
* The ways to connect a provider, one hook each, behind `AiConnectPanel`: a
@@ -300,49 +293,6 @@ export function useOpenrouterStart(scope: AiConnectionScope) {
return { start, busy };
}
/**
* Finishes a ChatGPT sign-in the page came back from through the relay
* (`?ai=chatgpt&code=…&state=…`): the server reads the address as it reads a
* pasted one. The parameters leave the address at once, so a reload does not
* try again.
*/
export function useChatgptReturn(onConnected: () => void) {
const { t } = useTranslation();
const toast = useToast();
const [params, setParams] = useSearchParams();
const handled = useRef(false);
const finish = useEffectEvent(async (returned: string) => {
const flowId = takeChatgptFlow();
if (!flowId) {
toast.error(t('assistant.connect.chatgptExpired'));
return;
}
try {
await aiConnectionsRepo.chatgptFinish({ flowId, pastedUrl: returned });
toast.success(t('assistant.connect.ok'));
onConnected();
} catch (err) {
toast.error(t(connectErrorKey(err)));
}
});
useEffect(() => {
if (handled.current || params.get('ai') !== 'chatgpt') return;
handled.current = true;
const returned = window.location.href;
setParams(
(prev) => {
const next = new URLSearchParams(prev);
for (const key of ['ai', 'code', 'state', 'scope', 'client_id', 'error']) next.delete(key);
return next;
},
{ replace: true }
);
void finish(returned);
}, [params, setParams]);
}
/**
* Finishes an OpenRouter sign-in the page came back from (`?ai=openrouter&code=…`)
* once, and takes both parameters out of the address so a reload does not try again.
@@ -9,7 +9,7 @@ import { useAuthStore } from '../../../store/authStore';
import { providerLabel } from '../../Assistant/providerCatalog';
import { useToast } from '../../shared/Toast';
import { type AiConnectMode, connectErrorKey, repairModeOf } from './aiConnectModel';
import { useChatgptReturn, useOpenrouterReturn } from './useAiConnect';
import { useOpenrouterReturn } from './useAiConnect';
/**
* What the AI connection card shows:
@@ -157,7 +157,6 @@ export function useAiConnections({ phone = false }: { phone?: boolean } = {}) {
void reload();
};
useOpenrouterReturn(onConnected);
useChatgptReturn(onConnected);
const own = status?.own ?? null;
const view = viewOf(status, loaded);
-70
View File
@@ -1,70 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<meta name="referrer" content="no-referrer" />
<title>TREK: back to your TREK</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font: 15px/1.5 system-ui, sans-serif;
background: #f8fafc;
color: #0f172a;
}
main {
max-width: 28rem;
padding: 2rem;
text-align: center;
}
a {
color: inherit;
}
</style>
</head>
<body>
<main>
<p id="message">Taking you back to your TREK…</p>
</main>
<script>
// Sign in with ChatGPT for every TREK install comes back here, the one
// address registered with the project's client id. The sign-in's state
// is "<check>.<base64url of the address to return to>": the install
// that started it wrote that address, and checks the whole state when
// the browser arrives. This page only passes the answer on. The code in
// it is of no use without the PKCE verifier, which only that install
// holds, so a state pointing elsewhere gives nothing away.
(function () {
var params = new URLSearchParams(window.location.search);
var message = document.getElementById('message');
function fail(text) {
message.textContent = text;
}
var state = params.get('state') || '';
var dot = state.indexOf('.');
if (dot < 1) return fail('This page is only a stop on the way back to TREK. Start the sign-in in TREK again.');
var back;
try {
var encoded = state.slice(dot + 1).replace(/-/g, '+').replace(/_/g, '/');
back = new URL(decodeURIComponent(escape(atob(encoded))));
} catch (e) {
return fail('The way back to TREK could not be read. Start the sign-in in TREK again.');
}
var local = back.hostname === 'localhost' || back.hostname === '127.0.0.1';
var settings = /\/settings$/.test(back.pathname) && back.searchParams.get('ai') === 'chatgpt';
if ((back.protocol !== 'https:' && !local) || !settings) {
return fail('This sign-in does not lead back to a TREK. Start it in TREK again.');
}
['code', 'state', 'scope', 'client_id', 'error', 'error_description'].forEach(function (key) {
var value = params.get(key);
if (value !== null) back.searchParams.set(key, value);
});
window.location.replace(back.toString());
})();
</script>
</body>
</html>
@@ -12,8 +12,6 @@ export interface ChatgptAuthFlow {
nonce: string;
/** The client id this user's registration was issued, sent instead of registering again. */
savedClientId: string | null;
/** Where the sign-in ends: the loopback page to paste from, or the relay that comes back by itself. */
redirectUri: string;
expiresAt: number;
}
@@ -1,4 +1,4 @@
import { getAppUrl, runningVersion } from '../../app-config';
import { runningVersion } from '../../app-config';
import { AiConnections } from '../../db/entities/AiConnections.entity';
import { AI_CONNECTION_ACCOUNT, type AiConnectionAccount } from '../../db/json-columns';
import type { AiConnectionRow, AiConnectionsRepository } from '../../db/repositories/AiConnections.repository';
@@ -11,7 +11,6 @@ import { aiRefusal, providerRefusal } from './ai-connections.constants';
import { AiRefreshLock } from './ai-refresh-lock';
import { openSecrets, sealSecrets, type ChatgptTokens } from './ai-secrets';
import { AiSettingsService } from './ai-settings.service';
import { chatgptRelay, isChatgptCallback, relayState } from './chatgpt-return';
import { ModelCatalogService, pickDefaultModel } from './model-catalog.service';
import { AiProviderError, errorCodeOf } from './providers/provider-errors';
import { InjectRepository } from '@mikro-orm/nestjs';
@@ -108,7 +107,7 @@ function readPastedCallback(pasted: string): PastedCallback {
} catch {
throw aiRefusal('wrong_url', 'That is not the address the sign-in ended on');
}
if (!isChatgptCallback(url, CHATGPT_REDIRECT_URI, getAppUrl())) {
if (`${url.origin}${url.pathname}` !== CHATGPT_REDIRECT_URI) {
throw aiRefusal('wrong_url', 'That is not the address the sign-in ended on');
}
const params = url.searchParams;
@@ -154,13 +153,9 @@ export class ChatgptAuthService {
async start(userId: number): Promise<AiAuthStartResponse> {
await this.settings.assertCanConnectOwn(userId);
const existing = await this.connections.findForUser(userId);
const relay = chatgptRelay(getAppUrl());
const registered = existing?.kind === 'chatgpt' ? (this.accountOf(existing).clientId ?? null) : null;
// Through the relay every user signs in with the project's client; otherwise each registers their own.
const savedClientId = relay?.clientId ?? registered;
const redirectUri = relay?.redirectUri ?? CHATGPT_REDIRECT_URI;
const savedClientId = existing?.kind === 'chatgpt' ? (this.accountOf(existing).clientId ?? null) : null;
const verifier = randomToken();
const state = relay ? relayState(randomToken(), relay.returnUrl) : randomToken();
const state = randomToken();
const nonce = randomToken();
const params = new URLSearchParams({
client_id: savedClientId ?? DYNAMIC_CLIENT_ID,
@@ -168,7 +163,7 @@ export class ChatgptAuthService {
...(savedClientId ? {} : { agent_name_hint: 'TREK' }),
ext_agent_host_id: await this.settings.siwcHostId(),
response_type: 'code',
redirect_uri: redirectUri,
redirect_uri: CHATGPT_REDIRECT_URI,
resource: RESOURCE,
scope: SCOPE,
state,
@@ -178,22 +173,11 @@ export class ChatgptAuthService {
});
const flowId = randomUUID();
const expiresAt = Date.now() + AI_AUTH_FLOW_TTL_MS;
const flow = {
provider: 'chatgpt' as const,
userId,
verifier,
state,
nonce,
savedClientId,
redirectUri,
expiresAt,
};
await this.flows.put(flowId, flow);
await this.flows.put(flowId, { provider: 'chatgpt', userId, verifier, state, nonce, savedClientId, expiresAt });
return {
flowId,
authorizeUrl: `${AUTHORIZE_URL}?${params.toString()}`,
expiresAt: new Date(expiresAt).toISOString(),
...(relay ? { returns: true } : {}),
};
}
@@ -221,7 +205,7 @@ export class ChatgptAuthService {
throw aiRefusal('wrong_url', 'That is not the address the sign-in ended on');
}
const tokens = await this.exchangeCode(clientId, callback.code, flow.verifier, flow.redirectUri);
const tokens = await this.exchangeCode(clientId, callback.code, flow.verifier);
if (!tokens.id_token || !tokens.refresh_token) {
throw aiRefusal('provider_unavailable', 'ChatGPT sent an incomplete answer');
}
@@ -333,19 +317,14 @@ export class ChatgptAuthService {
});
}
private async exchangeCode(
clientId: string,
code: string,
verifier: string,
redirectUri: string,
): Promise<TokenResponse> {
private async exchangeCode(clientId: string, code: string, verifier: string): Promise<TokenResponse> {
try {
return await this.tokenRequest({
grant_type: 'authorization_code',
client_id: clientId,
code,
code_verifier: verifier,
redirect_uri: redirectUri,
redirect_uri: CHATGPT_REDIRECT_URI,
resource: RESOURCE,
});
} catch (err) {
@@ -1,62 +0,0 @@
/**
* Sign in with ChatGPT that comes back to TREK by itself. OpenAI sends a
* sign-in only to an address registered with the client id; an install has
* its own address, so TREK registers one small page (the relay, a static page
* of the TREK project) that forwards the browser to the install named in the
* sign-in's `state`. The relay sees the code but cannot use it: redeeming it
* takes the PKCE verifier only the install that started the sign-in holds.
*
* Until the project's client id is registered, or for an install without an
* https address, the sign-in ends on the loopback page and the user pastes
* its address, as before.
*/
/** The client id the TREK project registered with OpenAI for the relay; null until it is. */
export const PROJECT_CHATGPT_CLIENT_ID: string | null = null;
/** The relay page, registered with that client id as its one redirect address. */
export const CHATGPT_RELAY_URL = 'https://liketrek.github.io/chatgpt-callback/';
/** Where a sign-in through the relay lands in the install: the settings, which finish it. */
const RETURN_PATH = '/settings';
const RETURN_QUERY = '?tab=integrations&ai=chatgpt';
/** The address the relay sends the browser back to, or null where the install cannot take it. */
export function chatgptReturnUrl(appUrl: string): string | null {
let url: URL;
try {
url = new URL(appUrl);
} catch {
return null;
}
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
if (url.protocol !== 'https:' && !local) return null;
return `${url.origin}${url.pathname.replace(/\/+$/, '')}${RETURN_PATH}${RETURN_QUERY}`;
}
/** The relay to sign in through, or null to keep the pasted address. */
export function chatgptRelay(appUrl: string): { clientId: string; redirectUri: string; returnUrl: string } | null {
const returnUrl = chatgptReturnUrl(appUrl);
if (!PROJECT_CHATGPT_CLIENT_ID || !returnUrl) return null;
return { clientId: PROJECT_CHATGPT_CLIENT_ID, redirectUri: CHATGPT_RELAY_URL, returnUrl };
}
/**
* The `state` of a sign-in through the relay: the random check value, a dot,
* and the address to come back to (base64url). The relay reads only the part
* after the dot; the install compares the whole value with the one it kept.
*/
export function relayState(check: string, returnUrl: string): string {
return `${check}.${Buffer.from(returnUrl, 'utf8').toString('base64url')}`;
}
/** Whether a pasted or returned address is one the sign-in can end on: the loopback page or this install's settings. */
export function isChatgptCallback(url: URL, loopback: string, appUrl: string): boolean {
if (`${url.origin}${url.pathname}` === loopback) return true;
const back = chatgptReturnUrl(appUrl);
if (!back) return false;
const expected = new URL(back);
return (
url.origin === expected.origin && url.pathname === expected.pathname && url.searchParams.get('ai') === 'chatgpt'
);
}
@@ -54,7 +54,6 @@ describe('InMemoryAiAuthFlowStore', () => {
state: 's',
nonce: 'n',
savedClientId: null,
redirectUri: 'http://127.0.0.1:1455/auth/callback',
expiresAt,
});
@@ -1,58 +0,0 @@
/**
* Sign in with ChatGPT through the TREK relay: where the browser comes back
* to, what the relay can read from the state, and which addresses a sign-in
* may end on.
*/
import {
chatgptRelay,
chatgptReturnUrl,
isChatgptCallback,
PROJECT_CHATGPT_CLIENT_ID,
relayState,
} from '../../../../src/nest/ai-connections/chatgpt-return';
import { describe, expect, it } from 'vitest';
const LOOPBACK = 'http://127.0.0.1:1455/auth/callback';
describe('the ChatGPT relay', () => {
it('CGRET-001: comes back to the settings of an https install, or of one on this machine, never elsewhere', () => {
expect(chatgptReturnUrl('https://trip.example.org')).toBe(
'https://trip.example.org/settings?tab=integrations&ai=chatgpt',
);
expect(chatgptReturnUrl('https://example.org/trek/')).toBe(
'https://example.org/trek/settings?tab=integrations&ai=chatgpt',
);
expect(chatgptReturnUrl('http://localhost:3000')).toBe('http://localhost:3000/settings?tab=integrations&ai=chatgpt');
expect(chatgptReturnUrl('http://192.168.1.4:3000')).toBeNull();
expect(chatgptReturnUrl('not a url')).toBeNull();
});
it('CGRET-002: is used only once the project has its client id', () => {
const relay = chatgptRelay('https://trip.example.org');
if (PROJECT_CHATGPT_CLIENT_ID) {
expect(relay).toMatchObject({ clientId: PROJECT_CHATGPT_CLIENT_ID, redirectUri: expect.stringMatching(/^https:/) });
} else {
expect(relay).toBeNull();
}
expect(chatgptRelay('http://192.168.1.4:3000')).toBeNull();
});
it('CGRET-003: the state carries the return address after its check value, readable by the relay', () => {
const state = relayState('check', 'https://trip.example.org/settings?tab=integrations&ai=chatgpt');
const [check, back] = state.split('.');
expect(check).toBe('check');
expect(Buffer.from(back ?? '', 'base64url').toString('utf8')).toBe(
'https://trip.example.org/settings?tab=integrations&ai=chatgpt',
);
});
it('CGRET-004: a sign-in ends on the loopback page or on these settings, and nowhere else', () => {
const app = 'https://trip.example.org';
expect(isChatgptCallback(new URL(`${LOOPBACK}?code=c`), LOOPBACK, app)).toBe(true);
expect(isChatgptCallback(new URL(`${app}/settings?tab=integrations&ai=chatgpt&code=c`), LOOPBACK, app)).toBe(true);
expect(isChatgptCallback(new URL(`${app}/settings?code=c`), LOOPBACK, app)).toBe(false);
expect(isChatgptCallback(new URL('https://evil.example/settings?ai=chatgpt'), LOOPBACK, app)).toBe(false);
expect(isChatgptCallback(new URL(`${app}/settings?ai=chatgpt`), LOOPBACK, 'http://192.168.1.4')).toBe(false);
});
});
-2
View File
@@ -135,8 +135,6 @@ export const aiAuthStartResponseSchema = z.object({
flowId: z.uuid(),
authorizeUrl: z.url(),
expiresAt: z.string(),
// The sign-in comes back to TREK by itself: open it in this tab, nothing to paste.
returns: z.boolean().optional(),
});
export type AiAuthStartResponse = z.infer<typeof aiAuthStartResponseSchema>;