1/**
2 * Shared browser-OAuth ceremony for auth providers: open the authorization
3 * URL, then race the provider's local callback listener against the branded
4 * "waiting for browser" prompt (spinner + compact URL; Enter switches to
5 * manual code entry, `o` opens the browser again, and `c` copies the URL).
6 *
7 * Built-in providers (Cloudflare, Planetscale) and custom stack-provided
8 * auth providers should all route their browser flows through this so the
9 * login UX stays uniform.
10 */
11import * as Effect from "effect/Effect";
12import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
13import * as Interaction from "../Interaction.ts";
14import { CallbackServerStartError } from "./OAuthFlow.ts";
15
16export interface BrowserOAuthOptions<A, E1, R1, E2, R2> {
17 /** Display name, e.g. "Cloudflare" — used in the prompt title. */
18 provider: string;
19 /** The authorization URL the browser was pointed at. */
20 url: string;
21 /** Resolves when the local callback listener receives the redirect. */
22 callback: Effect.Effect<A, E1, R1>;
23 /** Exchanges a manually pasted code / callback URL for credentials. */
24 exchange: (input: string) => Effect.Effect<A, E2, R2>;
25 /** Spinner label. @default "waiting for browser authorization (up to 5 minutes)…" */
26 waitingLabel?: string;
27}
28
29export const browserOAuth = Effect.fn(function* <A, E1, R1, E2, R2>(
30 options: BrowserOAuthOptions<A, E1, R1, E2, R2>,
31) {
32 const services = yield* Effect.context<ChildProcessSpawner>();
33 // This runner is invoked later by React's keyboard event boundary, not
34 // while the surrounding Effect is executing.
35 const openUrl = Effect.runPromiseWith(services);
36 const openFailed = yield* Interaction.openUrl(options.url).pipe(
37 Effect.as(false),
38 Effect.catch(() => Effect.succeed(true)),
39 );
40 return yield* Effect.raceFirst(
41 // A callback server that cannot start (e.g. the port is taken) must not
42 // fail the race — warn and hang so manual code entry stays available.
43 // Genuine OAuth failures delivered via the callback still fail fast.
44 options.callback.pipe(
45 Effect.catch((e) =>
46 e instanceof CallbackServerStartError
47 ? Interaction.accessors.output
48 .warning(`${e.message} — paste the authorization code instead.`)
49 .pipe(Effect.andThen(Effect.never))
50 : Effect.fail(e),
51 ),
52 ),
53 (yield* Interaction.Interaction).prompt
54 .awaitExternal({
55 message: `${options.provider} authorization`,
56 waitingLabel:
57 options.waitingLabel ??
58 "waiting for browser authorization (up to 5 minutes)…",
59 url: options.url,
60 openFailed,
61 onOpen: () => openUrl(Interaction.openUrl(options.url)),
62 inputLabel: "Paste the authorization code or callback URL",
63 validate: (value) =>
64 value.trim().length > 0 ? undefined : "Paste a code or URL",
65 })
66 .pipe(Effect.flatMap(options.exchange)),
67 );
68});