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
@@ -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'
);
}