| 1 | // @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Local mock OAuth server validates the browser callback boundary. |
| 2 | import * as NodeHttp from "node:http"; |
| 3 | import * as NodeCrypto from "node:crypto"; |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 5 | import { assert, it } from "@effect/vitest"; |
| 6 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; |
| 10 | import * as Stream from "effect/Stream"; |
| 11 | import * as Fiber from "effect/Fiber"; |
| 12 | import * as Deferred from "effect/Deferred"; |
| 13 | import * as TestClock from "effect/testing/TestClock"; |
| 14 | import { subscribeChatGptHandoff } from "./CodexChatGptHandoff.ts"; |
| 15 | import { FetchHttpClient } from "effect/http"; |
| 16 | import { exportJWK, generateKeyPair, SignJWT } from "jose"; |
| 17 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 18 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 19 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; |
| 20 | import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; |
| 21 | |
| 22 | const assertSameCallback = (actual: string | null, expected: string | null) => { |
| 23 | const left = new URL(actual!); |
| 24 | const right = new URL(expected!); |
| 25 | assert.strictEqual(left.protocol, right.protocol); |
| 26 | assert.strictEqual(left.hostname, right.hostname); |
| 27 | assert.strictEqual(left.pathname, right.pathname); |
| 28 | }; |
| 29 | const environmentIds = new WeakMap<Map<string, Uint8Array>, EnvironmentId>(); |
| 30 | const instanceId = ProviderInstanceId.make("managed-codex-test"); |
| 31 | const makeHarnessFor = Effect.fnUntraced(function* ( |
| 32 | instanceId: ProviderInstanceId, |
| 33 | bytes: Map<string, Uint8Array> = new Map(), |
| 34 | ) { |
| 35 | const environmentId = environmentIds.get(bytes) ?? EnvironmentId.make(NodeCrypto.randomUUID()); |
| 36 | environmentIds.set(bytes, environmentId); |
| 37 | const environment = ServerEnvironment.ServerEnvironmentIdentity.of({ |
| 38 | getEnvironmentId: Effect.succeed(environmentId), |
| 39 | }); |
| 40 | const keys = yield* Effect.promise(() => generateKeyPair("RS256")); |
| 41 | const jwk = yield* Effect.promise(() => exportJWK(keys.publicKey)); |
| 42 | const untrustedKeys = yield* Effect.promise(() => generateKeyPair("RS256")); |
| 43 | const secrets = ServerSecretStore.ServerSecretStore.of({ |
| 44 | get: (name) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(name))), |
| 45 | set: (name, value) => |
| 46 | Effect.sync(() => { |
| 47 | bytes.set(name, value); |
| 48 | }), |
| 49 | remove: (name) => |
| 50 | Effect.sync(() => { |
| 51 | bytes.delete(name); |
| 52 | }), |
| 53 | create: () => Effect.die("unused"), |
| 54 | getOrCreateRandom: () => Effect.die("Host identity must come from the environment."), |
| 55 | }); |
| 56 | let authorize: URL | undefined; |
| 57 | let refreshes = 0; |
| 58 | let revoked = false; |
| 59 | let refreshError: string | undefined; |
| 60 | let revocationStatus = 200; |
| 61 | let codeError: string | undefined; |
| 62 | const revocations: URLSearchParams[] = []; |
| 63 | let transient = false; |
| 64 | let invalidNonce = false; |
| 65 | let identityFailure: "issuer" | "audience" | "signature" | undefined; |
| 66 | let mismatchedState = false; |
| 67 | let callbackClientId: string | undefined; |
| 68 | let subject = "user-test"; |
| 69 | let email = "hidden@example.test"; |
| 70 | let grantScope = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"; |
| 71 | let origin = ""; |
| 72 | const exchanges: URLSearchParams[] = []; |
| 73 | const authorizationRequests: URL[] = []; |
| 74 | const callbackResponses: { body: string; headers: Headers }[] = []; |
| 75 | let returnUrl = "http://localhost:7001/welcome"; |
| 76 | const server = yield* Effect.acquireRelease( |
| 77 | Effect.promise( |
| 78 | () => |
| 79 | new Promise<NodeHttp.Server>((resolve) => { |
| 80 | const server = NodeHttp.createServer(async (request, response) => { |
| 81 | response.setHeader("content-type", "application/json"); |
| 82 | if (request.url === "/discovery") { |
| 83 | response.end( |
| 84 | JSON.stringify({ |
| 85 | issuer: origin, |
| 86 | authorization_endpoint: `${origin}/authorize`, |
| 87 | token_endpoint: `${origin}/token`, |
| 88 | jwks_uri: `${origin}/jwks`, |
| 89 | revocation_endpoint: `${origin}/revoke`, |
| 90 | }), |
| 91 | ); |
| 92 | return; |
| 93 | } |
| 94 | if (request.url === "/jwks") { |
| 95 | response.end( |
| 96 | JSON.stringify({ keys: [{ ...jwk, kid: "test", alg: "RS256", use: "sig" }] }), |
| 97 | ); |
| 98 | return; |
| 99 | } |
| 100 | if (request.url === "/revoke") { |
| 101 | let text = ""; |
| 102 | for await (const chunk of request) text += chunk.toString(); |
| 103 | revocations.push(new URLSearchParams(text)); |
| 104 | response.statusCode = revocationStatus; |
| 105 | response.end(); |
| 106 | return; |
| 107 | } |
| 108 | if (request.url === "/token") { |
| 109 | let text = ""; |
| 110 | for await (const chunk of request) text += chunk.toString(); |
| 111 | assert.strictEqual( |
| 112 | request.headers["content-type"], |
| 113 | "application/x-www-form-urlencoded", |
| 114 | ); |
| 115 | assert.isUndefined(request.headers.authorization); |
| 116 | const body = new URLSearchParams(text); |
| 117 | assert.isFalse(body.has("client_secret")); |
| 118 | assert.strictEqual(body.get("resource"), `${origin}/v1`); |
| 119 | exchanges.push(body); |
| 120 | if (body.get("grant_type") === "refresh_token") { |
| 121 | refreshes++; |
| 122 | if (transient) { |
| 123 | response.statusCode = 503; |
| 124 | response.end(JSON.stringify({ error: "temporarily_unavailable" })); |
| 125 | return; |
| 126 | } |
| 127 | if (revoked || refreshError) { |
| 128 | response.statusCode = 400; |
| 129 | response.end(JSON.stringify({ error: refreshError ?? "invalid_grant" })); |
| 130 | return; |
| 131 | } |
| 132 | response.end( |
| 133 | JSON.stringify({ |
| 134 | access_token: `access-${refreshes}`, |
| 135 | refresh_token: `refresh-${refreshes}`, |
| 136 | token_type: "Bearer", |
| 137 | expires_in: 3600, |
| 138 | scope: grantScope, |
| 139 | }), |
| 140 | ); |
| 141 | return; |
| 142 | } |
| 143 | if (codeError) { |
| 144 | response.statusCode = 400; |
| 145 | response.end(JSON.stringify({ error: codeError })); |
| 146 | return; |
| 147 | } |
| 148 | if (!authorize) { |
| 149 | response.statusCode = 400; |
| 150 | response.end("{}"); |
| 151 | return; |
| 152 | } |
| 153 | assertSameCallback( |
| 154 | body.get("redirect_uri"), |
| 155 | authorize.searchParams.get("redirect_uri"), |
| 156 | ); |
| 157 | assert.strictEqual( |
| 158 | NodeCrypto.createHash("sha256") |
| 159 | .update(body.get("code_verifier")!) |
| 160 | .digest("base64url"), |
| 161 | authorize.searchParams.get("code_challenge"), |
| 162 | ); |
| 163 | const token = await new SignJWT({ |
| 164 | nonce: invalidNonce ? "incorrect" : authorize.searchParams.get("nonce"), |
| 165 | email, |
| 166 | }) |
| 167 | .setProtectedHeader({ alg: "RS256", kid: "test" }) |
| 168 | .setIssuer(identityFailure === "issuer" ? "https://untrusted-issuer.test" : origin) |
| 169 | .setAudience( |
| 170 | identityFailure === "audience" |
| 171 | ? "oaiapp_untrusted_audience" |
| 172 | : body.get("client_id")!, |
| 173 | ) |
| 174 | .setSubject(subject) |
| 175 | .setIssuedAt() |
| 176 | .setExpirationTime("1h") |
| 177 | .sign(identityFailure === "signature" ? untrustedKeys.privateKey : keys.privateKey); |
| 178 | response.end( |
| 179 | JSON.stringify({ |
| 180 | access_token: "initial-access", |
| 181 | refresh_token: "initial-refresh", |
| 182 | id_token: token, |
| 183 | token_type: "Bearer", |
| 184 | expires_in: 3600, |
| 185 | scope: grantScope, |
| 186 | }), |
| 187 | ); |
| 188 | return; |
| 189 | } |
| 190 | response.statusCode = 404; |
| 191 | response.end("{}"); |
| 192 | }); |
| 193 | server.listen(0, "127.0.0.1", () => resolve(server)); |
| 194 | }), |
| 195 | ), |
| 196 | (server) => |
| 197 | Effect.promise( |
| 198 | () => |
| 199 | new Promise<void>((resolve) => { |
| 200 | server.closeAllConnections(); |
| 201 | server.close(() => resolve()); |
| 202 | }), |
| 203 | ), |
| 204 | ); |
| 205 | const address = server.address(); |
| 206 | if (!address || typeof address === "string") throw new Error("mock address"); |
| 207 | origin = `http://127.0.0.1:${address.port}`; |
| 208 | const auth = yield* makeCodexChatGptAuth({ |
| 209 | instanceId, |
| 210 | discoveryUrl: `${origin}/discovery`, |
| 211 | resource: `${origin}/v1`, |
| 212 | }).pipe( |
| 213 | Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), |
| 214 | Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment), |
| 215 | ); |
| 216 | const phase = (phase: string) => |
| 217 | auth.controller.subscribe("owner").pipe( |
| 218 | Stream.filter((state) => state.phase === phase), |
| 219 | Stream.runHead, |
| 220 | Effect.map(Option.getOrThrow), |
| 221 | ); |
| 222 | const prepareCallback = (waiting: { authorizationUrl: string | null }) => { |
| 223 | authorize = new URL(waiting.authorizationUrl!); |
| 224 | authorizationRequests.push(authorize); |
| 225 | const callback = new URL(authorize.searchParams.get("redirect_uri")!); |
| 226 | callback.search = new URLSearchParams({ |
| 227 | code: "authorization-code", |
| 228 | state: mismatchedState ? "unmatched-state" : authorize.searchParams.get("state")!, |
| 229 | ...(callbackClientId |
| 230 | ? { client_id: callbackClientId } |
| 231 | : authorize.searchParams.get("client_id") === "dynamic_agent_client" |
| 232 | ? { client_id: "oaiapp_test" } |
| 233 | : {}), |
| 234 | }).toString(); |
| 235 | return callback; |
| 236 | }; |
| 237 | const startRemote = (methodId?: string) => |
| 238 | Effect.gen(function* () { |
| 239 | yield* auth.controller.start("owner", Effect.void, methodId, returnUrl, "client"); |
| 240 | const waiting = yield* phase("waiting"); |
| 241 | return { waiting, callbackUrl: prepareCallback(waiting).toString() }; |
| 242 | }); |
| 243 | const signInWithMethod = (methodId?: string) => |
| 244 | Effect.gen(function* () { |
| 245 | yield* auth.controller.start("owner", Effect.void, methodId, returnUrl); |
| 246 | const waiting = yield* phase("waiting"); |
| 247 | assert.strictEqual(waiting.interaction?.type, "browser"); |
| 248 | const callback = prepareCallback(waiting); |
| 249 | yield* Effect.promise(async () => { |
| 250 | const response = await fetch(callback); |
| 251 | callbackResponses.push({ body: await response.text(), headers: response.headers }); |
| 252 | }); |
| 253 | }); |
| 254 | const seedExpired = Effect.gen(function* () { |
| 255 | const stored = yield* auth.read; |
| 256 | const record = Option.getOrThrow(stored); |
| 257 | const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe( |
| 258 | Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), |
| 259 | ); |
| 260 | yield* store.set(new TextEncoder().encode(JSON.stringify({ ...record, expiresAt: 0 }))); |
| 261 | }); |
| 262 | return { |
| 263 | auth, |
| 264 | secrets, |
| 265 | environmentId, |
| 266 | bytes, |
| 267 | handoff: (profile: Parameters<typeof subscribeChatGptHandoff>[0]["profile"]) => |
| 268 | subscribeChatGptHandoff( |
| 269 | { |
| 270 | instanceId, |
| 271 | environmentId, |
| 272 | attemptId: "test-handoff", |
| 273 | returnUrl, |
| 274 | profile, |
| 275 | }, |
| 276 | "owner", |
| 277 | { discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1` }, |
| 278 | ), |
| 279 | finishCallback: (state: { authorizationUrl: string | null }) => |
| 280 | Effect.promise(() => fetch(prepareCallback(state))), |
| 281 | destination: Effect.gen(function* () { |
| 282 | const destinationBytes = new Map<string, Uint8Array>(); |
| 283 | const destinationStore = ServerSecretStore.ServerSecretStore.of({ |
| 284 | ...secrets, |
| 285 | get: (name) => Effect.sync(() => Option.fromUndefinedOr(destinationBytes.get(name))), |
| 286 | set: (name, value) => |
| 287 | Effect.sync(() => { |
| 288 | destinationBytes.set(name, value); |
| 289 | }), |
| 290 | remove: (name) => |
| 291 | Effect.sync(() => { |
| 292 | destinationBytes.delete(name); |
| 293 | }), |
| 294 | }); |
| 295 | const destination = yield* makeCodexChatGptAuth({ |
| 296 | instanceId, |
| 297 | discoveryUrl: `${origin}/discovery`, |
| 298 | resource: `${origin}/v1`, |
| 299 | }).pipe( |
| 300 | Effect.provideService(ServerSecretStore.ServerSecretStore, destinationStore), |
| 301 | Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment), |
| 302 | ); |
| 303 | return { auth: destination, bytes: destinationBytes }; |
| 304 | }), |
| 305 | recreateAuth: makeCodexChatGptAuth({ |
| 306 | instanceId, |
| 307 | discoveryUrl: `${origin}/discovery`, |
| 308 | resource: `${origin}/v1`, |
| 309 | }).pipe( |
| 310 | Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), |
| 311 | Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, environment), |
| 312 | ), |
| 313 | startRemote, |
| 314 | signIn: signInWithMethod(), |
| 315 | changeAccount: signInWithMethod("chatgpt-change-account"), |
| 316 | reconnectProfile: (clientId: string) => signInWithMethod(`chatgpt-profile:${clientId}`), |
| 317 | phase, |
| 318 | seedExpired, |
| 319 | exchanges, |
| 320 | authorizationRequests, |
| 321 | callbackResponses, |
| 322 | setReturnUrl: (value: string) => { |
| 323 | returnUrl = value; |
| 324 | }, |
| 325 | origin, |
| 326 | storedRecords: () => |
| 327 | Array.from(bytes.entries()).flatMap(([, value]) => { |
| 328 | const record = JSON.parse(new TextDecoder().decode(value)); |
| 329 | return record.sessions ?? [record]; |
| 330 | }), |
| 331 | revocations, |
| 332 | setRefreshError: (value: string) => { |
| 333 | refreshError = value; |
| 334 | }, |
| 335 | setRevocationStatus: (value: number) => { |
| 336 | revocationStatus = value; |
| 337 | }, |
| 338 | setCodeError: (value: string | undefined) => { |
| 339 | codeError = value; |
| 340 | }, |
| 341 | refreshes: () => refreshes, |
| 342 | setRevoked: () => { |
| 343 | revoked = true; |
| 344 | }, |
| 345 | setTransient: (value: boolean) => { |
| 346 | transient = value; |
| 347 | }, |
| 348 | setInvalidNonce: () => { |
| 349 | invalidNonce = true; |
| 350 | }, |
| 351 | setIdentityFailure: (value: "issuer" | "audience" | "signature") => { |
| 352 | identityFailure = value; |
| 353 | }, |
| 354 | mismatchCallbackState: () => { |
| 355 | mismatchedState = true; |
| 356 | }, |
| 357 | setCallbackClientId: (value: string) => { |
| 358 | callbackClientId = value; |
| 359 | }, |
| 360 | setIdentity: (newSubject: string, newEmail: string) => { |
| 361 | subject = newSubject; |
| 362 | email = newEmail; |
| 363 | }, |
| 364 | declineSharing: () => { |
| 365 | grantScope = "openid profile email"; |
| 366 | }, |
| 367 | }; |
| 368 | }); |
| 369 | const makeHarness = makeHarnessFor(instanceId); |
| 370 | const provision = <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 371 | effect.pipe( |
| 372 | Effect.scoped, |
| 373 | Effect.provide(Layer.mergeAll(FetchHttpClient.layer, NodeServices.layer)), |
| 374 | ); |
| 375 | it.effect( |
| 376 | "changing account registers a new user-owned client and then reuses that registration", |
| 377 | () => |
| 378 | provision( |
| 379 | Effect.gen(function* () { |
| 380 | const h = yield* makeHarness; |
| 381 | yield* h.signIn; |
| 382 | yield* h.phase("succeeded"); |
| 383 | h.setCallbackClientId("oaiapp_other_account"); |
| 384 | h.setIdentity("other-user", "other@example.test"); |
| 385 | yield* h.changeAccount; |
| 386 | yield* h.phase("succeeded"); |
| 387 | assert.strictEqual( |
| 388 | h.authorizationRequests[1]!.searchParams.get("client_id"), |
| 389 | "dynamic_agent_client", |
| 390 | ); |
| 391 | assert.strictEqual(h.exchanges[1]!.get("client_id"), "oaiapp_other_account"); |
| 392 | const saved = Option.getOrThrow(yield* h.auth.read); |
| 393 | assert.strictEqual(saved.clientId, "oaiapp_other_account"); |
| 394 | assert.strictEqual(saved.subject, "other-user"); |
| 395 | assert.strictEqual(saved.email, "other@example.test"); |
| 396 | const redirectUri = h.authorizationRequests[1]!.searchParams.get("redirect_uri"); |
| 397 | yield* h.signIn; |
| 398 | yield* h.phase("succeeded"); |
| 399 | assert.strictEqual( |
| 400 | h.authorizationRequests[2]!.searchParams.get("client_id"), |
| 401 | "oaiapp_other_account", |
| 402 | ); |
| 403 | assertSameCallback( |
| 404 | h.authorizationRequests[2]!.searchParams.get("redirect_uri"), |
| 405 | redirectUri, |
| 406 | ); |
| 407 | }), |
| 408 | ), |
| 409 | ); |
| 410 | it.effect.each(["identity", "sharing"] as const)( |
| 411 | "failed account change preserves the original credentials and registration: %s", |
| 412 | (failure) => |
| 413 | provision( |
| 414 | Effect.gen(function* () { |
| 415 | const h = yield* makeHarness; |
| 416 | yield* h.signIn; |
| 417 | yield* h.phase("succeeded"); |
| 418 | const before = h.storedRecords(); |
| 419 | h.setCallbackClientId("oaiapp_other_account"); |
| 420 | if (failure === "identity") h.setInvalidNonce(); |
| 421 | else h.declineSharing(); |
| 422 | yield* h.changeAccount; |
| 423 | yield* h.phase("failed"); |
| 424 | assert.strictEqual( |
| 425 | h.authorizationRequests[1]!.searchParams.get("client_id"), |
| 426 | "dynamic_agent_client", |
| 427 | ); |
| 428 | if (failure === "sharing") { |
| 429 | assert.deepEqual( |
| 430 | Option.getOrThrow(yield* h.auth.read), |
| 431 | before.find((record) => record.accessToken), |
| 432 | ); |
| 433 | assert.lengthOf(h.storedRecords().find((record) => record.profiles).profiles, 2); |
| 434 | } else assert.deepEqual(h.storedRecords(), before); |
| 435 | }), |
| 436 | ), |
| 437 | ); |
| 438 | it.effect("retains the callback host and path after controller recreation and token removal", () => |
| 439 | provision( |
| 440 | Effect.gen(function* () { |
| 441 | const bytes = new Map<string, Uint8Array>(); |
| 442 | const first = yield* makeHarnessFor(instanceId, bytes); |
| 443 | yield* first.signIn; |
| 444 | yield* first.phase("succeeded"); |
| 445 | const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); |
| 446 | yield* first.auth.revoke; |
| 447 | const restarted = yield* makeHarnessFor(instanceId, bytes); |
| 448 | yield* restarted.signIn; |
| 449 | yield* restarted.phase("succeeded"); |
| 450 | assert.strictEqual( |
| 451 | restarted.authorizationRequests[0]!.searchParams.get("client_id"), |
| 452 | "oaiapp_test", |
| 453 | ); |
| 454 | assertSameCallback( |
| 455 | restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), |
| 456 | redirectUri, |
| 457 | ); |
| 458 | assert.strictEqual( |
| 459 | restarted.exchanges[0]!.get("redirect_uri"), |
| 460 | restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), |
| 461 | ); |
| 462 | }), |
| 463 | ), |
| 464 | ); |
| 465 | it.effect("reauthorizes on an available port when the original callback port is occupied", () => |
| 466 | provision( |
| 467 | Effect.gen(function* () { |
| 468 | const h = yield* makeHarness; |
| 469 | yield* h.signIn; |
| 470 | yield* h.phase("succeeded"); |
| 471 | const port = Number( |
| 472 | new URL(h.authorizationRequests[0]!.searchParams.get("redirect_uri")!).port, |
| 473 | ); |
| 474 | yield* Effect.acquireRelease( |
| 475 | Effect.promise( |
| 476 | () => |
| 477 | new Promise<NodeHttp.Server>((resolve, reject) => { |
| 478 | const server = NodeHttp.createServer(); |
| 479 | server.once("error", reject); |
| 480 | server.listen(port, "127.0.0.1", () => resolve(server)); |
| 481 | }), |
| 482 | ), |
| 483 | (server) => |
| 484 | Effect.promise(() => new Promise<void>((resolve) => server.close(() => resolve()))), |
| 485 | ); |
| 486 | yield* h.signIn; |
| 487 | yield* h.phase("succeeded"); |
| 488 | assert.notStrictEqual( |
| 489 | Number(new URL(h.authorizationRequests[1]!.searchParams.get("redirect_uri")!).port), |
| 490 | port, |
| 491 | ); |
| 492 | assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("client_id"), "oaiapp_test"); |
| 493 | assert.strictEqual(h.exchanges.length, 2); |
| 494 | }), |
| 495 | ), |
| 496 | ); |
| 497 | |
| 498 | it.effect("registers a fresh client when the original registration is no longer stored", () => |
| 499 | provision( |
| 500 | Effect.gen(function* () { |
| 501 | const bytes = new Map<string, Uint8Array>(); |
| 502 | const h = yield* makeHarnessFor(instanceId, bytes); |
| 503 | yield* h.signIn; |
| 504 | yield* h.phase("succeeded"); |
| 505 | for (const [key, value] of bytes) { |
| 506 | const record = JSON.parse(new TextDecoder().decode(value)); |
| 507 | if (record.profiles) bytes.delete(key); |
| 508 | } |
| 509 | h.setCallbackClientId("oaiapp_replacement"); |
| 510 | yield* h.signIn; |
| 511 | yield* h.phase("succeeded"); |
| 512 | assert.strictEqual( |
| 513 | h.authorizationRequests[1]!.searchParams.get("client_id"), |
| 514 | "dynamic_agent_client", |
| 515 | ); |
| 516 | assert.strictEqual(h.exchanges[1]!.get("client_id"), "oaiapp_replacement"); |
| 517 | assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_replacement"); |
| 518 | }), |
| 519 | ), |
| 520 | ); |
| 521 | it.effect("reauthorizes a registration without persisting its original port", () => |
| 522 | provision( |
| 523 | Effect.gen(function* () { |
| 524 | const bytes = new Map<string, Uint8Array>(); |
| 525 | const h = yield* makeHarnessFor(instanceId, bytes); |
| 526 | yield* h.signIn; |
| 527 | yield* h.phase("succeeded"); |
| 528 | yield* h.auth.revoke; |
| 529 | for (const [key, value] of bytes) { |
| 530 | const record = JSON.parse(new TextDecoder().decode(value)); |
| 531 | if (record.profiles) |
| 532 | bytes.set(key, new TextEncoder().encode(JSON.stringify({ clientId: "oaiapp_test" }))); |
| 533 | } |
| 534 | yield* h.signIn; |
| 535 | yield* h.phase("succeeded"); |
| 536 | assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("client_id"), "oaiapp_test"); |
| 537 | assert.strictEqual(new URL(h.exchanges[1]!.get("redirect_uri")!).hostname, "127.0.0.1"); |
| 538 | }), |
| 539 | ), |
| 540 | ); |
| 541 | |
| 542 | it.effect( |
| 543 | "separate Codex accounts register independently and disconnect only their own tokens", |
| 544 | () => |
| 545 | provision( |
| 546 | Effect.gen(function* () { |
| 547 | const bytes = new Map<string, Uint8Array>(); |
| 548 | const personal = yield* makeHarnessFor(ProviderInstanceId.make("codex_personal"), bytes); |
| 549 | const work = yield* makeHarnessFor(ProviderInstanceId.make("codex_work"), bytes); |
| 550 | yield* personal.signIn; |
| 551 | yield* personal.phase("succeeded"); |
| 552 | const personalCredentials = Option.getOrThrow(yield* personal.auth.read); |
| 553 | assert.isTrue(Option.isNone(yield* work.auth.read)); |
| 554 | yield* work.signIn; |
| 555 | yield* work.phase("succeeded"); |
| 556 | assert.strictEqual( |
| 557 | work.authorizationRequests[0]!.searchParams.get("client_id"), |
| 558 | "dynamic_agent_client", |
| 559 | ); |
| 560 | assert.notStrictEqual( |
| 561 | personal.auth.controller.credentialBinding?.key, |
| 562 | work.auth.controller.credentialBinding?.key, |
| 563 | ); |
| 564 | yield* work.auth.controller.logout(Effect.void); |
| 565 | assert.isTrue(Option.isNone(yield* work.auth.read)); |
| 566 | assert.deepEqual(Option.getOrThrow(yield* personal.auth.read), personalCredentials); |
| 567 | }), |
| 568 | ), |
| 569 | ); |
| 570 | it.effect( |
| 571 | "emits the guide's first-time authorization request and fresh reauthorization values", |
| 572 | () => |
| 573 | provision( |
| 574 | Effect.gen(function* () { |
| 575 | const h = yield* makeHarness; |
| 576 | yield* h.signIn; |
| 577 | yield* h.phase("succeeded"); |
| 578 | assert.include( |
| 579 | h.callbackResponses[0]!.body, |
| 580 | 'content="1;url=http://localhost:7001/welcome"', |
| 581 | ); |
| 582 | const first = h.authorizationRequests[0]!; |
| 583 | assert.strictEqual( |
| 584 | first.searchParams.get("ext_agent_host_id"), |
| 585 | `urn:uuid:${h.environmentId}`, |
| 586 | ); |
| 587 | assert.strictEqual(first.origin, h.origin); |
| 588 | assert.strictEqual(first.pathname, "/authorize"); |
| 589 | assert.deepEqual(Array.from(first.searchParams.keys()).sort(), [ |
| 590 | "agent_name_hint", |
| 591 | "client_id", |
| 592 | "code_challenge", |
| 593 | "code_challenge_method", |
| 594 | "ext_agent_host_id", |
| 595 | "nonce", |
| 596 | "redirect_uri", |
| 597 | "resource", |
| 598 | "response_type", |
| 599 | "scope", |
| 600 | "state", |
| 601 | ]); |
| 602 | assert.strictEqual(first.searchParams.get("client_id"), "dynamic_agent_client"); |
| 603 | assert.strictEqual(first.searchParams.get("agent_name_hint"), "T3 Code"); |
| 604 | assert.strictEqual(first.searchParams.get("response_type"), "code"); |
| 605 | assert.strictEqual( |
| 606 | first.searchParams.get("scope"), |
| 607 | "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct", |
| 608 | ); |
| 609 | assert.strictEqual(first.searchParams.get("resource"), `${h.origin}/v1`); |
| 610 | assert.strictEqual(first.searchParams.get("code_challenge_method"), "S256"); |
| 611 | const callback = new URL(first.searchParams.get("redirect_uri")!); |
| 612 | assert.strictEqual(callback.protocol, "http:"); |
| 613 | assert.strictEqual(callback.hostname, "127.0.0.1"); |
| 614 | assert.strictEqual(callback.pathname, "/auth/callback"); |
| 615 | assert.isAbove(Number(callback.port), 0); |
| 616 | for (const key of ["state", "nonce", "code_challenge"]) { |
| 617 | assert.match(first.searchParams.get(key)!, /^[A-Za-z0-9_-]{43}$/u); |
| 618 | } |
| 619 | assert.notStrictEqual(first.searchParams.get("state"), first.searchParams.get("nonce")); |
| 620 | const originalIdToken = Option.getOrThrow(yield* h.auth.read).idToken; |
| 621 | yield* h.signIn; |
| 622 | yield* h.phase("succeeded"); |
| 623 | const second = h.authorizationRequests[1]!; |
| 624 | assert.strictEqual(second.searchParams.get("client_id"), "oaiapp_test"); |
| 625 | assert.isFalse(second.searchParams.has("agent_name_hint")); |
| 626 | assert.strictEqual(second.searchParams.get("login_hint"), "hidden@example.test"); |
| 627 | assert.strictEqual(second.searchParams.get("id_token_hint"), originalIdToken); |
| 628 | assert.strictEqual( |
| 629 | second.searchParams.get("ext_agent_host_id"), |
| 630 | first.searchParams.get("ext_agent_host_id"), |
| 631 | ); |
| 632 | assertSameCallback( |
| 633 | second.searchParams.get("redirect_uri"), |
| 634 | first.searchParams.get("redirect_uri"), |
| 635 | ); |
| 636 | assert.deepEqual( |
| 637 | Array.from(second.searchParams.keys()).sort(), |
| 638 | [ |
| 639 | ...Array.from(first.searchParams.keys()).filter((key) => key !== "agent_name_hint"), |
| 640 | "login_hint", |
| 641 | "id_token_hint", |
| 642 | ].sort(), |
| 643 | ); |
| 644 | for (const key of ["state", "nonce", "code_challenge"]) { |
| 645 | assert.notStrictEqual(first.searchParams.get(key), second.searchParams.get(key)); |
| 646 | } |
| 647 | yield* h.auth.controller.logout(Effect.void); |
| 648 | yield* h.signIn; |
| 649 | yield* h.phase("succeeded"); |
| 650 | const reconnect = h.authorizationRequests[2]!; |
| 651 | assert.strictEqual(reconnect.searchParams.get("client_id"), "oaiapp_test"); |
| 652 | assertSameCallback( |
| 653 | reconnect.searchParams.get("redirect_uri"), |
| 654 | first.searchParams.get("redirect_uri"), |
| 655 | ); |
| 656 | assert.isFalse(reconnect.searchParams.has("agent_name_hint")); |
| 657 | assert.isFalse(reconnect.searchParams.has("id_token_hint")); |
| 658 | assert.strictEqual(reconnect.searchParams.get("login_hint"), "hidden@example.test"); |
| 659 | assert.strictEqual( |
| 660 | reconnect.searchParams.get("ext_agent_host_id"), |
| 661 | first.searchParams.get("ext_agent_host_id"), |
| 662 | ); |
| 663 | assert.strictEqual( |
| 664 | h.exchanges[2]!.get("redirect_uri"), |
| 665 | reconnect.searchParams.get("redirect_uri"), |
| 666 | ); |
| 667 | }), |
| 668 | ), |
| 669 | ); |
| 670 | it.effect( |
| 671 | "returns to the initiating client only after sign-in has verified and saved credentials", |
| 672 | () => |
| 673 | provision( |
| 674 | Effect.gen(function* () { |
| 675 | const h = yield* makeHarness; |
| 676 | h.setReturnUrl( |
| 677 | "http://localhost:7001/settings/providers?instanceId=codex_work&code=secret-code", |
| 678 | ); |
| 679 | yield* h.signIn; |
| 680 | assert.isTrue(Option.isSome(yield* h.auth.read)); |
| 681 | const response = h.callbackResponses[0]!; |
| 682 | assert.include(response.headers.get("content-type")!, "text/html"); |
| 683 | assert.strictEqual(response.headers.get("cache-control"), "no-store"); |
| 684 | assert.strictEqual(response.headers.get("referrer-policy"), "no-referrer"); |
| 685 | assert.include(response.headers.get("content-security-policy")!, "default-src 'none'"); |
| 686 | assert.include(response.body, "You're signed in".replace("'", "&#39;")); |
| 687 | assert.include( |
| 688 | response.body, |
| 689 | 'content="1;url=http://localhost:7001/settings/providers?instanceId=codex_work"', |
| 690 | ); |
| 691 | assert.notInclude(response.body, "secret-code"); |
| 692 | assert.include(response.body, 'history.replaceState(null,"","/auth/callback")'); |
| 693 | assert.notInclude(response.body, "authorization-code"); |
| 694 | assert.notInclude(response.body, "initial-access"); |
| 695 | assert.notInclude(response.body, "hidden@example.test"); |
| 696 | }), |
| 697 | ), |
| 698 | ); |
| 699 | it.effect("preserves the Welcome agents step and strips unrelated callback return parameters", () => |
| 700 | provision( |
| 701 | Effect.gen(function* () { |
| 702 | const h = yield* makeHarness; |
| 703 | h.setReturnUrl("http://localhost:7001/welcome?code=never-forward#agents:test-environment"); |
| 704 | yield* h.signIn; |
| 705 | assert.include( |
| 706 | h.callbackResponses[0]!.body, |
| 707 | 'content="1;url=http://localhost:7001/welcome#agents:test-environment"', |
| 708 | ); |
| 709 | assert.notInclude(h.callbackResponses[0]!.body, "never-forward"); |
| 710 | }), |
| 711 | ), |
| 712 | ); |
| 713 | it.effect( |
| 714 | "does not redirect to an arbitrary return URL or claim success after verification fails", |
| 715 | () => |
| 716 | provision( |
| 717 | Effect.gen(function* () { |
| 718 | const h = yield* makeHarness; |
| 719 | h.setReturnUrl("https://attacker.example/welcome"); |
| 720 | h.setInvalidNonce(); |
| 721 | yield* h.signIn; |
| 722 | yield* h.phase("failed"); |
| 723 | const response = h.callbackResponses[0]!; |
| 724 | assert.include(response.body, "Sign-in couldn&#39;t finish"); |
| 725 | assert.notInclude(response.body, 'http-equiv="refresh"'); |
| 726 | assert.notInclude(response.body, "attacker.example"); |
| 727 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 728 | }), |
| 729 | ), |
| 730 | ); |
| 731 | it.effect( |
| 732 | "verifies registration, PKCE, identity and persists rotating refresh before concurrent access", |
| 733 | () => |
| 734 | provision( |
| 735 | Effect.gen(function* () { |
| 736 | const h = yield* makeHarness; |
| 737 | yield* h.signIn; |
| 738 | yield* h.phase("succeeded"); |
| 739 | assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "user-test"); |
| 740 | assert.strictEqual(h.exchanges[0]?.get("client_id"), "oaiapp_test"); |
| 741 | yield* h.seedExpired; |
| 742 | const records = yield* Effect.all([h.auth.access, h.auth.access], { |
| 743 | concurrency: "unbounded", |
| 744 | }); |
| 745 | assert.strictEqual(h.refreshes(), 1); |
| 746 | assert.strictEqual(records[0].accessToken, "access-1"); |
| 747 | assert.strictEqual(records[1].refreshToken, "refresh-1"); |
| 748 | assert.isNull(h.exchanges[1]?.get("scope")); |
| 749 | yield* h.auth.controller.logout(Effect.void); |
| 750 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 751 | }), |
| 752 | ), |
| 753 | ); |
| 754 | it.effect("rejects invalid ID token nonce without saving credentials", () => |
| 755 | provision( |
| 756 | Effect.gen(function* () { |
| 757 | const h = yield* makeHarness; |
| 758 | h.setInvalidNonce(); |
| 759 | yield* h.signIn; |
| 760 | assert.include((yield* h.phase("failed")).message!, "could not be verified"); |
| 761 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 762 | }), |
| 763 | ), |
| 764 | ); |
| 765 | it.effect("retains identity after declined sharing but never admits inference", () => |
| 766 | provision( |
| 767 | Effect.gen(function* () { |
| 768 | const h = yield* makeHarness; |
| 769 | h.declineSharing(); |
| 770 | yield* h.signIn; |
| 771 | yield* h.phase("failed"); |
| 772 | assert.isTrue(Option.isSome(yield* h.auth.read)); |
| 773 | const result = yield* h.auth.access.pipe(Effect.result); |
| 774 | assert.strictEqual(result._tag, "Failure"); |
| 775 | assert.strictEqual(h.refreshes(), 0); |
| 776 | }), |
| 777 | ), |
| 778 | ); |
| 779 | it.effect("clears revoked refresh credentials and does not replay them", () => |
| 780 | provision( |
| 781 | Effect.gen(function* () { |
| 782 | const h = yield* makeHarness; |
| 783 | yield* h.signIn; |
| 784 | yield* h.phase("succeeded"); |
| 785 | yield* h.seedExpired; |
| 786 | h.setRevoked(); |
| 787 | yield* h.auth.access.pipe(Effect.result); |
| 788 | yield* h.auth.access.pipe(Effect.result); |
| 789 | assert.strictEqual(h.refreshes(), 1); |
| 790 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 791 | }), |
| 792 | ), |
| 793 | ); |
| 794 | |
| 795 | it.effect( |
| 796 | "preserves credentials through temporary renewal failure and retries the latest refresh", |
| 797 | () => |
| 798 | provision( |
| 799 | Effect.gen(function* () { |
| 800 | const h = yield* makeHarness; |
| 801 | yield* h.signIn; |
| 802 | yield* h.phase("succeeded"); |
| 803 | yield* h.seedExpired; |
| 804 | h.setTransient(true); |
| 805 | yield* h.auth.access.pipe(Effect.result); |
| 806 | assert.strictEqual(Option.getOrThrow(yield* h.auth.read).refreshToken, "initial-refresh"); |
| 807 | h.setTransient(false); |
| 808 | const renewed = yield* h.auth.access; |
| 809 | assert.strictEqual(renewed.refreshToken, "refresh-2"); |
| 810 | assert.strictEqual(h.exchanges.at(-1)?.get("refresh_token"), "initial-refresh"); |
| 811 | }), |
| 812 | ), |
| 813 | ); |
| 814 | |
| 815 | it.effect( |
| 816 | "disconnect retains the remembered account profile while a different account registers afresh", |
| 817 | () => |
| 818 | provision( |
| 819 | Effect.gen(function* () { |
| 820 | const h = yield* makeHarness; |
| 821 | yield* h.signIn; |
| 822 | yield* h.phase("succeeded"); |
| 823 | assert.strictEqual( |
| 824 | h.authorizationRequests[0]?.searchParams.get("client_id"), |
| 825 | "dynamic_agent_client", |
| 826 | ); |
| 827 | yield* h.auth.controller.logout(Effect.void); |
| 828 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 829 | assert.deepEqual(h.storedRecords(), [ |
| 830 | { |
| 831 | profiles: [ |
| 832 | { |
| 833 | clientId: "oaiapp_test", |
| 834 | connectionLabel: "Connection 1", |
| 835 | sharingEnabled: true, |
| 836 | redirectUri: h.authorizationRequests[0]!.searchParams.get("redirect_uri"), |
| 837 | subject: "user-test", |
| 838 | email: "hidden@example.test", |
| 839 | }, |
| 840 | ], |
| 841 | lastClientId: "oaiapp_test", |
| 842 | }, |
| 843 | ]); |
| 844 | const disconnected = yield* h.auth.controller |
| 845 | .subscribe("owner") |
| 846 | .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); |
| 847 | assert.include( |
| 848 | disconnected.methods!.find((method) => method.id === "chatgpt")!.description!, |
| 849 | "hidden@example.test", |
| 850 | ); |
| 851 | assert.strictEqual( |
| 852 | disconnected.methods!.find((method) => method.id === "chatgpt")!.accountEmail, |
| 853 | "hidden@example.test", |
| 854 | ); |
| 855 | assert.strictEqual( |
| 856 | disconnected.methods!.find((method) => method.id === "chatgpt-profile:oaiapp_test")! |
| 857 | .accountEmail, |
| 858 | "hidden@example.test", |
| 859 | ); |
| 860 | assert.strictEqual((yield* h.auth.access.pipe(Effect.result))._tag, "Failure"); |
| 861 | assert.strictEqual(h.refreshes(), 0); |
| 862 | h.setCallbackClientId("oaiapp_different_workspace"); |
| 863 | h.setIdentity("different-user", "different@example.test"); |
| 864 | yield* h.changeAccount; |
| 865 | yield* h.phase("succeeded"); |
| 866 | assert.strictEqual( |
| 867 | h.authorizationRequests[1]?.searchParams.get("client_id"), |
| 868 | "dynamic_agent_client", |
| 869 | ); |
| 870 | assert.strictEqual(h.exchanges[1]?.get("client_id"), "oaiapp_different_workspace"); |
| 871 | assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "different-user"); |
| 872 | }), |
| 873 | ), |
| 874 | ); |
| 875 | it.effect("revoked connection clears tokens but preserves registration for reconnect", () => |
| 876 | provision( |
| 877 | Effect.gen(function* () { |
| 878 | const h = yield* makeHarness; |
| 879 | yield* h.signIn; |
| 880 | yield* h.phase("succeeded"); |
| 881 | yield* h.seedExpired; |
| 882 | h.setRevoked(); |
| 883 | yield* h.auth.access.pipe(Effect.result); |
| 884 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 885 | assert.deepEqual(h.storedRecords(), [ |
| 886 | { |
| 887 | profiles: [ |
| 888 | { |
| 889 | clientId: "oaiapp_test", |
| 890 | connectionLabel: "Connection 1", |
| 891 | sharingEnabled: true, |
| 892 | redirectUri: h.authorizationRequests[0]!.searchParams.get("redirect_uri"), |
| 893 | subject: "user-test", |
| 894 | email: "hidden@example.test", |
| 895 | }, |
| 896 | ], |
| 897 | lastClientId: "oaiapp_test", |
| 898 | }, |
| 899 | ]); |
| 900 | yield* h.signIn; |
| 901 | yield* h.phase("succeeded"); |
| 902 | assert.strictEqual(h.authorizationRequests[1]?.searchParams.get("client_id"), "oaiapp_test"); |
| 903 | }), |
| 904 | ), |
| 905 | ); |
| 906 | |
| 907 | it.effect("fresh sign-in ignores a registration left by the old disconnect behavior", () => |
| 908 | provision( |
| 909 | Effect.gen(function* () { |
| 910 | const h = yield* makeHarness; |
| 911 | yield* h.signIn; |
| 912 | yield* h.phase("succeeded"); |
| 913 | yield* h.auth.revoke; |
| 914 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 915 | assert.strictEqual(h.storedRecords().length, 1); |
| 916 | h.setCallbackClientId("oaiapp_new_workspace"); |
| 917 | h.setIdentity("new-workspace-user", "new@example.test"); |
| 918 | yield* h.changeAccount; |
| 919 | yield* h.phase("succeeded"); |
| 920 | assert.strictEqual( |
| 921 | h.authorizationRequests[1]!.searchParams.get("client_id"), |
| 922 | "dynamic_agent_client", |
| 923 | ); |
| 924 | assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_new_workspace"); |
| 925 | assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "new-workspace-user"); |
| 926 | }), |
| 927 | ), |
| 928 | ); |
| 929 | |
| 930 | it.effect("rejects mismatched callback state before any token exchange or credential write", () => |
| 931 | provision( |
| 932 | Effect.gen(function* () { |
| 933 | const h = yield* makeHarness; |
| 934 | h.mismatchCallbackState(); |
| 935 | yield* h.signIn; |
| 936 | assert.include((yield* h.phase("failed")).message!, "could not be verified"); |
| 937 | assert.strictEqual(h.exchanges.length, 0); |
| 938 | assert.deepEqual(h.storedRecords(), []); |
| 939 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 940 | }), |
| 941 | ), |
| 942 | ); |
| 943 | it.effect.each(["issuer", "audience", "signature"] as const)( |
| 944 | "rejects ID token %s verification before saving tokens or registration", |
| 945 | (invalidClaim) => |
| 946 | provision( |
| 947 | Effect.gen(function* () { |
| 948 | const h = yield* makeHarness; |
| 949 | h.setIdentityFailure(invalidClaim); |
| 950 | yield* h.signIn; |
| 951 | assert.include((yield* h.phase("failed")).message!, "could not be verified"); |
| 952 | assert.strictEqual(h.exchanges.length, 1); |
| 953 | assert.deepEqual(h.storedRecords(), []); |
| 954 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 955 | }), |
| 956 | ), |
| 957 | ); |
| 958 | it.effect.each([ |
| 959 | { revoked: false, label: "without changing the current account" }, |
| 960 | { revoked: true, label: "after token removal" }, |
| 961 | ])("rejects conflicting callback client ID on reauthorization $label", ({ revoked }) => |
| 962 | provision( |
| 963 | Effect.gen(function* () { |
| 964 | const h = yield* makeHarness; |
| 965 | yield* h.signIn; |
| 966 | yield* h.phase("succeeded"); |
| 967 | if (revoked) yield* h.auth.revoke; |
| 968 | const before = h.storedRecords(); |
| 969 | h.setCallbackClientId("oaiapp_untrusted_callback"); |
| 970 | yield* h.signIn; |
| 971 | assert.include((yield* h.phase("failed")).message!, "registration is incomplete"); |
| 972 | assert.strictEqual(h.authorizationRequests[1]?.searchParams.get("client_id"), "oaiapp_test"); |
| 973 | assert.strictEqual(h.exchanges.length, 1); |
| 974 | assert.deepEqual(h.storedRecords(), before); |
| 975 | assert.strictEqual(Option.isNone(yield* h.auth.read), revoked); |
| 976 | }), |
| 977 | ), |
| 978 | ); |
| 979 | |
| 980 | it.effect("returns successful desktop sign-in to the original Welcome step", () => |
| 981 | provision( |
| 982 | Effect.gen(function* () { |
| 983 | const h = yield* makeHarness; |
| 984 | h.setReturnUrl("t3code-dev://app/welcome#agents:test-environment"); |
| 985 | yield* h.signIn; |
| 986 | yield* h.phase("succeeded"); |
| 987 | assert.include( |
| 988 | h.callbackResponses[0]!.body, |
| 989 | 'content="1;url=t3code-dev://app/welcome#agents:test-environment"', |
| 990 | ); |
| 991 | assert.include( |
| 992 | h.callbackResponses[0]!.body, |
| 993 | 'href="t3code-dev://app/welcome#agents:test-environment"', |
| 994 | ); |
| 995 | }), |
| 996 | ), |
| 997 | ); |
| 998 | |
| 999 | it.effect( |
| 1000 | "completes remote sign-in on the owning environment and reuses its exact callback for reauthorization", |
| 1001 | () => |
| 1002 | Effect.gen(function* () { |
| 1003 | const harness = yield* makeHarnessFor(instanceId); |
| 1004 | const first = yield* harness.startRemote(); |
| 1005 | assert.isTrue( |
| 1006 | first.waiting.interaction?.type === "browser" && first.waiting.interaction.acceptsCallback, |
| 1007 | ); |
| 1008 | yield* harness.auth.controller.complete("owner", { |
| 1009 | flowId: first.waiting.flowId!, |
| 1010 | callbackUrl: first.callbackUrl, |
| 1011 | }); |
| 1012 | yield* harness.phase("succeeded"); |
| 1013 | assert.strictEqual(Option.getOrThrow(yield* harness.auth.read).email, "hidden@example.test"); |
| 1014 | const second = yield* harness.startRemote(); |
| 1015 | assertSameCallback(second.callbackUrl, first.callbackUrl); |
| 1016 | assert.strictEqual( |
| 1017 | new URL(second.waiting.authorizationUrl!).searchParams.get("client_id"), |
| 1018 | "oaiapp_test", |
| 1019 | ); |
| 1020 | yield* harness.auth.controller.complete("owner", { |
| 1021 | flowId: second.waiting.flowId!, |
| 1022 | callbackUrl: second.callbackUrl, |
| 1023 | }); |
| 1024 | yield* harness.phase("succeeded"); |
| 1025 | assert.strictEqual(harness.exchanges.length, 2); |
| 1026 | }).pipe( |
| 1027 | Effect.scoped, |
| 1028 | Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), |
| 1029 | ), |
| 1030 | ); |
| 1031 | |
| 1032 | it.effect( |
| 1033 | "rejects foreign clients and malformed remote callbacks without consuming the owner's sign-in", |
| 1034 | () => |
| 1035 | Effect.gen(function* () { |
| 1036 | const harness = yield* makeHarnessFor(instanceId); |
| 1037 | const { waiting, callbackUrl } = yield* harness.startRemote(); |
| 1038 | assert.isTrue(Option.isNone(yield* harness.auth.read)); |
| 1039 | yield* Effect.flip( |
| 1040 | harness.auth.controller.complete("other-client", { flowId: waiting.flowId!, callbackUrl }), |
| 1041 | ); |
| 1042 | const wrongPort = new URL(callbackUrl); |
| 1043 | wrongPort.port = wrongPort.port === "65535" ? "65534" : "65535"; |
| 1044 | for (const invalid of [ |
| 1045 | wrongPort.toString(), |
| 1046 | callbackUrl.replace("/auth/callback", "/other"), |
| 1047 | callbackUrl + "&state=foreign", |
| 1048 | callbackUrl + "&code=duplicate", |
| 1049 | callbackUrl + "&error=access_denied", |
| 1050 | callbackUrl.replace("127.0.0.1", "attacker.example"), |
| 1051 | ]) { |
| 1052 | yield* Effect.flip( |
| 1053 | harness.auth.controller.complete("owner", { |
| 1054 | flowId: waiting.flowId!, |
| 1055 | callbackUrl: invalid, |
| 1056 | }), |
| 1057 | ); |
| 1058 | } |
| 1059 | assert.strictEqual(harness.exchanges.length, 0); |
| 1060 | assert.isTrue(Option.isNone(yield* harness.auth.read)); |
| 1061 | yield* harness.auth.controller.complete("owner", { flowId: waiting.flowId!, callbackUrl }); |
| 1062 | yield* harness.phase("succeeded"); |
| 1063 | }).pipe( |
| 1064 | Effect.scoped, |
| 1065 | Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), |
| 1066 | ), |
| 1067 | ); |
| 1068 | |
| 1069 | it.effect("keeps simultaneous remote accounts and environments independent", () => |
| 1070 | Effect.gen(function* () { |
| 1071 | const first = yield* makeHarnessFor(instanceId); |
| 1072 | const second = yield* makeHarnessFor(instanceId); |
| 1073 | second.setIdentity("other-user", "other@example.test"); |
| 1074 | second.setCallbackClientId("oaiapp_other"); |
| 1075 | const a = yield* first.startRemote(); |
| 1076 | const b = yield* second.startRemote(); |
| 1077 | assert.notStrictEqual( |
| 1078 | new URL(a.waiting.authorizationUrl!).searchParams.get("ext_agent_host_id"), |
| 1079 | new URL(b.waiting.authorizationUrl!).searchParams.get("ext_agent_host_id"), |
| 1080 | ); |
| 1081 | yield* Effect.flip( |
| 1082 | first.auth.controller.complete("owner", { |
| 1083 | flowId: a.waiting.flowId!, |
| 1084 | callbackUrl: b.callbackUrl, |
| 1085 | }), |
| 1086 | ); |
| 1087 | yield* first.auth.controller.complete("owner", { |
| 1088 | flowId: a.waiting.flowId!, |
| 1089 | callbackUrl: a.callbackUrl, |
| 1090 | }); |
| 1091 | yield* second.auth.controller.complete("owner", { |
| 1092 | flowId: b.waiting.flowId!, |
| 1093 | callbackUrl: b.callbackUrl, |
| 1094 | }); |
| 1095 | yield* first.phase("succeeded"); |
| 1096 | yield* second.phase("succeeded"); |
| 1097 | assert.strictEqual(Option.getOrThrow(yield* first.auth.read).subject, "user-test"); |
| 1098 | assert.strictEqual(Option.getOrThrow(yield* second.auth.read).subject, "other-user"); |
| 1099 | }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer))), |
| 1100 | ); |
| 1101 | |
| 1102 | it.effect( |
| 1103 | "reconnects after Disconnect and restart with the same client and host on an available port", |
| 1104 | () => |
| 1105 | provision( |
| 1106 | Effect.gen(function* () { |
| 1107 | const bytes = new Map<string, Uint8Array>(); |
| 1108 | const first = yield* makeHarnessFor(instanceId, bytes); |
| 1109 | yield* first.signIn; |
| 1110 | yield* first.phase("succeeded"); |
| 1111 | const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); |
| 1112 | yield* first.auth.controller.logout(Effect.void); |
| 1113 | const restarted = yield* makeHarnessFor(instanceId, bytes); |
| 1114 | yield* restarted.signIn; |
| 1115 | yield* restarted.phase("succeeded"); |
| 1116 | assert.strictEqual( |
| 1117 | restarted.authorizationRequests[0]!.searchParams.get("client_id"), |
| 1118 | "oaiapp_test", |
| 1119 | ); |
| 1120 | assertSameCallback( |
| 1121 | restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), |
| 1122 | redirectUri, |
| 1123 | ); |
| 1124 | assert.strictEqual( |
| 1125 | restarted.exchanges[0]!.get("redirect_uri"), |
| 1126 | restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), |
| 1127 | ); |
| 1128 | assert.strictEqual(Option.getOrThrow(yield* restarted.auth.read).subject, "user-test"); |
| 1129 | }), |
| 1130 | ), |
| 1131 | ); |
| 1132 | |
| 1133 | it.effect.each([ |
| 1134 | { disconnected: false, label: "while connected" }, |
| 1135 | { disconnected: true, label: "after Disconnect" }, |
| 1136 | ])("rejects a different verified identity during saved-profile reauth $label", ({ disconnected }) => |
| 1137 | provision( |
| 1138 | Effect.gen(function* () { |
| 1139 | const h = yield* makeHarness; |
| 1140 | yield* h.signIn; |
| 1141 | yield* h.phase("succeeded"); |
| 1142 | if (disconnected) yield* h.auth.controller.logout(Effect.void); |
| 1143 | const before = h.storedRecords(); |
| 1144 | h.setIdentity("another-user", "another@example.test"); |
| 1145 | yield* h.signIn; |
| 1146 | assert.include((yield* h.phase("failed")).message!, "different ChatGPT account"); |
| 1147 | assert.deepEqual(h.storedRecords(), before); |
| 1148 | assert.strictEqual(Option.isNone(yield* h.auth.read), disconnected); |
| 1149 | }), |
| 1150 | ), |
| 1151 | ); |
| 1152 | |
| 1153 | it.effect( |
| 1154 | "retains both profiles and reuses the original account's client and callback when returning from another account", |
| 1155 | () => |
| 1156 | provision( |
| 1157 | Effect.gen(function* () { |
| 1158 | const bytes = new Map<string, Uint8Array>(); |
| 1159 | const first = yield* makeHarnessFor(instanceId, bytes); |
| 1160 | yield* first.signIn; |
| 1161 | yield* first.phase("succeeded"); |
| 1162 | const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); |
| 1163 | first.setIdentity("other-user", "other@example.test"); |
| 1164 | first.setCallbackClientId("oaiapp_other_account"); |
| 1165 | yield* first.changeAccount; |
| 1166 | const changed = yield* first.phase("succeeded"); |
| 1167 | assert.isTrue( |
| 1168 | changed.methods!.some((method) => method.id === "chatgpt-profile:oaiapp_test"), |
| 1169 | ); |
| 1170 | assert.isTrue( |
| 1171 | changed.methods!.some((method) => method.id === "chatgpt-profile:oaiapp_other_account"), |
| 1172 | ); |
| 1173 | yield* first.auth.controller.logout(Effect.void); |
| 1174 | const restarted = yield* makeHarnessFor(instanceId, bytes); |
| 1175 | yield* restarted.reconnectProfile("oaiapp_test"); |
| 1176 | yield* restarted.phase("succeeded"); |
| 1177 | assert.strictEqual( |
| 1178 | restarted.authorizationRequests[0]!.searchParams.get("client_id"), |
| 1179 | "oaiapp_test", |
| 1180 | ); |
| 1181 | assertSameCallback( |
| 1182 | restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), |
| 1183 | redirectUri, |
| 1184 | ); |
| 1185 | assert.strictEqual(Option.getOrThrow(yield* restarted.auth.read).subject, "user-test"); |
| 1186 | }), |
| 1187 | ), |
| 1188 | ); |
| 1189 | |
| 1190 | it.effect("fresh sign-in to the same identity preserves separate registration profiles", () => |
| 1191 | provision( |
| 1192 | Effect.gen(function* () { |
| 1193 | const h = yield* makeHarness; |
| 1194 | yield* h.signIn; |
| 1195 | yield* h.phase("succeeded"); |
| 1196 | h.setIdentity("other-user", "other@example.test"); |
| 1197 | h.setCallbackClientId("oaiapp_other_account"); |
| 1198 | yield* h.changeAccount; |
| 1199 | yield* h.phase("succeeded"); |
| 1200 | h.setIdentity("user-test", "hidden@example.test"); |
| 1201 | h.setCallbackClientId("oaiapp_replacement"); |
| 1202 | yield* h.changeAccount; |
| 1203 | const changed = yield* h.phase("succeeded"); |
| 1204 | assert.deepEqual( |
| 1205 | changed |
| 1206 | .methods!.filter((method) => method.id.startsWith("chatgpt-profile:")) |
| 1207 | .map((method) => method.id), |
| 1208 | [ |
| 1209 | "chatgpt-profile:oaiapp_replacement", |
| 1210 | "chatgpt-profile:oaiapp_other_account", |
| 1211 | "chatgpt-profile:oaiapp_test", |
| 1212 | ], |
| 1213 | ); |
| 1214 | assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_replacement"); |
| 1215 | const redirectUri = h.authorizationRequests[2]!.searchParams.get("redirect_uri"); |
| 1216 | yield* h.auth.controller.logout(Effect.void); |
| 1217 | yield* h.reconnectProfile("oaiapp_replacement"); |
| 1218 | yield* h.phase("succeeded"); |
| 1219 | assert.strictEqual( |
| 1220 | h.authorizationRequests[3]!.searchParams.get("client_id"), |
| 1221 | "oaiapp_replacement", |
| 1222 | ); |
| 1223 | assertSameCallback(h.authorizationRequests[3]!.searchParams.get("redirect_uri"), redirectUri); |
| 1224 | }), |
| 1225 | ), |
| 1226 | ); |
| 1227 | |
| 1228 | it.effect("preserves legacy profiles with the same email and subject", () => |
| 1229 | provision( |
| 1230 | Effect.gen(function* () { |
| 1231 | const bytes = new Map<string, Uint8Array>(); |
| 1232 | const h = yield* makeHarnessFor(instanceId, bytes); |
| 1233 | yield* h.signIn; |
| 1234 | yield* h.phase("succeeded"); |
| 1235 | const redirectUri = h.authorizationRequests[0]!.searchParams.get("redirect_uri"); |
| 1236 | for (const [key, value] of bytes) { |
| 1237 | const record = JSON.parse(new TextDecoder().decode(value)); |
| 1238 | if (record.profiles) |
| 1239 | bytes.set( |
| 1240 | key, |
| 1241 | new TextEncoder().encode( |
| 1242 | JSON.stringify({ |
| 1243 | ...record, |
| 1244 | profiles: [ |
| 1245 | { ...record.profiles[0], clientId: "oaiapp_old_duplicate" }, |
| 1246 | ...record.profiles, |
| 1247 | ], |
| 1248 | }), |
| 1249 | ), |
| 1250 | ); |
| 1251 | } |
| 1252 | const restarted = yield* makeHarnessFor(instanceId, bytes); |
| 1253 | const state = yield* restarted.auth.controller.subscribe("owner").pipe( |
| 1254 | Stream.filter((state) => state.methods != null), |
| 1255 | Stream.runHead, |
| 1256 | Effect.map(Option.getOrThrow), |
| 1257 | ); |
| 1258 | assert.deepEqual( |
| 1259 | state |
| 1260 | .methods!.filter((method) => method.id.startsWith("chatgpt-profile:")) |
| 1261 | .map((method) => method.id), |
| 1262 | ["chatgpt-profile:oaiapp_test", "chatgpt-profile:oaiapp_old_duplicate"], |
| 1263 | ); |
| 1264 | yield* restarted.signIn; |
| 1265 | yield* restarted.phase("succeeded"); |
| 1266 | assert.strictEqual( |
| 1267 | restarted.authorizationRequests[0]!.searchParams.get("client_id"), |
| 1268 | "oaiapp_test", |
| 1269 | ); |
| 1270 | assertSameCallback( |
| 1271 | restarted.authorizationRequests[0]!.searchParams.get("redirect_uri"), |
| 1272 | redirectUri, |
| 1273 | ); |
| 1274 | assert.lengthOf(restarted.storedRecords().find((record) => record.profiles).profiles, 2); |
| 1275 | }), |
| 1276 | ), |
| 1277 | ); |
| 1278 | |
| 1279 | it.effect("keeps distinct verified identities even when their email matches", () => |
| 1280 | provision( |
| 1281 | Effect.gen(function* () { |
| 1282 | const h = yield* makeHarness; |
| 1283 | yield* h.signIn; |
| 1284 | yield* h.phase("succeeded"); |
| 1285 | h.setIdentity("other-user", "hidden@example.test"); |
| 1286 | h.setCallbackClientId("oaiapp_other_account"); |
| 1287 | yield* h.changeAccount; |
| 1288 | const changed = yield* h.phase("succeeded"); |
| 1289 | assert.lengthOf( |
| 1290 | changed.methods!.filter((method) => method.id.startsWith("chatgpt-profile:")), |
| 1291 | 2, |
| 1292 | ); |
| 1293 | }), |
| 1294 | ), |
| 1295 | ); |
| 1296 | |
| 1297 | it.effect( |
| 1298 | "migrates a legacy registration and retains its verified identity when credentials are cleared", |
| 1299 | () => |
| 1300 | provision( |
| 1301 | Effect.gen(function* () { |
| 1302 | const bytes = new Map<string, Uint8Array>(); |
| 1303 | const first = yield* makeHarnessFor(instanceId, bytes); |
| 1304 | yield* first.signIn; |
| 1305 | yield* first.phase("succeeded"); |
| 1306 | const redirectUri = first.authorizationRequests[0]!.searchParams.get("redirect_uri"); |
| 1307 | for (const [key, value] of bytes) { |
| 1308 | const saved = JSON.parse(new TextDecoder().decode(value)); |
| 1309 | if (saved.profiles) |
| 1310 | bytes.set( |
| 1311 | key, |
| 1312 | new TextEncoder().encode(JSON.stringify({ clientId: "oaiapp_test", redirectUri })), |
| 1313 | ); |
| 1314 | } |
| 1315 | yield* first.auth.revoke; |
| 1316 | assert.deepEqual(first.storedRecords(), [ |
| 1317 | { |
| 1318 | profiles: [ |
| 1319 | { |
| 1320 | clientId: "oaiapp_test", |
| 1321 | connectionLabel: "Connection 1", |
| 1322 | redirectUri, |
| 1323 | subject: "user-test", |
| 1324 | email: "hidden@example.test", |
| 1325 | }, |
| 1326 | ], |
| 1327 | lastClientId: "oaiapp_test", |
| 1328 | }, |
| 1329 | ]); |
| 1330 | const restarted = yield* makeHarnessFor(instanceId, bytes); |
| 1331 | restarted.setIdentity("another-user", "another@example.test"); |
| 1332 | yield* restarted.signIn; |
| 1333 | assert.include((yield* restarted.phase("failed")).message!, "different ChatGPT account"); |
| 1334 | assert.isTrue(Option.isNone(yield* restarted.auth.read)); |
| 1335 | assert.strictEqual( |
| 1336 | restarted.authorizationRequests[0]!.searchParams.get("ext_agent_host_id"), |
| 1337 | first.authorizationRequests[0]!.searchParams.get("ext_agent_host_id"), |
| 1338 | ); |
| 1339 | }), |
| 1340 | ), |
| 1341 | ); |
| 1342 | |
| 1343 | it.effect("declined sharing on an older profile does not replace the active account", () => |
| 1344 | provision( |
| 1345 | Effect.gen(function* () { |
| 1346 | const h = yield* makeHarness; |
| 1347 | yield* h.signIn; |
| 1348 | yield* h.phase("succeeded"); |
| 1349 | h.setIdentity("other-user", "other@example.test"); |
| 1350 | h.setCallbackClientId("oaiapp_other_account"); |
| 1351 | yield* h.changeAccount; |
| 1352 | yield* h.phase("succeeded"); |
| 1353 | const before = h.storedRecords(); |
| 1354 | h.setIdentity("user-test", "hidden@example.test"); |
| 1355 | h.setCallbackClientId("oaiapp_test"); |
| 1356 | h.declineSharing(); |
| 1357 | yield* h.reconnectProfile("oaiapp_test"); |
| 1358 | assert.include( |
| 1359 | (yield* h.phase("failed")).message!, |
| 1360 | "existing ChatGPT connection is unchanged", |
| 1361 | ); |
| 1362 | assert.deepEqual( |
| 1363 | Option.getOrThrow(yield* h.auth.read), |
| 1364 | before.find((record) => record.clientId === "oaiapp_other_account"), |
| 1365 | ); |
| 1366 | }), |
| 1367 | ), |
| 1368 | ); |
| 1369 | |
| 1370 | it.effect( |
| 1371 | "Disconnect preserves a remote profile's callback URI for client-delivered reconnect", |
| 1372 | () => |
| 1373 | provision( |
| 1374 | Effect.gen(function* () { |
| 1375 | const h = yield* makeHarness; |
| 1376 | const first = yield* h.startRemote(); |
| 1377 | yield* h.auth.controller.complete("owner", { |
| 1378 | flowId: first.waiting.flowId!, |
| 1379 | callbackUrl: first.callbackUrl, |
| 1380 | }); |
| 1381 | yield* h.phase("succeeded"); |
| 1382 | yield* h.auth.controller.logout(Effect.void); |
| 1383 | const second = yield* h.startRemote(); |
| 1384 | assert.strictEqual( |
| 1385 | new URL(second.waiting.authorizationUrl!).searchParams.get("client_id"), |
| 1386 | "oaiapp_test", |
| 1387 | ); |
| 1388 | assertSameCallback( |
| 1389 | new URL(second.waiting.authorizationUrl!).searchParams.get("redirect_uri"), |
| 1390 | new URL(first.waiting.authorizationUrl!).searchParams.get("redirect_uri"), |
| 1391 | ); |
| 1392 | yield* h.auth.controller.complete("owner", { |
| 1393 | flowId: second.waiting.flowId!, |
| 1394 | callbackUrl: second.callbackUrl, |
| 1395 | }); |
| 1396 | yield* h.phase("succeeded"); |
| 1397 | assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "user-test"); |
| 1398 | }), |
| 1399 | ), |
| 1400 | ); |
| 1401 | |
| 1402 | it.effect.each([ |
| 1403 | "invalid_grant", |
| 1404 | "invalid_refresh_token", |
| 1405 | "token_expired", |
| 1406 | "refresh_token_expired", |
| 1407 | "refresh_token_invalidated", |
| 1408 | "refresh_token_reused", |
| 1409 | "invalid_client", |
| 1410 | "invalid_token", |
| 1411 | ])("refresh recovery follows the machine-readable code: %s", (code) => |
| 1412 | provision( |
| 1413 | Effect.gen(function* () { |
| 1414 | const h = yield* makeHarness; |
| 1415 | yield* h.signIn; |
| 1416 | yield* h.phase("succeeded"); |
| 1417 | yield* h.seedExpired; |
| 1418 | h.setRefreshError(code); |
| 1419 | yield* Effect.flip(h.auth.access); |
| 1420 | assert.strictEqual( |
| 1421 | Option.isNone(yield* h.auth.read), |
| 1422 | !["invalid_client", "invalid_token"].includes(code), |
| 1423 | ); |
| 1424 | assert.isTrue( |
| 1425 | h |
| 1426 | .storedRecords() |
| 1427 | .some((record) => |
| 1428 | record.profiles?.some( |
| 1429 | (profile: { clientId: string }) => profile.clientId === "oaiapp_test", |
| 1430 | ), |
| 1431 | ), |
| 1432 | ); |
| 1433 | }), |
| 1434 | ), |
| 1435 | ); |
| 1436 | |
| 1437 | it.effect( |
| 1438 | "logout revokes the latest refresh token with the selected client and clears its ID hint", |
| 1439 | () => |
| 1440 | provision( |
| 1441 | Effect.gen(function* () { |
| 1442 | const h = yield* makeHarness; |
| 1443 | yield* h.signIn; |
| 1444 | yield* h.phase("succeeded"); |
| 1445 | const host = h.authorizationRequests[0]!.searchParams.get("ext_agent_host_id"); |
| 1446 | assert.match(host!, /^urn:uuid:[0-9a-f-]{36}$/u); |
| 1447 | yield* h.seedExpired; |
| 1448 | yield* h.auth.access; |
| 1449 | const state = yield* h.auth.controller.logout(Effect.void); |
| 1450 | assert.strictEqual(state.message, "Signed out."); |
| 1451 | assert.deepEqual(Array.from(h.revocations[0]!), [ |
| 1452 | ["token", "refresh-1"], |
| 1453 | ["token_type_hint", "refresh_token"], |
| 1454 | ["client_id", "oaiapp_test"], |
| 1455 | ]); |
| 1456 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 1457 | yield* h.signIn; |
| 1458 | yield* h.phase("succeeded"); |
| 1459 | assert.isFalse(h.authorizationRequests[1]!.searchParams.has("id_token_hint")); |
| 1460 | assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("ext_agent_host_id"), host); |
| 1461 | }), |
| 1462 | ), |
| 1463 | ); |
| 1464 | |
| 1465 | it.live("logout retries temporary revocation failures and reports local-only sign-out", () => |
| 1466 | provision( |
| 1467 | Effect.gen(function* () { |
| 1468 | const h = yield* makeHarness; |
| 1469 | yield* h.signIn; |
| 1470 | yield* h.phase("succeeded"); |
| 1471 | h.setRevocationStatus(503); |
| 1472 | const state = yield* h.auth.controller.logout(Effect.void); |
| 1473 | assert.lengthOf(h.revocations, 3); |
| 1474 | assert.strictEqual(state.phase, "idle"); |
| 1475 | assert.include(state.message!, "Remote revocation could not be confirmed"); |
| 1476 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 1477 | assert.isFalse(h.storedRecords().some((record) => record.idToken)); |
| 1478 | }), |
| 1479 | ), |
| 1480 | ); |
| 1481 | |
| 1482 | it.effect( |
| 1483 | "reauth hints come from the selected profile, including after another profile logs out", |
| 1484 | () => |
| 1485 | provision( |
| 1486 | Effect.gen(function* () { |
| 1487 | const h = yield* makeHarness; |
| 1488 | yield* h.signIn; |
| 1489 | yield* h.phase("succeeded"); |
| 1490 | const personal = Option.getOrThrow(yield* h.auth.read); |
| 1491 | h.setCallbackClientId("oaiapp_work"); |
| 1492 | h.setIdentity("work-user", "work@example.test"); |
| 1493 | yield* h.changeAccount; |
| 1494 | yield* h.phase("succeeded"); |
| 1495 | assert.isFalse(h.authorizationRequests[1]!.searchParams.has("id_token_hint")); |
| 1496 | assert.isFalse(h.authorizationRequests[1]!.searchParams.has("login_hint")); |
| 1497 | yield* h.auth.controller.logout(Effect.void); |
| 1498 | h.setCallbackClientId("oaiapp_test"); |
| 1499 | h.setIdentity("user-test", "hidden@example.test"); |
| 1500 | yield* h.reconnectProfile("oaiapp_test"); |
| 1501 | yield* h.phase("succeeded"); |
| 1502 | assert.strictEqual( |
| 1503 | h.authorizationRequests[2]!.searchParams.get("id_token_hint"), |
| 1504 | personal.idToken, |
| 1505 | ); |
| 1506 | assert.strictEqual( |
| 1507 | h.authorizationRequests[2]!.searchParams.get("login_hint"), |
| 1508 | personal.email, |
| 1509 | ); |
| 1510 | }), |
| 1511 | ), |
| 1512 | ); |
| 1513 | |
| 1514 | it.effect("an expired initial code retains the issued ID for a fresh authorization", () => |
| 1515 | provision( |
| 1516 | Effect.gen(function* () { |
| 1517 | const h = yield* makeHarness; |
| 1518 | h.setCodeError("invalid_grant"); |
| 1519 | yield* h.signIn; |
| 1520 | const failed = yield* h.phase("failed"); |
| 1521 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 1522 | assert.isTrue(failed.methods!.some((method) => method.id === "chatgpt-profile:oaiapp_test")); |
| 1523 | h.setCodeError(undefined); |
| 1524 | yield* h.reconnectProfile("oaiapp_test"); |
| 1525 | yield* h.phase("succeeded"); |
| 1526 | assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("client_id"), "oaiapp_test"); |
| 1527 | assert.notStrictEqual( |
| 1528 | h.authorizationRequests[0]!.searchParams.get("state"), |
| 1529 | h.authorizationRequests[1]!.searchParams.get("state"), |
| 1530 | ); |
| 1531 | }), |
| 1532 | ), |
| 1533 | ); |
| 1534 | |
| 1535 | it.effect("identity-only sign-in requests consent on an explicit retry, then rechecks scopes", () => |
| 1536 | provision( |
| 1537 | Effect.gen(function* () { |
| 1538 | const h = yield* makeHarness; |
| 1539 | h.declineSharing(); |
| 1540 | yield* h.signIn; |
| 1541 | yield* h.phase("failed"); |
| 1542 | yield* h.reconnectProfile("oaiapp_test"); |
| 1543 | yield* h.phase("failed"); |
| 1544 | assert.strictEqual(h.authorizationRequests[1]!.searchParams.get("prompt"), "consent"); |
| 1545 | assert.isFalse(h.authorizationRequests[1]!.searchParams.has("force_reconsent")); |
| 1546 | assert.include( |
| 1547 | h.authorizationRequests[1]!.searchParams.get("scope")!, |
| 1548 | "chatgpt.tokens.use.direct", |
| 1549 | ); |
| 1550 | yield* Effect.flip(h.auth.access); |
| 1551 | }), |
| 1552 | ), |
| 1553 | ); |
| 1554 | |
| 1555 | it.effect( |
| 1556 | "controller replacement shares refresh serialization for the same environment session", |
| 1557 | () => |
| 1558 | provision( |
| 1559 | Effect.gen(function* () { |
| 1560 | const h = yield* makeHarness; |
| 1561 | yield* h.signIn; |
| 1562 | yield* h.phase("succeeded"); |
| 1563 | const replacement = yield* h.recreateAuth; |
| 1564 | yield* h.seedExpired; |
| 1565 | const records = yield* Effect.all([h.auth.access, replacement.access], { |
| 1566 | concurrency: "unbounded", |
| 1567 | }); |
| 1568 | assert.strictEqual(h.refreshes(), 1); |
| 1569 | assert.strictEqual(records[0].refreshToken, "refresh-1"); |
| 1570 | assert.deepEqual(records[0], records[1]); |
| 1571 | }), |
| 1572 | ), |
| 1573 | ); |
| 1574 | |
| 1575 | it.effect( |
| 1576 | "old localhost registrations retain their profile while a new client gets its own identity", |
| 1577 | () => |
| 1578 | provision( |
| 1579 | Effect.gen(function* () { |
| 1580 | const bytes = new Map<string, Uint8Array>(); |
| 1581 | const h = yield* makeHarnessFor(instanceId, bytes); |
| 1582 | yield* h.signIn; |
| 1583 | yield* h.phase("succeeded"); |
| 1584 | for (const [key, value] of bytes) { |
| 1585 | const record = JSON.parse(new TextDecoder().decode(value)); |
| 1586 | if (record.profiles) |
| 1587 | bytes.set( |
| 1588 | key, |
| 1589 | new TextEncoder().encode( |
| 1590 | JSON.stringify({ |
| 1591 | ...record, |
| 1592 | profiles: record.profiles.map((profile: { redirectUri: string }) => ({ |
| 1593 | ...profile, |
| 1594 | redirectUri: profile.redirectUri.replace("127.0.0.1", "localhost"), |
| 1595 | })), |
| 1596 | }), |
| 1597 | ), |
| 1598 | ); |
| 1599 | } |
| 1600 | h.setCallbackClientId("oaiapp_migrated"); |
| 1601 | h.setIdentity("new-client-subject", "hidden@example.test"); |
| 1602 | yield* h.signIn; |
| 1603 | yield* h.phase("succeeded"); |
| 1604 | assert.strictEqual( |
| 1605 | h.authorizationRequests[1]!.searchParams.get("client_id"), |
| 1606 | "dynamic_agent_client", |
| 1607 | ); |
| 1608 | assert.strictEqual( |
| 1609 | new URL(h.authorizationRequests[1]!.searchParams.get("redirect_uri")!).hostname, |
| 1610 | "127.0.0.1", |
| 1611 | ); |
| 1612 | assert.strictEqual(Option.getOrThrow(yield* h.auth.read).subject, "new-client-subject"); |
| 1613 | const profiles = (yield* h.phase("succeeded")).methods!; |
| 1614 | assert.isTrue(profiles.some((profile) => profile.id === "chatgpt-profile:oaiapp_test")); |
| 1615 | assert.isTrue(profiles.some((profile) => profile.id === "chatgpt-profile:oaiapp_migrated")); |
| 1616 | yield* h.signIn; |
| 1617 | yield* h.phase("succeeded"); |
| 1618 | assert.strictEqual( |
| 1619 | h.authorizationRequests[2]!.searchParams.get("client_id"), |
| 1620 | "oaiapp_migrated", |
| 1621 | ); |
| 1622 | assert.strictEqual( |
| 1623 | h.authorizationRequests[2]!.searchParams.get("login_hint"), |
| 1624 | "hidden@example.test", |
| 1625 | ); |
| 1626 | assert.isTrue(h.authorizationRequests[2]!.searchParams.has("id_token_hint")); |
| 1627 | }), |
| 1628 | ), |
| 1629 | ); |
| 1630 | |
| 1631 | it.effect("keeps the active connection first when another profile declines sharing", () => |
| 1632 | provision( |
| 1633 | Effect.gen(function* () { |
| 1634 | const h = yield* makeHarness; |
| 1635 | yield* h.signIn; |
| 1636 | yield* h.phase("succeeded"); |
| 1637 | h.setCallbackClientId("oaiapp_identity_only"); |
| 1638 | h.setIdentity("other-user", "other@example.test"); |
| 1639 | h.declineSharing(); |
| 1640 | yield* h.changeAccount; |
| 1641 | const state = yield* h.phase("failed"); |
| 1642 | assert.strictEqual(Option.getOrThrow(yield* h.auth.read).clientId, "oaiapp_test"); |
| 1643 | const profiles = state.methods!.filter((method) => method.id.startsWith("chatgpt-profile:")); |
| 1644 | assert.strictEqual(profiles[0]!.id, "chatgpt-profile:oaiapp_test"); |
| 1645 | assert.include(profiles[0]!.name, "Connection 1"); |
| 1646 | assert.include(profiles[1]!.name, "Connection 2"); |
| 1647 | }), |
| 1648 | ), |
| 1649 | ); |
| 1650 | |
| 1651 | it.effect("primary completes OAuth and destination imports and owns the refresh session", () => |
| 1652 | provision( |
| 1653 | Effect.gen(function* () { |
| 1654 | const h = yield* makeHarness; |
| 1655 | const destination = yield* h.destination; |
| 1656 | const before = h.bytes.size; |
| 1657 | const waiting = yield* Deferred.make<{ authorizationUrl: string | null }>(); |
| 1658 | const transferred = yield* h.handoff(null).pipe( |
| 1659 | Stream.tap((state) => |
| 1660 | state.phase === "auth" && state.state.phase === "waiting" |
| 1661 | ? Deferred.succeed(waiting, state.state) |
| 1662 | : Effect.void, |
| 1663 | ), |
| 1664 | Stream.filter((state) => state.phase === "finished"), |
| 1665 | Stream.runHead, |
| 1666 | Effect.map(Option.getOrThrow), |
| 1667 | Effect.forkChild, |
| 1668 | ); |
| 1669 | yield* h.finishCallback(yield* Deferred.await(waiting)); |
| 1670 | const result = yield* Fiber.join(transferred); |
| 1671 | assert.strictEqual(result.phase, "finished"); |
| 1672 | if (result.phase !== "finished") return; |
| 1673 | assert.strictEqual(h.bytes.size, before); |
| 1674 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 1675 | let stopped = false; |
| 1676 | const imported = yield* destination.auth.controller.importProfile!( |
| 1677 | result.profile, |
| 1678 | Effect.sync(() => { |
| 1679 | stopped = true; |
| 1680 | }), |
| 1681 | ); |
| 1682 | assert.isTrue(stopped); |
| 1683 | assert.strictEqual(imported.phase, "succeeded"); |
| 1684 | const saved = Option.getOrThrow(yield* destination.auth.read); |
| 1685 | assert.strictEqual(saved.clientId, result.profile.registration.clientId); |
| 1686 | assert.strictEqual(saved.refreshToken, "initial-refresh"); |
| 1687 | const reconnect = yield* destination.auth.controller.reconnectProfile!("chatgpt"); |
| 1688 | assert.strictEqual(reconnect?.idTokenHint, result.profile.credentials.idToken); |
| 1689 | assert.isFalse("refreshToken" in reconnect!); |
| 1690 | const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId).pipe( |
| 1691 | Effect.provideService( |
| 1692 | ServerSecretStore.ServerSecretStore, |
| 1693 | ServerSecretStore.ServerSecretStore.of({ |
| 1694 | get: (name) => Effect.sync(() => Option.fromUndefinedOr(destination.bytes.get(name))), |
| 1695 | set: (name, value) => |
| 1696 | Effect.sync(() => { |
| 1697 | destination.bytes.set(name, value); |
| 1698 | }), |
| 1699 | remove: () => Effect.void, |
| 1700 | create: () => Effect.die("unused"), |
| 1701 | getOrCreateRandom: () => Effect.die("unused"), |
| 1702 | }), |
| 1703 | ), |
| 1704 | ); |
| 1705 | yield* store.set(new TextEncoder().encode(JSON.stringify({ ...saved, expiresAt: 0 }))); |
| 1706 | yield* destination.auth.access; |
| 1707 | assert.strictEqual( |
| 1708 | h.exchanges.filter((entry) => entry.get("grant_type") === "authorization_code").length, |
| 1709 | 1, |
| 1710 | ); |
| 1711 | assert.strictEqual( |
| 1712 | h.exchanges.filter((entry) => entry.get("grant_type") === "refresh_token").length, |
| 1713 | 1, |
| 1714 | ); |
| 1715 | }), |
| 1716 | ), |
| 1717 | ); |
| 1718 | |
| 1719 | it.effect("transferred profiles reject mismatched identities without overwriting credentials", () => |
| 1720 | provision( |
| 1721 | Effect.gen(function* () { |
| 1722 | const h = yield* makeHarness; |
| 1723 | yield* h.signIn; |
| 1724 | yield* h.phase("succeeded"); |
| 1725 | const profile = yield* h.auth.exportProfile; |
| 1726 | const destination = yield* h.destination; |
| 1727 | yield* destination.auth.controller.importProfile!(profile, Effect.void); |
| 1728 | const original = Option.getOrThrow(yield* destination.auth.read); |
| 1729 | for (const credentials of [ |
| 1730 | { ...profile.credentials, subject: "wrong-user" }, |
| 1731 | { ...profile.credentials, clientId: "oaiapp_wrong" }, |
| 1732 | { ...profile.credentials, idToken: "not-a-jwt" }, |
| 1733 | { ...profile.credentials, scopes: ["openid"] }, |
| 1734 | { ...profile.credentials, expiresAt: 0 }, |
| 1735 | ]) { |
| 1736 | const result = yield* destination.auth.controller.importProfile!( |
| 1737 | { ...profile, credentials }, |
| 1738 | Effect.void, |
| 1739 | ).pipe(Effect.result); |
| 1740 | assert.strictEqual(result._tag, "Failure"); |
| 1741 | assert.deepEqual(Option.getOrThrow(yield* destination.auth.read), original); |
| 1742 | } |
| 1743 | }), |
| 1744 | ), |
| 1745 | ); |
| 1746 | |
| 1747 | it.effect("primary handoff reuses the selected registration and ID token hint", () => |
| 1748 | provision( |
| 1749 | Effect.gen(function* () { |
| 1750 | const h = yield* makeHarness; |
| 1751 | yield* h.signIn; |
| 1752 | yield* h.phase("succeeded"); |
| 1753 | const profile = yield* h.auth.controller.reconnectProfile!("chatgpt"); |
| 1754 | const waiting = yield* Deferred.make<{ authorizationUrl: string | null }>(); |
| 1755 | const resultFiber = yield* h.handoff(profile).pipe( |
| 1756 | Stream.tap((state) => |
| 1757 | state.phase === "auth" && state.state.phase === "waiting" |
| 1758 | ? Deferred.succeed(waiting, state.state) |
| 1759 | : Effect.void, |
| 1760 | ), |
| 1761 | Stream.runCollect, |
| 1762 | Effect.forkChild, |
| 1763 | ); |
| 1764 | const state = yield* Deferred.await(waiting); |
| 1765 | const url = new URL(state.authorizationUrl!); |
| 1766 | assert.strictEqual(url.searchParams.get("client_id"), profile!.clientId); |
| 1767 | assert.strictEqual(url.searchParams.get("id_token_hint"), profile!.idTokenHint); |
| 1768 | assert.strictEqual(url.searchParams.get("login_hint"), profile!.email); |
| 1769 | assert.strictEqual(url.searchParams.get("ext_agent_host_id"), `urn:uuid:${h.environmentId}`); |
| 1770 | yield* h.finishCallback(state); |
| 1771 | const results = yield* Fiber.join(resultFiber); |
| 1772 | assert.strictEqual(results.at(-1)?.phase, "finished"); |
| 1773 | }), |
| 1774 | ), |
| 1775 | ); |
| 1776 | |
| 1777 | it.effect( |
| 1778 | "cancelling primary handoff closes its callback listener without saving credentials", |
| 1779 | () => |
| 1780 | provision( |
| 1781 | Effect.gen(function* () { |
| 1782 | const h = yield* makeHarness; |
| 1783 | const waiting = yield* Deferred.make<{ authorizationUrl: string | null }>(); |
| 1784 | const flow = yield* h.handoff(null).pipe( |
| 1785 | Stream.tap((state) => |
| 1786 | state.phase === "auth" && state.state.phase === "waiting" |
| 1787 | ? Deferred.succeed(waiting, state.state) |
| 1788 | : Effect.void, |
| 1789 | ), |
| 1790 | Stream.runDrain, |
| 1791 | Effect.forkChild, |
| 1792 | ); |
| 1793 | const state = yield* Deferred.await(waiting); |
| 1794 | yield* Fiber.interrupt(flow); |
| 1795 | const callback = new URL( |
| 1796 | new URL(state.authorizationUrl!).searchParams.get("redirect_uri")!, |
| 1797 | ); |
| 1798 | const request = yield* Effect.tryPromise(() => fetch(callback)).pipe(Effect.result); |
| 1799 | assert.strictEqual(request._tag, "Failure"); |
| 1800 | assert.isTrue(Option.isNone(yield* h.auth.read)); |
| 1801 | }), |
| 1802 | ), |
| 1803 | ); |
| 1804 | |
| 1805 | it.effect("expires sign-in when it reaches its deadline", () => |
| 1806 | provision( |
| 1807 | Effect.gen(function* () { |
| 1808 | const h = yield* makeHarness; |
| 1809 | yield* h.startRemote(); |
| 1810 | yield* TestClock.adjust(300_001); |
| 1811 | assert.include((yield* h.phase("failed")).message ?? "", "expired"); |
| 1812 | }), |
| 1813 | ), |
| 1814 | ); |