Skip to content

Commit 4da3645

Browse files
committed
fix(plugin-auth): WebContainer scrypt compatibility with @noble/hashes
1 parent 1959551 commit 4da3645

4 files changed

Lines changed: 50 additions & 23 deletions

File tree

.changeset/plugin-auth-webcontainer-scrypt.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,24 @@
44

55
WebContainer (StackBlitz) signup compatibility: `AuthManager` now auto-detects
66
WebContainer runtimes at construction time and swaps better-auth's default
7-
`node:crypto.scrypt`-based password hasher for the pure-JS hasher from
8-
`@better-auth/utils/password` (which uses `@noble/hashes/scrypt` under the
9-
hood).
7+
`node:crypto.scrypt`-based password hasher for a pure-JS scrypt hasher built
8+
directly on `@noble/hashes`.
109

1110
**Why:** WebContainer's `node:crypto` polyfill ships an incomplete `scrypt`
1211
implementation that throws `TypeError: y.run is not a function` on every
13-
signup, blocking template demos on StackBlitz. The pure-JS implementation is
14-
byte-compatible with the Node hasher (same scrypt params, same `salt:keyHex`
15-
storage format), so accounts created under either hasher remain mutually
16-
verifiable — no migration, no template changes.
12+
signup, blocking template demos on StackBlitz. We can't simply dynamic-import
13+
`@better-auth/utils/password` because its `exports` map gates the pure-JS
14+
build behind a non-`"node"` condition — Node-the-runtime (which WebContainer
15+
reports itself as) always resolves to the `password.node.mjs` build. So we
16+
reimplement the same scrypt hash directly with byte-identical params
17+
(N=16384, r=16, p=1, dkLen=64) and the same `{saltHex}:{keyHex}` storage
18+
format. Hashes produced by either implementation verify against the other —
19+
no migration, no template changes.
1720

1821
**Scope:** detection short-circuits to `undefined` on real Node, so production
19-
deployments are completely unaffected — the JS fallback module is only
20-
dynamically imported when one of `process.versions.webcontainer`,
21-
`SHELL` containing `jsh`, or `STACKBLITZ` env is present.
22+
deployments are completely unaffected — `@noble/hashes` is only dynamically
23+
imported when one of `process.versions.webcontainer`, `SHELL` containing
24+
`jsh`, or `STACKBLITZ` env is present.
2225

2326
Templates (`@template/todo`, `@template/contracts`, …) require no changes;
2427
the fix lives entirely inside `@objectstack/plugin-auth`.

packages/plugins/plugin-auth/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
},
2020
"dependencies": {
2121
"@better-auth/oauth-provider": "^1.6.11",
22-
"@better-auth/utils": "^0.4.0",
22+
"@noble/hashes": "^2.0.1",
2323
"@objectstack/core": "workspace:*",
2424
"@objectstack/platform-objects": "workspace:*",
2525
"@objectstack/spec": "workspace:*",

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -367,13 +367,19 @@ export class AuthManager {
367367
*
368368
* better-auth defaults to `@better-auth/utils/password.node`, which calls
369369
* `node:crypto.scrypt`. WebContainer polyfills that API incompletely and
370-
* signup throws `TypeError: y.run is not a function`. The pure-JS variant
371-
* at `@better-auth/utils/password` uses `@noble/hashes/scrypt` with
372-
* identical params (N=16384, r=16, p=1, dkLen=64) and emits the same
373-
* `{salt}:{keyHex}` format, so existing hashes remain verifiable.
370+
* signup throws `TypeError: y.run is not a function`.
371+
*
372+
* We can't dynamic-import `@better-auth/utils/password` because that
373+
* package's `exports` map gates the pure-JS build behind a non-`"node"`
374+
* condition — Node-the-runtime (which WebContainer reports itself as)
375+
* always resolves to `password.node.mjs`. So we reimplement the same hash
376+
* here using `@noble/hashes/scrypt` directly, with byte-identical params
377+
* (N=16384, r=16, p=1, dkLen=64) and the same `{saltHex}:{keyHex}` storage
378+
* format. Hashes produced by either implementation verify against the
379+
* other — no migration needed.
374380
*
375381
* Returns `undefined` outside WebContainer so production deployments keep
376-
* the native (fast) hasher and never load the JS fallback.
382+
* the native (fast) hasher and never load `@noble/hashes`.
377383
*/
378384
private async resolvePasswordHasher(): Promise<
379385
{ hash: (password: string) => Promise<string>; verify: (args: { hash: string; password: string }) => Promise<boolean> } | undefined
@@ -385,14 +391,32 @@ export class AuthManager {
385391
Boolean((globalThis as any).process?.env?.STACKBLITZ));
386392
if (!isWebContainer) return undefined;
387393
try {
388-
const mod = await import('@better-auth/utils/password');
394+
const { scryptAsync } = await import('@noble/hashes/scrypt.js');
395+
const PARAMS = { N: 16384, r: 16, p: 1, dkLen: 64, maxmem: 128 * 16384 * 16 * 2 } as const;
396+
const toHex = (b: Uint8Array): string => {
397+
let s = '';
398+
for (let i = 0; i < b.length; i++) s += b[i]!.toString(16).padStart(2, '0');
399+
return s;
400+
};
401+
const generateKey = (password: string, saltHex: string): Promise<Uint8Array> =>
402+
scryptAsync(password.normalize('NFKC'), saltHex, PARAMS);
389403
return {
390-
hash: (password: string) => mod.hashPassword(password),
391-
verify: ({ hash, password }) => mod.verifyPassword(hash, password),
404+
hash: async (password: string) => {
405+
const saltBytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
406+
const saltHex = toHex(saltBytes);
407+
const key = await generateKey(password, saltHex);
408+
return `${saltHex}:${toHex(key)}`;
409+
},
410+
verify: async ({ hash, password }) => {
411+
const [saltHex, keyHex] = hash.split(':');
412+
if (!saltHex || !keyHex) throw new Error('Invalid password hash');
413+
const target = await generateKey(password, saltHex);
414+
return toHex(target) === keyHex;
415+
},
392416
};
393417
} catch (err: any) {
394418
console.warn(
395-
`[AuthManager] WebContainer detected but pure-JS password hasher unavailable: ${err?.message ?? err}. Falling back to default.`,
419+
`[AuthManager] WebContainer detected but pure-JS scrypt unavailable: ${err?.message ?? err}. Falling back to default.`,
396420
);
397421
return undefined;
398422
}

pnpm-lock.yaml

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)