@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.
|
|
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.
|
|
63
|
-
"@jskit-ai/kernel": "0.1.
|
|
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 =
|
|
307
|
-
? "
|
|
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
|
|
643
|
-
if (!
|
|
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 && !
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
13
|
-
assert.equal(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|