@jskit-ai/auth-provider-local-core 0.1.84 → 0.1.85

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/auth-provider-local-core",
3
- "version": "0.1.84",
3
+ "version": "0.1.85",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -9,9 +9,6 @@
9
9
  "./server/providers/AuthLocalProvider": "./src/server/providers/AuthLocalProvider.js",
10
10
  "./server/lib/index": "./src/server/lib/index.js"
11
11
  },
12
- "dependencies": {
13
- "nodemailer": "^9.0.3"
14
- },
15
12
  "description": "Local auth provider with a file backend default and no database requirement.",
16
13
  "jskit": {
17
14
  "kind": "runtime",
@@ -53,13 +50,14 @@
53
50
  "auth.invitation-context",
54
51
  "auth.local.backend",
55
52
  "auth.local.password-strategy",
56
- "auth.profile.projector"
53
+ "auth.profile.projector",
54
+ "auth.local.recovery-sender"
57
55
  ]
58
56
  }
59
57
  }
60
58
  },
61
59
  "peerDependencies": {
62
- "@jskit-ai/auth-core": "0.1.181",
63
- "@jskit-ai/kernel": "0.1.183"
60
+ "@jskit-ai/auth-core": "0.1.182",
61
+ "@jskit-ai/kernel": "0.1.184"
64
62
  }
65
63
  }
@@ -257,37 +257,13 @@ function normalizeInvitationInput(value = null) {
257
257
  };
258
258
  }
259
259
 
260
- async function maybeSendRecoveryEmail(config, recoveryUrl, email) {
261
- if (!config.smtpConfigured) {
262
- return;
263
- }
264
- const nodemailer = await import("nodemailer");
265
- const transport = nodemailer.createTransport({
266
- host: config.smtp.host,
267
- port: config.smtp.port,
268
- secure: config.smtp.secure,
269
- auth: config.smtp.user
270
- ? {
271
- user: config.smtp.user,
272
- pass: config.smtp.password
273
- }
274
- : undefined
275
- });
276
- await transport.sendMail({
277
- from: config.smtp.from,
278
- replyTo: config.smtp.replyTo || undefined,
279
- to: email,
280
- subject: "Reset your password",
281
- text: `Open this link to reset your password:\n\n${recoveryUrl}\n`
282
- });
283
- }
284
-
285
260
  function createLocalAuthService({
286
261
  backend,
287
262
  config,
288
263
  profileProjector = null,
289
264
  passwordStrategy = null,
290
- invitationContextResolver = null
265
+ invitationContextResolver = null,
266
+ recoverySender = null
291
267
  }) {
292
268
  if (!backend || typeof backend.withTransaction !== "function") {
293
269
  throw new Error("Local auth requires auth.local.backend with withTransaction().");
@@ -296,6 +272,10 @@ function createLocalAuthService({
296
272
  throw new Error("Local auth requires a session secret.");
297
273
  }
298
274
 
275
+ if (recoverySender !== null && typeof recoverySender !== "function") {
276
+ throw new Error("auth.local.recovery-sender must be a function.");
277
+ }
278
+
299
279
  const passwords = normalizePasswordStrategy(passwordStrategy);
300
280
  const isProduction = config.nodeEnv === "production";
301
281
  const devAuth = config.devAuth || resolveDevAuthPolicy({
@@ -303,8 +283,8 @@ function createLocalAuthService({
303
283
  });
304
284
  assertDevAuthPolicy(devAuth);
305
285
  const profileProjectionEnabled = typeof profileProjector?.syncIdentityProfile === "function";
306
- const recoveryDelivery = config.smtpConfigured
307
- ? "smtp"
286
+ const recoveryDelivery = recoverySender
287
+ ? "email"
308
288
  : isProduction
309
289
  ? "disabled"
310
290
  : config.recoveryDevOutput === "response"
@@ -639,15 +619,15 @@ function createLocalAuthService({
639
619
  });
640
620
  });
641
621
  if (recoveryUrl) {
642
- await maybeSendRecoveryEmail(config, recoveryUrl, email);
643
- if (!config.smtpConfigured && config.recoveryDevOutput === "log" && config.logger?.info) {
622
+ if (recoverySender) await recoverySender({ email, recoveryUrl });
623
+ if (!recoverySender && config.recoveryDevOutput === "log" && config.logger?.info) {
644
624
  config.logger.info({ recoveryUrl, email }, "Local auth password recovery URL created.");
645
625
  }
646
626
  }
647
627
  return {
648
628
  ok: true,
649
629
  message: "If an account exists for that email, password reset instructions have been sent.",
650
- ...(recoveryUrl && !config.smtpConfigured && config.recoveryDevOutput === "response" ? { recoveryUrl } : {})
630
+ ...(recoveryUrl && !recoverySender && config.recoveryDevOutput === "response" ? { recoveryUrl } : {})
651
631
  };
652
632
  }
653
633
 
@@ -35,62 +35,41 @@ function resolveSessionSecret(env, { storeDir, isProduction }) {
35
35
  return generated;
36
36
  }
37
37
 
38
- function resolveSmtpConfig(env) {
39
- const host = String(env.AUTH_LOCAL_SMTP_HOST || "").trim();
40
- if (!host) return { smtpConfigured: false, smtp: null };
41
- const from = String(env.AUTH_LOCAL_SMTP_FROM || "").trim();
42
- if (!from) throw new Error("AUTH_LOCAL_SMTP_FROM is required when AUTH_LOCAL_SMTP_HOST is set.");
43
- return {
44
- smtpConfigured: true,
45
- smtp: {
46
- host,
47
- port: Number(env.AUTH_LOCAL_SMTP_PORT || 587),
48
- secure: parseBooleanFlag(env.AUTH_LOCAL_SMTP_SECURE, false),
49
- user: String(env.AUTH_LOCAL_SMTP_USER || "").trim(),
50
- password: String(env.AUTH_LOCAL_SMTP_PASSWORD || ""),
51
- from,
52
- replyTo: String(env.AUTH_LOCAL_SMTP_REPLY_TO || "").trim()
53
- }
54
- };
55
- }
56
-
57
- function resolveAppPublicUrl(env, { smtpConfigured }) {
38
+ function resolveAppPublicUrl(env, { recoverySender }) {
58
39
  const configured = String(env.APP_PUBLIC_URL || "").trim();
59
40
  if (!configured) {
60
- if (smtpConfigured) throw new Error("APP_PUBLIC_URL is required when local auth SMTP recovery is configured.");
41
+ if (recoverySender) throw new Error("APP_PUBLIC_URL is required when local auth email recovery is configured.");
61
42
  return "http://localhost:5173";
62
43
  }
63
44
  let parsed;
64
45
  try {
65
46
  parsed = new URL(configured);
66
47
  } catch {
67
- throw new Error("APP_PUBLIC_URL must be a valid URL when local auth SMTP recovery is configured.");
48
+ throw new Error("APP_PUBLIC_URL must be a valid URL when local auth email recovery is configured.");
68
49
  }
69
50
  if (!["http:", "https:"].includes(parsed.protocol)) {
70
- throw new Error("APP_PUBLIC_URL must use http or https when local auth SMTP recovery is configured.");
51
+ throw new Error("APP_PUBLIC_URL must use http or https when local auth email recovery is configured.");
71
52
  }
72
53
  return parsed.toString().replace(/\/$/u, "");
73
54
  }
74
55
 
75
- function resolveConfig({ appRoot, backend, env, logger }) {
56
+ function resolveConfig({ appRoot, backend, env, logger, recoverySender = null }) {
76
57
  const nodeEnv = String(env.NODE_ENV || "development").trim() || "development";
77
58
  const isProduction = nodeEnv === "production";
78
59
  const storeDir = resolveStoreDir(env, appRoot);
79
60
  if (!backend && isProduction && !parseBooleanFlag(env.AUTH_LOCAL_FILE_PRODUCTION_ACK, false)) {
80
61
  throw new Error("AUTH_LOCAL_FILE_PRODUCTION_ACK is required to use the local file auth backend in production.");
81
62
  }
82
- const smtp = resolveSmtpConfig(env);
83
63
  const devAuth = resolveDevAuthPolicyFromEnv(env);
84
64
  assertDevAuthPolicy(devAuth);
85
65
  return {
86
66
  storeDir,
87
67
  nodeEnv,
88
68
  logger,
89
- appPublicUrl: resolveAppPublicUrl(env, smtp),
69
+ appPublicUrl: resolveAppPublicUrl(env, { recoverySender }),
90
70
  sessionSecret: resolveSessionSecret(env, { storeDir, isProduction }),
91
71
  recoveryDevOutput: String(env.AUTH_LOCAL_RECOVERY_DEV_OUTPUT || "log").trim().toLowerCase() || "log",
92
- devAuth,
93
- ...smtp
72
+ devAuth
94
73
  };
95
74
  }
96
75
 
@@ -104,7 +83,8 @@ const AuthLocalProvider = defineProvider({
104
83
  },
105
84
  optional: {
106
85
  backend: "auth.local.backend",
107
- passwordStrategy: "auth.local.password-strategy"
86
+ passwordStrategy: "auth.local.password-strategy",
87
+ recoverySender: "auth.local.recovery-sender"
108
88
  },
109
89
  provides: {
110
90
  authService: "auth.service"
@@ -113,13 +93,14 @@ const AuthLocalProvider = defineProvider({
113
93
  return { authService: createDeferredAuthService() };
114
94
  },
115
95
  boot(dependencies, { outputs }) {
116
- const { appRoot, backend, env, extensions, logger, passwordStrategy } = dependencies;
117
- const config = resolveConfig({ appRoot, backend, env, logger });
96
+ const { appRoot, backend, env, extensions, logger, passwordStrategy, recoverySender = null } = dependencies;
97
+ const config = resolveConfig({ appRoot, backend, env, logger, recoverySender });
118
98
  const baseService = createLocalAuthService({
119
99
  backend: backend || createLocalFileBackend({ storeDir: config.storeDir }),
120
100
  config,
121
101
  profileProjector: extensions.resolveProfileProjector(),
122
102
  passwordStrategy,
103
+ recoverySender,
123
104
  invitationContextResolver: extensions.resolveInvitationContextResolver()
124
105
  });
125
106
  attachDeferredAuthService(outputs.authService, extensions.decorateService(baseService));
@@ -9,6 +9,7 @@ test("local auth has no package mutation or CI recipe", () => {
9
9
  assert.equal(Object.hasOwn(packageMetadata, "mutations"), false);
10
10
  });
11
11
 
12
- test("local auth declares Nodemailer as its own runtime dependency", () => {
13
- assert.equal(typeof packageJson.dependencies.nodemailer, "string");
12
+ test("local auth delegates recovery delivery to the application", () => {
13
+ assert.equal(packageJson.dependencies?.nodemailer, undefined);
14
+ assert.ok(packageMetadata.metadata.apiSummary.extensionCapabilities.includes("auth.local.recovery-sender"));
14
15
  });
@@ -77,6 +77,7 @@ async function createStartedApp({
77
77
  env = {},
78
78
  profileProjector = null,
79
79
  passwordStrategy = null,
80
+ recoverySender = null,
80
81
  invitationContextResolver = null,
81
82
  logger = null,
82
83
  configureExtensions = null
@@ -130,6 +131,7 @@ async function createStartedApp({
130
131
  "auth.extensions": extensions,
131
132
  "auth.local.backend": backend,
132
133
  ...(passwordStrategy ? { "auth.local.password-strategy": passwordStrategy } : {}),
134
+ ...(recoverySender !== null ? { "auth.local.recovery-sender": recoverySender } : {}),
133
135
  };
134
136
  const runtime = createCapabilityRuntime({
135
137
  inputs,
@@ -577,7 +579,6 @@ test("local auth login verifies password and creates session in one backend tran
577
579
  config: {
578
580
  sessionSecret: "test-secret",
579
581
  nodeEnv: "test",
580
- smtpConfigured: false,
581
582
  recoveryDevOutput: "response",
582
583
  appPublicUrl: "http://localhost:5173"
583
584
  }
@@ -683,7 +684,6 @@ test("local auth service accepts a custom stored-password format", async () => {
683
684
  config: {
684
685
  sessionSecret: "test-secret",
685
686
  nodeEnv: "test",
686
- smtpConfigured: false,
687
687
  recoveryDevOutput: "response",
688
688
  appPublicUrl: "http://localhost:5173"
689
689
  },
@@ -1055,16 +1055,13 @@ test("local auth provider passes resolved invitation context into profile projec
1055
1055
  assert.equal(registered.actor.appUserId, "invited-app-user");
1056
1056
  });
1057
1057
 
1058
- test("local auth provider requires an explicit public URL for SMTP recovery", async () => {
1058
+ test("local auth provider requires an explicit public URL for email recovery", async () => {
1059
1059
  await assert.rejects(
1060
1060
  () => createStartedApp({
1061
- env: {
1062
- APP_PUBLIC_URL: "",
1063
- AUTH_LOCAL_SMTP_HOST: "smtp.example.com",
1064
- AUTH_LOCAL_SMTP_FROM: "support@example.com"
1065
- }
1061
+ env: { APP_PUBLIC_URL: "" },
1062
+ recoverySender: async () => {}
1066
1063
  }),
1067
- /APP_PUBLIC_URL is required when local auth SMTP recovery is configured/
1064
+ /APP_PUBLIC_URL is required when local auth email recovery is configured/
1068
1065
  );
1069
1066
  });
1070
1067
 
@@ -1152,3 +1149,42 @@ test("local file backend replays a pending multi-file transaction journal before
1152
1149
  assert.equal(recovered.recovery.id, "rec_journal");
1153
1150
  await assert.rejects(() => fs.stat(path.join(storeDir, "transaction.journal")), { code: "ENOENT" });
1154
1151
  });
1152
+
1153
+ test("local recovery uses the application sender without returning or logging its token", async (t) => {
1154
+ const messages = [];
1155
+ const logs = [];
1156
+ const app = await createStartedApp({
1157
+ env: { NODE_ENV: "production", AUTH_LOCAL_SESSION_SECRET: "fixture-session-secret" },
1158
+ recoverySender: async (message) => { messages.push(message); },
1159
+ logger: { info: (...args) => logs.push(args), warn() {}, error() {}, debug() {} }
1160
+ });
1161
+ t.after(() => fs.rm(app.storeDir, { recursive: true, force: true }));
1162
+ await app.authService.register({ email: "sender@example.com", password: "correct horse battery staple" });
1163
+ assert.equal(app.authService.getCapabilities().features.passwordRecovery.delivery, "email");
1164
+ const result = await app.authService.requestPasswordReset({ email: "sender@example.com" });
1165
+ assert.equal(result.recoveryUrl, undefined);
1166
+ assert.equal(messages.length, 1);
1167
+ assert.equal(messages[0].email, "sender@example.com");
1168
+ assert.match(messages[0].recoveryUrl, /^http:\/\/localhost:5173\/auth\/reset-password\?/);
1169
+ assert.equal(JSON.stringify(logs).includes(messages[0].recoveryUrl), false);
1170
+ const unknown = await app.authService.requestPasswordReset({ email: "absent@example.com" });
1171
+ assert.deepEqual(unknown, result);
1172
+ assert.equal(messages.length, 1);
1173
+ const token = new URL(messages[0].recoveryUrl).searchParams.get("token");
1174
+ const recovery = await app.authService.completePasswordRecovery({ code: token, type: "recovery" });
1175
+ assert.equal(recovery.actor.email, "sender@example.com");
1176
+ });
1177
+
1178
+ test("a failing recovery sender does not fall back to development output", async (t) => {
1179
+ const logs = [];
1180
+ const failure = new Error("delivery unavailable");
1181
+ const app = await createStartedApp({
1182
+ env: { AUTH_LOCAL_RECOVERY_DEV_OUTPUT: "log" },
1183
+ recoverySender: async () => { throw failure; },
1184
+ logger: { info: (...args) => logs.push(args), warn() {}, error() {}, debug() {} }
1185
+ });
1186
+ t.after(() => fs.rm(app.storeDir, { recursive: true, force: true }));
1187
+ await app.authService.register({ email: "failure@example.com", password: "correct horse battery staple" });
1188
+ await assert.rejects(app.authService.requestPasswordReset({ email: "failure@example.com" }), failure);
1189
+ assert.equal(logs.some((args) => JSON.stringify(args).includes("recoveryUrl")), false);
1190
+ });