mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-26 21:45:40 -05:00
feat: electron integration (#7647)
Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
@@ -39,4 +39,6 @@ gitea
|
||||
MMDD
|
||||
ABCDEFGHJKLMNPQRSTUVWXYZ23456789
|
||||
setex
|
||||
kxeyPrefix
|
||||
kxeyPrefix
|
||||
dyld
|
||||
nsis
|
||||
|
||||
@@ -36,3 +36,5 @@ nuxtzzle
|
||||
Deel
|
||||
WorkOS
|
||||
authjs
|
||||
vite
|
||||
electronjs
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"!**/dist",
|
||||
"!**/coverage",
|
||||
"!**/build",
|
||||
"!**/demo/electron/out",
|
||||
"!**/.next",
|
||||
"!**/.svelte-kit",
|
||||
"!**/.contentlayer",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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/
|
||||
@@ -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()],
|
||||
},
|
||||
});
|
||||
@@ -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 |
@@ -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(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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();
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
import type { authClient } from "../lib/auth-client";
|
||||
|
||||
declare global {
|
||||
type Bridges = typeof authClient.$Infer.Bridges;
|
||||
interface Window extends Bridges {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { authClient } from "../lib/auth-client";
|
||||
|
||||
authClient.setupRenderer();
|
||||
@@ -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>
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.web.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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("; "),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./options";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["esnext", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../core/tsconfig.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineProject } from "vitest/config";
|
||||
|
||||
export default defineProject({
|
||||
test: {
|
||||
server: {
|
||||
deps: {
|
||||
external: ["electron"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+2831
-306
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,8 @@ onlyBuiltDependencies:
|
||||
- '@prisma/engines'
|
||||
- '@swc/core'
|
||||
- better-sqlite3
|
||||
- electron
|
||||
- electron-winstaller
|
||||
- esbuild
|
||||
- less
|
||||
- msw
|
||||
|
||||
Reference in New Issue
Block a user