knowless 0.1.4 → 0.1.6
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 +112 -0
- package/GUIDE.md +104 -0
- package/README.md +6 -6
- package/knowless.context.md +78 -8
- package/package.json +1 -1
- package/src/handle.js +24 -5
- package/src/handlers.js +123 -43
- package/src/index.js +20 -5
- package/src/mailer.js +57 -1
- package/src/session.js +2 -4
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,62 @@ Versioning is [SemVer](https://semver.org/).
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
10
|
- Caddy forward-auth Docker integration test (TASKS.md 6.8).
|
|
11
|
+
|
|
12
|
+
## [0.1.6] — 2026-04-28
|
|
13
|
+
|
|
14
|
+
addypin integration round 2 — one correctness fix (HMAC key handling)
|
|
15
|
+
and one common-want feature (operator footer on auth mail).
|
|
16
|
+
|
|
17
|
+
### Breaking
|
|
18
|
+
|
|
19
|
+
- **The `secret` is now hex-decoded before being used as the HMAC
|
|
20
|
+
key.** Prior versions passed the 64-char hex string to
|
|
21
|
+
`crypto.createHmac` as ASCII bytes — same 256 bits of entropy, but
|
|
22
|
+
a different HMAC output than systems that hex-decode first. The
|
|
23
|
+
PRD already implied 32 bytes ("≥64 hex chars (32 bytes)"); the
|
|
24
|
+
implementation matched the spec on key length but used the wrong
|
|
25
|
+
key bytes. **Effect:** every handle and session signature changes
|
|
26
|
+
on upgrade. Existing sessions invalidate (users re-login); existing
|
|
27
|
+
pre-seeded handles must be re-derived. There are no production
|
|
28
|
+
knowless deployments yet (addypin and webrevival are both pre-prod),
|
|
29
|
+
so we lock the correct semantics in before v1.0 freezes. Closes
|
|
30
|
+
AF-8.1.
|
|
31
|
+
- The startup secret check now also validates that the secret is
|
|
32
|
+
64-char lowercase hex (`/^[a-f0-9]{64,}$/i`). Mixed-case secrets
|
|
33
|
+
must be lowercased.
|
|
34
|
+
- `deriveHandle()` and `signSession()` accept `Buffer` directly for
|
|
35
|
+
adopters who already hold raw 32-byte keys.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`bodyFooter: string`** config option — append a constant operator
|
|
40
|
+
footer to every magic-link email after the standard `"-- "` (RFC
|
|
41
|
+
3676) signature delimiter. Constraints (deliberately strict to
|
|
42
|
+
preserve the URL-line invariant and 7bit body encoding):
|
|
43
|
+
- ASCII only (no unicode middle-dot — use `|` or `-`)
|
|
44
|
+
- ≤ 240 chars, ≤ 4 lines
|
|
45
|
+
- No CR
|
|
46
|
+
- No `http://` / `https://` substrings (would conflict with the
|
|
47
|
+
magic-link line and trigger MTA URL-rewriting heuristics)
|
|
48
|
+
Validated at factory startup; fails fast on misconfiguration.
|
|
49
|
+
Closes AF-8.2.
|
|
50
|
+
- `validateBodyFooter()` exported alongside `composeBody` for adopters
|
|
51
|
+
who want to validate operator-supplied footers themselves.
|
|
52
|
+
- `secretBytes()` exported from `./handle.js` (and via the package
|
|
53
|
+
root) for adopters who want to coerce a hex string to raw 32-byte
|
|
54
|
+
HMAC key on their own boundaries.
|
|
55
|
+
|
|
56
|
+
### Migration from 0.1.5
|
|
57
|
+
|
|
58
|
+
- **Pre-seeded handles must be re-derived.** `deriveHandle('alice@x',
|
|
59
|
+
SECRET)` returns a different value in 0.1.6. If you stored handles
|
|
60
|
+
in any external system, recompute them. Closed-registration users
|
|
61
|
+
must re-seed.
|
|
62
|
+
- **Active sessions invalidate.** Users will need to log in again
|
|
63
|
+
after the upgrade. Plan for a single-shot user-visible logout.
|
|
64
|
+
- **Magic links in flight at upgrade time** become invalid (token
|
|
65
|
+
hashes are stored as HMAC outputs and the key changes). 15 min
|
|
66
|
+
TTL by default; a brief read-only window during deploy is enough.
|
|
11
67
|
- `knowless-server --check-null-route`: CLI probe that submits a
|
|
12
68
|
test message to `shamRecipient` and confirms the local MTA
|
|
13
69
|
discarded it. Honest answer to "does the operator's null-route
|
|
@@ -15,6 +71,62 @@ Versioning is [SemVer](https://semver.org/).
|
|
|
15
71
|
not what the MTA did, so this is the closest we can get.
|
|
16
72
|
Targeted for v0.2.0.
|
|
17
73
|
|
|
74
|
+
## [0.1.5] — 2026-04-28
|
|
75
|
+
|
|
76
|
+
addypin POC findings round. Adds programmatic magic-link entry
|
|
77
|
+
(unblocks "use first, claim later" UX patterns), one ergonomic
|
|
78
|
+
helper, two safety/diagnostic fixes, and three doc updates.
|
|
79
|
+
|
|
80
|
+
### Added
|
|
81
|
+
|
|
82
|
+
- **`auth.startLogin({email, nextUrl?, sourceIp?})`** — programmatic
|
|
83
|
+
entry that runs the same 12-step sham-work flow as `POST /login`
|
|
84
|
+
but skips Origin/honeypot (no browser context). Returns
|
|
85
|
+
`{handle, submitted: true}` — same shape on rate-limit / sham /
|
|
86
|
+
real to preserve FR-6 timing equivalence. Throws only on
|
|
87
|
+
programmer error. SPEC §7.3a. Closes AF-7.3.
|
|
88
|
+
- **`auth.deriveHandle(email)`** — instance method that uses the
|
|
89
|
+
configured secret. Lets adopters compute owner-handles outside
|
|
90
|
+
HTTP context without spreading the secret across modules. Closes
|
|
91
|
+
AF-7.4.
|
|
92
|
+
- **GUIDE.md "Two adoption modes" section** — Mode B (register-
|
|
93
|
+
first, the form) and Mode A (use-first-claim-later, programmatic).
|
|
94
|
+
Both supported, pickable per-action.
|
|
95
|
+
- **GUIDE.md "Constraints / install footprint" section** — direct
|
|
96
|
+
deps, transitive count, deprecation-warning context. Closes AF-7.7.
|
|
97
|
+
|
|
98
|
+
### Changed
|
|
99
|
+
|
|
100
|
+
- **`devLogMagicLinks` lines now tagged with `cfg.from`** —
|
|
101
|
+
`[knowless dev:auth@app.example.com] magic link: ...`. Disambiguates
|
|
102
|
+
multi-instance dev logs. Closes AF-7.6.
|
|
103
|
+
- **`devLogMagicLinks` + sham + SMTP-fail now prints a one-line
|
|
104
|
+
silent-miss hint** instead of staying silent. Surfaces the
|
|
105
|
+
closed-registration-is-on case that previously cost adopters
|
|
106
|
+
~30min of debugging. Strictly opt-in dev mode. Closes AF-7.2.
|
|
107
|
+
|
|
108
|
+
### Safety / diagnostics
|
|
109
|
+
|
|
110
|
+
- **`createMailer` validates `transportOverride` at startup.** A
|
|
111
|
+
malformed override (e.g. an options bag mistaken for a transport)
|
|
112
|
+
throws fast with a pointed error instead of failing at first
|
|
113
|
+
submission. Closes AF-7.5.
|
|
114
|
+
- **`POST /login` warns once on stderr when `Content-Length > 0`
|
|
115
|
+
but body is empty.** Catches the common non-Express trap of
|
|
116
|
+
mounting `express.urlencoded()` or similar body parsers ahead of
|
|
117
|
+
`auth.login` (which consumes the stream itself). One warning per
|
|
118
|
+
handler instance. Closes AF-7.1.
|
|
119
|
+
|
|
120
|
+
### Documentation
|
|
121
|
+
|
|
122
|
+
- **SPEC §7.3a** specifies the programmatic entry's contract: which
|
|
123
|
+
steps it skips (Origin, honeypot), why FR-6 still holds, and
|
|
124
|
+
what programmer-error throws look like.
|
|
125
|
+
- **GUIDE.md** adds two traps for non-Express integrators (body-
|
|
126
|
+
parser conflict, Origin requirement) and a worked Mode-A example.
|
|
127
|
+
- **knowless.context.md** lists `startLogin` + `deriveHandle` in the
|
|
128
|
+
public API table and adds gotchas 15–16.
|
|
129
|
+
|
|
18
130
|
## [0.1.4] — 2026-04-28
|
|
19
131
|
|
|
20
132
|
First real-world integration release. Bugs and ergonomics surfaced
|
package/GUIDE.md
CHANGED
|
@@ -205,6 +205,95 @@ Fastify, Hono, `node:http` — all work. Each handler is a plain
|
|
|
205
205
|
`(req, res) => Promise<void>` function. No framework hooks, no
|
|
206
206
|
middleware injection.
|
|
207
207
|
|
|
208
|
+
#### Trap: do NOT pre-parse the body
|
|
209
|
+
|
|
210
|
+
knowless reads `POST /login`'s body itself. If a body parser
|
|
211
|
+
middleware runs ahead of `auth.login` and consumes the stream,
|
|
212
|
+
knowless sees an empty body and silently null-routes the request
|
|
213
|
+
(as if no email was submitted). Symptoms: form posts return 200,
|
|
214
|
+
no magic link arrives, no error logged.
|
|
215
|
+
|
|
216
|
+
Express works in the example above because `express.urlencoded()`
|
|
217
|
+
is mounted as application middleware but doesn't intercept the
|
|
218
|
+
specific path. **On Hono / Fastify-without-body-plugin / raw
|
|
219
|
+
node:http, mount `auth.login` directly with no body parser in
|
|
220
|
+
front.** Same goes for `auth.logout` (it doesn't read a body, but
|
|
221
|
+
keep the symmetry).
|
|
222
|
+
|
|
223
|
+
knowless will emit a one-time `console.warn(...)` if it sees an
|
|
224
|
+
empty body where `Content-Length > 0` — that's the canary for this
|
|
225
|
+
bug.
|
|
226
|
+
|
|
227
|
+
#### Trap: non-browser callers need an Origin header
|
|
228
|
+
|
|
229
|
+
`POST /login` runs an Origin/Referer whitelist (CSRF defense, see
|
|
230
|
+
the FAQ below). Browsers always send `Origin` on cross-origin
|
|
231
|
+
POSTs, so the form path is fine. **Curl / scripts / server-to-
|
|
232
|
+
server callers must send no Origin header at all** (knowless
|
|
233
|
+
allows browser-absent requests) **or** an Origin matching your
|
|
234
|
+
`cookieDomain`. If you set a foreign Origin, the request silently
|
|
235
|
+
falls through to a sham send. For programmatic callers, prefer
|
|
236
|
+
[`auth.startLogin()`](#mode-a-use-first-claim-later) over POSTing
|
|
237
|
+
the form.
|
|
238
|
+
|
|
239
|
+
### Two adoption modes (Mode A vs Mode B)
|
|
240
|
+
|
|
241
|
+
knowless supports two UX flows out of the box. Pick per-action,
|
|
242
|
+
not per-app — both can coexist.
|
|
243
|
+
|
|
244
|
+
**Mode B — register-first (the default).** User must log in before
|
|
245
|
+
performing the action. Wire `auth.login` / `auth.callback` as
|
|
246
|
+
above; gate your action with `auth.handleFromRequest(req)`. Use
|
|
247
|
+
when the action requires a session (account settings, paid
|
|
248
|
+
features, anything you want tied to an identity at the moment of
|
|
249
|
+
the action).
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
app.post('/api/comments', (req, res) => {
|
|
253
|
+
const handle = auth.handleFromRequest(req);
|
|
254
|
+
if (!handle) return res.status(401).end();
|
|
255
|
+
// create comment owned by `handle`
|
|
256
|
+
});
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**Mode A — use-first, claim-later.** User performs the action
|
|
260
|
+
without logging in; you capture their email and trigger a magic
|
|
261
|
+
link. Clicking it opens a session and your callback handler
|
|
262
|
+
"promotes" the deferred resource. Use for "drop a pin," "post a
|
|
263
|
+
share link," "submit a paste" — patterns where forcing a login
|
|
264
|
+
*before* the action would harm the UX.
|
|
265
|
+
|
|
266
|
+
```js
|
|
267
|
+
app.post('/api/pins', async (req, res) => {
|
|
268
|
+
const { email, lat, lng } = await readJsonBody(req);
|
|
269
|
+
const owner = auth.deriveHandle(email); // AF-7.4
|
|
270
|
+
await db.insertPendingPin({ owner, lat, lng }); // your code
|
|
271
|
+
await auth.startLogin({ // AF-7.3
|
|
272
|
+
email,
|
|
273
|
+
nextUrl: 'https://app.example.com/manage',
|
|
274
|
+
sourceIp: req.socket.remoteAddress,
|
|
275
|
+
});
|
|
276
|
+
res.status(202).end(); // "we'll email you the link"
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// On callback, promote pending pins owned by the now-logged-in handle.
|
|
280
|
+
app.get('/manage', (req, res) => {
|
|
281
|
+
const owner = auth.handleFromRequest(req);
|
|
282
|
+
if (!owner) return res.redirect('/login');
|
|
283
|
+
// db.promotePendingPinsFor(owner)
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`startLogin` runs the same 12-step sham-work flow as the form
|
|
288
|
+
handler, so unknown emails, rate-limit hits, and real sends all
|
|
289
|
+
return identical shapes — the caller can't observe which happened.
|
|
290
|
+
This preserves FR-6 timing equivalence even for programmatic
|
|
291
|
+
callers. See SPEC §7.3a for the full contract.
|
|
292
|
+
|
|
293
|
+
`auth.deriveHandle(email)` returns the same opaque HMAC handle
|
|
294
|
+
that the form path uses, without you having to import the helper
|
|
295
|
+
or pass the secret around.
|
|
296
|
+
|
|
208
297
|
### Step 5: Pre-seed users (closed-registration mode, default)
|
|
209
298
|
|
|
210
299
|
By default, knowless is closed: a handle must already exist before
|
|
@@ -465,3 +554,18 @@ than weakening the bar — see SPEC §14.5.
|
|
|
465
554
|
Yes: `tokenTtlSeconds`. Don't set it absurdly high. Magic links
|
|
466
555
|
that linger in inboxes are a phishing-amplification risk if the
|
|
467
556
|
mail account is later compromised.
|
|
557
|
+
|
|
558
|
+
## Constraints / install footprint
|
|
559
|
+
|
|
560
|
+
- **Two direct dependencies.** `nodemailer` (SMTP submission) and
|
|
561
|
+
`better-sqlite3` (storage). Both audited and pinned at major
|
|
562
|
+
versions in `package.json`.
|
|
563
|
+
- **~40 transitive packages** in a typical install. The bulk are
|
|
564
|
+
`nodemailer`'s ecosystem deps (mostly idle in our usage) and
|
|
565
|
+
`better-sqlite3`'s build-time prebuild fetcher. You may see one
|
|
566
|
+
deprecation warning during install for `prebuild-install` —
|
|
567
|
+
build-chain noise, not runtime code.
|
|
568
|
+
- **Node ≥ 20.** Uses `node:util parseArgs`, `node:net BlockList`
|
|
569
|
+
for CIDR support, and `--env-file=` for the standalone server.
|
|
570
|
+
- **No optional deps, no postinstall scripts** beyond `better-
|
|
571
|
+
sqlite3`'s native binding fetch.
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ that don't need to email their users for anything but the sign-in link.
|
|
|
7
7
|
npm install knowless
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
> v0.1.
|
|
10
|
+
> v0.1.6 | Node.js >= 20 | 2 deps (nodemailer, better-sqlite3) | Apache-2.0
|
|
11
11
|
|
|
12
12
|
## What this is
|
|
13
13
|
|
|
@@ -83,12 +83,12 @@ A short list (full table in [`docs/01-product/PRD.md`](docs/01-product/PRD.md)
|
|
|
83
83
|
|
|
84
84
|
| Mode | Status | When |
|
|
85
85
|
|---|---|---|
|
|
86
|
-
| **Library mode** | v0.1.0 | Mount handlers in your existing Node app |
|
|
87
|
-
| **Standalone server** (forward-auth) | v0.
|
|
86
|
+
| **Library mode** | shipped (v0.1.0) | Mount handlers in your existing Node app |
|
|
87
|
+
| **Standalone server** (forward-auth) | shipped (v0.1.3) | Self-hosters gating Uptime Kuma / AdGuard / Pi-hole / Sonarr / etc. via Caddy or nginx |
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
Library mode is the six-line example above. Standalone server is
|
|
90
|
+
`npx knowless-server` — see [`OPS.md`](OPS.md) for the full Postfix +
|
|
91
|
+
DNS + reverse-proxy walkthrough.
|
|
92
92
|
|
|
93
93
|
## Operator commitments
|
|
94
94
|
|
package/knowless.context.md
CHANGED
|
@@ -76,6 +76,7 @@ const auth = knowless({
|
|
|
76
76
|
// --- Cookie / session ---
|
|
77
77
|
cookieDomain: 'app.example.com', // default: hostname of baseUrl
|
|
78
78
|
cookieName: 'knowless_session', // default 'knowless_session'
|
|
79
|
+
cookieSecure: true, // default true; "false" only for localhost dev
|
|
79
80
|
sessionTtlSeconds: 30 * 86400, // 30 days
|
|
80
81
|
|
|
81
82
|
// --- Token ---
|
|
@@ -97,17 +98,33 @@ const auth = knowless({
|
|
|
97
98
|
// --- Behavior ---
|
|
98
99
|
openRegistration: false, // first-email-wins handle creation
|
|
99
100
|
includeLastLoginInEmail: true, // append "Last sign-in: <ISO ts>" line
|
|
100
|
-
confirmationMessage: 'Thanks. If
|
|
101
|
+
confirmationMessage: 'Thanks. If {email} is registered, a link is on its way.',
|
|
102
|
+
// ^ NOTE: the message is HTML-escaped before render (AF-6.5).
|
|
103
|
+
// {email} placeholder still works. For HTML, pre-render upstream.
|
|
104
|
+
|
|
105
|
+
// Operator footer on magic-link mail (AF-8.2). ASCII-only, ≤240
|
|
106
|
+
// chars, ≤4 lines, NO URLs (would conflict with the magic-link line).
|
|
107
|
+
// Validated at factory startup. Use | or - as separators (NOT · which
|
|
108
|
+
// is non-ASCII).
|
|
109
|
+
bodyFooter: 'feedback@example.com | privacy first',
|
|
101
110
|
|
|
102
111
|
// --- Abuse defenses (FR-38..41) ---
|
|
103
112
|
maxActiveTokensPerHandle: 5, // 0 to disable
|
|
104
113
|
maxLoginRequestsPerIpPerHour: 30, // 0 to disable
|
|
105
114
|
maxNewHandlesPerIpPerHour: 3, // 0 to disable (open-reg only)
|
|
106
115
|
honeypotFieldName: 'website',
|
|
107
|
-
|
|
116
|
+
// Plain IPs and/or CIDR ranges (AF-6.3). Useful for k8s/docker/cgnat.
|
|
117
|
+
trustedProxies: ['127.0.0.1', '::1', '10.0.0.0/8'],
|
|
108
118
|
|
|
109
119
|
// --- Lifecycle ---
|
|
110
120
|
sweepIntervalMs: 5 * 60 * 1000, // periodic sweeper tick
|
|
121
|
+
onSweepError: (err) => { /* alerting hook; errors are swallowed */ },
|
|
122
|
+
|
|
123
|
+
// --- Dev mode (AF-6.2) ---
|
|
124
|
+
// When SMTP submission fails AND this flag is true, the magic link
|
|
125
|
+
// is printed to stderr so a developer can click through. Off by
|
|
126
|
+
// default. Never fires for sham (silent-miss) submissions.
|
|
127
|
+
devLogMagicLinks: false,
|
|
111
128
|
|
|
112
129
|
// --- Injection (tests / advanced) ---
|
|
113
130
|
store: undefined, // bring your own store
|
|
@@ -127,7 +144,12 @@ const auth = knowless({
|
|
|
127
144
|
| `verify` | (req, res) | void | GET handler (forward-auth): 200+`X-User-Handle` if cookie valid, else 401 |
|
|
128
145
|
| `logout` | (req, res) | Promise\<void\> | POST handler: clears session row + cookie |
|
|
129
146
|
| `loginForm` | (req, res) | void | GET handler: renders the hardcoded login HTML; preserves `?next=` |
|
|
147
|
+
| `handleFromRequest` | (req) | string \| null | Programmatic session resolver for in-process middleware. Returns the handle if the cookie is valid, else null. SPEC §9.4. |
|
|
130
148
|
| `deleteHandle` | (handle: string) | void | Atomic delete of handle + tokens + sessions (FR-37a, GDPR) |
|
|
149
|
+
| `revokeSessions` | (handle: string) | number | Drops every session for `handle` without deleting the account ("log out everywhere"). Returns rows removed. AF-6.1. |
|
|
150
|
+
| `startLogin` | ({email, nextUrl?, sourceIp?}) | Promise\<{handle, submitted: true}\> | Programmatic magic-link send for "use first, claim later" flows. Same 12-step sham-work as form. SPEC §7.3a. AF-7.3. |
|
|
151
|
+
| `deriveHandle` | (email: string) | string | HMAC-SHA256(secret, normalize(email)) using the configured secret. Use to compute owner-handles outside HTTP context. AF-7.4. |
|
|
152
|
+
| `_sweep` | -- | void | Trigger one sweep tick on demand (tests, operator scripts). AF-5.3. |
|
|
131
153
|
| `config` | -- | object | Merged effective config; safe to read (do not mutate) |
|
|
132
154
|
| `close` | -- | void | Stops sweeper, closes mailer + store. Call on shutdown. |
|
|
133
155
|
|
|
@@ -141,9 +163,11 @@ import {
|
|
|
141
163
|
createHandlers, // bring your own factory wiring
|
|
142
164
|
composeBody, // pure: build the mail body
|
|
143
165
|
validateSubject, // pure: validate operator-supplied subject
|
|
166
|
+
validateBodyFooter, // pure: validate operator-supplied footer (AF-8.2)
|
|
144
167
|
renderLoginForm, // pure: HTML5 page rendering
|
|
145
168
|
normalize, // pure: email normalization
|
|
146
|
-
deriveHandle, // pure: HMAC-SHA256(secret, email)
|
|
169
|
+
deriveHandle, // pure: HMAC-SHA256(hex-decoded secret, email)
|
|
170
|
+
secretBytes, // pure: coerce hex string → 32-byte HMAC key
|
|
147
171
|
} from 'knowless';
|
|
148
172
|
```
|
|
149
173
|
|
|
@@ -372,11 +396,10 @@ rate-limits) belongs above the library.
|
|
|
372
396
|
The library does NOT compute eTLD+1 automatically (would
|
|
373
397
|
require a public-suffix-list dep).
|
|
374
398
|
|
|
375
|
-
5. **`Secure` cookie attribute
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
open question; works in Chrome with `--unsafely-treat-insecure-origin-as-secure`).
|
|
399
|
+
5. **`Secure` cookie attribute toggles via `cookieSecure`.** Default
|
|
400
|
+
is `true`. Set `cookieSecure: false` ONLY for `http://localhost`
|
|
401
|
+
development; the library logs a stderr warning at startup (AF-4.4).
|
|
402
|
+
Production deployments MUST use HTTPS and leave `cookieSecure: true`.
|
|
380
403
|
|
|
381
404
|
6. **Forward-auth needs the parent-domain cookie.** If your auth
|
|
382
405
|
subdomain is `auth.example.com` and protected service is
|
|
@@ -410,6 +433,53 @@ rate-limits) belongs above the library.
|
|
|
410
433
|
so it won't *prevent* exit, but the SQLite handle held by
|
|
411
434
|
`better-sqlite3` will leave a finalizer warning.
|
|
412
435
|
|
|
436
|
+
12. **CSRF defense is the Origin/Referer whitelist, not a token.**
|
|
437
|
+
Modern browsers always emit `Origin` on cross-origin POSTs;
|
|
438
|
+
knowless validates host against `cookieDomain` on POST /login
|
|
439
|
+
AND POST /logout. Browser-absent (curl / programmatic) is
|
|
440
|
+
allowed. **Do NOT add a CSRF token upstream** — the Origin
|
|
441
|
+
check is the defense. SPEC §7.3 Step 0.
|
|
442
|
+
|
|
443
|
+
13. **`confirmationMessage` is plain text + `{email}` placeholder.**
|
|
444
|
+
The whole message is HTML-escaped before render (AF-6.5). If
|
|
445
|
+
you want bold/italic/links in the confirmation copy, pre-render
|
|
446
|
+
the HTML upstream and pass the escaped string — but understand
|
|
447
|
+
you're then responsible for not interpolating user data.
|
|
448
|
+
|
|
449
|
+
14. **`devLogMagicLinks` is opt-in and dev-only.** When set true
|
|
450
|
+
AND SMTP submission fails, the magic link is printed to stderr
|
|
451
|
+
tagged `[knowless dev:<from>]`. Sham (silent-miss) submissions
|
|
452
|
+
print a `silent-miss: ...` hint instead of a link — opt-in dev
|
|
453
|
+
only, since this leaks closed-reg state. Don't enable in
|
|
454
|
+
production.
|
|
455
|
+
|
|
456
|
+
15. **POST /login: don't pre-parse the body.** knowless reads the
|
|
457
|
+
request stream itself. Any framework body parser mounted in
|
|
458
|
+
front of `auth.login` will silently steal the form data and
|
|
459
|
+
null-route the request. knowless emits a one-time
|
|
460
|
+
`console.warn` if it sees `Content-Length > 0` with an empty
|
|
461
|
+
body. AF-7.1.
|
|
462
|
+
|
|
463
|
+
16. **Two adoption modes — Mode B (register-first) and Mode A
|
|
464
|
+
(use-first claim-later).** Mode B is the form (`auth.login`).
|
|
465
|
+
Mode A is `auth.startLogin({email, nextUrl, sourceIp})` for
|
|
466
|
+
"drop a pin, claim by email click" patterns. Both run the
|
|
467
|
+
identical 12-step sham-work flow; same FR-6 guarantee. Pick
|
|
468
|
+
per-action, not per-app.
|
|
469
|
+
|
|
470
|
+
17. **Secret is hex-decoded (AF-8.1, since v0.1.6).** Pass a
|
|
471
|
+
64-char lowercase hex string; knowless decodes to 32 raw bytes
|
|
472
|
+
before HMAC. If you're upgrading from 0.1.5 or earlier, all
|
|
473
|
+
handles and session signatures change — re-seed handles, expect
|
|
474
|
+
one user-visible logout. `Buffer` accepted directly for adopters
|
|
475
|
+
who hold raw 32-byte keys.
|
|
476
|
+
|
|
477
|
+
18. **`bodyFooter` constraints (AF-8.2).** ASCII only — `·` is NOT
|
|
478
|
+
ASCII, use `|` or `-`. ≤ 240 chars, ≤ 4 lines, no `http(s)://`
|
|
479
|
+
URLs (would conflict with the magic-link line). Validated at
|
|
480
|
+
factory startup; fails fast. Goes after RFC 3676 `"-- "`
|
|
481
|
+
delimiter so mail clients strip it from quoted replies.
|
|
482
|
+
|
|
413
483
|
## Constraints
|
|
414
484
|
|
|
415
485
|
- **Node 20+** -- targeting LTS; tested on Node 22
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knowless",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/handle.js
CHANGED
|
@@ -26,6 +26,28 @@ export function normalize(input) {
|
|
|
26
26
|
return lowered;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Coerce an operator-supplied secret to the raw bytes used as HMAC key.
|
|
31
|
+
*
|
|
32
|
+
* AF-8.1: knowless requires `secret` to be a 64-char lowercase hex
|
|
33
|
+
* string (32 bytes). Prior versions passed it to `createHmac` as an
|
|
34
|
+
* ASCII string — same 256 bits of entropy, but a different HMAC
|
|
35
|
+
* output than systems that hex-decode first. That meant adopters
|
|
36
|
+
* with existing HMAC-keyed identifiers couldn't interoperate. The
|
|
37
|
+
* fix is to hex-decode at the boundary so HMAC uses 32 raw bytes.
|
|
38
|
+
*
|
|
39
|
+
* @param {Buffer|string} secret
|
|
40
|
+
* @returns {Buffer} 32 raw bytes
|
|
41
|
+
*/
|
|
42
|
+
export function secretBytes(secret) {
|
|
43
|
+
if (Buffer.isBuffer(secret)) return secret;
|
|
44
|
+
if (typeof secret !== 'string') throw new Error('secret required');
|
|
45
|
+
if (!/^[a-f0-9]{64,}$/i.test(secret)) {
|
|
46
|
+
throw new Error('secret must be ≥64 hex chars (lowercase a-f, 0-9)');
|
|
47
|
+
}
|
|
48
|
+
return Buffer.from(secret, 'hex');
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
/**
|
|
30
52
|
* Derive the opaque handle for a normalized email using the operator secret.
|
|
31
53
|
* HMAC-SHA256, lowercase hex output, 64 chars. See SPEC §3.
|
|
@@ -34,18 +56,15 @@ export function normalize(input) {
|
|
|
34
56
|
* HMAC use of `secret` MUST add a tag prefix (see SPEC §3.4).
|
|
35
57
|
*
|
|
36
58
|
* @param {string} emailNormalized output of normalize()
|
|
37
|
-
* @param {Buffer|string} secret operator HMAC secret
|
|
59
|
+
* @param {Buffer|string} secret operator HMAC secret (32+ raw bytes or ≥64 hex chars)
|
|
38
60
|
* @returns {string} 64-char lowercase hex handle
|
|
39
61
|
*/
|
|
40
62
|
export function deriveHandle(emailNormalized, secret) {
|
|
41
63
|
if (typeof emailNormalized !== 'string' || emailNormalized.length === 0) {
|
|
42
64
|
throw new Error('emailNormalized required');
|
|
43
65
|
}
|
|
44
|
-
if (!secret || (typeof secret !== 'string' && !Buffer.isBuffer(secret))) {
|
|
45
|
-
throw new Error('secret required');
|
|
46
|
-
}
|
|
47
66
|
return crypto
|
|
48
|
-
.createHmac('sha256', secret)
|
|
67
|
+
.createHmac('sha256', secretBytes(secret))
|
|
49
68
|
.update(emailNormalized, 'utf8')
|
|
50
69
|
.digest('hex');
|
|
51
70
|
}
|
package/src/handlers.js
CHANGED
|
@@ -182,6 +182,21 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
182
182
|
// Build once at handler creation; supports plain IPs and CIDRs (AF-6.3).
|
|
183
183
|
const trustedProxies = buildTrustedPeers(cfg.trustedProxies);
|
|
184
184
|
|
|
185
|
+
// AF-7.1: emit at most one warning per handler instance about an
|
|
186
|
+
// upstream body parser swallowing the request body. Loud enough to
|
|
187
|
+
// notice in dev, quiet enough not to spam.
|
|
188
|
+
let emptyBodyWarned = false;
|
|
189
|
+
function warnEmptyBodyOnce() {
|
|
190
|
+
if (emptyBodyWarned) return;
|
|
191
|
+
emptyBodyWarned = true;
|
|
192
|
+
console.warn(
|
|
193
|
+
'[knowless] POST /login received an empty body but Content-Length > 0. ' +
|
|
194
|
+
'A body parser running ahead of this handler likely consumed the stream. ' +
|
|
195
|
+
'knowless reads req itself; do not mount express.urlencoded() / express.json() / ' +
|
|
196
|
+
'similar middleware in front of POST /login. (Warned once per instance.)',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
185
200
|
// SPEC §5.4 / FR-30: build the cookie-attribute suffix once. Secure is
|
|
186
201
|
// emitted by default and omitted only when cookieSecure: false (localhost
|
|
187
202
|
// dev). HttpOnly + SameSite=Lax are always set.
|
|
@@ -208,56 +223,46 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
208
223
|
res.end();
|
|
209
224
|
}
|
|
210
225
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
226
|
+
/**
|
|
227
|
+
* The 12-step sham-work flow, reusable by both POST /login and the
|
|
228
|
+
* programmatic auth.startLogin() entry. Returns {handle, isSham} so
|
|
229
|
+
* the form handler can drive its same-response and the programmatic
|
|
230
|
+
* caller can return the handle to its caller. See SPEC §7.3 (form)
|
|
231
|
+
* and §7.3a (programmatic). AF-7.3.
|
|
232
|
+
*
|
|
233
|
+
* Steps skipped depending on the entry point:
|
|
234
|
+
* - Origin (§7.3 Step 0): form-only; programmatic callers are
|
|
235
|
+
* trusted server-side code.
|
|
236
|
+
* - Honeypot (§7.3 Step 2): form-only; no form context.
|
|
237
|
+
*
|
|
238
|
+
* Both entries run steps 1, 3, 4–12 identically — so the timing-
|
|
239
|
+
* equivalence guarantee (FR-6) holds for either.
|
|
240
|
+
*
|
|
241
|
+
* @returns {Promise<{handle: string|null, isSham: boolean,
|
|
242
|
+
* emailNorm: string, nextValidated: string|null}>}
|
|
243
|
+
* handle is null only when the email failed to normalize (programmer
|
|
244
|
+
* bug for startLogin; same-shape silent for /login).
|
|
245
|
+
*/
|
|
246
|
+
async function runSendLink({ emailRaw, nextRaw, sourceIp }) {
|
|
233
247
|
// Step 1: parse + normalize
|
|
234
248
|
let emailNorm;
|
|
235
249
|
try {
|
|
236
250
|
emailNorm = normalize(emailRaw);
|
|
237
251
|
} catch {
|
|
238
|
-
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Step 2: honeypot — exempt short-circuit (no sham work)
|
|
243
|
-
if (typeof honeypot === 'string' && honeypot.length > 0) {
|
|
244
|
-
sameResponse(res, emailNorm, nextRaw);
|
|
245
|
-
return;
|
|
252
|
+
return { handle: null, isSham: false, emailNorm: emailRaw, nextValidated: null };
|
|
246
253
|
}
|
|
247
254
|
|
|
248
255
|
// Step 3: per-IP rate limit on /login — exempt short-circuit
|
|
249
|
-
const ip = determineSourceIp(req, trustedProxies);
|
|
250
256
|
if (
|
|
251
257
|
rateLimitExceeded(
|
|
252
258
|
store,
|
|
253
259
|
'login_ip',
|
|
254
|
-
|
|
260
|
+
sourceIp,
|
|
255
261
|
cfg.maxLoginRequestsPerIpPerHour,
|
|
256
262
|
HOUR_MS,
|
|
257
263
|
)
|
|
258
264
|
) {
|
|
259
|
-
|
|
260
|
-
return;
|
|
265
|
+
return { handle: null, isSham: false, emailNorm, nextValidated: null };
|
|
261
266
|
}
|
|
262
267
|
|
|
263
268
|
// ---- Equivalent-work region begins (SPEC §7.3 step 4) ----
|
|
@@ -271,7 +276,7 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
271
276
|
rateLimitExceeded(
|
|
272
277
|
store,
|
|
273
278
|
'create_ip',
|
|
274
|
-
|
|
279
|
+
sourceIp,
|
|
275
280
|
cfg.maxNewHandlesPerIpPerHour,
|
|
276
281
|
HOUR_MS,
|
|
277
282
|
)
|
|
@@ -314,6 +319,7 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
314
319
|
baseUrl: cfg.baseUrl,
|
|
315
320
|
linkPath: cfg.linkPath,
|
|
316
321
|
lastLoginAt,
|
|
322
|
+
bodyFooter: cfg.bodyFooter,
|
|
317
323
|
});
|
|
318
324
|
|
|
319
325
|
try {
|
|
@@ -323,18 +329,91 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
323
329
|
console.error('[knowless] mail submit failed:', err.message);
|
|
324
330
|
// AF-6.2: dev-mode fallback. When SMTP is unreachable in local
|
|
325
331
|
// development the operator otherwise has no way to obtain the magic
|
|
326
|
-
// link. Print it to stderr only when explicitly opted in.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
332
|
+
// link. Print it to stderr only when explicitly opted in.
|
|
333
|
+
if (cfg.devLogMagicLinks) {
|
|
334
|
+
// AF-7.6: include `from` to disambiguate multi-instance logs.
|
|
335
|
+
const tag = `[knowless dev:${cfg.from}]`;
|
|
336
|
+
if (isSham) {
|
|
337
|
+
// AF-7.2 dev hint: silent-miss is by-design but in dev mode
|
|
338
|
+
// operators repeatedly debug "why no link?" — surface the
|
|
339
|
+
// reason. Only fires on opt-in dev mode + SMTP failure.
|
|
340
|
+
process.stderr.write(
|
|
341
|
+
`${tag} silent-miss: handle for "${emailNorm}" does not exist (openRegistration=${cfg.openRegistration})\n`,
|
|
342
|
+
);
|
|
343
|
+
} else {
|
|
344
|
+
const link = `${cfg.baseUrl}${cfg.linkPath}?t=${token.raw}`;
|
|
345
|
+
process.stderr.write(`${tag} magic link: ${link}\n`);
|
|
346
|
+
}
|
|
331
347
|
}
|
|
332
348
|
}
|
|
333
349
|
|
|
334
|
-
rateLimitIncrement(store, 'login_ip',
|
|
335
|
-
if (isCreating) rateLimitIncrement(store, 'create_ip',
|
|
350
|
+
rateLimitIncrement(store, 'login_ip', sourceIp, HOUR_MS);
|
|
351
|
+
if (isCreating) rateLimitIncrement(store, 'create_ip', sourceIp, HOUR_MS);
|
|
336
352
|
|
|
337
|
-
|
|
353
|
+
return { handle, isSham, emailNorm, nextValidated };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function login(req, res) {
|
|
357
|
+
// Step 0 — Origin / Referer validation (SPEC §7.3 Step 0, AF-4.3).
|
|
358
|
+
if (!validateOrigin(req, cfg.cookieDomain)) {
|
|
359
|
+
sameResponse(res, '', '');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let raw;
|
|
364
|
+
try {
|
|
365
|
+
raw = await readBody(req);
|
|
366
|
+
} catch {
|
|
367
|
+
sameResponse(res, '', '');
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// AF-7.1: warn when a body parser ahead of us has consumed the stream.
|
|
371
|
+
// POST /login with Content-Length > 0 but empty raw body is the
|
|
372
|
+
// signature; without this, the request silently null-routes and the
|
|
373
|
+
// adopter loses 30 minutes wondering why magic links never arrive.
|
|
374
|
+
if (raw.length === 0) {
|
|
375
|
+
const cl = Number(req.headers?.['content-length']);
|
|
376
|
+
if (Number.isFinite(cl) && cl > 0) {
|
|
377
|
+
warnEmptyBodyOnce();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const body = parseBody(raw, req.headers['content-type']);
|
|
381
|
+
const emailRaw = typeof body.email === 'string' ? body.email : '';
|
|
382
|
+
const honeypot = body[cfg.honeypotFieldName];
|
|
383
|
+
const nextRaw = body.next;
|
|
384
|
+
|
|
385
|
+
// Step 2: honeypot — exempt short-circuit (no sham work)
|
|
386
|
+
if (typeof honeypot === 'string' && honeypot.length > 0) {
|
|
387
|
+
sameResponse(res, emailRaw, nextRaw);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const sourceIp = determineSourceIp(req, trustedProxies);
|
|
392
|
+
const result = await runSendLink({ emailRaw, nextRaw, sourceIp });
|
|
393
|
+
sameResponse(res, result.emailNorm, result.nextValidated ?? '');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function startLogin({ email, nextUrl, sourceIp = '' } = {}) {
|
|
397
|
+
// Programmer-error guards (AF-7.3). These DO throw; they're not
|
|
398
|
+
// silent-miss conditions, they're "you called the API wrong."
|
|
399
|
+
if (typeof email !== 'string' || email.length === 0) {
|
|
400
|
+
throw new Error('startLogin: email is required (string)');
|
|
401
|
+
}
|
|
402
|
+
if (nextUrl !== undefined && nextUrl !== null && typeof nextUrl !== 'string') {
|
|
403
|
+
throw new Error('startLogin: nextUrl must be a string when provided');
|
|
404
|
+
}
|
|
405
|
+
if (typeof sourceIp !== 'string') {
|
|
406
|
+
throw new Error('startLogin: sourceIp must be a string when provided');
|
|
407
|
+
}
|
|
408
|
+
const { handle } = await runSendLink({
|
|
409
|
+
emailRaw: email,
|
|
410
|
+
nextRaw: nextUrl ?? null,
|
|
411
|
+
sourceIp,
|
|
412
|
+
});
|
|
413
|
+
// Same-shape return: rate-limit / sham / real all collapse here.
|
|
414
|
+
// `handle` is the HMAC of the normalized email (or null if email
|
|
415
|
+
// was malformed). It leaks nothing about existence per FR-6.
|
|
416
|
+
return { handle, submitted: true };
|
|
338
417
|
}
|
|
339
418
|
|
|
340
419
|
async function callback(req, res) {
|
|
@@ -453,6 +532,7 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
453
532
|
logout,
|
|
454
533
|
loginForm,
|
|
455
534
|
handleFromRequest,
|
|
535
|
+
startLogin,
|
|
456
536
|
validateNextUrl: (raw) => validateNextUrl(raw, cfg.baseUrl, cfg.cookieDomain),
|
|
457
537
|
// exposed for tests
|
|
458
538
|
_config: cfg,
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createStore } from './store.js';
|
|
2
|
-
import { createMailer } from './mailer.js';
|
|
2
|
+
import { createMailer, validateBodyFooter } from './mailer.js';
|
|
3
3
|
import { createHandlers } from './handlers.js';
|
|
4
|
+
import { deriveHandle as deriveHandleRaw } from './handle.js';
|
|
4
5
|
|
|
5
6
|
/** Default sweeper tick: 5 minutes. Per FR-13. */
|
|
6
7
|
const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
@@ -72,8 +73,12 @@ export function knowless(options = {}) {
|
|
|
72
73
|
for (const f of REQUIRED_FIELDS) {
|
|
73
74
|
if (!options[f]) throw new Error(`knowless: ${f} is required`);
|
|
74
75
|
}
|
|
75
|
-
if (typeof options.secret !== 'string' || options.secret
|
|
76
|
-
throw new Error('knowless: secret must be at least 64 hex chars (32 bytes)');
|
|
76
|
+
if (typeof options.secret !== 'string' || !/^[a-f0-9]{64,}$/i.test(options.secret)) {
|
|
77
|
+
throw new Error('knowless: secret must be at least 64 hex chars (32 bytes, lowercase a-f, 0-9)');
|
|
78
|
+
}
|
|
79
|
+
// Validate operator-supplied body footer at startup (AF-8.2).
|
|
80
|
+
if (options.bodyFooter !== undefined && options.bodyFooter !== null) {
|
|
81
|
+
validateBodyFooter(options.bodyFooter);
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
// SPEC §5.4: cookieSecure: false is allowed only for localhost dev.
|
|
@@ -141,6 +146,16 @@ export function knowless(options = {}) {
|
|
|
141
146
|
* "Log out everywhere." Returns the number of sessions removed.
|
|
142
147
|
* AF-6.1. */
|
|
143
148
|
revokeSessions: (handle) => store.revokeSessions(handle),
|
|
149
|
+
/** Programmatic magic-link send. Use this for "use first, claim
|
|
150
|
+
* later" UX flows (drop a pin, post a comment, then confirm via
|
|
151
|
+
* email). Returns `{handle, submitted: true}` — same shape on
|
|
152
|
+
* rate-limit / sham / real to preserve FR-6 timing equivalence.
|
|
153
|
+
* See SPEC §7.3a. AF-7.3. */
|
|
154
|
+
startLogin: handlers.startLogin,
|
|
155
|
+
/** Derive the opaque handle for an email using the configured
|
|
156
|
+
* secret. Lets adopters compute owner-handles outside HTTP context
|
|
157
|
+
* without spreading the secret across modules. AF-7.4. */
|
|
158
|
+
deriveHandle: (email) => deriveHandleRaw(email, options.secret),
|
|
144
159
|
/** Effective config (with defaults applied), useful for routing. */
|
|
145
160
|
config: handlers._config,
|
|
146
161
|
/** Run a sweep tick on demand. Useful for tests and operator scripts. */
|
|
@@ -158,7 +173,7 @@ export function knowless(options = {}) {
|
|
|
158
173
|
}
|
|
159
174
|
|
|
160
175
|
export { createStore } from './store.js';
|
|
161
|
-
export { createMailer, composeBody, validateSubject } from './mailer.js';
|
|
176
|
+
export { createMailer, composeBody, validateSubject, validateBodyFooter } from './mailer.js';
|
|
162
177
|
export { createHandlers } from './handlers.js';
|
|
163
178
|
export { renderLoginForm } from './form.js';
|
|
164
|
-
export { normalize, deriveHandle } from './handle.js';
|
|
179
|
+
export { normalize, deriveHandle, secretBytes } from './handle.js';
|
package/src/mailer.js
CHANGED
|
@@ -52,6 +52,39 @@ function composeRaw({ from, to, subject, body }) {
|
|
|
52
52
|
return `${headers}\r\n\r\n${normalized}`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Validate an operator-supplied body footer per AF-8.2.
|
|
57
|
+
*
|
|
58
|
+
* Constraints (deliberately strict to preserve the URL-line invariant
|
|
59
|
+
* and 7bit body encoding from the v0.11 POC finding):
|
|
60
|
+
* - ASCII only
|
|
61
|
+
* - ≤ 240 chars
|
|
62
|
+
* - No CR (LF allowed; line count ≤ 4)
|
|
63
|
+
* - No `http://` / `https://` substring (avoids URL-line confusion
|
|
64
|
+
* and avoids triggering MTA URL-rewriting heuristics)
|
|
65
|
+
*
|
|
66
|
+
* Throws on any violation. Returns the (already-trimmed) footer.
|
|
67
|
+
*
|
|
68
|
+
* @param {unknown} footer
|
|
69
|
+
* @returns {string|null}
|
|
70
|
+
*/
|
|
71
|
+
export function validateBodyFooter(footer) {
|
|
72
|
+
if (footer == null || footer === '') return null;
|
|
73
|
+
if (typeof footer !== 'string') {
|
|
74
|
+
throw new Error('bodyFooter must be a string');
|
|
75
|
+
}
|
|
76
|
+
if (footer.length > 240) throw new Error('bodyFooter must be ≤ 240 chars');
|
|
77
|
+
if (!ASCII_RE.test(footer)) throw new Error('bodyFooter must be ASCII');
|
|
78
|
+
if (footer.includes('\r')) throw new Error('bodyFooter must not contain CR');
|
|
79
|
+
if (footer.split('\n').length > 4) {
|
|
80
|
+
throw new Error('bodyFooter must be ≤ 4 lines');
|
|
81
|
+
}
|
|
82
|
+
if (/https?:\/\//i.test(footer)) {
|
|
83
|
+
throw new Error('bodyFooter must not contain URLs (would conflict with the magic-link line)');
|
|
84
|
+
}
|
|
85
|
+
return footer;
|
|
86
|
+
}
|
|
87
|
+
|
|
55
88
|
/**
|
|
56
89
|
* Compose the plain-text body of the magic-link email per SPEC §12.2.
|
|
57
90
|
*
|
|
@@ -68,6 +101,11 @@ function composeRaw({ from, to, subject, body }) {
|
|
|
68
101
|
* Last sign-in: <ISO 8601 UTC timestamp>.
|
|
69
102
|
* If that wasn't you, do not click the link above.
|
|
70
103
|
*
|
|
104
|
+
* Plus, when bodyFooter is provided (AF-8.2):
|
|
105
|
+
*
|
|
106
|
+
* --
|
|
107
|
+
* <footer text>
|
|
108
|
+
*
|
|
71
109
|
* The URL appears on its own line. Body is ASCII-only.
|
|
72
110
|
*
|
|
73
111
|
* @param {object} args
|
|
@@ -75,9 +113,10 @@ function composeRaw({ from, to, subject, body }) {
|
|
|
75
113
|
* @param {string} args.baseUrl e.g. 'https://app.example.com'
|
|
76
114
|
* @param {string} args.linkPath e.g. '/auth/callback'
|
|
77
115
|
* @param {number|null} [args.lastLoginAt] Unix ms; null/undefined to omit
|
|
116
|
+
* @param {string|null} [args.bodyFooter] operator footer; pre-validated
|
|
78
117
|
* @returns {string} the body text (ASCII)
|
|
79
118
|
*/
|
|
80
|
-
export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt }) {
|
|
119
|
+
export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt, bodyFooter }) {
|
|
81
120
|
const url = `${baseUrl}${linkPath}?t=${tokenRaw}`;
|
|
82
121
|
let body =
|
|
83
122
|
'Click to sign in:\n\n' +
|
|
@@ -89,6 +128,11 @@ export function composeBody({ tokenRaw, baseUrl, linkPath, lastLoginAt }) {
|
|
|
89
128
|
body +=
|
|
90
129
|
`\nLast sign-in: ${iso}.\n` + 'If that wasn\'t you, do not click the link above.\n';
|
|
91
130
|
}
|
|
131
|
+
if (bodyFooter) {
|
|
132
|
+
// Standard email signature delimiter: "-- " (dash-dash-space) on
|
|
133
|
+
// its own line. Mail clients strip this section from quoted replies.
|
|
134
|
+
body += `\n-- \n${bodyFooter}\n`;
|
|
135
|
+
}
|
|
92
136
|
if (!ASCII_RE.test(body)) {
|
|
93
137
|
throw new Error('mail body contains non-ASCII');
|
|
94
138
|
}
|
|
@@ -151,6 +195,18 @@ export function createMailer(cfg) {
|
|
|
151
195
|
auth: undefined,
|
|
152
196
|
});
|
|
153
197
|
|
|
198
|
+
// AF-7.5: validate the resolved transport at startup. Without this,
|
|
199
|
+
// a malformed transportOverride (or a bare options bag mistaken for a
|
|
200
|
+
// transport) silently constructs something that throws "sendMail is
|
|
201
|
+
// not a function" only at first submission — which in production may
|
|
202
|
+
// be hours later. Fail fast at factory time instead.
|
|
203
|
+
if (typeof transport.sendMail !== 'function') {
|
|
204
|
+
throw new Error(
|
|
205
|
+
'mailer: transport has no sendMail() — if you passed transportOverride, ' +
|
|
206
|
+
'pass the result of nodemailer.createTransport(opts), not an opts bag.',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
154
210
|
return {
|
|
155
211
|
async submit({ to, subject, body }) {
|
|
156
212
|
if (typeof to !== 'string' || !ASCII_RE.test(to)) {
|
package/src/session.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import { secretBytes } from './handle.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Domain-separation tag for session signatures. See SPEC §3.4 / §5.2.
|
|
@@ -22,7 +23,7 @@ export function newSid() {
|
|
|
22
23
|
*/
|
|
23
24
|
function signature(sidB64u, secret) {
|
|
24
25
|
return crypto
|
|
25
|
-
.createHmac('sha256', secret)
|
|
26
|
+
.createHmac('sha256', secretBytes(secret))
|
|
26
27
|
.update(SESS_TAG)
|
|
27
28
|
.update(sidB64u, 'utf8')
|
|
28
29
|
.digest('hex');
|
|
@@ -40,9 +41,6 @@ export function signSession(sidB64u, secret) {
|
|
|
40
41
|
if (typeof sidB64u !== 'string' || !/^[A-Za-z0-9_-]+$/.test(sidB64u)) {
|
|
41
42
|
throw new Error('invalid sid');
|
|
42
43
|
}
|
|
43
|
-
if (!secret || (typeof secret !== 'string' && !Buffer.isBuffer(secret))) {
|
|
44
|
-
throw new Error('secret required');
|
|
45
|
-
}
|
|
46
44
|
return `${sidB64u}.${signature(sidB64u, secret)}`;
|
|
47
45
|
}
|
|
48
46
|
|