@better-auth/electron 1.5.0-beta.12

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.
@@ -0,0 +1,476 @@
1
+ import { n as parseProtocolScheme, t as isProcessType } from "./utils-C3fLmbAT.mjs";
2
+ import { BetterAuthError } from "@better-auth/core/error";
3
+ import { APIError as APIError$1, getBaseURL, isDevelopment, isTest } from "better-auth";
4
+ import { generateRandomString } from "better-auth/crypto";
5
+ import * as z from "zod";
6
+ import { Buffer } from "node:buffer";
7
+ import { base64, base64Url } from "@better-auth/utils/base64";
8
+ import { createHash } from "@better-auth/utils/hash";
9
+ import { signInSocial } from "better-auth/api";
10
+ import { parseSetCookieHeader } from "better-auth/cookies";
11
+ import electron, { shell } from "electron";
12
+ import { resolve } from "node:path";
13
+
14
+ //#region src/bridges.ts
15
+ const { ipcRenderer, ipcMain, contextBridge, webContents: webContents$1 } = electron;
16
+ function getChannelPrefixWithDelimiter(ns = "better-auth") {
17
+ return ns.length > 0 ? ns + ":" : ns;
18
+ }
19
+ function listenerFactory(channel, listener) {
20
+ ipcRenderer.on(channel, listener);
21
+ return () => {
22
+ ipcRenderer.off(channel, listener);
23
+ };
24
+ }
25
+ /**
26
+ * Exposes IPC bridges to the renderer process.
27
+ */
28
+ function exposeBridges(opts) {
29
+ if (!process.contextIsolated) throw new BetterAuthError("Context isolation must be enabled to use IPC bridges securely.");
30
+ const prefix = getChannelPrefixWithDelimiter(opts.channelPrefix);
31
+ const bridges = {
32
+ getUser: async () => {
33
+ return await ipcRenderer.invoke(`${prefix}getUser`);
34
+ },
35
+ requestAuth: async (options) => {
36
+ await ipcRenderer.invoke(`${prefix}requestAuth`, options);
37
+ },
38
+ signOut: async () => {
39
+ await ipcRenderer.invoke(`${prefix}signOut`);
40
+ },
41
+ onAuthenticated: (callback) => {
42
+ return listenerFactory(`${prefix}authenticated`, async (_evt, user) => {
43
+ await callback(user);
44
+ });
45
+ },
46
+ onUserUpdated: (callback) => {
47
+ return listenerFactory(`${prefix}user-updated`, async (_evt, user) => {
48
+ await callback(user);
49
+ });
50
+ },
51
+ onAuthError: (callback) => {
52
+ return listenerFactory(`${prefix}error`, async (_evt, context) => {
53
+ await callback(context);
54
+ });
55
+ }
56
+ };
57
+ for (const [key, value] of Object.entries(bridges)) contextBridge.exposeInMainWorld(key, value);
58
+ return {};
59
+ }
60
+ /**
61
+ * Sets up IPC bridges in the main process.
62
+ */
63
+ function setupBridges(ctx, opts, clientOptions) {
64
+ const prefix = getChannelPrefixWithDelimiter(opts.channelPrefix);
65
+ ctx.$store?.atoms.session?.subscribe((state) => {
66
+ if (state.isPending === true) return;
67
+ webContents$1.getFocusedWebContents()?.send(`${prefix}user-updated`, state?.data?.user ?? null);
68
+ });
69
+ ipcMain.handle(`${prefix}getUser`, async () => {
70
+ return (await ctx.$fetch("/get-session", {
71
+ method: "GET",
72
+ headers: {
73
+ cookie: ctx.getCookie(),
74
+ "content-type": "application/json"
75
+ }
76
+ })).data?.user ?? null;
77
+ });
78
+ ipcMain.handle(`${prefix}requestAuth`, (_evt, options) => requestAuth(clientOptions, opts, options));
79
+ ipcMain.handle(`${prefix}signOut`, async () => {
80
+ await ctx.$fetch("/sign-out", {
81
+ method: "POST",
82
+ body: "{}",
83
+ headers: {
84
+ cookie: ctx.getCookie(),
85
+ "content-type": "application/json"
86
+ }
87
+ });
88
+ });
89
+ }
90
+
91
+ //#endregion
92
+ //#region src/authenticate.ts
93
+ const kCodeVerifier = Symbol.for("better-auth:code_verifier");
94
+ const kState = Symbol.for("better-auth:state");
95
+ (() => {
96
+ const { provider, idToken, loginHint, ...signInSocialBody } = signInSocial().options.body.shape;
97
+ return z.object({
98
+ ...signInSocialBody,
99
+ provider: z.string().nonempty().optional()
100
+ });
101
+ })();
102
+ /**
103
+ * Opens the system browser to request user authentication.
104
+ */
105
+ async function requestAuth(clientOptions, options, cfg) {
106
+ if (!isProcessType("browser")) throw new BetterAuthError("`requestAuth` can only be called in the main process");
107
+ const { randomBytes } = await import("node:crypto");
108
+ const state = generateRandomString(16, "A-Z", "a-z", "0-9");
109
+ const codeVerifier = base64Url.encode(randomBytes(32));
110
+ const codeChallenge = base64Url.encode(await createHash("SHA-256").digest(codeVerifier));
111
+ globalThis[kCodeVerifier] = codeVerifier;
112
+ globalThis[kState] = state;
113
+ let url = null;
114
+ if (cfg?.provider) {
115
+ const baseURL = getBaseURL(clientOptions?.baseURL, clientOptions?.basePath, void 0, true);
116
+ if (!baseURL) {
117
+ console.log("No base URL found in client options");
118
+ throw APIError$1.from("INTERNAL_SERVER_ERROR", {
119
+ code: "NO_BASE_URL",
120
+ message: "Base URL is required to use provider-based sign-in."
121
+ });
122
+ }
123
+ url = new URL(`${baseURL}/electron/init-oauth-proxy`);
124
+ for (const [key, value] of Object.entries(cfg)) url.searchParams.set(key, typeof value === "string" ? value : JSON.stringify(value));
125
+ } else url = new URL(options.signInURL);
126
+ url.searchParams.set("client_id", options.clientID || "electron");
127
+ url.searchParams.set("code_challenge", codeChallenge);
128
+ url.searchParams.set("code_challenge_method", "S256");
129
+ url.searchParams.set("state", state);
130
+ if (url === null) throw new Error("Failed to construct sign-in URL.");
131
+ await shell.openExternal(url.toString(), { activate: true });
132
+ }
133
+ /**
134
+ * Exchanges the authorization code for a session.
135
+ */
136
+ async function authenticate($fetch, options, body, getWindow) {
137
+ const codeVerifier = globalThis[kCodeVerifier];
138
+ const state = globalThis[kState];
139
+ globalThis[kCodeVerifier] = void 0;
140
+ globalThis[kState] = void 0;
141
+ if (!codeVerifier) throw new Error("Code verifier not found.");
142
+ if (!state) throw new Error("State not found.");
143
+ await $fetch("/electron/token", {
144
+ method: "POST",
145
+ body: {
146
+ ...body,
147
+ state,
148
+ code_verifier: codeVerifier
149
+ },
150
+ onSuccess: (ctx) => {
151
+ getWindow()?.webContents.send(`${getChannelPrefixWithDelimiter(options.channelPrefix)}authenticated`, ctx.data.user);
152
+ },
153
+ throw: true
154
+ });
155
+ }
156
+
157
+ //#endregion
158
+ //#region src/cookies.ts
159
+ function getSetCookie(header, prevCookie) {
160
+ const parsed = parseSetCookieHeader(header);
161
+ let toSetCookie = {};
162
+ parsed.forEach((cookie, key) => {
163
+ const expiresAt = cookie["expires"];
164
+ const maxAge = cookie["max-age"];
165
+ const expires = maxAge ? new Date(Date.now() + Number(maxAge) * 1e3) : expiresAt ? new Date(String(expiresAt)) : null;
166
+ toSetCookie[key] = {
167
+ value: cookie["value"],
168
+ expires: expires ? expires.toISOString() : null
169
+ };
170
+ });
171
+ if (prevCookie) try {
172
+ toSetCookie = {
173
+ ...JSON.parse(prevCookie),
174
+ ...toSetCookie
175
+ };
176
+ } catch {}
177
+ return JSON.stringify(toSetCookie);
178
+ }
179
+ function getCookie(cookie) {
180
+ let parsed = {};
181
+ try {
182
+ parsed = JSON.parse(cookie);
183
+ } catch (_e) {}
184
+ return Object.entries(parsed).reduce((acc, [key, value]) => {
185
+ if (value.expires && new Date(value.expires) < /* @__PURE__ */ new Date()) return acc;
186
+ return `${acc}; ${key}=${value.value}`;
187
+ }, "");
188
+ }
189
+ /**
190
+ * Compare if session cookies have actually changed by comparing their values.
191
+ * Ignores expiry timestamps that naturally change on each request.
192
+ *
193
+ * @param prevCookie - Previous cookie JSON string
194
+ * @param newCookie - New cookie JSON string
195
+ * @returns true if session cookies have changed, false otherwise
196
+ */
197
+ function hasSessionCookieChanged(prevCookie, newCookie) {
198
+ if (!prevCookie) return true;
199
+ try {
200
+ const prev = JSON.parse(prevCookie);
201
+ const next = JSON.parse(newCookie);
202
+ const sessionKeys = /* @__PURE__ */ new Set();
203
+ Object.keys(prev).forEach((key) => {
204
+ if (key.includes("session_token") || key.includes("session_data")) sessionKeys.add(key);
205
+ });
206
+ Object.keys(next).forEach((key) => {
207
+ if (key.includes("session_token") || key.includes("session_data")) sessionKeys.add(key);
208
+ });
209
+ for (const key of sessionKeys) if (prev[key]?.value !== next[key]?.value) return true;
210
+ return false;
211
+ } catch {
212
+ return true;
213
+ }
214
+ }
215
+ /**
216
+ * Check if the Set-Cookie header contains better-auth cookies.
217
+ * This prevents infinite refetching when non-better-auth cookies (like third-party cookies) change.
218
+ *
219
+ * Supports multiple cookie naming patterns:
220
+ * - Default: "better-auth.session_token", "better-auth-passkey", "__Secure-better-auth.session_token"
221
+ * - Custom prefix: "myapp.session_token", "myapp-passkey", "__Secure-myapp.session_token"
222
+ * - Custom full names: "my_custom_session_token", "custom_session_data"
223
+ * - No prefix (cookiePrefix=""): matches any cookie with known suffixes
224
+ * - Multiple prefixes: ["better-auth", "my-app"] matches cookies starting with any of the prefixes
225
+ *
226
+ * @param setCookieHeader - The Set-Cookie header value
227
+ * @param cookiePrefix - The cookie prefix(es) to check for. Can be a string, array of strings, or empty string.
228
+ * @returns true if the header contains better-auth cookies, false otherwise
229
+ */
230
+ function hasBetterAuthCookies(setCookieHeader, cookiePrefix) {
231
+ const cookies = parseSetCookieHeader(setCookieHeader);
232
+ const cookieSuffixes = ["session_token", "session_data"];
233
+ const prefixes = Array.isArray(cookiePrefix) ? cookiePrefix : [cookiePrefix];
234
+ for (const name of cookies.keys()) {
235
+ const nameWithoutSecure = name.startsWith("__Secure-") ? name.slice(9) : name;
236
+ for (const prefix of prefixes) if (prefix) {
237
+ if (nameWithoutSecure.startsWith(prefix)) return true;
238
+ } else for (const suffix of cookieSuffixes) if (nameWithoutSecure.endsWith(suffix)) return true;
239
+ }
240
+ return false;
241
+ }
242
+
243
+ //#endregion
244
+ //#region src/setup.ts
245
+ const { app: app$1, session, protocol, BrowserWindow } = electron;
246
+ function withGetWindowFallback(win) {
247
+ return win ?? (() => {
248
+ const allWindows = BrowserWindow.getAllWindows();
249
+ return allWindows.length > 0 ? allWindows[0] : null;
250
+ });
251
+ }
252
+ function setupRenderer(opts) {
253
+ if (!isProcessType("renderer")) throw new BetterAuthError("setupRenderer can only be called in the renderer process.");
254
+ exposeBridges(opts);
255
+ }
256
+ function setupMain($fetch, $store, getCookie, opts, clientOptions, cfg) {
257
+ if (!isProcessType("browser")) throw new BetterAuthError("setupMain can only be called in the main process.");
258
+ if (!cfg || cfg.csp === true) setupCSP(clientOptions);
259
+ if (!cfg || cfg.scheme === true) registerProtocolScheme($fetch, opts, withGetWindowFallback(cfg?.getWindow));
260
+ if (!cfg || cfg.bridges === true) setupBridges({
261
+ $fetch,
262
+ $store,
263
+ getCookie
264
+ }, opts, clientOptions);
265
+ }
266
+ /**
267
+ * Handles the deep link URL for authentication.
268
+ */
269
+ async function handleDeepLink({ $fetch, options, url, getWindow }) {
270
+ if (!isProcessType("browser")) throw new BetterAuthError("`handleDeepLink` can only be called in the main process.");
271
+ let parsedURL = null;
272
+ try {
273
+ parsedURL = new URL(url);
274
+ } catch {}
275
+ if (!parsedURL) return;
276
+ const { scheme } = parseProtocolScheme(options.protocol);
277
+ if (!url.startsWith(`${scheme}:/`)) return;
278
+ const { protocol, pathname, hostname, hash } = parsedURL;
279
+ if (protocol !== `${scheme}:`) return;
280
+ if ("/" + hostname + pathname !== (options.callbackPath || "/auth/callback")) return;
281
+ if (!hash.startsWith("#token=")) return;
282
+ await authenticate($fetch, options, { token: hash.substring(7) }, withGetWindowFallback(getWindow));
283
+ }
284
+ function registerProtocolScheme($fetch, options, getWindow) {
285
+ const { scheme, privileges = {} } = typeof options.protocol === "string" ? { scheme: options.protocol } : options.protocol;
286
+ protocol.registerSchemesAsPrivileged([{
287
+ scheme,
288
+ privileges: {
289
+ standard: false,
290
+ secure: true,
291
+ ...privileges
292
+ }
293
+ }]);
294
+ let hasSetupProtocolClient = false;
295
+ if (process?.defaultApp) {
296
+ if (process.argv.length >= 2 && typeof process.argv[1] === "string") hasSetupProtocolClient = app$1.setAsDefaultProtocolClient(scheme, process.execPath, [resolve(process.argv[1])]);
297
+ } else hasSetupProtocolClient = app$1.setAsDefaultProtocolClient(scheme);
298
+ if (!hasSetupProtocolClient) console.error(`Failed to register protocol ${scheme} as default protocol client.`);
299
+ if (!app$1.requestSingleInstanceLock()) app$1.quit();
300
+ else {
301
+ app$1.on("second-instance", async (_event, commandLine, _workingDir, url) => {
302
+ const win = getWindow();
303
+ if (win) {
304
+ if (win.isMinimized()) win.restore();
305
+ win.focus();
306
+ }
307
+ if (!url) {
308
+ const maybeURL = commandLine.pop();
309
+ if (typeof maybeURL === "string" && maybeURL.trim() !== "") try {
310
+ url = new URL(maybeURL).toString();
311
+ } catch {}
312
+ }
313
+ if (process?.platform !== "darwin" && typeof url === "string") await handleDeepLink({
314
+ $fetch,
315
+ options,
316
+ url,
317
+ getWindow
318
+ });
319
+ });
320
+ app$1.on("open-url", async (_event, url) => {
321
+ if (process?.platform === "darwin") await handleDeepLink({
322
+ $fetch,
323
+ options,
324
+ url,
325
+ getWindow
326
+ });
327
+ });
328
+ app$1.whenReady().then(async () => {
329
+ if (process?.platform !== "darwin" && typeof process.argv[1] === "string") await handleDeepLink({
330
+ $fetch,
331
+ options,
332
+ url: process.argv[1],
333
+ getWindow
334
+ });
335
+ });
336
+ }
337
+ }
338
+ function setupCSP(clientOptions) {
339
+ app$1.whenReady().then(() => {
340
+ session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
341
+ const origin = new URL(clientOptions?.baseURL || "", "http://localhost").origin;
342
+ const cspKey = Object.keys(details.responseHeaders || {}).find((k) => k.toLowerCase() === "content-security-policy");
343
+ if (!cspKey) return callback({ responseHeaders: {
344
+ ...details.responseHeaders || {},
345
+ "content-security-policy": `connect-src 'self' ${origin}`
346
+ } });
347
+ const policy = details.responseHeaders?.[cspKey]?.toString() || "";
348
+ const csp = /* @__PURE__ */ new Map();
349
+ for (let token of policy.split(";")) {
350
+ token = token.trim();
351
+ if (!token || !/^[\x00-\x7f]*$/.test(token)) continue;
352
+ const [rawDirectiveName, ...directiveValue] = token.split(/\s+/);
353
+ const directiveName = rawDirectiveName?.toLowerCase();
354
+ if (!directiveName) continue;
355
+ if (csp.has(directiveName)) continue;
356
+ csp.set(directiveName, directiveValue);
357
+ }
358
+ if (csp.has("connect-src")) {
359
+ const values = csp.get("connect-src") || [];
360
+ if (!values.includes(origin)) values.push(origin);
361
+ csp.set("connect-src", values);
362
+ } else csp.set("connect-src", ["'self'", origin]);
363
+ callback({ responseHeaders: {
364
+ ...details.responseHeaders,
365
+ "content-security-policy": Array.from(csp.entries()).map(([k, v]) => `${k} ${v.join(" ")}`).join("; ")
366
+ } });
367
+ });
368
+ });
369
+ }
370
+
371
+ //#endregion
372
+ //#region src/client.ts
373
+ const { app, safeStorage, webContents } = electron;
374
+ const storageAdapter = (storage) => {
375
+ return {
376
+ ...storage,
377
+ getDecrypted: (name) => {
378
+ const item = storage.getItem(name);
379
+ if (!item || typeof item !== "string") return null;
380
+ return safeStorage.decryptString(Buffer.from(base64.decode(item)));
381
+ },
382
+ setEncrypted: (name, value) => {
383
+ return storage.setItem(name, base64.encode(safeStorage.encryptString(value)));
384
+ }
385
+ };
386
+ };
387
+ const electronClient = (options) => {
388
+ const opts = {
389
+ storagePrefix: "better-auth",
390
+ cookiePrefix: "better-auth",
391
+ channelPrefix: "better-auth",
392
+ callbackPath: "/auth/callback",
393
+ ...options
394
+ };
395
+ const { scheme } = parseProtocolScheme(opts.protocol);
396
+ let store = null;
397
+ const cookieName = `${opts.storagePrefix}.cookie`;
398
+ const localCacheName = `${opts.storagePrefix}.local_cache`;
399
+ const { getDecrypted, setEncrypted } = storageAdapter(opts.storage);
400
+ if ((isDevelopment() || isTest()) && /^(?!\.)(?!.*\.\.)(?!.*\.$)[^.]+\.[^.]+$/.test(scheme)) console.warn("The provided scheme does not follow the reverse domain name notation. For example: `app.example.com` -> `com.example.app`.");
401
+ return {
402
+ id: "electron",
403
+ fetchPlugins: [{
404
+ id: "electron",
405
+ name: "Electron",
406
+ async init(url, options) {
407
+ if (!isProcessType("browser")) throw new Error("Requests must be made from the Electron main process");
408
+ const cookie = getCookie(getDecrypted(cookieName) || "{}");
409
+ options ||= {};
410
+ options.credentials = "omit";
411
+ options.headers = {
412
+ ...options.headers,
413
+ cookie,
414
+ "user-agent": app.userAgentFallback,
415
+ "electron-origin": `${scheme}:/`,
416
+ "x-skip-oauth-proxy": "true"
417
+ };
418
+ if (url.endsWith("/sign-out")) {
419
+ setEncrypted(cookieName, "{}");
420
+ store?.atoms.session?.set({
421
+ ...store.atoms.session.get(),
422
+ data: null,
423
+ error: null,
424
+ isPending: false
425
+ });
426
+ setEncrypted(localCacheName, "{}");
427
+ }
428
+ return {
429
+ url,
430
+ options
431
+ };
432
+ },
433
+ hooks: {
434
+ onSuccess: async (context) => {
435
+ const setCookie = context.response.headers.get("set-cookie");
436
+ if (setCookie) {
437
+ if (hasBetterAuthCookies(setCookie, opts.cookiePrefix)) {
438
+ const prevCookie = getDecrypted(cookieName);
439
+ const toSetCookie = getSetCookie(setCookie || "{}", prevCookie ?? void 0);
440
+ if (hasSessionCookieChanged(prevCookie, toSetCookie)) {
441
+ setEncrypted(cookieName, toSetCookie);
442
+ store?.notify("$sessionSignal");
443
+ } else setEncrypted(cookieName, toSetCookie);
444
+ }
445
+ }
446
+ if (context.request.url.toString().includes("/get-session") && !opts.disableCache) {
447
+ const data = context.data;
448
+ setEncrypted(localCacheName, JSON.stringify(data));
449
+ }
450
+ },
451
+ onError: async (context) => {
452
+ webContents.getFocusedWebContents()?.send(`${getChannelPrefixWithDelimiter(opts.channelPrefix)}error`, {
453
+ ...context.error,
454
+ path: context.request.url
455
+ });
456
+ }
457
+ }
458
+ }],
459
+ getActions: ($fetch, $store, clientOptions) => {
460
+ store = $store;
461
+ const getCookieFn = () => {
462
+ return getCookie(getDecrypted(cookieName) || "{}");
463
+ };
464
+ return {
465
+ getCookie: getCookieFn,
466
+ requestAuth: (options) => requestAuth(clientOptions, opts, options),
467
+ setupRenderer: () => setupRenderer(opts),
468
+ setupMain: (cfg) => setupMain($fetch, store, getCookieFn, opts, clientOptions, cfg),
469
+ $Infer: {}
470
+ };
471
+ }
472
+ };
473
+ };
474
+
475
+ //#endregion
476
+ export { electronClient, handleDeepLink };