| 1 | // @effect-diagnostics nodeBuiltinImport:off - OAuth loopback listener and PKCE use Node APIs. |
| 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | import * as NodeHttp from "node:http"; |
| 4 | import { createRemoteJWKSet, jwtVerify } from "jose"; |
| 5 | import { |
| 6 | ProviderSetupError, |
| 7 | type ChatGptReconnectProfile, |
| 8 | type ChatGptTransferredProfile, |
| 9 | type ProviderInstanceId, |
| 10 | } from "@t3tools/contracts"; |
| 11 | import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff"; |
| 12 | import * as Clock from "effect/Clock"; |
| 13 | import * as Exit from "effect/Exit"; |
| 14 | import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts"; |
| 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as Option from "effect/Option"; |
| 17 | import * as Schema from "effect/Schema"; |
| 18 | import * as Semaphore from "effect/Semaphore"; |
| 19 | import * as ProviderAuthFlow from "@t3tools/provider-core/server/providerAuthFlow"; |
| 20 | import type { ProviderAuthFlowContext } from "@t3tools/provider-core/server/providerAuthFlow"; |
| 21 | import { HttpClient, HttpClientRequest } from "effect/http"; |
| 22 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; |
| 23 | import { withChatGptSessionLock } from "./CodexChatGptSessionLock.ts"; |
| 24 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 25 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 26 | |
| 27 | const RESOURCE = "https://api.openai.com/v1"; |
| 28 | const REQUIRED_SCOPE = "chatgpt.tokens.use.direct"; |
| 29 | const DISCOVERY = "https://auth.openai.com/.well-known/openid-configuration"; |
| 30 | const 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 | }); |
| 39 | const 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 | }); |
| 51 | export type CodexChatGptCredentials = typeof Record.Type; |
| 52 | const Sessions = Schema.Struct({ |
| 53 | activeClientId: Schema.NullOr(Schema.String), |
| 54 | sessions: Schema.Array(Record), |
| 55 | }); |
| 56 | const sessionLocks = new WeakMap< |
| 57 | typeof ServerSecretStore.ServerSecretStore.Service, |
| 58 | Map<string, Semaphore.Semaphore> |
| 59 | >(); |
| 60 | const 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 | }); |
| 72 | const RegistrationProfiles = Schema.Struct({ |
| 73 | profiles: Schema.Array(Registration), |
| 74 | lastClientId: Schema.NullOr(Schema.String), |
| 75 | }); |
| 76 | const decodeRegistration = Schema.decodeUnknownEffect( |
| 77 | Schema.fromJsonString(Schema.Union([RegistrationProfiles, Registration])), |
| 78 | ); |
| 79 | const encodeRegistration = Schema.encodeEffect(Schema.fromJsonString(RegistrationProfiles)); |
| 80 | const profileMethodId = (clientId: string) => `chatgpt-profile:${clientId}`; |
| 81 | const 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 | }); |
| 88 | const OAuthError = Schema.Struct({ error: Schema.String }); |
| 89 | const decodeSessions = Schema.decodeUnknownEffect( |
| 90 | Schema.fromJsonString(Schema.Union([Sessions, Record])), |
| 91 | ); |
| 92 | const encodeSessions = Schema.encodeEffect(Schema.fromJsonString(Sessions)); |
| 93 | const decodeTokens = Schema.decodeUnknownSync(TokenResponse); |
| 94 | const decodeDiscoveryEffect = Schema.decodeUnknownEffect(Discovery); |
| 95 | const decodeOAuthError = Schema.decodeUnknownSync(OAuthError); |
| 96 | |
| 97 | export 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 | }); |