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
21 changes: 21 additions & 0 deletions .changeset/25932-import-users-temporary-password-length.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- 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. -->

**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()`.
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ async function importThroughMount(app: Hono, email: string): Promise<void> {
/** 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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> })?.destroy?.();
} catch {
/* noop */
}
}
});

async function bootEngine(): Promise<ObjectQL> {
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<AuthManager> {
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<Hono> {
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<Record<string, unknown>> {
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<Record<string, unknown>> } };
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<Response> {
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);
});
});
21 changes: 21 additions & 0 deletions packages/plugins/plugin-auth/src/admin-import-users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)', () => {
Expand Down
27 changes: 25 additions & 2 deletions packages/plugins/plugin-auth/src/admin-import-users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -168,6 +177,12 @@ export interface IdentityImportEngine {
export interface IdentityImportDeps {
/** better-auth server api (createUser + requestPasswordReset). */
getAuthApi(): Promise<any>;
/**
* [#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<AuthContextLike['password'], 'config'> }>;
getDataEngine(): IdentityImportEngine | undefined;
/**
* Optional object-declaration reader. It feeds field coercion (best-effort:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down
8 changes: 2 additions & 6 deletions packages/plugins/plugin-auth/src/admin-user-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading