@soorya-u/better-auth-desktop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,257 @@
1
+ # @soorya-u/better-auth-desktop
2
+
3
+ Loopback-based desktop authentication for [Better Auth](https://better-auth.com).
4
+
5
+ OAuth completes in the **system browser** and the resulting session is handed
6
+ back to the desktop app through a **`127.0.0.1` loopback** the app briefly
7
+ listens on — reached by a **top-level browser navigation**, not a custom URL
8
+ scheme. That works on **macOS, Linux, and Windows** with **no OS scheme
9
+ registration**, and avoids the CORS / Private-Network-Access / mixed-content
10
+ problems a `fetch`-based hand-off would hit.
11
+
12
+ A framework-agnostic **server plugin** and **core** drive the flow; thin
13
+ adapters bind it to a runtime. **Electrobun** ships today; **Electron** is a
14
+ drop-in (it never loads any Electrobun code).
15
+
16
+ ## How it works
17
+
18
+ ```
19
+ WebView (renderer) Desktop main process Better Auth server
20
+ │ requestAuth(github) ─────▶│ │
21
+ │ │ bind 127.0.0.1:<port> │
22
+ │ │ openExternal( │
23
+ │ │ /desktop/init-oauth-proxy │
24
+ │ │ ?provider&pkce&callbackURL=http://127.0.0.1:<port>/callback?nonce=N)
25
+ │ │ │
26
+ [ system browser ] ── OAuth ──▶ provider ──▶ server callback ──▶ session established
27
+ │ │ /desktop/oauth-complete:
28
+ │ │ mint one-time code; 302 →
29
+ │ │ http://127.0.0.1:<port>/callback?nonce=N&token=T
30
+ [ system browser ] ── navigates to 127.0.0.1:<port>/callback?token=T&nonce=N
31
+ │ │ loopback handler: │
32
+ │ │ verify nonce │
33
+ │ │ exchange T ────────────────────▶│ /desktop/token (PKCE verify)
34
+ │ │ ◀── session ────────────────────│
35
+ │ │ store session (keychain) │
36
+ │◀── onAuthenticated ───────┤ "you can close this tab" │
37
+ │ navigate("/app") │ close loopback │
38
+ ```
39
+
40
+ The one-time code in the loopback URL is **single-use** and **PKCE-bound** (the
41
+ verifier never leaves the desktop), the listener binds **`127.0.0.1` only**, the
42
+ request must carry the matching **nonce**, and the loopback closes after success
43
+ or a timeout. Works in dev (with `oAuthProxy` bouncing through
44
+ `/oauth-proxy-callback`) and prod (direct callback) alike.
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ bun add better-auth @soorya-u/better-auth-desktop
50
+ ```
51
+
52
+ ## Server
53
+
54
+ Add the plugin to your Better Auth server. Compose it with `oAuthProxy` if your
55
+ desktop/web origins differ from the OAuth-registered callback origin.
56
+
57
+ ```ts
58
+ // server/auth.ts
59
+ import { betterAuth } from "better-auth";
60
+ import { betterAuthDesktop } from "@soorya-u/better-auth-desktop/server";
61
+ import { oAuthProxy } from "better-auth/plugins";
62
+
63
+ export const auth = betterAuth({
64
+ socialProviders: {
65
+ github: {
66
+ clientId: process.env.OAUTH_GITHUB_CLIENT_ID!,
67
+ clientSecret: process.env.OAUTH_GITHUB_CLIENT_SECRET!,
68
+ },
69
+ },
70
+ plugins: [
71
+ betterAuthDesktop({ clientID: "my-desktop-app" }),
72
+ oAuthProxy(),
73
+ ],
74
+ });
75
+ ```
76
+
77
+ ### Server options
78
+
79
+ | Option | Default | Description |
80
+ | --- | --- | --- |
81
+ | `clientID` | `"desktop"` | Must match the desktop adapter's `clientID`. |
82
+ | `codeExpiresIn` | `300` | One-time-code TTL (seconds). |
83
+ | `hashKey` | `"token"` | Name of the one-time-code parameter. |
84
+ | `webCallbackUrl` | — | Optional branded callback page (see below). Omit for the default direct-to-loopback redirect. |
85
+ | `allowedLoopbackPorts` | — | Optional hardening: `number[]` or `{ min, max }`. Restricts which loopback port the server will redirect to. |
86
+ | `disableOriginOverride` | `false` | Disable rewriting the request origin from the `desktop-origin` header. |
87
+
88
+ By **default** the server redirects the browser straight to the desktop
89
+ loopback. No web callback page is involved.
90
+
91
+ ## Electrobun (desktop)
92
+
93
+ `electrobunDesktop` is a standard Better Auth client plugin. Use it with
94
+ `createAuthClient` in the Bun **main** process, pass `createBunRPC()` to your
95
+ `BrowserWindow`, and call `setupMain()`.
96
+
97
+ `storage` is **required** — pass `keychainStorage()` (Bun.secrets) or your own
98
+ keychain-backed `Storage`. The package never picks a default, so it can't
99
+ silently land on a runtime-inappropriate one.
100
+
101
+ ```ts
102
+ // desktop/bun/auth.ts
103
+ import { createAuthClient } from "better-auth/client";
104
+ import {
105
+ electrobunDesktop,
106
+ keychainStorage,
107
+ } from "@soorya-u/better-auth-desktop/electrobun";
108
+
109
+ export const authClient = createAuthClient({
110
+ baseURL: process.env.SERVER_URL!,
111
+ plugins: [
112
+ electrobunDesktop({
113
+ clientID: "my-desktop-app",
114
+ storage: await keychainStorage(),
115
+ // loopbackPort: 51789, // optional; omit to bind 127.0.0.1:0 (OS-assigned)
116
+ }),
117
+ ],
118
+ });
119
+
120
+ // Bun-side RPC the WebView calls; pass to `new BrowserWindow({ rpc })`.
121
+ export const authBunRpc = authClient.createBunRPC();
122
+ ```
123
+
124
+ ```ts
125
+ // desktop/bun/index.ts
126
+ import { BrowserWindow } from "electrobun/bun";
127
+ import { authClient, authBunRpc } from "./auth";
128
+
129
+ new BrowserWindow({ title: "My App", url, rpc: authBunRpc });
130
+ await authClient.setupMain();
131
+ ```
132
+
133
+ ### Renderer (WebView)
134
+
135
+ ```ts
136
+ // renderer entry
137
+ import { defineAuthWebviewRPC } from "@soorya-u/better-auth-desktop/rpc/webview";
138
+
139
+ export const auth = defineAuthWebviewRPC();
140
+
141
+ auth.onAuthenticated((user) => navigate("/app"));
142
+ await auth.requestAuth({ provider: "github" });
143
+ ```
144
+
145
+ The bridge exposes `requestAuth`, `getUser`, `signOut`, `getUserImage`, and the
146
+ `onAuthenticated` / `onUserUpdated` / `onAuthError` subscriptions.
147
+
148
+ No `urlSchemes` / `protocol` registration is needed in `electrobun.config.ts`.
149
+
150
+ ## Electron (desktop)
151
+
152
+ The Electron adapter shares the same core; it pulls in **no** Electrobun code.
153
+ `storage` is **required**. The package ships `electronStorage()` — persistent
154
+ via [`electron-store`](https://github.com/sindresorhus/electron-store) (install
155
+ it: `bun add electron-store`) with values encrypted by Electron `safeStorage`
156
+ (OS keychain) when available — or you can supply your own `Storage`.
157
+
158
+ ```ts
159
+ // main process
160
+ import { createAuthClient } from "better-auth/client";
161
+ import {
162
+ electronDesktop,
163
+ electronStorage,
164
+ } from "@soorya-u/better-auth-desktop/electron";
165
+
166
+ const authClient = createAuthClient({
167
+ baseURL: process.env.SERVER_URL!,
168
+ plugins: [
169
+ electronDesktop({
170
+ clientID: "my-desktop-app",
171
+ getWindow: () => mainWindow,
172
+ storage: await electronStorage(),
173
+ }),
174
+ ],
175
+ });
176
+ await authClient.setupMain();
177
+ ```
178
+
179
+ The renderer talks to it over IPC; the channel names are exported as
180
+ `ELECTRON_AUTH_CHANNELS` from `@soorya-u/better-auth-desktop/electron`.
181
+
182
+ ## Optional: branded web callback page
183
+
184
+ If you set `webCallbackUrl` on the server plugin, `oauth-complete` redirects the
185
+ browser to your page (`#token=…&loopback=…`) instead of straight to the
186
+ loopback. Your page calls `forwardToDesktop()`, which performs the top-level
187
+ navigation to `127.0.0.1` — still no CORS / PNA / mixed-content.
188
+
189
+ It's available both as a standalone function and as a Better Auth client plugin:
190
+
191
+ ```ts
192
+ // standalone
193
+ import { forwardToDesktop } from "@soorya-u/better-auth-desktop/web";
194
+ forwardToDesktop(); // safe no-op outside a desktop sign-in
195
+
196
+ // or as a plugin
197
+ import { webDesktop } from "@soorya-u/better-auth-desktop/web";
198
+ const authClient = createAuthClient({ plugins: [webDesktop()] });
199
+ authClient.forwardToDesktop();
200
+ ```
201
+
202
+ ### Customizing the loopback success page
203
+
204
+ By default the loopback serves a minimal "you can close this tab" page. Override
205
+ it via `loopbackSuccess` on the desktop plugin — a string is used as the HTML
206
+ body, or `{ redirectTo }` bounces the browser to your own page:
207
+
208
+ ```ts
209
+ electrobunDesktop({
210
+ storage: await keychainStorage(),
211
+ loopbackSuccess: { redirectTo: "https://app.example.com/signed-in" },
212
+ });
213
+ ```
214
+
215
+ ## Custom adapters
216
+
217
+ `@soorya-u/better-auth-desktop/core` exposes the framework-agnostic pieces —
218
+ the `DesktopAdapter` interface, `startAuthFlow`, `exchangeToken`, the loopback
219
+ helpers, and `desktopClient` — so you can target another runtime. An adapter
220
+ only has to:
221
+
222
+ ```ts
223
+ type DesktopAdapter = {
224
+ openExternal(url: string): void | Promise<void>;
225
+ serveLoopback(
226
+ onRequest: (req: LoopbackRequest) => Promise<LoopbackResponse>,
227
+ opts?: { port?: number },
228
+ ): Promise<{ port: number; close(): void }>;
229
+ notifyRenderer(event: AuthEvent): void;
230
+ storage: Storage;
231
+ };
232
+ ```
233
+
234
+ ## Exports
235
+
236
+ | Subpath | Purpose |
237
+ | --- | --- |
238
+ | `@soorya-u/better-auth-desktop` | re-exports `core` (types & utilities) |
239
+ | `@soorya-u/better-auth-desktop/server` | `betterAuthDesktop()` server plugin |
240
+ | `@soorya-u/better-auth-desktop/electrobun` | `electrobunDesktop()` plugin + `keychainStorage()` (Bun main process) |
241
+ | `@soorya-u/better-auth-desktop/rpc/webview` | `defineAuthWebviewRPC()` (Electrobun renderer) |
242
+ | `@soorya-u/better-auth-desktop/electron` | `electronDesktop()` plugin + `electronStorage()` (Electron main process) |
243
+ | `@soorya-u/better-auth-desktop/web` | `forwardToDesktop()` + `webDesktop()` plugin (optional branded page) |
244
+ | `@soorya-u/better-auth-desktop/client` | `desktopClient()` Better Auth client plugin |
245
+ | `@soorya-u/better-auth-desktop/core` | shared types & utilities for custom adapters |
246
+
247
+ ## Security notes
248
+
249
+ - The loopback binds `127.0.0.1` only and is unreachable off-box.
250
+ - The token in the loopback URL is a one-time, short-TTL, PKCE-bound code; the
251
+ verifier never leaves the desktop, so interception/replay is useless.
252
+ - The nonce stops a different local app from consuming the redirect.
253
+ - The loopback closes immediately after a successful exchange (or on timeout).
254
+
255
+ ## License
256
+
257
+ MIT
@@ -0,0 +1,49 @@
1
+ import { n as AuthUser } from "../types-h9AEfSnq.mjs";
2
+ import { n as DesktopClientPluginOptions } from "../client-De_zQzE1.mjs";
3
+ import { n as keychainStorage, t as KeychainStorageOptions } from "../storage-DtgX_vmE.mjs";
4
+ import * as _$_better_fetch_fetch0 from "@better-fetch/fetch";
5
+ import { BetterFetch } from "@better-fetch/fetch";
6
+ import { BetterAuthClientOptions, ClientStore } from "@better-auth/core";
7
+
8
+ //#region src/adapters/electrobun.d.ts
9
+ type AuthSender = {
10
+ onAuthenticated(user: AuthUser): void;
11
+ onUserUpdated(user: AuthUser | null): void;
12
+ onAuthError(payload: {
13
+ error: {
14
+ message: string;
15
+ };
16
+ path?: string;
17
+ }): void;
18
+ };
19
+ type ElectrobunDesktopRPC = {
20
+ send: AuthSender;
21
+ setTransport(transport: unknown): void;
22
+ };
23
+ type ElectrobunDesktopOptions = DesktopClientPluginOptions;
24
+ /**
25
+ * Better Auth client plugin for Electrobun (Bun main process). Use it with
26
+ * `createAuthClient`, then pass `createBunRPC()` to your `BrowserWindow` and
27
+ * call `setupMain()`:
28
+ *
29
+ * ```ts
30
+ * const authClient = createAuthClient({
31
+ * baseURL,
32
+ * plugins: [electrobunDesktop({ clientID, storage: await keychainStorage() })],
33
+ * });
34
+ * new BrowserWindow({ rpc: authClient.createBunRPC(), url });
35
+ * await authClient.setupMain();
36
+ * ```
37
+ */
38
+ declare const electrobunDesktop: (options: ElectrobunDesktopOptions) => {
39
+ id: "desktop";
40
+ version: string;
41
+ fetchPlugins: _$_better_fetch_fetch0.BetterFetchPlugin[];
42
+ getActions: ($fetch: BetterFetch, $store: ClientStore, clientOptions: BetterAuthClientOptions | undefined) => {
43
+ getCookie: () => string;
44
+ createBunRPC: () => ElectrobunDesktopRPC;
45
+ setupMain: () => Promise<() => void | undefined>;
46
+ };
47
+ };
48
+ //#endregion
49
+ export { ElectrobunDesktopOptions, ElectrobunDesktopRPC, type KeychainStorageOptions, electrobunDesktop, keychainStorage };
@@ -0,0 +1,166 @@
1
+ import { t as PACKAGE_VERSION } from "../version-1hEssvcU.mjs";
2
+ import { t as createDesktopCookieLayer } from "../client-DhRcoap2.mjs";
3
+ import { n as startAuthFlow, r as fetchUserImage } from "../exchange-DCZ0j_T5.mjs";
4
+ import { t as keychainStorage } from "../storage-BcwDmS-W.mjs";
5
+ import { BrowserView, Utils } from "electrobun/bun";
6
+ //#region src/adapters/electrobun.ts
7
+ function createLoopbackAdapter(getSender, storage) {
8
+ return {
9
+ openExternal(url) {
10
+ if (!Utils.openExternal(url)) throw new Error(`Failed to open the system browser: ${url}`);
11
+ },
12
+ async serveLoopback(onRequest, opts) {
13
+ const server = Bun.serve({
14
+ hostname: "127.0.0.1",
15
+ port: opts?.port ?? 0,
16
+ async fetch(req) {
17
+ const url = new URL(req.url);
18
+ const query = {};
19
+ url.searchParams.forEach((value, key) => {
20
+ query[key] = value;
21
+ });
22
+ const res = await onRequest({
23
+ path: url.pathname,
24
+ query
25
+ });
26
+ return new Response(res.body, {
27
+ status: res.status,
28
+ headers: res.headers
29
+ });
30
+ }
31
+ });
32
+ return {
33
+ port: server.port ?? 0,
34
+ close: () => void server.stop()
35
+ };
36
+ },
37
+ notifyRenderer(event) {
38
+ const send = getSender();
39
+ if (!send) return;
40
+ switch (event.type) {
41
+ case "authenticated":
42
+ send.onAuthenticated(event.user);
43
+ break;
44
+ case "user-updated":
45
+ send.onUserUpdated(event.user);
46
+ break;
47
+ case "error":
48
+ send.onAuthError({
49
+ error: { message: event.error.message },
50
+ path: event.path
51
+ });
52
+ break;
53
+ }
54
+ },
55
+ storage
56
+ };
57
+ }
58
+ /**
59
+ * Better Auth client plugin for Electrobun (Bun main process). Use it with
60
+ * `createAuthClient`, then pass `createBunRPC()` to your `BrowserWindow` and
61
+ * call `setupMain()`:
62
+ *
63
+ * ```ts
64
+ * const authClient = createAuthClient({
65
+ * baseURL,
66
+ * plugins: [electrobunDesktop({ clientID, storage: await keychainStorage() })],
67
+ * });
68
+ * new BrowserWindow({ rpc: authClient.createBunRPC(), url });
69
+ * await authClient.setupMain();
70
+ * ```
71
+ */
72
+ const electrobunDesktop = (options) => {
73
+ const layer = createDesktopCookieLayer(options);
74
+ let rpc = null;
75
+ const adapter = createLoopbackAdapter(() => rpc?.send ?? null, options.storage);
76
+ const sanitizeUser = async (user) => {
77
+ if (user !== null && typeof options.sanitizeUser === "function") try {
78
+ return await options.sanitizeUser(user);
79
+ } catch (error) {
80
+ console.error("Error while sanitizing user", error);
81
+ return null;
82
+ }
83
+ return user;
84
+ };
85
+ return {
86
+ id: "desktop",
87
+ version: PACKAGE_VERSION,
88
+ fetchPlugins: [layer.fetchPlugin],
89
+ getActions: ($fetch, $store, clientOptions) => {
90
+ layer.bindStore($store);
91
+ layer.bindServerOrigin(clientOptions?.baseURL);
92
+ const getCookie = layer.getCookie;
93
+ const requests = {
94
+ requestAuth: async ({ options: cfg }) => {
95
+ await startAuthFlow({
96
+ adapter,
97
+ $fetch,
98
+ clientOptions,
99
+ options,
100
+ cfg,
101
+ onAuthenticated: (user) => adapter.notifyRenderer({
102
+ type: "authenticated",
103
+ user
104
+ }),
105
+ onError: (error) => adapter.notifyRenderer({
106
+ type: "error",
107
+ error: error instanceof Error ? error : new Error(String(error)),
108
+ path: "/desktop/init-oauth-proxy"
109
+ })
110
+ });
111
+ },
112
+ getUser: async () => {
113
+ return await sanitizeUser((await $fetch("/get-session", {
114
+ method: "GET",
115
+ headers: {
116
+ cookie: getCookie(),
117
+ "content-type": "application/json"
118
+ }
119
+ })).data?.user ?? null);
120
+ },
121
+ signOut: async () => {
122
+ await $fetch("/sign-out", {
123
+ method: "POST",
124
+ body: "{}",
125
+ headers: {
126
+ cookie: getCookie(),
127
+ "content-type": "application/json"
128
+ }
129
+ });
130
+ },
131
+ getUserImage: async ({ url }) => {
132
+ const result = await fetchUserImage(clientOptions?.baseURL, url);
133
+ if (!result) return { dataUrl: null };
134
+ const { base64 } = await import("@better-auth/utils/base64");
135
+ return { dataUrl: `data:${result.mimeType};base64,${base64.encode(result.bytes)}` };
136
+ }
137
+ };
138
+ return {
139
+ getCookie,
140
+ createBunRPC: () => {
141
+ rpc = BrowserView.defineRPC({
142
+ maxRequestTime: 3e4,
143
+ handlers: {
144
+ requests,
145
+ messages: {}
146
+ }
147
+ });
148
+ return rpc;
149
+ },
150
+ setupMain: async () => {
151
+ const unsub = $store.atoms.session?.subscribe(async (state) => {
152
+ if (state.isPending === true) return;
153
+ const user = await sanitizeUser(state.data?.user ?? null);
154
+ adapter.notifyRenderer({
155
+ type: "user-updated",
156
+ user
157
+ });
158
+ });
159
+ return () => unsub?.();
160
+ }
161
+ };
162
+ }
163
+ };
164
+ };
165
+ //#endregion
166
+ export { electrobunDesktop, keychainStorage };
@@ -0,0 +1,55 @@
1
+ import { l as Storage } from "../types-h9AEfSnq.mjs";
2
+ import { n as DesktopClientPluginOptions } from "../client-De_zQzE1.mjs";
3
+ import * as _$_better_fetch_fetch0 from "@better-fetch/fetch";
4
+ import { BetterFetch } from "@better-fetch/fetch";
5
+ import { BetterAuthClientOptions, ClientStore } from "@better-auth/core";
6
+
7
+ //#region src/adapters/electron.d.ts
8
+ declare const ELECTRON_AUTH_CHANNELS: {
9
+ readonly requestAuth: "desktop:requestAuth";
10
+ readonly getUser: "desktop:getUser";
11
+ readonly signOut: "desktop:signOut";
12
+ readonly getUserImage: "desktop:getUserImage";
13
+ readonly onAuthenticated: "desktop:onAuthenticated";
14
+ readonly onUserUpdated: "desktop:onUserUpdated";
15
+ readonly onAuthError: "desktop:onAuthError";
16
+ };
17
+ type WebContents = {
18
+ send(channel: string, payload: unknown): void;
19
+ };
20
+ type ElectronWindowLike = {
21
+ webContents: WebContents;
22
+ } | null | undefined;
23
+ type ElectronStorageOptions = {
24
+ /** electron-store file name (without extension). @default "better-auth-desktop" */name?: string; /** Encrypt values with Electron `safeStorage` (OS keychain) when available. @default true */
25
+ encrypt?: boolean;
26
+ };
27
+ declare function electronStorage(opts?: ElectronStorageOptions): Promise<Storage>;
28
+ type ElectronDesktopOptions = DesktopClientPluginOptions & {
29
+ /** Returns the focused window whose renderer should receive auth events. */getWindow: () => ElectronWindowLike;
30
+ };
31
+ /**
32
+ * Better Auth client plugin for Electron (main process). Shares the same core
33
+ * as the Electrobun plugin; pulls in **no** Electrobun code. Use it with
34
+ * `createAuthClient`, then call `setupMain()` (which registers the IPC handlers
35
+ * and the session subscription, and returns a cleanup function):
36
+ *
37
+ * ```ts
38
+ * const authClient = createAuthClient({
39
+ * baseURL,
40
+ * plugins: [electronDesktop({ clientID, storage: await electronStorage(), getWindow })],
41
+ * });
42
+ * await authClient.setupMain();
43
+ * ```
44
+ */
45
+ declare const electronDesktop: (options: ElectronDesktopOptions) => {
46
+ id: "desktop";
47
+ version: string;
48
+ fetchPlugins: _$_better_fetch_fetch0.BetterFetchPlugin[];
49
+ getActions: ($fetch: BetterFetch, $store: ClientStore, clientOptions: BetterAuthClientOptions | undefined) => {
50
+ getCookie: () => string;
51
+ setupMain: () => Promise<() => void>;
52
+ };
53
+ };
54
+ //#endregion
55
+ export { ELECTRON_AUTH_CHANNELS, ElectronDesktopOptions, ElectronStorageOptions, electronDesktop, electronStorage };