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
22 changes: 22 additions & 0 deletions .changeset/import-users-none-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@objectstack/plugin-auth": minor
---

feat(auth): `passwordPolicy: 'none'` is the identity import's new default — import provisions identity, not credentials

`POST /api/v1/auth/admin/import-users` now supports (and defaults to)
`passwordPolicy: 'none'`: accounts are created without a credential record
(better-auth's optional-password create), so no password material is
generated, returned, or distributed at all. Users first sign in through a
channel — phone OTP, magic link, or a password-reset link — and the Console's
existing credential-less detection (`hasLocalPassword()` → set-initial-password)
nudges them to set a password afterwards.

The `invite` policy also no longer mints a throwaway password: it creates the
same credential-less account and sends the set-your-password invitation
(better-auth's reset flow creates the credential record on first set).
`temporary` is unchanged and remains the fallback for deployments without
email/SMS infrastructure.

Breaking-ish note: `passwordPolicy` was previously required — requests that
omitted it got a 400. They now succeed with the `none` behavior.
17 changes: 11 additions & 6 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -683,12 +683,17 @@ SSO-onboarded users who never had a local password.
generic data import (`rows[]` / `csv` / `xlsxBase64`, `dryRun`), but writes
every row through better-auth so the accounts are login-capable:

- `passwordPolicy: 'invite'` — rows need a reachable identity: a real email
(invitation goes out as a set-your-password email; requires an email
service) or, for phone-only rows, a wired SMS service (invitation SMS +
phone-OTP first sign-in).
- `passwordPolicy: 'temporary'` — per-row generated temporary passwords,
returned once in the response, with `mustChangePassword` enforced.
- `passwordPolicy: 'none'` (**default**) — identity only: accounts are created
without a credential record. Users first sign in through a channel (phone
OTP, magic link, or a password-reset link) and the Console detects the
missing password (`hasLocalPassword()`) and offers set-initial-password.
- `passwordPolicy: 'invite'` — `none` plus an invitation per created account:
rows need a reachable identity — a real email (set-your-password email;
requires an email service) or, for phone-only rows, a wired SMS service
(invitation SMS + phone-OTP first sign-in).
- `passwordPolicy: 'temporary'` — the no-infrastructure fallback (no email,
no SMS): per-row generated temporary passwords, returned once in the
response, with `mustChangePassword` enforced.
- `mode: 'insert' | 'upsert'` with `matchBy: 'email' | 'phone'`. Upsert
updates only touch profile fields (`name`, `phone_number`, `role`) — a
re-imported file can never modify an existing user's email or reset their
Expand Down
65 changes: 63 additions & 2 deletions packages/plugins/plugin-auth/src/admin-import-users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,28 @@ function expectNoPasswordLeak(m: ReturnType<typeof makeDeps>, passwords: string[
}

describe('runAdminImportUsers — request validation', () => {
it('rejects a missing/invalid passwordPolicy', async () => {
it('rejects an unknown passwordPolicy', async () => {
const m = makeDeps();
const res = await runAdminImportUsers(m.deps, makeRequest({ format: 'json', rows: [] }), ACTOR);
const res = await runAdminImportUsers(
m.deps,
makeRequest({ passwordPolicy: 'plaintext', format: 'json', rows: [] }),
ACTOR,
);
expect(res.status).toBe(400);
expect(m.createUser).not.toHaveBeenCalled();
});

it('defaults to the none policy when passwordPolicy is omitted', async () => {
const m = makeDeps();
const res = await runAdminImportUsers(
m.deps,
makeRequest({ format: 'json', rows: [{ email: 'a@b.co' }] }),
ACTOR,
);
expect(res.status).toBe(200);
expect((res.body.data as any).summary.passwordPolicy).toBe('none');
});

it('rejects matchBy phone when the phoneNumber plugin is off', async () => {
const m = makeDeps({ phoneEnabled: false });
const res = await runAdminImportUsers(
Expand Down Expand Up @@ -174,6 +189,52 @@ describe('runAdminImportUsers — row validation (also on dryRun)', () => {
});
});

describe('runAdminImportUsers — none policy (default: identity only, no credentials)', () => {
it('creates credential-less accounts: no password sent to better-auth, no stamps, no invitations', async () => {
const m = makeDeps({ phoneEnabled: true });
const res = await runAdminImportUsers(
m.deps,
makeRequest({
passwordPolicy: 'none', format: 'json',
rows: [
{ email: 'a@x.co', name: 'A' },
{ phone_number: '+8613800000001', name: 'B' },
],
}),
ACTOR,
);
expect(res.status).toBe(200);
const data = res.body.data as any;
expect(data.summary.created).toBe(2);
expect(data.summary.passwordPolicy).toBe('none');

// better-auth receives NO password key at all → credential-less account.
for (const call of m.createUser.mock.calls) {
expect('password' in call[0].body).toBe(false);
}
// Phone-only row still gets a placeholder email.
expect(m.createUser.mock.calls[1][0].body.email).toMatch(/@placeholder\.invalid$/);

// No must_change_password stamps, no temporary passwords, no invitations.
expect(m.update.mock.calls.filter((c) => c[1]?.must_change_password === true).length).toBe(0);
expect(m.noteMustChangePasswordIssued).not.toHaveBeenCalled();
expect(data.rows.every((r: any) => r.temporaryPassword === undefined)).toBe(true);
expect(m.requestPasswordReset).not.toHaveBeenCalled();
expect(m.sendInviteSms).not.toHaveBeenCalled();
});

it('does not require an email or SMS service (unlike invite)', async () => {
const m = makeDeps({ emailAvailable: false, smsInviteAvailable: false });
const res = await runAdminImportUsers(
m.deps,
makeRequest({ passwordPolicy: 'none', format: 'json', rows: [{ email: 'a@b.co' }] }),
ACTOR,
);
expect(res.status).toBe(200);
expect((res.body.data as any).summary.created).toBe(1);
});
});

describe('runAdminImportUsers — temporary policy', () => {
it('creates each row through better-auth, stamps must_change_password, returns per-row temp passwords once', async () => {
const m = makeDeps({ phoneEnabled: true });
Expand Down
53 changes: 34 additions & 19 deletions packages/plugins/plugin-auth/src/admin-import-users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,25 @@
* `ImportProtocolLike` whose `createData` drives `auth.api.createUser`.
*
* Password policies:
* - `invite` — every row needs a reachable identity. Accounts are created
* with a random throwaway password the user never sees, then
* a "set your password" invitation goes out per created
* account: a reset-password email for rows with a REAL
* email (requires a wired EmailService), or — #2780 — an
* invitation SMS for phone-only rows (requires a wired,
* - `none` — THE DEFAULT. No password is set at all: better-auth
* creates the account without a credential record, and the
* user's first sign-in is channel-based (phone OTP, magic
* link, or an email reset link). The Console already
* detects credential-less accounts (`hasLocalPassword()`)
* and offers `set-initial-password`, so users are nudged to
* set a password after their first OTP sign-in. Import's
* job is identity — credentials are the auth domain's.
* - `invite` — like `none` (credential-less creation; better-auth's
* reset-password creates the credential account on first
* set), plus a "set your password" invitation goes out per
* created account: a reset-password email for rows with a
* REAL email (requires a wired EmailService), or — #2780 —
* an invitation SMS for phone-only rows (requires a wired,
* deliverable SmsService + the phoneNumber plugin; the user
* first signs in via phone OTP and then sets a password).
* - `temporary` — each created account gets a generated temporary password,
* Rows are validated per-channel reachability.
* - `temporary` — the no-infrastructure fallback (no email, no SMS): each
* created account gets a generated temporary password,
* `must_change_password` is stamped (403 PASSWORD_EXPIRED
* until changed), and the passwords are returned ONCE in the
* HTTP response, one per row. Never persisted, never logged.
Expand Down Expand Up @@ -95,7 +105,7 @@ interface RowIdentity {
function resolveRowIdentity(
row: Record<string, any>,
opts: {
policy: 'invite' | 'temporary';
policy: 'none' | 'invite' | 'temporary';
phoneEnabled: boolean;
emailInviteOk: boolean;
smsInviteOk: boolean;
Expand Down Expand Up @@ -162,9 +172,12 @@ export async function runAdminImportUsers(
});

// ── Request-level validation ─────────────────────────────────────────
const policy = body?.passwordPolicy;
if (policy !== 'invite' && policy !== 'temporary') {
return fail(400, 'invalid_request', 'passwordPolicy must be "invite" or "temporary"');
// Default policy: `none` — import provisions identity, not credentials.
// Users first sign in via a channel (phone OTP / magic link / reset link)
// and the Console's hasLocalPassword() flow nudges them to set a password.
const policy = body?.passwordPolicy === undefined ? 'none' : body.passwordPolicy;
if (policy !== 'none' && policy !== 'invite' && policy !== 'temporary') {
return fail(400, 'invalid_request', 'passwordPolicy must be "none" (default), "invite", or "temporary"');
}
const mode = body?.mode === 'upsert' ? 'upsert' : body?.mode === 'insert' || body?.mode === undefined ? 'insert' : undefined;
if (!mode) return fail(400, 'invalid_request', 'mode must be "insert" or "upsert"');
Expand Down Expand Up @@ -254,24 +267,25 @@ export async function runAdminImportUsers(
const data: Record<string, any> = args?.data ?? {};
const email: string = typeof data.email === 'string' && data.email.length > 0
? data.email
: generatePlaceholderEmail(); // temporary policy only — invite rows were validated to carry one
: generatePlaceholderEmail(); // phone-only rows (none/temporary, or invite via SMS)
const placeholder = !(typeof data.email === 'string' && data.email.length > 0);
const phone: string | undefined = typeof data.phone_number === 'string' && data.phone_number.length > 0 ? data.phone_number : undefined;
const name: string = typeof data.name === 'string' && data.name.trim().length > 0
? data.name.trim()
: placeholder ? (phone as string) : email.split('@')[0];
const role: string | undefined = typeof data.role === 'string' && data.role.length > 0 ? data.role : undefined;

// Both policies create with a generated password: `temporary` hands it
// to the caller; `invite` throws it away (the user sets their own via
// the reset link) — the account is never password-less.
const password = generateTemporaryPassword();
// Only the `temporary` policy sets a password. `none`/`invite` create
// 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 = policy === 'temporary' ? generateTemporaryPassword() : undefined;

const created = await authApi.createUser({
body: {
email,
name,
password,
...(password ? { password } : {}),
...(role ? { role } : {}),
...(phone ? { data: { phoneNumber: phone } } : {}),
},
Expand All @@ -280,16 +294,17 @@ export async function runAdminImportUsers(
if (!id) throw Object.assign(new Error('better-auth returned no user id'), { code: 'CREATE_FAILED' });

if (policy === 'temporary') {
temporaryPasswords.set(id, password);
temporaryPasswords.set(id, password as string);
try {
await engine.update('sys_user', { id, must_change_password: true }, { context: SYSTEM_CTX } as any);
deps.noteMustChangePasswordIssued();
} catch (e) {
deps.logger?.warn(`[AuthPlugin] import-users: failed to stamp must_change_password for ${id}: ${(e as Error)?.message ?? e}`);
}
} else {
} else if (policy === 'invite') {
createdEmails.set(id, { email, placeholder, ...(phone ? { phone } : {}) });
}
// `none`: nothing else to do — identity only.
return { id };
},

Expand Down