knowless 0.1.0
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/CHANGELOG.md +142 -0
- package/GUIDE.md +441 -0
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +167 -0
- package/knowless.context.md +429 -0
- package/package.json +48 -0
- package/src/abuse.js +80 -0
- package/src/form.js +102 -0
- package/src/handle.js +51 -0
- package/src/handlers.js +383 -0
- package/src/index.js +132 -0
- package/src/mailer.js +156 -0
- package/src/session.js +75 -0
- package/src/store.js +265 -0
- package/src/token.js +38 -0
package/src/handle.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Email syntax accepted by knowless. Strict ASCII-only; no quoted-locals,
|
|
5
|
+
* no IP-literal domains, no IDN. See SPEC §2.1.
|
|
6
|
+
*/
|
|
7
|
+
const EMAIL_REGEX = /^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalize an email address per SPEC §2.1. Trim, ASCII-lowercase, reject
|
|
11
|
+
* non-ASCII, validate against the strict regex.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} input
|
|
14
|
+
* @returns {string} normalized, validated, lowercase ASCII email
|
|
15
|
+
* @throws {Error} on any invalid input — caller treats as silent miss
|
|
16
|
+
*/
|
|
17
|
+
export function normalize(input) {
|
|
18
|
+
if (typeof input !== 'string') throw new Error('invalid email');
|
|
19
|
+
const trimmed = input.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, '');
|
|
20
|
+
if (trimmed.length === 0 || trimmed.length > 254) throw new Error('invalid email');
|
|
21
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
22
|
+
if (trimmed.charCodeAt(i) > 0x7f) throw new Error('invalid email');
|
|
23
|
+
}
|
|
24
|
+
const lowered = trimmed.toLowerCase();
|
|
25
|
+
if (!EMAIL_REGEX.test(lowered)) throw new Error('invalid email');
|
|
26
|
+
return lowered;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Derive the opaque handle for a normalized email using the operator secret.
|
|
31
|
+
* HMAC-SHA256, lowercase hex output, 64 chars. See SPEC §3.
|
|
32
|
+
*
|
|
33
|
+
* The handle is grandfathered without a domain-separation tag. Any future
|
|
34
|
+
* HMAC use of `secret` MUST add a tag prefix (see SPEC §3.4).
|
|
35
|
+
*
|
|
36
|
+
* @param {string} emailNormalized output of normalize()
|
|
37
|
+
* @param {Buffer|string} secret operator HMAC secret
|
|
38
|
+
* @returns {string} 64-char lowercase hex handle
|
|
39
|
+
*/
|
|
40
|
+
export function deriveHandle(emailNormalized, secret) {
|
|
41
|
+
if (typeof emailNormalized !== 'string' || emailNormalized.length === 0) {
|
|
42
|
+
throw new Error('emailNormalized required');
|
|
43
|
+
}
|
|
44
|
+
if (!secret || (typeof secret !== 'string' && !Buffer.isBuffer(secret))) {
|
|
45
|
+
throw new Error('secret required');
|
|
46
|
+
}
|
|
47
|
+
return crypto
|
|
48
|
+
.createHmac('sha256', secret)
|
|
49
|
+
.update(emailNormalized, 'utf8')
|
|
50
|
+
.digest('hex');
|
|
51
|
+
}
|
package/src/handlers.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { normalize, deriveHandle } from './handle.js';
|
|
3
|
+
import { issueToken, hashToken } from './token.js';
|
|
4
|
+
import { newSid, signSession, verifySessionSignature } from './session.js';
|
|
5
|
+
import { composeBody } from './mailer.js';
|
|
6
|
+
import { renderLoginForm } from './form.js';
|
|
7
|
+
import {
|
|
8
|
+
determineSourceIp,
|
|
9
|
+
rateLimitExceeded,
|
|
10
|
+
rateLimitIncrement,
|
|
11
|
+
} from './abuse.js';
|
|
12
|
+
|
|
13
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
const DEFAULTS = {
|
|
16
|
+
cookieName: 'knowless_session',
|
|
17
|
+
linkPath: '/auth/callback',
|
|
18
|
+
loginPath: '/login',
|
|
19
|
+
verifyPath: '/verify',
|
|
20
|
+
logoutPath: '/logout',
|
|
21
|
+
tokenTtlSeconds: 900,
|
|
22
|
+
sessionTtlSeconds: 30 * 24 * 60 * 60,
|
|
23
|
+
subject: 'Sign in',
|
|
24
|
+
confirmationMessage:
|
|
25
|
+
'Thanks. If <strong>{email}</strong> is registered, a sign-in link is on its way. Check your inbox in a few minutes.',
|
|
26
|
+
includeLastLoginInEmail: true,
|
|
27
|
+
openRegistration: false,
|
|
28
|
+
maxActiveTokensPerHandle: 5,
|
|
29
|
+
maxLoginRequestsPerIpPerHour: 30,
|
|
30
|
+
maxNewHandlesPerIpPerHour: 3,
|
|
31
|
+
honeypotFieldName: 'website',
|
|
32
|
+
shamRecipient: 'null@knowless.invalid',
|
|
33
|
+
trustedProxies: ['127.0.0.1', '::1', '::ffff:127.0.0.1'],
|
|
34
|
+
failureRedirect: null,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read a request body up to maxBytes. Returns the UTF-8 string.
|
|
39
|
+
* Resolves with '' if the request never sent any data and ended.
|
|
40
|
+
*/
|
|
41
|
+
function readBody(req, maxBytes = 65_536) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
let total = 0;
|
|
44
|
+
const chunks = [];
|
|
45
|
+
req.on('data', (c) => {
|
|
46
|
+
total += c.length;
|
|
47
|
+
if (total > maxBytes) {
|
|
48
|
+
req.destroy(new Error('body too large'));
|
|
49
|
+
reject(new Error('body too large'));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
chunks.push(c);
|
|
53
|
+
});
|
|
54
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
55
|
+
req.on('error', reject);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseBody(raw, contentType) {
|
|
60
|
+
const ct = (contentType || '').split(';')[0].trim().toLowerCase();
|
|
61
|
+
if (ct === 'application/json') {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
65
|
+
} catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (ct === 'application/x-www-form-urlencoded') {
|
|
70
|
+
return Object.fromEntries(new URLSearchParams(raw));
|
|
71
|
+
}
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getCookie(req, name) {
|
|
76
|
+
const header = req.headers?.cookie;
|
|
77
|
+
if (!header) return null;
|
|
78
|
+
for (const part of header.split(';')) {
|
|
79
|
+
const trimmed = part.trim();
|
|
80
|
+
const eq = trimmed.indexOf('=');
|
|
81
|
+
if (eq < 0) continue;
|
|
82
|
+
if (trimmed.slice(0, eq) === name) return trimmed.slice(eq + 1);
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate the `next` URL per SPEC §11.2.
|
|
89
|
+
* @param {string|null|undefined} rawNext
|
|
90
|
+
* @param {string} baseUrl
|
|
91
|
+
* @param {string} cookieDomain
|
|
92
|
+
* @returns {string|null} canonical URL string, or null
|
|
93
|
+
*/
|
|
94
|
+
export function validateNextUrl(rawNext, baseUrl, cookieDomain) {
|
|
95
|
+
if (typeof rawNext !== 'string' || rawNext.length === 0 || rawNext.length > 2048) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = new URL(rawNext, baseUrl);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null;
|
|
105
|
+
const host = parsed.hostname.toLowerCase();
|
|
106
|
+
const dom = cookieDomain.toLowerCase();
|
|
107
|
+
if (host === dom || host.endsWith('.' + dom)) return parsed.toString();
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sidHashOf(sid) {
|
|
112
|
+
return crypto.createHash('sha256').update(Buffer.from(sid, 'base64url')).digest('hex');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build the knowless HTTP handlers. Each handler is framework-agnostic:
|
|
117
|
+
* (req, res) => Promise<void> | void, where req/res match node:http.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} args
|
|
120
|
+
* @param {object} args.store knowless store (from createStore)
|
|
121
|
+
* @param {object} args.mailer knowless mailer (from createMailer)
|
|
122
|
+
* @param {object} args.config merged config; see DEFAULTS for missing keys
|
|
123
|
+
* @returns {{
|
|
124
|
+
* login: (req:any,res:any)=>Promise<void>,
|
|
125
|
+
* callback: (req:any,res:any)=>Promise<void>,
|
|
126
|
+
* verify: (req:any,res:any)=>void,
|
|
127
|
+
* logout: (req:any,res:any)=>Promise<void>,
|
|
128
|
+
* loginForm: (req:any,res:any)=>void,
|
|
129
|
+
* validateNextUrl: (raw:string)=>string|null
|
|
130
|
+
* }}
|
|
131
|
+
*/
|
|
132
|
+
export function createHandlers({ store, mailer, config }) {
|
|
133
|
+
const cfg = { ...DEFAULTS, ...config };
|
|
134
|
+
if (!cfg.secret) throw new Error('config.secret required');
|
|
135
|
+
if (typeof cfg.secret !== 'string' || cfg.secret.length < 64) {
|
|
136
|
+
throw new Error('config.secret must be ≥64 hex chars (32 bytes)');
|
|
137
|
+
}
|
|
138
|
+
if (!cfg.baseUrl) throw new Error('config.baseUrl required');
|
|
139
|
+
if (!cfg.from) throw new Error('config.from required');
|
|
140
|
+
if (!cfg.cookieDomain) {
|
|
141
|
+
try {
|
|
142
|
+
cfg.cookieDomain = new URL(cfg.baseUrl).hostname;
|
|
143
|
+
} catch {
|
|
144
|
+
throw new Error('config.baseUrl invalid');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const trustedProxies = new Set(cfg.trustedProxies);
|
|
149
|
+
|
|
150
|
+
function sameResponse(res, echoedEmail, next) {
|
|
151
|
+
const html = renderLoginForm({
|
|
152
|
+
loginPath: cfg.loginPath,
|
|
153
|
+
honeypotName: cfg.honeypotFieldName,
|
|
154
|
+
confirmationMessage: cfg.confirmationMessage,
|
|
155
|
+
echoedEmail: echoedEmail ?? '',
|
|
156
|
+
next: typeof next === 'string' ? next : '',
|
|
157
|
+
});
|
|
158
|
+
res.statusCode = 200;
|
|
159
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
160
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
161
|
+
res.end(html);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function failureRedirect(res) {
|
|
165
|
+
res.statusCode = 302;
|
|
166
|
+
res.setHeader('Location', cfg.failureRedirect ?? cfg.loginPath);
|
|
167
|
+
res.end();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function login(req, res) {
|
|
171
|
+
let raw;
|
|
172
|
+
try {
|
|
173
|
+
raw = await readBody(req);
|
|
174
|
+
} catch {
|
|
175
|
+
sameResponse(res, '', '');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const body = parseBody(raw, req.headers['content-type']);
|
|
179
|
+
const emailRaw = typeof body.email === 'string' ? body.email : '';
|
|
180
|
+
const honeypot = body[cfg.honeypotFieldName];
|
|
181
|
+
const nextRaw = body.next;
|
|
182
|
+
|
|
183
|
+
// Step 1: parse + normalize
|
|
184
|
+
let emailNorm;
|
|
185
|
+
try {
|
|
186
|
+
emailNorm = normalize(emailRaw);
|
|
187
|
+
} catch {
|
|
188
|
+
sameResponse(res, emailRaw, nextRaw);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Step 2: honeypot — exempt short-circuit (no sham work)
|
|
193
|
+
if (typeof honeypot === 'string' && honeypot.length > 0) {
|
|
194
|
+
sameResponse(res, emailNorm, nextRaw);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Step 3: per-IP rate limit on /login — exempt short-circuit
|
|
199
|
+
const ip = determineSourceIp(req, trustedProxies);
|
|
200
|
+
if (
|
|
201
|
+
rateLimitExceeded(
|
|
202
|
+
store,
|
|
203
|
+
'login_ip',
|
|
204
|
+
ip,
|
|
205
|
+
cfg.maxLoginRequestsPerIpPerHour,
|
|
206
|
+
HOUR_MS,
|
|
207
|
+
)
|
|
208
|
+
) {
|
|
209
|
+
sameResponse(res, emailNorm, nextRaw);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- Equivalent-work region begins (SPEC §7.3 step 4) ----
|
|
214
|
+
const handle = deriveHandle(emailNorm, cfg.secret);
|
|
215
|
+
const nextValidated = validateNextUrl(nextRaw, cfg.baseUrl, cfg.cookieDomain);
|
|
216
|
+
const exists = store.handleExists(handle);
|
|
217
|
+
let isCreating = !exists && cfg.openRegistration;
|
|
218
|
+
|
|
219
|
+
if (isCreating) {
|
|
220
|
+
if (
|
|
221
|
+
rateLimitExceeded(
|
|
222
|
+
store,
|
|
223
|
+
'create_ip',
|
|
224
|
+
ip,
|
|
225
|
+
cfg.maxNewHandlesPerIpPerHour,
|
|
226
|
+
HOUR_MS,
|
|
227
|
+
)
|
|
228
|
+
) {
|
|
229
|
+
// Cap exceeded — fall through to sham, do NOT short-circuit.
|
|
230
|
+
isCreating = false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const expiresAt = Date.now() + cfg.tokenTtlSeconds * 1000;
|
|
235
|
+
const token = issueToken();
|
|
236
|
+
|
|
237
|
+
let toAddress;
|
|
238
|
+
let lastLoginAt = null;
|
|
239
|
+
let isSham;
|
|
240
|
+
|
|
241
|
+
if (exists || isCreating) {
|
|
242
|
+
if (isCreating) store.upsertHandle(handle);
|
|
243
|
+
isSham = false;
|
|
244
|
+
toAddress = emailNorm;
|
|
245
|
+
if (cfg.includeLastLoginInEmail) {
|
|
246
|
+
lastLoginAt = store.getLastLogin(handle);
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
isSham = true;
|
|
250
|
+
toAddress = cfg.shamRecipient;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
store.insertToken({
|
|
254
|
+
tokenHash: token.hash,
|
|
255
|
+
handle,
|
|
256
|
+
expiresAt,
|
|
257
|
+
nextUrl: nextValidated,
|
|
258
|
+
isSham,
|
|
259
|
+
maxActive: cfg.maxActiveTokensPerHandle,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const mailBody = composeBody({
|
|
263
|
+
tokenRaw: token.raw,
|
|
264
|
+
baseUrl: cfg.baseUrl,
|
|
265
|
+
linkPath: cfg.linkPath,
|
|
266
|
+
lastLoginAt,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
await mailer.submit({ to: toAddress, subject: cfg.subject, body: mailBody });
|
|
271
|
+
} catch (err) {
|
|
272
|
+
// Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
|
|
273
|
+
console.error('[knowless] mail submit failed:', err.message);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
rateLimitIncrement(store, 'login_ip', ip, HOUR_MS);
|
|
277
|
+
if (isCreating) rateLimitIncrement(store, 'create_ip', ip, HOUR_MS);
|
|
278
|
+
|
|
279
|
+
sameResponse(res, emailNorm, nextValidated ?? '');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function callback(req, res) {
|
|
283
|
+
const url = new URL(req.url, cfg.baseUrl);
|
|
284
|
+
const rawToken = url.searchParams.get('t');
|
|
285
|
+
const hash = hashToken(rawToken);
|
|
286
|
+
if (!hash) {
|
|
287
|
+
failureRedirect(res);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const row = store.getToken(hash);
|
|
291
|
+
if (
|
|
292
|
+
!row ||
|
|
293
|
+
row.usedAt != null ||
|
|
294
|
+
row.expiresAt <= Date.now() ||
|
|
295
|
+
row.isSham === true
|
|
296
|
+
) {
|
|
297
|
+
failureRedirect(res);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!store.markTokenUsed(hash, Date.now())) {
|
|
301
|
+
// Lost a race with a concurrent redemption.
|
|
302
|
+
failureRedirect(res);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
store.upsertLastLogin(row.handle, Date.now());
|
|
306
|
+
|
|
307
|
+
const sid = newSid();
|
|
308
|
+
const expiresAt = Date.now() + cfg.sessionTtlSeconds * 1000;
|
|
309
|
+
store.insertSession(sidHashOf(sid), row.handle, expiresAt);
|
|
310
|
+
const cookie = signSession(sid, cfg.secret);
|
|
311
|
+
|
|
312
|
+
res.statusCode = 302;
|
|
313
|
+
res.setHeader(
|
|
314
|
+
'Set-Cookie',
|
|
315
|
+
`${cfg.cookieName}=${cookie}; Domain=${cfg.cookieDomain}; Path=/; Max-Age=${cfg.sessionTtlSeconds}; Secure; HttpOnly; SameSite=Lax`,
|
|
316
|
+
);
|
|
317
|
+
res.setHeader('Location', row.nextUrl ?? `${cfg.baseUrl}/`);
|
|
318
|
+
res.end();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function verify(req, res) {
|
|
322
|
+
const cookie = getCookie(req, cfg.cookieName);
|
|
323
|
+
if (!cookie) {
|
|
324
|
+
res.statusCode = 401;
|
|
325
|
+
res.end();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const sid = verifySessionSignature(cookie, cfg.secret);
|
|
329
|
+
if (!sid) {
|
|
330
|
+
res.statusCode = 401;
|
|
331
|
+
res.end();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const row = store.getSession(sidHashOf(sid));
|
|
335
|
+
if (!row || row.expiresAt <= Date.now()) {
|
|
336
|
+
res.statusCode = 401;
|
|
337
|
+
res.end();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
res.statusCode = 200;
|
|
341
|
+
res.setHeader('X-User-Handle', row.handle);
|
|
342
|
+
res.end();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function logout(req, res) {
|
|
346
|
+
const cookie = getCookie(req, cfg.cookieName);
|
|
347
|
+
if (cookie) {
|
|
348
|
+
const sid = verifySessionSignature(cookie, cfg.secret);
|
|
349
|
+
if (sid) store.deleteSession(sidHashOf(sid));
|
|
350
|
+
}
|
|
351
|
+
res.statusCode = 200;
|
|
352
|
+
res.setHeader(
|
|
353
|
+
'Set-Cookie',
|
|
354
|
+
`${cfg.cookieName}=; Domain=${cfg.cookieDomain}; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax`,
|
|
355
|
+
);
|
|
356
|
+
res.end();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function loginForm(req, res) {
|
|
360
|
+
const url = new URL(req.url || '/', cfg.baseUrl);
|
|
361
|
+
const next = url.searchParams.get('next');
|
|
362
|
+
const html = renderLoginForm({
|
|
363
|
+
loginPath: cfg.loginPath,
|
|
364
|
+
honeypotName: cfg.honeypotFieldName,
|
|
365
|
+
next: next ?? undefined,
|
|
366
|
+
});
|
|
367
|
+
res.statusCode = 200;
|
|
368
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
369
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
370
|
+
res.end(html);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
login,
|
|
375
|
+
callback,
|
|
376
|
+
verify,
|
|
377
|
+
logout,
|
|
378
|
+
loginForm,
|
|
379
|
+
validateNextUrl: (raw) => validateNextUrl(raw, cfg.baseUrl, cfg.cookieDomain),
|
|
380
|
+
// exposed for tests
|
|
381
|
+
_config: cfg,
|
|
382
|
+
};
|
|
383
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createStore } from './store.js';
|
|
2
|
+
import { createMailer } from './mailer.js';
|
|
3
|
+
import { createHandlers } from './handlers.js';
|
|
4
|
+
|
|
5
|
+
/** Default sweeper tick: 5 minutes. Per FR-13. */
|
|
6
|
+
const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
/** Default rate-limit retention: 24 hours past window-start. */
|
|
9
|
+
const DEFAULT_RATE_LIMIT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
const REQUIRED_FIELDS = ['secret', 'baseUrl', 'from'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} KnowlessOptions
|
|
15
|
+
* @property {string} secret HMAC secret, ≥64 hex chars (32 bytes). FR-47, FR-48.
|
|
16
|
+
* @property {string} baseUrl Public base URL for magic links.
|
|
17
|
+
* @property {string} from Sender email address.
|
|
18
|
+
* @property {string} [dbPath='./knowless.db']
|
|
19
|
+
* @property {string} [cookieDomain] Defaults to baseUrl's hostname.
|
|
20
|
+
* @property {number} [tokenTtlSeconds=900]
|
|
21
|
+
* @property {number} [sessionTtlSeconds=2592000]
|
|
22
|
+
* @property {string} [linkPath='/auth/callback']
|
|
23
|
+
* @property {string} [loginPath='/login']
|
|
24
|
+
* @property {string} [verifyPath='/verify']
|
|
25
|
+
* @property {string} [logoutPath='/logout']
|
|
26
|
+
* @property {string} [smtpHost='localhost']
|
|
27
|
+
* @property {number} [smtpPort=25]
|
|
28
|
+
* @property {boolean} [openRegistration=false]
|
|
29
|
+
* @property {string} [subject='Sign in']
|
|
30
|
+
* @property {string} [confirmationMessage]
|
|
31
|
+
* @property {boolean} [includeLastLoginInEmail=true]
|
|
32
|
+
* @property {number} [maxActiveTokensPerHandle=5]
|
|
33
|
+
* @property {number} [maxLoginRequestsPerIpPerHour=30]
|
|
34
|
+
* @property {number} [maxNewHandlesPerIpPerHour=3]
|
|
35
|
+
* @property {string} [honeypotFieldName='website']
|
|
36
|
+
* @property {string[]} [trustedProxies]
|
|
37
|
+
* @property {string} [shamRecipient='null@knowless.invalid'] See SPEC §7.4.
|
|
38
|
+
* @property {number} [sweepIntervalMs] Sweeper tick; defaults to 5 minutes.
|
|
39
|
+
* @property {object} [store] Inject your own store implementation.
|
|
40
|
+
* @property {object} [mailer] Inject your own mailer.
|
|
41
|
+
* @property {object} [transportOverride] Pass to nodemailer.createTransport (tests).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The knowless factory. Call once at startup, mount the returned handlers
|
|
46
|
+
* on your HTTP framework, and call .close() at shutdown.
|
|
47
|
+
*
|
|
48
|
+
* Six-line library-mode example:
|
|
49
|
+
* ```js
|
|
50
|
+
* import { knowless } from 'knowless';
|
|
51
|
+
* const auth = knowless({ secret, baseUrl, from });
|
|
52
|
+
* app.get(auth.config.loginPath, auth.loginForm);
|
|
53
|
+
* app.post(auth.config.loginPath, auth.login);
|
|
54
|
+
* app.get(auth.config.linkPath, auth.callback);
|
|
55
|
+
* app.get(auth.config.verifyPath, auth.verify);
|
|
56
|
+
* app.post(auth.config.logoutPath, auth.logout);
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* @param {KnowlessOptions} options
|
|
60
|
+
* @returns {{
|
|
61
|
+
* login: Function,
|
|
62
|
+
* callback: Function,
|
|
63
|
+
* verify: Function,
|
|
64
|
+
* logout: Function,
|
|
65
|
+
* loginForm: Function,
|
|
66
|
+
* deleteHandle: (handle: string) => void,
|
|
67
|
+
* config: object,
|
|
68
|
+
* close: () => void,
|
|
69
|
+
* }}
|
|
70
|
+
*/
|
|
71
|
+
export function knowless(options = {}) {
|
|
72
|
+
for (const f of REQUIRED_FIELDS) {
|
|
73
|
+
if (!options[f]) throw new Error(`knowless: ${f} is required`);
|
|
74
|
+
}
|
|
75
|
+
if (typeof options.secret !== 'string' || options.secret.length < 64) {
|
|
76
|
+
throw new Error('knowless: secret must be at least 64 hex chars (32 bytes)');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const store = options.store ?? createStore(options.dbPath ?? './knowless.db');
|
|
80
|
+
|
|
81
|
+
const mailer =
|
|
82
|
+
options.mailer ??
|
|
83
|
+
createMailer({
|
|
84
|
+
from: options.from,
|
|
85
|
+
smtpHost: options.smtpHost,
|
|
86
|
+
smtpPort: options.smtpPort,
|
|
87
|
+
transportOverride: options.transportOverride,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const handlers = createHandlers({ store, mailer, config: options });
|
|
91
|
+
|
|
92
|
+
const sweepIntervalMs = options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
|
|
93
|
+
const sweepTimer = setInterval(() => {
|
|
94
|
+
try {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
store.sweepTokens(now);
|
|
97
|
+
store.sweepSessions(now);
|
|
98
|
+
store.sweepRateLimits(now - DEFAULT_RATE_LIMIT_RETENTION_MS);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.error('[knowless] sweep failed:', err.message);
|
|
101
|
+
}
|
|
102
|
+
}, sweepIntervalMs);
|
|
103
|
+
// Don't keep the event loop alive just for the sweeper.
|
|
104
|
+
if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
login: handlers.login,
|
|
108
|
+
callback: handlers.callback,
|
|
109
|
+
verify: handlers.verify,
|
|
110
|
+
logout: handlers.logout,
|
|
111
|
+
loginForm: handlers.loginForm,
|
|
112
|
+
/** Delete a handle + all tokens + all sessions atomically (FR-37a). */
|
|
113
|
+
deleteHandle: (handle) => store.deleteHandle(handle),
|
|
114
|
+
/** Effective config (with defaults applied), useful for routing. */
|
|
115
|
+
config: handlers._config,
|
|
116
|
+
close() {
|
|
117
|
+
clearInterval(sweepTimer);
|
|
118
|
+
try {
|
|
119
|
+
mailer.close?.();
|
|
120
|
+
} catch {
|
|
121
|
+
/* tolerate already-closed transports */
|
|
122
|
+
}
|
|
123
|
+
store.close();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { createStore } from './store.js';
|
|
129
|
+
export { createMailer, composeBody, validateSubject } from './mailer.js';
|
|
130
|
+
export { createHandlers } from './handlers.js';
|
|
131
|
+
export { renderLoginForm } from './form.js';
|
|
132
|
+
export { normalize, deriveHandle } from './handle.js';
|