1import {
2 ProviderSetupError,
3 type ProviderAuthInteraction,
4 type ProviderAuthMethod,
5 type ProviderAuthResponse,
6 type ProviderAuthState,
7 type ProviderInstanceId,
8} from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12import * as Clock from "effect/Clock";
13import * as SecureRandom from "effect/crypto/SecureRandom";
14import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";
16import * as Deferred from "effect/Deferred";
17import * as Exit from "effect/Exit";
18import * as Fiber from "effect/Fiber";
19import * as Scope from "effect/Scope";
20import * as Semaphore from "effect/Semaphore";
21import * as Stream from "effect/Stream";
22import * as SubscriptionRef from "effect/SubscriptionRef";
23
24import type * as ProviderAuth from "./auth.ts";
25
26export interface ProviderAuthFlowContext {
27 readonly flowId: string;
28 readonly expiresAt: number;
29 readonly returnUrl?: string;
30 readonly callbackMode?: "server" | "client";
31 readonly setInteraction: (
32 interaction: ProviderAuthInteraction,
33 respond?: (response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>,
34 complete?: (callbackUrl: string) => Effect.Effect<void, ProviderSetupError>,
35 ) => Effect.Effect<void>;
36 readonly verifying: Effect.Effect<void>;
37}
38
39const isSetupError = Schema.is(ProviderSetupError);
40
41const failureMessage = (cause: Cause.Cause<ProviderSetupError>) => {
42 const error = Cause.findErrorOption(cause);
43 return Option.isSome(error) && isSetupError(error.value)
44 ? error.value.detail
45 : "Sign-in failed. Start again.";
46};
47
48interface Flow {
49 readonly id: string;
50 readonly owner: string;
51 readonly expiresAt: number;
52 complete?: ((callbackUrl: string) => Effect.Effect<void, ProviderSetupError>) | undefined;
53 fiber?: Fiber.Fiber<void>;
54 responseFiber?: Fiber.Fiber<void, ProviderSetupError>;
55 respond:
56 | ((response: ProviderAuthResponse) => Effect.Effect<void, ProviderSetupError>)
57 | undefined;
58}
59
60export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: {
61 readonly instanceId: ProviderInstanceId;
62 readonly credentialBinding: NonNullable<ProviderAuth.ProviderAuthController["credentialBinding"]>;
63 readonly methods: Effect.Effect<ReadonlyArray<ProviderAuthMethod>, ProviderSetupError>;
64 readonly defaultMethodId?: string;
65 readonly refreshMethodsAfterAuth?: boolean;
66 /** Fail with ProviderSetupError containing safe text for the user, never native token data. */
67 readonly authenticate: (
68 methodId: string,
69 context: ProviderAuthFlowContext,
70 ) => Effect.Effect<void, ProviderSetupError, Scope.Scope>;
71 readonly logout: Effect.Effect<void | string, ProviderSetupError>;
72 readonly removeMethod?: (methodId: string) => Effect.Effect<void, ProviderSetupError>;
73 readonly timeoutMs?: number;
74}) {
75 const scope = yield* Scope.Scope;
76 const random = yield* SecureRandom.SecureRandom;
77 const lock = yield* Semaphore.make(1);
78 const timeoutMs = options.timeoutMs ?? 300_000;
79 const empty: ProviderAuthState = {
80 instanceId: options.instanceId,
81 phase: "idle",
82 flowId: null,
83 authorizationUrl: null,
84 expiresAt: null,
85 message: null,
86 interaction: null,
87 credentialOwner: options.credentialBinding.owner,
88 };
89 const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: empty });
90 let active: Flow | undefined;
91 let operation: "idle" | "auth" | "stopping" | "closed" = "idle";
92 const sessions = new Set<Scope.Closeable>();
93 const stopOwnedSessions = Effect.suspend(() =>
94 Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), {
95 discard: true,
96 concurrency: "unbounded",
97 }),
98 );
99 const publish = (flow: Flow, patch: Partial<ProviderAuthState>) =>
100 Effect.suspend(() =>
101 active === flow
102 ? SubscriptionRef.update(snapshot, (current) => ({
103 owner: flow.owner,
104 state: { ...current.state, ...patch },
105 }))
106 : Effect.void,
107 );
108
109 const requireFlow = Effect.fnUntraced(function* (owner: string, id: string) {
110 if (
111 operation !== "auth" ||
112 !active ||
113 active.owner !== owner ||
114 active.id !== id ||
115 (yield* Clock.currentTimeMillis) >= active.expiresAt
116 ) {
117 return yield* new ProviderSetupError({
118 instanceId: options.instanceId,
119 operation: "respond",
120 detail: "This sign-in is no longer active in this client.",
121 });
122 }
123 return active;
124 });
125
126 const refreshMethods = options.methods.pipe(
127 Effect.flatMap((methods) =>
128 SubscriptionRef.update(snapshot, (current) => ({
129 ...current,
130 state: { ...current.state, methods },
131 })),
132 ),
133 Effect.catch((error) =>
134 SubscriptionRef.update(snapshot, (current) => ({
135 ...current,
136 state: { ...current.state, methods: current.state.methods ?? [], message: error.detail },
137 })),
138 ),
139 );
140 yield* refreshMethods.pipe(Effect.forkIn(scope));
141
142 const controller: ProviderAuth.ProviderAuthController = {
143 credentialBinding: options.credentialBinding,
144 adoptCredentials: Effect.fn("providerAuthFlow.adoptCredentials")(function* (
145 update,
146 stopSessions,
147 ) {
148 yield* lock.withPermit(
149 Effect.gen(function* () {
150 if (operation !== "idle")
151 return yield* new ProviderSetupError({
152 instanceId: options.instanceId,
153 operation: "import",
154 detail: "Finish or cancel the existing sign-in first.",
155 });
156 operation = "stopping";
157 }),
158 );
159 const result = yield* Effect.gen(function* () {
160 yield* stopOwnedSessions;
161 yield* stopSessions;
162 yield* update;
163 yield* refreshMethods;
164 }).pipe(Effect.exit);
165 const state: ProviderAuthState = {
166 ...empty,
167 methods: snapshot.value.state.methods ?? [],
168 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
169 message: Exit.isSuccess(result) ? null : failureMessage(result.cause),
170 };
171 yield* lock.withPermit(
172 Effect.gen(function* () {
173 yield* SubscriptionRef.set(snapshot, { owner: null, state });
174 operation = "idle";
175 }),
176 );
177 if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause);
178 return state;
179 }, Effect.uninterruptible),
180 refreshMethods,
181 ...(options.removeMethod
182 ? {
183 removeMethod: Effect.fn("providerAuthFlow.removeMethod")(function* (methodId: string) {
184 return yield* lock.withPermit(
185 Effect.gen(function* () {
186 if (operation !== "idle")
187 return yield* new ProviderSetupError({
188 instanceId: options.instanceId,
189 operation: "removeMethod",
190 detail: "Finish or cancel the existing sign-in first.",
191 });
192 yield* options.removeMethod!(methodId);
193 const methods = yield* options.methods;
194 const state = { ...empty, methods };
195 yield* SubscriptionRef.set(snapshot, { owner: null, state });
196 return state;
197 }),
198 );
199 }, Effect.uninterruptible),
200 }
201 : {}),
202 invalidate: lock.withPermit(
203 Effect.gen(function* () {
204 if (operation !== "idle") return;
205 yield* stopOwnedSessions;
206 yield* SubscriptionRef.set(snapshot, {
207 owner: null,
208 state: {
209 ...empty,
210 methods: snapshot.value.state.methods ?? [],
211 message: "This provider's shared sign-in changed.",
212 },
213 });
214 }),
215 ),
216 isChangingCredentials: Effect.sync(() => operation !== "idle"),
217 withAccess: (task) =>
218 Effect.uninterruptibleMask((restore) =>
219 Effect.gen(function* () {
220 const parent = yield* Scope.Scope;
221 const child = yield* lock.withPermit(
222 Effect.gen(function* () {
223 if (operation !== "idle")
224 return yield* new ProviderSetupError({
225 instanceId: options.instanceId,
226 operation: "session",
227 detail: "Provider sign-in is changing. Try again after it finishes.",
228 });
229 const child = yield* Scope.make();
230 sessions.add(child);
231 yield* Scope.addFinalizer(
232 child,
233 Effect.sync(() => {
234 sessions.delete(child);
235 }),
236 );
237 yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void));
238 return child;
239 }),
240 );
241 const fiber = yield* restore(task).pipe(
242 Effect.provideService(Scope.Scope, child),
243 Effect.forkIn(child),
244 );
245 return yield* restore(Fiber.await(fiber)).pipe(
246 Effect.flatten,
247 Effect.onExit((result) =>
248 Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void,
249 ),
250 );
251 }),
252 ),
253 start: (owner, stopSessions = Effect.void, selectedMethodId, returnUrl, callbackMode) =>
254 lock.withPermit(
255 Effect.gen(function* () {
256 if (operation === "auth" && active?.owner === owner) return snapshot.value.state;
257 if (operation !== "idle")
258 return yield* new ProviderSetupError({
259 instanceId: options.instanceId,
260 operation: "start",
261 detail: "Provider setup is already in progress.",
262 });
263 const id = yield* random.uuidV4().pipe(
264 Effect.mapError(
265 () =>
266 new ProviderSetupError({
267 instanceId: options.instanceId,
268 operation: "start",
269 detail: "Could not start sign-in. Try again.",
270 }),
271 ),
272 );
273 const flow: Flow = {
274 id,
275 owner,
276 expiresAt: (yield* Clock.currentTimeMillis) + timeoutMs,
277 respond: undefined,
278 };
279 active = flow;
280 operation = "auth";
281 const state: ProviderAuthState = {
282 ...empty,
283 methods: snapshot.value.state.methods ?? [],
284 phase: "starting",
285 flowId: id,
286 expiresAt: DateTime.formatIso(DateTime.makeUnsafe(flow.expiresAt)),
287 message: "Starting sign-in.",
288 };
289 yield* SubscriptionRef.set(snapshot, { owner, state });
290 flow.fiber = yield* Effect.gen(function* () {
291 const methods = yield* options.methods;
292 yield* publish(flow, { methods });
293 const methodId = selectedMethodId ?? options.defaultMethodId ?? methods[0]?.id;
294 if (!methodId || !methods.some((method) => method.id === methodId))
295 return yield* new ProviderSetupError({
296 instanceId: options.instanceId,
297 operation: "start",
298 detail: "The provider did not advertise this sign-in method.",
299 });
300 yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions));
301 yield* options.authenticate(methodId, {
302 flowId: id,
303 expiresAt: flow.expiresAt,
304 ...(returnUrl ? { returnUrl } : {}),
305 ...(callbackMode ? { callbackMode } : {}),
306 // Terminal sign-in updates this for every output chunk within the auth operation.
307 setInteraction: Effect.fnUntraced(function* (interaction, respond, complete) {
308 if (active !== flow) return;
309 flow.respond = respond;
310 flow.complete = complete;
311 yield* publish(flow, {
312 phase: "waiting",
313 interaction,
314 authorizationUrl:
315 interaction.type === "browser" || interaction.type === "deviceCode"
316 ? interaction.url
317 : null,
318 message: "Complete sign-in to continue.",
319 });
320 }),
321 verifying: Effect.gen(function* () {
322 flow.respond = undefined;
323 flow.complete = undefined;
324 yield* publish(flow, {
325 phase: "verifying",
326 interaction: null,
327 authorizationUrl: null,
328 message: "Checking provider sign-in.",
329 });
330 }),
331 });
332 }).pipe(
333 Effect.scoped,
334 Effect.timeoutOrElse({
335 duration: timeoutMs,
336 orElse: () =>
337 Effect.fail(
338 new ProviderSetupError({
339 instanceId: options.instanceId,
340 operation: "start",
341 detail: "Sign-in expired. Start again.",
342 }),
343 ),
344 }),
345 Effect.exit,
346 Effect.flatMap((result) =>
347 Effect.gen(function* () {
348 const finishing = yield* lock.withPermit(
349 Effect.sync(() => {
350 if (active !== flow) return false;
351 operation = "stopping";
352 return true;
353 }),
354 );
355 if (!finishing) return;
356 if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
357 yield* lock.withPermit(
358 Effect.gen(function* () {
359 if (active !== flow) return;
360 if (options.refreshMethodsAfterAuth) yield* refreshMethods;
361 yield* publish(flow, {
362 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
363 interaction: null,
364 authorizationUrl: null,
365 expiresAt: null,
366 message: Exit.isSuccess(result)
367 ? "Sign-in complete."
368 : failureMessage(result.cause),
369 });
370 active = undefined;
371 operation = "idle";
372 }),
373 );
374 }),
375 ),
376 Effect.interruptible,
377 Effect.forkIn(scope),
378 );
379 return state;
380 }).pipe(Effect.uninterruptible),
381 ),
382 respond: (owner, input) =>
383 Effect.uninterruptibleMask((restore) =>
384 Effect.gen(function* () {
385 const { flow, fiber, ready } = yield* lock.withPermit(
386 Effect.gen(function* () {
387 const flow = yield* requireFlow(owner, input.flowId);
388 const interaction = snapshot.value.state.interaction;
389 if (
390 !interaction ||
391 interaction.id !== input.interactionId ||
392 interaction.type !== input.response.type ||
393 !flow.respond
394 )
395 return yield* new ProviderSetupError({
396 instanceId: options.instanceId,
397 operation: "respond",
398 detail: "This sign-in interaction is no longer available.",
399 });
400 if (flow.responseFiber)
401 return yield* new ProviderSetupError({
402 instanceId: options.instanceId,
403 operation: "respond",
404 detail: "A sign-in response is already in progress.",
405 });
406 const ready = yield* Deferred.make<void>();
407 const callback = flow.respond;
408 const fiber = yield* Deferred.await(ready).pipe(
409 Effect.andThen(Effect.suspend(() => callback(input.response))),
410 Effect.interruptible,
411 Effect.forkIn(scope),
412 );
413 flow.responseFiber = fiber;
414 return { flow, fiber, ready };
415 }),
416 );
417 yield* Deferred.succeed(ready, undefined);
418 yield* restore(Fiber.join(fiber)).pipe(
419 Effect.onInterrupt(() => Fiber.interrupt(fiber).pipe(Effect.asVoid)),
420 Effect.ensuring(
421 lock.withPermit(
422 Effect.sync(() => {
423 if (flow.responseFiber === fiber) delete flow.responseFiber;
424 }),
425 ),
426 ),
427 );
428 return snapshot.value.state;
429 }),
430 ),
431 complete: (owner, input) =>
432 lock.withPermit(
433 Effect.gen(function* () {
434 const flow = yield* requireFlow(owner, input.flowId);
435 const interaction = snapshot.value.state.interaction;
436 if (
437 snapshot.value.state.phase !== "waiting" ||
438 interaction?.type !== "browser" ||
439 !interaction.acceptsCallback ||
440 !flow.complete
441 )
442 return yield* new ProviderSetupError({
443 instanceId: options.instanceId,
444 operation: "complete",
445 detail: "This sign-in does not accept a redirect URL.",
446 });
447 yield* flow.complete(input.callbackUrl);
448 return snapshot.value.state;
449 }),
450 ),
451 cancel: Effect.fn("providerAuthFlow.cancel")(function* (owner, id) {
452 const flow = yield* lock.withPermit(
453 Effect.gen(function* () {
454 const flow = yield* requireFlow(owner, id);
455 yield* publish(flow, {
456 phase: "cancelled",
457 interaction: null,
458 authorizationUrl: null,
459 expiresAt: null,
460 message: "Sign-in cancelled.",
461 });
462 active = undefined;
463 operation = "stopping";
464 return flow;
465 }),
466 );
467 if (flow.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
468 if (flow.fiber) yield* Fiber.interrupt(flow.fiber);
469 operation = "idle";
470 return snapshot.value.state;
471 }, Effect.uninterruptible),
472 logout: Effect.fn("providerAuthFlow.logout")(function* (stopSessions) {
473 const flow = yield* lock.withPermit(
474 Effect.gen(function* () {
475 if (operation !== "idle" && operation !== "auth")
476 return yield* new ProviderSetupError({
477 instanceId: options.instanceId,
478 operation: "logout",
479 detail: "Provider setup is already stopping.",
480 });
481 operation = "stopping";
482 const flow = active;
483 active = undefined;
484 return flow;
485 }),
486 );
487 const result = yield* Effect.gen(function* () {
488 if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
489 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
490 yield* stopSessions.pipe(Effect.ensuring(stopOwnedSessions));
491 const message = yield* options.logout;
492 if (options.refreshMethodsAfterAuth) yield* refreshMethods;
493 return message;
494 }).pipe(Effect.exit);
495 const state: ProviderAuthState = {
496 ...empty,
497 methods: snapshot.value.state.methods ?? [],
498 phase: Exit.isSuccess(result) ? "idle" : "failed",
499 message: Exit.isSuccess(result)
500 ? (result.value ?? "Signed out.")
501 : "Could not sign out. Try again.",
502 };
503 yield* lock.withPermit(
504 Effect.gen(function* () {
505 yield* SubscriptionRef.set(snapshot, { owner: null, state });
506 operation = "idle";
507 }),
508 );
509 if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause);
510 return state;
511 }, Effect.uninterruptible),
512 subscribe: (owner) =>
513 SubscriptionRef.changes(snapshot).pipe(
514 Stream.map((current) =>
515 current.owner === null || current.owner === owner
516 ? current.state
517 : {
518 ...current.state,
519 flowId: null,
520 authorizationUrl: null,
521 interaction: null,
522 expiresAt: null,
523 message: active
524 ? "Sign-in is in progress in another client."
525 : current.state.message,
526 },
527 ),
528 ),
529 };
530 yield* Effect.addFinalizer(() =>
531 Effect.gen(function* () {
532 operation = "closed";
533 const flow = active;
534 active = undefined;
535 if (flow?.responseFiber) yield* Fiber.interrupt(flow.responseFiber);
536 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
537 // Settings rebuild instances; admitted sessions sharing this binding retain their own scopes.
538 }),
539 );
540 return controller;
541});