@jskit-ai/auth-provider-local-core 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  "packageVersion": 1,
3
3
  "packageId": "@jskit-ai/auth-provider-local-core",
4
- "version": "0.1.8",
4
+ "version": "0.1.10",
5
5
  "kind": "runtime",
6
6
  "description": "Local auth provider with a file backend default and no database requirement.",
7
7
  "dependsOn": [
@@ -41,7 +41,7 @@ export default Object.freeze({
41
41
  },
42
42
  {
43
43
  "subpath": "./server/lib/index",
44
- "summary": "Exports local auth service, file backend helpers, and password strategy helpers."
44
+ "summary": "Exports local auth service, file backend helpers, password strategy helpers, and local auth register hook decorators."
45
45
  }
46
46
  ],
47
47
  "containerTokens": {
@@ -57,8 +57,8 @@ export default Object.freeze({
57
57
  "mutations": {
58
58
  "dependencies": {
59
59
  "runtime": {
60
- "@jskit-ai/auth-core": "0.1.107",
61
- "@jskit-ai/kernel": "0.1.109",
60
+ "@jskit-ai/auth-core": "0.1.109",
61
+ "@jskit-ai/kernel": "0.1.111",
62
62
  "nodemailer": "^7.0.10",
63
63
  "dotenv": "^16.4.5"
64
64
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/auth-provider-local-core",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -11,8 +11,8 @@
11
11
  "./server/lib/index": "./src/server/lib/index.js"
12
12
  },
13
13
  "dependencies": {
14
- "@jskit-ai/auth-core": "0.1.107",
15
- "@jskit-ai/kernel": "0.1.109",
14
+ "@jskit-ai/auth-core": "0.1.109",
15
+ "@jskit-ai/kernel": "0.1.111",
16
16
  "nodemailer": "^7.0.10"
17
17
  }
18
18
  }
@@ -1,3 +1,7 @@
1
1
  export { createLocalAuthService } from "./service.js";
2
2
  export { createLocalFileBackend } from "./fileBackend.js";
3
+ export {
4
+ LOCAL_AUTH_USER_REGISTERED_EVENT,
5
+ createLocalAuthRegisterHookDecorator
6
+ } from "./registerHookDecorator.js";
3
7
  export { hashPassword, normalizePasswordStrategy, verifyPassword } from "./passwords.js";
@@ -42,7 +42,7 @@ async function verifyPassword(password, record) {
42
42
  }
43
43
 
44
44
  function normalizePasswordStrategy(strategy = null) {
45
- if (!strategy) {
45
+ if (strategy == null) {
46
46
  return Object.freeze({
47
47
  hashPassword,
48
48
  verifyPassword
@@ -53,13 +53,12 @@ function normalizePasswordStrategy(strategy = null) {
53
53
  throw new TypeError("Local auth password strategy must be an object.");
54
54
  }
55
55
 
56
- const normalized = strategy;
57
- const strategyHashPassword = typeof normalized.hashPassword === "undefined"
56
+ const strategyHashPassword = typeof strategy.hashPassword === "undefined"
58
57
  ? hashPassword
59
- : normalized.hashPassword;
60
- const strategyVerifyPassword = typeof normalized.verifyPassword === "undefined"
58
+ : strategy.hashPassword;
59
+ const strategyVerifyPassword = typeof strategy.verifyPassword === "undefined"
61
60
  ? verifyPassword
62
- : normalized.verifyPassword;
61
+ : strategy.verifyPassword;
63
62
 
64
63
  if (typeof strategyHashPassword !== "function") {
65
64
  throw new TypeError("Local auth password strategy hashPassword must be a function.");
@@ -0,0 +1,96 @@
1
+ const LOCAL_AUTH_USER_REGISTERED_EVENT = "auth.local.user.registered";
2
+
3
+ function assertBlockingMode(hook) {
4
+ if (typeof hook?.blocking !== "boolean") {
5
+ throw new TypeError("Local auth register hooks must set blocking to true or false.");
6
+ }
7
+ }
8
+
9
+ function normalizeRegisterHook(hook = null) {
10
+ if (!hook || typeof hook !== "object" || Array.isArray(hook)) {
11
+ return null;
12
+ }
13
+ assertBlockingMode(hook);
14
+ if (typeof hook.handle !== "function") {
15
+ throw new TypeError("Local auth register hook handle must be a function.");
16
+ }
17
+ return Object.freeze({
18
+ hookId: String(hook.hookId || "anonymous"),
19
+ blocking: hook.blocking,
20
+ handle: hook.handle
21
+ });
22
+ }
23
+
24
+ function logNonBlockingHookFailure({ logger, hook, error } = {}) {
25
+ const payload = {
26
+ err: error,
27
+ hook: hook?.hookId || "anonymous",
28
+ event: LOCAL_AUTH_USER_REGISTERED_EVENT
29
+ };
30
+ if (logger && typeof logger.warn === "function") {
31
+ logger.warn(payload, "Non-blocking local auth register hook failed.");
32
+ return;
33
+ }
34
+ console.warn("Non-blocking local auth register hook failed.", payload);
35
+ }
36
+
37
+ async function runRegisterHook({ hook, result, logger }) {
38
+ if (!hook) {
39
+ return;
40
+ }
41
+
42
+ const payload = Object.freeze({
43
+ event: LOCAL_AUTH_USER_REGISTERED_EVENT,
44
+ actor: result?.actor || null,
45
+ profile: result?.profile || null,
46
+ authResult: result
47
+ });
48
+
49
+ if (hook.blocking) {
50
+ await hook.handle(payload);
51
+ return;
52
+ }
53
+
54
+ Promise.resolve()
55
+ .then(() => hook.handle(payload))
56
+ .catch((error) => logNonBlockingHookFailure({ logger, hook, error }));
57
+ }
58
+
59
+ function createLocalAuthRegisterHookDecorator({
60
+ decoratorId = "auth.local.registerHook",
61
+ order = 0,
62
+ logger = null,
63
+ hook = null
64
+ } = {}) {
65
+ const registerHook = normalizeRegisterHook(hook);
66
+
67
+ if (!registerHook) {
68
+ throw new TypeError("createLocalAuthRegisterHookDecorator requires a hook object.");
69
+ }
70
+
71
+ return Object.freeze({
72
+ decoratorId: String(decoratorId || registerHook.hookId || "auth.local.registerHook"),
73
+ order: Number.isFinite(order) ? Number(order) : 0,
74
+ decorateAuthService(authService) {
75
+ if (!authService || typeof authService.register !== "function") {
76
+ return authService;
77
+ }
78
+
79
+ return Object.freeze(
80
+ Object.defineProperty(Object.create(authService), "register", {
81
+ enumerable: true,
82
+ value: async function register(input = {}) {
83
+ const result = await authService.register(input);
84
+ await runRegisterHook({ hook: registerHook, result, logger });
85
+ return result;
86
+ }
87
+ })
88
+ );
89
+ }
90
+ });
91
+ }
92
+
93
+ export {
94
+ LOCAL_AUTH_USER_REGISTERED_EVENT,
95
+ createLocalAuthRegisterHookDecorator
96
+ };
@@ -1,3 +1,4 @@
1
+ import { applyAuthServiceDecorators } from "@jskit-ai/auth-core/server/authServiceDecoratorRegistry";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { randomBytes } from "node:crypto";
@@ -6,6 +7,11 @@ import { createLocalFileBackend } from "../lib/fileBackend.js";
6
7
 
7
8
  const DEFAULT_STORE_DIR = ".jskit/auth";
8
9
 
10
+ function resolveLocalBackendMode(scope) {
11
+ const env = resolveRuntimeEnv(scope);
12
+ return String(env.AUTH_LOCAL_BACKEND || "file").trim().toLowerCase() || "file";
13
+ }
14
+
9
15
  function parseBoolean(value, fallback = false) {
10
16
  const raw = String(value || "").trim().toLowerCase();
11
17
  if (!raw) {
@@ -157,12 +163,9 @@ class AuthLocalServiceProvider {
157
163
  throw new Error("AuthLocalServiceProvider cannot register authService because another auth provider already registered it.");
158
164
  }
159
165
 
160
- if (!app.has("auth.local.backend")) {
166
+ if (!app.has("auth.local.backend") && resolveLocalBackendMode(app) === "file") {
161
167
  app.singleton("auth.local.backend", (scope) => {
162
168
  const config = resolveConfig(scope);
163
- if (config.backend !== "file") {
164
- throw new Error(`AUTH_LOCAL_BACKEND="${config.backend}" requires a custom auth.local.backend provider.`);
165
- }
166
169
  return createLocalFileBackend({
167
170
  storeDir: config.storeDir
168
171
  });
@@ -171,6 +174,9 @@ class AuthLocalServiceProvider {
171
174
 
172
175
  app.singleton("authService", (scope) => {
173
176
  const config = resolveConfig(scope);
177
+ if (!scope.has("auth.local.backend")) {
178
+ throw new Error(`AUTH_LOCAL_BACKEND="${config.backend}" requires a package or app provider that registers auth.local.backend.`);
179
+ }
174
180
  const backend = scope.make("auth.local.backend");
175
181
  const profileProjector = scope.has("auth.profile.projector")
176
182
  ? {
@@ -200,13 +206,14 @@ class AuthLocalServiceProvider {
200
206
  const passwordStrategy = scope.has("auth.local.passwordStrategy")
201
207
  ? scope.make("auth.local.passwordStrategy")
202
208
  : null;
203
- return createLocalAuthService({
209
+ const authService = createLocalAuthService({
204
210
  backend,
205
211
  config,
206
212
  profileProjector,
207
213
  passwordStrategy,
208
214
  invitationContextResolver
209
215
  });
216
+ return applyAuthServiceDecorators(scope, authService);
210
217
  });
211
218
  }
212
219
 
@@ -218,4 +225,4 @@ class AuthLocalServiceProvider {
218
225
  }
219
226
  }
220
227
 
221
- export { AuthLocalServiceProvider };
228
+ export { AuthLocalServiceProvider, resolveLocalBackendMode };
@@ -5,10 +5,12 @@ import path from "node:path";
5
5
  import test from "node:test";
6
6
  import { createApplication } from "@jskit-ai/kernel/_testable";
7
7
  import { ActionRuntimeServiceProvider } from "@jskit-ai/kernel/server/actions";
8
+ import { registerAuthServiceDecorator } from "@jskit-ai/auth-core/server/authServiceDecoratorRegistry";
8
9
  import { AuthActionsServiceProvider } from "@jskit-ai/auth-core/server/providers/AuthActionsServiceProvider";
9
10
  import { AuthLocalServiceProvider } from "../src/server/providers/AuthLocalServiceProvider.js";
10
11
  import { AuthProviderServiceProvider } from "../src/server/providers/AuthProviderServiceProvider.js";
11
12
  import {
13
+ createLocalAuthRegisterHookDecorator,
12
14
  createLocalAuthService,
13
15
  createLocalFileBackend,
14
16
  hashPassword,
@@ -54,7 +56,9 @@ async function createStartedApp({
54
56
  profileProjector = null,
55
57
  profileProjectorFactory = null,
56
58
  passwordStrategy = null,
57
- invitationContextResolver = null
59
+ invitationContextResolver = null,
60
+ logger = null,
61
+ configureApp = null
58
62
  } = {}) {
59
63
  const storeDir = await fs.mkdtemp(path.join(os.tmpdir(), "jskit-auth-local-"));
60
64
  const app = createApplication();
@@ -66,12 +70,15 @@ async function createStartedApp({
66
70
  APP_PUBLIC_URL: "http://localhost:5173",
67
71
  NODE_ENV: "test"
68
72
  });
69
- app.instance("jskit.logger", {
70
- info() {},
71
- warn() {},
72
- error() {},
73
- debug() {}
74
- });
73
+ app.instance(
74
+ "jskit.logger",
75
+ logger || {
76
+ info() {},
77
+ warn() {},
78
+ error() {},
79
+ debug() {}
80
+ }
81
+ );
75
82
  app.instance("domainEvents", {
76
83
  async publish() {}
77
84
  });
@@ -87,6 +94,9 @@ async function createStartedApp({
87
94
  if (invitationContextResolver) {
88
95
  app.instance("auth.invitationContextResolver", invitationContextResolver);
89
96
  }
97
+ if (typeof configureApp === "function") {
98
+ configureApp(app);
99
+ }
90
100
  await app.start({
91
101
  providers: [
92
102
  ActionRuntimeServiceProvider,
@@ -152,6 +162,102 @@ test("local auth provider registers, logs in, reads session, and logs out with f
152
162
  assert.match(usersFile, /^user:v1:/);
153
163
  });
154
164
 
165
+ test("local auth provider applies auth service decorators for blocking and non-blocking hooks", async () => {
166
+ const calls = [];
167
+ const errors = [];
168
+ const { app } = await createStartedApp({
169
+ logger: {
170
+ info() {},
171
+ warn(payload, message) {
172
+ errors.push({ payload, message });
173
+ },
174
+ error() {},
175
+ debug() {}
176
+ },
177
+ configureApp(app) {
178
+ registerAuthServiceDecorator(app, "test.auth.local.nonBlockingHook", (scope) => ({
179
+ ...createLocalAuthRegisterHookDecorator({
180
+ decoratorId: "test.auth.local.nonBlockingHook",
181
+ order: 20,
182
+ logger: scope.make("jskit.logger"),
183
+ hook: {
184
+ hookId: "audit",
185
+ blocking: false,
186
+ async handle({ actor }) {
187
+ calls.push({ hook: "audit", email: actor.email });
188
+ throw new Error("audit unavailable");
189
+ }
190
+ }
191
+ })
192
+ }));
193
+ registerAuthServiceDecorator(app, "test.auth.local.blockingHook", () =>
194
+ createLocalAuthRegisterHookDecorator({
195
+ decoratorId: "test.auth.local.blockingHook",
196
+ order: 10,
197
+ hook: {
198
+ hookId: "permissions",
199
+ blocking: true,
200
+ async handle({ actor, profile }) {
201
+ calls.push({ hook: "permissions", email: actor.email });
202
+ if (profile?.displayName === "Block Permissions") {
203
+ throw new Error("permission provisioning failed");
204
+ }
205
+ }
206
+ }
207
+ })
208
+ );
209
+ }
210
+ });
211
+ const authService = app.make("authService");
212
+
213
+ const registered = await authService.register({
214
+ email: "hooks@example.com",
215
+ password: "correct horse battery staple",
216
+ displayName: "Hooks"
217
+ });
218
+ assert.equal(registered.actor.email, "hooks@example.com");
219
+ await new Promise((resolve) => setImmediate(resolve));
220
+ assert.deepEqual(calls, [
221
+ { hook: "permissions", email: "hooks@example.com" },
222
+ { hook: "audit", email: "hooks@example.com" }
223
+ ]);
224
+ assert.equal(errors.length, 1);
225
+ assert.equal(errors[0].message, "Non-blocking local auth register hook failed.");
226
+
227
+ await assert.rejects(
228
+ () =>
229
+ authService.register({
230
+ email: "blocked-hooks@example.com",
231
+ password: "correct horse battery staple",
232
+ displayName: "Block Permissions"
233
+ }),
234
+ /permission provisioning failed/
235
+ );
236
+ });
237
+
238
+ test("local auth register hook decorators require explicit blocking mode", () => {
239
+ assert.throws(
240
+ () =>
241
+ createLocalAuthRegisterHookDecorator({
242
+ hook: {
243
+ hookId: "invalid",
244
+ async handle() {}
245
+ }
246
+ }),
247
+ /blocking to true or false/
248
+ );
249
+ assert.throws(
250
+ () =>
251
+ createLocalAuthRegisterHookDecorator({
252
+ hook: {
253
+ hookId: "invalid",
254
+ blocking: false
255
+ }
256
+ }),
257
+ /handle must be a function/
258
+ );
259
+ });
260
+
155
261
  test("local auth provider resolves a custom password strategy from the container", async () => {
156
262
  const passwordStrategy = {
157
263
  prefix: "strategy",
@@ -196,10 +302,26 @@ test("local auth password strategy supports partial overrides and rejects invali
196
302
  assert.equal(await verifyPassword("new password value", defaultHashed), true);
197
303
  assert.equal(await strategy.verifyPassword("old password value", "legacy:users:old password value"), true);
198
304
 
305
+ const hashOnlyStrategy = normalizePasswordStrategy({
306
+ async hashPassword(password) {
307
+ return hashPassword(password);
308
+ }
309
+ });
310
+ const hashOnlyRecord = await hashOnlyStrategy.hashPassword("hash only password");
311
+ assert.equal(await hashOnlyStrategy.verifyPassword("hash only password", hashOnlyRecord), true);
312
+
199
313
  assert.throws(
200
314
  () => normalizePasswordStrategy("invalid"),
201
315
  /Local auth password strategy must be an object/
202
316
  );
317
+ assert.throws(
318
+ () => normalizePasswordStrategy(false),
319
+ /Local auth password strategy must be an object/
320
+ );
321
+ assert.throws(
322
+ () => normalizePasswordStrategy(0),
323
+ /Local auth password strategy must be an object/
324
+ );
203
325
  assert.throws(
204
326
  () => normalizePasswordStrategy([]),
205
327
  /Local auth password strategy must be an object/
@@ -222,165 +344,280 @@ test("local auth login verifies password and creates session in one backend tran
222
344
  const password = await hashPassword("current password value");
223
345
  let transactions = 0;
224
346
  let sessionCreated = false;
225
- const backend = {
347
+ const backend = createLocalFileBackend({
348
+ storeDir: await fs.mkdtemp(path.join(os.tmpdir(), "jskit-auth-login-tx-"))
349
+ });
350
+ await backend.withTransaction((tx) =>
351
+ tx.users.create({
352
+ id: "usr_login_tx",
353
+ email: "tx@example.com",
354
+ displayName: "Transaction User",
355
+ password
356
+ })
357
+ );
358
+ const wrappedBackend = {
226
359
  async withTransaction(callback) {
227
360
  transactions += 1;
228
- const tx = {
229
- users: {
230
- async findByEmail(email) {
231
- assert.equal(email, "race@example.com");
232
- return {
233
- id: "usr_race",
234
- email,
235
- displayName: "Race User",
236
- password,
237
- disabled: false
238
- };
239
- }
240
- },
241
- sessions: {
242
- async create(input) {
243
- sessionCreated = true;
244
- return {
245
- ...input,
246
- createdAt: new Date().toISOString(),
247
- revokedAt: ""
248
- };
361
+ return backend.withTransaction(async (tx) => {
362
+ const result = await callback({
363
+ ...tx,
364
+ sessions: {
365
+ ...tx.sessions,
366
+ async create(input) {
367
+ sessionCreated = true;
368
+ return tx.sessions.create(input);
369
+ }
249
370
  }
250
- }
251
- };
252
- return callback(tx);
371
+ });
372
+ return result;
373
+ });
253
374
  }
254
375
  };
255
376
  const authService = createLocalAuthService({
256
- backend,
377
+ backend: wrappedBackend,
257
378
  config: {
258
- nodeEnv: "test",
259
379
  sessionSecret: "test-secret",
260
- appPublicUrl: "http://localhost:5173",
380
+ nodeEnv: "test",
261
381
  smtpConfigured: false,
262
- recoveryDevOutput: "disabled"
382
+ recoveryDevOutput: "response",
383
+ appPublicUrl: "http://localhost:5173"
263
384
  }
264
385
  });
265
386
 
266
387
  const result = await authService.login({
267
- email: "race@example.com",
388
+ email: "tx@example.com",
268
389
  password: "current password value"
269
390
  });
270
391
 
271
- assert.equal(result.actor.email, "race@example.com");
272
- assert.equal(Boolean(result.session?.access_token), true);
273
- assert.equal(sessionCreated, true);
392
+ assert.equal(result.actor.email, "tx@example.com");
274
393
  assert.equal(transactions, 1);
394
+ assert.equal(sessionCreated, true);
275
395
  });
276
396
 
277
397
  test("local auth service accepts a custom strategy for legacy stored password records", async () => {
278
- const usersByEmail = new Map([
279
- [
280
- "legacy@example.com",
281
- {
282
- id: "usr_legacy",
283
- email: "legacy@example.com",
284
- displayName: "Legacy User",
285
- password: {
286
- algorithm: "legacy-bcrypt",
287
- hash: "legacy password value"
288
- },
289
- disabled: false
290
- }
291
- ]
292
- ]);
398
+ const passwordRecords = [];
293
399
  const sessions = [];
294
- const backend = {
295
- async withTransaction(callback) {
296
- const tx = {
297
- users: {
298
- async findByEmail(email) {
299
- return usersByEmail.get(email) || null;
400
+ const authService = createLocalAuthService({
401
+ backend: {
402
+ async withTransaction(callback) {
403
+ return callback({
404
+ users: {
405
+ async findByEmail(email) {
406
+ if (email !== "legacy@example.com") {
407
+ return null;
408
+ }
409
+ return {
410
+ id: "usr_legacy",
411
+ email,
412
+ displayName: "Legacy User",
413
+ password: "legacy:legacy@example.com:old-password",
414
+ disabled: false
415
+ };
416
+ },
417
+ async findById(userId) {
418
+ if (userId !== "usr_legacy") {
419
+ return null;
420
+ }
421
+ return {
422
+ id: "usr_legacy",
423
+ email: "legacy@example.com",
424
+ displayName: "Legacy User",
425
+ password: passwordRecords.at(-1) || "legacy:legacy@example.com:old-password",
426
+ disabled: false
427
+ };
428
+ },
429
+ async create(input) {
430
+ passwordRecords.push(input.password);
431
+ return input;
432
+ },
433
+ async updatePassword(_userId, password) {
434
+ passwordRecords.push(password);
435
+ return {
436
+ id: "usr_legacy",
437
+ email: "legacy@example.com",
438
+ displayName: "Legacy User",
439
+ password,
440
+ disabled: false
441
+ };
442
+ },
443
+ async updateProfile() {
444
+ throw new Error("not needed");
445
+ }
300
446
  },
301
- async create(input) {
302
- const user = {
303
- ...input,
304
- disabled: false
305
- };
306
- usersByEmail.set(user.email, user);
307
- return user;
308
- }
309
- },
310
- sessions: {
311
- async create(input) {
312
- const session = {
313
- ...input,
314
- createdAt: new Date().toISOString(),
315
- revokedAt: ""
316
- };
317
- sessions.push(session);
318
- return session;
447
+ sessions: {
448
+ async create(input) {
449
+ sessions.push(input);
450
+ return {
451
+ ...input,
452
+ createdAt: new Date().toISOString(),
453
+ revokedAt: ""
454
+ };
455
+ },
456
+ async findById() {
457
+ return null;
458
+ },
459
+ async findByTokenHash() {
460
+ return null;
461
+ },
462
+ async revoke() {
463
+ return null;
464
+ },
465
+ async revokeForUser() {
466
+ return 0;
467
+ }
468
+ },
469
+ recovery: {
470
+ async create() {},
471
+ async findByTokenHash() {
472
+ return null;
473
+ },
474
+ async consume() {
475
+ return null;
476
+ },
477
+ async consumeForUser() {
478
+ return 0;
479
+ }
319
480
  }
320
- }
321
- };
322
- return callback(tx);
481
+ });
482
+ }
483
+ },
484
+ config: {
485
+ sessionSecret: "test-secret",
486
+ nodeEnv: "test",
487
+ smtpConfigured: false,
488
+ recoveryDevOutput: "response",
489
+ appPublicUrl: "http://localhost:5173"
490
+ },
491
+ passwordStrategy: {
492
+ async verifyPassword(password, record) {
493
+ return record === `legacy:legacy@example.com:${password}`;
494
+ }
323
495
  }
324
- };
496
+ });
497
+
498
+ const legacyLogin = await authService.login({
499
+ email: "legacy@example.com",
500
+ password: "old-password"
501
+ });
502
+ assert.equal(legacyLogin.actor.providerUserId, "usr_legacy");
503
+ assert.equal(sessions.length, 1);
504
+
505
+ await authService.register({
506
+ email: "new@example.com",
507
+ password: "new-password",
508
+ displayName: "New User"
509
+ });
510
+ assert.equal(passwordRecords.length, 1);
511
+ assert.equal(passwordRecords[0].algorithm, "scrypt");
512
+ assert.equal(sessions.length, 2);
513
+ });
514
+
515
+ test("local auth password strategy handles register, reset, login, and change password paths", async () => {
516
+ const hashCalls = [];
517
+ const verifyCalls = [];
518
+ const encodePassword = (password) => Buffer.from(String(password), "utf8").toString("base64url");
325
519
  const passwordStrategy = {
326
520
  async hashPassword(password) {
521
+ hashCalls.push(password);
327
522
  return {
328
- algorithm: "test-scrypt",
523
+ algorithm: "strategy-password",
329
524
  version: "v1",
330
- salt: "test",
331
- hash: `hashed-${password}`
525
+ salt: "",
526
+ hash: encodePassword(password)
332
527
  };
333
528
  },
334
529
  async verifyPassword(password, record) {
335
- if (record?.algorithm === "legacy-bcrypt") {
336
- return record.hash === password;
337
- }
338
- return record?.algorithm === "test-scrypt" && record.hash === `hashed-${password}`;
530
+ verifyCalls.push({
531
+ password,
532
+ hash: record?.hash || ""
533
+ });
534
+ return (
535
+ record?.algorithm === "strategy-password" &&
536
+ record.hash === encodePassword(password)
537
+ );
339
538
  }
340
539
  };
341
- const authService = createLocalAuthService({
342
- backend,
343
- passwordStrategy,
344
- config: {
345
- nodeEnv: "test",
346
- sessionSecret: "test-secret",
347
- appPublicUrl: "http://localhost:5173",
348
- smtpConfigured: false,
349
- recoveryDevOutput: "disabled"
350
- }
540
+ const { app } = await createStartedApp({ passwordStrategy });
541
+ const authService = app.make("authService");
542
+
543
+ await authService.register({
544
+ email: "strategy-paths@example.com",
545
+ password: "original password value",
546
+ displayName: "Strategy Paths"
547
+ });
548
+ await authService.login({
549
+ email: "strategy-paths@example.com",
550
+ password: "original password value"
351
551
  });
352
552
 
353
- const legacyLogin = await authService.login({
354
- email: "legacy@example.com",
355
- password: "legacy password value"
553
+ const resetRequest = await authService.requestPasswordReset({
554
+ email: "strategy-paths@example.com"
356
555
  });
357
- assert.equal(legacyLogin.actor.email, "legacy@example.com");
556
+ const recoveryToken = new URL(resetRequest.recoveryUrl).searchParams.get("token");
557
+ const recovery = await authService.completePasswordRecovery({
558
+ code: recoveryToken,
559
+ type: "recovery"
560
+ });
561
+ const recoveryReply = createReplyFixture();
562
+ authService.writeSessionCookies(recoveryReply, recovery.session);
563
+ await authService.resetPassword(
564
+ {
565
+ cookies: recoveryReply.cookies
566
+ },
567
+ {
568
+ password: "reset password value"
569
+ }
570
+ );
358
571
 
359
- await authService.register({
360
- email: "new-strategy@example.com",
361
- password: "new password value",
362
- displayName: "New Strategy"
572
+ const resetLogin = await authService.login({
573
+ email: "strategy-paths@example.com",
574
+ password: "reset password value"
363
575
  });
364
- assert.deepEqual(usersByEmail.get("new-strategy@example.com").password, {
365
- algorithm: "test-scrypt",
366
- version: "v1",
367
- salt: "test",
368
- hash: "hashed-new password value"
576
+ const normalReply = createReplyFixture();
577
+ authService.writeSessionCookies(normalReply, resetLogin.session);
578
+ await authService.changePassword(
579
+ {
580
+ cookies: normalReply.cookies
581
+ },
582
+ {
583
+ currentPassword: "reset password value",
584
+ newPassword: "changed password value"
585
+ }
586
+ );
587
+ await authService.login({
588
+ email: "strategy-paths@example.com",
589
+ password: "changed password value"
369
590
  });
370
- assert.equal(sessions.length, 2);
591
+
592
+ assert.deepEqual(hashCalls, [
593
+ "original password value",
594
+ "reset password value",
595
+ "changed password value"
596
+ ]);
597
+ assert.deepEqual(verifyCalls.map((call) => call.password), [
598
+ "original password value",
599
+ "reset password value",
600
+ "reset password value",
601
+ "changed password value"
602
+ ]);
371
603
  });
372
604
 
373
605
  test("local auth provider completes recovery through a recovery-scoped session", async () => {
374
606
  const { app } = await createStartedApp();
375
607
  const authService = app.make("authService");
376
- const registered = await authService.register({
377
- email: "grace@example.com",
378
- password: "old password value",
379
- displayName: "Grace"
608
+ await authService.register({
609
+ email: "Recovery@example.com",
610
+ password: "correct horse battery staple",
611
+ displayName: "Recovery User"
380
612
  });
381
- const normalReply = createReplyFixture();
382
- authService.writeSessionCookies(normalReply, registered.session);
383
613
 
614
+ const normalReply = createReplyFixture();
615
+ const normalLogin = await authService.login({
616
+ email: "recovery@example.com",
617
+ password: "correct horse battery staple"
618
+ });
619
+ authService.writeSessionCookies(normalReply, normalLogin.session);
620
+ normalReply.cookies.jskit_local_recovery_token = normalReply.cookies.jskit_local_access_token;
384
621
  await assert.rejects(
385
622
  () =>
386
623
  authService.resetPassword(
@@ -388,28 +625,27 @@ test("local auth provider completes recovery through a recovery-scoped session",
388
625
  cookies: normalReply.cookies
389
626
  },
390
627
  {
391
- password: "bypassed password value"
628
+ password: "not allowed value"
392
629
  }
393
630
  ),
394
- /Authentication required/
631
+ /Recovery session required/
395
632
  );
396
633
 
397
634
  const resetRequest = await authService.requestPasswordReset({
398
- email: "grace@example.com"
635
+ email: "recovery@example.com"
399
636
  });
400
- assert.equal(resetRequest.ok, true);
401
- assert.match(resetRequest.recoveryUrl, /\/auth\/reset-password\?token=/);
402
- const recoveryToken = new URL(resetRequest.recoveryUrl).searchParams.get("token");
403
-
637
+ assert.match(resetRequest.recoveryUrl, /^http:\/\/localhost:5173\/auth\/reset-password\?/);
638
+ const token = new URL(resetRequest.recoveryUrl).searchParams.get("token");
404
639
  const recovery = await authService.completePasswordRecovery({
405
- code: recoveryToken,
640
+ code: token,
406
641
  type: "recovery"
407
642
  });
643
+ assert.equal(recovery.actor.email, "recovery@example.com");
644
+
408
645
  const reply = createReplyFixture();
409
646
  authService.writeSessionCookies(reply, recovery.session);
410
647
  assert.equal(Boolean(reply.cookies.jskit_local_recovery_token), true);
411
- assert.equal(reply.cookieOptions.jskit_local_recovery_token.maxAge, 15 * 60);
412
- assert.equal(reply.cookieOptions.jskit_local_refresh_token.maxAge, 15 * 60);
648
+ assert.equal(Boolean(reply.cookies.jskit_local_refresh_token), true);
413
649
 
414
650
  const generalSession = await authService.authenticateRequest({ cookies: reply.cookies });
415
651
  assert.equal(generalSession.authenticated, false);
@@ -420,39 +656,39 @@ test("local auth provider completes recovery through a recovery-scoped session",
420
656
  cookies: reply.cookies
421
657
  },
422
658
  {
423
- password: "new password value"
659
+ password: "updated password value"
424
660
  }
425
661
  );
426
662
 
427
663
  await assert.rejects(
428
664
  () =>
429
665
  authService.login({
430
- email: "grace@example.com",
431
- password: "old password value"
666
+ email: "recovery@example.com",
667
+ password: "correct horse battery staple"
432
668
  }),
433
669
  /Invalid email or password/
434
670
  );
435
671
  const login = await authService.login({
436
- email: "grace@example.com",
437
- password: "new password value"
672
+ email: "recovery@example.com",
673
+ password: "updated password value"
438
674
  });
439
- assert.equal(login.actor.email, "grace@example.com");
675
+ assert.equal(login.actor.email, "recovery@example.com");
440
676
  });
441
677
 
442
678
  test("local auth reset invalidates other outstanding recovery tokens for the user", async () => {
443
679
  const { app } = await createStartedApp();
444
680
  const authService = app.make("authService");
445
681
  await authService.register({
446
- email: "multi-reset@example.com",
447
- password: "old password value",
448
- displayName: "Multi Reset"
682
+ email: "recovery-tokens@example.com",
683
+ password: "correct horse battery staple",
684
+ displayName: "Recovery Tokens"
449
685
  });
450
686
 
451
687
  const firstResetRequest = await authService.requestPasswordReset({
452
- email: "multi-reset@example.com"
688
+ email: "recovery-tokens@example.com"
453
689
  });
454
690
  const secondResetRequest = await authService.requestPasswordReset({
455
- email: "multi-reset@example.com"
691
+ email: "recovery-tokens@example.com"
456
692
  });
457
693
  const firstToken = new URL(firstResetRequest.recoveryUrl).searchParams.get("token");
458
694
  const secondToken = new URL(secondResetRequest.recoveryUrl).searchParams.get("token");
@@ -463,13 +699,12 @@ test("local auth reset invalidates other outstanding recovery tokens for the use
463
699
  });
464
700
  const reply = createReplyFixture();
465
701
  authService.writeSessionCookies(reply, recovery.session);
466
-
467
702
  await authService.resetPassword(
468
703
  {
469
704
  cookies: reply.cookies
470
705
  },
471
706
  {
472
- password: "new password value"
707
+ password: "updated password value"
473
708
  }
474
709
  );
475
710
 
@@ -487,9 +722,9 @@ test("local auth provider changes password through the account-security contract
487
722
  const { app } = await createStartedApp();
488
723
  const authService = app.make("authService");
489
724
  const registered = await authService.register({
490
- email: "lin@example.com",
491
- password: "old password value",
492
- displayName: "Lin"
725
+ email: "Security@example.com",
726
+ password: "correct horse battery staple",
727
+ displayName: "Security User"
493
728
  });
494
729
  const reply = createReplyFixture();
495
730
  authService.writeSessionCookies(reply, registered.session);
@@ -497,74 +732,69 @@ test("local auth provider changes password through the account-security contract
497
732
  await assert.rejects(
498
733
  () =>
499
734
  authService.changePassword(
500
- { cookies: reply.cookies },
501
735
  {
502
- currentPassword: "wrong password",
503
- newPassword: "new password value"
736
+ cookies: reply.cookies
737
+ },
738
+ {
739
+ currentPassword: "wrong password value",
740
+ newPassword: "updated password value"
504
741
  }
505
742
  ),
506
743
  /Current password is invalid/
507
744
  );
508
745
 
509
746
  await authService.changePassword(
510
- { cookies: reply.cookies },
511
747
  {
512
- currentPassword: "old password value",
513
- newPassword: "new password value"
748
+ cookies: reply.cookies
749
+ },
750
+ {
751
+ currentPassword: "correct horse battery staple",
752
+ newPassword: "updated password value"
514
753
  }
515
754
  );
516
755
 
517
756
  await assert.rejects(
518
757
  () =>
519
758
  authService.login({
520
- email: "lin@example.com",
521
- password: "old password value"
759
+ email: "security@example.com",
760
+ password: "correct horse battery staple"
522
761
  }),
523
762
  /Invalid email or password/
524
763
  );
525
764
  const login = await authService.login({
526
- email: "lin@example.com",
527
- password: "new password value"
765
+ email: "security@example.com",
766
+ password: "updated password value"
528
767
  });
529
- assert.equal(login.actor.email, "lin@example.com");
768
+ assert.equal(login.actor.email, "security@example.com");
530
769
  });
531
770
 
532
771
  test("local auth provider defers profile projector resolution until projection is needed", async () => {
533
- let projectorResolved = 0;
534
- let projectionCalls = 0;
772
+ let projectorFactoryCalls = 0;
535
773
  const { app } = await createStartedApp({
536
- profileProjectorFactory: () => {
537
- projectorResolved += 1;
774
+ profileProjectorFactory() {
775
+ projectorFactoryCalls += 1;
538
776
  return {
539
777
  async syncIdentityProfile(profile) {
540
- projectionCalls += 1;
541
778
  return {
542
779
  ...profile,
543
- id: "app-profile-id",
780
+ id: "projected-user",
544
781
  profileSource: "users"
545
782
  };
546
783
  }
547
784
  };
548
785
  }
549
786
  });
550
-
551
- assert.equal(projectorResolved, 0);
552
-
787
+ assert.equal(projectorFactoryCalls, 0);
553
788
  const authService = app.make("authService");
554
- assert.equal(authService.getCapabilities().features.appProfileProjection, true);
555
- assert.equal(projectorResolved, 0);
556
-
789
+ assert.equal(projectorFactoryCalls, 0);
557
790
  const registered = await authService.register({
558
- email: "projector@example.com",
559
- password: "projector password value",
560
- displayName: "Projector User"
791
+ email: "projected@example.com",
792
+ password: "correct horse battery staple",
793
+ displayName: "Projected User"
561
794
  });
562
-
563
- assert.equal(projectorResolved, 1);
564
- assert.equal(projectionCalls, 1);
565
- assert.equal(registered.actor.appUserId, "app-profile-id");
795
+ assert.equal(projectorFactoryCalls, 1);
796
+ assert.equal(registered.actor.appUserId, "projected-user");
566
797
  });
567
-
568
798
  test("local auth provider projects app profile when auth.profile.projector is installed", async () => {
569
799
  const projectedProfiles = [];
570
800
  const { app } = await createStartedApp({