Skip to content
Source
Sign inSign up

providerAuthFlow.test.ts

534 lines20.2 KB

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