import { randomBytes } from "node:crypto"; import { createAuthMiddleware } from "@better-auth/core/api"; import { BetterAuthError } from "@better-auth/core/error"; import { base64Url } from "@better-auth/utils/base64"; import { createAuthClient } from "better-auth/client"; import { parseSetCookieHeader } from "better-auth/cookies"; import { generateRandomString } from "better-auth/crypto"; import { getMigrations } from "better-auth/db/migration"; import { generateCodeChallenge } from "better-auth/oauth2"; import { beforeEach, describe, expect, vi } from "vitest"; import { authenticate, kElectron } from "../src/authenticate"; import { electronClient } from "../src/client"; import { getCookie } from "../src/cookies"; import { ELECTRON_ERROR_CODES } from "../src/error-codes"; import { electron } from "../src/index"; import { fetchUserImage, normalizeUserOutput } from "../src/user"; import { encodeRedirectToken, it, testUtils } from "./utils"; const mockElectron = vi.hoisted(() => { const BrowserWindow = { constructor: vi.fn(), send: vi.fn(), webContents: { send: vi.fn(), }, }; const electron = { ipcMain: { handle: vi.fn(), }, app: { userAgentFallback: "test-user-agent", whenReady: vi.fn(() => Promise.resolve()), setAsDefaultProtocolClient: vi.fn(() => true), requestSingleInstanceLock: vi.fn(() => true), on: vi.fn(), quit: vi.fn(), }, shell: { openExternal: vi.fn(), }, safeStorage: { isEncryptionAvailable: vi.fn(() => true), encryptString: vi.fn((str: string) => Buffer.from(str).toString("base64"), ), decryptString: vi.fn((buf: Buffer) => Buffer.from(buf.toString(), "base64").toString(), ), }, webContents: { getFocusedWebContents: vi.fn(() => BrowserWindow), }, session: { defaultSession: { webRequest: { onBeforeSendHeaders: vi.fn(), onHeadersReceived: vi.fn(), }, }, }, net: { fetch: vi.fn(), }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn(), }, BrowserWindow, }; return { ...electron, default: electron, }; }); vi.mock("electron", () => mockElectron); describe("Electron", () => { const { auth, client, proxyClient, options, customFetchImpl } = testUtils(); async function s256Challenge(verifier: string) { return generateCodeChallenge(verifier); } it("should throw error when making requests outside the main process", async ({ setProcessType, }) => { setProcessType("renderer"); await expect(client.getSession()).rejects.toThrowError( "Requests must be made from the Electron main process", ); }); it("should open external url in default browser", async ({ setProcessType, }) => { setProcessType("browser"); await client.requestAuth(); (globalThis as any)[kElectron] = undefined; expect(mockElectron.shell.openExternal).toHaveBeenCalledWith( expect.stringContaining(options.signInURL), { activate: true, }, ); }); it("should set redirect cookie after signing in", async () => { (globalThis as any)[kElectron] = new Map([ ["abc", "test-challenge"], ]); const challenge = await s256Challenge("test-challenge"); const { error } = await proxyClient.signUp.email( { email: "test@test.com", password: "password", name: "Test User", }, { query: { client_id: "electron", code_challenge: challenge, state: "abc", }, onResponse: async (ctx) => { const cookies = parseSetCookieHeader( ctx.response.headers.get("set-cookie") || "", ); const redirectCookie = cookies.get(`better-auth.electron`); expect(redirectCookie).toBeDefined(); expect(redirectCookie?.httponly).not.toBe(true); expect(redirectCookie?.["max-age"]).toStrictEqual(120); }, customFetchImpl: (url, init) => { const req = new Request(url.toString(), init); return auth.handler(req); }, }, ); expect(error).toBeNull(); }); it("should include `electron_authorization_code` in sign-up response", async () => { (globalThis as any)[kElectron] = new Map([ ["abc", "test-challenge"], ]); const challenge = await s256Challenge("test-challenge"); const { data } = await proxyClient.signUp.email( { email: "electron-code-test@test.com", password: "password", name: "Electron Code Test", }, { query: { client_id: "electron", code_challenge: challenge, state: "abc", }, }, ); expect(data).not.toBeNull(); expect(data).toHaveProperty("electron_authorization_code"); // @ts-expect-error expect(data!.electron_authorization_code).toBeTypeOf("string"); }); it("/electron/init-oauth-proxy should forward each Set-Cookie from the inner sign-in response as a separate header", async () => { const innerHeaders = new Headers(); innerHeaders.append("set-cookie", "first.cookie=one; Path=/; HttpOnly"); innerHeaders.append("set-cookie", "second.cookie=two; Path=/; HttpOnly"); vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response( JSON.stringify({ url: "https://provider.example/oauth/authorize", redirect: true, }), { status: 200, headers: innerHeaders }, ), ); const res = (await (auth.api as any).electronInitOAuthProxy({ query: { provider: "google", state: "x", code_challenge: "y", code_challenge_method: "S256", }, asResponse: true, })) as Response; const setCookies = res.headers.getSetCookie(); expect(setCookies).toHaveLength(2); expect(setCookies[0]).toMatch(/^first\.cookie=one/); expect(setCookies[1]).toMatch(/^second\.cookie=two/); }); it("should exchange token", async ({ setProcessType }) => { setProcessType("browser"); const { user } = await auth.api.signInEmail({ body: { email: "test@test.com", password: "password", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); const { data } = await client.$fetch("/electron/token", { method: "POST", body: { token: identifier, code_verifier: codeVerifier, state: "abc", }, onResponse: async (ctx) => { const cookies = parseSetCookieHeader( ctx.response.headers.get("set-cookie") || "", ); expect(cookies.has("better-auth.session_token")).toBe(true); }, }); expect(data?.token).toBeDefined(); expect(data?.user.id).toBe(user.id); expect(mockElectron.safeStorage.encryptString).toHaveBeenCalled(); }); // The Electron authorization code is single-use. Two concurrent exchanges of // the same valid token/state/code_verifier must yield exactly one session; // the losing racer must be rejected because the code is consumed atomically. it("should mint only one session for concurrent exchanges of the same code", async ({ setProcessType, }) => { setProcessType("browser"); const { user } = await auth.api.signUpEmail({ body: { email: "concurrent-exchange@test.com", password: "password", name: "Concurrent Exchange", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await generateCodeChallenge(codeVerifier); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, codeChallengeMethod: "s256", state: "abc", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); const exchange = () => client.$fetch("/electron/token", { method: "POST", body: { token: identifier, code_verifier: codeVerifier, state: "abc", }, }); const results = await Promise.all([exchange(), exchange()]); const succeeded = results.filter((r) => r.data?.token); const failed = results.filter((r) => r.error); expect(succeeded).toHaveLength(1); expect(failed).toHaveLength(1); expect(failed[0]?.error?.status).toBe(404); }); it("should emit authenticated event on success", async ({ setProcessType, }) => { setProcessType("browser"); const { user } = await auth.api.signInEmail({ body: { email: "test@test.com", password: "password", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); (globalThis as any)[kElectron] = new Map([ ["abc", codeVerifier], ]); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); await expect( authenticate({ $fetch: client.$fetch, options, token: encodeRedirectToken(identifier, "abc"), // @ts-expect-error getWindow: () => mockElectron.BrowserWindow, }), ).resolves.toBeDefined(); expect(mockElectron.BrowserWindow.webContents.send).toHaveBeenCalledWith( "better-auth:authenticated", expect.objectContaining({ id: user.id, }), ); }); it("should emit user-updated event when session atom updates", async ({ setProcessType, }) => { setProcessType("browser"); client.setupMain(); const mockUser = { id: "test-user", }; client.$store.atoms.session!.set({ data: { user: mockUser, }, }); // flush await Promise.resolve(); expect(mockElectron.BrowserWindow.send).toHaveBeenCalledWith( "better-auth:user-updated", mockUser, ); }); it("should reject expired tokens", async ({ setProcessType }) => { setProcessType("browser"); const { user } = await auth.api.signInEmail({ body: { email: "test@test.com", password: "password", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); (globalThis as any)[kElectron] = new Map([ ["abc", codeVerifier], ]); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 999), }, }); vi.advanceTimersByTime(1000); await expect( authenticate({ $fetch: client.$fetch, options, token: encodeRedirectToken(identifier, "abc"), // @ts-expect-error getWindow: () => mockElectron.BrowserWindow, fetchOptions: { throw: true }, }).catch((err: any) => { expect(err.error.message).toBe("Invalid or expired token."); throw err; }), ).rejects.toThrowError("NOT_FOUND"); }); it("should reject invalid/non-existent tokens", async ({ setProcessType, }) => { setProcessType("browser"); (globalThis as any)[kElectron] = new Map([ ["abc", "test-challenge"], ]); await expect( authenticate({ $fetch: client.$fetch, options, token: encodeRedirectToken("non-existent", "abc"), // @ts-expect-error getWindow: () => mockElectron.BrowserWindow, fetchOptions: { throw: true }, }).catch((err: any) => { expect(err.error.message).toBe("Invalid or expired token."); throw err; }), ).rejects.toThrowError("NOT_FOUND"); }); it("should emit error event on failure", async ({ setProcessType }) => { setProcessType("browser"); await client.changeEmail({ // @ts-expect-error newEmail: null, }); expect(mockElectron.BrowserWindow.send).toHaveBeenCalledWith( "better-auth:error", expect.objectContaining({ status: 400, }), ); }); it("should error when user referenced by token does not exist", async ({ setProcessType, }) => { setProcessType("browser"); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); // Create verification referencing a non-existent user id const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: "non-existent-user", codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300_000), }, }); await expect( client .$fetch("/electron/token", { method: "POST", body: { token: identifier, code_verifier: codeVerifier, state: "abc", }, throw: true, customFetchImpl: (url, init) => { const req = new Request(url.toString(), init); return auth.handler(req); }, }) .catch((err) => { throw err.cause; }), ).rejects.toThrow( expect.objectContaining({ code: auth.$ERROR_CODES.USER_NOT_FOUND.code, }), ); }); it("should throw when createSession returns null", async ({ setProcessType, }) => { setProcessType("browser"); const { user } = await auth.api.signUpEmail({ body: { name: "Test User", email: "test-create-session-null@test.com", password: "password", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300_000), }, }); const ctx = await auth.$context; const original = ctx.internalAdapter.createSession; try { ctx.internalAdapter.createSession = vi.fn().mockResolvedValue(null); await expect( client.$fetch("/electron/token", { method: "POST", body: { token: identifier, code_verifier: codeVerifier, state: "abc", }, throw: true, customFetchImpl: (url, init) => { const req = new Request(url.toString(), init); return auth.handler(req); }, }), ).rejects.toThrow(/FAILED_TO_CREATE_SESSION|INTERNAL_SERVER_ERROR/); } finally { ctx.internalAdapter.createSession = original; } }); it("requestAuth should throw if called outside browser process", async ({ setProcessType, }) => { setProcessType("renderer"); await expect(client.requestAuth()).rejects.toThrowError( "`requestAuth` can only be called in the main process", ); }); it("authenticate should throw error if called outside browser process", async ({ setProcessType, }) => { setProcessType("renderer"); await expect( authenticate({ $fetch: client.$fetch, options, token: "any", getWindow: () => null, }), ).rejects.toThrow(BetterAuthError); await expect( authenticate({ $fetch: client.$fetch, options, token: "any", getWindow: () => null, }), ).rejects.toThrowError( "`authenticate` can only be called in the main process.", ); }); it("authenticate should exchange token and return user", async ({ setProcessType, }) => { setProcessType("browser"); client.setupMain({ // @ts-expect-error getWindow: () => mockElectron.BrowserWindow, }); const { user } = await auth.api.signInEmail({ body: { email: "test@test.com", password: "password", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); (globalThis as any)[kElectron] = new Map([ ["abc", codeVerifier], ]); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); const result = await client.authenticate({ token: encodeRedirectToken(identifier, "abc"), }); expect(result.data?.user?.id).toBe(user.id); expect(mockElectron.BrowserWindow.webContents.send).toHaveBeenCalledWith( "better-auth:authenticated", expect.objectContaining({ id: user.id }), ); }); it("IPC authenticate bridge should exchange token via invoke", async ({ setProcessType, }) => { setProcessType("browser"); mockElectron.ipcMain.handle.mockClear(); client.setupMain({ bridges: true, getWindow: () => mockElectron.BrowserWindow as any, }); const authenticateHandler = mockElectron.ipcMain.handle.mock.calls.find( (call) => call[0] === "better-auth:authenticate", )?.[1] as (evt: unknown, data: { token: string }) => Promise; expect(authenticateHandler).toBeDefined(); const { user } = await auth.api.signInEmail({ body: { email: "test@test.com", password: "password", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); (globalThis as any)[kElectron] = new Map([ ["abc", codeVerifier], ]); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); await authenticateHandler(null, { token: encodeRedirectToken(identifier, "abc"), }); expect(mockElectron.BrowserWindow.webContents.send).toHaveBeenCalledWith( "better-auth:authenticated", expect.objectContaining({ id: user.id }), ); }); it("should delete verification entry after successful token exchange", async ({ setProcessType, }) => { setProcessType("browser"); const { user } = await auth.api.signUpEmail({ body: { name: "Test User", email: "test-delete-verification@test.com", password: "password", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); const ctx = await auth.$context; const { data } = await client.$fetch("/electron/token", { method: "POST", body: { token: identifier, code_verifier: codeVerifier, state: "abc", }, }); expect(data?.token).toBeDefined(); expect(data?.user.id).toBe(user.id); const remaining = await ctx.internalAdapter.findVerificationValue( `electron:${identifier}`, ); expect(remaining).toBeNull(); }); describe("transferUser", () => { const transferQuery = "client_id=electron&state=xyz&code_challenge=challenge"; const post = (cookie: string, body?: object) => auth.handler( new Request( `http://localhost:3000/api/auth/electron/transfer-user?${transferQuery}`, { method: "POST", headers: { cookie: cookie, "content-type": "application/json", }, body: JSON.stringify(body ?? {}), }, ), ); async function getSessionCookie() { let res = await auth.handler( new Request("http://localhost:3000/api/auth/sign-up/email", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "transfer-test@test.com", password: "password", name: "Transfer Test", }), }), ); if (res.status !== 200) { res = await auth.handler( new Request("http://localhost:3000/api/auth/sign-in/email", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "transfer-test@test.com", password: "password", }), }), ); } const setCookie = res.headers.get("set-cookie") ?? ""; const parsed = parseSetCookieHeader(setCookie); const parts: string[] = []; parsed.forEach((value, name) => { parts.push(`${name}=${value.value}`); }); return parts.join("; "); } it("should return url and redirect from body when callbackURL provided", async () => { const cookie = await getSessionCookie(); const res = await post(cookie, { callbackURL: "https://app.example.com/callback", }); expect(res.status).toBe(200); const data = await res.json(); expect(data).toMatchObject({ url: "https://app.example.com/callback", redirect: true, electron_authorization_code: expect.any(String), }); }); it("should return null url and false redirect when callbackURL omitted", async () => { const cookie = await getSessionCookie(); const res = await post(cookie); expect(res.status).toBe(200); const data = await res.json(); expect(data).toMatchObject({ url: null, redirect: false, electron_authorization_code: expect.any(String), }); }); it("should throw INVALID_CLIENT_ID when client_id does not match", async () => { const cookie = await getSessionCookie(); const res = await auth.handler( new Request( "http://localhost:3000/api/auth/electron/transfer-user?client_id=wrong&state=xyz&code_challenge=challenge", { method: "POST", headers: { cookie, "content-type": "application/json", }, body: JSON.stringify({}), }, ), ); expect(res.status).toBe(400); const data = await res.json(); expect(data.code).toBe(ELECTRON_ERROR_CODES.INVALID_CLIENT_ID.code); }); it("should set redirect cookie on success", async () => { const cookie = await getSessionCookie(); const res = await post(cookie); expect(res.status).toBe(200); const setCookie = res.headers.get("set-cookie") ?? ""; const cookies = parseSetCookieHeader(setCookie); expect(cookies.has("better-auth.electron")).toBe(true); }); }); it("should register protocol", async ({ setProcessType }) => { setProcessType("browser"); client.setupMain({ scheme: true, }); expect( mockElectron.protocol.registerSchemesAsPrivileged, ).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ scheme: options.protocol.scheme, }), ]), ); expect(mockElectron.app.setAsDefaultProtocolClient).toHaveBeenCalledWith( options.protocol.scheme, ); }); it("should add client origin to CSP if missing", async ({ setProcessType, }) => { setProcessType("browser"); client.setupMain({ csp: true, }); // wait a tick for whenReady then-callback to run await Promise.resolve(); const onHeadersReceived = mockElectron.session.defaultSession.webRequest.onHeadersReceived; expect(onHeadersReceived).toHaveBeenCalled(); const handler = onHeadersReceived.mock.calls[0][0]; const details = { responseHeaders: {}, }; let callbackResult: any = null; const callback = (res: any) => { callbackResult = res; }; await handler(details, callback); expect(callbackResult).not.toBeNull(); const respHeaders = callbackResult.responseHeaders; expect(respHeaders).toBeDefined(); const csp = String(respHeaders["content-security-policy"]); expect(csp).toContain("connect-src"); expect(csp).toContain("http://localhost:3000"); }); describe("pkce", () => { it("should require pkce", async ({ setProcessType }) => { setProcessType("browser"); const { user } = await auth.api.signInEmail({ body: { email: "test@test.com", password: "password", }, }); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); await expect( authenticate({ $fetch: client.$fetch, options, token: encodeRedirectToken(identifier, "no-match"), // @ts-expect-error getWindow: () => mockElectron.BrowserWindow, }), ).rejects.toThrowError("Code verifier not found."); }); it("should require a state parameter", async ({ setProcessType }) => { setProcessType("browser"); const { user } = await auth.api.signInEmail({ body: { email: "test@test.com", password: "password", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); (globalThis as any)[kElectron] = new Map([ ["abc", codeVerifier], ]); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); await expect( authenticate({ $fetch: client.$fetch, options, token: encodeRedirectToken(identifier, "abc"), // @ts-expect-error getWindow: () => mockElectron.BrowserWindow, fetchOptions: { throw: true }, }), ).rejects.toThrowError("BAD_REQUEST"); }); it("should verify that state parameter matches", async ({ setProcessType, }) => { setProcessType("browser"); const { user } = await auth.api.signInEmail({ body: { email: "test@test.com", password: "password", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); (globalThis as any)[kElectron] = new Map([ ["abc", codeVerifier], ]); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "def", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); await expect( authenticate({ $fetch: client.$fetch, options, token: encodeRedirectToken(identifier, "abc"), // @ts-expect-error getWindow: () => mockElectron.BrowserWindow, fetchOptions: { throw: true }, }).catch((err: any) => { expect(err.error.message).toBe("state mismatch"); throw err; }), ).rejects.toThrowError("BAD_REQUEST"); }); it("should return error when token record is missing code challenge", async ({ setProcessType, }) => { setProcessType("browser"); const { user } = await auth.api.signInEmail({ body: { email: "test@test.com", password: "password" }, }); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, state: "abc", }), expiresAt: new Date(Date.now() + 300_000), }, }); await expect( client.$fetch("/electron/token", { method: "POST", body: { token: identifier, code_verifier: "anything", state: "abc" }, throw: true, customFetchImpl: (url, init) => { const req = new Request(url.toString(), init); return auth.handler(req); }, }), ).rejects.toThrowError("BAD_REQUEST"); }); it("should reject when code_verifier does not match", async ({ setProcessType, }) => { setProcessType("browser"); const { user } = await auth.api.signInEmail({ body: { email: "test@test.com", password: "password" }, }); const actualVerifier = base64Url.encode(randomBytes(32)); const actualChallenge = await s256Challenge(actualVerifier); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge: actualChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300_000), }, }); const invalidVerifier = base64Url.encode(randomBytes(32)); await expect( client.$fetch("/electron/token", { method: "POST", body: { token: identifier, code_verifier: invalidVerifier, state: "abc", }, throw: true, customFetchImpl: (url, init) => { const req = new Request(url.toString(), init); return auth.handler(req); }, }), ).rejects.toThrowError("BAD_REQUEST"); }); it("should reject a non-S256 (plaintext) challenge at the token endpoint", async ({ setProcessType, }) => { setProcessType("browser"); const { user } = await auth.api.signInEmail({ body: { email: "test@test.com", password: "password" }, }); // A plaintext-PKCE row (challenge equals the raw verifier) is what the // pre-hardening flow could persist. It must never be exchangeable: the // token endpoint always verifies the verifier's SHA-256 digest. const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge: "attacker-challenge", state: "abc", }), expiresAt: new Date(Date.now() + 300_000), }, }); const res = await client.$fetch("/electron/token", { method: "POST", body: { token: identifier, code_verifier: "attacker-challenge", state: "abc", }, customFetchImpl: (url, init) => { const req = new Request(url.toString(), init); return auth.handler(req); }, }); expect((res.error as any)?.code).toBe( ELECTRON_ERROR_CODES.INVALID_CODE_VERIFIER.code, ); }); it("should issue an authorization code for an S256 PKCE challenge", async () => { const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); (globalThis as any)[kElectron] = new Map([ ["abc", codeVerifier], ]); const { data } = await proxyClient.signUp.email( { email: "pkce-default-s256@test.com", password: "password", name: "Default S256", }, { query: { client_id: "electron", code_challenge: codeChallenge, state: "abc", }, }, ); expect(data).not.toBeNull(); expect(data).toHaveProperty("electron_authorization_code"); }); it("should forward the challenge to sign-in/social without a method param", async () => { const { auth: proxyAuth } = testUtils({ plugins: [electron()], }); const { runMigrations } = await getMigrations(proxyAuth.options); await runMigrations(); let capturedUrl: string | null = null; const originalFetch = globalThis.fetch; //@ts-expect-error - intentionally mocking fetch globalThis.fetch = async (input: any, init?: any) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; if (url.includes("/sign-in/social")) { capturedUrl = url; } return proxyAuth.handler(new Request(url, init)); }; try { const codeChallenge = await s256Challenge("test-verifier"); await proxyAuth.handler( new Request( `http://localhost:3000/api/auth/electron/init-oauth-proxy?provider=google&state=abc&code_challenge=${encodeURIComponent(codeChallenge)}`, { method: "GET" }, ), ); expect(capturedUrl).not.toBeNull(); const params = new URLSearchParams(new URL(capturedUrl!).search); expect(params.get("code_challenge")).toBe(codeChallenge); expect(params.get("code_challenge_method")).toBeNull(); } finally { globalThis.fetch = originalFetch; } }); }); describe("origin header hardening", () => { it("should not substitute electron-origin into Origin", async () => { const res = await auth.handler( new Request("http://localhost:3000/api/auth/get-session", { method: "GET", headers: { "electron-origin": "myapp:/", }, }), ); expect(res.status).toBe(200); const body = await res.json(); expect(body).toBeNull(); }); it("should accept requests with direct origin header from electron client", async () => { await auth.api.signUpEmail({ body: { email: "origin-direct@test.com", password: "password", name: "Origin Direct", }, }); const res = await auth.handler( new Request("http://localhost:3000/api/auth/sign-in/email", { method: "POST", headers: { "content-type": "application/json", origin: "myapp:/", }, body: JSON.stringify({ email: "origin-direct@test.com", password: "password", }), }), ); expect(res.status).toBe(200); }); it("should not use electron-origin to spoof origin in hooks", async () => { let observedOrigin: string | null = null; const { auth: authInstance } = testUtils({ plugins: [electron()], hooks: { before: createAuthMiddleware(async (ctx) => { observedOrigin = ctx.request?.headers.get("origin") ?? null; }), }, }); const { runMigrations } = await getMigrations(authInstance.options); await runMigrations(); await authInstance.handler( new Request("http://localhost:3000/api/auth/get-session", { method: "GET", headers: { "electron-origin": "http://attacker.com", }, }), ); expect(observedOrigin).not.toBe("http://attacker.com"); }); }); describe("cookies", () => { async function setupSessionWithTokenExchange() { const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); (globalThis as any)[kElectron] = new Map([ ["abc", codeVerifier], ]); const { user } = await auth.api.signInEmail({ body: { email: "test@test.com", password: "password" }, }); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); await client.$fetch("/electron/token", { method: "POST", body: { token: identifier, code_verifier: codeVerifier, state: "abc", }, }); } it("should send cookie and get session", async ({ setProcessType }) => { setProcessType("browser"); await setupSessionWithTokenExchange(); const { data } = await client.getSession(); expect(data).toMatchObject({ session: expect.any(Object), user: expect.any(Object), }); expect(mockElectron.safeStorage.decryptString).toHaveBeenCalled(); }); it("should get cookies", async ({ setProcessType }) => { setProcessType("browser"); await setupSessionWithTokenExchange(); const c = client.getCookie(); expect(c).includes("better-auth.session_token"); }); it("should not trigger infinite refetch with non-./src/cookies", async ({ setProcessType, }) => { setProcessType("browser"); const { hasBetterAuthCookies } = await import("../src/cookies"); const betterAuthOnlyHeader = "better-auth.session_token=abc; Path=/"; expect(hasBetterAuthCookies(betterAuthOnlyHeader, "better-auth")).toBe( true, ); const sessionDataHeader = "better-auth.session_data=xyz; Path=/"; expect(hasBetterAuthCookies(sessionDataHeader, "better-auth")).toBe(true); const secureBetterAuthHeader = "__Secure-better-auth.session_token=abc; Path=/"; expect(hasBetterAuthCookies(secureBetterAuthHeader, "better-auth")).toBe( true, ); const secureSessionDataHeader = "__Secure-better-auth.session_data=xyz; Path=/"; expect(hasBetterAuthCookies(secureSessionDataHeader, "better-auth")).toBe( true, ); const nonBetterAuthHeader = "__cf_bm=abc123; Path=/; HttpOnly; Secure"; expect(hasBetterAuthCookies(nonBetterAuthHeader, "better-auth")).toBe( false, ); const mixedHeader = "__cf_bm=abc123; Path=/; HttpOnly; Secure, better-auth.session_token=xyz; Path=/"; expect(hasBetterAuthCookies(mixedHeader, "better-auth")).toBe(true); const customPrefixHeader = "my-app.session_token=abc; Path=/"; expect(hasBetterAuthCookies(customPrefixHeader, "my-app")).toBe(true); expect(hasBetterAuthCookies(customPrefixHeader, "better-auth")).toBe( false, ); const customPrefixDataHeader = "my-app.session_data=abc; Path=/"; expect(hasBetterAuthCookies(customPrefixDataHeader, "my-app")).toBe(true); const emptyPrefixHeader = "session_token=abc; Path=/"; expect(hasBetterAuthCookies(emptyPrefixHeader, "")).toBe(true); const customFullNameHeader = "my_custom_session_token=abc; Path=/"; expect(hasBetterAuthCookies(customFullNameHeader, "")).toBe(true); const customFullDataHeader = "my_custom_session_data=xyz; Path=/"; expect(hasBetterAuthCookies(customFullDataHeader, "")).toBe(true); const multipleNonBetterAuthHeader = "__cf_bm=abc123; Path=/, _ga=GA1.2.123456789.1234567890; Path=/"; expect( hasBetterAuthCookies(multipleNonBetterAuthHeader, "better-auth"), ).toBe(false); // Non-session better-auth cookies should still be detected (e.g., passkey cookies) const nonSessionBetterAuthHeader = "better-auth.other_cookie=abc; Path=/"; expect( hasBetterAuthCookies(nonSessionBetterAuthHeader, "better-auth"), ).toBe(true); // Passkey cookie should be detected const passkeyHeader = "better-auth-passkey=xyz; Path=/"; expect(hasBetterAuthCookies(passkeyHeader, "better-auth")).toBe(true); // Secure passkey cookie should be detected const securePasskeyHeader = "__Secure-better-auth-passkey=xyz; Path=/"; expect(hasBetterAuthCookies(securePasskeyHeader, "better-auth")).toBe( true, ); // Custom passkey cookie name should be detected const customPasskeyHeader = "better-auth-custom-challenge=xyz; Path=/"; expect(hasBetterAuthCookies(customPasskeyHeader, "better-auth")).toBe( true, ); }); it("should allow independent cookiePrefix configuration", async () => { const { hasBetterAuthCookies } = await import("../src/cookies"); const customCookieHeader = "my-app.session_token=abc; Path=/"; expect(hasBetterAuthCookies(customCookieHeader, "my-app")).toBe(true); expect(hasBetterAuthCookies(customCookieHeader, "better-auth")).toBe( false, ); }); it("should support array of cookie prefixes", async () => { const { hasBetterAuthCookies } = await import("../src/cookies"); // Test with multiple prefixes - should match any of them const betterAuthHeader = "better-auth.session_token=abc; Path=/"; expect( hasBetterAuthCookies(betterAuthHeader, ["better-auth", "my-app"]), ).toBe(true); const myAppHeader = "my-app.session_data=xyz; Path=/"; expect(hasBetterAuthCookies(myAppHeader, ["better-auth", "my-app"])).toBe( true, ); const otherAppHeader = "other-app.session_token=def; Path=/"; expect( hasBetterAuthCookies(otherAppHeader, ["better-auth", "my-app"]), ).toBe(false); // Test with passkey cookies const passkeyHeader1 = "better-auth-passkey=xyz; Path=/"; expect( hasBetterAuthCookies(passkeyHeader1, ["better-auth", "my-app"]), ).toBe(true); const passkeyHeader2 = "my-app-passkey=xyz; Path=/"; expect( hasBetterAuthCookies(passkeyHeader2, ["better-auth", "my-app"]), ).toBe(true); // Test with __Secure- prefix const secureHeader = "__Secure-my-app.session_token=abc; Path=/"; expect( hasBetterAuthCookies(secureHeader, ["better-auth", "my-app"]), ).toBe(true); // Test with empty array (should check for suffixes) const sessionTokenHeader = "session_token=abc; Path=/"; expect(hasBetterAuthCookies(sessionTokenHeader, [])).toBe(false); expect(hasBetterAuthCookies(sessionTokenHeader, [""])).toBe(true); }); }); it("should preserve unchanged client store session properties on signout", async ({ setProcessType, }) => { setProcessType("browser"); await client.signUp.email({ name: "Test User", email: "test-2@test.com", password: "password", }); const { refetch } = client.$store.atoms.session!.get(); await client.signOut(); await vi.waitFor(() => { expect(client.$store.atoms.session!.get()).toEqual({ data: null, error: null, isPending: false, isRefetching: false, refetch, }); }); }); it("should set origin header directly from electron client", async ({ setProcessType, }) => { setProcessType("browser"); let origin: string | null = null; const { auth, client } = testUtils({ hooks: { before: createAuthMiddleware(async (ctx) => { origin = ctx.request?.headers.get("origin") ?? null; }), }, plugins: [electron()], }); const { runMigrations } = await getMigrations(auth.options); await runMigrations(); await client.signUp.email({ name: "Test User", email: "test@test.com", password: "password", callbackURL: "http://localhost:3000/callback", }); expect(origin).toBe("myapp:/"); }); it("should allow caller to override origin header", async ({ setProcessType, }) => { setProcessType("browser"); let origin: string | null = null; const { auth, client } = testUtils({ trustedOrigins: ["http://custom-origin.com"], hooks: { before: createAuthMiddleware(async (ctx) => { origin = ctx.request?.headers.get("origin") ?? null; }), }, plugins: [electron()], }); const { runMigrations } = await getMigrations(auth.options); await runMigrations(); await client.signUp.email( { name: "Test User", email: "test@test.com", password: "password", callbackURL: "http://localhost:3000/callback", }, { headers: { origin: "http://custom-origin.com", }, }, ); expect(origin).toBe("http://custom-origin.com"); }); it("should not use electron-origin header for origin substitution", async ({ setProcessType, }) => { setProcessType("browser"); let origin: string | null = null; const { auth } = testUtils({ plugins: [electron()], hooks: { before: createAuthMiddleware(async (ctx) => { origin = ctx.request?.headers.get("origin") ?? null; }), }, }); const { runMigrations } = await getMigrations(auth.options); await runMigrations(); await auth.handler( new Request("http://localhost:3000/api/auth/get-session", { method: "GET", headers: { "electron-origin": "http://attacker.com", }, }), ); expect(origin).not.toBe("http://attacker.com"); }); it("should register ipc handlers", async ({ setProcessType }) => { setProcessType("browser"); client.setupMain({ bridges: true, }); const prefix = `${(options as any).channelPrefix ?? "better-auth"}:`; expect(mockElectron.ipcMain.handle).toHaveBeenCalledWith( `${prefix}getUser`, expect.any(Function), ); expect(mockElectron.ipcMain.handle).toHaveBeenCalledWith( `${prefix}requestAuth`, expect.any(Function), ); expect(mockElectron.ipcMain.handle).toHaveBeenCalledWith( `${prefix}signOut`, expect.any(Function), ); }); it("should not store when encryption fails", async ({ setProcessType }) => { setProcessType("browser"); const { user } = await auth.api.signUpEmail({ body: { name: "Sage Storage Test", email: "safe-storage@test.com", password: "password", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); mockElectron.safeStorage.encryptString.mockImplementationOnce(() => { throw new Error("encryption failed"); }); const result = await client.$fetch("/electron/token", { method: "POST", body: { token: identifier, code_verifier: codeVerifier, state: "abc", }, }); expect((result.data as any)?.user).toMatchObject({ email: "safe-storage@test.com", name: "Sage Storage Test", }); expect(client.getCookie()).not.toMatch( /better-auth\.session_token=[a-zA-Z0-9_-]{10,}/, ); }); it("should use memory storage when encryption is unavailable", async ({ setProcessType, }) => { setProcessType("browser"); mockElectron.safeStorage.isEncryptionAvailable.mockReturnValue(false); try { const memoryFallbackStorage = new Map(); const clientWithStorage = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl }, plugins: [ electronClient({ ...options, storage: { getItem: (name) => memoryFallbackStorage.get(name) ?? null, setItem: (name, value) => { memoryFallbackStorage.set(name, value); }, }, }), ], }); const { user } = await auth.api.signUpEmail({ body: { name: "Memory Storage Test", email: "memory-storage@test.com", password: "password", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); const result = await clientWithStorage.$fetch("/electron/token", { method: "POST", body: { token: identifier, code_verifier: codeVerifier, state: "abc", }, }); expect((result.data as any)?.user).toMatchObject({ email: "memory-storage@test.com", name: "Memory Storage Test", }); expect(clientWithStorage.getCookie()).toContain( "better-auth.session_token=", ); expect(memoryFallbackStorage.has("better-auth.cookie")).toBe(false); } finally { mockElectron.safeStorage.isEncryptionAvailable.mockReturnValue(true); } }); it("should return null on decrypt failure", async ({ setProcessType }) => { setProcessType("browser"); const cookieStorage = new Map([ [ "better-auth.cookie", Buffer.from('{"session":"old"}').toString("base64"), ], ]); const clientWithStorage = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl }, plugins: [ electronClient({ ...options, storage: { getItem: (name) => cookieStorage.get(name) ?? null, setItem: (name, value) => { cookieStorage.set(name, value); return true; }, }, }), ], }); mockElectron.safeStorage.decryptString.mockImplementationOnce(() => { throw new Error( "Error while decrypting the ciphertext provided to safeStorage.decryptString.", ); }); // getCookie() uses getDecrypted internally const cookie = clientWithStorage.getCookie(); expect(cookie).toBe(""); }); it("should quit when single instance lock not acquired", async ({ setProcessType, }) => { setProcessType("browser"); mockElectron.app.requestSingleInstanceLock.mockReturnValueOnce(false); client.setupMain({ scheme: true, }); expect(mockElectron.app.quit).toHaveBeenCalled(); }); it("should log error when setAsDefaultProtocolClient fails", async ({ setProcessType, }) => { setProcessType("browser"); const original = mockElectron.app.setAsDefaultProtocolClient; mockElectron.app.setAsDefaultProtocolClient.mockReturnValueOnce(false); const spy = vi.spyOn(console, "error").mockImplementation(() => {}); client.setupMain({ scheme: true, }); expect(mockElectron.app.setAsDefaultProtocolClient).toHaveBeenCalled(); expect(spy).toHaveBeenCalled(); mockElectron.app.setAsDefaultProtocolClient = original; }); it("should not duplicate CSP origin entry when already present", async ({ setProcessType, }) => { setProcessType("browser"); client.setupMain({ csp: true, }); // wait a tick for whenReady then-callback to run await Promise.resolve(); const onHeadersReceived = mockElectron.session.defaultSession.webRequest.onHeadersReceived; expect(onHeadersReceived).toHaveBeenCalled(); const handler = onHeadersReceived.mock.calls[0][0]; const origin = "http://localhost:3000"; const details = { responseHeaders: { "content-security-policy": [`connect-src 'self' ${origin}`], }, }; let callbackResult: any = null; const callback = (res: any) => { callbackResult = res; }; // First invocation - policy already contains origin once await handler(details, callback); const firstPolicy = String( callbackResult.responseHeaders["content-security-policy"], ); const firstCount = ( firstPolicy.match( new RegExp(origin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), ) || [] ).length; // Invoke handler again with the policy that already contains the origin callbackResult = null; await handler(details, callback); const secondPolicy = String( callbackResult.responseHeaders["content-security-policy"], ); const secondCount = ( secondPolicy.match( new RegExp(origin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), ) || [] ).length; // Origin should not be duplicated expect(firstCount).toBeGreaterThanOrEqual(1); expect(secondCount).toBe(firstCount); }); it("should reject requestAuth when shell.openExternal fails", async ({ setProcessType, }) => { setProcessType("browser"); mockElectron.shell.openExternal.mockRejectedValueOnce( new Error("failed to open"), ); await expect(client.requestAuth()).rejects.toThrow(); }); describe("sanitizeUser", () => { it("should strip fields from user sent in authenticated event", async ({ setProcessType, }) => { setProcessType("browser"); const { user } = await auth.api.signUpEmail({ body: { email: "sanitize-strip@test.com", password: "password", name: "Sanitize Strip", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); (globalThis as any)[kElectron] = new Map([ ["abc", codeVerifier], ]); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); mockElectron.BrowserWindow.webContents.send.mockClear(); await authenticate({ $fetch: client.$fetch, options: { ...options, sanitizeUser: (u) => { const { email, ...rest } = u; return rest as typeof u; }, }, token: encodeRedirectToken(identifier, "abc"), // @ts-expect-error getWindow: () => mockElectron.BrowserWindow, }); expect(mockElectron.BrowserWindow.webContents.send).toHaveBeenCalledWith( "better-auth:authenticated", expect.not.objectContaining({ email: expect.any(String) }), ); expect(mockElectron.BrowserWindow.webContents.send).toHaveBeenCalledWith( "better-auth:authenticated", expect.objectContaining({ id: user.id }), ); }); it("should not emit authenticated event when sanitizeUser throws", async ({ setProcessType, }) => { setProcessType("browser"); const { user } = await auth.api.signUpEmail({ body: { email: "sanitize-throw@test.com", password: "password", name: "Sanitize Throw", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); (globalThis as any)[kElectron] = new Map([ ["abc", codeVerifier], ]); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); mockElectron.BrowserWindow.webContents.send.mockClear(); const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); await authenticate({ $fetch: client.$fetch, options: { ...options, sanitizeUser: () => { throw new Error("sanitize failed"); }, }, token: encodeRedirectToken(identifier, "abc"), // @ts-expect-error getWindow: () => mockElectron.BrowserWindow, }); expect(consoleSpy).toHaveBeenCalledWith( "Error while sanitizing user", expect.any(Error), ); expect( mockElectron.BrowserWindow.webContents.send, ).not.toHaveBeenCalledWith( "better-auth:authenticated", expect.anything(), ); consoleSpy.mockRestore(); }); it("should apply sanitizeUser to user-updated event", async ({ setProcessType, }) => { setProcessType("browser"); const sanitizeUser = vi.fn((u: any) => { const { email, ...rest } = u; return rest; }); const { client: sanitizedClient } = (() => { const sanitizedOptions = { ...options, sanitizeUser }; const sanitizedClient = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl: (url, init) => { const req = new Request(url.toString(), init); return auth.handler(req); }, }, plugins: [electronClient(sanitizedOptions)], }); return { client: sanitizedClient }; })(); sanitizedClient.setupMain({ bridges: true, // @ts-expect-error getWindow: () => mockElectron.BrowserWindow, }); mockElectron.BrowserWindow.send.mockClear(); sanitizedClient.$store.atoms.session!.set({ data: { user: { id: "user-123", email: "secret@test.com", name: "Test", }, }, }); // Flush await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); expect(sanitizeUser).toHaveBeenCalled(); expect(mockElectron.BrowserWindow.send).toHaveBeenCalledWith( "better-auth:user-updated", expect.objectContaining({ id: "user-123" }), ); expect(mockElectron.BrowserWindow.send).toHaveBeenCalledWith( "better-auth:user-updated", expect.not.objectContaining({ email: expect.any(String) }), ); }); it("should send null user-updated event when sanitizeUser throws", async ({ setProcessType, }) => { setProcessType("browser"); const { client: sanitizedClient } = (() => { const sanitizedOptions = { ...options, sanitizeUser: () => { throw new Error("sanitize boom"); }, }; const sanitizedClient = createAuthClient({ baseURL: "http://localhost:3000", fetchOptions: { customFetchImpl: (url, init) => { const req = new Request(url.toString(), init); return auth.handler(req); }, }, plugins: [electronClient(sanitizedOptions)], }); return { client: sanitizedClient }; })(); sanitizedClient.setupMain({ bridges: true, // @ts-expect-error getWindow: () => mockElectron.BrowserWindow, }); mockElectron.BrowserWindow.send.mockClear(); const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); sanitizedClient.$store.atoms.session!.set({ data: { user: { id: "user-456", email: "test@test.com", name: "Test", }, }, }); // Flush await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); expect(consoleSpy).toHaveBeenCalledWith( "Error while sanitizing user", expect.any(Error), ); expect(mockElectron.BrowserWindow.send).toHaveBeenCalledWith( "better-auth:user-updated", null, ); consoleSpy.mockRestore(); }); it("should apply async sanitizeUser", async ({ setProcessType }) => { setProcessType("browser"); const { user } = await auth.api.signUpEmail({ body: { email: "sanitize-async@test.com", password: "password", name: "Sanitize Async", }, }); const codeVerifier = base64Url.encode(randomBytes(32)); const codeChallenge = await s256Challenge(codeVerifier); (globalThis as any)[kElectron] = new Map([ ["abc", codeVerifier], ]); const identifier = generateRandomString(16, "A-Z", "a-z", "0-9"); await (await auth.$context).adapter.create({ model: "verification", data: { identifier: `electron:${identifier}`, value: JSON.stringify({ userId: user.id, codeChallenge, state: "abc", }), expiresAt: new Date(Date.now() + 300 * 1000), }, }); mockElectron.BrowserWindow.webContents.send.mockClear(); await authenticate({ $fetch: client.$fetch, options: { ...options, sanitizeUser: async (u) => { await Promise.resolve(); return { ...u, name: "Sanitized" }; }, }, token: encodeRedirectToken(identifier, "abc"), // @ts-expect-error getWindow: () => mockElectron.BrowserWindow, }); expect(mockElectron.BrowserWindow.webContents.send).toHaveBeenCalledWith( "better-auth:authenticated", expect.objectContaining({ id: user.id, name: "Sanitized" }), ); }); }); describe("user normalization", () => { const MINIMAL_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; const makeImageBytes = (magic: number[]) => new Uint8Array([...magic, ...new Array(20).fill(0)]); async function streamToBytes( stream: ReadableStream, ): Promise { const reader = stream.getReader(); const chunks: Uint8Array[] = []; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } if (chunks.length === 1) return chunks[0]!; let total = 0; for (const c of chunks) total += c.byteLength; const result = new Uint8Array(total); let offset = 0; for (const c of chunks) { result.set(c, offset); offset += c.byteLength; } return result; } const customFetchImpl = vi.fn(async (input: string | URL | Request) => { const url = input.toString(); if (url.endsWith(".jpg")) { return new Response(makeImageBytes([0xff, 0xd8, 0xff, 0xe0]).buffer, { headers: new Headers({ "content-type": "image/jpeg" }), }); } else if (url.endsWith(".png") && !url.endsWith("avatar-fail.png")) { return new Response( makeImageBytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) .buffer, { headers: new Headers({ "content-type": "image/png" }), }, ); } else if (url.endsWith(".gif")) { return new Response( makeImageBytes([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]).buffer, { headers: new Headers({ "content-type": "image/gif" }), }, ); } else if (url.endsWith(".webp")) { return new Response( makeImageBytes([ 0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, ]).buffer, { headers: new Headers({ "content-type": "image/webp" }), }, ); } else if (url.endsWith(".bmp")) { return new Response(makeImageBytes([0x42, 0x4d]).buffer, { headers: new Headers({ "content-type": "image/bmp" }), }); } else if (url.endsWith(".ico")) { return new Response(makeImageBytes([0x00, 0x00, 0x01, 0x00]).buffer, { headers: new Headers({ "content-type": "image/x-icon" }), }); } return new Response(null, { status: 404 }); }); beforeEach(() => { vi.clearAllMocks(); mockElectron.net.fetch.mockImplementation(customFetchImpl as any); }); it("normalizeUserOutput should replace image with protocol URL", async ({ setProcessType, }) => { setProcessType("browser"); const user = normalizeUserOutput({ id: "abc123", name: "Test", email: "test@test.com", image: "https://example.com/avatar.png", emailVerified: false, createdAt: new Date(), updatedAt: new Date(), }); expect(user.image).toBe("user-image://abc123"); }); it("normalizeUserOutput should leave null image as null", async ({ setProcessType, }) => { setProcessType("browser"); const user = normalizeUserOutput({ id: "abc123", name: "Test", email: "test@test.com", image: null, emailVerified: false, createdAt: new Date(), updatedAt: new Date(), }); expect(user.image).toBeNull(); }); it("should decode valid data URL", async ({ setProcessType }) => { setProcessType("browser"); const dataUrl = `data:image/png;base64,${MINIMAL_PNG_BASE64}`; const result = await fetchUserImage(undefined, dataUrl); expect(result).not.toBeNull(); expect(result!.mimeType).toBe("image/png"); const bytes = await streamToBytes(result!.stream); expect(bytes.length).toBeGreaterThan(0); }); it("should reject SVG data URL", async ({ setProcessType }) => { setProcessType("browser"); const result = await fetchUserImage( undefined, "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==", ); expect(result).toBeNull(); }); it("should reject invalid base64 data URL", async ({ setProcessType }) => { setProcessType("browser"); const result = await fetchUserImage( undefined, "data:image/png;base64,!!!invalid!!!", ); expect(result).toBeNull(); }); it("should fetch http URL and return stream", async ({ setProcessType, }) => { setProcessType("browser"); const result = await fetchUserImage( undefined, "https://example.com/avatar.png", ); expect(mockElectron.net.fetch).toHaveBeenCalledWith( "https://example.com/avatar.png", expect.objectContaining({ method: "GET", headers: { accept: "image/*" }, }), ); expect(result).not.toBeNull(); expect(result!.mimeType).toBe("image/png"); }); it("should return null when fetch fails", async ({ setProcessType }) => { setProcessType("browser"); const result = await fetchUserImage( undefined, "https://example.com/avatar-fail.png", ); expect(result).toBeNull(); }); it("should return null when fetched content is not a valid image", async ({ setProcessType, }) => { setProcessType("browser"); const result = await fetchUserImage( undefined, "https://example.com/page.html", ); expect(result).toBeNull(); }); it("should fetch JPEG and return correct mimeType", async ({ setProcessType, }) => { setProcessType("browser"); const result = await fetchUserImage( undefined, "https://example.com/avatar.jpg", ); expect(result).not.toBeNull(); expect(result!.mimeType).toBe("image/jpeg"); }); it("should fetch GIF and return correct mimeType", async ({ setProcessType, }) => { setProcessType("browser"); const result = await fetchUserImage( undefined, "https://example.com/avatar.gif", ); expect(result).not.toBeNull(); expect(result!.mimeType).toBe("image/gif"); }); it("should fetch WebP and return correct mimeType", async ({ setProcessType, }) => { setProcessType("browser"); const result = await fetchUserImage( undefined, "https://example.com/avatar.webp", ); expect(result).not.toBeNull(); expect(result!.mimeType).toBe("image/webp"); }); it("should fetch BMP and return correct mimeType", async ({ setProcessType, }) => { setProcessType("browser"); const result = await fetchUserImage( undefined, "https://example.com/avatar.bmp", ); expect(result).not.toBeNull(); expect(result!.mimeType).toBe("image/bmp"); }); it("should fetch ICO and return correct mimeType", async ({ setProcessType, }) => { setProcessType("browser"); const result = await fetchUserImage( undefined, "https://example.com/favicon.ico", ); expect(result).not.toBeNull(); expect(result!.mimeType).toBe("image/x-icon"); }); it("should decode valid GIF87a data URL", async ({ setProcessType }) => { setProcessType("browser"); const gif87aBytes = makeImageBytes([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]); const dataUrl = `data:image/gif;base64,${Buffer.from(gif87aBytes).toString("base64")}`; const result = await fetchUserImage(undefined, dataUrl); expect(result).not.toBeNull(); expect(result!.mimeType).toBe("image/gif"); }); describe("isLocalOrigin (SSRF mitigation)", () => { it.each([ ["http://localhost/avatar.png"], ["http://127.0.0.1/avatar.png"], ["http://10.0.0.1/avatar.png"], ["http://172.16.0.1/avatar.png"], ["http://192.168.1.1/avatar.png"], ["http://169.254.169.254/avatar.png"], ["http://[::1]/avatar.png"], ["http://[fe80::1]/avatar.png"], ])("should reject local origin %s", async (imageUrl) => { const result = await fetchUserImage(undefined, imageUrl); expect(result).toBeNull(); expect(mockElectron.net.fetch).not.toHaveBeenCalled(); }); it.each([ ["https://example.com/avatar/abc.png"], ["https://gravatar.com/avatar/abc.png"], ["https://8.8.8.8/avatar.png"], ])("should allow public origin %s", async (imageUrl) => { const result = await fetchUserImage(undefined, imageUrl); expect(mockElectron.net.fetch).toHaveBeenCalled(); expect(result).not.toBeNull(); }); }); }); }); describe("cookies getCookie", () => { it("serializes stored cookies into a Cookie header string", () => { const stored = JSON.stringify({ "better-auth.session_token": { value: "abc", expires: null }, }); expect(getCookie(stored)).toBe("better-auth.session_token=abc"); }); it("joins multiple stored cookies with `; ` without a leading separator", () => { const stored = JSON.stringify({ a: { value: "1", expires: null }, b: { value: "2", expires: null }, }); expect(getCookie(stored)).toBe("a=1; b=2"); }); it("percent-encodes stored values containing reserved cookie-octet bytes", () => { const stored = JSON.stringify({ session: { value: "safe", expires: null }, pref: { value: "foo;bar=baz", expires: null }, }); expect(getCookie(stored)).toBe("session=safe; pref=foo%3Bbar%3Dbaz"); }); it("skips stored entries whose name violates the cookie-name token", () => { const stored = JSON.stringify({ session: { value: "safe", expires: null }, "bad name": { value: "x", expires: null }, }); expect(getCookie(stored)).toBe("session=safe"); }); it("skips expired entries", () => { const stored = JSON.stringify({ session: { value: "abc", expires: new Date(0).toISOString() }, }); expect(getCookie(stored)).toBe(""); }); });