knowless 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -0
- package/GUIDE.md +441 -0
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +167 -0
- package/knowless.context.md +429 -0
- package/package.json +48 -0
- package/src/abuse.js +80 -0
- package/src/form.js +102 -0
- package/src/handle.js +51 -0
- package/src/handlers.js +383 -0
- package/src/index.js +132 -0
- package/src/mailer.js +156 -0
- package/src/session.js +75 -0
- package/src/store.js +265 -0
- package/src/token.js +38 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `knowless` are recorded here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
Versioning is [SemVer](https://semver.org/).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
- Standalone server (`bin/knowless-server`): env-var-driven CLI with
|
|
11
|
+
`--print-config` and `--config-check`, forward-auth deployment shape
|
|
12
|
+
for self-hosters gating no-auth services. (Tracked in TASKS.md
|
|
13
|
+
Phase 6.)
|
|
14
|
+
- `OPS.md`: full operator setup walkthrough (Postfix, null-route for
|
|
15
|
+
sham mail, SPF/DKIM/PTR, reverse-proxy configs for Caddy / nginx /
|
|
16
|
+
Traefik). (Tracked in TASKS.md Phase 7.)
|
|
17
|
+
|
|
18
|
+
## [0.1.0] — 2026-04-28
|
|
19
|
+
|
|
20
|
+
First public release. Library-mode auth flow is complete and
|
|
21
|
+
production-grounded; standalone-server deployment shape ships in 0.2.0.
|
|
22
|
+
|
|
23
|
+
### Added — library
|
|
24
|
+
|
|
25
|
+
- `knowless({ secret, baseUrl, from, ... })` factory wires store,
|
|
26
|
+
mailer, handlers, and a periodic sweeper.
|
|
27
|
+
- Five framework-agnostic HTTP handlers: `login`, `callback`, `verify`,
|
|
28
|
+
`logout`, `loginForm`. Each is `(req, res) => Promise<void>`,
|
|
29
|
+
mountable on Express / Fastify / Hono / `node:http`.
|
|
30
|
+
- `deleteHandle(handle)` for GDPR right-to-erasure (FR-37a). Removes
|
|
31
|
+
the handle, all active tokens, all active sessions, and the
|
|
32
|
+
`last_login_at` row in one transaction.
|
|
33
|
+
- `close()` stops the sweeper and the SQLite handle for graceful
|
|
34
|
+
shutdown.
|
|
35
|
+
|
|
36
|
+
### Added — privacy / security
|
|
37
|
+
|
|
38
|
+
- **Silent-on-miss with practical timing equivalence (FR-6).** The
|
|
39
|
+
registered-hit and silent-miss paths are practically
|
|
40
|
+
indistinguishable: the in-tree timing test (SPEC §14, 1ms
|
|
41
|
+
delta-mean bar) measures `Δ_mean = 0.002ms` on commodity hardware
|
|
42
|
+
— 500× under the bar. Achieved via the four-step sham-work
|
|
43
|
+
pattern in SPEC §7.3.
|
|
44
|
+
- **Sham mail goes to a configurable null-route address** (default
|
|
45
|
+
`null@knowless.invalid`), not to the unregistered email. Real
|
|
46
|
+
users never receive unsolicited mail. Operator's MTA discards via
|
|
47
|
+
`transport_maps`. Documented in OPS.md (forthcoming) and SPEC §7.4.
|
|
48
|
+
- **Plaintext email never persisted.** Handle is `HMAC-SHA256(secret,
|
|
49
|
+
normalized_email)` per SPEC §3. DB-only leak yields opaque hashes,
|
|
50
|
+
not a mailing list.
|
|
51
|
+
- **Plain-text 7bit ASCII mail** with the magic-link URL on its own
|
|
52
|
+
line (FR-17). Sidesteps the v0.11 POC finding that nodemailer's
|
|
53
|
+
default quoted-printable encoding wraps the URL with `=\n` soft
|
|
54
|
+
breaks. Implemented by composing the RFC822 message ourselves and
|
|
55
|
+
using nodemailer only as the SMTP submission transport.
|
|
56
|
+
- **Tokens stored as SHA-256 hashes** at rest (FR-13). 256-bit
|
|
57
|
+
entropy from `node:crypto.randomBytes`, base64url-encoded raw
|
|
58
|
+
(43 chars). Single-use; used / expired tokens are swept on a
|
|
59
|
+
schedule.
|
|
60
|
+
- **Session cookies are signed** with HMAC-SHA256 (`sess\0`
|
|
61
|
+
domain-separated), `Secure; HttpOnly; SameSite=Lax`. Server-side
|
|
62
|
+
expiry enforced via stored row; cookie expiry is advisory.
|
|
63
|
+
- **Replay protection** via atomic `markTokenUsed`. Replays return
|
|
64
|
+
the same response as expired or never-existed.
|
|
65
|
+
- **Forward-auth return URL** via DB-bound `next_url` on the token
|
|
66
|
+
row (SPEC §11). Same security as URL-signing without bloating the
|
|
67
|
+
magic link. Cross-domain `next` is silently dropped per the
|
|
68
|
+
cookie-domain whitelist; `javascript:` and other schemes
|
|
69
|
+
rejected.
|
|
70
|
+
- **Per-IP and per-handle rate limiting** with safe defaults
|
|
71
|
+
(FR-38, FR-39, FR-40). Per-IP login cap: 30/hour. Per-handle
|
|
72
|
+
active token cap: 5 (newest replaces oldest). Per-IP
|
|
73
|
+
account-creation cap (open-registration only): 3/hour.
|
|
74
|
+
- **Honeypot field** in the login form (FR-41), `aria-hidden="true"`
|
|
75
|
+
and `tabindex="-1"` so screen-reader users aren't trapped.
|
|
76
|
+
- **No JS, no external resources** in any HTML page (FR-22). Inline
|
|
77
|
+
CSS only. Login form works in text-mode browsers.
|
|
78
|
+
|
|
79
|
+
### Added — storage
|
|
80
|
+
|
|
81
|
+
- `better-sqlite3`-backed store implementing the SPEC §13 interface.
|
|
82
|
+
WAL journal mode, prepared-statement caching, transactional
|
|
83
|
+
token issuance with cap-eviction, transactional `deleteHandle`.
|
|
84
|
+
- Periodic sweeper (default 5 min) deletes expired tokens (with
|
|
85
|
+
24h grace for redeemed ones), expired sessions, and rate-limit
|
|
86
|
+
rows older than 24h.
|
|
87
|
+
|
|
88
|
+
### Added — docs
|
|
89
|
+
|
|
90
|
+
- `docs/01-product/PRD.md` (v0.12) — product requirements.
|
|
91
|
+
- `docs/02-design/SPEC.md` (v0.1) — wire formats, byte layouts,
|
|
92
|
+
algorithms.
|
|
93
|
+
- `docs/03-tasks/TASKS.md` (v0.1) — 8-phase implementation plan.
|
|
94
|
+
- `README.md`, `GUIDE.md`, `knowless.context.md`, `CHANGELOG.md`.
|
|
95
|
+
|
|
96
|
+
### Tests
|
|
97
|
+
|
|
98
|
+
- 102 tests passing on Node 20+ and Node 22+.
|
|
99
|
+
- Testing Trophy: ~50 unit tests (handle, token, session, form,
|
|
100
|
+
abuse), ~50 integration tests (store, mailer, full-flow,
|
|
101
|
+
sham-work, forward-auth-next, library-mode), 1 timing test
|
|
102
|
+
(FR-6 acceptance gate).
|
|
103
|
+
|
|
104
|
+
### Production deps
|
|
105
|
+
|
|
106
|
+
- `nodemailer` ^8.0.7 — SMTP submission to localhost MTA.
|
|
107
|
+
- `better-sqlite3` ^11.0.0 — synchronous SQLite via N-API.
|
|
108
|
+
|
|
109
|
+
Two deps total. Both stable, MIT-licensed, well-maintained.
|
|
110
|
+
|
|
111
|
+
### Audience
|
|
112
|
+
|
|
113
|
+
Two primary audiences (PRD §4):
|
|
114
|
+
|
|
115
|
+
1. **In-app services where auth is the only legitimate email need.**
|
|
116
|
+
Indie tools, side projects, internal dashboards, member areas,
|
|
117
|
+
self-hosted apps. Library mode.
|
|
118
|
+
2. **Self-hosters gating services without good auth.** Uptime Kuma,
|
|
119
|
+
AdGuard Home, Pi-hole, Sonarr, Jellyfin admin, etc. Standalone
|
|
120
|
+
server mode (ships in 0.2.0).
|
|
121
|
+
|
|
122
|
+
### Known limitations (deliberate)
|
|
123
|
+
|
|
124
|
+
- **ASCII-only email addresses** in v0.1. IDN support deferred to
|
|
125
|
+
0.2 (SPEC §15 Q-5).
|
|
126
|
+
- **Standalone server not yet shipped.** v0.1.0 is library-mode
|
|
127
|
+
only. Use as `import { knowless } from 'knowless'` and mount
|
|
128
|
+
the handlers on your existing HTTP framework.
|
|
129
|
+
- **No standalone server `bin/knowless-server`** — that's 0.2.0.
|
|
130
|
+
Forward-auth deployments wait for 0.2.0 unless you write a small
|
|
131
|
+
`node:http` wrapper yourself; see GUIDE.md for the ~30-line
|
|
132
|
+
pattern.
|
|
133
|
+
- **Postfix on localhost is the only outbound mail transport.**
|
|
134
|
+
No remote SMTP, no Mailgun / Postmark / SES (intentional, see
|
|
135
|
+
PRD §16.2).
|
|
136
|
+
|
|
137
|
+
### License
|
|
138
|
+
|
|
139
|
+
Apache 2.0 with NOTICE preservation. See `LICENSE` and `NOTICE`.
|
|
140
|
+
|
|
141
|
+
[Unreleased]: https://github.com/hamr0/knowless/compare/v0.1.0...HEAD
|
|
142
|
+
[0.1.0]: https://github.com/hamr0/knowless/releases/tag/v0.1.0
|
package/GUIDE.md
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
# knowless — Adopter Guide
|
|
2
|
+
|
|
3
|
+
> The "is this for me, and how do I wire it up" doc. For the dense
|
|
4
|
+
> AI-agent reference, see [`knowless.context.md`](knowless.context.md).
|
|
5
|
+
> For the product philosophy, see
|
|
6
|
+
> [`docs/01-product/PRD.md`](docs/01-product/PRD.md).
|
|
7
|
+
|
|
8
|
+
## Who this is for
|
|
9
|
+
|
|
10
|
+
Three audiences, in order of fit:
|
|
11
|
+
|
|
12
|
+
### 1. In-app services where auth is the only legitimate email need
|
|
13
|
+
|
|
14
|
+
You're building something where users log in, do their work in the
|
|
15
|
+
app, leave. Email is purely the door opener — once they're in, the
|
|
16
|
+
app delivers value through its UI.
|
|
17
|
+
|
|
18
|
+
Good fits:
|
|
19
|
+
- Web apps and SaaS dashboards (occasional login, work in-app)
|
|
20
|
+
- Indie tools and side projects with infrequent users
|
|
21
|
+
- Small-business B2B internal tools (HR portals, ops dashboards)
|
|
22
|
+
- Member areas, paywalled forums, community sites
|
|
23
|
+
- Self-hosted apps your team uses
|
|
24
|
+
|
|
25
|
+
The disqualifier isn't service type — it's **email needs**. If you
|
|
26
|
+
genuinely need to send order confirmations, subscription renewals,
|
|
27
|
+
billing notifications, calendar invites, or any digest /
|
|
28
|
+
newsletter, knowless is the wrong choice. Use a vendor with
|
|
29
|
+
deliverability as their core business (Postmark, SES, Mailgun).
|
|
30
|
+
|
|
31
|
+
### 2. Self-hosters gating services without good native auth
|
|
32
|
+
|
|
33
|
+
You're running Uptime Kuma, AdGuard Home, Pi-hole, Sonarr, Jellyfin,
|
|
34
|
+
n8n, Homepage, Heimdall, Portainer, Paperless-ngx — and their
|
|
35
|
+
built-in auth is either missing or weak. The existing alternatives
|
|
36
|
+
(Authelia, Authentik, Keycloak, oauth2-proxy) are heavyweight for
|
|
37
|
+
the job: "redirect to login if no cookie, otherwise let through."
|
|
38
|
+
|
|
39
|
+
knowless's standalone server (v0.2.0, in development) sits behind
|
|
40
|
+
Caddy / nginx / Traefik via forward-auth. One auth subdomain, one
|
|
41
|
+
session cookie scoped to the parent eTLD+1, SSO across all your
|
|
42
|
+
services for free.
|
|
43
|
+
|
|
44
|
+
### 3. Privacy-skeptical developers building for clients
|
|
45
|
+
|
|
46
|
+
Small businesses, non-profits, EU operators, healthcare-adjacent,
|
|
47
|
+
legal, education. Where the privacy story is part of the sale.
|
|
48
|
+
knowless gives you a clean, defensible answer to "what data do you
|
|
49
|
+
store about your users?": *an opaque salted hash of their email,
|
|
50
|
+
nothing else*.
|
|
51
|
+
|
|
52
|
+
## Who this isn't for
|
|
53
|
+
|
|
54
|
+
- Apps that need to send any email beyond the sign-in link (order
|
|
55
|
+
confirmations, billing, reminders, digests, newsletters,
|
|
56
|
+
calendar invites)
|
|
57
|
+
- Apps that need OAuth / OIDC / SAML / federated identity
|
|
58
|
+
- Apps that need integrated 2FA / WebAuthn / TOTP (compose
|
|
59
|
+
separately if needed)
|
|
60
|
+
- Teams without VPS ops capability — running your own Postfix is
|
|
61
|
+
real work
|
|
62
|
+
- Anything where email deliverability problems would be
|
|
63
|
+
catastrophic
|
|
64
|
+
|
|
65
|
+
## What knowless commits to (so you know what you're getting)
|
|
66
|
+
|
|
67
|
+
- **Plaintext email is never persisted.** It's salted-hashed
|
|
68
|
+
(`HMAC-SHA256(secret, normalized_email)`) on the way in and
|
|
69
|
+
discarded.
|
|
70
|
+
- **Only the magic link is ever sent.** No welcome email. No
|
|
71
|
+
digest. No notification. The library has no API to send anything
|
|
72
|
+
else.
|
|
73
|
+
- **All outbound mail goes via your localhost MTA.** No Postmark.
|
|
74
|
+
No SES. No vendor credentials.
|
|
75
|
+
- **The login flow is timing-equivalent** between registered and
|
|
76
|
+
unregistered emails — practical-effect-size delta under 1ms,
|
|
77
|
+
measured by a CI test.
|
|
78
|
+
- **The library is self-contained.** Two npm deps. No build step.
|
|
79
|
+
Plain ES modules with JSDoc.
|
|
80
|
+
- **Walks away at v1.0.0.** Maintenance mode (security patches +
|
|
81
|
+
bug fixes) after that, by design.
|
|
82
|
+
|
|
83
|
+
## Walkthrough: library mode (v0.1.0)
|
|
84
|
+
|
|
85
|
+
The shape: import `knowless`, configure it, mount the handlers on
|
|
86
|
+
your HTTP framework.
|
|
87
|
+
|
|
88
|
+
### Step 1: Install
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
npm install knowless
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Requires Node.js 20+ (LTS until April 2026).
|
|
95
|
+
|
|
96
|
+
### Step 2: Generate the secret
|
|
97
|
+
|
|
98
|
+
The HMAC secret is the keystone of the privacy model. It must be
|
|
99
|
+
≥32 random bytes (≥64 hex chars).
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Store it in your env vars / secret manager. **Never** commit it.
|
|
106
|
+
Rotating the secret invalidates every existing handle and session
|
|
107
|
+
— it's a one-way switch.
|
|
108
|
+
|
|
109
|
+
### Step 3: Set up Postfix on localhost
|
|
110
|
+
|
|
111
|
+
knowless submits SMTP to `localhost:25`. You need a localhost MTA.
|
|
112
|
+
|
|
113
|
+
On Ubuntu / Debian:
|
|
114
|
+
```
|
|
115
|
+
sudo apt install postfix
|
|
116
|
+
# Pick "Internet Site" when prompted
|
|
117
|
+
# System mail name: your sending domain (e.g. app.example.com)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Add the **null-route** for sham mail (this is the destination
|
|
121
|
+
knowless uses on silent-miss to keep timing equivalence without
|
|
122
|
+
delivering unsolicited mail to unregistered addresses):
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
# /etc/postfix/transport
|
|
126
|
+
knowless.invalid discard:silently dropped by knowless null-route
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
# /etc/postfix/main.cf — add this line
|
|
131
|
+
transport_maps = hash:/etc/postfix/transport
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
sudo postmap /etc/postfix/transport
|
|
136
|
+
sudo systemctl reload postfix
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Then the DNS records — set on your sending domain, **not** your
|
|
140
|
+
app's primary domain (typical setup: `auth.example.com` is the
|
|
141
|
+
sending domain):
|
|
142
|
+
|
|
143
|
+
- **SPF**: `v=spf1 ip4:<your-server-ip> -all`
|
|
144
|
+
- **DKIM**: generate via `opendkim-genkey` and publish the public
|
|
145
|
+
key as a TXT record
|
|
146
|
+
- **PTR (reverse DNS)**: ask your VPS provider to set the PTR for
|
|
147
|
+
your IP to your sending hostname
|
|
148
|
+
|
|
149
|
+
Without all three, Gmail / Outlook will silently drop your auth
|
|
150
|
+
mail. This is the operator commitment knowless asks of you.
|
|
151
|
+
|
|
152
|
+
> Full Postfix walkthrough lives in `OPS.md` (shipping with v0.2.0).
|
|
153
|
+
|
|
154
|
+
### Step 4: Mount the handlers
|
|
155
|
+
|
|
156
|
+
Express:
|
|
157
|
+
```js
|
|
158
|
+
import express from 'express';
|
|
159
|
+
import { knowless } from 'knowless';
|
|
160
|
+
|
|
161
|
+
const app = express();
|
|
162
|
+
const auth = knowless({
|
|
163
|
+
secret: process.env.KNOWLESS_SECRET,
|
|
164
|
+
baseUrl: 'https://app.example.com',
|
|
165
|
+
from: 'auth@app.example.com',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
app.use(express.urlencoded({ extended: false }));
|
|
169
|
+
app.get('/login', auth.loginForm);
|
|
170
|
+
app.post('/login', auth.login);
|
|
171
|
+
app.get('/auth/callback', auth.callback);
|
|
172
|
+
app.get('/verify', auth.verify);
|
|
173
|
+
app.post('/logout', auth.logout);
|
|
174
|
+
|
|
175
|
+
app.listen(8080);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Fastify, Hono, `node:http` — all work. Each handler is a plain
|
|
179
|
+
`(req, res) => Promise<void>` function. No framework hooks, no
|
|
180
|
+
middleware injection.
|
|
181
|
+
|
|
182
|
+
### Step 5: Pre-seed users (closed-registration mode, default)
|
|
183
|
+
|
|
184
|
+
By default, knowless is closed: a handle must already exist before
|
|
185
|
+
that email can request a magic link. To seed users:
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
import { deriveHandle } from 'knowless';
|
|
189
|
+
|
|
190
|
+
// At admin setup time:
|
|
191
|
+
auth._handlers; // not the public path — use a custom admin script
|
|
192
|
+
// that calls into the underlying store.
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Actually the cleanest pattern: write a tiny admin script using the
|
|
196
|
+
re-exported primitives:
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
import { knowless, deriveHandle, createStore } from 'knowless';
|
|
200
|
+
|
|
201
|
+
const SECRET = process.env.KNOWLESS_SECRET;
|
|
202
|
+
const store = createStore('./knowless.db');
|
|
203
|
+
|
|
204
|
+
const teamEmails = ['alice@example.com', 'bob@example.com'];
|
|
205
|
+
for (const email of teamEmails) {
|
|
206
|
+
store.upsertHandle(deriveHandle(email, SECRET));
|
|
207
|
+
}
|
|
208
|
+
store.close();
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Or run with `openRegistration: true` if you want first-email-wins:
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
const auth = knowless({ ..., openRegistration: true });
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Note that open registration adds a per-IP cap on new handles
|
|
218
|
+
(default 3/hour) to mitigate signup spam.
|
|
219
|
+
|
|
220
|
+
### Step 6: Use sessions in your app
|
|
221
|
+
|
|
222
|
+
After `/auth/callback` succeeds, the user has a session cookie.
|
|
223
|
+
Read it on every protected request:
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
function requireAuth(req, res, next) {
|
|
227
|
+
// Use auth.verify() in a sub-request shape, or read the cookie
|
|
228
|
+
// and call into the store. Simplest pattern:
|
|
229
|
+
// Mount a middleware that calls the verify handler against the
|
|
230
|
+
// request and checks the result.
|
|
231
|
+
// (Cleaner pattern coming in v0.2.0 with a middleware factory.)
|
|
232
|
+
next();
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
For now, the friendliest pattern: route a dedicated `/me` endpoint
|
|
237
|
+
through `auth.verify` and have the rest of your app fetch it on
|
|
238
|
+
mount.
|
|
239
|
+
|
|
240
|
+
### Step 7: GDPR right-to-erasure
|
|
241
|
+
|
|
242
|
+
The store interface exposes `deleteHandle(handle)` — atomic delete
|
|
243
|
+
of the handle row, all active tokens, and all active sessions.
|
|
244
|
+
Wire it to your "delete my account" UX:
|
|
245
|
+
|
|
246
|
+
```js
|
|
247
|
+
app.post('/account/delete', requireAuth, (req, res) => {
|
|
248
|
+
const handle = /* read from session via auth.verify or cookie */;
|
|
249
|
+
auth.deleteHandle(handle);
|
|
250
|
+
res.redirect('/goodbye');
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Library doesn't ship a built-in HTTP endpoint for this — operator
|
|
255
|
+
chooses the UX (admin CLI, in-app self-service, ticket-driven
|
|
256
|
+
support).
|
|
257
|
+
|
|
258
|
+
## Walkthrough: standalone server mode (v0.2.0, coming)
|
|
259
|
+
|
|
260
|
+
The shape: run `npx knowless-server`, point Caddy / nginx /
|
|
261
|
+
Traefik at it for forward-auth, protect any HTTP service behind
|
|
262
|
+
magic-link login.
|
|
263
|
+
|
|
264
|
+
The deployment-shape pattern:
|
|
265
|
+
```
|
|
266
|
+
[browser] → [Caddy] → [knowless-server /verify]
|
|
267
|
+
↓ 200 OK + X-User-Handle
|
|
268
|
+
[Caddy proxies to Uptime Kuma]
|
|
269
|
+
-OR-
|
|
270
|
+
↓ 401 Unauthorized
|
|
271
|
+
[Caddy redirects to auth.example.com/login?next=...]
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Sample Caddyfile (forthcoming OPS.md will have the full setup):
|
|
275
|
+
```caddy
|
|
276
|
+
auth.example.com {
|
|
277
|
+
reverse_proxy localhost:8080
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
kuma.example.com {
|
|
281
|
+
forward_auth localhost:8080 {
|
|
282
|
+
uri /verify
|
|
283
|
+
copy_headers X-User-Handle
|
|
284
|
+
}
|
|
285
|
+
reverse_proxy localhost:3001 # Uptime Kuma
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
adguard.example.com {
|
|
289
|
+
forward_auth localhost:8080 {
|
|
290
|
+
uri /verify
|
|
291
|
+
copy_headers X-User-Handle
|
|
292
|
+
}
|
|
293
|
+
reverse_proxy localhost:3000 # AdGuard Home
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
One auth subdomain, one cookie, SSO across all gated services
|
|
298
|
+
because the cookie is scoped to the parent eTLD+1.
|
|
299
|
+
|
|
300
|
+
Until v0.2.0, you can replicate this yourself with ~30 lines of
|
|
301
|
+
`node:http` wrapping the library-mode handlers — see
|
|
302
|
+
`knowless.context.md` for the pattern.
|
|
303
|
+
|
|
304
|
+
## Configuration reference
|
|
305
|
+
|
|
306
|
+
Full options table:
|
|
307
|
+
|
|
308
|
+
| Option | Required | Default | Purpose |
|
|
309
|
+
|---|---|---|---|
|
|
310
|
+
| `secret` | yes | — | HMAC key, ≥64 hex chars (32 bytes). FR-47, FR-48. |
|
|
311
|
+
| `baseUrl` | yes | — | Base URL for magic-link construction. |
|
|
312
|
+
| `from` | yes | — | Sender email address. |
|
|
313
|
+
| `dbPath` | no | `./knowless.db` | SQLite file path. |
|
|
314
|
+
| `cookieDomain` | no | (eTLD+1 of `baseUrl`) | Session cookie scope. |
|
|
315
|
+
| `cookieName` | no | `knowless_session` | Session cookie name. |
|
|
316
|
+
| `tokenTtlSeconds` | no | `900` | Magic-link expiry (15 min). |
|
|
317
|
+
| `sessionTtlSeconds` | no | `2592000` | Session lifetime (30 days). |
|
|
318
|
+
| `linkPath` | no | `/auth/callback` | Magic-link URL path. |
|
|
319
|
+
| `loginPath` | no | `/login` | Login form / submission path. |
|
|
320
|
+
| `verifyPath` | no | `/verify` | Forward-auth check. |
|
|
321
|
+
| `logoutPath` | no | `/logout` | Logout endpoint. |
|
|
322
|
+
| `smtpHost` | no | `localhost` | MTA host. |
|
|
323
|
+
| `smtpPort` | no | `25` | MTA port. |
|
|
324
|
+
| `openRegistration` | no | `false` | Allow new-handle creation on first email. |
|
|
325
|
+
| `subject` | no | `'Sign in'` | Mail subject. ASCII, ≤60 chars. |
|
|
326
|
+
| `confirmationMessage` | no | (default with `{email}` placeholder) | Shown after submission. |
|
|
327
|
+
| `includeLastLoginInEmail` | no | `true` | Append "Last sign-in" line for compromise hint. |
|
|
328
|
+
| `maxActiveTokensPerHandle` | no | `5` | Per-handle cap; 0 disables. |
|
|
329
|
+
| `maxLoginRequestsPerIpPerHour` | no | `30` | Per-IP login cap; 0 disables. |
|
|
330
|
+
| `maxNewHandlesPerIpPerHour` | no | `3` | Per-IP creation cap (open-reg only); 0 disables. |
|
|
331
|
+
| `honeypotFieldName` | no | `website` | Hidden form field name. |
|
|
332
|
+
| `trustedProxies` | no | `['127.0.0.1', '::1']` | IPs allowed to set `X-Forwarded-For`. |
|
|
333
|
+
| `shamRecipient` | no | `null@knowless.invalid` | Where sham mail goes (your MTA must discard it). |
|
|
334
|
+
| `sweepIntervalMs` | no | `300000` | Sweeper tick (5 min default). |
|
|
335
|
+
| `failureRedirect` | no | (= `loginPath`) | Where /auth/callback failures redirect. |
|
|
336
|
+
| `store` | no | (built-in better-sqlite3) | Inject your own store implementation. |
|
|
337
|
+
| `mailer` | no | (built-in nodemailer) | Inject your own mailer. |
|
|
338
|
+
|
|
339
|
+
## FAQ
|
|
340
|
+
|
|
341
|
+
### My users say magic links land in spam.
|
|
342
|
+
|
|
343
|
+
This is operator infrastructure, not the library. The library
|
|
344
|
+
sends RFC-clean plain-text mail with whitelisted headers; what
|
|
345
|
+
mail providers do with it depends entirely on your sending
|
|
346
|
+
domain's reputation. Verify SPF, DKIM, and PTR records are all
|
|
347
|
+
set correctly. Test with [mail-tester.com](https://www.mail-tester.com/).
|
|
348
|
+
|
|
349
|
+
### Can I use Mailgun / Postmark / SES instead of localhost Postfix?
|
|
350
|
+
|
|
351
|
+
No, by design. The library refuses remote SMTP and vendor APIs.
|
|
352
|
+
The reasoning is in [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §16.2: a
|
|
353
|
+
vendor relationship invites the operator to use the same mailer
|
|
354
|
+
for non-auth mail (welcome emails, digests), which contradicts
|
|
355
|
+
the philosophy. If you need a vendor mailer, you're not the
|
|
356
|
+
audience for knowless.
|
|
357
|
+
|
|
358
|
+
### How do I rotate the secret?
|
|
359
|
+
|
|
360
|
+
You can't, in practice. Rotating invalidates every existing handle
|
|
361
|
+
(they're salted by the secret) and every session (they're signed
|
|
362
|
+
by it). Treat the secret like a database master key: generate it
|
|
363
|
+
once, back it up safely, never expose it.
|
|
364
|
+
|
|
365
|
+
### Can I customise the login HTML?
|
|
366
|
+
|
|
367
|
+
No. The form is hardcoded. Operators wanting branding fork the
|
|
368
|
+
project. Rationale in [`docs/01-product/PRD.md`](docs/01-product/PRD.md) §16.12: templating
|
|
369
|
+
is a slope ("let me put my logo" → "let me theme the page" →
|
|
370
|
+
"let me embed a JS framework"). The hardcoded form refuses to
|
|
371
|
+
drift.
|
|
372
|
+
|
|
373
|
+
### How do I add 2FA / WebAuthn / TOTP?
|
|
374
|
+
|
|
375
|
+
Compose with a separate library. knowless does magic-link, full
|
|
376
|
+
stop. WebAuthn after login is a different layer.
|
|
377
|
+
|
|
378
|
+
### What about CSRF on POST /login?
|
|
379
|
+
|
|
380
|
+
The login form is unauthenticated, so traditional CSRF
|
|
381
|
+
mitigations (anti-CSRF tokens) don't apply directly. SameSite=Lax
|
|
382
|
+
on the session cookie covers the post-login risk. CSRF on the
|
|
383
|
+
unauthenticated /login endpoint is on the v0.2 open-questions
|
|
384
|
+
list (SPEC §15 Q-4) — Origin-header validation is the likely
|
|
385
|
+
answer.
|
|
386
|
+
|
|
387
|
+
### Can I run multiple instances behind a load balancer?
|
|
388
|
+
|
|
389
|
+
Yes — the SQLite store is shared across processes via the file
|
|
390
|
+
system. Concurrent writes use SQLite's `BEGIN IMMEDIATE` for the
|
|
391
|
+
token-issuance transaction (SPEC §4.7). For very high concurrency
|
|
392
|
+
(>1000 logins/sec), implement the store interface against
|
|
393
|
+
Postgres or Redis.
|
|
394
|
+
|
|
395
|
+
### How do I see what's in the store?
|
|
396
|
+
|
|
397
|
+
```
|
|
398
|
+
sqlite3 knowless.db
|
|
399
|
+
sqlite> .schema
|
|
400
|
+
sqlite> SELECT count(*) FROM handles;
|
|
401
|
+
sqlite> SELECT count(*) FROM sessions WHERE expires_at > unixepoch() * 1000;
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
The schema is documented in [`docs/02-design/SPEC.md`](docs/02-design/SPEC.md) §6.
|
|
405
|
+
|
|
406
|
+
## Troubleshooting
|
|
407
|
+
|
|
408
|
+
### "config.secret must be ≥64 hex chars (32 bytes)"
|
|
409
|
+
|
|
410
|
+
You passed a secret shorter than 64 characters or not a string.
|
|
411
|
+
Run `node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"`
|
|
412
|
+
and use the output.
|
|
413
|
+
|
|
414
|
+
### Magic link works but `/verify` returns 401
|
|
415
|
+
|
|
416
|
+
Common causes:
|
|
417
|
+
- Cookie domain mismatch. The cookie is set to the eTLD+1 of
|
|
418
|
+
`baseUrl` by default; if your protected service is on a
|
|
419
|
+
different parent domain, the browser won't send the cookie.
|
|
420
|
+
Set `cookieDomain` explicitly.
|
|
421
|
+
- Cookie not surviving the redirect. The `Set-Cookie` from
|
|
422
|
+
`/auth/callback` must be `Secure`, so HTTP-only origins won't
|
|
423
|
+
receive it. Use HTTPS in production.
|
|
424
|
+
|
|
425
|
+
### "ERR_UNKNOWN_ENCODING: 7bit" or "Content-Transfer-Encoding: base64" in mail
|
|
426
|
+
|
|
427
|
+
Library bug — the mailer is supposed to compose 7bit raw RFC822.
|
|
428
|
+
Open an issue with the captured wire output.
|
|
429
|
+
|
|
430
|
+
### Tests fail intermittently in CI
|
|
431
|
+
|
|
432
|
+
The FR-6 timing test has a 1ms `delta_mean` bar. On extremely
|
|
433
|
+
noisy CI runners this can spuriously fail. Re-run; if persistent,
|
|
434
|
+
your runner is anomalous. Document the policy locally rather
|
|
435
|
+
than weakening the bar — see SPEC §14.5.
|
|
436
|
+
|
|
437
|
+
### "The link expires in 15 minutes" — can I make it longer?
|
|
438
|
+
|
|
439
|
+
Yes: `tokenTtlSeconds`. Don't set it absurdly high. Magic links
|
|
440
|
+
that linger in inboxes are a phishing-amplification risk if the
|
|
441
|
+
mail account is later compromised.
|