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