From 0c9118a45a1c0c77804e5eef731cb05be5eace1c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:16:40 +0000 Subject: [PATCH 1/3] test(plugin-auth): pin import-users temporary password length to the deployment range (red) Claude-Session: https://claude.ai/code/session_01A5wzhKHa98KsGQNHM9Z3Tq Co-authored-by: Claude --- ...admin-import-users-password-length.test.ts | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/admin-import-users-password-length.test.ts diff --git a/packages/plugins/plugin-auth/src/admin-import-users-password-length.test.ts b/packages/plugins/plugin-auth/src/admin-import-users-password-length.test.ts new file mode 100644 index 00000000000..f1074de5906 --- /dev/null +++ b/packages/plugins/plugin-auth/src/admin-import-users-password-length.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25932] The identity import sizes a generated temporary password to the + * deployment's password length range, the way create-user's + * `generatePassword` does: `min(max(16, minPasswordLength), maxPasswordLength)`, + * read from better-auth's own `$context` (`password.config`), the one reader + * of that range. + * + * Driven on a real stack: real `ObjectQL` over `@objectstack/driver-sql` + + * better-sqlite3, the objects from this plugin's own manifest, a real + * `AuthManager` whose `emailAndPassword` range is what the ADR-0069 + * `auth.password_min_length` / `password_max_length` settings map onto, and + * the plugin's REAL `POST /admin/import-users` mount, so the pins also hold + * the route's wiring of the import deps. Only the platform-admin gate's + * session read is a stand-in (the `admin-import-users-account-invitation` + * shape). + * + * Pinned: + * ① `temporary` policy under a minimum of 24: the row's password is 24 + * characters, and the imported user signs in with it. + * ② `auto` policy, a row with no deliverable channel (no email service + * wired): the fallback temporary password is sized the same way. + * ③ The default range: still 16 characters. + * ④ A maximum below 16 caps the length at that maximum. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { Hono } from 'hono'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { PluginContext } from '@objectstack/core'; +import { AuthManager } from './auth-manager.js'; +import { AuthPlugin } from './auth-plugin.js'; +import { authIdentityObjects } from './manifest.js'; + +const ORIGIN = 'https://acme.example.com'; +const BASE = '/api/v1/auth'; +const SECRET = 'test-secret-at-least-32-chars-long-25932'; +const ADMIN = { id: 'admin-25932', email: 'ada.admin@acme.example.com' }; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { + await (engines.pop() as unknown as { destroy?(): Promise })?.destroy?.(); + } catch { + /* noop */ + } + } +}); + +async function bootEngine(): Promise { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + await engine.syncSchemas(); + return engine; +} + +/** A real manager with the given length range; `withEmail` wires a capturing email service. */ +async function bootManager( + emailAndPassword: { minPasswordLength?: number; maxPasswordLength?: number }, + withEmail = true, +): Promise { + const engine = await bootEngine(); + const manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine as never, + plugins: { admin: true }, + emailAndPassword, + } as never); + if (withEmail) manager.setEmailService({ sendTemplate: async () => ({ status: 'sent' }) } as never); + return manager; +} + +const mockCtx = (): PluginContext => + ({ + registerService: vi.fn(), + getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)), + getServices: vi.fn(() => new Map()), + hook: vi.fn(), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + }) as unknown as PluginContext; + +/** The plugin's REAL route registration; only the gate's session read is a stand-in. */ +async function mountImport(manager: AuthManager): Promise { + const app = new Hono(); + const ctx = mockCtx(); + const plugin = new AuthPlugin({ secret: SECRET, plugins: { oidcProvider: false } }); + await plugin.init(ctx); + const realApi = await manager.getApi(); + const gated = new Proxy(manager, { + get(target, key) { + if (key === 'getApi') { + return async () => ({ ...realApi, getSession: async () => ({ user: { ...ADMIN, isPlatformAdmin: true } }) }); + } + const value = Reflect.get(target, key, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + (plugin as unknown as { authManager: unknown }).authManager = gated; + (plugin as unknown as { + registerAuthRoutes(server: unknown, ctx: PluginContext): void; + }).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx); + return app; +} + +/** One email row through the mounted import; answers that row. */ +async function importOne(app: Hono, email: string, passwordPolicy: 'temporary' | 'auto'): Promise> { + const res = await app.request(`${ORIGIN}${BASE}/admin/import-users`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN }, + body: JSON.stringify({ format: 'json', mode: 'insert', passwordPolicy, rows: [{ email, name: 'Imported' }] }), + }); + const answer = (await res.json()) as { data?: { summary?: { created?: number; errors?: number }; rows?: Array> } }; + expect(res.status, JSON.stringify(answer)).toBe(200); + expect(answer.data?.summary).toMatchObject({ created: 1, errors: 0 }); + return answer.data!.rows![0]!; +} + +async function signIn(manager: AuthManager, email: string, password: string): Promise { + return manager.handleRequest( + new Request(`${ORIGIN}${BASE}/sign-in/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: ORIGIN }, + body: JSON.stringify({ email, password }), + }), + ); +} + +describe('[#25932] the identity import sizes a temporary password to the deployment length range', () => { + it('① temporary policy, minimum 24: a 24-character password the user signs in with', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = await bootManager({ minPasswordLength: 24 }); + const app = await mountImport(manager); + const email = 'temp-25932@acme.example.com'; + + const row = await importOne(app, email, 'temporary'); + + expect(row.delivery).toBe('temporary'); + const password = String(row.temporaryPassword); + expect(password).toHaveLength(24); + const res = await signIn(manager, email, password); + expect(res.status, await res.clone().text()).toBe(200); + }); + + it('② auto policy, a row with no deliverable channel: the fallback password is sized the same way', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = await bootManager({ minPasswordLength: 24 }, false); + expect(manager.isEmailServiceAvailable(), 'no email service: the row has no deliverable channel').toBe(false); + const app = await mountImport(manager); + const email = 'auto-25932@acme.example.com'; + + const row = await importOne(app, email, 'auto'); + + expect(row.delivery).toBe('temporary'); + const password = String(row.temporaryPassword); + expect(password).toHaveLength(24); + const res = await signIn(manager, email, password); + expect(res.status, await res.clone().text()).toBe(200); + }); + + it('③ the default range: still 16 characters', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = await bootManager({}); + const app = await mountImport(manager); + + const row = await importOne(app, 'default-25932@acme.example.com', 'temporary'); + + expect(String(row.temporaryPassword)).toHaveLength(16); + }); + + it('④ a maximum below 16 caps the length at that maximum', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = await bootManager({ maxPasswordLength: 14 }); + const app = await mountImport(manager); + const email = 'capped-25932@acme.example.com'; + + const row = await importOne(app, email, 'temporary'); + + const password = String(row.temporaryPassword); + expect(password).toHaveLength(14); + const res = await signIn(manager, email, password); + expect(res.status, await res.clone().text()).toBe(200); + }); +}); From 71668cb657e400cfe3fd737f4e9e942695b61847 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:18:14 +0000 Subject: [PATCH 2/3] fix(plugin-auth)!: import-users sizes a temporary password to the deployment's length range The identity import minted every temporary password at 16 characters, below a configured minPasswordLength above 16, and the user signed in with it. IdentityImportDeps now carries getAuthContext (the reader create-user holds); the batch reads password.config once before any write and sizes each generated password min(max(16, min), max). The sizing moves to an internal module both doors import. Claude-Session: https://claude.ai/code/session_01A5wzhKHa98KsGQNHM9Z3Tq Co-authored-by: Claude --- ...unt-invitation-marker.webcontainer.test.ts | 2 ++ ...in-import-users-account-invitation.test.ts | 1 + ...port-users-invite-delivery-failure.test.ts | 1 + .../admin-import-users-invite-link.test.ts | 1 + .../admin-import-users-manager-pass.test.ts | 1 + .../src/admin-import-users.test.ts | 21 +++++++++++++++ .../plugin-auth/src/admin-import-users.ts | 27 +++++++++++++++++-- .../plugin-auth/src/admin-user-endpoints.ts | 8 ++---- .../plugins/plugin-auth/src/auth-plugin.ts | 1 + .../src/durability-swallow-repair.test.ts | 1 + .../src/temporary-password-length.ts | 25 +++++++++++++++++ 11 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/temporary-password-length.ts diff --git a/packages/plugins/plugin-auth/src/account-invitation-marker.webcontainer.test.ts b/packages/plugins/plugin-auth/src/account-invitation-marker.webcontainer.test.ts index 3ad0705ece1..046de3fa245 100644 --- a/packages/plugins/plugin-auth/src/account-invitation-marker.webcontainer.test.ts +++ b/packages/plugins/plugin-auth/src/account-invitation-marker.webcontainer.test.ts @@ -102,6 +102,7 @@ describe('[#23343] the account-invitation marker under the WebContainer request- const res = await runAdminImportUsers( { getAuthApi: () => manager.getApi(), + getAuthContext: () => manager.getAuthContext(), getDataEngine: () => manager.getDataEngine() as never, phoneNumberEnabled: () => false, emailServiceAvailable: () => manager.isEmailServiceAvailable(), @@ -166,6 +167,7 @@ describe('[#23343] the account-invitation marker under the WebContainer request- const res = await runAdminImportUsers( { getAuthApi: () => manager.getApi(), + getAuthContext: () => manager.getAuthContext(), getDataEngine: () => manager.getDataEngine() as never, phoneNumberEnabled: () => false, emailServiceAvailable: () => manager.isEmailServiceAvailable(), diff --git a/packages/plugins/plugin-auth/src/admin-import-users-account-invitation.test.ts b/packages/plugins/plugin-auth/src/admin-import-users-account-invitation.test.ts index b00b728528e..3eb25eec890 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users-account-invitation.test.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users-account-invitation.test.ts @@ -188,6 +188,7 @@ async function importThroughMount(app: Hono, email: string): Promise { /** Deps for calling the import directly (no mount), as the invite-link file's ② does. */ const directDeps = (manager: AuthManager, logger?: IdentityImportDeps['logger']): IdentityImportDeps => ({ getAuthApi: () => manager.getApi(), + getAuthContext: () => manager.getAuthContext(), getDataEngine: () => manager.getDataEngine() as never, phoneNumberEnabled: () => manager.isPhoneNumberEnabled(), emailServiceAvailable: () => manager.isEmailServiceAvailable(), diff --git a/packages/plugins/plugin-auth/src/admin-import-users-invite-delivery-failure.test.ts b/packages/plugins/plugin-auth/src/admin-import-users-invite-delivery-failure.test.ts index 984a26f89e9..47371f608ed 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users-invite-delivery-failure.test.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users-invite-delivery-failure.test.ts @@ -251,6 +251,7 @@ describe('[#23598] the import reports an invitation the email transport did not const res = await runAdminImportUsers( { getAuthApi: () => manager.getApi(), + getAuthContext: () => manager.getAuthContext(), getDataEngine: () => manager.getDataEngine() as never, phoneNumberEnabled: () => false, emailServiceAvailable: () => manager.isEmailServiceAvailable(), diff --git a/packages/plugins/plugin-auth/src/admin-import-users-invite-link.test.ts b/packages/plugins/plugin-auth/src/admin-import-users-invite-link.test.ts index 504e458664f..c4e3b5587c8 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users-invite-link.test.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users-invite-link.test.ts @@ -209,6 +209,7 @@ describe('[#23152] the identity import invitation link', () => { const email = 'invitee-direct@acme.example.com'; const deps: IdentityImportDeps = { getAuthApi: () => manager.getApi(), + getAuthContext: () => manager.getAuthContext(), getDataEngine: () => manager.getDataEngine() as never, phoneNumberEnabled: () => manager.isPhoneNumberEnabled(), emailServiceAvailable: () => manager.isEmailServiceAvailable(), diff --git a/packages/plugins/plugin-auth/src/admin-import-users-manager-pass.test.ts b/packages/plugins/plugin-auth/src/admin-import-users-manager-pass.test.ts index 015466dc679..1cdc42fe1c5 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users-manager-pass.test.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users-manager-pass.test.ts @@ -163,6 +163,7 @@ function makeHarness(opts: { const deps: IdentityImportDeps = { getAuthApi: async () => ({ createUser, requestPasswordReset }), + getAuthContext: async () => ({ password: { config: { minPasswordLength: 8, maxPasswordLength: 128 } } }), getDataEngine: () => ({ find, update, insert }), phoneNumberEnabled: () => opts.phoneEnabled ?? false, emailServiceAvailable: () => opts.emailAvailable ?? true, diff --git a/packages/plugins/plugin-auth/src/admin-import-users.test.ts b/packages/plugins/plugin-auth/src/admin-import-users.test.ts index c02a17176f4..d6ff568e8c1 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users.test.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users.test.ts @@ -73,6 +73,8 @@ function makeDeps(opts: { }); const deps: IdentityImportDeps = { getAuthApi: async () => ({ createUser, requestPasswordReset }), + // better-auth's default range (8–128): temporary passwords stay 16 characters. + getAuthContext: async () => ({ password: { config: { minPasswordLength: 8, maxPasswordLength: 128 } } }), getDataEngine: () => ({ find, update, insert }), phoneNumberEnabled: () => opts.phoneEnabled ?? false, emailServiceAvailable: () => opts.emailAvailable ?? true, @@ -169,6 +171,25 @@ describe('runAdminImportUsers — request validation', () => { expect(res.status).toBe(413); expect(m.createUser).not.toHaveBeenCalled(); }); + + it('[#25932] refuses before any write when the length range cannot be read', async () => { + const m = makeDeps(); + const deps: IdentityImportDeps = { + ...m.deps, + getAuthContext: async () => { + throw new Error('auth context unavailable'); + }, + }; + const res = await runAdminImportUsers( + deps, + makeRequest({ passwordPolicy: 'temporary', format: 'json', rows: [{ email: 'a@b.co' }] }), + ACTOR, + ); + expect(res.status).toBe(500); + expect(res.body.error?.code).toBe('INTERNAL_ERROR'); + expect(m.createUser).not.toHaveBeenCalled(); + expect(m.update).not.toHaveBeenCalled(); + }); }); describe('runAdminImportUsers — row validation (also on dryRun)', () => { diff --git a/packages/plugins/plugin-auth/src/admin-import-users.ts b/packages/plugins/plugin-auth/src/admin-import-users.ts index 7f53836dde5..a7809b323f2 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users.ts @@ -111,7 +111,16 @@ import type { } from '@objectstack/rest'; import { prepareImportRequest, runImport } from '@objectstack/rest'; import { generatePlaceholderEmail, isPlaceholderEmail } from './placeholder-email.js'; -import { generateTemporaryPassword, normalizePhoneNumber, isLikelyEmail, type AdminActor, type EndpointResult } from './admin-user-endpoints.js'; +import { + generateTemporaryPassword, + normalizePhoneNumber, + isLikelyEmail, + mapAuthApiError, + type AdminActor, + type AuthContextLike, + type EndpointResult, +} from './admin-user-endpoints.js'; +import { temporaryPasswordLength } from './temporary-password-length.js'; import { SYS_USER_IMPORT_UPDATE_FIELDS } from './sys-user-writable-fields.js'; import { readJsonRequestBody } from './request-json-body.js'; import { @@ -168,6 +177,12 @@ export interface IdentityImportEngine { export interface IdentityImportDeps { /** better-auth server api (createUser + requestPasswordReset). */ getAuthApi(): Promise; + /** + * [#25932] better-auth's `$context`, the same reader create-user holds: its + * `password.config` is the deployment's length range, which sizes every + * generated temporary password (`temporaryPasswordLength`). + */ + getAuthContext(): Promise<{ password: Pick }>; getDataEngine(): IdentityImportEngine | undefined; /** * Optional object-declaration reader. It feeds field coercion (best-effort: @@ -610,6 +625,14 @@ export async function runAdminImportUsers( if (typeof authApi.createUser !== 'function') { return fail(501, 'NOT_IMPLEMENTED', 'The better-auth admin plugin is not enabled (auth.plugins.admin)'); } + // [#25932] The deployment's length range sizes every temporary password this + // batch mints, read before any row is written, as create-user reads it. + let temporaryLength: number; + try { + temporaryLength = temporaryPasswordLength((await deps.getAuthContext()).password.config); + } catch (error) { + return mapAuthApiError(error, 'import-users failed'); + } const protocol: ImportProtocolLike = { // [#22800] The declaration reader the parser was handed above. With it, @@ -687,7 +710,7 @@ export async function runAdminImportUsers( // credential-less accounts (better-auth: omitted password → no // credential record); the credential is minted later by the user via // set-initial-password / the reset flow, which creates it on demand. - const password = plan.kind === 'temporary' ? generateTemporaryPassword() : undefined; + const password = plan.kind === 'temporary' ? generateTemporaryPassword(temporaryLength) : undefined; const created = await authApi.createUser({ body: { diff --git a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts index b89b91847a3..2d4c87e9643 100644 --- a/packages/plugins/plugin-auth/src/admin-user-endpoints.ts +++ b/packages/plugins/plugin-auth/src/admin-user-endpoints.ts @@ -231,6 +231,7 @@ import { resolveDefaultOrgId } from './tenancy-service.js'; import type { AddMemberCapableApi } from './organization-add-member.js'; import { readJsonRequestBody } from './request-json-body.js'; import { cloudManagedRefusal } from './cloud-managed.js'; +import { temporaryPasswordLength } from './temporary-password-length.js'; import { isCloudManaged } from '@objectstack/core'; import { resolveThrownHttpError, validationFailureDetails } from '@objectstack/types'; @@ -241,7 +242,7 @@ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; // Must satisfy the ADR-0069 class-mix policy (lower + upper + digit + symbol), // so one guaranteed character per class + random fill. Never shorter than 12; // a caller holding the deployment's length range sizes it to that range (see -// `temporaryPasswordLength`). Ambiguous glyphs (O/0, l/1) are excluded — these +// `temporaryPasswordLength` in temporary-password-length.ts). Ambiguous glyphs (O/0, l/1) are excluded — these // passwords are read off a dialog and typed once. const LOWER = 'abcdefghijkmnopqrstuvwxyz'; const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; @@ -325,11 +326,6 @@ export function isLikelyEmail(value: string): boolean { */ type PasswordLengthPolicy = AuthContextLike['password']['config']; -/** A generated password's length: 16, raised to the range's minimum and capped at its maximum. */ -function temporaryPasswordLength({ minPasswordLength, maxPasswordLength }: PasswordLengthPolicy): number { - return Math.min(Math.max(16, minPasswordLength), maxPasswordLength); -} - /** * Resolve the password for a create/set request: an explicit `password` / * `newPassword`, or a generated temporary one when `generatePassword` is true. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 685bd535e77..2ed2c1bac9c 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -3766,6 +3766,7 @@ export class AuthPlugin implements Plugin { runAdminImportUsers( { getAuthApi: () => this.authManager!.getApi() as any, + getAuthContext: () => this.authManager!.getAuthContext(), getDataEngine: () => this.authManager!.getDataEngine(), ...(typeof metaReader?.getMetaItem === 'function' ? { getMetaItem: (ref: { type: string; name: string }) => metaReader.getMetaItem(ref) } diff --git a/packages/plugins/plugin-auth/src/durability-swallow-repair.test.ts b/packages/plugins/plugin-auth/src/durability-swallow-repair.test.ts index 6908d41c9e6..5f7f29d3fc8 100644 --- a/packages/plugins/plugin-auth/src/durability-swallow-repair.test.ts +++ b/packages/plugins/plugin-auth/src/durability-swallow-repair.test.ts @@ -677,6 +677,7 @@ describe('#12981 batch 6 — the plugin-auth admin-audit swallows report instead })), requestPasswordReset: vi.fn(async () => ({ status: true })), }), + getAuthContext: async () => ({ password: { config: { minPasswordLength: 8, maxPasswordLength: 128 } } }), getDataEngine: () => engine as never, phoneNumberEnabled: () => false, emailServiceAvailable: () => true, diff --git a/packages/plugins/plugin-auth/src/temporary-password-length.ts b/packages/plugins/plugin-auth/src/temporary-password-length.ts new file mode 100644 index 00000000000..8bc68fd250b --- /dev/null +++ b/packages/plugins/plugin-auth/src/temporary-password-length.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The length of a generated temporary password, for every admin door that + * mints one: create-user / set-user-password with `generatePassword` + * (#25824) and the identity import's `temporary` rows (#25932). + * + * The range is the deployment's, as better-auth resolved it + * (`$context.password.config`: `emailAndPassword.minPasswordLength` / + * `maxPasswordLength`, which ADR-0069 maps the `auth.password_min_length` / + * `password_max_length` settings onto). Each door reads it from its own + * `getAuthContext()` dep; this file holds only the sizing, so the doors + * cannot size differently. Internal: not re-exported from the package index. + */ + +/** 16, raised to the range's minimum and capped at its maximum. */ +export function temporaryPasswordLength({ + minPasswordLength, + maxPasswordLength, +}: { + minPasswordLength: number; + maxPasswordLength: number; +}): number { + return Math.min(Math.max(16, minPasswordLength), maxPasswordLength); +} From 425cc7db21b4e5ac643f0ae4bf7994e900b82c4b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:22:54 +0000 Subject: [PATCH 3/3] chore(changeset): plugin-auth import-users temporary password length Claude-Session: https://claude.ai/code/session_01A5wzhKHa98KsGQNHM9Z3Tq Co-authored-by: Claude --- ...-import-users-temporary-password-length.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/25932-import-users-temporary-password-length.md diff --git a/.changeset/25932-import-users-temporary-password-length.md b/.changeset/25932-import-users-temporary-password-length.md new file mode 100644 index 00000000000..feeaf65020b --- /dev/null +++ b/.changeset/25932-import-users-temporary-password-length.md @@ -0,0 +1,21 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth)!: admin import-users sizes a generated temporary password to the deployment's length range, as create-user does + +`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. + +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. + +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 (an admin REST door's handling of a request; nothing a metadata author writes changes) + + + +**BREAKING** for a host that calls `runAdminImportUsers` itself (the `AuthPlugin` route is already wired). + +- **Before.** `IdentityImportDeps` had no auth-context member, and every temporary password was 16 characters. +- **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. +- **Fix.** Pass `getAuthContext` in the deps, wired to the same reader create-user uses, for example `AuthManager.getAuthContext()`.