mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user