Skip to content

Commit 08e7407

Browse files
committed
refactor(plugin-auth): move AuthManager's WebContainer password hasher into auth-manager-password-hasher.ts
One method body moves unedited: resolvePasswordHasher, the pure-JS scrypt hasher handed to better-auth inside StackBlitz's WebContainer. The class keeps the method, its docblock and signature; it reads no member of the class, so its body is now a plain call. isWebContainerRuntime stays in auth-manager.ts, where the request-state polyfill also reads it, and is exported there for the new module; index.ts does not publish it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr
1 parent e2b7669 commit 08e7407

2 files changed

Lines changed: 83 additions & 33 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25224 cut 11] The WebContainer password hasher of `AuthManager` — the
5+
* pure-JS scrypt hasher handed to better-auth when the runtime is StackBlitz's
6+
* WebContainer, whose `node:crypto.scrypt` polyfill fails sign-up — moved out
7+
* of `auth-manager.ts` whole.
8+
*
9+
* A MOVE, not a redesign. The function below is the body of the
10+
* `AuthManager` method of the same name as it stood, re-indented one level
11+
* and otherwise unedited: the same runtime test, the same scrypt parameters
12+
* and `{saltHex}:{keyHex}` storage format, the same lazy load of
13+
* `@noble/hashes` (only inside WebContainer), and the same warning and
14+
* fallback when that load fails.
15+
*
16+
* ## How the moved body reaches the class
17+
*
18+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
19+
* header carries the full reasoning), which the ten `auth-manager-*.ts`
20+
* modules split out before this one follow. The class keeps the method — the
21+
* published `dist/*.d.ts` lists it — and its body is a plain call: it reads
22+
* no member of the class, so it is handed none, and there is no host type.
23+
* It is no longer `async`: it returns the moved function's own promise, so a
24+
* caller awaits the same promise the body settles, with no extra wrapper.
25+
*
26+
* `isWebContainerRuntime` stays: the request-state polyfill in
27+
* `auth-manager.ts`, which the constructor installs, reads it too. So that
28+
* file exports it and this module imports it by VALUE, for one read inside
29+
* `resolvePasswordHasher`, when the better-auth instance is built.
30+
* `index.ts` lists `auth-manager.ts`'s published names one by one, so that
31+
* export is not published. The two modules import each other, and that is
32+
* safe because nothing reads the binding while they are first evaluated: this
33+
* module's top level is one import and one function declaration.
34+
*
35+
* Nothing re-exports this module, so the published surface does not move.
36+
*/
37+
38+
import { isWebContainerRuntime } from './auth-manager.js';
39+
40+
/** The body of `AuthManager#resolvePasswordHasher`; the method's docblock there is its contract. */
41+
export async function resolvePasswordHasher(): Promise<
42+
{ hash: (password: string) => Promise<string>; verify: (args: { hash: string; password: string }) => Promise<boolean> } | undefined
43+
> {
44+
if (!isWebContainerRuntime()) return undefined;
45+
try {
46+
const { scryptAsync } = await import('@noble/hashes/scrypt.js');
47+
const PARAMS = { N: 16384, r: 16, p: 1, dkLen: 64, maxmem: 128 * 16384 * 16 * 2 } as const;
48+
const toHex = (b: Uint8Array): string => {
49+
let s = '';
50+
for (let i = 0; i < b.length; i++) s += b[i]!.toString(16).padStart(2, '0');
51+
return s;
52+
};
53+
const generateKey = (password: string, saltHex: string): Promise<Uint8Array> =>
54+
scryptAsync(password.normalize('NFKC'), saltHex, PARAMS);
55+
return {
56+
hash: async (password: string) => {
57+
const saltBytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
58+
const saltHex = toHex(saltBytes);
59+
const key = await generateKey(password, saltHex);
60+
return `${saltHex}:${toHex(key)}`;
61+
},
62+
verify: async ({ hash, password }) => {
63+
const [saltHex, keyHex] = hash.split(':');
64+
if (!saltHex || !keyHex) throw new Error('Invalid password hash');
65+
const target = await generateKey(password, saltHex);
66+
return toHex(target) === keyHex;
67+
},
68+
};
69+
} catch (err: any) {
70+
console.warn(
71+
`[AuthManager] WebContainer detected but pure-JS scrypt unavailable: ${err?.message ?? err}. Falling back to default.`,
72+
);
73+
return undefined;
74+
}
75+
}

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

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,10 @@ import {
249249
ownsRoute,
250250
type RequestRoutingHost,
251251
} from './auth-manager-request-routing.js';
252+
// [#25224 cut 11] The WebContainer password hasher; the method of the same
253+
// name below calls it, and hands it nothing, because it reads no member of
254+
// this class.
255+
import { resolvePasswordHasher } from './auth-manager-password-hasher.js';
252256

253257
/**
254258
* Detect WebContainer (StackBlitz) environment.
@@ -259,7 +263,7 @@ import {
259263
* propagate the store across `await` boundaries the way Node's native
260264
* implementation does.
261265
*/
262-
function isWebContainerRuntime(): boolean {
266+
export function isWebContainerRuntime(): boolean {
263267
if (typeof globalThis === 'undefined') return false;
264268
const proc = (globalThis as any).process;
265269
return (
@@ -3157,40 +3161,11 @@ export class AuthManager {
31573161
* Returns `undefined` outside WebContainer so production deployments keep
31583162
* the native (fast) hasher and never load `@noble/hashes`.
31593163
*/
3160-
private async resolvePasswordHasher(): Promise<
3164+
private resolvePasswordHasher(): Promise<
31613165
{ hash: (password: string) => Promise<string>; verify: (args: { hash: string; password: string }) => Promise<boolean> } | undefined
31623166
> {
3163-
if (!isWebContainerRuntime()) return undefined;
3164-
try {
3165-
const { scryptAsync } = await import('@noble/hashes/scrypt.js');
3166-
const PARAMS = { N: 16384, r: 16, p: 1, dkLen: 64, maxmem: 128 * 16384 * 16 * 2 } as const;
3167-
const toHex = (b: Uint8Array): string => {
3168-
let s = '';
3169-
for (let i = 0; i < b.length; i++) s += b[i]!.toString(16).padStart(2, '0');
3170-
return s;
3171-
};
3172-
const generateKey = (password: string, saltHex: string): Promise<Uint8Array> =>
3173-
scryptAsync(password.normalize('NFKC'), saltHex, PARAMS);
3174-
return {
3175-
hash: async (password: string) => {
3176-
const saltBytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
3177-
const saltHex = toHex(saltBytes);
3178-
const key = await generateKey(password, saltHex);
3179-
return `${saltHex}:${toHex(key)}`;
3180-
},
3181-
verify: async ({ hash, password }) => {
3182-
const [saltHex, keyHex] = hash.split(':');
3183-
if (!saltHex || !keyHex) throw new Error('Invalid password hash');
3184-
const target = await generateKey(password, saltHex);
3185-
return toHex(target) === keyHex;
3186-
},
3187-
};
3188-
} catch (err: any) {
3189-
console.warn(
3190-
`[AuthManager] WebContainer detected but pure-JS scrypt unavailable: ${err?.message ?? err}. Falling back to default.`,
3191-
);
3192-
return undefined;
3193-
}
3167+
// [#25224 cut 11] The body moved to `auth-manager-password-hasher.ts`; it reads no member of this class.
3168+
return resolvePasswordHasher();
31943169
}
31953170

31963171
/**

0 commit comments

Comments
 (0)