1// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Local mock OAuth server validates the browser callback boundary.
2import * as NodeHttp from "node:http";
3import * as NodeCrypto from "node:crypto";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { assert, it } from "@effect/vitest";
6import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Stream from "effect/Stream";
11import * as Fiber from "effect/Fiber";
12import * as Deferred from "effect/Deferred";
13import * as TestClock from "effect/testing/TestClock";
14import { subscribeChatGptHandoff } from "./CodexChatGptHandoff.ts";
15import { FetchHttpClient } from "effect/http";
16import { exportJWK, generateKeyPair, SignJWT } from "jose";
17import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
18import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
19import * as ProviderCredentialStore from "./ProviderCredentialStore.ts";
20import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts";
21
22const 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};
29const environmentIds = new WeakMap<Map<string, Uint8Array>, EnvironmentId>();
30const instanceId = ProviderInstanceId.make("managed-codex-test");
31const 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});
369const makeHarness = makeHarnessFor(instanceId);
370const 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 );
375it.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);
410it.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);
438it.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);
465it.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
498it.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);
521it.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
542it.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);
570it.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);
670it.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);
699it.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);
713it.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);
731it.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);
754it.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);
765it.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);
779it.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
795it.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
815it.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);
875it.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
907it.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
930it.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);
943it.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);
958it.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
980it.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
999it.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
1032it.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
1069it.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
1102it.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
1133it.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
1153it.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
1190it.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
1228it.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
1279it.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
1297it.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
1343it.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
1370it.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
1402it.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
1437it.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
1465it.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
1482it.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
1514it.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
1535it.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
1555it.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
1575it.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
1631it.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
1651it.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
1719it.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
1747it.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
1777it.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
1805it.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);