fix(electron)!: enforce S256 PKCE and harden origin checks (#9645)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
Maxwell
2026-06-07 23:43:06 +00:00
committed by GitHub
co-authored by cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Gustavo Valverde
parent 41cca606d1
commit e0140297a5
12 changed files with 520 additions and 181 deletions
+10
View File
@@ -0,0 +1,10 @@
---
"better-auth": minor
"@better-auth/electron": minor
---
Harden the Electron OAuth flow and tighten custom-scheme trusted-origin matching.
The Electron sign-in flow now mandates PKCE S256. Plain PKCE is rejected: the `code_challenge_method` parameter is gone and every authorization code is verified by hashing the verifier with SHA-256. The server no longer trusts an `electron-origin` header to set the request Origin. The Electron client now sends a real `Origin` (for example `myapp:/`), so upgrade the `@better-auth/electron` client and server together and make sure your app's scheme is in `trustedOrigins`. The unused `disableOriginOverride` option is removed.
Custom-scheme entries in `trustedOrigins` now match by scheme and authority instead of string prefix. A host-less entry such as `myapp://` or `exp://` still trusts every host of that scheme, but a host-bearing entry such as `myapp://callback` matches that host exactly, so it is no longer satisfied by `myapp://callback.attacker.tld`.
@@ -292,7 +292,6 @@ function SignIn({
client_id?: string | undefined;
state?: string | undefined;
code_challenge?: string | undefined;
code_challenge_method?: string | undefined;
}>;
}) {
const query = use(searchParams);
@@ -601,18 +600,6 @@ 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`
+1 -1
View File
@@ -176,7 +176,7 @@ Expo is a popular framework for building cross-platform apps with React Native.
// Development mode - Expo's exp:// scheme with local IP ranges
...(process.env.NODE_ENV === "development" ? [
"exp://", // Trust all Expo URLs (prefix matching)
"exp://", // Trust any host of the exp:// scheme
"exp://**", // Trust all Expo URLs (wildcard matching)
"exp://192.168.*.*:*/**", // Trust 192.168.x.x IP range with any port and path
] : [])
+1 -1
View File
@@ -156,7 +156,7 @@ export const auth = betterAuth({
**Notes**
* For `http://` and `https://` URLs, patterns match the full origin. Paths and query strings are ignored.
* For custom schemes (like `exp://` or `myapp://`), patterns match against the full URL including paths when wildcards are present, or use prefix matching when no wildcards exist.
* For custom schemes (like `exp://` or `myapp://`), wildcard patterns match against the full URL including paths. A non-wildcard pattern matches by scheme and authority: a host-less entry such as `myapp://` or `exp://` trusts every host of that scheme, while a host-bearing entry such as `myapp://callback` must match that host exactly (so it is not satisfied by `myapp://callback.attacker.tld`).
* The separator is `/` (forward slash). This means `http://*.example.com` matches both `http://api.example.com` and `http://api.app.example.com`.
</Callout>
@@ -297,6 +297,133 @@ describe("trusted origins", () => {
});
});
describe("custom-scheme origin matching", () => {
it("should reject URLs where the host extends the trusted pattern", async () => {
const { isTrustedOrigin } = await createAuthTestInstance({
trustedOrigins: ["myapp://callback"],
});
await expect(
isTrustedOrigin("myapp://callback.attacker.tld"),
).resolves.toBe(false);
await expect(
isTrustedOrigin("myapp://callback.attacker.tld/path"),
).resolves.toBe(false);
});
it("should allow exact custom-scheme origin match", async () => {
const { isTrustedOrigin } = await createAuthTestInstance({
trustedOrigins: ["myapp://callback"],
});
await expect(isTrustedOrigin("myapp://callback")).resolves.toBe(true);
await expect(isTrustedOrigin("myapp://callback/")).resolves.toBe(true);
await expect(isTrustedOrigin("myapp://callback/path")).resolves.toBe(
true,
);
// A query or fragment directly on the host is not part of the
// authority, so the host still matches exactly.
await expect(isTrustedOrigin("myapp://callback?token=x")).resolves.toBe(
true,
);
await expect(isTrustedOrigin("myapp://callback#frag")).resolves.toBe(
true,
);
});
it("should match custom-scheme with empty host (myapp:/)", async () => {
const { isTrustedOrigin } = await createAuthTestInstance({
trustedOrigins: ["myapp:/"],
});
await expect(isTrustedOrigin("myapp:/")).resolves.toBe(true);
await expect(isTrustedOrigin("myapp://")).resolves.toBe(true);
await expect(isTrustedOrigin("myapp:/auth/callback")).resolves.toBe(true);
});
it("should reject different custom schemes", async () => {
const { isTrustedOrigin } = await createAuthTestInstance({
trustedOrigins: ["myapp://callback"],
});
await expect(isTrustedOrigin("otherapp://callback")).resolves.toBe(false);
});
it("should still support wildcard matching for custom schemes", async () => {
const { isTrustedOrigin } = await createAuthTestInstance({
trustedOrigins: ["exp://192.168.*.*:*/*"],
});
await expect(
isTrustedOrigin("exp://192.168.1.100:8081/--/"),
).resolves.toBe(true);
await expect(isTrustedOrigin("exp://10.0.0.1:8081/--/")).resolves.toBe(
false,
);
});
it("should trust any host for a host-less custom-scheme pattern", async () => {
// The Expo plugin registers a bare `exp://` in development to trust
// every Expo Go dev origin, and apps register `myapp://` to trust any
// deep link. A host-less pattern matches any host of the scheme.
const { isTrustedOrigin } = await createAuthTestInstance({
trustedOrigins: ["exp://", "myapp://"],
});
await expect(isTrustedOrigin("exp://192.168.1.5:8081/--/")).resolves.toBe(
true,
);
await expect(isTrustedOrigin("exp://localhost:8081/--/")).resolves.toBe(
true,
);
await expect(isTrustedOrigin("myapp://callback")).resolves.toBe(true);
await expect(isTrustedOrigin("myapp://other-host/path")).resolves.toBe(
true,
);
await expect(isTrustedOrigin("evil://anything")).resolves.toBe(false);
});
it("should match custom-scheme host case-insensitively", async () => {
const { isTrustedOrigin } = await createAuthTestInstance({
trustedOrigins: ["myapp://callback"],
});
await expect(isTrustedOrigin("myapp://CALLBACK")).resolves.toBe(true);
await expect(isTrustedOrigin("MyApp://Callback")).resolves.toBe(true);
});
it("should pin the path when the pattern includes one", async () => {
const { isTrustedOrigin } = await createAuthTestInstance({
trustedOrigins: ["myapp://host/cb"],
});
await expect(isTrustedOrigin("myapp://host/cb")).resolves.toBe(true);
await expect(isTrustedOrigin("myapp://host/cb/extra")).resolves.toBe(
true,
);
await expect(isTrustedOrigin("myapp://host/cbx")).resolves.toBe(false);
await expect(isTrustedOrigin("myapp://host/other")).resolves.toBe(false);
});
it("should not let a path-pinned pattern be bypassed with traversal", async () => {
const { isTrustedOrigin } = await createAuthTestInstance({
trustedOrigins: ["myapp://host/cb"],
});
await expect(isTrustedOrigin("myapp://host/cb/../evil")).resolves.toBe(
false,
);
await expect(
isTrustedOrigin("myapp://host/cb/%2e%2e/evil"),
).resolves.toBe(false);
await expect(
isTrustedOrigin("myapp://host/cb%2f..%2fevil"),
).resolves.toBe(false);
});
});
describe("dynamic trusted origins", () => {
it("should allow dynamically computed trusted origins", async () => {
const { isTrustedOrigin } = await createAuthTestInstance({
@@ -1,6 +1,68 @@
import { getHost, getOrigin, getProtocol } from "../utils/url";
import { wildcardMatch } from "../utils/wildcard";
/**
* Resolves `.` and `..` segments in a path after percent-decoding so a
* path-pinned pattern cannot be bypassed with traversal: e.g.
* `myapp://host/cb/../evil` must not satisfy pattern `myapp://host/cb`.
* Returns "" for an empty or root path.
*/
const normalizePath = (path: string): string => {
let decoded = path;
try {
decoded = decodeURIComponent(path);
} catch {
// Not valid percent-encoding; fall back to the raw path.
}
const segments: string[] = [];
for (const segment of decoded.split("/")) {
if (segment === "..") {
segments.pop();
} else if (segment !== "." && segment !== "") {
segments.push(segment);
}
}
return segments.length > 0 ? `/${segments.join("/")}` : "";
};
/**
* Splits a custom-scheme origin into its scheme, authority and path using
* plain string operations.
*
* `new URL()` is deliberately avoided here: its parsing of non-special schemes
* (e.g. `myapp://`, `exp://`) is not consistent across the runtimes Better Auth
* targets (Node, Bun, Deno, Cloudflare Workers), and the result of an origin
* check must not depend on which engine extracts the authority.
*
* Scheme and authority are lower-cased (matching how a URL canonicalizes its
* host); the path is percent-decoded and resolved so traversal cannot bypass
* a path-pinned pattern.
*/
const parseCustomSchemeOrigin = (value: string) => {
const schemeEnd = value.indexOf(":");
if (schemeEnd <= 0) {
return null;
}
const scheme = value.slice(0, schemeEnd).toLowerCase();
let rest = value.slice(schemeEnd + 1);
let authority = "";
if (rest.startsWith("//")) {
rest = rest.slice(2);
// The authority ends at the first "/", "?" or "#" (RFC 3986); the
// remainder is the path, with any query/fragment stripped below.
const authorityEnd = rest.search(/[/?#]/);
if (authorityEnd === -1) {
authority = rest;
rest = "";
} else {
authority = rest.slice(0, authorityEnd);
rest = rest.slice(authorityEnd);
}
}
const path = normalizePath(rest.replace(/[?#].*$/, ""));
return { scheme, authority: authority.toLowerCase(), path };
};
/**
* Matches the given url against an origin or origin pattern
* See "options.trustedOrigins" for details of supported patterns
@@ -41,7 +103,29 @@ export const matchesOriginPattern = (
return wildcardMatch(pattern)(host);
}
const protocol = getProtocol(url);
return protocol === "http:" || protocol === "https:" || !protocol
? pattern === getOrigin(url)
: url.startsWith(pattern);
if (protocol === "http:" || protocol === "https:" || !protocol) {
return pattern === getOrigin(url);
}
// Custom schemes (e.g. myapp://, exp://). A pattern matches by scheme and,
// when it pins a host, by exact authority, so "myapp://callback" is not
// satisfied by "myapp://callback.attacker.tld". A host-less pattern
// ("myapp://", "exp://", "myapp:/") trusts every host of the scheme, since
// for custom schemes the OS-registered scheme is the trust boundary.
const parsed = parseCustomSchemeOrigin(url);
const parsedPattern = parseCustomSchemeOrigin(pattern);
if (!parsed || !parsedPattern || parsed.scheme !== parsedPattern.scheme) {
return false;
}
if (parsedPattern.authority && parsed.authority !== parsedPattern.authority) {
return false;
}
// A pattern without a path trusts every path; otherwise the url path must
// equal the pattern path or be nested beneath it.
if (!parsedPattern.path) {
return true;
}
return (
parsed.path === parsedPattern.path ||
parsed.path.startsWith(`${parsedPattern.path}/`)
);
};
+3 -6
View File
@@ -2,11 +2,11 @@ import type { BetterAuthClientOptions } from "@better-auth/core";
import type { User } from "@better-auth/core/db";
import { BetterAuthError } from "@better-auth/core/error";
import { base64Url } from "@better-auth/utils/base64";
import { createHash } from "@better-auth/utils/hash";
import type { BetterFetch, CreateFetchOption } from "@better-fetch/fetch";
import { APIError, getBaseURL, safeJSONParse } from "better-auth";
import { signInSocial } from "better-auth/api";
import { generateRandomString } from "better-auth/crypto";
import { generateCodeChallenge } from "better-auth/oauth2";
import { shell } from "electron";
import * as z from "zod";
import type { ElectronClientOptions } from "./types/client";
@@ -45,10 +45,8 @@ export async function requestAuth(
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),
);
const codeVerifier = base64Url.encode(randomBytes(32), { padding: false });
const codeChallenge = await generateCodeChallenge(codeVerifier);
((globalThis as any)[kElectron] ??= new Map<string, string>()).set(
state,
@@ -83,7 +81,6 @@ export async function requestAuth(
}
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);
await shell.openExternal(url.toString(), {
+1 -1
View File
@@ -108,10 +108,10 @@ export const electronClient = <O extends ElectronClientOptions>(options: O) => {
options ||= {};
options.credentials = "omit";
options.headers = {
origin: `${scheme}:/`,
...options.headers,
cookie,
"user-agent": app.userAgentFallback,
"electron-origin": `${scheme}:/`,
"x-skip-oauth-proxy": "true",
};
+1 -25
View File
@@ -57,15 +57,9 @@ export const electron = (options?: ElectronOptions | undefined) => {
client_id: string;
state: string;
code_challenge: string;
code_challenge_method?: string | undefined;
},
) => {
const {
client_id,
state,
code_challenge,
code_challenge_method = "plain",
} = payload;
const { client_id, state, code_challenge } = payload;
const userId =
ctx.context.session?.user.id || ctx.context.newSession?.user.id;
if (!userId || client_id !== opts.clientID) {
@@ -88,7 +82,6 @@ export const electron = (options?: ElectronOptions | undefined) => {
value: JSON.stringify({
userId,
codeChallenge: code_challenge,
codeChallengeMethod: code_challenge_method.toLowerCase(),
state,
}),
expiresAt,
@@ -110,22 +103,6 @@ export const electron = (options?: ElectronOptions | undefined) => {
return {
id: "electron",
version: PACKAGE_VERSION,
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: [
{
@@ -159,7 +136,6 @@ export const electron = (options?: ElectronOptions | undefined) => {
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", {
+15 -35
View File
@@ -1,19 +1,22 @@
import { Buffer } from "node:buffer";
import { timingSafeEqual } from "node:crypto";
import type { GenericEndpointContext } from "@better-auth/core";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
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, sessionMiddleware } from "better-auth/api";
import { setSessionCookie } from "better-auth/cookies";
import type { User } from "better-auth/db";
import { parseUserOutput } from "better-auth/db";
import { generateCodeChallenge } from "better-auth/oauth2";
import * as z from "zod";
import { ELECTRON_ERROR_CODES } from "./error-codes";
import type { ElectronOptions } from "./types";
type ElectronVerificationValue = {
userId: string;
codeChallenge: string;
state: string;
};
const electronTokenBodySchema = z.object({
token: z.string().nonempty(),
state: z.string().nonempty(),
@@ -64,7 +67,7 @@ export const electronToken = (_opts: ElectronOptions) =>
throw APIError.from("NOT_FOUND", ELECTRON_ERROR_CODES.INVALID_TOKEN);
}
const tokenRecord = safeJSONParse<Record<string, any>>(token.value);
const tokenRecord = safeJSONParse<ElectronVerificationValue>(token.value);
if (!tokenRecord) {
throw APIError.from(
"INTERNAL_SERVER_ERROR",
@@ -82,30 +85,14 @@ export const electronToken = (_opts: ElectronOptions) =>
ELECTRON_ERROR_CODES.MISSING_CODE_CHALLENGE,
);
}
if (tokenRecord.codeChallengeMethod === "s256") {
const codeChallenge = Buffer.from(
base64Url.decode(tokenRecord.codeChallenge),
// PKCE is always S256: the stored challenge is the SHA-256 digest of
// the verifier, so a legacy or plaintext challenge fails this check.
const codeChallenge = await generateCodeChallenge(ctx.body.code_verifier);
if (codeChallenge !== tokenRecord.codeChallenge) {
throw APIError.from(
"BAD_REQUEST",
ELECTRON_ERROR_CODES.INVALID_CODE_VERIFIER,
);
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.deleteVerificationByIdentifier(
`electron:${ctx.body.token}`,
@@ -146,7 +133,6 @@ 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) =>
@@ -200,10 +186,6 @@ export const electronInitOAuthProxy = (opts: ElectronOptions) =>
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;
@@ -245,7 +227,6 @@ const electronTransferUserQuerySchema = z.object({
client_id: z.string(),
state: z.string(),
code_challenge: z.string(),
code_challenge_method: z.string().optional(),
});
const electronTransferUserBodySchema = z.object({
callbackURL: z.string().optional(),
@@ -262,7 +243,6 @@ export const electronTransferUser = (
client_id: string;
state: string;
code_challenge: string;
code_challenge_method?: string | undefined;
},
) => Promise<string | null>;
},
-7
View File
@@ -26,11 +26,4 @@ export interface ElectronOptions extends ElectronSharedOptions {
* @default "better-auth"
*/
cookiePrefix?: 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;
}
+274 -89
View File
@@ -2,11 +2,11 @@ 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 { createHash } from "@better-auth/utils/hash";
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";
@@ -81,6 +81,10 @@ 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,
}) => {
@@ -112,6 +116,7 @@ describe("Electron", () => {
(globalThis as any)[kElectron] = new Map<string, string>([
["abc", "test-challenge"],
]);
const challenge = await s256Challenge("test-challenge");
const { error } = await proxyClient.signUp.email(
{
@@ -122,8 +127,7 @@ describe("Electron", () => {
{
query: {
client_id: "electron",
code_challenge: "test-challenge",
code_challenge_method: "plain",
code_challenge: challenge,
state: "abc",
},
onResponse: async (ctx) => {
@@ -149,6 +153,7 @@ describe("Electron", () => {
(globalThis as any)[kElectron] = new Map<string, string>([
["abc", "test-challenge"],
]);
const challenge = await s256Challenge("test-challenge");
const { data } = await proxyClient.signUp.email(
{
@@ -159,8 +164,7 @@ describe("Electron", () => {
{
query: {
client_id: "electron",
code_challenge: "test-challenge",
code_challenge_method: "plain",
code_challenge: challenge,
state: "abc",
},
},
@@ -183,9 +187,7 @@ describe("Electron", () => {
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = base64Url.encode(
await createHash("SHA-256").digest(codeVerifier),
);
const codeChallenge = await s256Challenge(codeVerifier);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
await (await auth.$context).adapter.create({
@@ -195,7 +197,6 @@ describe("Electron", () => {
value: JSON.stringify({
userId: user.id,
codeChallenge,
codeChallengeMethod: "s256",
state: "abc",
}),
expiresAt: new Date(Date.now() + 300 * 1000),
@@ -236,8 +237,11 @@ describe("Electron", () => {
},
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = await s256Challenge(codeVerifier);
(globalThis as any)[kElectron] = new Map<string, string>([
["abc", "test-challenge"],
["abc", codeVerifier],
]);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
@@ -247,8 +251,7 @@ describe("Electron", () => {
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: user.id,
codeChallenge: "test-challenge",
codeChallengeMethod: "plain",
codeChallenge,
state: "abc",
}),
expiresAt: new Date(Date.now() + 300 * 1000),
@@ -308,8 +311,11 @@ describe("Electron", () => {
},
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = await s256Challenge(codeVerifier);
(globalThis as any)[kElectron] = new Map<string, string>([
["abc", "test-challenge"],
["abc", codeVerifier],
]);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
@@ -319,8 +325,7 @@ describe("Electron", () => {
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: user.id,
codeChallenge: "test-challenge",
codeChallengeMethod: "plain",
codeChallenge,
state: "abc",
}),
expiresAt: new Date(Date.now() + 999),
@@ -389,6 +394,9 @@ describe("Electron", () => {
}) => {
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({
@@ -397,8 +405,7 @@ describe("Electron", () => {
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: "non-existent-user",
codeChallenge: "x",
codeChallengeMethod: "plain",
codeChallenge,
state: "abc",
}),
expiresAt: new Date(Date.now() + 300_000),
@@ -409,7 +416,11 @@ describe("Electron", () => {
client
.$fetch("/electron/token", {
method: "POST",
body: { token: identifier, code_verifier: "x", state: "abc" },
body: {
token: identifier,
code_verifier: codeVerifier,
state: "abc",
},
throw: true,
customFetchImpl: (url, init) => {
const req = new Request(url.toString(), init);
@@ -439,6 +450,9 @@ describe("Electron", () => {
},
});
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",
@@ -446,8 +460,7 @@ describe("Electron", () => {
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: user.id,
codeChallenge: "x",
codeChallengeMethod: "plain",
codeChallenge,
state: "abc",
}),
expiresAt: new Date(Date.now() + 300_000),
@@ -461,7 +474,11 @@ describe("Electron", () => {
await expect(
client.$fetch("/electron/token", {
method: "POST",
body: { token: identifier, code_verifier: "x", state: "abc" },
body: {
token: identifier,
code_verifier: codeVerifier,
state: "abc",
},
throw: true,
customFetchImpl: (url, init) => {
const req = new Request(url.toString(), init);
@@ -523,8 +540,11 @@ describe("Electron", () => {
},
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = await s256Challenge(codeVerifier);
(globalThis as any)[kElectron] = new Map<string, string>([
["abc", "test-challenge"],
["abc", codeVerifier],
]);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
@@ -534,8 +554,7 @@ describe("Electron", () => {
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: user.id,
codeChallenge: "test-challenge",
codeChallengeMethod: "plain",
codeChallenge,
state: "abc",
}),
expiresAt: new Date(Date.now() + 300 * 1000),
@@ -577,8 +596,11 @@ describe("Electron", () => {
},
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = await s256Challenge(codeVerifier);
(globalThis as any)[kElectron] = new Map<string, string>([
["abc", "test-challenge"],
["abc", codeVerifier],
]);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
@@ -588,8 +610,7 @@ describe("Electron", () => {
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: user.id,
codeChallenge: "test-challenge",
codeChallengeMethod: "plain",
codeChallenge,
state: "abc",
}),
expiresAt: new Date(Date.now() + 300 * 1000),
@@ -620,9 +641,7 @@ describe("Electron", () => {
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = base64Url.encode(
await createHash("SHA-256").digest(codeVerifier),
);
const codeChallenge = await s256Challenge(codeVerifier);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
await (await auth.$context).adapter.create({
@@ -632,7 +651,6 @@ describe("Electron", () => {
value: JSON.stringify({
userId: user.id,
codeChallenge,
codeChallengeMethod: "s256",
state: "abc",
}),
expiresAt: new Date(Date.now() + 300 * 1000),
@@ -865,8 +883,11 @@ describe("Electron", () => {
},
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = await s256Challenge(codeVerifier);
(globalThis as any)[kElectron] = new Map<string, string>([
["abc", "test-challenge"],
["abc", codeVerifier],
]);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
@@ -876,8 +897,7 @@ describe("Electron", () => {
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: user.id,
codeChallenge: "test-challenge",
codeChallengeMethod: "plain",
codeChallenge,
}),
expiresAt: new Date(Date.now() + 300 * 1000),
},
@@ -907,8 +927,11 @@ describe("Electron", () => {
},
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = await s256Challenge(codeVerifier);
(globalThis as any)[kElectron] = new Map<string, string>([
["abc", "test-challenge"],
["abc", codeVerifier],
]);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
@@ -918,8 +941,7 @@ describe("Electron", () => {
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: user.id,
codeChallenge: "test-challenge",
codeChallengeMethod: "plain",
codeChallenge,
state: "def",
}),
expiresAt: new Date(Date.now() + 300 * 1000),
@@ -986,9 +1008,7 @@ describe("Electron", () => {
});
const actualVerifier = base64Url.encode(randomBytes(32));
const actualChallenge = base64Url.encode(
await createHash("SHA-256").digest(actualVerifier),
);
const actualChallenge = await s256Challenge(actualVerifier);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
await (await auth.$context).adapter.create({
@@ -998,7 +1018,6 @@ describe("Electron", () => {
value: JSON.stringify({
userId: user.id,
codeChallenge: actualChallenge,
codeChallengeMethod: "s256",
state: "abc",
}),
expiresAt: new Date(Date.now() + 300_000),
@@ -1022,12 +1041,188 @@ describe("Electron", () => {
}),
).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<string, string>([
["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<string, string>([
["abc", "test-challenge"],
["abc", codeVerifier],
]);
const { user } = await auth.api.signInEmail({
body: { email: "test@test.com", password: "password" },
@@ -1039,8 +1234,7 @@ describe("Electron", () => {
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: user.id,
codeChallenge: "test-challenge",
codeChallengeMethod: "plain",
codeChallenge,
state: "abc",
}),
expiresAt: new Date(Date.now() + 300 * 1000),
@@ -1050,7 +1244,7 @@ describe("Electron", () => {
method: "POST",
body: {
token: identifier,
code_verifier: "test-challenge",
code_verifier: codeVerifier,
state: "abc",
},
});
@@ -1238,12 +1432,11 @@ describe("Electron", () => {
});
});
it("should modify origin header to electron origin if origin is not set", async ({
it("should set origin header directly from electron client", async ({
setProcessType,
}) => {
setProcessType("browser");
let originalOrigin: string | null = null;
let origin: string | null = null;
const { auth, client } = testUtils({
hooks: {
@@ -1251,16 +1444,7 @@ describe("Electron", () => {
origin = ctx.request?.headers.get("origin") ?? null;
}),
},
plugins: [
{
id: "test",
async onRequest(request, ctx) {
const origin = request.headers.get("origin");
originalOrigin = origin;
},
},
electron(),
],
plugins: [electron()],
});
const { runMigrations } = await getMigrations(auth.options);
await runMigrations();
@@ -1271,17 +1455,16 @@ describe("Electron", () => {
callbackURL: "http://localhost:3000/callback",
});
expect(origin).toBe("myapp:/");
expect(originalOrigin).toBeNull();
});
it("should not modify origin header if origin is set", async ({
it("should allow caller to override origin header", async ({
setProcessType,
}) => {
setProcessType("browser");
const originalOrigin = "test.com";
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;
@@ -1300,21 +1483,21 @@ describe("Electron", () => {
},
{
headers: {
origin: originalOrigin,
origin: "http://custom-origin.com",
},
},
);
expect(origin).toBe(originalOrigin);
expect(origin).toBe("http://custom-origin.com");
});
it("should not modify origin header if disableOriginOverride is set", async ({
it("should not use electron-origin header for origin substitution", async ({
setProcessType,
}) => {
setProcessType("browser");
let origin: string | null = null;
const { auth, client } = testUtils({
plugins: [electron({ disableOriginOverride: true })],
const { auth } = testUtils({
plugins: [electron()],
hooks: {
before: createAuthMiddleware(async (ctx) => {
origin = ctx.request?.headers.get("origin") ?? null;
@@ -1323,13 +1506,15 @@ describe("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(null);
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 }) => {
@@ -1367,9 +1552,7 @@ describe("Electron", () => {
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = base64Url.encode(
await createHash("SHA-256").digest(codeVerifier),
);
const codeChallenge = await s256Challenge(codeVerifier);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
await (await auth.$context).adapter.create({
@@ -1379,7 +1562,6 @@ describe("Electron", () => {
value: JSON.stringify({
userId: user.id,
codeChallenge,
codeChallengeMethod: "s256",
state: "abc",
}),
expiresAt: new Date(Date.now() + 300 * 1000),
@@ -1440,9 +1622,7 @@ describe("Electron", () => {
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = base64Url.encode(
await createHash("SHA-256").digest(codeVerifier),
);
const codeChallenge = await s256Challenge(codeVerifier);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
await (await auth.$context).adapter.create({
@@ -1452,7 +1632,6 @@ describe("Electron", () => {
value: JSON.stringify({
userId: user.id,
codeChallenge,
codeChallengeMethod: "s256",
state: "abc",
}),
expiresAt: new Date(Date.now() + 300 * 1000),
@@ -1636,8 +1815,11 @@ describe("Electron", () => {
},
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = await s256Challenge(codeVerifier);
(globalThis as any)[kElectron] = new Map<string, string>([
["abc", "test-challenge"],
["abc", codeVerifier],
]);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
@@ -1647,8 +1829,7 @@ describe("Electron", () => {
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: user.id,
codeChallenge: "test-challenge",
codeChallengeMethod: "plain",
codeChallenge,
state: "abc",
}),
expiresAt: new Date(Date.now() + 300 * 1000),
@@ -1694,8 +1875,11 @@ describe("Electron", () => {
},
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = await s256Challenge(codeVerifier);
(globalThis as any)[kElectron] = new Map<string, string>([
["abc", "test-challenge"],
["abc", codeVerifier],
]);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
@@ -1705,8 +1889,7 @@ describe("Electron", () => {
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: user.id,
codeChallenge: "test-challenge",
codeChallengeMethod: "plain",
codeChallenge,
state: "abc",
}),
expiresAt: new Date(Date.now() + 300 * 1000),
@@ -1878,8 +2061,11 @@ describe("Electron", () => {
},
});
const codeVerifier = base64Url.encode(randomBytes(32));
const codeChallenge = await s256Challenge(codeVerifier);
(globalThis as any)[kElectron] = new Map<string, string>([
["abc", "test-challenge"],
["abc", codeVerifier],
]);
const identifier = generateRandomString(16, "A-Z", "a-z", "0-9");
@@ -1889,8 +2075,7 @@ describe("Electron", () => {
identifier: `electron:${identifier}`,
value: JSON.stringify({
userId: user.id,
codeChallenge: "test-challenge",
codeChallengeMethod: "plain",
codeChallenge,
state: "abc",
}),
expiresAt: new Date(Date.now() + 300 * 1000),