1// @effect-diagnostics nodeBuiltinImport:off - OAuth loopback listener and PKCE use Node APIs.
2import * as NodeCrypto from "node:crypto";
3import * as NodeHttp from "node:http";
4import { createRemoteJWKSet, jwtVerify } from "jose";
5import {
6 ProviderSetupError,
7 type ChatGptReconnectProfile,
8 type ChatGptTransferredProfile,
9 type ProviderInstanceId,
10} from "@t3tools/contracts";
11import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff";
12import * as Clock from "effect/Clock";
13import * as Exit from "effect/Exit";
14import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts";
15import * as Effect from "effect/Effect";
16import * as Option from "effect/Option";
17import * as Schema from "effect/Schema";
18import * as Semaphore from "effect/Semaphore";
19import * as ProviderAuthFlow from "@t3tools/provider-core/server/providerAuthFlow";
20import type { ProviderAuthFlowContext } from "@t3tools/provider-core/server/providerAuthFlow";
21import { HttpClient, HttpClientRequest } from "effect/http";
22import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
23import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts";
24import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
25import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
26
27const RESOURCE = "https://api.openai.com/v1";
28const REQUIRED_SCOPE = "chatgpt.tokens.use.direct";
29const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration";
30const TokenResponse = Schema.Struct({
31 access_token: Schema.NonEmptyString,
32 refresh_token: Schema.optionalKey(Schema.NonEmptyString),
33 id_token: Schema.optionalKey(Schema.String),
34 token_type: Schema.String,
35 expires_in: Schema.Int.check(Schema.isGreaterThan(0)),
36 scope: Schema.String,
37 earliest_refresh_at: Schema.optionalKey(Schema.Union([Schema.Finite, Schema.String])),
38});
39const Record = Schema.Struct({
40 clientId: Schema.String,
41 accessToken: Schema.String,
42 refreshToken: Schema.NullOr(Schema.String),
43 expiresAt: Schema.Finite,
44 earliestRefreshAt: Schema.NullOr(Schema.Finite),
45 scopes: Schema.Array(Schema.String),
46 subject: Schema.String,
47 email: Schema.NullOr(Schema.String),
48 idToken: Schema.optionalKey(Schema.String),
49 issuer: Schema.optionalKey(Schema.String),
50});
51export type CodexChatGptCredentials = typeof Record.Type;
52const Sessions = Schema.Struct({
53 activeClientId: Schema.NullOr(Schema.String),
54 sessions: Schema.Array(Record),
55});
56const sessionLocks = new WeakMap<
57 typeof ServerSecretStore.ServerSecretStore.Service,
58 Map<string, Semaphore.Semaphore>
59>();
60const Registration = Schema.Struct({
61 clientId: Schema.String.check(Schema.isPattern(/^oaiapp_/u)),
62 subject: Schema.optionalKey(Schema.String),
63 connectionLabel: Schema.optionalKey(Schema.String),
64 sharingEnabled: Schema.optionalKey(Schema.Boolean),
65 email: Schema.optionalKey(Schema.NullOr(Schema.String)),
66 redirectUri: Schema.optionalKey(
67 Schema.String.check(
68 Schema.isPattern(/^http:\/\/(?:127\.0\.0\.1|localhost):[1-9]\d{0,4}\/auth\/callback$/u),
69 ),
70 ),
71});
72const RegistrationProfiles = Schema.Struct({
73 profiles: Schema.Array(Registration),
74 lastClientId: Schema.NullOr(Schema.String),
75});
76const decodeRegistration = Schema.decodeUnknownEffect(
77 Schema.fromJsonString(Schema.Union([RegistrationProfiles, Registration])),
78);
79const encodeRegistration = Schema.encodeEffect(Schema.fromJsonString(RegistrationProfiles));
80const profileMethodId = (clientId: string) => `chatgpt-profile:${clientId}`;
81const Discovery = Schema.Struct({
82 issuer: Schema.String,
83 authorization_endpoint: Schema.String,
84 token_endpoint: Schema.String,
85 jwks_uri: Schema.String,
86 revocation_endpoint: Schema.optionalKey(Schema.String),
87});
88const OAuthError = Schema.Struct({ error: Schema.String });
89const decodeSessions = Schema.decodeUnknownEffect(
90 Schema.fromJsonString(Schema.Union([Sessions, Record])),
91);
92const encodeSessions = Schema.encodeEffect(Schema.fromJsonString(Sessions));
93const decodeTokens = Schema.decodeUnknownSync(TokenResponse);
94const decodeDiscoveryEffect = Schema.decodeUnknownEffect(Discovery);
95const decodeOAuthError = Schema.decodeUnknownSync(OAuthError);
96
97export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* (options: {
98 readonly instanceId: ProviderInstanceId;
99 readonly discoveryUrl?: string;
100 readonly resource?: string;
101 readonly defaultReturnUrl?: string;
102 readonly reconnectProfile?: ChatGptReconnectProfile | null;
103}) {
104 const http = yield* HttpClient.HttpClient;
105 const store = yield* ProviderCredentialStore.make("codex-chatgpt", options.instanceId);
106 const registrationStore = yield* ProviderCredentialStore.make(
107 "codex-chatgpt-registration",
108 options.instanceId,
109 );
110 const secrets = yield* ServerSecretStore.ServerSecretStore;
111 const environmentLocks = sessionLocks.get(secrets) ?? new Map<string, Semaphore.Semaphore>();
112 sessionLocks.set(secrets, environmentLocks);
113 const lock = environmentLocks.get(store.binding.key) ?? (yield* Semaphore.make(1));
114 environmentLocks.set(store.binding.key, lock);
115 const withSessionLock = <A, E, R>(task: Effect.Effect<A, E, R>) =>
116 withChatGptSessionLock(secrets.directory, store.binding.key, options.instanceId, task);
117 const environment = yield* ServerEnvironment.ServerEnvironmentIdentity;
118 const hostId = `urn:uuid:${yield* environment.getEnvironmentId}`;
119 const resource = options.resource ?? RESOURCE;
120 const failure = (operation: string, detail: string) =>
121 new ProviderSetupError({ instanceId: options.instanceId, operation, detail });
122 let metadata: typeof Discovery.Type | undefined;
123 let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
124 const discover = Effect.gen(function* () {
125 if (metadata) return metadata;
126 const result = yield* http.get(options.discoveryUrl ?? DISCOVERY).pipe(
127 Effect.flatMap((response) =>
128 Effect.gen(function* () {
129 if (response.status < 200 || response.status >= 300)
130 return yield* failure("discover", "Could not reach ChatGPT sign-in. Try again.");
131 return yield* response.json;
132 }),
133 ),
134 Effect.flatMap(decodeDiscoveryEffect),
135 Effect.mapError(() => failure("discover", "Could not reach ChatGPT sign-in. Try again.")),
136 );
137 if (
138 !options.discoveryUrl &&
139 (result.issuer !== "https://auth.openai.com" ||
140 [
141 result.authorization_endpoint,
142 result.token_endpoint,
143 result.jwks_uri,
144 ...(result.revocation_endpoint ? [result.revocation_endpoint] : []),
145 ].some((url) => new URL(url).origin !== result.issuer))
146 )
147 return yield* failure("discover", "ChatGPT sign-in configuration could not be verified.");
148 metadata = result;
149 jwks = createRemoteJWKSet(new URL(result.jwks_uri));
150 return result;
151 });
152 const readSessions = store.get.pipe(
153 Effect.mapError(() => failure("read", "Could not read the saved ChatGPT connection.")),
154 Effect.flatMap((bytes) =>
155 Option.isNone(bytes)
156 ? Effect.succeed<typeof Sessions.Type>({ activeClientId: null, sessions: [] })
157 : decodeSessions(new TextDecoder().decode(bytes.value)).pipe(
158 Effect.map((saved) =>
159 "sessions" in saved ? saved : { activeClientId: saved.clientId, sessions: [saved] },
160 ),
161 Effect.mapError(() =>
162 failure("read", "The saved ChatGPT connection is invalid. Sign in again."),
163 ),
164 ),
165 ),
166 );
167 const read = readSessions.pipe(
168 Effect.map((saved) =>
169 Option.fromUndefinedOr(
170 saved.sessions.find((session) => session.clientId === saved.activeClientId),
171 ),
172 ),
173 );
174 const writeSessions = (saved: typeof Sessions.Type) =>
175 encodeSessions(saved).pipe(
176 Effect.flatMap((json) => store.set(new TextEncoder().encode(json))),
177 Effect.mapError(() => failure("save", "Could not save the ChatGPT connection.")),
178 );
179 // Registration profiles outlive tokens, but belong only to this environment/instance.
180 // Accept the original single-registration record until it is next saved.
181 const readRegistrations = registrationStore.get.pipe(
182 Effect.mapError(() =>
183 failure("registration", "Could not read the ChatGPT sign-in registration. Try again."),
184 ),
185 Effect.flatMap((bytes) =>
186 Option.isNone(bytes)
187 ? Effect.succeed<typeof RegistrationProfiles.Type>({ profiles: [], lastClientId: null })
188 : decodeRegistration(new TextDecoder().decode(bytes.value)).pipe(
189 Effect.map((record) => {
190 const saved =
191 "profiles" in record
192 ? record
193 : { profiles: [record], lastClientId: record.clientId };
194 const last = saved.profiles.find(
195 (profile) => profile.clientId === saved.lastClientId,
196 );
197 const ordered = last
198 ? [last, ...saved.profiles.filter((profile) => profile.clientId !== last.clientId)]
199 : saved.profiles;
200 return {
201 ...saved,
202 profiles: ordered.map((profile) => ({
203 ...profile,
204 connectionLabel:
205 profile.connectionLabel ?? `Connection ${saved.profiles.indexOf(profile) + 1}`,
206 })),
207 };
208 }),
209 Effect.mapError(() =>
210 failure("registration", "The saved ChatGPT sign-in registration is invalid."),
211 ),
212 ),
213 ),
214 );
215 const saveRegistration = Effect.fnUntraced(function* (profile: typeof Registration.Type) {
216 const saved = yield* readRegistrations;
217 profile = {
218 ...profile,
219 connectionLabel:
220 saved.profiles.find((entry) => entry.clientId === profile.clientId)?.connectionLabel ??
221 profile.connectionLabel ??
222 `Connection ${saved.profiles.length + 1}`,
223 };
224 yield* encodeRegistration({
225 profiles: [profile, ...saved.profiles.filter((entry) => entry.clientId !== profile.clientId)],
226 lastClientId: profile.clientId,
227 }).pipe(
228 Effect.flatMap((json) => registrationStore.set(new TextEncoder().encode(json))),
229 Effect.mapError(() =>
230 failure("registration", "Could not save the ChatGPT sign-in registration. Try again."),
231 ),
232 );
233 });
234 // The active pointer and all profile token sets change in one protected atomic write.
235 const save = Effect.fnUntraced(function* (record: CodexChatGptCredentials, activate = true) {
236 const saved = yield* readSessions;
237 yield* writeSessions({
238 activeClientId: activate ? record.clientId : saved.activeClientId,
239 sessions: [
240 ...saved.sessions.filter((session) => session.clientId !== record.clientId),
241 record,
242 ],
243 });
244 });
245 const clearTokens = Effect.gen(function* () {
246 const saved = yield* readSessions;
247 const sessions = saved.sessions.filter((session) => session.clientId !== saved.activeClientId);
248 if (sessions.length) yield* writeSessions({ activeClientId: null, sessions });
249 else
250 yield* store.remove.pipe(
251 Effect.mapError(() =>
252 failure("disconnect", "Could not clear the ChatGPT connection. Try again."),
253 ),
254 );
255 });
256 const remove = Effect.gen(function* () {
257 // Promote legacy identity into the retained profile before deleting credentials.
258 const credentials = yield* read;
259 if (Option.isSome(credentials)) {
260 const { clientId, subject, email } = credentials.value;
261 const saved = yield* readRegistrations;
262 const profile = saved.profiles.find((entry) => entry.clientId === clientId);
263 if (profile && (profile.subject === undefined || profile.email === undefined))
264 yield* saveRegistration({ ...profile, subject, email });
265 }
266 yield* clearTokens;
267 });
268 const exchange = Effect.fn("CodexChatGptAuth.exchange")(function* (body: URLSearchParams) {
269 const endpoints = yield* discover;
270 const response = yield* http
271 .execute(
272 HttpClientRequest.post(endpoints.token_endpoint).pipe(
273 HttpClientRequest.setHeader("accept", "application/json"),
274 HttpClientRequest.bodyText(body.toString(), "application/x-www-form-urlencoded"),
275 ),
276 )
277 .pipe(
278 Effect.flatMap((result) =>
279 result.json.pipe(
280 Effect.map((raw) => ({
281 ok: result.status >= 200 && result.status < 300,
282 status: result.status,
283 raw,
284 })),
285 ),
286 ),
287 Effect.mapError(() =>
288 failure("exchange", "ChatGPT sign-in is temporarily unavailable. Try again."),
289 ),
290 );
291 if (!response.ok) {
292 const error = yield* Effect.try({
293 try: () => decodeOAuthError(response.raw).error,
294 catch: () => failure("exchange", "ChatGPT did not accept this sign-in. Try again."),
295 });
296 if (
297 body.get("grant_type") === "refresh_token" &&
298 [
299 "invalid_grant",
300 "invalid_refresh_token",
301 "token_expired",
302 "refresh_token_expired",
303 "refresh_token_invalidated",
304 "refresh_token_reused",
305 ].includes(error)
306 ) {
307 yield* remove;
308 return yield* failure(
309 "refresh",
310 "Your ChatGPT connection expired or was disconnected. Sign in again.",
311 );
312 }
313 if (error === "invalid_client")
314 return yield* failure(
315 "client",
316 "OpenAI rejected this app's client registration. Check the ChatGPT connection configuration.",
317 );
318 if (body.get("grant_type") === "authorization_code" && error === "invalid_grant")
319 return yield* failure("code-expired", "This sign-in code expired. Start again.");
320 return yield* failure(
321 "exchange",
322 error === "access_denied"
323 ? "ChatGPT sign-in was declined. Sign in again when you are ready."
324 : "ChatGPT could not complete sign-in. Try again.",
325 );
326 }
327 return yield* Effect.try({
328 try: () => decodeTokens(response.raw),
329 catch: () =>
330 failure("exchange", "ChatGPT returned an invalid token response. Sign in again."),
331 });
332 });
333 const earliest = (value: (typeof TokenResponse.Type)["earliest_refresh_at"]) => {
334 if (value === undefined) return null;
335 const time = typeof value === "number" ? value * 1000 : Date.parse(value);
336 return Number.isFinite(time) ? time : null;
337 };
338 const authenticate = Effect.fn("CodexChatGptAuth.authenticate")(function* (
339 method: string,
340 context: ProviderAuthFlowContext,
341 ) {
342 const endpoints = yield* discover;
343 const existing = yield* read;
344 const previous = Option.getOrUndefined(existing);
345 const registrations = yield* readRegistrations;
346 const changingAccount = method === "chatgpt-change-account";
347 const selectedClientId = method.startsWith("chatgpt-profile:")
348 ? method.slice("chatgpt-profile:".length)
349 : (previous?.clientId ?? registrations.lastClientId);
350 const savedRegistration = changingAccount
351 ? undefined
352 : registrations.profiles.find((profile) => profile.clientId === selectedClientId);
353 // Older development registrations used localhost, which cannot be changed on reauth.
354 // Register a 127.0.0.1 connection instead, preserving the verified account.
355 const legacyCallback = savedRegistration?.redirectUri?.includes("//localhost:") === true;
356 const registeredClientId = legacyCallback ? undefined : savedRegistration?.clientId;
357 const selectedTokens = (yield* readSessions).sessions.find(
358 (session) => session.clientId === selectedClientId,
359 );
360 const state = NodeCrypto.randomBytes(32).toString("base64url");
361 const nonce = NodeCrypto.randomBytes(32).toString("base64url");
362 const verifier = NodeCrypto.randomBytes(64).toString("base64url");
363 const returnUrl =
364 codexAuthReturnUrl(context.returnUrl) ?? codexAuthReturnUrl(options.defaultReturnUrl);
365 const pageNonce = NodeCrypto.randomBytes(24).toString("base64url");
366 const callback = Promise.withResolvers<{ url: URL; response?: NodeHttp.ServerResponse }>();
367 const clientCallback = context.callbackMode === "client";
368 let used = false;
369 const server = clientCallback
370 ? undefined
371 : yield* Effect.acquireRelease(
372 Effect.tryPromise({
373 try: () =>
374 new Promise<NodeHttp.Server>((resolve, reject) => {
375 const server = NodeHttp.createServer((request, response) => {
376 const url = new URL(request.url ?? "/", "http://127.0.0.1");
377 if (request.method !== "GET" || url.pathname !== "/auth/callback") {
378 response.writeHead(404).end();
379 return;
380 }
381 if (used) {
382 response.writeHead(410).end("Sign-in is no longer active.");
383 return;
384 }
385 used = true;
386 callback.resolve({ url, response });
387 });
388 server.once("error", reject);
389 server.listen(0, "127.0.0.1", () => resolve(server));
390 }),
391 catch: () =>
392 failure("callback", "Could not start the local sign-in callback. Try again."),
393 }),
394 (server) =>
395 Effect.promise(
396 () =>
397 new Promise<void>((resolve) => {
398 server.closeAllConnections();
399 server.close(() => resolve());
400 }),
401 ),
402 );
403 const address = server?.address();
404 if (!clientCallback && (!address || typeof address === "string"))
405 return yield* failure("callback", "Could not start the local sign-in callback.");
406 // Only the port may vary between attempts; token exchange uses this exact URI.
407 const port =
408 address && typeof address !== "string" ? address.port : NodeCrypto.randomInt(49_152, 65_536);
409 const redirectUri = `http://127.0.0.1:${port}/auth/callback`;
410 const idTokenHint = selectedTokens?.idToken ?? options.reconnectProfile?.idTokenHint;
411 const url = new URL(endpoints.authorization_endpoint);
412 url.search = new URLSearchParams({
413 client_id: registeredClientId ?? "dynamic_agent_client",
414 ...(registeredClientId
415 ? {
416 ...(savedRegistration?.email ? { login_hint: savedRegistration.email } : {}),
417 ...(idTokenHint ? { id_token_hint: idTokenHint } : {}),
418 ...(savedRegistration?.sharingEnabled === false ||
419 (selectedTokens && !selectedTokens.scopes.includes(REQUIRED_SCOPE))
420 ? { prompt: "consent" }
421 : {}),
422 }
423 : { agent_name_hint: "T3 Code" }),
424 ext_agent_host_id: hostId,
425 response_type: "code",
426 redirect_uri: redirectUri,
427 scope: "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct",
428 resource,
429 state,
430 nonce,
431 code_challenge_method: "S256",
432 code_challenge: NodeCrypto.createHash("sha256").update(verifier).digest("base64url"),
433 }).toString();
434 yield* context.setInteraction(
435 {
436 type: "browser",
437 id: context.flowId,
438 url: url.toString(),
439 requiresConsent: false,
440 acceptsCallback: true,
441 },
442 undefined,
443 (callbackUrl) =>
444 Effect.try({
445 try: () => {
446 const returned = codexCallbackUrl(callbackUrl, redirectUri, state);
447 if (used) throw new Error("used");
448 used = true;
449 callback.resolve({ url: returned });
450 },
451 catch: () =>
452 failure("complete", "This redirect URL does not belong to the active ChatGPT sign-in."),
453 }),
454 );
455 const received = yield* Effect.tryPromise({
456 try: () => callback.promise,
457 catch: () => failure("callback", "ChatGPT sign-in could not be completed."),
458 });
459 const returned = received.url;
460 yield* Effect.gen(function* () {
461 if (returned.searchParams.get("state") !== state)
462 return yield* failure("callback", "ChatGPT sign-in could not be verified. Start again.");
463 if (returned.searchParams.has("error"))
464 return yield* failure(
465 "callback",
466 returned.searchParams.get("error") === "access_denied"
467 ? "ChatGPT sign-in was declined. Sign in again when you are ready."
468 : "ChatGPT sign-in could not be completed. Start again.",
469 );
470 const code = returned.searchParams.get("code");
471 const clientId = registeredClientId ?? returned.searchParams.get("client_id");
472 if (
473 !code ||
474 !clientId ||
475 !clientId.startsWith("oaiapp_") ||
476 (registeredClientId &&
477 returned.searchParams.has("client_id") &&
478 returned.searchParams.get("client_id") !== registeredClientId)
479 )
480 return yield* failure(
481 "callback",
482 "ChatGPT registration is incomplete. Start sign-in again.",
483 );
484 yield* context.verifying;
485 const tokens = yield* exchange(
486 new URLSearchParams({
487 grant_type: "authorization_code",
488 client_id: clientId,
489 code,
490 code_verifier: verifier,
491 redirect_uri: redirectUri,
492 resource,
493 }),
494 ).pipe(
495 Effect.catch((error) =>
496 Effect.gen(function* () {
497 if (error.operation === "code-expired") {
498 if (!registeredClientId)
499 yield* withSessionLock(saveRegistration({ clientId, redirectUri }));
500 return yield* failure(
501 "exchange",
502 "This sign-in code expired. Reconnect the saved ChatGPT profile to start a fresh sign-in.",
503 );
504 }
505 return yield* error;
506 }),
507 ),
508 );
509 if (!tokens.id_token)
510 return yield* failure(
511 "verify",
512 "ChatGPT did not return a verified identity. Sign in again.",
513 );
514 const identity = yield* Effect.tryPromise({
515 try: async () => {
516 const { payload } = await jwtVerify(tokens.id_token!, jwks!, {
517 issuer: endpoints.issuer,
518 audience: clientId,
519 algorithms: ["RS256", "ES256"],
520 clockTolerance: 5,
521 });
522 if (payload.nonce !== nonce || !payload.sub || !payload.exp) throw new Error("identity");
523 return {
524 subject: payload.sub,
525 email: typeof payload.email === "string" ? payload.email : null,
526 };
527 },
528 catch: () => failure("verify", "ChatGPT sign-in could not be verified. Start again."),
529 });
530 const expectedSubject =
531 savedRegistration?.subject ??
532 (previous?.clientId === registeredClientId ? previous?.subject : undefined);
533 // Subjects are checked within the same registration. Legacy callback migration
534 // registers a new client, whose subject cannot be compared with the old client.
535 if (registeredClientId && expectedSubject && identity.subject !== expectedSubject)
536 return yield* failure(
537 "verify",
538 "This sign-in returned a different ChatGPT account. Use the different-account sign-in option instead. Your saved connection is unchanged.",
539 );
540 const knownProfile = registrations.profiles.find((profile) => profile.clientId === clientId);
541 if (
542 knownProfile &&
543 ((knownProfile.redirectUri &&
544 (() => {
545 const original = new URL(knownProfile.redirectUri);
546 const current = new URL(redirectUri);
547 return (
548 original.protocol !== current.protocol ||
549 original.hostname !== current.hostname ||
550 original.pathname !== current.pathname
551 );
552 })()) ||
553 (knownProfile.subject && knownProfile.subject !== identity.subject))
554 )
555 return yield* failure(
556 "verify",
557 "ChatGPT returned a conflicting account registration. Start again.",
558 );
559 if (tokens.token_type.toLowerCase() !== "bearer")
560 return yield* failure(
561 "verify",
562 "ChatGPT returned an unsupported connection. Sign in again.",
563 );
564 const scopes = tokens.scope.split(/\s+/).filter(Boolean);
565 yield* withSessionLock(
566 Effect.gen(function* () {
567 yield* saveRegistration({
568 clientId,
569 redirectUri,
570 sharingEnabled: scopes.includes(REQUIRED_SCOPE),
571 ...identity,
572 });
573 yield* save(
574 {
575 clientId,
576 accessToken: tokens.access_token,
577 idToken: tokens.id_token!,
578 issuer: endpoints.issuer,
579 refreshToken: tokens.refresh_token ?? null,
580 expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000,
581 earliestRefreshAt: earliest(tokens.earliest_refresh_at),
582 scopes,
583 ...identity,
584 },
585 scopes.includes(REQUIRED_SCOPE) || Option.isNone(yield* read),
586 );
587 }),
588 );
589 if (!scopes.includes(REQUIRED_SCOPE))
590 return yield* failure(
591 "sharing",
592 previous && previous.clientId !== clientId
593 ? "Token sharing was not enabled for the new account. Your existing ChatGPT connection is unchanged."
594 : "Signed in with ChatGPT, but token sharing is disabled. Sign in again and enable token sharing, or use another provider.",
595 );
596 }).pipe(
597 Effect.onExit((result) =>
598 Effect.promise(
599 () =>
600 new Promise<void>((resolve) => {
601 const response = received.response;
602 if (!response || response.destroyed) {
603 resolve();
604 return;
605 }
606 response.once("close", resolve);
607 response
608 .writeHead(200, {
609 "content-type": "text/html; charset=utf-8",
610 "cache-control": "no-store",
611 "referrer-policy": "no-referrer",
612 "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${pageNonce}'; base-uri 'none'; frame-ancestors 'none'`,
613 "x-content-type-options": "nosniff",
614 })
615 .end(codexAuthCallbackPage(Exit.isSuccess(result), returnUrl, pageNonce), resolve);
616 }),
617 ),
618 ),
619 );
620 });
621 const access = lock.withPermit(
622 withSessionLock(
623 Effect.uninterruptible(
624 Effect.gen(function* () {
625 const saved = yield* read;
626 if (Option.isNone(saved))
627 return yield* failure("access", "Sign in with ChatGPT to use managed Codex.");
628 let record = saved.value;
629 if (!record.scopes.includes(REQUIRED_SCOPE))
630 return yield* failure(
631 "sharing",
632 "Token sharing is disabled. Sign in with ChatGPT and enable token sharing.",
633 );
634 const now = yield* Clock.currentTimeMillis;
635 if (record.expiresAt - now > 60_000) return record;
636 if (record.earliestRefreshAt !== null && record.earliestRefreshAt > now) {
637 if (record.expiresAt > now) return record;
638 return yield* failure(
639 "refresh",
640 "ChatGPT cannot renew this connection yet. Try again shortly.",
641 );
642 }
643 if (!record.refreshToken) {
644 yield* remove;
645 return yield* failure("refresh", "Your ChatGPT connection expired. Sign in again.");
646 }
647 const tokens = yield* exchange(
648 new URLSearchParams({
649 grant_type: "refresh_token",
650 client_id: record.clientId,
651 refresh_token: record.refreshToken,
652 resource,
653 }),
654 ).pipe(
655 Effect.interruptible,
656 Effect.timeout("20 seconds"),
657 Effect.mapError((error) =>
658 error._tag === "ProviderSetupError" && error.operation === "client"
659 ? error
660 : failure(
661 "refresh",
662 "Could not renew the ChatGPT connection. Retry, or sign in again.",
663 ),
664 ),
665 );
666 if (!tokens.refresh_token || tokens.token_type.toLowerCase() !== "bearer") {
667 return yield* failure("refresh", "ChatGPT returned an invalid renewal. Sign in again.");
668 }
669 record = {
670 ...record,
671 accessToken: tokens.access_token,
672 refreshToken: tokens.refresh_token,
673 expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000,
674 earliestRefreshAt: earliest(tokens.earliest_refresh_at),
675 scopes: tokens.scope.split(/\s+/).filter(Boolean),
676 };
677 yield* save(record);
678 if (!record.scopes.includes(REQUIRED_SCOPE))
679 return yield* failure(
680 "sharing",
681 "ChatGPT token sharing is no longer enabled. Sign in again.",
682 );
683 return record;
684 }),
685 ),
686 ),
687 );
688 const logout = Effect.gen(function* () {
689 const credentials = Option.getOrUndefined(yield* read);
690 let confirmed = !credentials?.refreshToken;
691 if (credentials?.refreshToken) {
692 for (let attempt = 0; attempt < 3; attempt++) {
693 const result = yield* Effect.gen(function* () {
694 const endpoints = yield* discover;
695 if (!endpoints.revocation_endpoint) return { confirmed: false, retry: false };
696 const response = yield* http.execute(
697 HttpClientRequest.post(endpoints.revocation_endpoint).pipe(
698 HttpClientRequest.bodyText(
699 new URLSearchParams({
700 token: credentials.refreshToken!,
701 token_type_hint: "refresh_token",
702 client_id: credentials.clientId,
703 }).toString(),
704 "application/x-www-form-urlencoded",
705 ),
706 ),
707 );
708 return { confirmed: response.status === 200, retry: response.status >= 500 };
709 }).pipe(
710 Effect.timeout("10 seconds"),
711 Effect.orElseSucceed(() => ({ confirmed: false, retry: true })),
712 );
713 confirmed = result.confirmed;
714 if (confirmed || !result.retry || attempt === 2) break;
715 yield* Effect.sleep(attempt === 0 ? "250 millis" : "1 second");
716 }
717 }
718 yield* remove;
719 return confirmed
720 ? undefined
721 : "Signed out locally. Remote revocation could not be confirmed. Disconnect the app in ChatGPT Settings.";
722 });
723 if (options.reconnectProfile) {
724 const { idTokenHint: _hint, ...registration } = options.reconnectProfile;
725 yield* saveRegistration(registration).pipe(Effect.orDie);
726 }
727 const controller = yield* ProviderAuthFlow.make({
728 instanceId: options.instanceId,
729 credentialBinding: store.binding,
730 refreshMethodsAfterAuth: true,
731 methods: Effect.gen(function* () {
732 const saved = yield* readRegistrations;
733 const active = Option.getOrUndefined(yield* read);
734 const current = saved.profiles.find(
735 (profile) => profile.clientId === (active?.clientId ?? saved.lastClientId),
736 );
737 const profiles = current
738 ? [current, ...saved.profiles.filter((profile) => profile.clientId !== current.clientId)]
739 : saved.profiles;
740 return [
741 {
742 id: "chatgpt",
743 name: "Sign in with ChatGPT",
744 description: current?.email ? `Reconnect ${current.email}.` : null,
745 ...(current?.email ? { accountEmail: current.email } : {}),
746 type: "agent" as const,
747 },
748 {
749 id: "chatgpt-change-account",
750 name: "Use a different ChatGPT account",
751 description: "Register a connection for another ChatGPT account.",
752 type: "agent" as const,
753 },
754 // The wire contract allows 32 methods; advertise the 30 most recent profiles.
755 ...profiles.slice(0, 30).map((profile) => ({
756 id: profileMethodId(profile.clientId),
757 name: `${profile.email ?? "ChatGPT account"} · ${profile.connectionLabel}`,
758 ...(profile.email ? { accountEmail: profile.email } : {}),
759 description: "Reuse this account's original sign-in registration.",
760 type: "agent" as const,
761 })),
762 ];
763 }),
764 authenticate: (method, context) => lock.withPermit(authenticate(method, context)),
765 logout: lock.withPermit(withSessionLock(logout)),
766 });
767 const reconnectProfile = Effect.fnUntraced(function* (methodId: string) {
768 if (methodId === "chatgpt-change-account") return null;
769 const registrations = yield* readRegistrations;
770 const active = Option.getOrUndefined(yield* read);
771 const clientId = methodId.startsWith("chatgpt-profile:")
772 ? methodId.slice("chatgpt-profile:".length)
773 : (active?.clientId ?? registrations.lastClientId);
774 const registration = registrations.profiles.find((profile) => profile.clientId === clientId);
775 if (!registration) {
776 if (methodId.startsWith("chatgpt-profile:"))
777 return yield* failure("export", "This saved connection is no longer available.");
778 return null;
779 }
780 const session = (yield* readSessions).sessions.find((session) => session.clientId === clientId);
781 return { ...registration, ...(session?.idToken ? { idTokenHint: session.idToken } : {}) };
782 });
783 const exportProfile = Effect.gen(function* () {
784 const credentials = Option.getOrUndefined(yield* read);
785 const registration =
786 credentials &&
787 (yield* readRegistrations).profiles.find(
788 (profile) => profile.clientId === credentials.clientId,
789 );
790 if (!credentials?.idToken || !credentials.issuer || !registration)
791 return yield* failure(
792 "export",
793 "Complete ChatGPT sign-in before transferring this connection.",
794 );
795 return {
796 registration,
797 credentials: { ...credentials, idToken: credentials.idToken, issuer: credentials.issuer },
798 } satisfies ChatGptTransferredProfile;
799 });
800 const importProfile = (
801 profile: ChatGptTransferredProfile,
802 stopSessions: Effect.Effect<void, ProviderSetupError>,
803 ) => {
804 const validate = Effect.gen(function* () {
805 const endpoints = yield* discover;
806 const credentials = profile.credentials;
807 if (
808 credentials.clientId !== profile.registration.clientId ||
809 credentials.issuer !== endpoints.issuer ||
810 !credentials.scopes.includes(REQUIRED_SCOPE) ||
811 credentials.expiresAt <= (yield* Clock.currentTimeMillis)
812 )
813 return yield* failure(
814 "import",
815 "The transferred ChatGPT connection is invalid or expired.",
816 );
817 const identity = yield* Effect.tryPromise({
818 try: () =>
819 jwtVerify(credentials.idToken, jwks!, {
820 issuer: endpoints.issuer,
821 audience: credentials.clientId,
822 algorithms: ["RS256", "ES256"],
823 clockTolerance: 5,
824 }),
825 catch: () => failure("import", "The transferred ChatGPT identity could not be verified."),
826 });
827 if (
828 identity.payload.sub !== credentials.subject ||
829 profile.registration.subject !== credentials.subject ||
830 (identity.payload.email ?? null) !== credentials.email ||
831 (profile.registration.email !== undefined &&
832 profile.registration.email !== credentials.email)
833 )
834 return yield* failure(
835 "import",
836 "The transferred ChatGPT identity does not match its profile.",
837 );
838 });
839 const saveImported = Effect.gen(function* () {
840 const { idTokenHint: _hint, ...registration } = profile.registration;
841 const existing = (yield* readRegistrations).profiles.find(
842 (saved) => saved.clientId === registration.clientId,
843 );
844 if (existing?.subject && existing.subject !== profile.credentials.subject)
845 return yield* failure(
846 "import",
847 "The transferred identity conflicts with the saved ChatGPT connection.",
848 );
849 yield* saveRegistration(registration);
850 yield* save(profile.credentials, true);
851 });
852 return lock.withPermit(
853 validate.pipe(
854 Effect.andThen(controller.adoptCredentials!(withSessionLock(saveImported), stopSessions)),
855 ),
856 );
857 };
858 return {
859 controller: { ...controller, reconnectProfile, importProfile },
860 read,
861 access,
862 exportProfile,
863 revoke: lock.withPermit(withSessionLock(remove)),
864 };
865});