@jskit-ai/auth-provider-local-core 0.1.2 → 0.1.4

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.2",
4
+ "version": "0.1.4",
5
5
  "kind": "runtime",
6
6
  "description": "Local auth provider with a file backend default and no database requirement.",
7
7
  "dependsOn": [
@@ -41,13 +41,14 @@ export default Object.freeze({
41
41
  },
42
42
  {
43
43
  "subpath": "./server/lib/index",
44
- "summary": "Exports local auth service and file backend helpers."
44
+ "summary": "Exports local auth service, file backend helpers, and password strategy helpers."
45
45
  }
46
46
  ],
47
47
  "containerTokens": {
48
48
  "server": [
49
49
  "authService",
50
- "auth.local.backend"
50
+ "auth.local.backend",
51
+ "auth.local.passwordStrategy"
51
52
  ],
52
53
  "client": []
53
54
  }
@@ -56,8 +57,8 @@ export default Object.freeze({
56
57
  "mutations": {
57
58
  "dependencies": {
58
59
  "runtime": {
59
- "@jskit-ai/auth-core": "0.1.101",
60
- "@jskit-ai/kernel": "0.1.103",
60
+ "@jskit-ai/auth-core": "0.1.103",
61
+ "@jskit-ai/kernel": "0.1.105",
61
62
  "nodemailer": "^7.0.10",
62
63
  "dotenv": "^16.4.5"
63
64
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/auth-provider-local-core",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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.101",
15
- "@jskit-ai/kernel": "0.1.103",
14
+ "@jskit-ai/auth-core": "0.1.103",
15
+ "@jskit-ai/kernel": "0.1.105",
16
16
  "nodemailer": "^7.0.10"
17
17
  }
18
18
  }
@@ -1,2 +1,3 @@
1
1
  export { createLocalAuthService } from "./service.js";
2
2
  export { createLocalFileBackend } from "./fileBackend.js";
3
+ export { hashPassword, normalizePasswordStrategy, verifyPassword } from "./passwords.js";
@@ -41,4 +41,41 @@ async function verifyPassword(password, record) {
41
41
  return expected.length === actual.length && timingSafeEqual(expected, actual);
42
42
  }
43
43
 
44
- export { hashPassword, verifyPassword };
44
+ function normalizePasswordStrategy(strategy = null) {
45
+ if (!strategy) {
46
+ return Object.freeze({
47
+ hashPassword,
48
+ verifyPassword
49
+ });
50
+ }
51
+
52
+ if (typeof strategy !== "object" || Array.isArray(strategy)) {
53
+ throw new TypeError("Local auth password strategy must be an object.");
54
+ }
55
+
56
+ const normalized = strategy;
57
+ const strategyHashPassword = typeof normalized.hashPassword === "undefined"
58
+ ? hashPassword
59
+ : normalized.hashPassword;
60
+ const strategyVerifyPassword = typeof normalized.verifyPassword === "undefined"
61
+ ? verifyPassword
62
+ : normalized.verifyPassword;
63
+
64
+ if (typeof strategyHashPassword !== "function") {
65
+ throw new TypeError("Local auth password strategy hashPassword must be a function.");
66
+ }
67
+ if (typeof strategyVerifyPassword !== "function") {
68
+ throw new TypeError("Local auth password strategy verifyPassword must be a function.");
69
+ }
70
+
71
+ return Object.freeze({
72
+ async hashPassword(password) {
73
+ return strategyHashPassword.call(strategy, password);
74
+ },
75
+ async verifyPassword(password, record) {
76
+ return strategyVerifyPassword.call(strategy, password, record);
77
+ }
78
+ });
79
+ }
80
+
81
+ export { hashPassword, verifyPassword, normalizePasswordStrategy };
@@ -8,7 +8,7 @@ import { buildSecurityStatusFromAuthMethodsStatus } from "@jskit-ai/auth-core/sh
8
8
  import { normalizeAuthActor, normalizeAuthResult } from "@jskit-ai/auth-core/server/authActor";
9
9
  import { normalizeEmail } from "@jskit-ai/auth-core/server/utils";
10
10
  import { throwUnsupportedAuthOperation } from "@jskit-ai/auth-core/server/unsupportedOperation";
11
- import { hashPassword, verifyPassword } from "./passwords.js";
11
+ import { normalizePasswordStrategy } from "./passwords.js";
12
12
  import { randomToken, sha256Base64url, signToken, verifySignedToken } from "./tokens.js";
13
13
 
14
14
  const ACCESS_TOKEN_COOKIE = "jskit_local_access_token";
@@ -191,7 +191,7 @@ async function maybeSendRecoveryEmail(config, recoveryUrl, email) {
191
191
  });
192
192
  }
193
193
 
194
- function createLocalAuthService({ backend, config, profileProjector = null }) {
194
+ function createLocalAuthService({ backend, config, profileProjector = null, passwordStrategy = null }) {
195
195
  if (!backend || typeof backend.withTransaction !== "function") {
196
196
  throw new Error("Local auth requires auth.local.backend with withTransaction().");
197
197
  }
@@ -199,6 +199,7 @@ function createLocalAuthService({ backend, config, profileProjector = null }) {
199
199
  throw new Error("Local auth requires a session secret.");
200
200
  }
201
201
 
202
+ const passwords = normalizePasswordStrategy(passwordStrategy);
202
203
  const isProduction = config.nodeEnv === "production";
203
204
  const profileProjectionEnabled = typeof profileProjector?.syncIdentityProfile === "function";
204
205
  const recoveryDelivery = config.smtpConfigured
@@ -284,7 +285,7 @@ function createLocalAuthService({ backend, config, profileProjector = null }) {
284
285
  const email = validateEmailInput(input.email);
285
286
  validatePasswordInput(input.password);
286
287
  const displayName = normalizeDisplayName(input.displayName, email);
287
- const password = await hashPassword(input.password);
288
+ const password = await passwords.hashPassword(input.password);
288
289
  const result = await backend.withTransaction(async (tx) => {
289
290
  const existing = await tx.users.findByEmail(email);
290
291
  if (existing) {
@@ -312,7 +313,7 @@ function createLocalAuthService({ backend, config, profileProjector = null }) {
312
313
  const password = String(input.password || "");
313
314
  return backend.withTransaction(async (tx) => {
314
315
  const user = await tx.users.findByEmail(email);
315
- if (!user || user.disabled || !(await verifyPassword(password, user.password))) {
316
+ if (!user || user.disabled || !(await passwords.verifyPassword(password, user.password))) {
316
317
  throw new AppError(401, "Invalid email or password.");
317
318
  }
318
319
  const session = await createSessionForUser(tx, user);
@@ -517,7 +518,7 @@ function createLocalAuthService({ backend, config, profileProjector = null }) {
517
518
  if (!payload?.sub || !payload?.sid) {
518
519
  throw new AppError(401, "Authentication required.");
519
520
  }
520
- const password = await hashPassword(input.password);
521
+ const password = await passwords.hashPassword(input.password);
521
522
  await backend.withTransaction(async (tx) => {
522
523
  const session = await tx.sessions.findById(String(payload.sid));
523
524
  if (!session || session.revokedAt || isExpiredIso(session.expiresAt)) {
@@ -546,13 +547,13 @@ function createLocalAuthService({ backend, config, profileProjector = null }) {
546
547
  }
547
548
 
548
549
  const currentPassword = String(input.currentPassword || "");
549
- const password = await hashPassword(input.newPassword);
550
+ const password = await passwords.hashPassword(input.newPassword);
550
551
  await backend.withTransaction(async (tx) => {
551
552
  const user = await tx.users.findById(authResult.actor.providerUserId);
552
553
  if (!user || user.disabled) {
553
554
  throw new AppError(401, "Authentication required.");
554
555
  }
555
- if (!(await verifyPassword(currentPassword, user.password))) {
556
+ if (!(await passwords.verifyPassword(currentPassword, user.password))) {
556
557
  throw new AppError(401, "Current password is invalid.");
557
558
  }
558
559
  await tx.users.updatePassword(user.id, password);
@@ -173,12 +173,24 @@ class AuthLocalServiceProvider {
173
173
  const config = resolveConfig(scope);
174
174
  const backend = scope.make("auth.local.backend");
175
175
  const profileProjector = scope.has("auth.profile.projector")
176
- ? scope.make("auth.profile.projector")
176
+ ? {
177
+ async syncIdentityProfile(profile) {
178
+ const projector = scope.make("auth.profile.projector");
179
+ if (!projector || typeof projector.syncIdentityProfile !== "function") {
180
+ throw new Error("auth.profile.projector.syncIdentityProfile() must be a function.");
181
+ }
182
+ return projector.syncIdentityProfile(profile);
183
+ }
184
+ }
185
+ : null;
186
+ const passwordStrategy = scope.has("auth.local.passwordStrategy")
187
+ ? scope.make("auth.local.passwordStrategy")
177
188
  : null;
178
189
  return createLocalAuthService({
179
190
  backend,
180
191
  config,
181
- profileProjector
192
+ profileProjector,
193
+ passwordStrategy
182
194
  });
183
195
  });
184
196
  }
@@ -8,9 +8,13 @@ import { ActionRuntimeServiceProvider } from "@jskit-ai/kernel/server/actions";
8
8
  import { AuthActionsServiceProvider } from "@jskit-ai/auth-core/server/providers/AuthActionsServiceProvider";
9
9
  import { AuthLocalServiceProvider } from "../src/server/providers/AuthLocalServiceProvider.js";
10
10
  import { AuthProviderServiceProvider } from "../src/server/providers/AuthProviderServiceProvider.js";
11
- import { createLocalFileBackend } from "../src/server/lib/fileBackend.js";
12
- import { createLocalAuthService } from "../src/server/lib/service.js";
13
- import { hashPassword } from "../src/server/lib/passwords.js";
11
+ import {
12
+ createLocalAuthService,
13
+ createLocalFileBackend,
14
+ hashPassword,
15
+ normalizePasswordStrategy,
16
+ verifyPassword
17
+ } from "../src/server/lib/index.js";
14
18
 
15
19
  function createAppConfigFixture() {
16
20
  return {
@@ -46,7 +50,7 @@ function createReplyFixture() {
46
50
  };
47
51
  }
48
52
 
49
- async function createStartedApp({ profileProjector = null } = {}) {
53
+ async function createStartedApp({ profileProjector = null, profileProjectorFactory = null, passwordStrategy = null } = {}) {
50
54
  const storeDir = await fs.mkdtemp(path.join(os.tmpdir(), "jskit-auth-local-"));
51
55
  const app = createApplication();
52
56
  app.instance("appConfig", createAppConfigFixture());
@@ -69,6 +73,12 @@ async function createStartedApp({ profileProjector = null } = {}) {
69
73
  if (profileProjector) {
70
74
  app.instance("auth.profile.projector", profileProjector);
71
75
  }
76
+ if (profileProjectorFactory) {
77
+ app.singleton("auth.profile.projector", profileProjectorFactory);
78
+ }
79
+ if (passwordStrategy) {
80
+ app.instance("auth.local.passwordStrategy", passwordStrategy);
81
+ }
72
82
  await app.start({
73
83
  providers: [
74
84
  ActionRuntimeServiceProvider,
@@ -134,6 +144,72 @@ test("local auth provider registers, logs in, reads session, and logs out with f
134
144
  assert.match(usersFile, /^user:v1:/);
135
145
  });
136
146
 
147
+ test("local auth provider resolves a custom password strategy from the container", async () => {
148
+ const passwordStrategy = {
149
+ prefix: "strategy",
150
+ async hashPassword(password) {
151
+ return {
152
+ algorithm: "test-password",
153
+ version: "v1",
154
+ salt: "",
155
+ hash: `${this.prefix}-${password}`
156
+ };
157
+ },
158
+ async verifyPassword(password, record) {
159
+ return record?.algorithm === "test-password" && record?.hash === `${this.prefix}-${password}`;
160
+ }
161
+ };
162
+ const { app } = await createStartedApp({ passwordStrategy });
163
+ const authService = app.make("authService");
164
+
165
+ await authService.register({
166
+ email: "strategy@example.com",
167
+ password: "strategy password value",
168
+ displayName: "Strategy User"
169
+ });
170
+
171
+ const loggedIn = await authService.login({
172
+ email: "strategy@example.com",
173
+ password: "strategy password value"
174
+ });
175
+
176
+ assert.equal(loggedIn.actor.email, "strategy@example.com");
177
+ });
178
+
179
+ test("local auth password strategy supports partial overrides and rejects invalid methods", async () => {
180
+ const strategy = normalizePasswordStrategy({
181
+ verifyPassword(password, record) {
182
+ return record === `legacy:${this.realm}:${password}`;
183
+ },
184
+ realm: "users"
185
+ });
186
+
187
+ const defaultHashed = await strategy.hashPassword("new password value");
188
+ assert.equal(await verifyPassword("new password value", defaultHashed), true);
189
+ assert.equal(await strategy.verifyPassword("old password value", "legacy:users:old password value"), true);
190
+
191
+ assert.throws(
192
+ () => normalizePasswordStrategy("invalid"),
193
+ /Local auth password strategy must be an object/
194
+ );
195
+ assert.throws(
196
+ () => normalizePasswordStrategy([]),
197
+ /Local auth password strategy must be an object/
198
+ );
199
+ assert.throws(
200
+ () => normalizePasswordStrategy({ hashPassword: "invalid" }),
201
+ /Local auth password strategy hashPassword must be a function/
202
+ );
203
+ assert.throws(
204
+ () => normalizePasswordStrategy({ hashPassword: null }),
205
+ /Local auth password strategy hashPassword must be a function/
206
+ );
207
+ assert.throws(
208
+ () => normalizePasswordStrategy({ verifyPassword: "invalid" }),
209
+ /Local auth password strategy verifyPassword must be a function/
210
+ );
211
+ });
212
+
137
213
  test("local auth login verifies password and creates session in one backend transaction", async () => {
138
214
  const password = await hashPassword("current password value");
139
215
  let transactions = 0;
@@ -190,6 +266,102 @@ test("local auth login verifies password and creates session in one backend tran
190
266
  assert.equal(transactions, 1);
191
267
  });
192
268
 
269
+ test("local auth service accepts a custom strategy for legacy stored password records", async () => {
270
+ const usersByEmail = new Map([
271
+ [
272
+ "legacy@example.com",
273
+ {
274
+ id: "usr_legacy",
275
+ email: "legacy@example.com",
276
+ displayName: "Legacy User",
277
+ password: {
278
+ algorithm: "legacy-bcrypt",
279
+ hash: "legacy password value"
280
+ },
281
+ disabled: false
282
+ }
283
+ ]
284
+ ]);
285
+ const sessions = [];
286
+ const backend = {
287
+ async withTransaction(callback) {
288
+ const tx = {
289
+ users: {
290
+ async findByEmail(email) {
291
+ return usersByEmail.get(email) || null;
292
+ },
293
+ async create(input) {
294
+ const user = {
295
+ ...input,
296
+ disabled: false
297
+ };
298
+ usersByEmail.set(user.email, user);
299
+ return user;
300
+ }
301
+ },
302
+ sessions: {
303
+ async create(input) {
304
+ const session = {
305
+ ...input,
306
+ createdAt: new Date().toISOString(),
307
+ revokedAt: ""
308
+ };
309
+ sessions.push(session);
310
+ return session;
311
+ }
312
+ }
313
+ };
314
+ return callback(tx);
315
+ }
316
+ };
317
+ const passwordStrategy = {
318
+ async hashPassword(password) {
319
+ return {
320
+ algorithm: "test-scrypt",
321
+ version: "v1",
322
+ salt: "test",
323
+ hash: `hashed-${password}`
324
+ };
325
+ },
326
+ async verifyPassword(password, record) {
327
+ if (record?.algorithm === "legacy-bcrypt") {
328
+ return record.hash === password;
329
+ }
330
+ return record?.algorithm === "test-scrypt" && record.hash === `hashed-${password}`;
331
+ }
332
+ };
333
+ const authService = createLocalAuthService({
334
+ backend,
335
+ passwordStrategy,
336
+ config: {
337
+ nodeEnv: "test",
338
+ sessionSecret: "test-secret",
339
+ appPublicUrl: "http://localhost:5173",
340
+ smtpConfigured: false,
341
+ recoveryDevOutput: "disabled"
342
+ }
343
+ });
344
+
345
+ const legacyLogin = await authService.login({
346
+ email: "legacy@example.com",
347
+ password: "legacy password value"
348
+ });
349
+ assert.equal(legacyLogin.actor.email, "legacy@example.com");
350
+
351
+ await authService.register({
352
+ email: "new-strategy@example.com",
353
+ password: "new password value",
354
+ displayName: "New Strategy"
355
+ });
356
+ assert.deepEqual(usersByEmail.get("new-strategy@example.com").password, {
357
+ algorithm: "test-scrypt",
358
+ version: "v1",
359
+ salt: "test",
360
+ hash: "hashed-new password value"
361
+ });
362
+ assert.equal(sessions.length, 2);
363
+ });
364
+
193
365
  test("local auth provider completes recovery through a recovery-scoped session", async () => {
194
366
  const { app } = await createStartedApp();
195
367
  const authService = app.make("authService");
@@ -349,6 +521,42 @@ test("local auth provider changes password through the account-security contract
349
521
  assert.equal(login.actor.email, "lin@example.com");
350
522
  });
351
523
 
524
+ test("local auth provider defers profile projector resolution until projection is needed", async () => {
525
+ let projectorResolved = 0;
526
+ let projectionCalls = 0;
527
+ const { app } = await createStartedApp({
528
+ profileProjectorFactory: () => {
529
+ projectorResolved += 1;
530
+ return {
531
+ async syncIdentityProfile(profile) {
532
+ projectionCalls += 1;
533
+ return {
534
+ ...profile,
535
+ id: "app-profile-id",
536
+ profileSource: "users"
537
+ };
538
+ }
539
+ };
540
+ }
541
+ });
542
+
543
+ assert.equal(projectorResolved, 0);
544
+
545
+ const authService = app.make("authService");
546
+ assert.equal(authService.getCapabilities().features.appProfileProjection, true);
547
+ assert.equal(projectorResolved, 0);
548
+
549
+ const registered = await authService.register({
550
+ email: "projector@example.com",
551
+ password: "projector password value",
552
+ displayName: "Projector User"
553
+ });
554
+
555
+ assert.equal(projectorResolved, 1);
556
+ assert.equal(projectionCalls, 1);
557
+ assert.equal(registered.actor.appUserId, "app-profile-id");
558
+ });
559
+
352
560
  test("local auth provider projects app profile when auth.profile.projector is installed", async () => {
353
561
  const projectedProfiles = [];
354
562
  const { app } = await createStartedApp({