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/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
+ }
@@ -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';