diff --git a/client/src/components/Settings/ai/ChatGptPasteStep.tsx b/client/src/components/Settings/ai/ChatGptPasteStep.tsx index 82b45bc9e..daef222c4 100644 --- a/client/src/components/Settings/ai/ChatGptPasteStep.tsx +++ b/client/src/components/Settings/ai/ChatGptPasteStep.tsx @@ -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; @@ -98,12 +97,9 @@ export default function ChatGptPasteStep({
{ - 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({
- {!flow?.returns && ( - -
- 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() ? ( - - ) : ( - - )} -
-
- )} + +
+ 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() ? ( + + ) : ( + + )} +
+
{error && ( diff --git a/client/src/components/Settings/ai/aiConnectModel.ts b/client/src/components/Settings/ai/aiConnectModel.ts index 336daa864..f5382383c 100644 --- a/client/src/components/Settings/ai/aiConnectModel.ts +++ b/client/src/components/Settings/ai/aiConnectModel.ts @@ -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 { diff --git a/client/src/components/Settings/ai/useAiConnect.test.ts b/client/src/components/Settings/ai/useAiConnect.test.ts index e33143476..6b3e24542 100644 --- a/client/src/components/Settings/ai/useAiConnect.test.ts +++ b/client/src/components/Settings/ai/useAiConnect.test.ts @@ -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(); - }); -}); diff --git a/client/src/components/Settings/ai/useAiConnect.ts b/client/src/components/Settings/ai/useAiConnect.ts index 96fb22942..66ab79439 100644 --- a/client/src/components/Settings/ai/useAiConnect.ts +++ b/client/src/components/Settings/ai/useAiConnect.ts @@ -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. diff --git a/client/src/components/Settings/ai/useAiConnections.ts b/client/src/components/Settings/ai/useAiConnections.ts index 8495aae81..d4ce65dcb 100644 --- a/client/src/components/Settings/ai/useAiConnections.ts +++ b/client/src/components/Settings/ai/useAiConnections.ts @@ -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); diff --git a/relay/chatgpt-callback/index.html b/relay/chatgpt-callback/index.html deleted file mode 100644 index d048e91f5..000000000 --- a/relay/chatgpt-callback/index.html +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - TREK: back to your TREK - - - -
-

Taking you back to your TREK…

-
- - - diff --git a/server/src/nest/ai-connections/ai-auth-flow.store.ts b/server/src/nest/ai-connections/ai-auth-flow.store.ts index ae0851b36..7bc9d9791 100644 --- a/server/src/nest/ai-connections/ai-auth-flow.store.ts +++ b/server/src/nest/ai-connections/ai-auth-flow.store.ts @@ -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; } diff --git a/server/src/nest/ai-connections/chatgpt-auth.service.ts b/server/src/nest/ai-connections/chatgpt-auth.service.ts index 391574167..090d7da12 100644 --- a/server/src/nest/ai-connections/chatgpt-auth.service.ts +++ b/server/src/nest/ai-connections/chatgpt-auth.service.ts @@ -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 { 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 { + private async exchangeCode(clientId: string, code: string, verifier: string): Promise { 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) { diff --git a/server/src/nest/ai-connections/chatgpt-return.ts b/server/src/nest/ai-connections/chatgpt-return.ts deleted file mode 100644 index 07b8fb8a9..000000000 --- a/server/src/nest/ai-connections/chatgpt-return.ts +++ /dev/null @@ -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' - ); -} diff --git a/server/tests/unit/nest/ai-connections/ai-connections.helpers.test.ts b/server/tests/unit/nest/ai-connections/ai-connections.helpers.test.ts index a110e5550..e2bcd5645 100644 --- a/server/tests/unit/nest/ai-connections/ai-connections.helpers.test.ts +++ b/server/tests/unit/nest/ai-connections/ai-connections.helpers.test.ts @@ -54,7 +54,6 @@ describe('InMemoryAiAuthFlowStore', () => { state: 's', nonce: 'n', savedClientId: null, - redirectUri: 'http://127.0.0.1:1455/auth/callback', expiresAt, }); diff --git a/server/tests/unit/nest/ai-connections/chatgpt-return.test.ts b/server/tests/unit/nest/ai-connections/chatgpt-return.test.ts deleted file mode 100644 index 81989957f..000000000 --- a/server/tests/unit/nest/ai-connections/chatgpt-return.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/shared/src/ai/ai-connections.schema.ts b/shared/src/ai/ai-connections.schema.ts index 7e6b5e367..15923ae6d 100644 --- a/shared/src/ai/ai-connections.schema.ts +++ b/shared/src/ai/ai-connections.schema.ts @@ -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;