Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"scripts": {
"dev": "dotenv -e .env.local -- env NODE_OPTIONS='--import ./instrument.server.mjs' vp dev",
"generate-routes": "tsr generate",
"build": "vp build && cp instrument.server.mjs .output/server",
"build": "vp build && node scripts/check-server-bundle.mjs && cp instrument.server.mjs .output/server",
"preview": "vp preview",
"start": "node --import ./.output/server/instrument.server.mjs scripts/start.mjs",
"db:generate": "drizzle-kit generate",
Expand Down
93 changes: 93 additions & 0 deletions scripts/check-server-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { readFile, readdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";

/**
* Better Auth keeps the in-flight OpenID Connect request in per-request state,
* keyed by module-private tokens `defineRequestState()` mints once per module
* evaluation. Two copies of a module in one bundle means two sets of keys: the
* OAuth provider plugin stores the pending authorization under one, Better Auth
* looks for it under the other, and the sign-in completes without ever resuming
* the authorization. The user lands back on the sign-in page, signed in, while
* the application that sent them there waits for a code that never arrives.
*
* Nothing about that failure is loud, so this guards the shape of the bundle
* instead: every `defineRequestState()` call must appear exactly once, and so
* must the core module that owns the request state. Checking every call, rather
* than a fixed list, also covers state added by future Better Auth releases.
* `vite.config.ts` keeps them in one chunk through `ssr.noExternal`.
*/
const stateCall = /\b(?:var|let|const)\s+([^=;]+?)\s*=\s*defineRequestState(?:\$\d+)?\(/g;
const coreMarker = "No request state found";

/** @param {{ path: string, code: string }[]} sources */
export function findBundleProblems(sources) {
const calls = new Map();
for (const { path, code } of sources) {
for (const [, binding] of code.matchAll(stateCall)) {
const name = binding.replace(/\s+/g, " ").trim();
calls.set(name, [...(calls.get(name) ?? []), path]);
}
}

const problems = [];
if (calls.size === 0) {
problems.push(
"no `defineRequestState()` call found in the build, so this check no longer guards anything. Update it.",
);
}
for (const [name, paths] of calls) {
if (paths.length > 1) {
problems.push(
`request state \`${name}\` is bundled ${paths.length} times (${paths.join(", ")}).`,
);
}
}

const coreHolders = sources.filter(({ code }) => code.includes(coreMarker));
if (coreHolders.length === 0) {
problems.push(
`marker "${coreMarker}" is gone from the build, so this check no longer guards anything. Update the marker.`,
);
} else if (coreHolders.length > 1) {
const where = coreHolders.map(({ path }) => path).join(", ");
problems.push(
`@better-auth/core request state is bundled ${coreHolders.length} times (${where}).`,
);
}
return problems;
}

async function serverChunks(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map((entry) => {
const path = `${directory}/${entry.name}`;
if (entry.isDirectory()) return serverChunks(path);
return /\.[cm]?js$/.test(entry.name) ? [path] : [];
}),
);
return files.flat();
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
const serverDir = fileURLToPath(new URL("../.output/server", import.meta.url));
const chunks = await serverChunks(serverDir);
const sources = await Promise.all(
chunks.map(async (path) => ({
path: path.slice(serverDir.length + 1),
code: await readFile(path, "utf8"),
})),
);

const problems = findBundleProblems(sources);
if (problems.length) {
console.error(
`Server bundle check failed:\n- ${problems.join("\n- ")}\n` +
"Signing in through an application would succeed without ever returning an" +
" authorization code. Keep every better-auth package in `ssr.noExternal` in vite.config.ts.",
);
process.exit(1);
}

console.info("Server bundle check passed: per-request state is not duplicated.");
}
42 changes: 42 additions & 0 deletions scripts/check-server-bundle.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vite-plus/test";

import { findBundleProblems } from "./check-server-bundle.mjs";

const core = `function defineRequestState(initFn) {}
throw new Error("No request state found. Please make sure...");`;
const betterAuth = `var { get: getOAuthServerContext, set: setOAuthServerContext } = defineRequestState(() => null);`;
const oauthProvider = `var oAuthState = defineRequestState$1(() => null);`;

describe("server bundle check", () => {
it("passes when every piece of request state is bundled once", () => {
const sources = [{ path: "_ssr/router.mjs", code: `${core}\n${betterAuth}\n${oauthProvider}` }];

expect(findBundleProblems(sources)).toEqual([]);
});

it("fails when a module that defines request state is bundled twice", () => {
const sources = [
{ path: "_ssr/router.mjs", code: `${core}\n${betterAuth}` },
{ path: "_libs/oauth-provider.mjs", code: `${betterAuth}\n${oauthProvider}` },
];

expect(findBundleProblems(sources)).toEqual([
"request state `{ get: getOAuthServerContext, set: setOAuthServerContext }` is bundled 2 times (_ssr/router.mjs, _libs/oauth-provider.mjs).",
]);
});

it("fails when the core request state module is bundled twice", () => {
const sources = [
{ path: "_ssr/router.mjs", code: `${core}\n${betterAuth}` },
{ path: "_libs/core.mjs", code: core },
];

expect(findBundleProblems(sources)).toEqual([
"@better-auth/core request state is bundled 2 times (_ssr/router.mjs, _libs/core.mjs).",
]);
});

it("fails when it can no longer find what it guards", () => {
expect(findBundleProblems([{ path: "_ssr/router.mjs", code: "" }])).toHaveLength(2);
});
});
14 changes: 14 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ const config = defineConfig({
options: { typeAware: true, typeCheck: true },
},
resolve: { tsconfigPaths: true },
// Better Auth carries the in-flight OpenID Connect request across the upstream
// provider redirect in per-request state, keyed by module-private tokens that
// `defineRequestState()` mints once per module evaluation. Left alone, the server
// build inlines `better-auth` into the SSR bundle while giving each
// `@better-auth/*` plugin its own chunk with a second copy inlined, so the plugin
// writes the pending request under one key and Better Auth reads another. Nothing
// fails loudly: sign-in succeeds, the authorization is silently dropped, and the
// application that sent the user here never receives its code. Bundling them
// together keeps one module instance, and one set of keys. Match by pattern so a
// newly installed `@better-auth/*` plugin is covered without editing this list;
// `scripts/check-server-bundle.mjs` fails the build if anything is duplicated anyway.
ssr: {
noExternal: [/^better-auth(\/|$)/, /^@better-auth\//],
},
plugins: lazyPlugins(() =>
process.env.VITEST
? []
Expand Down
Loading