Skip to content

Commit e3e936e

Browse files
fix(plugin-auth)!: admin import-users sizes a generated temporary password to the deployment's length range (#26018)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XNJBeHjn3bakXGPZdmRLiDi)_ Fixes #25932 Clause-②: no (narrowing) — the exported `IdentityImportDeps` gains a required member, `getAuthContext()`, so a deps object without it no longer type-checks against `runAdminImportUsers`. No request shape is widened or refused. Metadata-protocol: none ## Why `POST /api/v1/auth/admin/import-users` minted every temporary password at 16 characters: `generateTemporaryPassword()` was called with no length, and the import deps carried no auth context to read the range from. Under a configured minimum above 16 the row got a password below it and the user signed in with it (the card's measurement). This is the import half of what PR #25927 did for create-user. ## What changed - **`IdentityImportDeps.getAuthContext()`** (required): better-auth's `$context`, the reader create-user already holds. `AuthPlugin`'s import route wires it to `AuthManager.getAuthContext()`. No second reader of the range exists: the import reads `password.config` from that context, as create-user does. - **`runAdminImportUsers` reads the range once, before any row is written**, and sizes each generated temporary password `min(max(16, minPasswordLength), maxPasswordLength)`. If the context cannot be read the request is refused through `mapAuthApiError` (`import-users failed`) with nothing created, the same mapping create-user uses for the same read. - **The sizing has one home.** `temporaryPasswordLength` moved from `admin-user-endpoints.ts` to the internal module `temporary-password-length.ts` (not re-exported from the package index), and both doors import it. - Nothing else about import changes. With the default range a temporary password is still 16 characters. - Every test fixture that builds `IdentityImportDeps` gains the member (real-manager fixtures wire `manager.getAuthContext()`, mocks answer better-auth's default 8 to 128 range). Changeset: `.changeset/25932-import-users-temporary-password-length.md` (`@objectstack/plugin-auth` patch, BREAKING banner for a host that calls `runAdminImportUsers` itself, ADR-0087 `not-required (runtime-interface-only packages/plugins/plugin-auth/src/admin-import-users.ts#IdentityImportDeps)`, before / now / fix). ## Verification Code head `71668cb6` (the changeset commit after it touches no code). - **Red first** (`admin-import-users-password-length.test.ts`, commit `0c9118a4`, run before the fix): real `AuthManager` over SQLite, the plugin's real mounted route. - ① `temporary` policy, `minPasswordLength: 24`: `expected 'nw33z7aozuB%upS@' to have a length of 24 but got 16`. - ② `auto` policy, no email service (a row with no deliverable channel): `expected '2#jZWgdP#H2oKgGL' to have a length of 24 but got 16`. So `auto` takes the same path, now measured rather than read from code. - ④ `maxPasswordLength: 14`: the row failed (`created: 0`), because better-auth refused the 16-character password. - ③ default range: 16, green before and after. - **Green after the fix**: ① and ② answer 24-character passwords and `sign-in/email` answers `200` with each; ④ answers 14 characters and signs in; ③ stays 16. - New unit pin in `admin-import-users.test.ts`: a context read that throws is refused `500 INTERNAL_ERROR` with `createUser` and the engine update never called. - **Ablation** (`scripts/ablation-replace.mjs`, anchor `generateTemporaryPassword(temporaryLength)` to `generateTemporaryPassword()`, 1 hit, blob `a7809b32` to `e056f1db`): ①, ② and ④ red, ③ green; restored, blob equals HEAD and `git diff HEAD` empty. The tests import `src`, so no build leg. - `pnpm --filter @objectstack/plugin-auth exec vitest run`: 196 files, 3726 passed, 10 skipped, lock VERDICT command-exit 0. - `pnpm --filter @objectstack/plugin-auth typecheck` (after building the package, which the examples program reads): exit 0, `check:test-typecheck` OK. - `eslint --no-inline-config packages/plugins/plugin-auth`: exit 0. - Derived gates (`dispatch-gates --commands` at `425cc7db`, 73 commands): 72 exit 0, `dispatch-gates --ran` reconciles 73 derived, 72 run, 1 NOT MEASURED. The one is `check:dual-build-cjs-loads`, PREREQUISITE NOT MET (exit 3: it reads every package's `dist`, and this worktree built only the plugin-auth and dogfood closures); CI's `Lint & Repo Gates` runs it. Three first-pass reds were prerequisites, re-run green: `check-plugin-teardown-shape --self-test` (shallow clone, fixed by fetching its pinned fixture commit), `check:dts-closure` (a concurrent closure build was rewriting `dist`) and `check:type-check-debt` (lock queue timeout, then exit 0 under the lock). - Full dogfood at `425cc7db` (title carries `!`): 295 of 296 files pass, 2522 tests pass. The one red is `authz-probe-blind-spot.test.ts` on `packages/runtime/src/domains/i18n.ts` (control `handleI18nRequest`: expected 3, got 4). This diff does not touch `packages/runtime`; the file already holds 4 occurrences at the base `05f7758f`, since `69fa23d6` (#25956), so it is red on `main`'s own dogfood battery and is reported to the seat rather than changed here. ## Acceptance notes - `IdentityImportDeps.getAuthContext` is required rather than optional. An optional member would need a fallback when absent, and that fallback is the 16-character defect for any host that forgets it; a required one makes the compiler name every such host. - `#25824` (create-user and set-user-password) is landed by PR #25927; this PR does not touch those doors beyond moving the shared sizing function. --- _Generated by [Claude Code](https://claude.ai/code/session_01A5wzhKHa98KsGQNHM9Z3Tq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d83ee8 commit e3e936e

13 files changed

Lines changed: 298 additions & 8 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth)!: admin import-users sizes a generated temporary password to the deployment's length range, as create-user does
6+
7+
`POST /api/v1/auth/admin/import-users` minted every temporary password at 16 characters, whatever the deployment's `minPasswordLength` / `maxPasswordLength` (the `auth.password_min_length` / `password_max_length` settings, ADR-0069). With a minimum above 16, an imported row got a password below that minimum and the user signed in with it. With a maximum below 16, better-auth refused the 16-character password and the row failed. This covers the `temporary` policy and the `auto` policy's fallback for a row with no deliverable channel. A generated temporary password is now 16 characters, raised to the minimum when the minimum is above 16 and capped at the maximum when the maximum is below 16: the same sizing create-user's `generatePassword` uses, read from the same better-auth context. With the default range it is still 16 characters. Nothing else about the import changes.
8+
9+
The import reads the range once, before any row is written. If the context cannot be read, the request is refused (`500 INTERNAL_ERROR`, `import-users failed`) and no account is created, the same mapping create-user uses for the same read.
10+
11+
Clause-②: no (narrowing) — the exported `IdentityImportDeps` gains a required member, `getAuthContext()`, so a deps object without it no longer type-checks against `runAdminImportUsers`. No request shape is widened or refused.
12+
13+
Metadata-protocol: none (an admin REST door's handling of a request; nothing a metadata author writes changes)
14+
15+
<!-- adr-0087: not-required (runtime-interface-only packages/plugins/plugin-auth/src/admin-import-users.ts#IdentityImportDeps) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped. The one exported declaration that changes is the runtime deps interface `IdentityImportDeps`, which gains a required member; it has no Zod schema, no spec declaration and no stored form, so there is nothing for `objectstack migrate meta` to rewrite, and the compiler names every host that must supply the new member. -->
16+
17+
**BREAKING** for a host that calls `runAdminImportUsers` itself (the `AuthPlugin` route is already wired).
18+
19+
- **Before.** `IdentityImportDeps` had no auth-context member, and every temporary password was 16 characters.
20+
- **Now.** `IdentityImportDeps.getAuthContext()` is required. It answers better-auth's `$context`, whose `password.config` holds the range. A JavaScript host that omits it has every import refused with `500 INTERNAL_ERROR` before anything is written.
21+
- **Fix.** Pass `getAuthContext` in the deps, wired to the same reader create-user uses, for example `AuthManager.getAuthContext()`.

‎packages/plugins/plugin-auth/src/account-invitation-marker.webcontainer.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ describe('[#23343] the account-invitation marker under the WebContainer request-
102102
const res = await runAdminImportUsers(
103103
{
104104
getAuthApi: () => manager.getApi(),
105+
getAuthContext: () => manager.getAuthContext(),
105106
getDataEngine: () => manager.getDataEngine() as never,
106107
phoneNumberEnabled: () => false,
107108
emailServiceAvailable: () => manager.isEmailServiceAvailable(),
@@ -166,6 +167,7 @@ describe('[#23343] the account-invitation marker under the WebContainer request-
166167
const res = await runAdminImportUsers(
167168
{
168169
getAuthApi: () => manager.getApi(),
170+
getAuthContext: () => manager.getAuthContext(),
169171
getDataEngine: () => manager.getDataEngine() as never,
170172
phoneNumberEnabled: () => false,
171173
emailServiceAvailable: () => manager.isEmailServiceAvailable(),

‎packages/plugins/plugin-auth/src/admin-import-users-account-invitation.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ async function importThroughMount(app: Hono, email: string): Promise<void> {
188188
/** Deps for calling the import directly (no mount), as the invite-link file's ② does. */
189189
const directDeps = (manager: AuthManager, logger?: IdentityImportDeps['logger']): IdentityImportDeps => ({
190190
getAuthApi: () => manager.getApi(),
191+
getAuthContext: () => manager.getAuthContext(),
191192
getDataEngine: () => manager.getDataEngine() as never,
192193
phoneNumberEnabled: () => manager.isPhoneNumberEnabled(),
193194
emailServiceAvailable: () => manager.isEmailServiceAvailable(),

‎packages/plugins/plugin-auth/src/admin-import-users-invite-delivery-failure.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ describe('[#23598] the import reports an invitation the email transport did not
251251
const res = await runAdminImportUsers(
252252
{
253253
getAuthApi: () => manager.getApi(),
254+
getAuthContext: () => manager.getAuthContext(),
254255
getDataEngine: () => manager.getDataEngine() as never,
255256
phoneNumberEnabled: () => false,
256257
emailServiceAvailable: () => manager.isEmailServiceAvailable(),

‎packages/plugins/plugin-auth/src/admin-import-users-invite-link.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ describe('[#23152] the identity import invitation link', () => {
209209
const email = 'invitee-direct@acme.example.com';
210210
const deps: IdentityImportDeps = {
211211
getAuthApi: () => manager.getApi(),
212+
getAuthContext: () => manager.getAuthContext(),
212213
getDataEngine: () => manager.getDataEngine() as never,
213214
phoneNumberEnabled: () => manager.isPhoneNumberEnabled(),
214215
emailServiceAvailable: () => manager.isEmailServiceAvailable(),

‎packages/plugins/plugin-auth/src/admin-import-users-manager-pass.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ function makeHarness(opts: {
163163

164164
const deps: IdentityImportDeps = {
165165
getAuthApi: async () => ({ createUser, requestPasswordReset }),
166+
getAuthContext: async () => ({ password: { config: { minPasswordLength: 8, maxPasswordLength: 128 } } }),
166167
getDataEngine: () => ({ find, update, insert }),
167168
phoneNumberEnabled: () => opts.phoneEnabled ?? false,
168169
emailServiceAvailable: () => opts.emailAvailable ?? true,
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25932] The identity import sizes a generated temporary password to the
5+
* deployment's password length range, the way create-user's
6+
* `generatePassword` does: `min(max(16, minPasswordLength), maxPasswordLength)`,
7+
* read from better-auth's own `$context` (`password.config`), the one reader
8+
* of that range.
9+
*
10+
* Driven on a real stack: real `ObjectQL` over `@objectstack/driver-sql` +
11+
* better-sqlite3, the objects from this plugin's own manifest, a real
12+
* `AuthManager` whose `emailAndPassword` range is what the ADR-0069
13+
* `auth.password_min_length` / `password_max_length` settings map onto, and
14+
* the plugin's REAL `POST /admin/import-users` mount, so the pins also hold
15+
* the route's wiring of the import deps. Only the platform-admin gate's
16+
* session read is a stand-in (the `admin-import-users-account-invitation`
17+
* shape).
18+
*
19+
* Pinned:
20+
* ① `temporary` policy under a minimum of 24: the row's password is 24
21+
* characters, and the imported user signs in with it.
22+
* ② `auto` policy, a row with no deliverable channel (no email service
23+
* wired): the fallback temporary password is sized the same way.
24+
* ③ The default range: still 16 characters.
25+
* ④ A maximum below 16 caps the length at that maximum.
26+
*/
27+
28+
import { describe, it, expect, afterEach, vi } from 'vitest';
29+
import { Hono } from 'hono';
30+
import { ObjectQL } from '@objectstack/objectql';
31+
import { SqlDriver } from '@objectstack/driver-sql';
32+
import type { PluginContext } from '@objectstack/core';
33+
import { AuthManager } from './auth-manager.js';
34+
import { AuthPlugin } from './auth-plugin.js';
35+
import { authIdentityObjects } from './manifest.js';
36+
37+
const ORIGIN = 'https://acme.example.com';
38+
const BASE = '/api/v1/auth';
39+
const SECRET = 'test-secret-at-least-32-chars-long-25932';
40+
const ADMIN = { id: 'admin-25932', email: 'ada.admin@acme.example.com' };
41+
42+
const engines: ObjectQL[] = [];
43+
afterEach(async () => {
44+
vi.restoreAllMocks();
45+
while (engines.length) {
46+
try {
47+
await (engines.pop() as unknown as { destroy?(): Promise<void> })?.destroy?.();
48+
} catch {
49+
/* noop */
50+
}
51+
}
52+
});
53+
54+
async function bootEngine(): Promise<ObjectQL> {
55+
const engine = new ObjectQL();
56+
engines.push(engine);
57+
engine.registerDriver(
58+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
59+
true,
60+
);
61+
await engine.init();
62+
for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth');
63+
await engine.syncSchemas();
64+
return engine;
65+
}
66+
67+
/** A real manager with the given length range; `withEmail` wires a capturing email service. */
68+
async function bootManager(
69+
emailAndPassword: { minPasswordLength?: number; maxPasswordLength?: number },
70+
withEmail = true,
71+
): Promise<AuthManager> {
72+
const engine = await bootEngine();
73+
const manager = new AuthManager({
74+
secret: SECRET,
75+
baseUrl: ORIGIN,
76+
dataEngine: engine as never,
77+
plugins: { admin: true },
78+
emailAndPassword,
79+
} as never);
80+
if (withEmail) manager.setEmailService({ sendTemplate: async () => ({ status: 'sent' }) } as never);
81+
return manager;
82+
}
83+
84+
const mockCtx = (): PluginContext =>
85+
({
86+
registerService: vi.fn(),
87+
getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)),
88+
getServices: vi.fn(() => new Map()),
89+
hook: vi.fn(),
90+
trigger: vi.fn(),
91+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
92+
getKernel: vi.fn(),
93+
}) as unknown as PluginContext;
94+
95+
/** The plugin's REAL route registration; only the gate's session read is a stand-in. */
96+
async function mountImport(manager: AuthManager): Promise<Hono> {
97+
const app = new Hono();
98+
const ctx = mockCtx();
99+
const plugin = new AuthPlugin({ secret: SECRET, plugins: { oidcProvider: false } });
100+
await plugin.init(ctx);
101+
const realApi = await manager.getApi();
102+
const gated = new Proxy(manager, {
103+
get(target, key) {
104+
if (key === 'getApi') {
105+
return async () => ({ ...realApi, getSession: async () => ({ user: { ...ADMIN, isPlatformAdmin: true } }) });
106+
}
107+
const value = Reflect.get(target, key, target);
108+
return typeof value === 'function' ? value.bind(target) : value;
109+
},
110+
});
111+
(plugin as unknown as { authManager: unknown }).authManager = gated;
112+
(plugin as unknown as {
113+
registerAuthRoutes(server: unknown, ctx: PluginContext): void;
114+
}).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx);
115+
return app;
116+
}
117+
118+
/** One email row through the mounted import; answers that row. */
119+
async function importOne(app: Hono, email: string, passwordPolicy: 'temporary' | 'auto'): Promise<Record<string, unknown>> {
120+
const res = await app.request(`${ORIGIN}${BASE}/admin/import-users`, {
121+
method: 'POST',
122+
headers: { 'content-type': 'application/json', origin: ORIGIN },
123+
body: JSON.stringify({ format: 'json', mode: 'insert', passwordPolicy, rows: [{ email, name: 'Imported' }] }),
124+
});
125+
const answer = (await res.json()) as { data?: { summary?: { created?: number; errors?: number }; rows?: Array<Record<string, unknown>> } };
126+
expect(res.status, JSON.stringify(answer)).toBe(200);
127+
expect(answer.data?.summary).toMatchObject({ created: 1, errors: 0 });
128+
return answer.data!.rows![0]!;
129+
}
130+
131+
async function signIn(manager: AuthManager, email: string, password: string): Promise<Response> {
132+
return manager.handleRequest(
133+
new Request(`${ORIGIN}${BASE}/sign-in/email`, {
134+
method: 'POST',
135+
headers: { 'content-type': 'application/json', origin: ORIGIN },
136+
body: JSON.stringify({ email, password }),
137+
}),
138+
);
139+
}
140+
141+
describe('[#25932] the identity import sizes a temporary password to the deployment length range', () => {
142+
it('① temporary policy, minimum 24: a 24-character password the user signs in with', async () => {
143+
vi.spyOn(console, 'warn').mockImplementation(() => {});
144+
const manager = await bootManager({ minPasswordLength: 24 });
145+
const app = await mountImport(manager);
146+
const email = 'temp-25932@acme.example.com';
147+
148+
const row = await importOne(app, email, 'temporary');
149+
150+
expect(row.delivery).toBe('temporary');
151+
const password = String(row.temporaryPassword);
152+
expect(password).toHaveLength(24);
153+
const res = await signIn(manager, email, password);
154+
expect(res.status, await res.clone().text()).toBe(200);
155+
});
156+
157+
it('② auto policy, a row with no deliverable channel: the fallback password is sized the same way', async () => {
158+
vi.spyOn(console, 'warn').mockImplementation(() => {});
159+
const manager = await bootManager({ minPasswordLength: 24 }, false);
160+
expect(manager.isEmailServiceAvailable(), 'no email service: the row has no deliverable channel').toBe(false);
161+
const app = await mountImport(manager);
162+
const email = 'auto-25932@acme.example.com';
163+
164+
const row = await importOne(app, email, 'auto');
165+
166+
expect(row.delivery).toBe('temporary');
167+
const password = String(row.temporaryPassword);
168+
expect(password).toHaveLength(24);
169+
const res = await signIn(manager, email, password);
170+
expect(res.status, await res.clone().text()).toBe(200);
171+
});
172+
173+
it('③ the default range: still 16 characters', async () => {
174+
vi.spyOn(console, 'warn').mockImplementation(() => {});
175+
const manager = await bootManager({});
176+
const app = await mountImport(manager);
177+
178+
const row = await importOne(app, 'default-25932@acme.example.com', 'temporary');
179+
180+
expect(String(row.temporaryPassword)).toHaveLength(16);
181+
});
182+
183+
it('④ a maximum below 16 caps the length at that maximum', async () => {
184+
vi.spyOn(console, 'warn').mockImplementation(() => {});
185+
const manager = await bootManager({ maxPasswordLength: 14 });
186+
const app = await mountImport(manager);
187+
const email = 'capped-25932@acme.example.com';
188+
189+
const row = await importOne(app, email, 'temporary');
190+
191+
const password = String(row.temporaryPassword);
192+
expect(password).toHaveLength(14);
193+
const res = await signIn(manager, email, password);
194+
expect(res.status, await res.clone().text()).toBe(200);
195+
});
196+
});

‎packages/plugins/plugin-auth/src/admin-import-users.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ function makeDeps(opts: {
7373
});
7474
const deps: IdentityImportDeps = {
7575
getAuthApi: async () => ({ createUser, requestPasswordReset }),
76+
// better-auth's default range (8–128): temporary passwords stay 16 characters.
77+
getAuthContext: async () => ({ password: { config: { minPasswordLength: 8, maxPasswordLength: 128 } } }),
7678
getDataEngine: () => ({ find, update, insert }),
7779
phoneNumberEnabled: () => opts.phoneEnabled ?? false,
7880
emailServiceAvailable: () => opts.emailAvailable ?? true,
@@ -169,6 +171,25 @@ describe('runAdminImportUsers — request validation', () => {
169171
expect(res.status).toBe(413);
170172
expect(m.createUser).not.toHaveBeenCalled();
171173
});
174+
175+
it('[#25932] refuses before any write when the length range cannot be read', async () => {
176+
const m = makeDeps();
177+
const deps: IdentityImportDeps = {
178+
...m.deps,
179+
getAuthContext: async () => {
180+
throw new Error('auth context unavailable');
181+
},
182+
};
183+
const res = await runAdminImportUsers(
184+
deps,
185+
makeRequest({ passwordPolicy: 'temporary', format: 'json', rows: [{ email: 'a@b.co' }] }),
186+
ACTOR,
187+
);
188+
expect(res.status).toBe(500);
189+
expect(res.body.error?.code).toBe('INTERNAL_ERROR');
190+
expect(m.createUser).not.toHaveBeenCalled();
191+
expect(m.update).not.toHaveBeenCalled();
192+
});
172193
});
173194

174195
describe('runAdminImportUsers — row validation (also on dryRun)', () => {

‎packages/plugins/plugin-auth/src/admin-import-users.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,16 @@ import type {
111111
} from '@objectstack/rest';
112112
import { prepareImportRequest, runImport } from '@objectstack/rest';
113113
import { generatePlaceholderEmail, isPlaceholderEmail } from './placeholder-email.js';
114-
import { generateTemporaryPassword, normalizePhoneNumber, isLikelyEmail, type AdminActor, type EndpointResult } from './admin-user-endpoints.js';
114+
import {
115+
generateTemporaryPassword,
116+
normalizePhoneNumber,
117+
isLikelyEmail,
118+
mapAuthApiError,
119+
type AdminActor,
120+
type AuthContextLike,
121+
type EndpointResult,
122+
} from './admin-user-endpoints.js';
123+
import { temporaryPasswordLength } from './temporary-password-length.js';
115124
import { SYS_USER_IMPORT_UPDATE_FIELDS } from './sys-user-writable-fields.js';
116125
import { readJsonRequestBody } from './request-json-body.js';
117126
import {
@@ -168,6 +177,12 @@ export interface IdentityImportEngine {
168177
export interface IdentityImportDeps {
169178
/** better-auth server api (createUser + requestPasswordReset). */
170179
getAuthApi(): Promise<any>;
180+
/**
181+
* [#25932] better-auth's `$context`, the same reader create-user holds: its
182+
* `password.config` is the deployment's length range, which sizes every
183+
* generated temporary password (`temporaryPasswordLength`).
184+
*/
185+
getAuthContext(): Promise<{ password: Pick<AuthContextLike['password'], 'config'> }>;
171186
getDataEngine(): IdentityImportEngine | undefined;
172187
/**
173188
* Optional object-declaration reader. It feeds field coercion (best-effort:
@@ -610,6 +625,14 @@ export async function runAdminImportUsers(
610625
if (typeof authApi.createUser !== 'function') {
611626
return fail(501, 'NOT_IMPLEMENTED', 'The better-auth admin plugin is not enabled (auth.plugins.admin)');
612627
}
628+
// [#25932] The deployment's length range sizes every temporary password this
629+
// batch mints, read before any row is written, as create-user reads it.
630+
let temporaryLength: number;
631+
try {
632+
temporaryLength = temporaryPasswordLength((await deps.getAuthContext()).password.config);
633+
} catch (error) {
634+
return mapAuthApiError(error, 'import-users failed');
635+
}
613636

614637
const protocol: ImportProtocolLike = {
615638
// [#22800] The declaration reader the parser was handed above. With it,
@@ -687,7 +710,7 @@ export async function runAdminImportUsers(
687710
// credential-less accounts (better-auth: omitted password → no
688711
// credential record); the credential is minted later by the user via
689712
// set-initial-password / the reset flow, which creates it on demand.
690-
const password = plan.kind === 'temporary' ? generateTemporaryPassword() : undefined;
713+
const password = plan.kind === 'temporary' ? generateTemporaryPassword(temporaryLength) : undefined;
691714

692715
const created = await authApi.createUser({
693716
body: {

‎packages/plugins/plugin-auth/src/admin-user-endpoints.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ import { resolveDefaultOrgId } from './tenancy-service.js';
231231
import type { AddMemberCapableApi } from './organization-add-member.js';
232232
import { readJsonRequestBody } from './request-json-body.js';
233233
import { cloudManagedRefusal } from './cloud-managed.js';
234+
import { temporaryPasswordLength } from './temporary-password-length.js';
234235
import { isCloudManaged } from '@objectstack/core';
235236
import { resolveThrownHttpError, validationFailureDetails } from '@objectstack/types';
236237

@@ -241,7 +242,7 @@ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
241242
// Must satisfy the ADR-0069 class-mix policy (lower + upper + digit + symbol),
242243
// so one guaranteed character per class + random fill. Never shorter than 12;
243244
// a caller holding the deployment's length range sizes it to that range (see
244-
// `temporaryPasswordLength`). Ambiguous glyphs (O/0, l/1) are excluded — these
245+
// `temporaryPasswordLength` in temporary-password-length.ts). Ambiguous glyphs (O/0, l/1) are excluded — these
245246
// passwords are read off a dialog and typed once.
246247
const LOWER = 'abcdefghijkmnopqrstuvwxyz';
247248
const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
@@ -325,11 +326,6 @@ export function isLikelyEmail(value: string): boolean {
325326
*/
326327
type PasswordLengthPolicy = AuthContextLike['password']['config'];
327328

328-
/** A generated password's length: 16, raised to the range's minimum and capped at its maximum. */
329-
function temporaryPasswordLength({ minPasswordLength, maxPasswordLength }: PasswordLengthPolicy): number {
330-
return Math.min(Math.max(16, minPasswordLength), maxPasswordLength);
331-
}
332-
333329
/**
334330
* Resolve the password for a create/set request: an explicit `password` /
335331
* `newPassword`, or a generated temporary one when `generatePassword` is true.

0 commit comments

Comments
 (0)