Skip to content

Commit a94b1fd

Browse files
committed
feat(auth): identity import defaults to passwordPolicy 'none' — no credentials at import time
Import provisions identity, not credentials. The new default 'none' policy creates credential-less accounts (better-auth optional-password create): no password material is generated, returned, or distributed. Users first sign in through a channel (phone OTP / magic link / reset link) and the Console's existing hasLocalPassword() → set-initial-password flow nudges them to set one afterwards. 'invite' also stops minting a throwaway password — better-auth's reset-password creates the credential record on first set (verified in 1.6.23 dist), so it is now 'none' + the invitation send. 'temporary' is unchanged as the no-infrastructure fallback. passwordPolicy was previously required (400 when omitted); omitted now means 'none'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o
1 parent d9566cc commit a94b1fd

4 files changed

Lines changed: 130 additions & 27 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
feat(auth): `passwordPolicy: 'none'` is the identity import's new default — import provisions identity, not credentials
6+
7+
`POST /api/v1/auth/admin/import-users` now supports (and defaults to)
8+
`passwordPolicy: 'none'`: accounts are created without a credential record
9+
(better-auth's optional-password create), so no password material is
10+
generated, returned, or distributed at all. Users first sign in through a
11+
channel — phone OTP, magic link, or a password-reset link — and the Console's
12+
existing credential-less detection (`hasLocalPassword()` → set-initial-password)
13+
nudges them to set a password afterwards.
14+
15+
The `invite` policy also no longer mints a throwaway password: it creates the
16+
same credential-less account and sends the set-your-password invitation
17+
(better-auth's reset flow creates the credential record on first set).
18+
`temporary` is unchanged and remains the fallback for deployments without
19+
email/SMS infrastructure.
20+
21+
Breaking-ish note: `passwordPolicy` was previously required — requests that
22+
omitted it got a 400. They now succeed with the `none` behavior.

content/docs/permissions/authentication.mdx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -683,12 +683,17 @@ SSO-onboarded users who never had a local password.
683683
generic data import (`rows[]` / `csv` / `xlsxBase64`, `dryRun`), but writes
684684
every row through better-auth so the accounts are login-capable:
685685

686-
- `passwordPolicy: 'invite'` — rows need a reachable identity: a real email
687-
(invitation goes out as a set-your-password email; requires an email
688-
service) or, for phone-only rows, a wired SMS service (invitation SMS +
689-
phone-OTP first sign-in).
690-
- `passwordPolicy: 'temporary'` — per-row generated temporary passwords,
691-
returned once in the response, with `mustChangePassword` enforced.
686+
- `passwordPolicy: 'none'` (**default**) — identity only: accounts are created
687+
without a credential record. Users first sign in through a channel (phone
688+
OTP, magic link, or a password-reset link) and the Console detects the
689+
missing password (`hasLocalPassword()`) and offers set-initial-password.
690+
- `passwordPolicy: 'invite'``none` plus an invitation per created account:
691+
rows need a reachable identity — a real email (set-your-password email;
692+
requires an email service) or, for phone-only rows, a wired SMS service
693+
(invitation SMS + phone-OTP first sign-in).
694+
- `passwordPolicy: 'temporary'` — the no-infrastructure fallback (no email,
695+
no SMS): per-row generated temporary passwords, returned once in the
696+
response, with `mustChangePassword` enforced.
692697
- `mode: 'insert' | 'upsert'` with `matchBy: 'email' | 'phone'`. Upsert
693698
updates only touch profile fields (`name`, `phone_number`, `role`) — a
694699
re-imported file can never modify an existing user's email or reset their

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

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,28 @@ function expectNoPasswordLeak(m: ReturnType<typeof makeDeps>, passwords: string[
6464
}
6565

6666
describe('runAdminImportUsers — request validation', () => {
67-
it('rejects a missing/invalid passwordPolicy', async () => {
67+
it('rejects an unknown passwordPolicy', async () => {
6868
const m = makeDeps();
69-
const res = await runAdminImportUsers(m.deps, makeRequest({ format: 'json', rows: [] }), ACTOR);
69+
const res = await runAdminImportUsers(
70+
m.deps,
71+
makeRequest({ passwordPolicy: 'plaintext', format: 'json', rows: [] }),
72+
ACTOR,
73+
);
7074
expect(res.status).toBe(400);
7175
expect(m.createUser).not.toHaveBeenCalled();
7276
});
7377

78+
it('defaults to the none policy when passwordPolicy is omitted', async () => {
79+
const m = makeDeps();
80+
const res = await runAdminImportUsers(
81+
m.deps,
82+
makeRequest({ format: 'json', rows: [{ email: 'a@b.co' }] }),
83+
ACTOR,
84+
);
85+
expect(res.status).toBe(200);
86+
expect((res.body.data as any).summary.passwordPolicy).toBe('none');
87+
});
88+
7489
it('rejects matchBy phone when the phoneNumber plugin is off', async () => {
7590
const m = makeDeps({ phoneEnabled: false });
7691
const res = await runAdminImportUsers(
@@ -174,6 +189,52 @@ describe('runAdminImportUsers — row validation (also on dryRun)', () => {
174189
});
175190
});
176191

192+
describe('runAdminImportUsers — none policy (default: identity only, no credentials)', () => {
193+
it('creates credential-less accounts: no password sent to better-auth, no stamps, no invitations', async () => {
194+
const m = makeDeps({ phoneEnabled: true });
195+
const res = await runAdminImportUsers(
196+
m.deps,
197+
makeRequest({
198+
passwordPolicy: 'none', format: 'json',
199+
rows: [
200+
{ email: 'a@x.co', name: 'A' },
201+
{ phone_number: '+8613800000001', name: 'B' },
202+
],
203+
}),
204+
ACTOR,
205+
);
206+
expect(res.status).toBe(200);
207+
const data = res.body.data as any;
208+
expect(data.summary.created).toBe(2);
209+
expect(data.summary.passwordPolicy).toBe('none');
210+
211+
// better-auth receives NO password key at all → credential-less account.
212+
for (const call of m.createUser.mock.calls) {
213+
expect('password' in call[0].body).toBe(false);
214+
}
215+
// Phone-only row still gets a placeholder email.
216+
expect(m.createUser.mock.calls[1][0].body.email).toMatch(/@placeholder\.invalid$/);
217+
218+
// No must_change_password stamps, no temporary passwords, no invitations.
219+
expect(m.update.mock.calls.filter((c) => c[1]?.must_change_password === true).length).toBe(0);
220+
expect(m.noteMustChangePasswordIssued).not.toHaveBeenCalled();
221+
expect(data.rows.every((r: any) => r.temporaryPassword === undefined)).toBe(true);
222+
expect(m.requestPasswordReset).not.toHaveBeenCalled();
223+
expect(m.sendInviteSms).not.toHaveBeenCalled();
224+
});
225+
226+
it('does not require an email or SMS service (unlike invite)', async () => {
227+
const m = makeDeps({ emailAvailable: false, smsInviteAvailable: false });
228+
const res = await runAdminImportUsers(
229+
m.deps,
230+
makeRequest({ passwordPolicy: 'none', format: 'json', rows: [{ email: 'a@b.co' }] }),
231+
ACTOR,
232+
);
233+
expect(res.status).toBe(200);
234+
expect((res.body.data as any).summary.created).toBe(1);
235+
});
236+
});
237+
177238
describe('runAdminImportUsers — temporary policy', () => {
178239
it('creates each row through better-auth, stamps must_change_password, returns per-row temp passwords once', async () => {
179240
const m = makeDeps({ phoneEnabled: true });

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

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,25 @@
1414
* `ImportProtocolLike` whose `createData` drives `auth.api.createUser`.
1515
*
1616
* Password policies:
17-
* - `invite` — every row needs a reachable identity. Accounts are created
18-
* with a random throwaway password the user never sees, then
19-
* a "set your password" invitation goes out per created
20-
* account: a reset-password email for rows with a REAL
21-
* email (requires a wired EmailService), or — #2780 — an
22-
* invitation SMS for phone-only rows (requires a wired,
17+
* - `none` — THE DEFAULT. No password is set at all: better-auth
18+
* creates the account without a credential record, and the
19+
* user's first sign-in is channel-based (phone OTP, magic
20+
* link, or an email reset link). The Console already
21+
* detects credential-less accounts (`hasLocalPassword()`)
22+
* and offers `set-initial-password`, so users are nudged to
23+
* set a password after their first OTP sign-in. Import's
24+
* job is identity — credentials are the auth domain's.
25+
* - `invite` — like `none` (credential-less creation; better-auth's
26+
* reset-password creates the credential account on first
27+
* set), plus a "set your password" invitation goes out per
28+
* created account: a reset-password email for rows with a
29+
* REAL email (requires a wired EmailService), or — #2780 —
30+
* an invitation SMS for phone-only rows (requires a wired,
2331
* deliverable SmsService + the phoneNumber plugin; the user
2432
* first signs in via phone OTP and then sets a password).
25-
* - `temporary` — each created account gets a generated temporary password,
33+
* Rows are validated per-channel reachability.
34+
* - `temporary` — the no-infrastructure fallback (no email, no SMS): each
35+
* created account gets a generated temporary password,
2636
* `must_change_password` is stamped (403 PASSWORD_EXPIRED
2737
* until changed), and the passwords are returned ONCE in the
2838
* HTTP response, one per row. Never persisted, never logged.
@@ -95,7 +105,7 @@ interface RowIdentity {
95105
function resolveRowIdentity(
96106
row: Record<string, any>,
97107
opts: {
98-
policy: 'invite' | 'temporary';
108+
policy: 'none' | 'invite' | 'temporary';
99109
phoneEnabled: boolean;
100110
emailInviteOk: boolean;
101111
smsInviteOk: boolean;
@@ -162,9 +172,12 @@ export async function runAdminImportUsers(
162172
});
163173

164174
// ── Request-level validation ─────────────────────────────────────────
165-
const policy = body?.passwordPolicy;
166-
if (policy !== 'invite' && policy !== 'temporary') {
167-
return fail(400, 'invalid_request', 'passwordPolicy must be "invite" or "temporary"');
175+
// Default policy: `none` — import provisions identity, not credentials.
176+
// Users first sign in via a channel (phone OTP / magic link / reset link)
177+
// and the Console's hasLocalPassword() flow nudges them to set a password.
178+
const policy = body?.passwordPolicy === undefined ? 'none' : body.passwordPolicy;
179+
if (policy !== 'none' && policy !== 'invite' && policy !== 'temporary') {
180+
return fail(400, 'invalid_request', 'passwordPolicy must be "none" (default), "invite", or "temporary"');
168181
}
169182
const mode = body?.mode === 'upsert' ? 'upsert' : body?.mode === 'insert' || body?.mode === undefined ? 'insert' : undefined;
170183
if (!mode) return fail(400, 'invalid_request', 'mode must be "insert" or "upsert"');
@@ -254,24 +267,25 @@ export async function runAdminImportUsers(
254267
const data: Record<string, any> = args?.data ?? {};
255268
const email: string = typeof data.email === 'string' && data.email.length > 0
256269
? data.email
257-
: generatePlaceholderEmail(); // temporary policy only — invite rows were validated to carry one
270+
: generatePlaceholderEmail(); // phone-only rows (none/temporary, or invite via SMS)
258271
const placeholder = !(typeof data.email === 'string' && data.email.length > 0);
259272
const phone: string | undefined = typeof data.phone_number === 'string' && data.phone_number.length > 0 ? data.phone_number : undefined;
260273
const name: string = typeof data.name === 'string' && data.name.trim().length > 0
261274
? data.name.trim()
262275
: placeholder ? (phone as string) : email.split('@')[0];
263276
const role: string | undefined = typeof data.role === 'string' && data.role.length > 0 ? data.role : undefined;
264277

265-
// Both policies create with a generated password: `temporary` hands it
266-
// to the caller; `invite` throws it away (the user sets their own via
267-
// the reset link) — the account is never password-less.
268-
const password = generateTemporaryPassword();
278+
// Only the `temporary` policy sets a password. `none`/`invite` create
279+
// credential-less accounts (better-auth: omitted password → no
280+
// credential record); the credential is minted later by the user via
281+
// set-initial-password / the reset flow, which creates it on demand.
282+
const password = policy === 'temporary' ? generateTemporaryPassword() : undefined;
269283

270284
const created = await authApi.createUser({
271285
body: {
272286
email,
273287
name,
274-
password,
288+
...(password ? { password } : {}),
275289
...(role ? { role } : {}),
276290
...(phone ? { data: { phoneNumber: phone } } : {}),
277291
},
@@ -280,16 +294,17 @@ export async function runAdminImportUsers(
280294
if (!id) throw Object.assign(new Error('better-auth returned no user id'), { code: 'CREATE_FAILED' });
281295

282296
if (policy === 'temporary') {
283-
temporaryPasswords.set(id, password);
297+
temporaryPasswords.set(id, password as string);
284298
try {
285299
await engine.update('sys_user', { id, must_change_password: true }, { context: SYSTEM_CTX } as any);
286300
deps.noteMustChangePasswordIssued();
287301
} catch (e) {
288302
deps.logger?.warn(`[AuthPlugin] import-users: failed to stamp must_change_password for ${id}: ${(e as Error)?.message ?? e}`);
289303
}
290-
} else {
304+
} else if (policy === 'invite') {
291305
createdEmails.set(id, { email, placeholder, ...(phone ? { phone } : {}) });
292306
}
307+
// `none`: nothing else to do — identity only.
293308
return { id };
294309
},
295310

0 commit comments

Comments
 (0)