1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 ProviderInstanceId,
5 ProviderSetupError,
6 type ProviderAuthInteraction,
7 type ProviderAuthResponse,
8 type ProviderAuthState,
9} from "@t3tools/contracts";
10import * as Fiber from "effect/Fiber";
11import * as Exit from "effect/Exit";
12import * as Scope from "effect/Scope";
13import * as Deferred from "effect/Deferred";
14import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";
16import * as Stream from "effect/Stream";
17import * as TestClock from "effect/testing/TestClock";
18
19import * as ProviderAuthFlow from "./providerAuthFlow.ts";
20
21const instanceId = ProviderInstanceId.make("auth-flow-test");
22const method = { id: "browser", name: "Browser", description: null, type: "agent" as const };
23
24it.effect("distinguishes pending method discovery from an agent with no sign-in methods", () =>
25 Effect.gen(function* () {
26 const discovered = yield* Deferred.make<ReadonlyArray<typeof method>>();
27 const controller = yield* ProviderAuthFlow.make({
28 instanceId,
29 credentialBinding: { owner: "provider", key: "shared-agent" },
30 methods: Deferred.await(discovered),
31 authenticate: () => Effect.void,
32 logout: Effect.void,
33 });
34 const pending = yield* controller
35 .subscribe("owner")
36 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
37 assert.isUndefined(pending.methods);
38 yield* Deferred.succeed(discovered, []);
39 const ready = yield* controller.subscribe("owner").pipe(
40 Stream.filter((state) => state.methods !== undefined),
41 Stream.runHead,
42 Effect.map(Option.getOrThrow),
43 );
44 assert.deepEqual(ready.methods, []);
45 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
46);
47
48const makeHarness = Effect.gen(function* () {
49 const approved = yield* Deferred.make<void>();
50 const verified = yield* Deferred.make<void>();
51 const started = yield* Deferred.make<void>();
52 let attempts = 0;
53 const controller = yield* ProviderAuthFlow.make({
54 instanceId,
55 credentialBinding: { owner: "provider", key: "shared-agent" },
56 methods: Effect.succeed([method]),
57 authenticate: (_, context) =>
58 Effect.gen(function* () {
59 attempts++;
60 yield* context.setInteraction(
61 {
62 type: "browser",
63 id: "consent",
64 url: "https://example.com/login",
65 requiresConsent: true,
66 },
67 (response) =>
68 response.type === "browser" && response.action === "accept"
69 ? Deferred.succeed(approved, undefined).pipe(Effect.asVoid)
70 : Effect.void,
71 );
72 yield* Deferred.succeed(started, undefined);
73 yield* Deferred.await(approved);
74 yield* context.verifying;
75 yield* Deferred.await(verified);
76 }),
77 logout: Effect.void,
78 });
79 const phase = (phase: ProviderAuthState["phase"], owner = "owner") =>
80 controller.subscribe(owner).pipe(
81 Stream.filter((state) => state.phase === phase),
82 Stream.runHead,
83 Effect.map(Option.getOrThrow),
84 );
85 return { controller, phase, started, verified, attempts: () => attempts };
86});
87
88it.effect("requires owner consent and provider verification before success", () =>
89 Effect.gen(function* () {
90 const { controller, phase, started, verified, attempts } = yield* makeHarness;
91 const state = yield* controller.start("owner");
92 yield* Deferred.await(started);
93 const response = {
94 instanceId,
95 flowId: state.flowId!,
96 interactionId: "consent",
97 response: { type: "browser" as const, action: "accept" as const },
98 };
99 const other = yield* phase("waiting", "other");
100 assert.isNull(other.interaction);
101 assert.isNull(other.flowId);
102 assert.isTrue(
103 (yield* controller.respond!("other", response).pipe(Effect.result))._tag === "Failure",
104 );
105 assert.isTrue(
106 (yield* controller.respond!("owner", { ...response, interactionId: "stale" }).pipe(
107 Effect.result,
108 ))._tag === "Failure",
109 );
110 yield* controller.start("owner");
111 assert.strictEqual(attempts(), 1);
112 yield* controller.respond!("owner", response);
113 yield* phase("verifying");
114 yield* Deferred.succeed(verified, undefined);
115 assert.strictEqual((yield* phase("succeeded")).phase, "succeeded");
116 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
117);
118
119it.effect("cancellation rejects late consent and permits another login", () =>
120 Effect.gen(function* () {
121 const { controller, started, phase } = yield* makeHarness;
122 const state = yield* controller.start("owner");
123 yield* Deferred.await(started);
124 yield* controller.cancel("owner", state.flowId!);
125 assert.strictEqual((yield* phase("cancelled")).phase, "cancelled");
126 assert.isTrue(
127 (yield* controller.respond!("owner", {
128 instanceId,
129 flowId: state.flowId!,
130 interactionId: "consent",
131 response: { type: "browser", action: "accept" },
132 }).pipe(Effect.result))._tag === "Failure",
133 );
134 const next = yield* controller.start("other");
135 assert.notStrictEqual(next.flowId, state.flowId);
136 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
137);
138
139it.effect("expires pending login and rejects its response", () =>
140 Effect.gen(function* () {
141 const { controller, started, phase } = yield* makeHarness;
142 const state = yield* controller.start("owner");
143 yield* Deferred.await(started);
144 yield* TestClock.adjust(300_001);
145 assert.include((yield* phase("failed")).message ?? "", "expired");
146 assert.isTrue(
147 (yield* controller.respond!("owner", {
148 instanceId,
149 flowId: state.flowId!,
150 interactionId: "consent",
151 response: { type: "browser", action: "accept" },
152 }).pipe(Effect.result))._tag === "Failure",
153 );
154 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
155);
156
157it.effect("closes admitted provider processes and blocks new ones while login is pending", () =>
158 Effect.gen(function* () {
159 const { controller, started } = yield* makeHarness;
160 let closed = false;
161 yield* controller.withAccess!(
162 Effect.addFinalizer(() =>
163 Effect.sync(() => {
164 closed = true;
165 }),
166 ),
167 );
168 yield* controller.start("owner");
169 yield* Deferred.await(started);
170 assert.isTrue(closed);
171 assert.isTrue(
172 (yield* controller.withAccess!(Effect.void).pipe(Effect.result))._tag === "Failure",
173 );
174 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
175);
176
177it.effect("shared credential invalidation closes admitted processes before permitting reuse", () =>
178 Effect.gen(function* () {
179 const { controller } = yield* makeHarness;
180 let closed = false;
181 yield* controller.withAccess!(
182 Effect.addFinalizer(() =>
183 Effect.sync(() => {
184 closed = true;
185 }),
186 ),
187 );
188 yield* controller.invalidate!;
189 assert.isTrue(closed);
190 assert.strictEqual(yield* controller.withAccess!(Effect.succeed("new process")), "new process");
191 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
192);
193
194it.effect.each([
195 {
196 interaction: { type: "terminal", id: "terminal", output: "Log in" },
197 response: { type: "terminal", data: "input", size: { cols: 80, rows: 24 } },
198 },
199 {
200 interaction: {
201 type: "credentials",
202 id: "credentials",
203 fields: [{ name: "token", label: "Token", secret: true }],
204 },
205 response: { type: "credentials", values: { token: "private-token" } },
206 },
207] satisfies ReadonlyArray<{
208 interaction: ProviderAuthInteraction;
209 response: ProviderAuthResponse;
210}>)(
211 "keeps $interaction.type responses private and waits for adapter verification",
212 ({ interaction, response }) =>
213 Effect.gen(function* () {
214 const received = yield* Deferred.make<ProviderAuthResponse>();
215 const controller = yield* ProviderAuthFlow.make({
216 instanceId,
217 credentialBinding: { owner: "t3", key: "binding" },
218 methods: Effect.succeed([method]),
219 authenticate: (_, context) =>
220 Effect.gen(function* () {
221 yield* context.setInteraction(interaction, (response) =>
222 Deferred.succeed(received, response).pipe(Effect.asVoid),
223 );
224 yield* Deferred.await(received);
225 yield* context.verifying;
226 return yield* Effect.never;
227 }),
228 logout: Effect.void,
229 });
230 const state = yield* controller.start("owner");
231 const pending = yield* controller.subscribe("owner").pipe(
232 Stream.filter((state) => state.phase === "waiting"),
233 Stream.runHead,
234 Effect.map(Option.getOrThrow),
235 );
236 assert.deepStrictEqual(pending.interaction, interaction);
237 const hidden = yield* controller
238 .subscribe("other")
239 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
240 assert.isNull(hidden.interaction);
241 yield* controller.respond!("owner", {
242 instanceId,
243 flowId: state.flowId!,
244 interactionId: interaction.id,
245 response,
246 });
247 assert.deepStrictEqual(yield* Deferred.await(received), response);
248 const verifying = yield* controller.subscribe("owner").pipe(
249 Stream.filter((state) => state.phase === "verifying"),
250 Stream.runHead,
251 Effect.map(Option.getOrThrow),
252 );
253 assert.isNull(verifying.interaction);
254 assert.notInclude(verifying.message ?? "", "private-token");
255 yield* controller.cancel("owner", state.flowId!);
256 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
257);
258
259it.effect("shows a device code only to its owner without treating it as authenticated", () =>
260 Effect.gen(function* () {
261 const controller = yield* ProviderAuthFlow.make({
262 instanceId,
263 credentialBinding: { owner: "provider", key: "device" },
264 methods: Effect.succeed([method]),
265 authenticate: (_, context) =>
266 context
267 .setInteraction({
268 type: "deviceCode",
269 id: "code",
270 url: "https://example.com/device",
271 userCode: "ABCD-EFGH",
272 })
273 .pipe(Effect.andThen(Effect.never)),
274 logout: Effect.void,
275 });
276 const start = yield* controller.start("owner");
277 const waiting = yield* controller.subscribe("owner").pipe(
278 Stream.filter((state) => state.phase === "waiting"),
279 Stream.runHead,
280 Effect.map(Option.getOrThrow),
281 );
282 assert.strictEqual(waiting.interaction?.type, "deviceCode");
283 assert.strictEqual(waiting.authorizationUrl, "https://example.com/device");
284 const other = yield* controller
285 .subscribe("other")
286 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
287 assert.isNull(other.interaction);
288 assert.isNull(other.authorizationUrl);
289 yield* controller.cancel("owner", start.flowId!);
290 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
291);
292
293it.effect.each([
294 {
295 failure: Effect.fail(
296 new ProviderSetupError({
297 instanceId,
298 operation: "authenticate",
299 detail: "This account cannot sign in. Choose another account.",
300 }),
301 ),
302 message: "This account cannot sign in. Choose another account.",
303 },
304 {
305 failure: Effect.die(new Error("private-token in native diagnostics")),
306 message: "Sign-in failed. Start again.",
307 },
308])("publishes only safe authentication failure text %#", ({ failure, message }) =>
309 Effect.gen(function* () {
310 const controller = yield* ProviderAuthFlow.make({
311 instanceId,
312 credentialBinding: { owner: "t3", key: "failure" },
313 methods: Effect.succeed([method]),
314 authenticate: () => failure,
315 logout: Effect.void,
316 });
317 yield* controller.start("owner");
318 const failed = yield* controller.subscribe("owner").pipe(
319 Stream.filter((state) => state.phase === "failed"),
320 Stream.runHead,
321 Effect.map(Option.getOrThrow),
322 );
323 assert.strictEqual(failed.message, message);
324 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
325);
326
327const makeBlockingResponseHarness = Effect.gen(function* () {
328 const entered = yield* Deferred.make<void>();
329 const cleanupStarted = yield* Deferred.make<void>();
330 const cleanupReleased = yield* Deferred.make<void>();
331 const cleanupFinished = yield* Deferred.make<void>();
332 const authenticationFinished = yield* Deferred.make<void>();
333 let responses = 0;
334 const controller = yield* ProviderAuthFlow.make({
335 instanceId,
336 credentialBinding: { owner: "t3", key: "blocked-response" },
337 methods: Effect.succeed([method]),
338 authenticate: (_, context) =>
339 Effect.gen(function* () {
340 yield* context.setInteraction(
341 {
342 type: "browser",
343 id: "blocked",
344 url: "https://example.com/login",
345 requiresConsent: true,
346 },
347 () =>
348 Effect.gen(function* () {
349 responses++;
350 yield* Deferred.succeed(entered, undefined);
351 return yield* Effect.never;
352 }).pipe(
353 Effect.ensuring(
354 Effect.gen(function* () {
355 yield* Deferred.succeed(cleanupStarted, undefined);
356 yield* Deferred.await(cleanupReleased);
357 yield* Deferred.succeed(cleanupFinished, undefined);
358 }),
359 ),
360 ),
361 );
362 yield* Deferred.await(authenticationFinished);
363 }),
364 logout: Effect.void,
365 });
366 const start = yield* controller.start("owner");
367 yield* controller.subscribe("owner").pipe(
368 Stream.filter((state) => state.phase === "waiting"),
369 Stream.runHead,
370 );
371 const input = {
372 instanceId,
373 flowId: start.flowId!,
374 interactionId: "blocked",
375 response: { type: "browser" as const, action: "accept" as const },
376 };
377 const response = yield* controller.respond!("owner", input).pipe(Effect.exit, Effect.forkChild);
378 yield* Deferred.await(entered);
379 return {
380 controller,
381 input,
382 response,
383 cleanupStarted,
384 cleanupReleased,
385 cleanupFinished,
386 authenticationFinished,
387 responses: () => responses,
388 };
389});
390
391it.effect.each(["cancel", "logout"] as const)(
392 "%s interrupts and awaits a blocked adapter response before admitting another sign-in",
393 (action) =>
394 Effect.gen(function* () {
395 const harness = yield* makeBlockingResponseHarness;
396 const duplicate = yield* Effect.flip(harness.controller.respond!("owner", harness.input));
397 assert.include(duplicate.detail, "already in progress");
398 assert.equal(harness.responses(), 1);
399 const stopping = yield* (
400 action === "cancel"
401 ? harness.controller.cancel("owner", harness.input.flowId)
402 : harness.controller.logout(Effect.void)
403 ).pipe(Effect.forkChild);
404 yield* Deferred.await(harness.cleanupStarted);
405 assert.isTrue(yield* harness.controller.isChangingCredentials!);
406 assert.isUndefined(stopping.pollUnsafe());
407 const premature = yield* Effect.flip(harness.controller.start("other"));
408 assert.include(premature.detail, "in progress");
409 yield* Deferred.succeed(harness.cleanupReleased, undefined);
410 yield* Fiber.join(stopping);
411 assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished));
412 assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response)));
413 assert.isFalse(yield* harness.controller.isChangingCredentials!);
414 assert.notEqual((yield* harness.controller.start("other")).flowId, harness.input.flowId);
415 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
416);
417
418it.effect("timeout interrupts and drains a blocked response before publishing failure", () =>
419 Effect.gen(function* () {
420 const harness = yield* makeBlockingResponseHarness;
421 yield* TestClock.adjust(300_001);
422 yield* Deferred.await(harness.cleanupStarted);
423 assert.isTrue(yield* harness.controller.isChangingCredentials!);
424 const duringCleanup = yield* harness.controller
425 .subscribe("owner")
426 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
427 assert.notEqual(duringCleanup.phase, "failed");
428 yield* Deferred.succeed(harness.cleanupReleased, undefined);
429 const failed = yield* harness.controller.subscribe("owner").pipe(
430 Stream.filter((state) => state.phase === "failed"),
431 Stream.runHead,
432 Effect.map(Option.getOrThrow),
433 );
434 assert.include(failed.message ?? "", "expired");
435 assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished));
436 assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response)));
437 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
438);
439
440it.effect(
441 "successful authentication drains a still-running response before admitting provider access",
442 () =>
443 Effect.gen(function* () {
444 const harness = yield* makeBlockingResponseHarness;
445 yield* Deferred.succeed(harness.authenticationFinished, undefined);
446 yield* Deferred.await(harness.cleanupStarted);
447 const denied = yield* Effect.flip(harness.controller.withAccess!(Effect.void));
448 assert.include(denied.detail, "changing");
449 yield* Deferred.succeed(harness.cleanupReleased, undefined);
450 const succeeded = yield* harness.controller.subscribe("owner").pipe(
451 Stream.filter((state) => state.phase === "succeeded"),
452 Stream.runHead,
453 Effect.map(Option.getOrThrow),
454 );
455 assert.equal(succeeded.phase, "succeeded");
456 assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished));
457 assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response)));
458 yield* harness.controller.withAccess!(Effect.void);
459 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
460);
461
462it.effect("closing the controller scope interrupts and drains its adapter response", () =>
463 Effect.gen(function* () {
464 const scope = yield* Scope.make();
465 const harness = yield* makeBlockingResponseHarness.pipe(
466 Effect.provideService(Scope.Scope, scope),
467 );
468 const closing = yield* Scope.close(scope, Exit.void).pipe(Effect.forkChild);
469 yield* Deferred.await(harness.cleanupStarted);
470 assert.isUndefined(closing.pollUnsafe());
471 yield* Deferred.succeed(harness.cleanupReleased, undefined);
472 yield* Fiber.join(closing);
473 assert.isTrue(yield* Deferred.isDone(harness.cleanupFinished));
474 assert.isTrue(Exit.isFailure(yield* Fiber.join(harness.response)));
475 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
476);
477
478it.effect("a failed method refresh keeps the last discovered methods", () =>
479 Effect.gen(function* () {
480 let fail = false;
481 const controller = yield* ProviderAuthFlow.make({
482 instanceId,
483 credentialBinding: { owner: "provider", key: "shared-agent" },
484 methods: Effect.suspend(() =>
485 fail
486 ? Effect.fail(
487 new ProviderSetupError({ instanceId, operation: "status", detail: "interrupted" }),
488 )
489 : Effect.succeed([method]),
490 ),
491 authenticate: () => Effect.void,
492 logout: Effect.void,
493 });
494 yield* controller.refreshMethods!;
495 fail = true;
496 yield* controller.refreshMethods!;
497 const state = yield* controller
498 .subscribe("owner")
499 .pipe(Stream.runHead, Effect.map(Option.getOrThrow));
500 assert.deepEqual(state.methods, [method]);
501 assert.strictEqual(state.message, "interrupted");
502 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
503);
504
505it.effect("rebuilding a controller leaves sessions it admitted to their owners", () =>
506 Effect.gen(function* () {
507 const scope = yield* Scope.make();
508 const { controller } = yield* makeHarness.pipe(Effect.provideService(Scope.Scope, scope));
509 let closed = false;
510 yield* controller.withAccess!(
511 Effect.addFinalizer(() =>
512 Effect.sync(() => {
513 closed = true;
514 }),
515 ),
516 );
517 yield* Scope.close(scope, Exit.void);
518 assert.isFalse(closed);
519 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
520);
521
522it.effect("profile import stops owned sessions and gates access until credentials are saved", () =>
523 Effect.gen(function* () {
524 const controller = yield* ProviderAuthFlow.make({
525 instanceId,
526 credentialBinding: { owner: "t3", key: "handoff-binding" },
527 methods: Effect.succeed([method]),
528 authenticate: () => Effect.void,
529 logout: Effect.void,
530 });
531 let closed = false;
532 yield* controller.withAccess!(
533 Effect.addFinalizer(() =>
534 Effect.sync(() => {
535 closed = true;
536 }),
537 ),
538 );
539 const writing = yield* Deferred.make<void>();
540 const release = yield* Deferred.make<void>();
541 const imported = yield* controller.adoptCredentials!(
542 Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))),
543 Effect.sync(() => {
544 assert.isTrue(closed);
545 }),
546 ).pipe(Effect.forkChild);
547 yield* Deferred.await(writing);
548 const denied = yield* controller.withAccess!(Effect.succeed("old credential process")).pipe(
549 Effect.result,
550 );
551 assert.strictEqual(denied._tag, "Failure");
552 yield* Deferred.succeed(release, undefined);
553 assert.strictEqual((yield* Fiber.join(imported)).phase, "succeeded");
554 assert.strictEqual(
555 yield* controller.withAccess!(Effect.succeed("new credential process")),
556 "new credential process",
557 );
558 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
559);