Skip to content

Commit 3490bf5

Browse files
fix(plugin-auth): the identity import's invitation link lands on the Console set-password page (#23152) (#23342)
Fixes #23152 Clause-②: yes (widening) — adds the public `AuthManager.getConsoleUrl(path)` and the optional `IdentityImportDeps.consoleResetPasswordUrl()` Changeset level: `@objectstack/plugin-auth` **minor**, the precedent of #22463 (`e148ca984`), which declared an optional addition to an exported plugin-auth API `Clause-②: yes (widening)` at `minor`. Both new members are re-exported through `packages/plugins/plugin-auth/src/index.ts` (`export * from './auth-manager.js'` and `export * from './admin-import-users.js'`), and both reach the built `dist/index.d.ts` (one hit each, with `sendPhoneInviteSms(phone: string)` as the positive control). This follows triage's direction `6140964843`: the import's reset request carries a `redirectTo` to the Console's set-password page, built from the same source the Console's own links use. The deps member is optional, and the new `AuthManager` member is a public console-page URL reader. ## What changed - `packages/plugins/plugin-auth/src/auth-manager.ts`: a new public `getConsoleUrl(path)`. Its body is one line, `return this.getConsolePageUrl(path);`. The private composer that builds every Console link this class sends (`origin` + `uiBasePath` + `path`) is unchanged, and no second URL builder exists. - `packages/plugins/plugin-auth/src/admin-import-users.ts`: - `IdentityImportDeps` gains the optional `consoleResetPasswordUrl?(): string`. - When it is present, the email invitation requests `{ email, redirectTo: deps.consoleResetPasswordUrl() }`. - When it is absent, the request is `{ email }`, byte for byte as today. The import already uses this shape for its other optional members (`getMetaItem`, `getSchema`). - A throw from the member lands in the existing per-row `catch`, as `INVITE_EMAIL_FAILED`. - `packages/plugins/plugin-auth/src/auth-plugin.ts`: one wiring line in the import mount's deps object, beside its SMS twin `sendInviteSms`. It reads `consoleResetPasswordUrl: () => this.authManager!.getConsoleUrl('/reset-password')`. - The page is the one the Console serves. At the objectui pin `1fb1ced36243` (`.objectui-sha`, unchanged), `apps/console/src/App.tsx:208` routes `/reset-password` under the SPA basename to `ResetPasswordPage`, which reads `?token=`. The Console's own forgot-password flow passes `BASENAME/reset-password` as its `redirectTo` (`packages/auth/src/createAuthClient.ts`). ## Measured first (dispatch Zone 2) 1. **The defect still reproduces on `main`** (`cc14657ed`). `admin-import-users.ts:704` called `requestPasswordReset({ body: { email } })` with no `redirectTo`. better-auth 1.7.3 then builds `…/reset-password/TOKEN?callbackURL=` (`dist/api/routes/password.mjs:80-81`), and its GET handler redirects to the error page on `!callbackURL` (`:124`). I measured it end to end through the real mount (leg A below): with the three source files restored to `cc14657ed`, the emailed link's GET answered `Received: "https://acme.example.com/api/v1/auth/error?error=INVALID_TOKEN"`. I wrote no separate probe. The ablated tree was the probe, and it was restored to `HEAD` (blob hashes equal, `git diff HEAD` empty). 2. **The SMS twin is the house shape.** It holds. `sendInviteSms: (phone) => this.authManager!.sendPhoneInviteSms(phone)` sits in the same deps object, and `sendPhoneInviteSms` composes its link through `getConsolePageUrl('/login')`. The optional member plus the reader needed exactly one wiring line. 3. **The origin check.** It holds, with the check ON. Under a test runner better-auth defaults `skipOriginCheck` to `isTest()` (`dist/context/create-context.mjs:211`). The pin turns it off on the resolved context, which the handler copies per request. With the check on, the redirect to `https://acme.example.com/_console/reset-password?token=…` is accepted, because `baseURL`'s origin is trusted, and a foreign `callbackURL` on the same token is refused with `403`. Leg E shows that without the flip the foreign one is redirected (`302`). 4. **Exports and level.** It holds, as above. #22800's changeset (`f9ebb7dd3`) relied on `IdentityImportDeps` keeping its shape, and this PR changes that shape additively. ## Pins `packages/plugins/plugin-auth/src/admin-import-users-invite-link.test.ts` runs a real ObjectQL over driver-sql (better-sqlite3 in memory), this plugin's manifest objects, a real `AuthManager` (admin plugin on, `baseUrl` `https://acme.example.com`) with a capturing email service, and better-auth's origin check on. - **①** It drives the plugin's REAL `POST /api/v1/auth/admin/import-users` mount, through `registerAuthRoutes` on Hono, so the wiring line is under test. That matters because the member is optional, so a missing line still compiles. The emailed `auth.password_reset` link's GET answers `302` to `https://acme.example.com/_console/reset-password?token=TOKEN`. A control with a foreign `callbackURL` is refused with `403` and no `location`. `POST /reset-password` with that token answers `200`. Only the platform-admin gate's session read is a stand-in, the `principal-less-producers-system-context.test.ts` shape. Every other manager call reaches the real manager through a `Proxy`. - **②** It calls `runAdminImportUsers` with deps that carry no `consoleResetPasswordUrl`. The link stays today's link, `…/reset-password/TOKEN?callbackURL=`. - `admin-import-users.test.ts` gains two unit cases that pin the request body strictly in both directions (`toStrictEqual` on `mock.lastCall`). With the member the body is `{ email, redirectTo }`. Without it the body is `{ email }`, with no `redirectTo` key at all. ## Reverse verification (one-shot; no permanent test files) Each leg was mutated on disk with its marker counts read back, run, and restored to `HEAD`, proven by blob hash equal to `HEAD` and an empty `git diff HEAD`. Legs B to E used `scripts/ablation-replace.mjs` and leg A used a trap-armed script. Every subject here resolves from `src/` through relative imports, so no leg needed a rebuild. The test runs were `vitest run --reporter=verbose` on the two files, under the verify lock. | leg | mutation | expected | observed | |---|---|---|---| | A | all three source files at `cc14657ed` (today's `main`) | ① red, ② green | ① red, `Received: "https://acme.example.com/api/v1/auth/error?error=INVALID_TOKEN"`; ② green | | B | the `auth-plugin.ts` wiring line deleted | only ① red | `Tests 1 failed / 39 passed (40)`: ① red with the same `INVALID_TOKEN` landing | | C | the import sends `{ email }` even when the member is present | ① red and the "names the page" unit case red | `2 failed / 38 passed (40)`, exactly those two | | D | the absent branch sends a `redirectTo` anyway | ② red and the "exactly { email }" unit case red | `2 failed / 38 passed (40)`, exactly those two; ② `Received: …?callbackURL=https%3A%2F%2Facme.example.com%2F_console%2Freset-password` | | E | the pin's `skipOriginCheck = false` deleted | ① red at the foreign-origin control | ① red, `expected 302 to be 403` | ## Tests Every reading below was taken at the final commit `825365af7` (`git rev-parse --short HEAD`), each under `scripts/pm/os-verify-lock.sh`, on a shared box. - **Builds.** - `pnpm --filter '@objectstack/plugin-auth^...' build` (dependency closure, 29 projects): `VERDICT command-exit 0`. - Then `pnpm --filter @objectstack/plugin-auth run build`: `VERDICT command-exit 0`, with `check-dts-emitted: @objectstack/plugin-auth - 2/2 declared declaration file(s) present`. - **Typecheck.** `pnpm --filter @objectstack/plugin-auth run typecheck` (the `src` program, the examples program, then `check:test-typecheck`): `VERDICT command-exit 0`. - The ratchet reads `check:test-typecheck: OK … 10 file(s) / 94 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json`, which is the ledger unchanged. - The new test file is in that program: `tsc -p tsconfig.test.json --listFiles` lists it (1 hit), among 139 test files. - **Tests.** `pnpm --filter @objectstack/plugin-auth run test`: `Test Files 139 passed (139)` and `Tests 2798 passed | 10 skipped (2808)`. Before this PR the package had 138 test files. - **Gates.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, with no paths, derived 68 commands. All 68 were run, each exit code recorded before any pipe. - Reconciled with `--ran`: `✓ dispatch-gates --ran: 68 derived famil(ies) accounted for — 67 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. The 67 that ran exited 0. - **NOT MEASURED: `pnpm check:dual-build-cjs-loads`.** Reason: `PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/`. It needs the whole tree built, and 39 packages outside this diff's closure had no `dist/` in this worktree. CI builds the tree. - **Lint (a measured narrowing; the whole-tree `pnpm lint` is CI's run).** - `eslint --no-inline-config --format json` on the 5 changed TypeScript files: `files 5 errors 0 warnings 0`. - The population was read from eslint's own API: `isPathIgnored` is false for all 5. - Invariance: no file's computed config enables type-aware parsing (`parserOptions.project` / `projectService` are absent), so this diff cannot move the verdict of any file it does not touch. - **Reverse verification.** See the table above. Legs A to E ran at the same `HEAD`, each restored and proven. ## Acceptance notes - **Two notes for reviewers.** - The changeset names `runAdminImportUsers` hosts that build their own deps. Such a host keeps today's dead link until it wires `consoleResetPasswordUrl`. This is the cost of the optional member that triage ruled. In this repo the only builder of these deps is `auth-plugin.ts`. - The doc page `content/docs/permissions/authentication.mdx` (Bulk Import Users) already says "a real email gets a set-your-password email". This PR makes that sentence true and does not edit the page, which is outside the claimed file surface. - **The invitation copy, out of scope here.** The invitation still renders the password-reset template (`auth.password_reset`), whose copy says "If you didn't request this, ignore this email" to a user who requested nothing. The copy lives at `packages/plugins/plugin-email/src/templates/auth-templates.ts:78` and `:87`, and pin ① finds the invitation by that template key. The card names this as its secondary note, outside triage's direction. A distinct template key is new surface, so it is reported to the seat as a finding and not changed here. - **`origin/main` moved during this run.** It went to `ca0bdbcca`, with two commits in `spec`, `service-automation` and `metadata-protocol`, none in `plugin-auth` or its tests. I did not merge. CI runs on the merge ref. --- _Generated by [Claude Code](https://claude.ai/code/session_01BRQ9Mp1yH7R1az6MaUM18H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 688a39d commit 3490bf5

6 files changed

Lines changed: 298 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-auth': minor
3+
---
4+
5+
The identity import's automatic invitation email links to the Console's set-password page, no longer to an `INVALID_TOKEN` error
6+
7+
Clause-②: yes (widening)
8+
9+
- **Before.** For every row an admin imported under the Automatic (or Invite) password policy with email delivery, `POST /api/v1/auth/admin/import-users` requested the password reset with no `redirectTo`. better-auth then emailed a link ending in an empty `callbackURL=`, and its `GET /reset-password/:token` refused that link with a redirect to `?error=INVALID_TOKEN`, which the Console shows on its sign-in page. The token in the link was still valid, but the invited user had no page to use it on.
10+
- **Now.** The reset request carries `redirectTo` set to the Console's set-password page (`${origin}${uiBasePath}/reset-password`, by default `/_console/reset-password`), the page the Console's own forgot-password flow lands on. The emailed link opens `/_console/reset-password?token=…`, and better-auth's origin check accepts it because it is the deployment's own origin.
11+
- **The two new members.** Both are additions; nothing existing changes shape.
12+
- `AuthManager.getConsoleUrl(path)` is a public reader of the absolute URL of a Console page. It returns what the class already composes for every email and SMS link it sends.
13+
- `IdentityImportDeps.consoleResetPasswordUrl?()` is optional. `AuthPlugin` wires it to `getConsoleUrl('/reset-password')`. A host that calls `runAdminImportUsers` itself should pass it to get a working invitation link. A caller that omits it sends the request exactly as before (`{ email }`), so the link keeps the empty `callbackURL`.
14+
- **Unchanged.** The SMS invitation, the temporary-password policy, and the email copy (the invitation still uses the password-reset template).
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23152] The identity import's automatic invitation email carries a link
5+
* that lands on the Console's set-password page.
6+
*
7+
* The import requests a password reset for every email-invited row. better-auth
8+
* writes the request's `redirectTo` into the emailed link as `callbackURL`, and
9+
* its `GET /reset-password/:token` refuses an EMPTY one: it redirects to
10+
* `?error=INVALID_TOKEN` although the token itself still redeems. So the link
11+
* works only when the request names where it lands.
12+
*
13+
* Real `ObjectQL` over `@objectstack/driver-sql` + better-sqlite3, the objects
14+
* from this plugin's own manifest, and a real `AuthManager` (admin plugin on,
15+
* an `https` origin that is not a localhost one). better-auth's origin check is
16+
* switched ON here: it defaults to off under a test runner
17+
* (`skipOriginCheck: isTest()` in better-auth's context), and with it off the
18+
* landing would never be judged against the trusted origins a deployment has.
19+
*
20+
* Pinned:
21+
* ① Through the plugin's REAL `POST /admin/import-users` mount, so the one
22+
* wiring line in `auth-plugin.ts` is under test (the deps member is
23+
* optional, so a missing wiring line compiles): the emailed link's GET lands
24+
* on `/_console/reset-password?token=…`, and that token sets the password.
25+
* A control GET naming a foreign `callbackURL` is refused, which proves the
26+
* origin check judged the landing.
27+
* ② `runAdminImportUsers` handed deps WITHOUT `consoleResetPasswordUrl` keeps
28+
* today's link, an empty `callbackURL`.
29+
*
30+
* Only the platform-admin gate's session read is a stand-in (the
31+
* `principal-less-producers-system-context.test.ts` shape); the gate has its
32+
* own suites. Every other manager call reaches the real `AuthManager`.
33+
*/
34+
35+
import { describe, it, expect, afterEach, vi } from 'vitest';
36+
import { Hono } from 'hono';
37+
import { ObjectQL } from '@objectstack/objectql';
38+
import { SqlDriver } from '@objectstack/driver-sql';
39+
import type { PluginContext } from '@objectstack/core';
40+
import { AuthManager } from './auth-manager.js';
41+
import { AuthPlugin } from './auth-plugin.js';
42+
import { authIdentityObjects } from './manifest.js';
43+
import { runAdminImportUsers, type IdentityImportDeps } from './admin-import-users.js';
44+
45+
const ORIGIN = 'https://acme.example.com';
46+
const BASE = '/api/v1/auth';
47+
const SECRET = 'test-secret-at-least-32-chars-long-23152';
48+
const SET_PASSWORD_PAGE = `${ORIGIN}/_console/reset-password`;
49+
const ADMIN = { id: 'usr_platform_admin_23152', email: 'admin@acme.example.com' };
50+
51+
type SentMail = { template: string; to: { address: string }; data: Record<string, unknown> };
52+
53+
const engines: ObjectQL[] = [];
54+
afterEach(async () => {
55+
vi.restoreAllMocks();
56+
while (engines.length) {
57+
try {
58+
await (engines.pop() as unknown as { destroy?(): Promise<void> })?.destroy?.();
59+
} catch {
60+
/* noop */
61+
}
62+
}
63+
});
64+
65+
async function bootEngine(): Promise<ObjectQL> {
66+
const engine = new ObjectQL();
67+
engines.push(engine);
68+
engine.registerDriver(
69+
new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }),
70+
true,
71+
);
72+
await engine.init();
73+
for (const object of authIdentityObjects) engine.registry.registerObject(object as never, '@objectstack/plugin-auth');
74+
await engine.syncSchemas();
75+
return engine;
76+
}
77+
78+
/** A real manager with a capturing email service and the origin check ON. */
79+
async function bootManager(): Promise<{ manager: AuthManager; sent: SentMail[] }> {
80+
const engine = await bootEngine();
81+
const manager = new AuthManager({
82+
secret: SECRET,
83+
baseUrl: ORIGIN,
84+
dataEngine: engine as never,
85+
plugins: { admin: true },
86+
} as never);
87+
const sent: SentMail[] = [];
88+
manager.setEmailService({
89+
sendTemplate: async (input: SentMail) => {
90+
sent.push(input);
91+
return { status: 'sent' };
92+
},
93+
} as never);
94+
const authContext = (await manager.getAuthContext()) as { skipOriginCheck: unknown };
95+
authContext.skipOriginCheck = false;
96+
return { manager, sent };
97+
}
98+
99+
const mockCtx = (): PluginContext =>
100+
({
101+
registerService: vi.fn(),
102+
getService: vi.fn((name: string) => (name === 'manifest' ? { register: vi.fn() } : undefined)),
103+
getServices: vi.fn(() => new Map()),
104+
hook: vi.fn(),
105+
trigger: vi.fn(),
106+
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
107+
getKernel: vi.fn(),
108+
}) as unknown as PluginContext;
109+
110+
/**
111+
* The plugin's REAL route registration on a real Hono app. Its manager is the
112+
* real one, with one seam replaced: `getApi()` answers better-auth's real api
113+
* whose `getSession` reports a platform admin, which is what the gate judges.
114+
*/
115+
async function mountImport(manager: AuthManager): Promise<Hono> {
116+
const app = new Hono();
117+
const ctx = mockCtx();
118+
const plugin = new AuthPlugin({ secret: SECRET, plugins: { oidcProvider: false } });
119+
await plugin.init(ctx);
120+
const realApi = await manager.getApi();
121+
const gated = new Proxy(manager, {
122+
get(target, key) {
123+
if (key === 'getApi') {
124+
return async () => ({ ...realApi, getSession: async () => ({ user: { ...ADMIN, isPlatformAdmin: true } }) });
125+
}
126+
const value = Reflect.get(target, key, target);
127+
return typeof value === 'function' ? value.bind(target) : value;
128+
},
129+
});
130+
(plugin as unknown as { authManager: unknown }).authManager = gated;
131+
(plugin as unknown as {
132+
registerAuthRoutes(server: unknown, ctx: PluginContext): void;
133+
}).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx);
134+
return app;
135+
}
136+
137+
const importBody = (email: string) =>
138+
JSON.stringify({ format: 'json', mode: 'insert', passwordPolicy: 'auto', rows: [{ email, name: 'Invitee' }] });
139+
140+
/** The invitation email better-auth sent to `address`, once its background send settles. */
141+
async function invitationTo(sent: SentMail[], address: string): Promise<SentMail> {
142+
await vi.waitFor(() =>
143+
expect(sent.some((m) => m.template === 'auth.password_reset' && m.to.address === address)).toBe(true),
144+
);
145+
return sent.find((m) => m.template === 'auth.password_reset' && m.to.address === address)!;
146+
}
147+
148+
/** GET a link the way a browser follows it, with no redirect followed. */
149+
const follow = (manager: AuthManager, link: string) => manager.handleRequest(new Request(link));
150+
151+
describe('[#23152] the identity import invitation link', () => {
152+
it('① through the mounted import, lands on the Console set-password page with the origin check on', async () => {
153+
vi.spyOn(console, 'warn').mockImplementation(() => {});
154+
const { manager, sent } = await bootManager();
155+
const app = await mountImport(manager);
156+
const email = 'invitee-mounted@acme.example.com';
157+
158+
const res = await app.request(`${ORIGIN}${BASE}/admin/import-users`, {
159+
method: 'POST',
160+
headers: { 'content-type': 'application/json', origin: ORIGIN },
161+
body: importBody(email),
162+
});
163+
const answer = (await res.json()) as { data?: { rows?: Array<Record<string, unknown>> } };
164+
expect(res.status, JSON.stringify(answer)).toBe(200);
165+
expect(answer.data?.rows?.[0]?.delivery).toBe('email');
166+
expect(answer.data?.rows?.[0]?.code, 'the invitation was requested').toBeUndefined();
167+
168+
const mail = await invitationTo(sent, email);
169+
const link = new URL(String(mail.data.resetUrl));
170+
const token = String(mail.data.token);
171+
expect(link.origin + link.pathname).toBe(`${ORIGIN}${BASE}/reset-password/${token}`);
172+
173+
// Where the invitee lands first: before the fix this read
174+
// `${ORIGIN}${BASE}/error?error=INVALID_TOKEN`.
175+
const landed = await follow(manager, link.href);
176+
expect(landed.status).toBe(302);
177+
expect(landed.headers.get('location')).toBe(`${SET_PASSWORD_PAGE}?token=${token}`);
178+
expect(link.searchParams.get('callbackURL')).toBe(SET_PASSWORD_PAGE);
179+
180+
// Control: the same token with a callbackURL on a foreign origin is refused
181+
// rather than redirected, so the landing above was judged, not waved through.
182+
const foreign = new URL(link.href);
183+
foreign.searchParams.set('callbackURL', 'https://elsewhere.example.net/_console/reset-password');
184+
const refused = await follow(manager, foreign.href);
185+
expect(refused.status).toBe(403);
186+
expect(refused.headers.get('location')).toBeNull();
187+
188+
// The token the page receives sets the invitee's password.
189+
const reset = await manager.handleRequest(
190+
new Request(`${ORIGIN}${BASE}/reset-password`, {
191+
method: 'POST',
192+
headers: { 'content-type': 'application/json', origin: ORIGIN },
193+
body: JSON.stringify({ token, newPassword: 'Inv1ted!Passw0rd-23152' }),
194+
}),
195+
);
196+
expect(reset.status, await reset.clone().text()).toBe(200);
197+
}, 120_000);
198+
199+
it('② a caller whose deps carry no consoleResetPasswordUrl keeps today\'s link, an empty callbackURL', async () => {
200+
vi.spyOn(console, 'warn').mockImplementation(() => {});
201+
const { manager, sent } = await bootManager();
202+
const email = 'invitee-direct@acme.example.com';
203+
const deps: IdentityImportDeps = {
204+
getAuthApi: () => manager.getApi(),
205+
getDataEngine: () => manager.getDataEngine() as never,
206+
phoneNumberEnabled: () => manager.isPhoneNumberEnabled(),
207+
emailServiceAvailable: () => manager.isEmailServiceAvailable(),
208+
smsInviteAvailable: () => false,
209+
sendInviteSms: (phone: string) => manager.sendPhoneInviteSms(phone),
210+
noteMustChangePasswordIssued: () => manager.noteMustChangePasswordIssued(),
211+
};
212+
213+
const res = await runAdminImportUsers(
214+
deps,
215+
new Request(`${ORIGIN}${BASE}/admin/import-users`, {
216+
method: 'POST',
217+
headers: { 'content-type': 'application/json' },
218+
body: importBody(email),
219+
}),
220+
ADMIN,
221+
);
222+
expect(res.status, JSON.stringify(res.body)).toBe(200);
223+
224+
const mail = await invitationTo(sent, email);
225+
expect(String(mail.data.resetUrl)).toBe(`${ORIGIN}${BASE}/reset-password/${String(mail.data.token)}?callbackURL=`);
226+
}, 120_000);
227+
});

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ function makeDeps(opts: {
2828
smsInviteAvailable?: boolean;
2929
resetFails?: boolean;
3030
smsFails?: boolean;
31+
/** [#23152] When set, the deps carry `consoleResetPasswordUrl` answering it. */
32+
setPasswordPageUrl?: string;
3133
} = {}) {
3234
const existing = opts.existingUsers ?? [];
3335
let nextId = 1;
@@ -59,6 +61,9 @@ function makeDeps(opts: {
5961
emailServiceAvailable: () => opts.emailAvailable ?? true,
6062
smsInviteAvailable: () => opts.smsInviteAvailable ?? false,
6163
sendInviteSms,
64+
...(opts.setPasswordPageUrl !== undefined
65+
? { consoleResetPasswordUrl: () => opts.setPasswordPageUrl! }
66+
: {}),
6267
noteMustChangePasswordIssued,
6368
logger: { warn },
6469
};
@@ -326,6 +331,27 @@ describe('runAdminImportUsers — invite policy', () => {
326331
expectNoPasswordLeak(m, m.createUser.mock.calls.map((c) => c[0].body.password));
327332
});
328333

334+
it('[#23152] names the Console set-password page as the reset redirectTo when the deps carry it', async () => {
335+
const page = 'https://acme.example.com/_console/reset-password';
336+
const m = makeDeps({ setPasswordPageUrl: page });
337+
await runAdminImportUsers(
338+
m.deps,
339+
makeRequest({ passwordPolicy: 'invite', format: 'json', rows: [{ email: 'a@x.co' }] }),
340+
ACTOR,
341+
);
342+
expect(m.requestPasswordReset.mock.lastCall).toStrictEqual([{ body: { email: 'a@x.co', redirectTo: page } }]);
343+
});
344+
345+
it('[#23152] without the member, the reset request is exactly { email } — no redirectTo key', async () => {
346+
const m = makeDeps();
347+
await runAdminImportUsers(
348+
m.deps,
349+
makeRequest({ passwordPolicy: 'invite', format: 'json', rows: [{ email: 'a@x.co' }] }),
350+
ACTOR,
351+
);
352+
expect(m.requestPasswordReset.mock.lastCall).toStrictEqual([{ body: { email: 'a@x.co' } }]);
353+
});
354+
329355
it('keeps the row created (with INVITE_EMAIL_FAILED) when the email fails — no rollback', async () => {
330356
const m = makeDeps({ resetFails: true });
331357
const res = await runAdminImportUsers(

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,17 @@ export interface IdentityImportDeps {
176176
smsInviteAvailable(): boolean;
177177
/** #2780 — deliver the invitation SMS (no credential in the message). */
178178
sendInviteSms(phone: string): Promise<void>;
179+
/**
180+
* [#23152] Optional — the absolute URL of the Console's set-password page
181+
* (`AuthManager.getConsoleUrl('/reset-password')`), handed to better-auth
182+
* as the invitation reset request's `redirectTo`. better-auth puts it in the
183+
* emailed link as `callbackURL`, and its `GET /reset-password/:token`
184+
* redirects there with `?token=`. Without it the link carries an EMPTY
185+
* `callbackURL`, which that handler refuses with `?error=INVALID_TOKEN`, so
186+
* a host that mounts this import itself wires it to get a working invitation.
187+
* Absent, the request is today's `{ email }`, byte for byte.
188+
*/
189+
consoleResetPasswordUrl?(): string;
179190
noteMustChangePasswordIssued(): void;
180191
logger?: { warn(msg: string): void };
181192
}
@@ -701,7 +712,13 @@ export async function runAdminImportUsers(
701712
r.delivery = 'email';
702713
delivery.emailInvite++;
703714
try {
704-
await authApi.requestPasswordReset({ body: { email: invite.email } });
715+
// [#23152] The link lands on the Console's set-password page only
716+
// when the request names it; see `consoleResetPasswordUrl`.
717+
await authApi.requestPasswordReset({
718+
body: deps.consoleResetPasswordUrl
719+
? { email: invite.email, redirectTo: deps.consoleResetPasswordUrl() }
720+
: { email: invite.email },
721+
});
705722
} catch (e) {
706723
// The account exists; only the email failed. Not a rollback —
707724
// remediation is re-sending or an admin set-user-password.

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6485,6 +6485,18 @@ export class AuthManager {
64856485
return `${this.getCanonicalOrigin()}${uiBase}${rel}`;
64866486
}
64876487

6488+
/**
6489+
* The public reader of {@link getConsolePageUrl}: the absolute URL of a
6490+
* Console page, `${origin}${uiBasePath}${path}`. It returns exactly what the
6491+
* private composer computes and composes nothing of its own, so a caller
6492+
* outside this class (the identity import's invitation, which hands it to
6493+
* better-auth as the reset request's `redirectTo`) gets the same link every
6494+
* email and SMS this class sends, and no second link builder exists.
6495+
*/
6496+
getConsoleUrl(path: string): string {
6497+
return this.getConsolePageUrl(path);
6498+
}
6499+
64886500
/**
64896501
* The OAuth issuer identifier: better-auth's `baseURL` INCLUDING `basePath`
64906502
* (e.g. `https://acme.example.com/api/v1/auth`) — this is the `iss` claim

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3175,6 +3175,7 @@ export class AuthPlugin implements Plugin {
31753175
smsInviteAvailable: () =>
31763176
this.authManager!.isPhoneNumberEnabled() && this.authManager!.isPhoneOtpDeliverable(),
31773177
sendInviteSms: (phone: string) => this.authManager!.sendPhoneInviteSms(phone),
3178+
consoleResetPasswordUrl: () => this.authManager!.getConsoleUrl('/reset-password'),
31783179
noteMustChangePasswordIssued: () => this.authManager!.noteMustChangePasswordIssued(),
31793180
logger: ctx.logger,
31803181
},

0 commit comments

Comments
 (0)