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
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# knowless -- Integration Guide
|
|
2
|
+
|
|
3
|
+
> For AI assistants and developers wiring knowless into a project.
|
|
4
|
+
> v0.1.0 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
|
|
5
|
+
|
|
6
|
+
## What this is
|
|
7
|
+
|
|
8
|
+
knowless is a passwordless auth library for Node.js services
|
|
9
|
+
(~1,500 lines src + ~1,800 lines tests). Email in, signed-cookie
|
|
10
|
+
session out. Magic-link round-trip + nothing else stored. The
|
|
11
|
+
library is opinionated about *not* sending non-auth email, *not*
|
|
12
|
+
storing plaintext identity, and *not* growing into a user-management
|
|
13
|
+
platform.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
npm install knowless
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Two integration paths:
|
|
20
|
+
|
|
21
|
+
1. **Library mode (v0.1.0):** `import { knowless } from 'knowless'` --
|
|
22
|
+
mount five handlers on Express / Fastify / Hono / `node:http`
|
|
23
|
+
2. **Standalone server (v0.2.0, in development):** `npx knowless-server` --
|
|
24
|
+
forward-auth gateway for Caddy / nginx / Traefik in front of
|
|
25
|
+
no-auth services like Uptime Kuma, AdGuard, Pi-hole
|
|
26
|
+
|
|
27
|
+
This document is the dense reference. For the why, see
|
|
28
|
+
`docs/01-product/PRD.md`. For the wire formats, see
|
|
29
|
+
`docs/02-design/SPEC.md`. For an adopter walkthrough, see `GUIDE.md`.
|
|
30
|
+
|
|
31
|
+
## Which mode do I need?
|
|
32
|
+
|
|
33
|
+
| Mode | What it does | When to use |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| Library | Import + mount handlers on your existing Node app | You already run a Node service and want auth in front of it |
|
|
36
|
+
| Standalone (0.2.0) | `npx knowless-server` exposes /verify for forward-auth | Self-hosting Kuma / AdGuard / etc.; one auth subdomain, SSO across services |
|
|
37
|
+
|
|
38
|
+
## Minimal usage: library mode
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import express from 'express';
|
|
42
|
+
import { knowless } from 'knowless';
|
|
43
|
+
|
|
44
|
+
const app = express();
|
|
45
|
+
const auth = knowless({
|
|
46
|
+
secret: process.env.KNOWLESS_SECRET, // 64-char hex (32 bytes), required
|
|
47
|
+
baseUrl: 'https://app.example.com', // required
|
|
48
|
+
from: 'auth@app.example.com', // required
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
app.use(express.urlencoded({ extended: false }));
|
|
52
|
+
app.get('/login', auth.loginForm); // GET form, optional
|
|
53
|
+
app.post('/login', auth.login); // POST: triggers magic link
|
|
54
|
+
app.get('/auth/callback', auth.callback); // GET: redeems token, sets cookie
|
|
55
|
+
app.get('/verify', auth.verify); // GET: forward-auth check
|
|
56
|
+
app.post('/logout', auth.logout); // POST: clears session
|
|
57
|
+
app.listen(8080);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`auth.config` exposes the merged config (defaults + overrides) for
|
|
61
|
+
routing tables. `auth.deleteHandle(handle)` is GDPR right-to-erasure.
|
|
62
|
+
`auth.close()` shuts the sweeper and DB cleanly.
|
|
63
|
+
|
|
64
|
+
## All options
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
const auth = knowless({
|
|
68
|
+
// --- Required ---
|
|
69
|
+
secret: '...', // 64-char hex; HMAC + cookie sig key
|
|
70
|
+
baseUrl: 'https://app.example.com', // base for magic-link URL construction
|
|
71
|
+
from: 'auth@app.example.com', // sender address
|
|
72
|
+
|
|
73
|
+
// --- Storage ---
|
|
74
|
+
dbPath: './knowless.db', // SQLite file; ':memory:' for tests
|
|
75
|
+
|
|
76
|
+
// --- Cookie / session ---
|
|
77
|
+
cookieDomain: 'app.example.com', // default: hostname of baseUrl
|
|
78
|
+
cookieName: 'knowless_session', // default 'knowless_session'
|
|
79
|
+
sessionTtlSeconds: 30 * 86400, // 30 days
|
|
80
|
+
|
|
81
|
+
// --- Token ---
|
|
82
|
+
tokenTtlSeconds: 900, // 15 min
|
|
83
|
+
|
|
84
|
+
// --- Routing ---
|
|
85
|
+
loginPath: '/login',
|
|
86
|
+
linkPath: '/auth/callback',
|
|
87
|
+
verifyPath: '/verify',
|
|
88
|
+
logoutPath: '/logout',
|
|
89
|
+
failureRedirect: null, // null → loginPath
|
|
90
|
+
|
|
91
|
+
// --- Mail / SMTP ---
|
|
92
|
+
smtpHost: 'localhost',
|
|
93
|
+
smtpPort: 25,
|
|
94
|
+
subject: 'Sign in', // ASCII, ≤ 60 chars
|
|
95
|
+
shamRecipient: 'null@knowless.invalid', // operator's MTA must discard this
|
|
96
|
+
|
|
97
|
+
// --- Behavior ---
|
|
98
|
+
openRegistration: false, // first-email-wins handle creation
|
|
99
|
+
includeLastLoginInEmail: true, // append "Last sign-in: <ISO ts>" line
|
|
100
|
+
confirmationMessage: 'Thanks. If <strong>{email}</strong>...',
|
|
101
|
+
|
|
102
|
+
// --- Abuse defenses (FR-38..41) ---
|
|
103
|
+
maxActiveTokensPerHandle: 5, // 0 to disable
|
|
104
|
+
maxLoginRequestsPerIpPerHour: 30, // 0 to disable
|
|
105
|
+
maxNewHandlesPerIpPerHour: 3, // 0 to disable (open-reg only)
|
|
106
|
+
honeypotFieldName: 'website',
|
|
107
|
+
trustedProxies: ['127.0.0.1', '::1'],
|
|
108
|
+
|
|
109
|
+
// --- Lifecycle ---
|
|
110
|
+
sweepIntervalMs: 5 * 60 * 1000, // periodic sweeper tick
|
|
111
|
+
|
|
112
|
+
// --- Injection (tests / advanced) ---
|
|
113
|
+
store: undefined, // bring your own store
|
|
114
|
+
mailer: undefined, // bring your own mailer
|
|
115
|
+
transportOverride: undefined, // pass to nodemailer.createTransport
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Public API
|
|
120
|
+
|
|
121
|
+
`knowless(options)` returns:
|
|
122
|
+
|
|
123
|
+
| Method | Args | Returns | Notes |
|
|
124
|
+
|---|---|---|---|
|
|
125
|
+
| `login` | (req, res) | Promise\<void\> | POST handler: parses form, applies sham-work, sends magic link |
|
|
126
|
+
| `callback` | (req, res) | Promise\<void\> | GET handler: redeems `?t=<token>`, sets cookie, redirects to `next_url` or default |
|
|
127
|
+
| `verify` | (req, res) | void | GET handler (forward-auth): 200+`X-User-Handle` if cookie valid, else 401 |
|
|
128
|
+
| `logout` | (req, res) | Promise\<void\> | POST handler: clears session row + cookie |
|
|
129
|
+
| `loginForm` | (req, res) | void | GET handler: renders the hardcoded login HTML; preserves `?next=` |
|
|
130
|
+
| `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
|
|
131
|
+
| `config` | -- | object | Merged effective config; safe to read (do not mutate) |
|
|
132
|
+
| `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
|
|
133
|
+
|
|
134
|
+
Re-exports for advanced consumers:
|
|
135
|
+
|
|
136
|
+
```js
|
|
137
|
+
import {
|
|
138
|
+
knowless, // factory
|
|
139
|
+
createStore, // direct store access (admin scripts)
|
|
140
|
+
createMailer, // direct mailer access
|
|
141
|
+
createHandlers, // bring your own factory wiring
|
|
142
|
+
composeBody, // pure: build the mail body
|
|
143
|
+
validateSubject, // pure: validate operator-supplied subject
|
|
144
|
+
renderLoginForm, // pure: HTML5 page rendering
|
|
145
|
+
normalize, // pure: email normalization
|
|
146
|
+
deriveHandle, // pure: HMAC-SHA256(secret, email)
|
|
147
|
+
} from 'knowless';
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Handle / token / session lifecycles
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
email cookie
|
|
154
|
+
| |
|
|
155
|
+
v v
|
|
156
|
+
normalize() ----> deriveHandle() -+ verifySessionSignature()
|
|
157
|
+
| |
|
|
158
|
+
v v
|
|
159
|
+
handle sid_b64u
|
|
160
|
+
| |
|
|
161
|
+
v v
|
|
162
|
+
[handles table] [sessions table]
|
|
163
|
+
| |
|
|
164
|
+
v |
|
|
165
|
+
issueToken() |
|
|
166
|
+
| |
|
|
167
|
+
v |
|
|
168
|
+
{raw, hash} |
|
|
169
|
+
| |
|
|
170
|
+
+----------+ |
|
|
171
|
+
| |
|
|
172
|
+
v |
|
|
173
|
+
[tokens table] -- redeem ->+
|
|
174
|
+
|
|
|
175
|
+
sweep on
|
|
176
|
+
expiry/use
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
| Property | Default | Configurable | Notes |
|
|
180
|
+
|---|---|---|---|
|
|
181
|
+
| Token entropy | 256 bits | No | Floor, not target |
|
|
182
|
+
| Token TTL | 15 min | Yes | Single-use; expired ≡ replayed ≡ never-existed |
|
|
183
|
+
| Token at rest | SHA-256 hash | No | Raw never persisted |
|
|
184
|
+
| Session lifetime | 30 days | Yes | Server-enforced via stored expiry |
|
|
185
|
+
| Cookie format | `<sid_b64u>.<sig_hex>` | Name only | 108 chars total |
|
|
186
|
+
| Sweep interval | 5 min | Yes | Drops expired tokens, sessions, old rate-limit rows |
|
|
187
|
+
|
|
188
|
+
## The sham-work pattern (the thing that's special about knowless)
|
|
189
|
+
|
|
190
|
+
When an unregistered email submits to /login, the library does the
|
|
191
|
+
*same work* as a registered hit: derives the handle, looks it up,
|
|
192
|
+
inserts a token row (flagged `is_sham=1`), composes a mail, submits
|
|
193
|
+
it via SMTP. The difference: the mail's recipient is the configured
|
|
194
|
+
`shamRecipient` (default `null@knowless.invalid`) and your MTA's
|
|
195
|
+
`transport_maps` discards mail to that address.
|
|
196
|
+
|
|
197
|
+
Why: prevents email-enumeration via the login form. An attacker
|
|
198
|
+
submitting candidate emails cannot tell from response timing,
|
|
199
|
+
status, body, or "did the user receive a mail?" whether each
|
|
200
|
+
email is registered.
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
POST /login (alice@registered.com) POST /login (nobody@example.com)
|
|
204
|
+
| |
|
|
205
|
+
v v
|
|
206
|
+
normalize → derive → exists normalize → derive → !exists
|
|
207
|
+
| |
|
|
208
|
+
v v
|
|
209
|
+
issueToken (is_sham=0) issueToken (is_sham=1)
|
|
210
|
+
| |
|
|
211
|
+
v v
|
|
212
|
+
compose mail to alice@registered.com compose mail to null@knowless.invalid
|
|
213
|
+
| |
|
|
214
|
+
v v
|
|
215
|
+
submit via SMTP submit via SMTP
|
|
216
|
+
| |
|
|
217
|
+
v v
|
|
218
|
+
Postfix → delivers Postfix → discards (transport_maps)
|
|
219
|
+
| |
|
|
220
|
+
v v
|
|
221
|
+
same 200 OK + same HTML body same 200 OK + same HTML body
|
|
222
|
+
(timing within ~2μs)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The `is_sham=1` flag also makes sham tokens *un-redeemable*: even
|
|
226
|
+
if the discard misconfigured and a sham mail leaked, clicking
|
|
227
|
+
the link returns the same redirect as expired/replayed.
|
|
228
|
+
|
|
229
|
+
Operator setup for the null-route (Postfix):
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
# /etc/postfix/transport
|
|
233
|
+
knowless.invalid discard:silently dropped by knowless null-route
|
|
234
|
+
|
|
235
|
+
# /etc/postfix/main.cf
|
|
236
|
+
transport_maps = hash:/etc/postfix/transport
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
postmap /etc/postfix/transport && systemctl reload postfix
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## FR-6: timing equivalence (the testable property)
|
|
244
|
+
|
|
245
|
+
The library ships a CI test (`test/integration/timing.test.js`)
|
|
246
|
+
that asserts `|mean(hit_time) - mean(miss_time)| < 1ms` over 1000
|
|
247
|
+
interleaved iterations after a 200-iter warmup.
|
|
248
|
+
|
|
249
|
+
Local result on commodity hardware: `Δ_mean = 0.002ms` (500x under
|
|
250
|
+
the bar). The full sham-work pattern is achieving practically
|
|
251
|
+
perfect timing equivalence — better than the v0.11 POC measurement
|
|
252
|
+
of 260μs because production prepared statements and tighter
|
|
253
|
+
hot-path caching.
|
|
254
|
+
|
|
255
|
+
Why effect-size, not p-value: with N=10,000 and a Welch's t-test,
|
|
256
|
+
*any* constant offset above ~50μs registers as "statistically
|
|
257
|
+
significant" even though the offset is invisible across realistic
|
|
258
|
+
network jitter. The 1ms bar is what an attacker actually observes
|
|
259
|
+
through a connection. Detail in SPEC §14.
|
|
260
|
+
|
|
261
|
+
## Forward-auth `?next=` handling
|
|
262
|
+
|
|
263
|
+
When `/login` receives a `?next=<url>` form field (typical from
|
|
264
|
+
forward-auth proxies redirecting unauthenticated requests):
|
|
265
|
+
|
|
266
|
+
1. URL is validated against `cookieDomain` whitelist (host
|
|
267
|
+
equals or `.endsWith` of the cookie domain). `https`/`http`
|
|
268
|
+
only; `javascript:` etc. rejected.
|
|
269
|
+
2. Validated URL is stored on the token row as `next_url`.
|
|
270
|
+
3. On `/auth/callback` redemption, the redirect goes to
|
|
271
|
+
`next_url` (or `baseUrl + '/'` if absent).
|
|
272
|
+
|
|
273
|
+
The magic link URL stays short (`?t=<43 chars>` only) — the bound
|
|
274
|
+
URL doesn't bloat it. Tamper-resistance comes from token opacity:
|
|
275
|
+
substituting a different `next_url` requires forging a token,
|
|
276
|
+
which requires the operator secret.
|
|
277
|
+
|
|
278
|
+
```
|
|
279
|
+
[unauth request to kuma.app.example.com]
|
|
280
|
+
|
|
|
281
|
+
v
|
|
282
|
+
[Caddy] -- 401 if no session cookie --> [redirect to auth.app.example.com/login?next=https://kuma.app.example.com/]
|
|
283
|
+
|
|
|
284
|
+
v
|
|
285
|
+
[user submits email; library binds next_url to token]
|
|
286
|
+
|
|
|
287
|
+
v
|
|
288
|
+
[email lands in inbox; user clicks magic link]
|
|
289
|
+
|
|
|
290
|
+
v
|
|
291
|
+
[GET /auth/callback?t=...]
|
|
292
|
+
|
|
|
293
|
+
v
|
|
294
|
+
[token redeemed, session created, cookie set on .app.example.com]
|
|
295
|
+
|
|
|
296
|
+
v
|
|
297
|
+
[302 Location: https://kuma.app.example.com/]
|
|
298
|
+
|
|
|
299
|
+
v
|
|
300
|
+
[Caddy verifies cookie via /verify, proxies to Uptime Kuma]
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## Architecture
|
|
304
|
+
|
|
305
|
+
```
|
|
306
|
+
URL/email -> handlers.js (login: 12-step sham-work flow per SPEC §7.3)
|
|
307
|
+
-> handle.js (normalize ASCII-only, HMAC-SHA256)
|
|
308
|
+
-> abuse.js (per-IP rate limit, per-handle token cap, honeypot)
|
|
309
|
+
-> token.js (32 random bytes, base64url; SHA-256 at rest)
|
|
310
|
+
-> store.js (better-sqlite3, transactional, prepared statements)
|
|
311
|
+
-> mailer.js (raw RFC822 7bit; nodemailer for SMTP submission only)
|
|
312
|
+
-> session.js (HMAC-signed cookie with "sess\\0" domain tag)
|
|
313
|
+
-> form.js (hardcoded HTML5; no JS, no external resources)
|
|
314
|
+
-> index.js (factory + sweeper)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
| Module | Lines | Purpose |
|
|
318
|
+
|---|---|---|
|
|
319
|
+
| `src/index.js` | ~140 | Public factory, sweeper, re-exports |
|
|
320
|
+
| `src/handlers.js` | ~310 | login (sham), callback, verify, logout, loginForm, validateNextUrl |
|
|
321
|
+
| `src/store.js` | ~210 | better-sqlite3 store; SPEC §13 interface |
|
|
322
|
+
| `src/mailer.js` | ~120 | RFC822 raw composition + nodemailer SMTP submission |
|
|
323
|
+
| `src/abuse.js` | ~95 | Source-IP determination, rate limits |
|
|
324
|
+
| `src/handle.js` | ~50 | Email normalization, handle derivation |
|
|
325
|
+
| `src/token.js` | ~40 | issueToken, hashToken |
|
|
326
|
+
| `src/session.js` | ~80 | Cookie signing/verification with constant-time compare |
|
|
327
|
+
| `src/form.js` | ~110 | Hardcoded login HTML |
|
|
328
|
+
|
|
329
|
+
## Threat model summary
|
|
330
|
+
|
|
331
|
+
**Defends well:** DB-only leaks (handles are HMAC-salted),
|
|
332
|
+
plaintext-email exfiltration (none persisted), password reuse
|
|
333
|
+
(no passwords), email enumeration via login form (timing
|
|
334
|
+
equivalent + same response shape), email-bombing (per-handle
|
|
335
|
+
cap), naive bot traffic (honeypot), high-volume floods (per-IP
|
|
336
|
+
cap), replay attacks (markTokenUsed atomic), open redirects
|
|
337
|
+
(`next_url` whitelist).
|
|
338
|
+
|
|
339
|
+
**Partial:** HMAC secret leak alone (allows targeted existence
|
|
340
|
+
checks but not session forgery), phishing (no password to
|
|
341
|
+
phish, but a phished mailbox still receives links).
|
|
342
|
+
|
|
343
|
+
**Does NOT defend against:** sophisticated bots that bypass the
|
|
344
|
+
honeypot, distributed floods, full server compromise,
|
|
345
|
+
compromised email accounts, social engineering, insider threat
|
|
346
|
+
at the operator. Layer-2 (Cloudflare / fail2ban / proxy
|
|
347
|
+
rate-limits) belongs above the library.
|
|
348
|
+
|
|
349
|
+
## Gotchas
|
|
350
|
+
|
|
351
|
+
1. **Closed-registration by default.** A handle must already
|
|
352
|
+
exist before its email can request a link. Operators pre-seed
|
|
353
|
+
via the re-exported `createStore` + `deriveHandle`, OR set
|
|
354
|
+
`openRegistration: true`.
|
|
355
|
+
|
|
356
|
+
2. **Postfix on localhost is required.** No remote SMTP, no
|
|
357
|
+
Mailgun / Postmark / SES. The localhost requirement is
|
|
358
|
+
intentional (PRD §16.2): vendor mailers invite "while we're
|
|
359
|
+
at it, let's send a welcome email," which contradicts the
|
|
360
|
+
philosophy. If you can't run Postfix, knowless isn't your
|
|
361
|
+
library.
|
|
362
|
+
|
|
363
|
+
3. **`shamRecipient` MUST be discarded by your MTA.** Default
|
|
364
|
+
is `null@knowless.invalid`. If your MTA tries to deliver it,
|
|
365
|
+
it'll bounce against an `.invalid` TLD that never resolves —
|
|
366
|
+
noise in your mail logs, wasted DNS lookups. Add the
|
|
367
|
+
`transport_maps` entry per Postfix snippet above.
|
|
368
|
+
|
|
369
|
+
4. **Cookie domain defaults to baseUrl's hostname.** This is the
|
|
370
|
+
*narrow* default; for SSO across subdomains (forward-auth
|
|
371
|
+
pattern), set `cookieDomain` to the parent eTLD+1 explicitly.
|
|
372
|
+
The library does NOT compute eTLD+1 automatically (would
|
|
373
|
+
require a public-suffix-list dep).
|
|
374
|
+
|
|
375
|
+
5. **`Secure` cookie attribute is non-negotiable.** All session
|
|
376
|
+
cookies set `Secure`. HTTP-only origins won't receive them.
|
|
377
|
+
Use HTTPS in production. Localhost development: use
|
|
378
|
+
`--insecure-localhost-cookies` (not implemented yet — TASKS
|
|
379
|
+
open question; works in Chrome with `--unsafely-treat-insecure-origin-as-secure`).
|
|
380
|
+
|
|
381
|
+
6. **Forward-auth needs the parent-domain cookie.** If your auth
|
|
382
|
+
subdomain is `auth.example.com` and protected service is
|
|
383
|
+
`kuma.example.com`, set `cookieDomain: 'example.com'` so the
|
|
384
|
+
browser sends the cookie to both. Otherwise SSO doesn't work.
|
|
385
|
+
|
|
386
|
+
7. **Session cookies don't slide.** Each session has a fixed
|
|
387
|
+
expiry (30 days default). User re-authenticates after expiry.
|
|
388
|
+
Sliding sessions are SPEC §15 Q-3 (deferred to v0.2).
|
|
389
|
+
|
|
390
|
+
8. **Token replay is silent.** A second click on a magic link
|
|
391
|
+
redirects to /login (same as expired/never-existed). Users
|
|
392
|
+
sometimes double-click and wonder where they went. Mention
|
|
393
|
+
this in your UX copy.
|
|
394
|
+
|
|
395
|
+
9. **Mail composition is a raw RFC822 string.** Nodemailer 8's
|
|
396
|
+
default encoding picks base64 or QP for plain ASCII bodies,
|
|
397
|
+
breaking the magic-link URL with QP soft-breaks (the v0.11
|
|
398
|
+
POC finding). We sidestep by composing the message ourselves
|
|
399
|
+
and using nodemailer only for SMTP. If you swap mailers, your
|
|
400
|
+
replacement must handle this OR keep emitting raw RFC822.
|
|
401
|
+
|
|
402
|
+
10. **No JavaScript in any HTML page.** The login form, the
|
|
403
|
+
confirmation page, error pages — all static HTML5. Works in
|
|
404
|
+
text-mode browsers (Lynx, w3m). Operators wanting branding
|
|
405
|
+
fork the project.
|
|
406
|
+
|
|
407
|
+
11. **Process cleanup matters.** `auth.close()` stops the
|
|
408
|
+
sweeper and closes the SQLite handle. Without it, your
|
|
409
|
+
process won't exit cleanly. The sweeper timer is `unref()`d
|
|
410
|
+
so it won't *prevent* exit, but the SQLite handle held by
|
|
411
|
+
`better-sqlite3` will leave a finalizer warning.
|
|
412
|
+
|
|
413
|
+
## Constraints
|
|
414
|
+
|
|
415
|
+
- **Node 20+** -- targeting LTS; tested on Node 22
|
|
416
|
+
- **Plain ES modules** -- no TypeScript source, no build step;
|
|
417
|
+
ships JSDoc + (eventual) `.d.ts`
|
|
418
|
+
- **Two production deps** -- `nodemailer` (SMTP submission) and
|
|
419
|
+
`better-sqlite3` (storage). No third dep without revisiting
|
|
420
|
+
AGENT_RULES External Dependency Checklist.
|
|
421
|
+
- **Localhost MTA only** -- no remote SMTP, no vendor SDKs.
|
|
422
|
+
Operators run their own Postfix / OpenSMTPD / Exim.
|
|
423
|
+
- **ASCII-only email addresses** in v0.1. IDN deferred to v0.2.
|
|
424
|
+
- **SQLite is the default store** -- swap-in stores must
|
|
425
|
+
implement the SPEC §13 interface synchronously.
|
|
426
|
+
- **No telemetry of any kind.** No phone-home, no metrics
|
|
427
|
+
endpoint, no analytics.
|
|
428
|
+
- **Walks away at v1.0.0.** Maintenance mode (security + bug
|
|
429
|
+
fix) after that, by intent (PRD §6.3).
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "knowless",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Small, opinionated, full-stack passwordless auth for Node.js services that don't need to email their users for anything but the sign-in link.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"NOTICE",
|
|
14
|
+
"README.md",
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"GUIDE.md",
|
|
17
|
+
"knowless.context.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20.0.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test 'test/**/*.test.js'",
|
|
24
|
+
"lint": "find src bin -type f \\( -name '*.js' -o -name 'knowless-server' \\) -exec node --check {} \\;"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"better-sqlite3": "^11.0.0",
|
|
28
|
+
"nodemailer": "^8.0.7"
|
|
29
|
+
},
|
|
30
|
+
"license": "Apache-2.0",
|
|
31
|
+
"homepage": "https://github.com/hamr0/knowless",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/hamr0/knowless.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/hamr0/knowless/issues"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"auth",
|
|
41
|
+
"authentication",
|
|
42
|
+
"passwordless",
|
|
43
|
+
"magic-link",
|
|
44
|
+
"forward-auth",
|
|
45
|
+
"self-hosted",
|
|
46
|
+
"privacy"
|
|
47
|
+
]
|
|
48
|
+
}
|
package/src/abuse.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Determine the source IP of a request per FR-42 and SPEC §7.6.
|
|
3
|
+
*
|
|
4
|
+
* If the request's connection peer is in `trustedProxies`, honour the
|
|
5
|
+
* `X-Forwarded-For` (first element) or `X-Real-IP` header. Otherwise
|
|
6
|
+
* fall back to the connection's remote address. This prevents IP
|
|
7
|
+
* spoofing from clients while supporting forward-auth deployments.
|
|
8
|
+
*
|
|
9
|
+
* @param {{
|
|
10
|
+
* socket?: { remoteAddress?: string },
|
|
11
|
+
* connection?: { remoteAddress?: string },
|
|
12
|
+
* headers?: Record<string, string|string[]|undefined>
|
|
13
|
+
* }} req a node:http request (or shape-compatible)
|
|
14
|
+
* @param {Set<string>|string[]} trustedProxies set or array of trusted peer IPs
|
|
15
|
+
* @returns {string} the determined IP, or '' if undeterminable
|
|
16
|
+
*/
|
|
17
|
+
export function determineSourceIp(req, trustedProxies) {
|
|
18
|
+
const peer =
|
|
19
|
+
req?.socket?.remoteAddress ?? req?.connection?.remoteAddress ?? '';
|
|
20
|
+
const trusted =
|
|
21
|
+
trustedProxies instanceof Set ? trustedProxies : new Set(trustedProxies ?? []);
|
|
22
|
+
if (!trusted.has(peer)) {
|
|
23
|
+
return peer;
|
|
24
|
+
}
|
|
25
|
+
const xff = req.headers?.['x-forwarded-for'];
|
|
26
|
+
if (typeof xff === 'string' && xff.length > 0) {
|
|
27
|
+
// First element is the original client; subsequent are proxy chain.
|
|
28
|
+
const first = xff.split(',')[0].trim();
|
|
29
|
+
if (first) return first;
|
|
30
|
+
}
|
|
31
|
+
const xri = req.headers?.['x-real-ip'];
|
|
32
|
+
if (typeof xri === 'string' && xri.length > 0) return xri.trim();
|
|
33
|
+
return peer;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compute the window-start timestamp (ms) for a given now and window size.
|
|
38
|
+
* Buckets are aligned to epoch — every `windowMs` slice has a stable start.
|
|
39
|
+
*
|
|
40
|
+
* @param {number} now Unix ms
|
|
41
|
+
* @param {number} windowMs window size in ms (e.g. 3_600_000 for 1h)
|
|
42
|
+
* @returns {number}
|
|
43
|
+
*/
|
|
44
|
+
export function windowStart(now, windowMs) {
|
|
45
|
+
return Math.floor(now / windowMs) * windowMs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check whether the given (scope, key) has exceeded `limit` events in the
|
|
50
|
+
* current window.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} store knowless store
|
|
53
|
+
* @param {string} scope e.g. 'login_ip'
|
|
54
|
+
* @param {string} key the value being limited (IP, handle hex)
|
|
55
|
+
* @param {number} limit threshold; if 0, the check is disabled (returns false)
|
|
56
|
+
* @param {number} windowMs
|
|
57
|
+
* @param {number} [now] override for testing
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
export function rateLimitExceeded(store, scope, key, limit, windowMs, now = Date.now()) {
|
|
61
|
+
if (limit <= 0) return false;
|
|
62
|
+
const ws = windowStart(now, windowMs);
|
|
63
|
+
return store.rateLimitGet(scope, key, ws) >= limit;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Increment the counter for (scope, key) in the current window. Returns the
|
|
68
|
+
* new count.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} store
|
|
71
|
+
* @param {string} scope
|
|
72
|
+
* @param {string} key
|
|
73
|
+
* @param {number} windowMs
|
|
74
|
+
* @param {number} [now]
|
|
75
|
+
* @returns {number}
|
|
76
|
+
*/
|
|
77
|
+
export function rateLimitIncrement(store, scope, key, windowMs, now = Date.now()) {
|
|
78
|
+
const ws = windowStart(now, windowMs);
|
|
79
|
+
return store.rateLimitIncrement(scope, key, ws);
|
|
80
|
+
}
|
package/src/form.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML escape for use in attribute values and text content.
|
|
3
|
+
* @param {string} s
|
|
4
|
+
* @returns {string}
|
|
5
|
+
*/
|
|
6
|
+
function htmlEscape(s) {
|
|
7
|
+
return String(s)
|
|
8
|
+
.replace(/&/g, '&')
|
|
9
|
+
.replace(/</g, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
11
|
+
.replace(/"/g, '"')
|
|
12
|
+
.replace(/'/g, ''');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const STYLE = `
|
|
16
|
+
body { font-family: system-ui, -apple-system, sans-serif; max-width: 36rem;
|
|
17
|
+
margin: 4rem auto; padding: 0 1rem; line-height: 1.5; color: #111; }
|
|
18
|
+
form { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
19
|
+
label { font-weight: 600; }
|
|
20
|
+
input[type="email"] { padding: 0.5rem; font-size: 1rem; border: 1px solid #999;
|
|
21
|
+
border-radius: 0.25rem; }
|
|
22
|
+
button { padding: 0.5rem 1rem; font-size: 1rem; cursor: pointer;
|
|
23
|
+
background: #111; color: #fff; border: 0; border-radius: 0.25rem; }
|
|
24
|
+
button:hover { background: #333; }
|
|
25
|
+
.hp { position: absolute; left: -9999px; top: -9999px; width: 1px;
|
|
26
|
+
height: 1px; overflow: hidden; }
|
|
27
|
+
.msg { padding: 0.75rem 1rem; background: #f4f4f4; border-radius: 0.25rem; }
|
|
28
|
+
`.trim();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render the login page (FR-22, FR-23).
|
|
32
|
+
*
|
|
33
|
+
* Two states share one page:
|
|
34
|
+
* - Initial GET /login: form is shown, no message.
|
|
35
|
+
* - After POST /login: form is shown again with the confirmation
|
|
36
|
+
* message above it (FR-7); user can resubmit if needed.
|
|
37
|
+
*
|
|
38
|
+
* No JavaScript, no external resources, inline minimal CSS only.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} args
|
|
41
|
+
* @param {string} args.loginPath form action URL
|
|
42
|
+
* @param {string} args.honeypotName honeypot input field name (FR-41)
|
|
43
|
+
* @param {string} [args.confirmationMessage] message shown after submission;
|
|
44
|
+
* supports "{email}" placeholder, replaced with HTML-escaped echoedEmail.
|
|
45
|
+
* Omit/null to render the bare form (initial GET).
|
|
46
|
+
* @param {string} [args.echoedEmail] user-supplied email to echo back
|
|
47
|
+
* @param {string} [args.next] forward-auth next URL to preserve
|
|
48
|
+
* @returns {string} complete HTML5 document
|
|
49
|
+
*/
|
|
50
|
+
export function renderLoginForm(args) {
|
|
51
|
+
const {
|
|
52
|
+
loginPath,
|
|
53
|
+
honeypotName,
|
|
54
|
+
confirmationMessage,
|
|
55
|
+
echoedEmail,
|
|
56
|
+
next,
|
|
57
|
+
} = args;
|
|
58
|
+
|
|
59
|
+
const messageBlock =
|
|
60
|
+
confirmationMessage != null
|
|
61
|
+
? `<div class="msg" role="status">${
|
|
62
|
+
confirmationMessage.replace(
|
|
63
|
+
/\{email\}/g,
|
|
64
|
+
htmlEscape(echoedEmail ?? ''),
|
|
65
|
+
)
|
|
66
|
+
}</div>`
|
|
67
|
+
: '';
|
|
68
|
+
|
|
69
|
+
const nextField = next
|
|
70
|
+
? `<input type="hidden" name="next" value="${htmlEscape(next)}">`
|
|
71
|
+
: '';
|
|
72
|
+
|
|
73
|
+
return `<!doctype html>
|
|
74
|
+
<html lang="en">
|
|
75
|
+
<head>
|
|
76
|
+
<meta charset="utf-8">
|
|
77
|
+
<title>Sign in</title>
|
|
78
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
79
|
+
<meta name="referrer" content="no-referrer">
|
|
80
|
+
<style>${STYLE}</style>
|
|
81
|
+
</head>
|
|
82
|
+
<body>
|
|
83
|
+
<h1>Sign in</h1>
|
|
84
|
+
${messageBlock}
|
|
85
|
+
<form method="POST" action="${htmlEscape(loginPath)}" autocomplete="off">
|
|
86
|
+
<label for="email">Email address</label>
|
|
87
|
+
<input id="email" name="email" type="email" required autocomplete="email"
|
|
88
|
+
value="${htmlEscape(echoedEmail ?? '')}">
|
|
89
|
+
<div class="hp" aria-hidden="true">
|
|
90
|
+
<label for="${htmlEscape(honeypotName)}">Leave this empty</label>
|
|
91
|
+
<input id="${htmlEscape(honeypotName)}" name="${htmlEscape(honeypotName)}"
|
|
92
|
+
type="text" tabindex="-1" autocomplete="off">
|
|
93
|
+
</div>
|
|
94
|
+
${nextField}
|
|
95
|
+
<button type="submit">Send sign-in link</button>
|
|
96
|
+
</form>
|
|
97
|
+
</body>
|
|
98
|
+
</html>
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { htmlEscape };
|