feat: electron integration (#7647)

Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
Joél Solano
2026-02-04 20:37:05 +00:00
committed by GitHub
co-authored by Alex Yang
parent 001fe7f3f6
commit 1c9aab3bce
72 changed files with 7776 additions and 308 deletions
+3 -1
View File
@@ -39,4 +39,6 @@ gitea
MMDD
ABCDEFGHJKLMNPQRSTUVWXYZ23456789
setex
kxeyPrefix
kxeyPrefix
dyld
nsis
+2
View File
@@ -36,3 +36,5 @@ nuxtzzle
Deel
WorkOS
authjs
vite
electronjs
+1
View File
@@ -132,6 +132,7 @@
"!**/dist",
"!**/coverage",
"!**/build",
"!**/demo/electron/out",
"!**/.next",
"!**/.svelte-kit",
"!**/.contentlayer",
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*
+3
View File
@@ -0,0 +1,3 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
shamefully-hoist=true
+19
View File
@@ -0,0 +1,19 @@
# Better Auth Electron Example
This is an example of how to use Better Auth with Electron.
## Prerequirements
Make sure you've set-up the [Better Auth Demo App](../nextjs/README.md).
## How to run
1. Clone the repo and open it in your code editor
2. Run the following commands
```bash
pnpm install
pnpm dev
```
Checkout the [electron guide](https://www.better-auth.com/docs/integrations/electron) to learn more.
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+44
View File
@@ -0,0 +1,44 @@
appId: com.electron.app
productName: electron
directories:
buildResources: build
files:
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.{js,ts,mjs,cjs}"
- "!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
- "!{.env,.env.*,.npmrc,pnpm-lock.yaml}"
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
asarUnpack:
- resources/**
win:
executableName: electron
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
installerIcon: build/icon.ico
installerHeaderIcon: build/icon.ico
mac:
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
+17
View File
@@ -0,0 +1,17 @@
import { resolve } from "node:path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "electron-vite";
export default defineConfig({
main: {},
preload: {},
renderer: {
resolve: {
alias: {
"@renderer": resolve("src/renderer/src"),
},
},
plugins: [tailwindcss(), react()],
},
});
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@better-auth/electron-demo",
"productName": "Better Auth + Electron",
"private": true,
"version": "1.0.0",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"scripts": {
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir",
"build:win": "npm run build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux"
},
"dependencies": {
"@better-auth/electron": "workspace:*",
"@electron-toolkit/utils": "^4.0.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-slot": "^1.2.3",
"better-auth": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"conf": "^15.0.2",
"electron-store": "^11.0.2",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "catalog:tailwind"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@tailwindcss/vite": "catalog:tailwind",
"@types/node": "^22.19.1",
"@types/react": "catalog:react19",
"@types/react-dom": "catalog:react19",
"@vitejs/plugin-react": "^5.1.1",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"react": "catalog:react19",
"react-dom": "catalog:react19",
"tailwindcss": "catalog:tailwind",
"typescript": "catalog:",
"vite": "^7.2.6"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

+16
View File
@@ -0,0 +1,16 @@
import { electronClient } from "@better-auth/electron/client";
import { storage } from "@better-auth/electron/storage";
import { createAuthClient } from "better-auth/client";
export const authClient = createAuthClient({
baseURL: "http://localhost:3000/api/auth",
plugins: [
electronClient({
protocol: {
scheme: "com.better-auth.demo",
},
signInURL: "http://localhost:3000/sign-in",
storage: storage(),
}),
],
});
+68
View File
@@ -0,0 +1,68 @@
import { join } from "node:path";
import { electronApp, optimizer } from "@electron-toolkit/utils";
import { app, BrowserWindow, shell } from "electron";
import icon from "../../resources/icon.png?asset";
import { authClient } from "../lib/auth-client";
function createWindow(): void {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 900,
height: 670,
show: false,
autoHideMenuBar: true,
title: "Better Auth + Electron",
icon,
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
},
});
mainWindow.on("ready-to-show", () => {
mainWindow.show();
});
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: "deny" };
});
mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
// Set app user model id for windows
electronApp.setAppUserModelId("com.better-auth.demo");
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
createWindow();
app.on("activate", function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
authClient.setupMain();
+6
View File
@@ -0,0 +1,6 @@
import type { authClient } from "../lib/auth-client";
declare global {
type Bridges = typeof authClient.$Infer.Bridges;
interface Window extends Bridges {}
}
+3
View File
@@ -0,0 +1,3 @@
import { authClient } from "../lib/auth-client";
authClient.setupRenderer();
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
import { BackgroundRippleEffect } from "./components/background-ripple-effect";
import { Profile } from "./components/profile";
import { RequestAuth } from "./components/request-auth";
import { useUser } from "./components/user-provider";
function App(): React.JSX.Element {
const { user, loading } = useUser();
return (
<div className="relative flex-1 flex flex-col items-center justify-center">
{/* Background Ripple Effect */}
<div className="absolute inset-0 z-0 overflow-hidden">
<BackgroundRippleEffect />
</div>
<div className="z-10 flex flex-col items-center gap-6">
<h3 className="text-3xl sm:text-4xl text-black dark:text-white text-center">
BETTER-AUTH.
</h3>
<p className="text-center wrap-break-word text-sm md:text-base">
Official demo to showcase{" "}
<a
href="https://better-auth.com"
target="_blank"
className="italic underline"
>
better-auth.
</a>{" "}
with Electron.
</p>
{loading && (
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
className="size-4 animate-spin"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span>Loading...</span>
</div>
)}
{!loading && !user ? <RequestAuth /> : <Profile />}
</div>
</div>
);
}
export default App;
@@ -0,0 +1,134 @@
"use client";
import type React from "react";
import { useMemo, useRef, useState } from "react";
import { cn } from "../lib/utils";
export const BackgroundRippleEffect = ({
rows = 8,
cols = 27,
cellSize = 56,
}: {
rows?: number;
cols?: number;
cellSize?: number;
}) => {
const [clickedCell, setClickedCell] = useState<{
row: number;
col: number;
} | null>(null);
const [rippleKey, setRippleKey] = useState(0);
const ref = useRef<HTMLDivElement | null>(null);
return (
<div
ref={ref}
className={cn(
"absolute inset-0 h-full w-full pointer-events-none",
"[--cell-border-color:hsl(0_0%_85%)] [--cell-fill-color:hsl(0_0%_97%)] [--cell-shadow-color:hsl(0_0%_80%)]",
"dark:[--cell-border-color:hsl(20_14.3%_15%)] dark:[--cell-fill-color:hsl(20_14.3%_7%)] dark:[--cell-shadow-color:hsl(20_14.3%_12%)]",
)}
>
<div className="relative h-auto w-auto overflow-hidden pointer-events-auto">
<div className="pointer-events-none absolute inset-0 z-[2] h-full w-full overflow-hidden" />
<DivGrid
key={`base-${rippleKey}`}
className="mask-radial-from-20% mask-radial-at-top"
rows={rows}
cols={cols}
cellSize={cellSize}
borderColor="var(--cell-border-color)"
fillColor="var(--cell-fill-color)"
clickedCell={clickedCell}
onCellClick={(row, col) => {
setClickedCell({ row, col });
setRippleKey((k) => k + 1);
}}
interactive
/>
</div>
</div>
);
};
type DivGridProps = {
className?: string;
rows: number;
cols: number;
cellSize: number; // in pixels
borderColor: string;
fillColor: string;
clickedCell: { row: number; col: number } | null;
onCellClick?: (row: number, col: number) => void;
interactive?: boolean;
};
type CellStyle = React.CSSProperties & {
["--delay"]?: string;
["--duration"]?: string;
};
const DivGrid = ({
className,
rows = 7,
cols = 30,
cellSize = 56,
borderColor = "#3f3f46",
fillColor = "rgba(14,165,233,0.3)",
clickedCell = null,
onCellClick = () => {},
interactive = true,
}: DivGridProps) => {
const cells = useMemo(
() => Array.from({ length: rows * cols }, (_, idx) => idx),
[rows, cols],
);
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: `repeat(${cols}, ${cellSize}px)`,
gridTemplateRows: `repeat(${rows}, ${cellSize}px)`,
width: cols * cellSize,
height: rows * cellSize,
marginInline: "auto",
};
return (
<div className={cn("relative z-[3]", className)} style={gridStyle}>
{cells.map((idx) => {
const rowIdx = Math.floor(idx / cols);
const colIdx = idx % cols;
const distance = clickedCell
? Math.hypot(clickedCell.row - rowIdx, clickedCell.col - colIdx)
: 0;
const delay = clickedCell ? Math.max(0, distance * 55) : 0; // ms
const duration = 200 + distance * 80; // ms
const style: CellStyle = clickedCell
? {
"--delay": `${delay}ms`,
"--duration": `${duration}ms`,
}
: {};
return (
<div
key={idx}
className={cn(
"cell relative border-[0.5px] opacity-50 transition-opacity duration-150 will-change-transform hover:opacity-80",
clickedCell && "animate-cell-ripple [animation-fill-mode:none]",
!interactive && "pointer-events-none",
)}
style={{
backgroundColor: fillColor,
borderColor: borderColor,
...style,
}}
onClick={
interactive ? () => onCellClick?.(rowIdx, colIdx) : undefined
}
/>
);
})}
</div>
);
};
@@ -0,0 +1,52 @@
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
import { Button } from "./ui/button";
import { useUser } from "./user-provider";
export function Profile() {
const { user } = useUser();
if (!user) {
return null;
}
return (
<div className="flex items-center gap-3 bg-background p-2.5 border border-border rounded-md drop-shadow-md">
<Avatar>
<AvatarImage
src={user.image ?? undefined}
alt={user.name || user.email}
/>
<AvatarFallback>
{(user.name || user.email)?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<p className="flex flex-col max-w-46 leading-tight">
<span className="font-medium truncate">{user.name}</span>
<span className="text-sm text-muted-foreground truncate">
{user.email}
</span>
</p>
<Button
size="sm"
variant="outline"
onClick={() => {
void window.signOut();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
className="size-3.5"
>
<path d="m16 17 5-5-5-5M21 12H9M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
</svg>
Sign Out
</Button>
</div>
);
}
@@ -0,0 +1,150 @@
import type { ElectronRequestAuthOptions } from "@better-auth/electron/client";
import { cn } from "@renderer/lib/utils";
import { useState } from "react";
import { Button } from "./ui/button";
export function RequestAuth() {
const [openedBrowser, setOpenedBrowser] = useState(false);
const handleRequestAuth =
(opts?: ElectronRequestAuthOptions | undefined) => () => {
void window.requestAuth(opts);
setTimeout(() => {
setOpenedBrowser(true);
}, 500);
};
return (
<>
<div className="grid grid-cols-2 gap-2 w-full max-w-sm px-8">
<Button
size="lg"
onClick={handleRequestAuth()}
className="col-span-2 mb-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M5 3H3v4h2V5h14v14H5v-2H3v4h18V3zm12 8h-2V9h-2V7h-2v2h2v2H3v2h10v2h-2v2h2v-2h2v-2h2z"
/>
</svg>
Sign in with Browser
</Button>
<Button
onClick={handleRequestAuth({
provider: "google",
})}
variant="outline"
aria-label="Sign in with Google"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="0.98em"
height="1em"
viewBox="0 0 256 262"
>
<path
fill="#4285F4"
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
></path>
<path
fill="#34A853"
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
></path>
<path
fill="#FBBC05"
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z"
></path>
<path
fill="#EB4335"
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
></path>
</svg>
<span className="hidden sm:inline">Google</span>
</Button>
<Button
onClick={handleRequestAuth({
provider: "github",
})}
variant="outline"
aria-label="Sign in with GitHub"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"
></path>
</svg>
<span className="hidden sm:inline">GitHub</span>
</Button>
<Button
onClick={handleRequestAuth({
provider: "microsoft",
})}
variant="outline"
aria-label="Sign in with Microsoft"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M2 3h9v9H2zm9 19H2v-9h9zM21 3v9h-9V3zm0 19h-9v-9h9z"
></path>
</svg>
<span className="hidden sm:inline">Microsoft</span>
</Button>
<Button
onClick={handleRequestAuth({
provider: "vercel",
})}
variant="outline"
aria-label="Sign in with Vercel"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 256 222"
className="dark:fill-white fill-black"
>
<path d="m128 0l128 221.705H0z" />
</svg>
<span className="hidden sm:inline">Vercel</span>
</Button>
</div>
<div
className={cn(
"transition-all duration-150 ease-in-out overflow-hidden",
openedBrowser ? "max-h-fit mt-4" : "max-h-0",
)}
>
<p
className={cn(
"text-muted-foreground text-center wrap-break-word text-xs md:text-sm",
"delay-150 transition-opacity",
openedBrowser ? "opacity-100" : "opacity-0",
)}
>
A browser window has been opened for authentication.
<br />
Please complete the process there.
</p>
</div>
</>
);
}
@@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within <ThemeProvider />");
return context;
};
@@ -0,0 +1,51 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import type * as React from "react";
import { cn } from "../../lib/utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };
@@ -0,0 +1,59 @@
import { Slot } from "@radix-ui/react-slot";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type * as React from "react";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -0,0 +1,62 @@
import {
createContext,
useContext,
useEffect,
useState,
useTransition,
} from "react";
import type { authClient } from "../../../lib/auth-client";
const UserContext = createContext<{
loading: boolean;
user: (typeof authClient.$Infer.Session)["user"] | null;
} | null>(null);
export function UserProvider({
children,
}: {
children?: React.ReactNode | undefined;
}) {
const [loading, startTransition] = useTransition();
const [user, setUser] = useState<
(typeof authClient.$Infer.Session)["user"] | null
>(null);
useEffect(() => {
startTransition(async () => {
setUser(await window.getUser());
});
const unsubscribe = window.onAuthenticated((u) => {
setUser(u);
});
const unsubscribeUserUpdated = window.onUserUpdated((u) => {
setUser(u);
});
return () => {
unsubscribe();
unsubscribeUserUpdated();
setUser(null);
};
}, []);
return (
<UserContext.Provider
value={{
loading: !user && loading,
user,
}}
>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const state = useContext(UserContext);
if (state === null) {
throw new Error("useUser must be used within <UserProvider />");
}
return state;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+131
View File
@@ -0,0 +1,131 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.216 0.006 56.043);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: oklch(0.216 0.006 56.043);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.709 0.01 56.259);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.709 0.01 56.259);
--scrollbar-thumb: var(--border);
--scrollbar-thumb-hover: var(--ring);
--scrollbar-track: transparent;
--radius: 0.2rem;
--fd-nav-height: 56px;
--fd-banner-height: 0px;
--fd-tocnav-height: 0px;
}
.dark {
--background: hsl(0 0% 0%);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.147 0.004 49.25);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.147 0.004 49.25);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.985 0.001 106.423);
--primary-foreground: oklch(0.216 0.006 56.043);
--secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: oklch(0.985 0.001 106.423);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.268 0.007 34.298);
--input: oklch(0.268 0.007 34.298);
--ring: oklch(0.553 0.013 58.071);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(0.268 0.007 34.298);
--sidebar-ring: oklch(0.553 0.013 58.071);
--scrollbar-thumb: var(--border);
--scrollbar-thumb-hover: var(--ring);
--scrollbar-track: transparent;
}
@theme inline {
--breakpoint-navbar: 64rem;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
html {
@apply bg-background text-foreground;
}
#root {
@apply min-h-dvh flex flex-col;
}
@@ -0,0 +1,7 @@
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+19
View File
@@ -0,0 +1,19 @@
import "./global.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { ThemeProvider } from "./components/theme-provider";
import { UserProvider } from "./components/user-provider";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider
defaultTheme="system"
storageKey="better-auth-demo-electron-theme"
>
<UserProvider>
<App />
</UserProvider>
</ThemeProvider>
</StrictMode>,
);
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"electron.vite.config.*",
"src/main/**/*",
"src/preload/**/*",
"src/lib/auth-client.ts"
],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts",
"src/lib/auth-client.ts"
],
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@renderer/*": ["src/renderer/src/*"]
}
}
}
@@ -40,6 +40,7 @@ export default function SignIn() {
<CardContent>
<div className="grid gap-4">
<SignInForm
params={params}
onSuccess={() => router.push(getCallbackURL(params))}
callbackURL="/dashboard"
/>
@@ -53,6 +54,9 @@ export default function SignIn() {
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
fetchOptions: {
query: params,
},
});
}}
aria-label="Sign in with Google"
@@ -92,6 +96,9 @@ export default function SignIn() {
await authClient.signIn.social({
provider: "github",
callbackURL: "/dashboard",
fetchOptions: {
query: params,
},
});
}}
aria-label="Sign in with GitHub"
@@ -119,6 +126,9 @@ export default function SignIn() {
await authClient.signIn.social({
provider: "microsoft",
callbackURL: "/dashboard",
fetchOptions: {
query: params,
},
});
}}
aria-label="Sign in with Microsoft"
@@ -146,6 +156,9 @@ export default function SignIn() {
await authClient.signIn.social({
provider: "vercel",
callbackURL: "/dashboard",
fetchOptions: {
query: params,
},
});
}}
aria-label="Sign in with Vercel"
@@ -185,6 +198,7 @@ export default function SignIn() {
onClick={async () => {
await authClient.signIn.passkey({
fetchOptions: {
query: params,
onSuccess() {
toast.success("Successfully signed in");
router.push(getCallbackURL(params));
@@ -27,6 +27,7 @@ export function SignUp() {
</CardHeader>
<CardContent>
<SignUpForm
params={params}
onSuccess={() => router.push(getCallbackURL(params))}
callbackURL={getCallbackURL(params)}
/>
+1
View File
@@ -16,6 +16,7 @@ export default function Page() {
useEffect(() => {
authClient.oneTap({
fetchOptions: {
query: params,
onError: ({ error }) => {
toast.error(error.message || "An error occurred");
},
@@ -32,12 +32,14 @@ interface SignInFormProps {
onSuccess?: () => void;
callbackURL?: string;
showPasswordToggle?: boolean;
params?: URLSearchParams;
}
export function SignInForm({
onSuccess,
callbackURL = "/dashboard",
showPasswordToggle = false,
params,
}: SignInFormProps) {
const [loading, startTransition] = useTransition();
const [isMounted, setIsMounted] = useState(false);
@@ -65,6 +67,7 @@ export function SignInForm({
callbackURL,
},
{
query: params ? Object.fromEntries(params.entries()) : undefined,
onSuccess() {
toast.success("Successfully signed in");
onSuccess?.();
@@ -36,11 +36,13 @@ type SignUpFormValues = z.infer<typeof signUpSchema>;
interface SignUpFormProps {
onSuccess?: () => void;
callbackURL?: string;
params?: URLSearchParams;
}
export function SignUpForm({
onSuccess,
callbackURL = "/dashboard",
params,
}: SignUpFormProps) {
const [loading, startTransition] = useTransition();
const { image, imagePreview, handleImageChange, clearImage } =
@@ -66,6 +68,7 @@ export function SignUpForm({
image: image ? await convertImageToBase64(image) : "",
callbackURL,
fetchOptions: {
query: params ? Object.fromEntries(params.entries()) : undefined,
onError: (ctx) => {
toast.error(ctx.error.message);
},
+7
View File
@@ -2,7 +2,9 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useEffect } from "react";
import { getQueryClient } from "@/data/query-client";
import { authClient } from "@/lib/auth-client";
import { ThemeProvider } from "./theme-provider";
import { Toaster } from "./ui/sonner";
@@ -13,6 +15,11 @@ type Props = {
const Providers = ({ children }: Props) => {
const queryClient = getQueryClient();
useEffect(() => {
const id = authClient.ensureElectronRedirect();
return () => clearInterval(id);
}, []);
return (
<ThemeProvider attribute="class" defaultTheme="dark">
<QueryClientProvider client={queryClient}>
+6
View File
@@ -1,4 +1,5 @@
import { dashClient } from "@better-auth/dash/client";
import { electronProxyClient } from "@better-auth/electron/proxy";
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
import { passkeyClient } from "@better-auth/passkey/client";
import { stripeClient } from "@better-auth/stripe/client";
@@ -41,6 +42,11 @@ export const authClient = createAuthClient({
customSessionClient<typeof auth>(),
deviceAuthorizationClient(),
lastLoginMethodClient(),
electronProxyClient({
protocol: {
scheme: "com.better-auth.demo",
},
}),
],
fetchOptions: {
onError(e) {
+3
View File
@@ -1,4 +1,5 @@
import { dash, sendEmail } from "@better-auth/dash";
import { electron } from "@better-auth/electron";
import { oauthProvider } from "@better-auth/oauth-provider";
import { passkey } from "@better-auth/passkey";
import { scim } from "@better-auth/scim";
@@ -435,11 +436,13 @@ const authOptions = {
oauthAuthServerConfig: true,
},
}),
electron(),
],
trustedOrigins: [
"https://*.better-auth.com",
"https://better-auth-demo-*-better-auth.vercel.app",
"exp://",
"com.better-auth.demo:/",
"https://appleid.apple.com",
],
} satisfies BetterAuthOptions;
+1
View File
@@ -18,6 +18,7 @@
"@better-auth/scim": "workspace:*",
"@better-auth/sso": "workspace:*",
"@better-auth/stripe": "workspace:*",
"@better-auth/electron": "workspace:*",
"@hookform/resolvers": "^5.2.2",
"@libsql/kysely-libsql": "^0.4.1",
"@modelcontextprotocol/sdk": "^1.25.3",
+15
View File
@@ -584,4 +584,19 @@ export const Icons = {
></path>
</svg>
),
electron: (props?: SVGProps<any>) => (
<svg
className={props?.className}
viewBox="0 0 24 24"
width="1.2em"
height="1.2em"
xmlns="http://www.w3.org/2000/svg"
fill="none"
>
<path
d="M12.0111 0c-.85 0-1.5392.6891-1.5392 1.5392 0 .8501.6891 1.5393 1.5392 1.5393.595 0 1.11-.338 1.3662-.832 2.2208 1.2675 3.847 5.4728 3.847 10.3623 0 2.0715-.2891 4.056-.825 5.7685a.3215.3215 0 0 0 .2107.403.322.322 0 0 0 .4033-.2111c.5558-1.7763.8542-3.8251.8542-5.9604 0-5.1927-1.7717-9.686-4.3206-11.0027.001-.0223.0035-.0443.0035-.0669 0-.85-.6891-1.5392-1.5393-1.5392zm0 .6432a.896.896 0 1 1 0 1.792.896.896 0 1 1 0-1.792zm-5.486 4.3052c-2.067.0074-3.6473.6646-4.3885 1.9485-.7375 1.2774-.5267 2.971.5113 4.7813a.3217.3217 0 0 0 .558-.32C2.271 9.7274 2.089 8.266 2.6938 7.2185c.821-1.422 3.033-1.9552 5.9321-1.4271a.3216.3216 0 0 0 .1153-.6329c-.784-.1428-1.5271-.2125-2.216-.21zm11.0522.0176a.3216.3216 0 0 0-.0084.6432c1.8337.0239 3.1556.5956 3.7502 1.6256.8192 1.419.1798 3.5947-1.7182 5.837a.322.322 0 0 0 .0377.4535.3215.3215 0 0 0 .4532-.0377c2.0535-2.426 2.7708-4.8661 1.7845-6.5744-.7257-1.257-2.26-1.9207-4.299-1.9472zm-2.6984.2924a.3225.3225 0 0 0-.0647.0072c-1.8568.3979-3.8333 1.1755-5.7314 2.2714-4.5699 2.6384-7.5924 6.4948-7.3601 9.3717-.4726.2628-.7928.7664-.7928 1.3455 0 .85.6892 1.5392 1.5393 1.5392.85 0 1.5392-.6891 1.5392-1.5392 0-.8501-.6891-1.5393-1.5392-1.5393-.038 0-.0754.003-.1128.0057-.1002-2.5597 2.7434-6.1412 7.048-8.6265 1.8413-1.063 3.7551-1.8163 5.5445-2.1997a.3217.3217 0 0 0-.07-.636zm-2.8787 6.2364a1.1192 1.1192 0 0 0-.2243.0255c-.6012.1301-.983.7225-.8533 1.3238.1302.6012.7226.9832 1.3238.8533.6012-.1302.9832-.7226.8533-1.3238-.1139-.526-.5816-.8844-1.0995-.8788zM4.532 13.341a.321.321 0 0 0-.2318.0835.3214.3214 0 0 0-.0214.4542c1.2682 1.3936 2.9157 2.701 4.7946 3.7857 4.4146 2.5489 9.1056 3.2849 11.5608 1.8392a1.53 1.53 0 0 0 .8966.2899c.8501 0 1.5392-.6891 1.5392-1.5392 0-.8501-.689-1.5393-1.5392-1.5393-.85 0-1.5392.6892-1.5392 1.5393 0 .276.0737.5344.201.7584-2.2448 1.214-6.631.5002-10.7976-1.9054-1.8228-1.0524-3.418-2.3181-4.6404-3.6614a.3206.3206 0 0 0-.2226-.1049zm-2.0628 4.0172a.896.896 0 1 1 0 1.792.896.896 0 1 1 0-1.792zm19.0616 0a.896.896 0 1 1 0 1.792.891.891 0 0 1-.5864-.2194c-.0025-.004-.0039-.0083-.0066-.0123a.3195.3195 0 0 0-.0957-.0914.896.896 0 0 1 .6887-1.4689zm-14.0045 1.368a.3215.3215 0 0 0-.3207.4296C8.2793 22.154 10.036 24 12.0111 24c1.4406 0 2.7735-.9822 3.8128-2.711a.3215.3215 0 0 0-.11-.4413.3219.3219 0 0 0-.4415.11c-.934 1.5537-2.0812 2.399-3.2613 2.399-1.6407 0-3.2075-1.6465-4.2-4.4179a.3216.3216 0 0 0-.2848-.2126z"
fill="currentColor"
/>
</svg>
),
};
+6
View File
@@ -1506,6 +1506,12 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
href: "",
icon: LucideAArrowDown,
},
{
title: "Electron",
icon: Icons.electron,
href: "/docs/integrations/electron",
isNew: true,
},
{
title: "Expo",
icon: Icons.expo,
+683
View File
@@ -0,0 +1,683 @@
---
title: Electron Integration
description: Integrate Better Auth with Electron.
---
Electron is a popular framework for building cross-platform desktop applications using web technologies.
Better Auth can be integrated into Electron apps to provide secure authentication flows, leveraging the system browser.
## Installation
<Steps>
<Step>
### Configure a Better Auth front- & back-end
Before integrating with Electron, ensure you have a Better Auth server and client set up.
To get started, check out our [installation](/docs/installation) guide for setting up Better Auth.
</Step>
<Step>
### Install the required packages
Install the Better Auth server package and the Electron integration package in your server, Electron app, and web client projects.
**In each project, run:**
```package-install
better-auth @better-auth/electron
```
</Step>
<Step>
### Add the Electron plugin to your Better Auth server
Add the Electron plugin to your Better Auth server.
```ts title="web/lib/auth.ts"
import { betterAuth } from "better-auth";
import { electron } from "@better-auth/electron";
export const auth = betterAuth({
plugins: [electron()],
emailAndPassword: {
enabled: true,
},
social: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
});
```
</Step>
<Step>
### Add the proxy plugin to your web client
On your frontend, add the proxy plugin to handle redirects back into the Electron app.
```ts title="web/lib/auth-client.ts"
import { createAuthClient } from "better-auth/client";
import { electronProxyClient } from "@better-auth/electron/proxy";
export const authClient = createAuthClient({
baseURL: "http://localhost:8081",
plugins: [
electronProxyClient({
protocol: {
scheme: "com.example.app"
},
}),
],
});
```
</Step>
<Step>
### Initialize the Electron client
```ts title="electron/lib/auth-client.ts"
import { createAuthClient } from "better-auth/client";
import { electronClient } from "@better-auth/electron/client";
export const authClient = createAuthClient({
baseURL: "http://localhost:8081", // Base URL of your Better Auth frontend
plugins: [
electronClient({
signInURL: "https://app.example.com/sign-in", // The URL to redirect to for authentication
protocol: {
scheme: "com.example.app" // The custom protocol scheme registered by your Electron app
},
storage: {
getItem: async (key) => {
// retrieve entry from storage
},
setItem: async (key, value) => {
// set entry in storage
},
},
}),
],
});
```
If you'd rather not implement your own storage solution, we offer a default option:
1. Make sure to install the `conf` package:
```package-install
conf
```
2. Then, you can use it as follows:
```ts title="electron/lib/auth-client.ts"
import { storage } from "@better-auth/electron/storage";
electronClient({
storage: storage(),
});
```
<Callout type="warning">
You should never expose the authClient directly
to the renderer process. Instead, [create an IPC bridge](#creating-ipc-bridges) to securely communicate between the main and renderer processes.
</Callout>
</Step>
<Step>
### Scheme and Trusted Origins
The Electron plugin uses deep links to redirect users back to your app after authentication.
To enable this, you need to add your app's protocol scheme to the `trustedOrigins` on your **Better Auth server**.
First, make sure you have a custom protocol scheme registered in your build configuration.
<Tabs items={["electron-forge", "electron-builder"]}>
<Tab value="electron-forge">
```ts title="forge.config.js"
module.exports = {
packagerConfig: {
protocols: [{
name: "MyApp Protocol",
schemes: ["com.example.app"],
}],
},
// ...other config options
};
```
</Tab>
<Tab value="electron-builder">
```ts title="electron-builder.json5"
{
"protocols": [
{
"name": "MyApp Protocol",
"schemes": ["com.example.app"]
}
],
// ...other config options
}
```
</Tab>
</Tabs>
Then, update your Better Auth config to include the scheme in `trustedOrigins`:
```ts title="web/lib/auth.ts"
export const auth = betterAuth({
trustedOrigins: ["com.example.app:/"],
});
```
</Step>
<Step>
### Configure the BrowserWindow
Ensure that your `BrowserWindow` is configured with `nodeIntegration` set to `false` and `contextIsolation` set to `true`, to ensure NodeJS APIs aren't exposed to any JavaScript process running in the browser.
```ts title="electron/main.ts"
import { BrowserWindow } from "electron";
import { join } from "node:path";
const win = new BrowserWindow({
webPreferences: {
preload: join(__dirname, "preload.mjs"),
nodeIntegration: false,
contextIsolation: true,
},
});
```
</Step>
<Step>
### Setup the Main Process
In your Electron main process, use the `setupMain()` method from the Electron auth client to handle necessary configurations.
This will:
- Register the protocol handler for deep links.
- Set up content security policies.
- Set up IPC bridges for communication between main and renderer processes.
```ts title="electron/main.ts"
import { authClient } from "./lib/auth-client";
authClient.setupMain();
```
Note that this must be called before the app is ready.
</Step>
<Step>
### Setup the Renderer Process
In your preload script, use the `setupRenderer()` method from the Electron auth client to expose safe IPC bridges to the renderer process.
```ts title="electron/preload.ts"
import { authClient } from "./lib/auth-client";
authClient.setupRenderer();
```
Note that this must also be called before the app is ready.
<Callout type="info">
To infer the types of the exposed bridges, you can use `authClient.$Infer.Bridges` to extend the `Window` interface.
```ts title="electron/preload.d.ts"
import type { authClient } from "./lib/auth-client";
declare global {
type Bridges = typeof authClient.$Infer.Bridges;
interface Window extends Bridges {}
}
```
</Callout>
</Step>
</Steps>
## Usage
### Handling Authorization in the Browser
In order to redirect users back to your Electron application, you need to call the `ensureElectronRedirect()` method on your web sign-in callback page. Also make sure to preserve any PKCE and state parameters during the sign-in initiation, by passing them via `fetchOptions.query`.
The following example uses React, but the same logic applies to any framework:
```tsx title="web/pages/sign-in.tsx"
import { useEffect, use } from "react";
import { authClient } from "../auth-client";
function SignIn({
searchParams,
}: {
searchParams: Promise<{
client_id?: string | undefined;
state?: string | undefined;
code_challenge?: string | undefined;
code_challenge_method?: string | undefined;
}>;
}) {
const query = use(searchParams);
useEffect(() => {
const id = authClient.ensureElectronRedirect();
return () => {
clearTimeout(id);
}
}, []);
return (
<button
onClick={() =>
authClient.signIn.social({
provider: "google",
fetchOptions: { query }, // preserve PKCE/state
});
}
>
Sign in with Google
</button>
);
}
```
### Handling Authentication in Electron
In your Electron renderer process, you can use the IPC bridges exposed by the preload script.
```tsx title="electron/App.tsx"
function Auth() {
useEffect(() => {
const unsubscribeAuthenticated =
window.onAuthenticated((user) => {
console.log("Authenticated user:", user);
});
const unsubscribeAuthError =
window.onAuthError((ctx) => {
toast.error(`Authentication error: ${ctx.message}`);
});
return () => {
unsubscribeAuthenticated();
unsubscribeAuthError();
};
}, []);
return (
<>
<button onClick={() => window.requestAuth()}>
Sign in with Browser
</button>
<button
onClick={() =>
window.requestAuth({
provider: "google", // sign in with social provider, redirecting directly to the provider
});
}
>
Sign in with Google
</button>
</>
);
}
```
In the main process, you can call from the auth client directly:
```ts title="electron/main.ts"
import { authClient } from "./lib/auth-client";
authClient.requestAuth();
```
### Sign Out
To sign out the user inside the renderer process, you can use the `signOut` bridge in the renderer process:
```ts title="electron/App.tsx"
<button onClick={() => window.signOut()}>Sign out</button>
```
### Subscribing to User Updates
You can listen for user changes via the `onUserUpdated` bridge in the renderer process:
```ts title="electron/App.tsx"
useEffect(() => {
const unsubscribe = window.onUserUpdated((user) => {
console.log("User updated:", user);
});
return () => unsubscribe();
}, []);
```
### Handling Errors
Listen for authentication errors via the `onAuthError` bridge in the renderer process. The bridge will receive error context forwarded from fetch hooks.
```ts title="electron/error-listener.tsx"
useEffect(() => {
const unsubscribe = window.onAuthError((ctx) => {
console.error("Authentication error:", ctx);
});
return () => unsubscribe();
}, []);
```
### Creating IPC bridges
You should create IPC bridges to extend the functionality exposed to your renderer process. This ensures a minimal, safe API surface.
First, create an IPC handler in the main process that uses the `authClient` to perform the desired action:
```ts title="electron/main.ts"
import { authClient } from "./lib/auth-client";
import { ipcMain } from "electron";
ipcMain.handle("myBridge", async (_event, data) => {
const cookie = authClient.getCookie();
return await authClient.someEndpoint({
data,
fetchOptions: {
headers: { cookie },
},
});
});
```
Next, expose the bridge in your preload script using `contextBridge`:
```ts title="electron/preload.ts"
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("myBridge", (data: Record<string, any>) => {
return ipcRenderer.invoke("myBridge", data);
});
```
<Callout type="info">
To infer the types of your bridge, extend the `Window` interface:
```ts title="electron/preload.d.ts"
import type { authClient } from "./lib/auth-client";
declare global {
type Bridges = typeof authClient.$Infer.Bridges;
interface Window extends Bridges {
myBridge: (data: Record<string, any>) => Promise<any>;
}
}
```
</Callout>
Now you can call your custom bridge from anywhere in the renderer process:
```ts title="electron/App.tsx"
useEffect(() => {
window
.myBridge({
foo: "bar",
})
.then((res) => {
console.log("Bridge response:", res);
});
}, []);
```
For more details, check out Electron's [Inter-Process Communication](https://www.electronjs.org/docs/latest/tutorial/ipc) tutorial.
## Options
### Server plugin
#### `codeExpiresIn?`
The duration, in seconds, for which the authorization code is valid. (defaults to `300`)
Note that the authorization code will be refreshed during active endpoint usage.
```ts title="web/auth.ts"
electron({
codeExpiresIn: 300, // 5 minutes
});
```
#### `redirectCookieExpiresIn?`
The duration, in seconds, for which the redirect cookie remains valid. (defaults to `120`)
```ts title="web/auth.ts"
electron({
redirectCookieExpiresIn: 120, // 2 minutes
});
```
The redirect cookie name is derived by the `clientID`.
#### `cookiePrefix?`
The prefix to use for cookies set by the plugin. (defaults to `better-auth`)
```ts title="web/auth.ts"
electron({
cookiePrefix: "better-auth",
});
```
#### `clientID?`
The client id to use for identifying the Electron client during authorization. (defaults to `electron`)
Make sure this matches the clientID provided to both the proxy and electron client plugin.
```ts title="web/auth.ts"
electron({
clientID: "electron",
});
```
#### `disableOriginOverride?`
Override the origin for Electron API routes. (defaults to `false`)
Enable this if you're facing cors origin issues with Electron API routes.
```ts title="web/auth.ts"
electron({
disableOriginOverride: true,
});
```
### Proxy client
#### `protocol`
The protocol scheme to use for deep linking in Electron.
Should follow the [reverse domain name notation](https://datatracker.ietf.org/doc/html/rfc8252#section-7.1) to ensure uniqueness.
Make sure this matches the protocol scheme provided to the Electron client and `trustedOrigins`.
```ts title="web/auth-client.ts"
electronProxyClient({
protocol: "com.example.app",
});
```
```ts title="web/auth-client.ts"
electronProxyClient({
protocol: {
scheme: "com.example.app",
},
});
```
#### `callbackPath?`
The callback path to use for authentication redirects. (defaults to `/auth/callback`)
Make sure this matches the path provided to the electron client plugin.
```ts title="web/auth-client.ts"
electronProxyClient({
callbackPath: "/auth/callback",
});
```
#### `clientID?`
The client id to use for identifying the Electron client during authorization. (defaults to `electron`)
Make sure this matches the clientID provided to both the server and electron client plugin.
```ts title="web/auth-client.ts"
electronProxyClient({
clientID: "electron",
});
```
#### `cookiePrefix?`
The prefix to use for cookies set by the plugin. (defaults to `better-auth`)
```ts title="web/auth-client.ts"
electronProxyClient({
cookiePrefix: "better-auth",
});
```
### Client plugin
#### `signInURL`
The URL to redirect to for authentication.
```ts title="electron/auth-client.ts"
electronClient({
signInURL: "http://localhost:3000/sign-in",
});
```
#### `protocol`
The protocol scheme to use for deep linking in Electron.
Should follow the [reverse domain name notation](https://datatracker.ietf.org/doc/html/rfc8252#section-7.1) to ensure uniqueness.
Make sure this matches the protocol scheme provided to the proxy client and `trustedOrigins`.
```ts title="web/auth-client.ts"
electronProxyClient({
protocol: "com.example.app",
});
```
```ts title="web/auth-client.ts"
electronProxyClient({
protocol: {
scheme: "com.example.app",
},
});
```
#### `callbackPath?`
The callback path to use for authentication redirects. (defaults to `/auth/callback`)
Make sure this matches the path provided to the proxy client plugin.
```ts title="electron/auth-client.ts"
electronClient({
callbackPath: "/auth/callback",
});
```
#### `storage`
Storage solution to use to store session and cookie data.
By default a storage file is generated in the `userData` directory. The name is derived by the project name.
```ts title="electron/auth-client.ts"
electronClient({
storage: {
getItem: (key) => {
// get entry from storage
},
setItem: (key, value) => {
// set entry in storage
},
},
});
```
#### `storagePrefix?`
Prefix for local storage keys. (defaults to `better-auth`)
```ts title="electron/auth-client.ts"
electronClient({
storagePrefix: "better-auth",
});
```
#### `cookiePrefix?`
Prefix(es) for server cookie names to filter. (defaults to `better-auth`)
This is used to identify which cookies belong to better-auth to
prevent infinite refetching when third-party cookies are set.
```ts title="electron/auth-client.ts"
electronClient({
cookiePrefix: "better-auth",
});
```
```ts title="electron/auth-client.ts"
electronClient({
cookiePrefix: ["better-auth", "my-app"],
});
```
#### `channelPrefix?`
Channel prefix for IPC bridges. (defaults to `better-auth`)
```ts title="electron/auth-client.ts"
electronClient({
channelPrefix: "myapp",
});
```
#### `clientID?`
The client id to use for identifying the Electron client during authorization. (defaults to `electron`)
Make sure this matches the clientID provided to both the server and proxy client plugin.
```ts title="electron/auth-client.ts"
electronClient({
clientID: "electron",
});
```
#### `disableCache?`
Whether to disable caching the session data locally. (defaults to `false`)
```ts title="electron/auth-client.ts"
electronClient({
disableCache: true,
});
```
+10
View File
@@ -81,6 +81,16 @@
"react-native"
]
},
"packages/electron": {
"ignoreDependencies": [
// loading of optional peers
"conf",
// implicit dependencies
"electron"
],
"project": ["src/**/*.ts", "test/**/*.ts"]
},
"packages/stripe": {
"ignoreDependencies": [
// implicit dependencies
@@ -1,4 +1,4 @@
interface CookieAttributes {
export interface CookieAttributes {
value: string;
"max-age"?: number | undefined;
expires?: Date | undefined;
+1
View File
@@ -2,3 +2,4 @@ export * from "../oauth2/state";
export type { StateData } from "../state";
export { generateGenericState, parseGenericState } from "../state";
export * from "./hide-metadata";
export * from "./url";
+14
View File
@@ -0,0 +1,14 @@
# Better Auth Electron Plugin
This plugin integrates Better Auth with Electron, allowing you to easily add
authentication to your Electron applications.
- **Main Better Auth Installation:** [Installation Guide][]
- **Electron Integration Guide:** [Electron Integration Guide][]
## License
MIT
[electron integration guide]: https://www.better-auth.com/docs/integrations/electron
[installation guide]: https://www.better-auth.com/docs/installation
+104
View File
@@ -0,0 +1,104 @@
{
"name": "@better-auth/electron",
"version": "1.5.0-beta.1",
"type": "module",
"description": "Better Auth integration for Electron applications.",
"main": "dist/index.mjs",
"module": "dist/index.mjs",
"types": "dist/index.d.mts",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/better-auth/better-auth",
"directory": "packages/electron"
},
"homepage": "https://www.better-auth.com/docs/integrations/electron",
"keywords": [
"electron",
"auth",
"better-auth",
"typescript"
],
"scripts": {
"test": "vitest",
"coverage": "vitest run --coverage --coverage.provider=istanbul",
"lint:package": "publint run --strict",
"lint:types": "attw --profile esm-only --pack .",
"build": "tsdown",
"dev": "tsdown --watch",
"typecheck": "tsc --project tsconfig.json"
},
"exports": {
".": {
"dev-source": "./src/index.ts",
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"./client": {
"dev-source": "./src/client.ts",
"types": "./dist/client.d.mts",
"default": "./dist/client.mjs"
},
"./proxy": {
"dev-source": "./src/proxy.ts",
"types": "./dist/proxy.d.mts",
"default": "./dist/proxy.mjs"
},
"./storage": {
"dev-source": "./src/storage.ts",
"types": "./dist/storage.d.mts",
"default": "./dist/storage.mjs"
}
},
"typesVersions": {
"*": {
"*": [
"./dist/index.d.mts"
],
"client": [
"./dist/client.d.mts"
],
"proxy": [
"./dist/proxy.d.mts"
],
"storage": [
"./dist/storage.d.mts"
]
}
},
"peerDependenciesMeta": {
"electron": {
"optional": true
},
"conf": {
"optional": true
}
},
"peerDependencies": {
"@better-auth/core": "workspace:*",
"@better-auth/utils": "catalog:",
"@better-fetch/fetch": "catalog:",
"better-auth": "workspace:*",
"better-call": "catalog:",
"conf": "^15.0.2"
},
"dependencies": {
"zod": "^4.3.5"
},
"devDependencies": {
"@better-auth/core": "workspace:*",
"@types/better-sqlite3": "^7.6.13",
"better-auth": "workspace:*",
"better-sqlite3": "^12.6.2",
"tsdown": "catalog:"
},
"engines": {
"node": ">=22.0.0"
},
"files": [
"dist"
]
}
+133
View File
@@ -0,0 +1,133 @@
import type { BetterAuthClientOptions } from "@better-auth/core";
import { BetterAuthError } from "@better-auth/core/error";
import { base64Url } from "@better-auth/utils/base64";
import { createHash } from "@better-auth/utils/hash";
import type { BetterFetch } from "@better-fetch/fetch";
import { APIError, getBaseURL } from "better-auth";
import { signInSocial } from "better-auth/api";
import { generateRandomString } from "better-auth/crypto";
import { shell } from "electron";
import * as z from "zod";
import type { ElectronClientOptions } from "./types/client";
import { isProcessType } from "./utils";
export const kCodeVerifier = Symbol.for("better-auth:code_verifier");
export const kState = Symbol.for("better-auth:state");
const requestAuthOptionsSchema = (() => {
const { provider, idToken, loginHint, ...signInSocialBody } =
signInSocial().options.body.shape;
return z.object({
...signInSocialBody,
provider: z.string().nonempty().optional(),
});
})();
export type ElectronRequestAuthOptions = z.infer<
typeof requestAuthOptionsSchema
>;
/**
* Opens the system browser to request user authentication.
*/
export async function requestAuth(
clientOptions: BetterAuthClientOptions | undefined,
options: ElectronClientOptions,
cfg?: ElectronRequestAuthOptions | undefined,
) {
if (!isProcessType("browser")) {
throw new BetterAuthError(
"`requestAuth` can only be called in the main process",
);
}
const { randomBytes } = await import("node:crypto");
const state = generateRandomString(16, "A-Z", "a-z", "0-9");
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = base64Url.encode(
await createHash("SHA-256").digest(codeVerifier),
);
(globalThis as any)[kCodeVerifier] = codeVerifier;
(globalThis as any)[kState] = state;
let url: URL | null = null;
if (cfg?.provider) {
const baseURL = getBaseURL(
clientOptions?.baseURL,
clientOptions?.basePath,
undefined,
true,
);
if (!baseURL) {
console.log("No base URL found in client options");
throw APIError.from("INTERNAL_SERVER_ERROR", {
code: "NO_BASE_URL",
message: "Base URL is required to use provider-based sign-in.",
});
}
url = new URL(`${baseURL}/electron/init-oauth-proxy`);
for (const [key, value] of Object.entries(cfg)) {
url.searchParams.set(
key,
typeof value === "string" ? value : JSON.stringify(value),
);
}
} else {
url = new URL(options.signInURL);
}
url.searchParams.set("client_id", options.clientID || "electron");
url.searchParams.set("code_challenge", codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);
if (url === null) {
throw new Error("Failed to construct sign-in URL.");
}
await shell.openExternal(url.toString(), {
activate: true,
});
}
/**
* Exchanges the authorization code for a session.
*/
export async function authenticate(
$fetch: BetterFetch,
options: ElectronClientOptions,
body: {
token: string;
},
getWindow: () => Electron.BrowserWindow | null | undefined,
) {
const codeVerifier = (globalThis as any)[kCodeVerifier];
const state = (globalThis as any)[kState];
(globalThis as any)[kCodeVerifier] = undefined;
(globalThis as any)[kState] = undefined;
if (!codeVerifier) {
throw new Error("Code verifier not found.");
}
if (!state) {
throw new Error("State not found.");
}
await $fetch("/electron/token", {
method: "POST",
body: {
...body,
state,
code_verifier: codeVerifier,
},
onSuccess: (ctx) => {
getWindow()?.webContents.send(
`${options.channelPrefix || "better-auth"}:authenticated`,
ctx.data.user,
);
},
throw: true,
});
}
+137
View File
@@ -0,0 +1,137 @@
import type { BetterAuthClientOptions, ClientStore } from "@better-auth/core";
import { BetterAuthError } from "@better-auth/core/error";
import type { BetterFetch, BetterFetchError } from "@better-fetch/fetch";
import type { User } from "better-auth";
import electron from "electron";
import type { ElectronRequestAuthOptions } from "./authenticate";
import { requestAuth } from "./authenticate";
import type { ElectronClientOptions } from "./client";
const { ipcRenderer, ipcMain, contextBridge, webContents } = electron;
export function getChannelPrefixWithDelimiter(ns: string = "better-auth") {
return ns.length > 0 ? ns + ":" : ns;
}
function listenerFactory(
channel: string,
listener: (
event: Electron.IpcRendererEvent,
...args: any[]
) => void | Promise<void>,
) {
ipcRenderer.on(channel, listener);
return () => {
ipcRenderer.off(channel, listener);
};
}
/**
* Exposes IPC bridges to the renderer process.
*/
export function exposeBridges(opts: ElectronClientOptions) {
if (!process.contextIsolated) {
throw new BetterAuthError(
"Context isolation must be enabled to use IPC bridges securely.",
);
}
const prefix = getChannelPrefixWithDelimiter(opts.channelPrefix);
const bridges = {
getUser: async () => {
return (await ipcRenderer.invoke(`${prefix}getUser`)) as User &
Record<string, any>;
},
requestAuth: async (options?: ElectronRequestAuthOptions) => {
await ipcRenderer.invoke(`${prefix}requestAuth`, options);
},
signOut: async () => {
await ipcRenderer.invoke(`${prefix}signOut`);
},
onAuthenticated: (
callback: (user: User & Record<string, any>) => unknown,
) => {
const channel = `${prefix}authenticated`;
return listenerFactory(channel, async (_evt, user) => {
await callback(user);
});
},
onUserUpdated: (
callback: (user: (User & Record<string, any>) | null) => unknown,
) => {
const channel = `${prefix}user-updated`;
return listenerFactory(channel, async (_evt, user) => {
await callback(user);
});
},
onAuthError: (
callback: (context: BetterFetchError & { path: string }) => unknown,
) => {
const channel = `${prefix}error`;
return listenerFactory(channel, async (_evt, context) => {
await callback(context);
});
},
};
for (const [key, value] of Object.entries(bridges)) {
contextBridge.exposeInMainWorld(key, value);
}
return {} as {
$InferBridges: typeof bridges;
};
}
/**
* Sets up IPC bridges in the main process.
*/
export function setupBridges(
ctx: {
$fetch: BetterFetch;
$store: ClientStore | null;
getCookie: () => string;
},
opts: ElectronClientOptions,
clientOptions: BetterAuthClientOptions | undefined,
) {
const prefix = getChannelPrefixWithDelimiter(opts.channelPrefix);
ctx.$store?.atoms.session?.subscribe((state) => {
if (state.isPending === true) return;
webContents
.getFocusedWebContents()
?.send(`${prefix}user-updated`, state?.data?.user ?? null);
});
ipcMain.handle(`${prefix}getUser`, async () => {
const result = await ctx.$fetch<{ user: User & Record<string, any> }>(
"/get-session",
{
method: "GET",
headers: {
cookie: ctx.getCookie(),
"content-type": "application/json",
},
},
);
return result.data?.user ?? null;
});
ipcMain.handle(
`${prefix}requestAuth`,
(_evt, options?: ElectronRequestAuthOptions | undefined) =>
requestAuth(clientOptions, opts, options),
);
ipcMain.handle(`${prefix}signOut`, async () => {
await ctx.$fetch("/sign-out", {
method: "POST",
body: "{}",
headers: {
cookie: ctx.getCookie(),
"content-type": "application/json",
},
});
});
}
+208
View File
@@ -0,0 +1,208 @@
import { Buffer } from "node:buffer";
import { base64 } from "@better-auth/utils/base64";
import type { BetterAuthClientPlugin, ClientStore } from "better-auth";
import { isDevelopment, isTest } from "better-auth";
import electron from "electron";
import type { ElectronRequestAuthOptions } from "./authenticate";
import { requestAuth } from "./authenticate";
import type { exposeBridges } from "./bridges";
import { getChannelPrefixWithDelimiter } from "./bridges";
import {
getCookie,
getSetCookie,
hasBetterAuthCookies,
hasSessionCookieChanged,
} from "./cookies";
import { setupMain, setupRenderer } from "./setup";
import type { ElectronClientOptions, Storage } from "./types/client";
import { isProcessType, parseProtocolScheme } from "./utils";
const { app, safeStorage, webContents } = electron;
const storageAdapter = (storage: Storage) => {
return {
...storage,
getDecrypted: (name: string) => {
const item = storage.getItem(name);
if (!item || typeof item !== "string") return null;
return safeStorage.decryptString(Buffer.from(base64.decode(item)));
},
setEncrypted: (name: string, value: string) => {
return storage.setItem(
name,
base64.encode(safeStorage.encryptString(value)),
);
},
};
};
export const electronClient = (options: ElectronClientOptions) => {
const opts = {
storagePrefix: "better-auth",
cookiePrefix: "better-auth",
channelPrefix: "better-auth",
callbackPath: "/auth/callback",
...options,
};
const { scheme } = parseProtocolScheme(opts.protocol);
let store: ClientStore | null = null;
const cookieName = `${opts.storagePrefix}.cookie`;
const localCacheName = `${opts.storagePrefix}.local_cache`;
const { getDecrypted, setEncrypted } = storageAdapter(opts.storage);
if (
(isDevelopment() || isTest()) &&
// At least 1 dot, no leading or trailing dot, no consecutive dots
/^(?!\.)(?!.*\.\.)(?!.*\.$)[^.]+\.[^.]+$/.test(scheme)
) {
console.warn(
"The provided scheme does not follow the reverse domain name notation. For example: `app.example.com` -> `com.example.app`.",
);
}
return {
id: "electron",
fetchPlugins: [
{
id: "electron",
name: "Electron",
async init(url, options) {
if (!isProcessType("browser")) {
throw new Error(
"Requests must be made from the Electron main process",
);
}
const storedCookie = getDecrypted(cookieName);
const cookie = getCookie(storedCookie || "{}");
options ||= {};
options.credentials = "omit";
options.headers = {
...options.headers,
cookie,
"user-agent": app.userAgentFallback,
"electron-origin": `${scheme}:/`,
"x-skip-oauth-proxy": "true",
};
if (url.endsWith("/sign-out")) {
setEncrypted(cookieName, "{}");
store?.atoms.session?.set({
...store.atoms.session.get(),
data: null,
error: null,
isPending: false,
});
setEncrypted(localCacheName, "{}");
}
return {
url,
options,
};
},
hooks: {
onSuccess: async (context) => {
const setCookie = context.response.headers.get("set-cookie");
if (setCookie) {
if (hasBetterAuthCookies(setCookie, opts.cookiePrefix)) {
const prevCookie = getDecrypted(cookieName);
const toSetCookie = getSetCookie(
setCookie || "{}",
prevCookie ?? undefined,
);
if (hasSessionCookieChanged(prevCookie, toSetCookie)) {
setEncrypted(cookieName, toSetCookie);
store?.notify("$sessionSignal");
} else {
setEncrypted(cookieName, toSetCookie);
}
}
}
if (
context.request.url.toString().includes("/get-session") &&
!opts.disableCache
) {
const data = context.data;
setEncrypted(localCacheName, JSON.stringify(data));
}
},
onError: async (context) => {
webContents
.getFocusedWebContents()
?.send(
`${getChannelPrefixWithDelimiter(opts.channelPrefix)}error`,
{
...context.error,
path: context.request.url,
},
);
},
},
},
],
getActions: ($fetch, $store, clientOptions) => {
store = $store;
const getCookieFn = () => {
const cookie = getDecrypted(cookieName);
return getCookie(cookie || "{}");
};
return {
/**
* Gets the stored cookie.
*
* You can use this to get the cookie stored in
* the device and use it in your fetch requests.
*
* @example
* ```ts
* const cookie = client.getCookie();
* await fetch("https://api.example.com", {
* headers: {
* cookie,
* },
* });
* ```
*/
getCookie: getCookieFn,
/**
* Initiates the authentication process.
* Opens the system's default browser for user authentication.
*/
requestAuth: (options?: ElectronRequestAuthOptions | undefined) =>
requestAuth(clientOptions, opts, options),
/**
* Sets up the renderer process.
*
* - Exposes IPC bridges to the renderer process.
*/
setupRenderer: () => setupRenderer(opts),
/**
* Sets up the main process.
*
* - Registers custom protocol scheme.
* - Registers IPC bridge handlers.
* - Handles content security policy if needed.
*/
setupMain: (cfg?: {
csp?: boolean | undefined;
bridges?: boolean | undefined;
scheme?: boolean | undefined;
getWindow?: () => electron.BrowserWindow | null | undefined;
}) => setupMain($fetch, store, getCookieFn, opts, clientOptions, cfg),
$Infer: {} as {
Bridges: ReturnType<typeof exposeBridges>["$InferBridges"];
},
};
},
} satisfies BetterAuthClientPlugin;
};
export { handleDeepLink } from "./setup";
export * from "./types/client";
+148
View File
@@ -0,0 +1,148 @@
import { parseSetCookieHeader } from "better-auth/cookies";
interface StoredCookie {
value: string;
expires: string | null;
}
export function getSetCookie(header: string, prevCookie?: string | undefined) {
const parsed = parseSetCookieHeader(header);
let toSetCookie: Record<string, StoredCookie> = {};
parsed.forEach((cookie, key) => {
const expiresAt = cookie["expires"];
const maxAge = cookie["max-age"];
const expires = maxAge
? new Date(Date.now() + Number(maxAge) * 1000)
: expiresAt
? new Date(String(expiresAt))
: null;
toSetCookie[key] = {
value: cookie["value"],
expires: expires ? expires.toISOString() : null,
};
});
if (prevCookie) {
try {
const prevCookieParsed = JSON.parse(prevCookie);
toSetCookie = {
...prevCookieParsed,
...toSetCookie,
};
} catch {
//
}
}
return JSON.stringify(toSetCookie);
}
export function getCookie(cookie: string) {
let parsed = {} as Record<string, StoredCookie>;
try {
parsed = JSON.parse(cookie) as Record<string, StoredCookie>;
} catch (_e) {}
const toSend = Object.entries(parsed).reduce((acc, [key, value]) => {
if (value.expires && new Date(value.expires) < new Date()) {
return acc;
}
return `${acc}; ${key}=${value.value}`;
}, "");
return toSend;
}
/**
* Compare if session cookies have actually changed by comparing their values.
* Ignores expiry timestamps that naturally change on each request.
*
* @param prevCookie - Previous cookie JSON string
* @param newCookie - New cookie JSON string
* @returns true if session cookies have changed, false otherwise
*/
export function hasSessionCookieChanged(
prevCookie: string | null,
newCookie: string,
): boolean {
if (!prevCookie) return true;
try {
const prev = JSON.parse(prevCookie) as Record<string, StoredCookie>;
const next = JSON.parse(newCookie) as Record<string, StoredCookie>;
// Get all session-related cookie keys (session_token, session_data)
const sessionKeys = new Set<string>();
Object.keys(prev).forEach((key) => {
if (key.includes("session_token") || key.includes("session_data")) {
sessionKeys.add(key);
}
});
Object.keys(next).forEach((key) => {
if (key.includes("session_token") || key.includes("session_data")) {
sessionKeys.add(key);
}
});
// Compare the values of session cookies (ignore expires timestamps)
for (const key of sessionKeys) {
const prevValue = prev[key]?.value;
const nextValue = next[key]?.value;
if (prevValue !== nextValue) {
return true;
}
}
return false;
} catch {
// If parsing fails, assume cookie changed
return true;
}
}
/**
* Check if the Set-Cookie header contains better-auth cookies.
* This prevents infinite refetching when non-better-auth cookies (like third-party cookies) change.
*
* Supports multiple cookie naming patterns:
* - Default: "better-auth.session_token", "better-auth-passkey", "__Secure-better-auth.session_token"
* - Custom prefix: "myapp.session_token", "myapp-passkey", "__Secure-myapp.session_token"
* - Custom full names: "my_custom_session_token", "custom_session_data"
* - No prefix (cookiePrefix=""): matches any cookie with known suffixes
* - Multiple prefixes: ["better-auth", "my-app"] matches cookies starting with any of the prefixes
*
* @param setCookieHeader - The Set-Cookie header value
* @param cookiePrefix - The cookie prefix(es) to check for. Can be a string, array of strings, or empty string.
* @returns true if the header contains better-auth cookies, false otherwise
*/
export function hasBetterAuthCookies(
setCookieHeader: string,
cookiePrefix: string | string[],
): boolean {
const cookies = parseSetCookieHeader(setCookieHeader);
const cookieSuffixes = ["session_token", "session_data"];
const prefixes = Array.isArray(cookiePrefix) ? cookiePrefix : [cookiePrefix];
// Check if any cookie is a better-auth cookie
for (const name of cookies.keys()) {
// Remove __Secure- prefix if present for comparison
const nameWithoutSecure = name.startsWith("__Secure-")
? name.slice(9)
: name;
// Check against all provided prefixes
for (const prefix of prefixes) {
if (prefix) {
// When prefix is provided, check if cookie starts with the prefix
// This matches all better-auth cookies including session cookies, passkey cookies, etc.
if (nameWithoutSecure.startsWith(prefix)) {
return true;
}
} else {
// When prefix is empty, check for common better-auth cookie patterns
for (const suffix of cookieSuffixes) {
if (nameWithoutSecure.endsWith(suffix)) {
return true;
}
}
}
}
}
return false;
}
+10
View File
@@ -0,0 +1,10 @@
import { defineErrorCodes } from "better-auth";
export const ELECTRON_ERROR_CODES = defineErrorCodes({
INVALID_TOKEN: "Invalid or expired token.",
STATE_MISMATCH: "state mismatch",
MISSING_CODE_CHALLENGE: "missing code challenge",
INVALID_CODE_VERIFIER: "Invalid code verifier",
MISSING_STATE: "state is required",
MISSING_PKCE: "pkce is required",
});
+198
View File
@@ -0,0 +1,198 @@
/// <reference types="electron" />
import type { HookEndpointContext } from "@better-auth/core";
import { createAuthMiddleware } from "@better-auth/core/api";
import { APIError } from "@better-auth/core/error";
import type { BetterAuthPlugin } from "better-auth";
import { safeJSONParse } from "better-auth";
import { generateRandomString } from "better-auth/crypto";
import * as z from "zod";
import { ELECTRON_ERROR_CODES } from "./error-codes";
import { electronInitOAuthProxy, electronToken } from "./routes";
import type { ElectronOptions } from "./types";
declare module "@better-auth/core" {
interface BetterAuthPluginRegistry<AuthOptions, Options> {
electron: {
creator: typeof electron;
};
}
}
export const electron = (options?: ElectronOptions | undefined) => {
const opts = {
codeExpiresIn: 300, // 5 minutes
redirectCookieExpiresIn: 120, // 2 minutes
cookiePrefix: "better-auth",
clientID: "electron",
...(options || {}),
};
const hookMatcher = (ctx: HookEndpointContext) => {
return !!(
ctx.path?.startsWith("/sign-in") ||
ctx.path?.startsWith("/sign-up") ||
ctx.path?.startsWith("/callback") ||
ctx.path?.startsWith("/oauth2/callback") ||
ctx.path?.startsWith("/magic-link/verify") ||
ctx.path?.startsWith("/email-otp/verify-email") ||
ctx.path?.startsWith("/verify-email") ||
ctx.path?.startsWith("/one-tap/callback") ||
ctx.path?.startsWith("/passkey/verify-authentication") ||
ctx.path?.startsWith("/phone-number/verify")
);
};
return {
id: "electron",
async onRequest(request, _ctx) {
if (opts.disableOriginOverride || request.headers.get("origin")) {
return;
}
const electronOrigin = request.headers.get("electron-origin");
if (!electronOrigin) {
return;
}
const req = request.clone();
req.headers.set("origin", electronOrigin);
return {
request: req,
};
},
hooks: {
after: [
{
matcher: (ctx) => !hookMatcher(ctx),
handler: createAuthMiddleware(async (ctx) => {
const transferCookie = await ctx.getSignedCookie(
`${opts.cookiePrefix}.transfer_token`,
ctx.context.secret,
);
if (!ctx.context.newSession?.session || !transferCookie) {
return;
}
const cookie = ctx.context.createAuthCookie("transfer_token", {
maxAge: opts.codeExpiresIn,
});
// Refresh the transfer cookie to extend its validity
// Avoids expiration during multi-step auth flows on active usage
// Can still expire when no endpoint is hit within the valid period
await ctx.setSignedCookie(
cookie.name,
transferCookie,
ctx.context.secret,
cookie.attributes,
);
}),
},
{
matcher: hookMatcher,
handler: createAuthMiddleware(async (ctx) => {
const querySchema = z.object({
client_id: z.string(),
code_challenge: z.string().nonempty(),
code_challenge_method: z.string().optional().default("plain"),
state: z.string().nonempty(),
});
const cookie = ctx.context.createAuthCookie("transfer_token", {
maxAge: opts.codeExpiresIn,
});
if (
ctx.query?.client_id === opts.clientID &&
(ctx.path.startsWith("/sign-in") ||
ctx.path.startsWith("/sign-up"))
) {
const query = querySchema.safeParse(ctx.query);
if (query.success) {
await ctx.setSignedCookie(
cookie.name,
JSON.stringify(query.data),
ctx.context.secret,
cookie.attributes,
);
}
}
if (!ctx.context.newSession?.session) {
return;
}
const transferCookie = await ctx.getSignedCookie(
cookie.name,
ctx.context.secret,
);
ctx.setCookie(cookie.name, "", {
...cookie.attributes,
maxAge: 0,
});
let transferPayload: z.infer<typeof querySchema> | null = null;
if (!!transferCookie) {
transferPayload = safeJSONParse(transferCookie);
} else {
const query = querySchema.safeParse(ctx.query);
if (query.success) {
transferPayload = query.data;
}
}
if (!transferPayload) {
return;
}
const { client_id, code_challenge, code_challenge_method, state } =
transferPayload;
if (client_id !== opts.clientID) {
return;
}
if (!state) {
throw APIError.from(
"BAD_REQUEST",
ELECTRON_ERROR_CODES.MISSING_STATE,
);
}
if (!code_challenge) {
throw APIError.from(
"BAD_REQUEST",
ELECTRON_ERROR_CODES.MISSING_PKCE,
);
}
const redirectCookieName = `${opts.cookiePrefix}.${opts.clientID}`;
const identifier = generateRandomString(32, "a-z", "A-Z", "0-9");
const codeExpiresInMs = opts.codeExpiresIn * 1000;
const expiresAt = new Date(Date.now() + codeExpiresInMs);
await ctx.context.internalAdapter.createVerificationValue({
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: ctx.context.newSession.user.id,
codeChallenge: code_challenge,
codeChallengeMethod: code_challenge_method.toLowerCase(),
state,
}),
expiresAt,
});
ctx.setCookie(redirectCookieName, identifier, {
...ctx.context.authCookies.sessionToken.attributes,
maxAge: opts.redirectCookieExpiresIn,
httpOnly: false,
});
return ctx;
}),
},
],
},
endpoints: {
electronToken: electronToken(opts),
electronInitOAuthProxy: electronInitOAuthProxy(opts),
},
options: opts,
$ERROR_CODES: ELECTRON_ERROR_CODES,
} satisfies BetterAuthPlugin;
};
export type * from "./types";
+75
View File
@@ -0,0 +1,75 @@
import type { BetterAuthClientPlugin } from "@better-auth/core";
import { parseCookies } from "better-auth/cookies";
import type { ElectronProxyClientOptions } from "./types/client";
import { parseProtocolScheme } from "./utils";
export const electronProxyClient = (options: ElectronProxyClientOptions) => {
const opts = {
clientID: "electron",
cookiePrefix: "better-auth",
callbackPath: "/auth/callback",
...options,
};
const redirectCookieName = `${opts.cookiePrefix}.${opts.clientID}`;
const { scheme } = parseProtocolScheme(opts.protocol);
return {
id: "electron-proxy",
getActions: () => {
return {
/**
* Ensures redirecting to the Electron app.
*
* Polls for a cookie set by the server to indicate that an authorization code is available.
*
* @returns The interval ID which can be used to clear the polling.
*/
ensureElectronRedirect: (
cfg?:
| {
/**
* @default 10_000
*/
timeout?: number | undefined;
/**
* @default 100
*/
interval?: number | undefined;
}
| undefined,
) => {
const timeout = cfg?.timeout || 10_000;
const interval = cfg?.interval || 100;
const handleRedirect = () => {
if (typeof document === "undefined") {
return false;
}
const authorizationCode = parseCookies(document.cookie).get(
redirectCookieName,
);
if (!authorizationCode) {
return false;
}
document.cookie = `${redirectCookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`;
window.location.replace(
`${scheme}:/${opts.callbackPath}#token=${authorizationCode}`,
);
return true;
};
const start = Date.now();
const id = setInterval(() => {
const success = handleRedirect();
if (success || Date.now() - start > timeout) {
clearInterval(id);
}
}, interval);
return id;
},
};
},
} satisfies BetterAuthClientPlugin;
};
+191
View File
@@ -0,0 +1,191 @@
import { Buffer } from "node:buffer";
import { timingSafeEqual } from "node:crypto";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
import { SocialProviderListEnum } from "@better-auth/core/social-providers";
import { safeJSONParse } from "@better-auth/core/utils/json";
import { base64Url } from "@better-auth/utils/base64";
import { createHash } from "@better-auth/utils/hash";
import { betterFetch } from "@better-fetch/fetch";
import { createAuthEndpoint } from "better-auth/api";
import { setSessionCookie } from "better-auth/cookies";
import type { User } from "better-auth/db";
import { parseUserOutput } from "better-auth/db";
import * as z from "zod";
import { ELECTRON_ERROR_CODES } from "./error-codes";
import type { ElectronOptions } from "./types";
const electronTokenBodySchema = z.object({
token: z.string().nonempty(),
state: z.string().nonempty(),
code_verifier: z.string().nonempty(),
});
export const electronToken = (opts: ElectronOptions) =>
createAuthEndpoint(
"/electron/token",
{
method: "POST",
body: electronTokenBodySchema,
metadata: {
scope: "http",
},
},
async (ctx) => {
const token = await ctx.context.internalAdapter.findVerificationValue(
`electron:${ctx.body.token}`,
);
if (!token || token.expiresAt < new Date()) {
throw APIError.from("NOT_FOUND", ELECTRON_ERROR_CODES.INVALID_TOKEN);
}
const tokenRecord = safeJSONParse<Record<string, any>>(token.value);
if (!tokenRecord) {
throw APIError.from(
"INTERNAL_SERVER_ERROR",
ELECTRON_ERROR_CODES.INVALID_TOKEN,
);
}
if (tokenRecord.state !== ctx.body.state) {
throw APIError.from("BAD_REQUEST", ELECTRON_ERROR_CODES.STATE_MISMATCH);
}
if (!tokenRecord.codeChallenge) {
throw APIError.from(
"BAD_REQUEST",
ELECTRON_ERROR_CODES.MISSING_CODE_CHALLENGE,
);
}
if (tokenRecord.codeChallengeMethod === "s256") {
const codeChallenge = Buffer.from(
base64Url.decode(tokenRecord.codeChallenge),
);
const codeVerifier = Buffer.from(
await createHash("SHA-256").digest(ctx.body.code_verifier),
);
if (
codeChallenge.length !== codeVerifier.length ||
!timingSafeEqual(codeChallenge, codeVerifier)
) {
throw APIError.from(
"BAD_REQUEST",
ELECTRON_ERROR_CODES.INVALID_CODE_VERIFIER,
);
}
} else {
if (tokenRecord.codeChallenge !== ctx.body.code_verifier) {
throw APIError.from(
"BAD_REQUEST",
ELECTRON_ERROR_CODES.INVALID_CODE_VERIFIER,
);
}
}
await ctx.context.internalAdapter.deleteVerificationValue(token.id);
const user = await ctx.context.internalAdapter.findUserById(
tokenRecord.userId,
);
if (!user) {
throw APIError.from(
"INTERNAL_SERVER_ERROR",
BASE_ERROR_CODES.USER_NOT_FOUND,
);
}
const session = await ctx.context.internalAdapter.createSession(user.id);
if (!session) {
throw APIError.from(
"INTERNAL_SERVER_ERROR",
BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION,
);
}
await setSessionCookie(ctx, {
session,
user,
});
return ctx.json({
token: session.token,
user: parseUserOutput(ctx.context.options, user) as User &
Record<string, any>,
});
},
);
const electronInitOAuthProxyQuerySchema = z.object({
provider: z.string().nonempty(),
state: z.string(),
code_challenge: z.string(),
code_challenge_method: z.string().optional(),
});
export const electronInitOAuthProxy = (opts: ElectronOptions) =>
createAuthEndpoint(
"/electron/init-oauth-proxy",
{
method: "GET",
query: electronInitOAuthProxyQuerySchema,
metadata: {
scope: "http",
},
},
async (ctx) => {
const isSocialProvider = SocialProviderListEnum.safeParse(
ctx.query.provider,
);
if (!isSocialProvider && !ctx.context.getPlugin("generic-oauth")) {
throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PROVIDER_NOT_FOUND);
}
const headers = new Headers(ctx.request?.headers);
headers.set("origin", new URL(ctx.context.baseURL).origin);
let setCookie: string | null = null;
const searchParams = new URLSearchParams();
searchParams.set("client_id", opts.clientID || "electron");
searchParams.set("code_challenge", ctx.query.code_challenge);
searchParams.set(
"code_challenge_method",
ctx.query.code_challenge_method || "plain",
);
searchParams.set("state", ctx.query.state);
const res = await betterFetch<{
url: string | undefined;
redirect: boolean;
user?: User & Record<string, any>;
token?: string;
}>(
`${isSocialProvider ? "/sign-in/social" : "/sign-in/oauth2"}?${searchParams.toString()}`,
{
baseURL: ctx.context.baseURL,
method: "POST",
body: {
provider: ctx.query.provider,
disableRedirect: true,
},
onResponse: (ctx) => {
const headers = ctx.response.headers;
setCookie = headers.get("set-cookie") ?? null;
},
headers,
},
);
if (res.error) {
throw new APIError("INTERNAL_SERVER_ERROR", {
message: res.error.message || "An unknown error occurred.",
});
}
if (setCookie) {
ctx.setHeader("set-cookie", setCookie);
}
if (res.data.url) {
ctx.setHeader("Location", res.data.url);
ctx.setStatus(302);
return;
}
return ctx.json(res.data);
},
);
+289
View File
@@ -0,0 +1,289 @@
import { resolve } from "node:path";
import type { BetterAuthClientOptions, ClientStore } from "@better-auth/core";
import { BetterAuthError } from "@better-auth/core/error";
import type { BetterFetch } from "@better-fetch/fetch";
import electron from "electron";
import { authenticate } from "./authenticate";
import { exposeBridges, setupBridges } from "./bridges";
import type { ElectronClientOptions } from "./client";
import { isProcessType, parseProtocolScheme } from "./utils";
const { app, session, protocol, BrowserWindow } = electron;
export function withGetWindowFallback(
win?: (() => Electron.BrowserWindow | null | undefined) | undefined,
) {
return (
win ??
(() => {
const allWindows = BrowserWindow.getAllWindows();
return allWindows.length > 0 ? allWindows[0] : null;
})
);
}
export function setupRenderer(opts: ElectronClientOptions) {
if (!isProcessType("renderer")) {
throw new BetterAuthError(
"setupRenderer can only be called in the renderer process.",
);
}
void exposeBridges(opts);
}
export type SetupMainConfig = {
getWindow?: () => electron.BrowserWindow | null | undefined;
csp?: boolean | undefined;
bridges?: boolean | undefined;
scheme?: boolean | undefined;
};
export function setupMain(
$fetch: BetterFetch,
$store: ClientStore | null,
getCookie: () => string,
opts: ElectronClientOptions,
clientOptions: BetterAuthClientOptions | undefined,
cfg?: SetupMainConfig | undefined,
) {
if (!isProcessType("browser")) {
throw new BetterAuthError(
"setupMain can only be called in the main process.",
);
}
if (!cfg || cfg.csp === true) {
setupCSP(clientOptions);
}
if (!cfg || cfg.scheme === true) {
registerProtocolScheme($fetch, opts, withGetWindowFallback(cfg?.getWindow));
}
if (!cfg || cfg.bridges === true) {
setupBridges(
{
$fetch,
$store,
getCookie,
},
opts,
clientOptions,
);
}
}
/**
* Handles the deep link URL for authentication.
*/
export async function handleDeepLink({
$fetch,
options,
url,
getWindow,
}: {
$fetch: BetterFetch;
options: ElectronClientOptions;
url: string;
getWindow?: SetupMainConfig["getWindow"] | undefined;
}) {
if (!isProcessType("browser")) {
throw new BetterAuthError(
"`handleDeepLink` can only be called in the main process.",
);
}
let parsedURL: URL | null = null;
try {
parsedURL = new URL(url);
} catch {}
if (!parsedURL) {
return;
}
const { scheme } = parseProtocolScheme(options.protocol);
if (!url.startsWith(`${scheme}:/`)) {
return;
}
const { protocol, pathname, hostname, hash } = parsedURL;
if (protocol !== `${scheme}:`) {
return;
}
const path = "/" + hostname + pathname;
if (path !== (options.callbackPath || "/auth/callback")) {
return;
}
if (!hash.startsWith("#token=")) {
return;
}
const token = hash.substring("#token=".length);
await authenticate(
$fetch,
options,
{
token,
},
withGetWindowFallback(getWindow),
);
}
function registerProtocolScheme(
$fetch: BetterFetch,
options: ElectronClientOptions,
getWindow: () => electron.BrowserWindow | null | undefined,
) {
const { scheme, privileges = {} } =
typeof options.protocol === "string"
? {
scheme: options.protocol,
}
: options.protocol;
protocol.registerSchemesAsPrivileged([
{
scheme,
privileges: {
standard: false,
secure: true,
...privileges,
},
},
]);
let hasSetupProtocolClient = false;
if (process?.defaultApp) {
if (process.argv.length >= 2 && typeof process.argv[1] === "string") {
hasSetupProtocolClient = app.setAsDefaultProtocolClient(
scheme,
process.execPath,
[resolve(process.argv[1])],
);
}
} else {
hasSetupProtocolClient = app.setAsDefaultProtocolClient(scheme);
}
if (!hasSetupProtocolClient) {
console.error(
`Failed to register protocol ${scheme} as default protocol client.`,
);
}
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on("second-instance", async (_event, commandLine, _workingDir, url) => {
// Someone tried to run a second instance, we should focus our window.
const win = getWindow();
if (win) {
if (win.isMinimized()) win.restore();
win.focus();
}
if (!url) {
const maybeURL = commandLine.pop();
if (typeof maybeURL === "string" && maybeURL.trim() !== "") {
try {
url = new URL(maybeURL).toString();
} catch {
//
}
}
}
if (process?.platform !== "darwin" && typeof url === "string") {
await handleDeepLink({
$fetch,
options,
url,
getWindow,
});
}
});
app.on("open-url", async (_event, url) => {
if (process?.platform === "darwin") {
await handleDeepLink({
$fetch,
options,
url,
getWindow,
});
}
});
app.whenReady().then(async () => {
if (
process?.platform !== "darwin" &&
typeof process.argv[1] === "string"
) {
await handleDeepLink({
$fetch,
options,
url: process.argv[1],
getWindow,
});
}
});
}
}
function setupCSP(clientOptions: BetterAuthClientOptions | undefined) {
app.whenReady().then(() => {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const origin = new URL(clientOptions?.baseURL || "", "http://localhost")
.origin;
const cspKey = Object.keys(details.responseHeaders || {}).find(
(k) => k.toLowerCase() === "content-security-policy",
);
if (!cspKey) {
return callback({
responseHeaders: {
...(details.responseHeaders || {}),
"content-security-policy": `connect-src 'self' ${origin}`,
},
});
}
const policy = details.responseHeaders?.[cspKey]?.toString() || "";
const csp = new Map<string, string[]>();
for (let token of policy.split(";")) {
token = token.trim();
if (!token || !/^[\x00-\x7f]*$/.test(token)) continue;
const [rawDirectiveName, ...directiveValue] = token.split(/\s+/);
const directiveName = rawDirectiveName?.toLowerCase();
if (!directiveName) continue;
if (csp.has(directiveName)) continue;
csp.set(directiveName, directiveValue);
}
if (csp.has("connect-src")) {
const values = csp.get("connect-src") || [];
if (!values.includes(origin)) {
values.push(origin);
}
csp.set("connect-src", values);
} else {
csp.set("connect-src", ["'self'", origin]);
}
callback({
responseHeaders: {
...details.responseHeaders,
"content-security-policy": Array.from(csp.entries())
.map(([k, v]) => `${k} ${v.join(" ")}`)
.join("; "),
},
});
});
});
}
+33
View File
@@ -0,0 +1,33 @@
import type { Options as ConfOptions } from "conf";
import Conf from "conf";
import electron from "electron";
import type { Storage } from "./client";
const { app } = electron;
export const storage = (
opts?: ConfOptions<Record<string, any>> | undefined,
): Storage => {
if (!app) {
return {
getItem: () => null,
setItem: () => {},
};
}
const config = new Conf({
cwd: app.getPath("userData"),
projectName: app.getName(),
projectVersion: app.getVersion(),
...opts,
});
return {
getItem: (key) => {
return config.get(key, null);
},
setItem: (key, value) => {
config.set(key, value);
},
};
};
+118
View File
@@ -0,0 +1,118 @@
export interface Storage {
getItem: (name: string) => unknown | null;
setItem: (name: string, value: unknown) => void;
}
export interface ElectronClientOptions {
/**
* The URL to redirect to for authentication.
*
* @example "http://localhost:3000/sign-in"
*/
signInURL: string | URL;
/**
* The protocol scheme to use for deep linking in Electron.
*
* Should follow the reverse domain name notation to ensure uniqueness.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc8252#section-7.1}
* @example "com.example.app"
*/
protocol:
| string
| {
scheme: string;
privileges?: Electron.Privileges | undefined;
};
/**
* The callback path to use for authentication redirects.
*
* @default "/auth/callback"
*/
callbackPath?: string;
/**
* An instance of a storage solution (e.g., `electron-store`)
* to store session and cookie data.
*
* @example
* ```ts
* electronClient({
* storage: window.localStorage,
* });
* ```
*/
storage: Storage;
/**
* Prefix for local storage keys (e.g., "my-app_cookie", "my-app_session_data")
* @default "better-auth"
*/
storagePrefix?: string | undefined;
/**
* Prefix(es) for server cookie names to filter (e.g., "better-auth.session_token")
* This is used to identify which cookies belong to better-auth to prevent
* infinite refetching when third-party cookies are set.
*
* Can be a single string or an array of strings to match multiple prefixes.
*
* @default "better-auth"
* @example "better-auth"
* @example ["better-auth", "my-app"]
*/
cookiePrefix?: string | string[] | undefined;
/**
* Channel prefix for IPC bridges (e.g., "better-auth:request-auth")
*
* @default "better-auth"
*/
channelPrefix?: string | undefined;
/**
* Client ID to use for identifying the Electron client during authorization.
*
* @default "electron"
*/
clientID?: string | undefined;
/**
* Whether to disable caching the session data locally.
*
* @default false
*/
disableCache?: boolean | undefined;
}
export interface ElectronProxyClientOptions {
/**
* The protocol scheme to use for deep linking in Electron.
*
* Should follow the reverse domain name notation to ensure uniqueness.
*
* Note that this must match the protocol scheme registered in the server plugin.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc8252#section-7.1}
* @example "com.example.app"
*/
protocol:
| string
| {
scheme: string;
};
/**
* The callback path to use for authentication redirects.
*
* @default "/auth/callback"
*/
callbackPath?: string;
/**
* Client ID to use for identifying the Electron client during authorization.
*
* @default "electron"
*/
clientID?: string | undefined;
/**
* The prefix to use for cookies set by the plugin.
*
* @default "better-auth"
*/
cookiePrefix?: string | undefined;
}
export type { ElectronRequestAuthOptions } from "../authenticate";
+1
View File
@@ -0,0 +1 @@
export * from "./options";
+33
View File
@@ -0,0 +1,33 @@
export interface ElectronOptions {
/**
* The duration (in seconds) for which the authorization code remains valid.
*
* @default 300 (5 minutes)
*/
codeExpiresIn?: number | undefined;
/**
* The duration (in seconds) for which the redirect cookie remains valid.
*
* @default 120 (2 minutes)
*/
redirectCookieExpiresIn?: number | undefined;
/**
* The prefix to use for cookies set by the plugin.
*
* @default "better-auth"
*/
cookiePrefix?: string | undefined;
/**
* Client ID to use for identifying the Electron client during authorization.
*
* @default "electron"
*/
clientID?: string | undefined;
/**
* Override the origin for Electron API routes.
* Enable this if you're facing cors origin issues with Electron API routes.
*
* @default false
*/
disableOriginOverride?: boolean | undefined;
}
+21
View File
@@ -0,0 +1,21 @@
import type { ElectronClientOptions } from "./types/client";
export function isProcessType(type: typeof process.type) {
return typeof process !== "undefined" && process.type === type;
}
export function parseProtocolScheme(
protocolOption: ElectronClientOptions["protocol"],
) {
if (typeof protocolOption === "string") {
return {
scheme: protocolOption,
privileges: {},
};
}
return {
scheme: protocolOption.scheme,
privileges: protocolOption.privileges || {},
};
}
File diff suppressed because it is too large Load Diff
+127
View File
@@ -0,0 +1,127 @@
/// <reference types="electron" />
import type { BetterAuthOptions } from "better-auth";
import { betterAuth } from "better-auth";
import type { FetchEsque } from "better-auth/client";
import { createAuthClient } from "better-auth/client";
import { getMigrations } from "better-auth/db/migration";
import { oAuthProxy } from "better-auth/plugins";
import Database from "better-sqlite3";
import { afterAll, beforeAll, test, vi } from "vitest";
import { electronClient } from "../src/client";
import { electron } from "../src/index";
import { electronProxyClient } from "../src/proxy";
import type { ElectronClientOptions } from "../src/types/client";
export const it = test.extend<{
setProcessType: (type: typeof process.type) => void;
}>({
setProcessType: async ({}, use) => {
const originalType = process.type;
const originalVersions = process.versions;
const assignProcessType = (type: typeof process.type) => {
Object.defineProperties(process, {
type: {
configurable: true,
get: () => type,
},
versions: {
configurable: true,
get: () => ({
...originalVersions,
electron: "some-version",
}),
},
});
};
try {
await use(assignProcessType);
} finally {
Object.defineProperties(process, {
type: { configurable: true, value: originalType },
versions: { configurable: true, value: originalVersions },
});
}
},
});
function getTestInstance(overrideOpts?: BetterAuthOptions) {
const storage = new Map<string, any>();
const options = {
signInURL: "http://localhost:3000/sign-in",
protocol: {
scheme: "myapp",
},
storage: {
getItem: (name) => {
return storage.get(name) || null;
},
setItem: (name, value) => {
storage.set(name, value);
},
},
} satisfies ElectronClientOptions;
const auth = betterAuth({
baseURL: "http://localhost:3000",
database: new Database(":memory:"),
emailAndPassword: {
enabled: true,
},
socialProviders: {
google: {
clientId: "test",
clientSecret: "test",
},
},
plugins: [electron(), oAuthProxy()],
trustedOrigins: ["myapp:/"],
...(overrideOpts || {}),
});
const customFetchImpl: FetchEsque = (url, init) => {
const req = new Request(url.toString(), init);
return auth.handler(req);
};
const proxyClient = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
},
plugins: [electronProxyClient(options)],
});
const client = createAuthClient({
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
},
plugins: [electronClient(options)],
});
return {
auth,
proxyClient,
client,
options,
customFetchImpl,
};
}
export function testUtils(overrideOpts?: BetterAuthOptions) {
const testInstance = getTestInstance(overrideOpts);
beforeAll(async () => {
const { runMigrations } = await getMigrations(testInstance.auth.options);
await runMigrations();
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
return testInstance;
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["esnext", "dom", "dom.iterable"]
},
"include": ["./src"],
"references": [
{
"path": "../core/tsconfig.json"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "tsdown";
export default defineConfig({
dts: { build: true, incremental: true },
format: ["esm"],
entry: [
"./src/index.ts",
"./src/client.ts",
"./src/proxy.ts",
"./src/storage.ts",
],
external: ["better-auth", "better-call", "@better-fetch/fetch", "electron"],
treeshake: true,
});
+11
View File
@@ -0,0 +1,11 @@
import { defineProject } from "vitest/config";
export default defineProject({
test: {
server: {
deps: {
external: ["electron"],
},
},
},
});
+2831 -306
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -40,6 +40,8 @@ onlyBuiltDependencies:
- '@prisma/engines'
- '@swc/core'
- better-sqlite3
- electron
- electron-winstaller
- esbuild
- less
- msw