knowless 0.1.2 → 0.1.4
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 +82 -7
- package/GUIDE.md +26 -0
- package/OPS.md +573 -0
- package/README.md +6 -4
- package/bin/.gitkeep +0 -0
- package/bin/knowless-server +313 -0
- package/config.example.env +74 -0
- package/package.json +7 -1
- package/src/abuse.js +63 -3
- package/src/form.js +6 -1
- package/src/handlers.js +20 -1
- package/src/index.js +4 -0
- package/src/store.js +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,13 +7,88 @@ Versioning is [SemVer](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
- Caddy forward-auth Docker integration test (TASKS.md 6.8).
|
|
11
|
+
- `knowless-server --check-null-route`: CLI probe that submits a
|
|
12
|
+
test message to `shamRecipient` and confirms the local MTA
|
|
13
|
+
discarded it. Honest answer to "does the operator's null-route
|
|
14
|
+
actually work?" — the library can know what it submitted but
|
|
15
|
+
not what the MTA did, so this is the closest we can get.
|
|
16
|
+
Targeted for v0.2.0.
|
|
17
|
+
|
|
18
|
+
## [0.1.4] — 2026-04-28
|
|
19
|
+
|
|
20
|
+
First real-world integration release. Bugs and ergonomics surfaced
|
|
21
|
+
by the addypin team's spike on v0.1.3, plus two minor security
|
|
22
|
+
hardenings that fell out of the audit.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **`auth.revokeSessions(handle)`** — log out everywhere without
|
|
27
|
+
deleting the account. Returns the number of sessions removed.
|
|
28
|
+
Closes AF-6.1.
|
|
29
|
+
- **`devLogMagicLinks: true`** opt-in — when SMTP fails AND this
|
|
30
|
+
flag is set, prints the magic link to stderr so a developer can
|
|
31
|
+
click through. Off by default; never fires for sham (silent-miss)
|
|
32
|
+
submissions; never replaces real SMTP delivery on success. Closes
|
|
33
|
+
AF-6.2.
|
|
34
|
+
- **CIDR support in `trustedProxies`** — accept `10.0.0.0/8`,
|
|
35
|
+
`fd00::/8`, etc. in addition to plain IPs. Uses `node:net`
|
|
36
|
+
`BlockList`, no new dep. Closes AF-6.3.
|
|
37
|
+
|
|
38
|
+
### Security
|
|
39
|
+
|
|
40
|
+
- **CSRF on `POST /logout`.** Origin/Referer validation now mirrors
|
|
41
|
+
`POST /login` (AF-4.3). Without this, a malicious page could
|
|
42
|
+
force-logout an authenticated victim. Closes AF-6.4.
|
|
43
|
+
- **`confirmationMessage` is HTML-escaped before rendering.** The
|
|
44
|
+
message is operator-config (not user input), but a careless
|
|
45
|
+
operator interpolating user data into it would have produced an
|
|
46
|
+
XSS. The whole message is now escaped before `{email}` substitution
|
|
47
|
+
(which was already escaped). Operators who want HTML in the
|
|
48
|
+
confirmation message must pre-render upstream. Closes AF-6.5.
|
|
49
|
+
|
|
50
|
+
### Documentation
|
|
51
|
+
|
|
52
|
+
- **SPEC §10.2** documents the new logout Origin check.
|
|
53
|
+
- **SPEC §7.3 Step 0** adds an explicit "do NOT add a CSRF token
|
|
54
|
+
upstream — the Origin/Referer whitelist IS the CSRF defense"
|
|
55
|
+
note for adopters. Closes AF-6.6.
|
|
56
|
+
- **GUIDE.md** front-matter now leads with the v1.0.0 walks-away
|
|
57
|
+
commitment. Procurement signal: a library that has explicitly
|
|
58
|
+
committed to *not growing* is a different risk profile from a
|
|
59
|
+
typical OSS package. Closes AF-6.7.
|
|
60
|
+
|
|
61
|
+
## [0.1.3] — 2026-04-28
|
|
62
|
+
|
|
63
|
+
Standalone-deployment release. The library could already be embedded
|
|
64
|
+
in a Node service since v0.1.0; v0.1.3 closes the operator-side story
|
|
65
|
+
so a self-hoster can `npx knowless-server` and have a working
|
|
66
|
+
forward-auth gate in front of arbitrary services.
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
|
|
70
|
+
- **Standalone server** — `bin/knowless-server` ships a self-contained
|
|
71
|
+
HTTP server for forward-auth deployments. Configuration is via
|
|
72
|
+
`KNOWLESS_*` env vars (PRD FR-49 to FR-56); CLI flags are inspection-
|
|
73
|
+
only:
|
|
74
|
+
- `--help` lists every env var with default and purpose
|
|
75
|
+
- `--version` prints the package version
|
|
76
|
+
- `--print-config` prints effective config with secrets redacted as
|
|
77
|
+
`<set>` / `<unset>`
|
|
78
|
+
- `--config-check` validates required vars are present, the secret
|
|
79
|
+
is ≥64 hex chars, the SMTP host is reachable, and the DB path is
|
|
80
|
+
writable. Suitable for systemd `ExecStartPre`.
|
|
81
|
+
- `config.example.env` — documented sample env file at repo root.
|
|
82
|
+
Operators copy this and load via `node --env-file=...` or systemd
|
|
83
|
+
`EnvironmentFile=`. Library does not auto-load it (FR-56).
|
|
84
|
+
- Startup log block (FR-54) with effective config, SMTP check result,
|
|
85
|
+
and listening address.
|
|
86
|
+
- **`OPS.md`** — full operator setup walkthrough: Postfix
|
|
87
|
+
outbound-only install, **required** null-route for sham mail,
|
|
88
|
+
SPF / DKIM / PTR / DMARC, port-25 verification, hardened systemd
|
|
89
|
+
unit, Caddy / nginx / Traefik forward-auth examples, Tailscale
|
|
90
|
+
pattern, reverse-proxy rate limiting, fail2ban / Turnstile
|
|
91
|
+
references, backup guidance.
|
|
17
92
|
|
|
18
93
|
## [0.1.2] — 2026-04-28
|
|
19
94
|
|
package/GUIDE.md
CHANGED
|
@@ -5,6 +5,32 @@
|
|
|
5
5
|
> For the product philosophy, see
|
|
6
6
|
> [`docs/01-product/PRD.md`](docs/01-product/PRD.md).
|
|
7
7
|
|
|
8
|
+
## Read this first: knowless walks away at v1.0.0
|
|
9
|
+
|
|
10
|
+
knowless commits to a small, audit-able surface and a *closed* feature
|
|
11
|
+
list. v1.0.0 is the **terminal release** for new functionality: only
|
|
12
|
+
security fixes ship after that. There will be no v2.0 with sessions+,
|
|
13
|
+
no plugin system, no second mailer, no SaaS counterpart.
|
|
14
|
+
|
|
15
|
+
What this means for you as an adopter:
|
|
16
|
+
|
|
17
|
+
- **You own integration breadth.** If knowless's defaults don't fit
|
|
18
|
+
exactly, you patch around it (the API is small enough to do this) or
|
|
19
|
+
fork it (Apache 2.0). We won't add a config knob to absorb your
|
|
20
|
+
case.
|
|
21
|
+
- **You can pin and forget.** v1.0.0 will work the same way three
|
|
22
|
+
years later. Security patches will land in v1.x.
|
|
23
|
+
- **Procurement signal.** A library that has explicitly committed to
|
|
24
|
+
*not growing* is a different risk profile from a typical OSS
|
|
25
|
+
package. Most reviews assume "still actively developed" is good;
|
|
26
|
+
for an auth dependency, "still actively developed" is also "still
|
|
27
|
+
changing in ways you'll have to track." knowless inverts that.
|
|
28
|
+
|
|
29
|
+
If you need a kitchen-sink auth library with active feature
|
|
30
|
+
development, this isn't the right tool. See
|
|
31
|
+
[Lucia](https://lucia-auth.com/), [Auth.js](https://authjs.dev/),
|
|
32
|
+
or commercial offerings.
|
|
33
|
+
|
|
8
34
|
## Who this is for
|
|
9
35
|
|
|
10
36
|
Three audiences, in order of fit:
|
package/OPS.md
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
# OPS — knowless operator setup
|
|
2
|
+
|
|
3
|
+
This guide takes a fresh Ubuntu/Debian VPS to "knowless is delivering
|
|
4
|
+
magic-link mail to your users' inbox." Every step is a manual decision
|
|
5
|
+
point for the operator. There are no automation scripts; ops is your
|
|
6
|
+
job and we provide the checklist.
|
|
7
|
+
|
|
8
|
+
If any step here surprises you — outbound port 25, DKIM, PTR records,
|
|
9
|
+
the null-route requirement — stop and read PRD §11 first. knowless
|
|
10
|
+
trades operator effort for a small, auditable surface. If you'd rather
|
|
11
|
+
delegate email to a SaaS, knowless is the wrong tool.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Prerequisites
|
|
16
|
+
|
|
17
|
+
- Ubuntu 22.04 / 24.04 or Debian 12, on a host that can:
|
|
18
|
+
- bind a public DNS name (e.g. `auth.example.com`)
|
|
19
|
+
- send outbound TCP/25 (verify before going further — see §3)
|
|
20
|
+
- have a working PTR record for its public IPv4 (and IPv6 if used)
|
|
21
|
+
- A domain with control of its DNS records
|
|
22
|
+
- Node.js ≥ 20 installed
|
|
23
|
+
- A reverse proxy in front of HTTP (Caddy, nginx, or Traefik)
|
|
24
|
+
|
|
25
|
+
knowless does not handle TLS termination. Your reverse proxy does.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 2. Install Postfix (outbound-only)
|
|
30
|
+
|
|
31
|
+
knowless submits mail to a localhost MTA over plain SMTP on port 25
|
|
32
|
+
without auth. The MTA does the actual delivery. Postfix is the
|
|
33
|
+
recommended choice; any MTA that accepts unauthenticated localhost
|
|
34
|
+
submission works.
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y postfix mailutils
|
|
38
|
+
# When the installer asks: choose "Internet Site"
|
|
39
|
+
# System mail name: your sending domain (e.g. example.com)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Minimal `/etc/postfix/main.cf` for outbound-only:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
myhostname = mail.example.com
|
|
46
|
+
mydomain = example.com
|
|
47
|
+
myorigin = $mydomain
|
|
48
|
+
inet_interfaces = loopback-only
|
|
49
|
+
inet_protocols = ipv4
|
|
50
|
+
mydestination =
|
|
51
|
+
relayhost =
|
|
52
|
+
smtp_tls_security_level = may
|
|
53
|
+
smtp_tls_loglevel = 1
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`inet_interfaces = loopback-only` means Postfix accepts submission
|
|
57
|
+
only from `127.0.0.1`. knowless connects there. Restart:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
sudo systemctl restart postfix
|
|
61
|
+
sudo systemctl enable postfix
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Verify the localhost submission works:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
echo "Subject: test" | sendmail -v you@somewhere-you-control.com
|
|
68
|
+
sudo tail /var/log/mail.log
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If you see `status=sent` you are done with §2.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 3. Verify outbound port 25
|
|
76
|
+
|
|
77
|
+
Many cloud providers (AWS, GCP, Azure, Oracle, DigitalOcean droplets,
|
|
78
|
+
Hetzner cloud, …) block outbound TCP/25 by default to limit spam from
|
|
79
|
+
compromised instances. **Test before troubleshooting anything else.**
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
nc -zv gmail-smtp-in.l.google.com 25
|
|
83
|
+
# Expected: succeeded / Connection to ... 25 port [tcp/smtp] succeeded
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
If this hangs or fails:
|
|
87
|
+
- AWS: open a "Request to remove email sending limitations" ticket.
|
|
88
|
+
- GCP: port 25 is permanently blocked. Use a relay (see below) or
|
|
89
|
+
move to a provider that allows it.
|
|
90
|
+
- Hetzner: port 25 is blocked on new accounts; ask support.
|
|
91
|
+
- Most VPS hosts (Vultr, OVH, Linode, your own metal): open by default.
|
|
92
|
+
|
|
93
|
+
**Alternative if port 25 is permanently blocked:** configure Postfix
|
|
94
|
+
to relay through a transactional provider's submission endpoint
|
|
95
|
+
(587/465 with auth). knowless still talks to localhost; Postfix is
|
|
96
|
+
what relays out. That's a Postfix concern, not a knowless concern.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 4. Null-route for sham mail (REQUIRED)
|
|
101
|
+
|
|
102
|
+
knowless's silent-miss design (PRD FR-2 to FR-6) submits a real-shaped
|
|
103
|
+
SMTP message on every login attempt — including ones where the email
|
|
104
|
+
doesn't map to any registered handle. The "sham" submissions are
|
|
105
|
+
addressed to `null@knowless.invalid` by default. Without a null-route,
|
|
106
|
+
Postfix will queue these forever trying to resolve a nonexistent
|
|
107
|
+
domain. **Configure the null-route. SPEC §7.4.**
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
# /etc/postfix/transport
|
|
111
|
+
knowless.invalid discard:silently dropped by knowless null-route
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Then:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
sudo postmap /etc/postfix/transport
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Add to `/etc/postfix/main.cf`:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
transport_maps = hash:/etc/postfix/transport
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Reload:
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
sudo systemctl reload postfix
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Verify discard works:
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
echo "test" | mail -s "should be dropped" null@knowless.invalid
|
|
136
|
+
sudo tail /var/log/mail.log | grep knowless.invalid
|
|
137
|
+
# Expected: status=sent (silently dropped by knowless null-route)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
If you customized `shamRecipient` in your knowless config to point
|
|
141
|
+
elsewhere, change the transport entry's domain to match.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 5. SPF, DKIM, PTR
|
|
146
|
+
|
|
147
|
+
Without these your magic-link mail goes to spam. There is no
|
|
148
|
+
shortcut here.
|
|
149
|
+
|
|
150
|
+
### 5.1 SPF
|
|
151
|
+
|
|
152
|
+
Add a TXT record at the apex of your sending domain:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
example.com. TXT "v=spf1 mx a ~all"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
If your knowless server is *not* an MX for the domain, replace
|
|
159
|
+
`mx` with `ip4:1.2.3.4` listing the server's public IP.
|
|
160
|
+
|
|
161
|
+
### 5.2 DKIM
|
|
162
|
+
|
|
163
|
+
Install OpenDKIM:
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
sudo apt-get install -y opendkim opendkim-tools
|
|
167
|
+
sudo opendkim-genkey -D /etc/opendkim/keys -d example.com -s mail
|
|
168
|
+
sudo chown opendkim:opendkim /etc/opendkim/keys/mail.private
|
|
169
|
+
sudo chmod 600 /etc/opendkim/keys/mail.private
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`/etc/opendkim/keys/mail.txt` now contains the DNS record. Publish it
|
|
173
|
+
as `mail._domainkey.example.com` in your DNS.
|
|
174
|
+
|
|
175
|
+
Wire OpenDKIM into Postfix — add to `/etc/postfix/main.cf`:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
milter_default_action = accept
|
|
179
|
+
smtpd_milters = inet:localhost:8891
|
|
180
|
+
non_smtpd_milters = $smtpd_milters
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Configure `/etc/opendkim.conf`:
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
Domain example.com
|
|
187
|
+
KeyFile /etc/opendkim/keys/mail.private
|
|
188
|
+
Selector mail
|
|
189
|
+
Socket inet:8891@localhost
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Restart both:
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
sudo systemctl restart opendkim postfix
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Verify with `mail-tester.com`: send a test, expect 9–10/10.
|
|
199
|
+
|
|
200
|
+
### 5.3 PTR (reverse DNS)
|
|
201
|
+
|
|
202
|
+
Your server's public IP must reverse-resolve to a hostname. **Most
|
|
203
|
+
providers expose this in their control panel** (Hetzner: "rDNS";
|
|
204
|
+
DigitalOcean: name your droplet `mail.example.com`; OVH: "Reverse
|
|
205
|
+
DNS" tab). Set the PTR to the same hostname you put in
|
|
206
|
+
`myhostname` (§2). Verify:
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
dig -x 1.2.3.4 +short
|
|
210
|
+
# Expected: mail.example.com.
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
A missing or generic PTR (`ec2-...`, `static.cloud-provider.tld`)
|
|
214
|
+
is the single most common reason mail lands in spam.
|
|
215
|
+
|
|
216
|
+
### 5.4 Optional: DMARC
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
_dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:postmaster@example.com"
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Start with `p=none` to monitor. Move to `p=quarantine` later if you
|
|
223
|
+
want stricter handling.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 6. Run knowless-server under systemd
|
|
228
|
+
|
|
229
|
+
```sh
|
|
230
|
+
sudo useradd --system --home /var/lib/knowless --create-home knowless
|
|
231
|
+
sudo install -d -o knowless -g knowless -m 0750 /var/lib/knowless /etc/knowless
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Generate a secret and create `/etc/knowless/knowless.env`:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
sudo install -m 0600 -o knowless -g knowless /dev/null /etc/knowless/knowless.env
|
|
238
|
+
sudo tee /etc/knowless/knowless.env > /dev/null <<EOF
|
|
239
|
+
KNOWLESS_SECRET=$(openssl rand -hex 32)
|
|
240
|
+
KNOWLESS_BASE_URL=https://auth.example.com
|
|
241
|
+
KNOWLESS_FROM=auth@example.com
|
|
242
|
+
KNOWLESS_DB_PATH=/var/lib/knowless/knowless.db
|
|
243
|
+
KNOWLESS_COOKIE_DOMAIN=example.com
|
|
244
|
+
KNOWLESS_COOKIE_SECURE=true
|
|
245
|
+
KNOWLESS_HOST=127.0.0.1
|
|
246
|
+
KNOWLESS_PORT=8080
|
|
247
|
+
EOF
|
|
248
|
+
sudo chmod 0600 /etc/knowless/knowless.env
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`/etc/systemd/system/knowless.service`:
|
|
252
|
+
|
|
253
|
+
```ini
|
|
254
|
+
[Unit]
|
|
255
|
+
Description=knowless passwordless auth server
|
|
256
|
+
Wants=network-online.target postfix.service
|
|
257
|
+
After=network-online.target postfix.service
|
|
258
|
+
|
|
259
|
+
[Service]
|
|
260
|
+
Type=simple
|
|
261
|
+
User=knowless
|
|
262
|
+
Group=knowless
|
|
263
|
+
EnvironmentFile=/etc/knowless/knowless.env
|
|
264
|
+
ExecStartPre=/usr/bin/npx --yes knowless-server --config-check
|
|
265
|
+
ExecStart=/usr/bin/npx --yes knowless-server
|
|
266
|
+
Restart=on-failure
|
|
267
|
+
RestartSec=2
|
|
268
|
+
|
|
269
|
+
# Hardening
|
|
270
|
+
NoNewPrivileges=true
|
|
271
|
+
ProtectSystem=strict
|
|
272
|
+
ProtectHome=true
|
|
273
|
+
ReadWritePaths=/var/lib/knowless
|
|
274
|
+
PrivateTmp=true
|
|
275
|
+
PrivateDevices=true
|
|
276
|
+
ProtectKernelTunables=true
|
|
277
|
+
ProtectKernelModules=true
|
|
278
|
+
ProtectControlGroups=true
|
|
279
|
+
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
|
280
|
+
LockPersonality=true
|
|
281
|
+
SystemCallArchitectures=native
|
|
282
|
+
|
|
283
|
+
[Install]
|
|
284
|
+
WantedBy=multi-user.target
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Enable:
|
|
288
|
+
|
|
289
|
+
```sh
|
|
290
|
+
sudo systemctl daemon-reload
|
|
291
|
+
sudo systemctl enable --now knowless
|
|
292
|
+
sudo systemctl status knowless
|
|
293
|
+
sudo journalctl -u knowless -f
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
`ExecStartPre=knowless-server --config-check` ensures a misconfigured
|
|
297
|
+
deploy fails to start instead of silently breaking auth.
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## 7. Reverse proxy — pick one
|
|
302
|
+
|
|
303
|
+
knowless does not terminate TLS. Your proxy fronts it on `:443`,
|
|
304
|
+
forwards `Host` and `X-Forwarded-For`, and (for forward-auth
|
|
305
|
+
deployments) routes protected upstreams through `/verify`.
|
|
306
|
+
|
|
307
|
+
### 7.1 Caddy
|
|
308
|
+
|
|
309
|
+
```caddy
|
|
310
|
+
auth.example.com {
|
|
311
|
+
reverse_proxy 127.0.0.1:8080
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
# Protect Uptime Kuma with knowless forward-auth
|
|
315
|
+
kuma.example.com {
|
|
316
|
+
forward_auth 127.0.0.1:8080 {
|
|
317
|
+
uri /verify
|
|
318
|
+
copy_headers X-Knowless-Handle
|
|
319
|
+
}
|
|
320
|
+
reverse_proxy 127.0.0.1:3001
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Caddy auto-issues TLS via Let's Encrypt and trusts itself as a proxy
|
|
325
|
+
on the loopback. knowless's default `KNOWLESS_TRUSTED_PROXIES=127.0.0.1,::1`
|
|
326
|
+
covers this.
|
|
327
|
+
|
|
328
|
+
### 7.2 nginx
|
|
329
|
+
|
|
330
|
+
```nginx
|
|
331
|
+
# /etc/nginx/sites-available/auth.example.com
|
|
332
|
+
server {
|
|
333
|
+
listen 443 ssl http2;
|
|
334
|
+
server_name auth.example.com;
|
|
335
|
+
# ssl_certificate / ssl_certificate_key managed by certbot
|
|
336
|
+
|
|
337
|
+
location / {
|
|
338
|
+
proxy_pass http://127.0.0.1:8080;
|
|
339
|
+
proxy_set_header Host $host;
|
|
340
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
341
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
# Protect a service with auth_request
|
|
346
|
+
server {
|
|
347
|
+
listen 443 ssl http2;
|
|
348
|
+
server_name kuma.example.com;
|
|
349
|
+
|
|
350
|
+
location = /_knowless_verify {
|
|
351
|
+
internal;
|
|
352
|
+
proxy_pass http://127.0.0.1:8080/verify;
|
|
353
|
+
proxy_pass_request_body off;
|
|
354
|
+
proxy_set_header Content-Length "";
|
|
355
|
+
proxy_set_header X-Original-URI $request_uri;
|
|
356
|
+
proxy_set_header Cookie $http_cookie;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
error_page 401 = @knowless_login;
|
|
360
|
+
location @knowless_login {
|
|
361
|
+
return 302 https://auth.example.com/login?next=https://$host$request_uri;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
location / {
|
|
365
|
+
auth_request /_knowless_verify;
|
|
366
|
+
auth_request_set $handle $upstream_http_x_knowless_handle;
|
|
367
|
+
proxy_set_header X-Knowless-Handle $handle;
|
|
368
|
+
proxy_pass http://127.0.0.1:3001;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### 7.3 Traefik
|
|
374
|
+
|
|
375
|
+
```yaml
|
|
376
|
+
# dynamic.yml
|
|
377
|
+
http:
|
|
378
|
+
middlewares:
|
|
379
|
+
knowless:
|
|
380
|
+
forwardAuth:
|
|
381
|
+
address: "http://127.0.0.1:8080/verify"
|
|
382
|
+
authResponseHeaders: [ "X-Knowless-Handle" ]
|
|
383
|
+
|
|
384
|
+
routers:
|
|
385
|
+
auth:
|
|
386
|
+
rule: "Host(`auth.example.com`)"
|
|
387
|
+
service: knowless
|
|
388
|
+
tls: { certResolver: le }
|
|
389
|
+
kuma:
|
|
390
|
+
rule: "Host(`kuma.example.com`)"
|
|
391
|
+
service: kuma
|
|
392
|
+
middlewares: [ knowless ]
|
|
393
|
+
tls: { certResolver: le }
|
|
394
|
+
|
|
395
|
+
services:
|
|
396
|
+
knowless:
|
|
397
|
+
loadBalancer:
|
|
398
|
+
servers: [{ url: "http://127.0.0.1:8080" }]
|
|
399
|
+
kuma:
|
|
400
|
+
loadBalancer:
|
|
401
|
+
servers: [{ url: "http://127.0.0.1:3001" }]
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## 8. Tailscale / WireGuard pattern
|
|
407
|
+
|
|
408
|
+
A common deployment: knowless lives on a public VPS (port 25 open,
|
|
409
|
+
PTR set), but the services it protects live on a home server.
|
|
410
|
+
Connect them with a mesh VPN.
|
|
411
|
+
|
|
412
|
+
```
|
|
413
|
+
public VPS home server
|
|
414
|
+
+----------------+ +-------------------+
|
|
415
|
+
| Caddy :443 | | Uptime Kuma :3001 |
|
|
416
|
+
| knowless :8080 | | Vaultwarden :8222 |
|
|
417
|
+
| tailscale0 |--------------| tailscale0 |
|
|
418
|
+
+----------------+ +-------------------+
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
In Caddy on the VPS:
|
|
422
|
+
|
|
423
|
+
```caddy
|
|
424
|
+
kuma.example.com {
|
|
425
|
+
forward_auth 127.0.0.1:8080 {
|
|
426
|
+
uri /verify
|
|
427
|
+
}
|
|
428
|
+
reverse_proxy 100.64.0.5:3001 # tailscale IP of home server
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Home server exposes nothing publicly. The VPS terminates TLS, runs
|
|
433
|
+
auth, and proxies through the mesh.
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
## 9. Reverse-proxy rate limiting (defence in depth)
|
|
438
|
+
|
|
439
|
+
knowless ships modest in-process limits as a baseline (FR-39).
|
|
440
|
+
Operators expecting elevated abuse should layer stricter limits at
|
|
441
|
+
the proxy. The proxy sees traffic earlier and can drop it without
|
|
442
|
+
even hitting Node.
|
|
443
|
+
|
|
444
|
+
### 9.1 Caddy
|
|
445
|
+
|
|
446
|
+
```caddy
|
|
447
|
+
auth.example.com {
|
|
448
|
+
rate_limit {
|
|
449
|
+
zone login_ip {
|
|
450
|
+
key {client_ip}
|
|
451
|
+
window 1m
|
|
452
|
+
events 10
|
|
453
|
+
}
|
|
454
|
+
match path /login
|
|
455
|
+
}
|
|
456
|
+
reverse_proxy 127.0.0.1:8080
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
(Requires the `caddy-ratelimit` plugin; build with
|
|
461
|
+
`xcaddy build --with github.com/mholt/caddy-ratelimit`.)
|
|
462
|
+
|
|
463
|
+
### 9.2 nginx
|
|
464
|
+
|
|
465
|
+
```nginx
|
|
466
|
+
limit_req_zone $binary_remote_addr zone=knowless_login:10m rate=10r/m;
|
|
467
|
+
|
|
468
|
+
server {
|
|
469
|
+
location = /login {
|
|
470
|
+
limit_req zone=knowless_login burst=5 nodelay;
|
|
471
|
+
proxy_pass http://127.0.0.1:8080;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
## 10. fail2ban / Cloudflare Turnstile (heavier abuse profiles)
|
|
479
|
+
|
|
480
|
+
Optional; both have trade-offs.
|
|
481
|
+
|
|
482
|
+
**fail2ban** can tail `journalctl -u knowless` and block IPs that
|
|
483
|
+
trip many rate-limit responses:
|
|
484
|
+
|
|
485
|
+
```ini
|
|
486
|
+
# /etc/fail2ban/filter.d/knowless.conf
|
|
487
|
+
[Definition]
|
|
488
|
+
failregex = ^.*\s+\[knowless\]\s+rate-limited\s+ip=<HOST>
|
|
489
|
+
ignoreregex =
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
```ini
|
|
493
|
+
# /etc/fail2ban/jail.d/knowless.conf
|
|
494
|
+
[knowless]
|
|
495
|
+
enabled = true
|
|
496
|
+
filter = knowless
|
|
497
|
+
backend = systemd
|
|
498
|
+
journalmatch = _SYSTEMD_UNIT=knowless.service
|
|
499
|
+
maxretry = 20
|
|
500
|
+
findtime = 600
|
|
501
|
+
bantime = 3600
|
|
502
|
+
action = iptables[name=knowless, port=https, protocol=tcp]
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
(knowless does not log structured rate-limit lines today; if you want
|
|
506
|
+
this you may need to wrap it. See Issue tracker.)
|
|
507
|
+
|
|
508
|
+
**Cloudflare Turnstile** is the lowest-friction CAPTCHA but
|
|
509
|
+
introduces a third-party dependency on Cloudflare for every login
|
|
510
|
+
form load. knowless doesn't integrate it natively; if you need it,
|
|
511
|
+
embed the widget in your own login page (use the library mode
|
|
512
|
+
`renderLoginForm` as a starting template).
|
|
513
|
+
|
|
514
|
+
Both are last-resort knobs. Most operators won't need them.
|
|
515
|
+
|
|
516
|
+
---
|
|
517
|
+
|
|
518
|
+
## 11. Operational checks
|
|
519
|
+
|
|
520
|
+
Once running:
|
|
521
|
+
|
|
522
|
+
```sh
|
|
523
|
+
# Validate config
|
|
524
|
+
sudo -u knowless npx --yes knowless-server --config-check
|
|
525
|
+
|
|
526
|
+
# Print effective config (secrets redacted)
|
|
527
|
+
sudo -u knowless npx --yes knowless-server --print-config
|
|
528
|
+
|
|
529
|
+
# Watch logs
|
|
530
|
+
sudo journalctl -u knowless -f
|
|
531
|
+
|
|
532
|
+
# Send yourself a magic link end-to-end
|
|
533
|
+
curl -i -X POST https://auth.example.com/login \
|
|
534
|
+
-d email=you@somewhere-you-control.com
|
|
535
|
+
# Then check your inbox (NOT spam) for the link.
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
If the email lands in spam, work through §5 (SPF, DKIM, PTR, DMARC)
|
|
539
|
+
in that order — most spam-folder verdicts trace to one of those four.
|
|
540
|
+
|
|
541
|
+
---
|
|
542
|
+
|
|
543
|
+
## 12. Backup and recovery
|
|
544
|
+
|
|
545
|
+
The only stateful file is the SQLite database (`KNOWLESS_DB_PATH`,
|
|
546
|
+
default `/var/lib/knowless/knowless.db`). It contains:
|
|
547
|
+
|
|
548
|
+
- Handles (HMAC outputs of email addresses)
|
|
549
|
+
- Active token hashes (short-lived, 15 min default)
|
|
550
|
+
- Session ID hashes (30 day default)
|
|
551
|
+
- Rate-limit counters
|
|
552
|
+
|
|
553
|
+
Loss is recoverable: users sign in again and get a new session. There
|
|
554
|
+
is no irreplaceable data here. A weekly `sqlite3 knowless.db .backup`
|
|
555
|
+
is sufficient.
|
|
556
|
+
|
|
557
|
+
The HMAC secret in `/etc/knowless/knowless.env` is the actual asset
|
|
558
|
+
to protect. Losing it logs every user out and changes every handle
|
|
559
|
+
(emails will be re-derived to new opaque values on next login).
|
|
560
|
+
**Back up the secret separately, out-of-band, encrypted.** Never
|
|
561
|
+
commit it.
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
## 13. Where things are documented
|
|
566
|
+
|
|
567
|
+
- **PRD** (`docs/01-product/PRD.md`) — what the library does and
|
|
568
|
+
doesn't do, threat model, NO-GO list
|
|
569
|
+
- **SPEC** (`docs/02-design/SPEC.md`) — wire formats, exact flows,
|
|
570
|
+
schema
|
|
571
|
+
- **GUIDE.md** — application-developer integration in library mode
|
|
572
|
+
- **README.md** — short orientation
|
|
573
|
+
- **OPS.md** (this document) — operator-side setup
|
package/README.md
CHANGED
|
@@ -104,16 +104,18 @@ By choosing knowless, you commit to:
|
|
|
104
104
|
silent-miss sham mail is dropped, not delivered to NXDOMAIN
|
|
105
105
|
- Accepting that this is the **only email** your service ever sends
|
|
106
106
|
|
|
107
|
-
These are documented in `OPS.md`
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
These are documented in [`OPS.md`](OPS.md): Postfix install,
|
|
108
|
+
null-route, SPF/DKIM/PTR, systemd unit, Caddy / nginx / Traefik
|
|
109
|
+
forward-auth examples, Tailscale pattern, reverse-proxy rate limiting,
|
|
110
|
+
and fail2ban / Turnstile references.
|
|
111
111
|
|
|
112
112
|
## Documentation
|
|
113
113
|
|
|
114
114
|
- [`README.md`](README.md) (this file) — project pitch, six-line example
|
|
115
115
|
- [`GUIDE.md`](GUIDE.md) — adopter walkthrough: who it's for, who it
|
|
116
116
|
isn't, how to integrate, configuration reference, FAQ
|
|
117
|
+
- [`OPS.md`](OPS.md) — operator setup: Postfix, null-route, DNS,
|
|
118
|
+
systemd, reverse-proxy forward-auth examples
|
|
117
119
|
- [`knowless.context.md`](knowless.context.md) — dense AI-agent
|
|
118
120
|
integration guide (tables, gotchas, public API at a glance)
|
|
119
121
|
- [`docs/01-product/PRD.md`](docs/01-product/PRD.md) — product
|
package/bin/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// knowless-server — standalone forward-auth HTTP server.
|
|
3
|
+
// All configuration via KNOWLESS_* env vars. PRD §7.12 (FR-49 to FR-56).
|
|
4
|
+
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import net from 'node:net';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import { knowless } from '../src/index.js';
|
|
13
|
+
|
|
14
|
+
const PKG = JSON.parse(
|
|
15
|
+
readFileSync(
|
|
16
|
+
join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
|
|
17
|
+
'utf8',
|
|
18
|
+
),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
// [optionKey, envVar, default (null = required), purpose, secret?]
|
|
22
|
+
const SPEC = [
|
|
23
|
+
['secret', 'KNOWLESS_SECRET', null, 'HMAC secret, ≥64 hex chars (32 bytes). REQUIRED.', true],
|
|
24
|
+
['baseUrl', 'KNOWLESS_BASE_URL', null, 'Public base URL for magic links. REQUIRED.'],
|
|
25
|
+
['from', 'KNOWLESS_FROM', null, 'Sender email address. REQUIRED.'],
|
|
26
|
+
['dbPath', 'KNOWLESS_DB_PATH', './knowless.db', 'SQLite database path.'],
|
|
27
|
+
['cookieDomain', 'KNOWLESS_COOKIE_DOMAIN', '', 'Session cookie domain. Defaults to baseUrl hostname.'],
|
|
28
|
+
['cookieSecure', 'KNOWLESS_COOKIE_SECURE', 'true', 'Set Secure flag on cookies. "false" only for localhost dev.'],
|
|
29
|
+
['tokenTtlSeconds', 'KNOWLESS_TOKEN_TTL_SECONDS', '900', 'Magic-link token lifetime (seconds).'],
|
|
30
|
+
['sessionTtlSeconds', 'KNOWLESS_SESSION_TTL_SECONDS', '2592000', 'Session lifetime (seconds, default 30d).'],
|
|
31
|
+
['linkPath', 'KNOWLESS_LINK_PATH', '/auth/callback', 'Magic-link callback path.'],
|
|
32
|
+
['loginPath', 'KNOWLESS_LOGIN_PATH', '/login', 'Login form path.'],
|
|
33
|
+
['verifyPath', 'KNOWLESS_VERIFY_PATH', '/verify', 'Forward-auth check path.'],
|
|
34
|
+
['logoutPath', 'KNOWLESS_LOGOUT_PATH', '/logout', 'Logout path.'],
|
|
35
|
+
['smtpHost', 'KNOWLESS_SMTP_HOST', 'localhost', 'SMTP host for outgoing magic-link mail.'],
|
|
36
|
+
['smtpPort', 'KNOWLESS_SMTP_PORT', '25', 'SMTP port.'],
|
|
37
|
+
['openRegistration', 'KNOWLESS_OPEN_REGISTRATION', 'false', 'Allow new-handle creation on first email.'],
|
|
38
|
+
['subject', 'KNOWLESS_SUBJECT', 'Sign in', 'Email subject line.'],
|
|
39
|
+
['includeLastLoginInEmail', 'KNOWLESS_INCLUDE_LAST_LOGIN_IN_EMAIL', 'true', 'Append last-sign-in note to magic-link email.'],
|
|
40
|
+
['maxActiveTokensPerHandle', 'KNOWLESS_MAX_ACTIVE_TOKENS_PER_HANDLE', '5', 'Cap on concurrent magic links per handle. 0 disables.'],
|
|
41
|
+
['maxLoginRequestsPerIpPerHour', 'KNOWLESS_MAX_LOGIN_REQUESTS_PER_IP_PER_HOUR', '30', 'Per-IP login submission cap. 0 disables.'],
|
|
42
|
+
['maxNewHandlesPerIpPerHour', 'KNOWLESS_MAX_NEW_HANDLES_PER_IP_PER_HOUR', '3', 'Per-IP account-creation cap (openRegistration only). 0 disables.'],
|
|
43
|
+
['honeypotFieldName', 'KNOWLESS_HONEYPOT_FIELD_NAME', 'website', 'Name of the honeypot field.'],
|
|
44
|
+
['trustedProxies', 'KNOWLESS_TRUSTED_PROXIES', '127.0.0.1,::1', 'Comma-separated IPs trusted for X-Forwarded-For.'],
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// PORT is a runtime-only knob; not part of the library options object.
|
|
48
|
+
const SERVER_SPEC = [
|
|
49
|
+
['port', 'KNOWLESS_PORT', '8080', 'TCP port the HTTP server binds.'],
|
|
50
|
+
['host', 'KNOWLESS_HOST', '0.0.0.0', 'Address the HTTP server binds.'],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const NUMERIC = new Set([
|
|
54
|
+
'tokenTtlSeconds',
|
|
55
|
+
'sessionTtlSeconds',
|
|
56
|
+
'smtpPort',
|
|
57
|
+
'maxActiveTokensPerHandle',
|
|
58
|
+
'maxLoginRequestsPerIpPerHour',
|
|
59
|
+
'maxNewHandlesPerIpPerHour',
|
|
60
|
+
]);
|
|
61
|
+
const BOOLEAN = new Set([
|
|
62
|
+
'cookieSecure',
|
|
63
|
+
'openRegistration',
|
|
64
|
+
'includeLastLoginInEmail',
|
|
65
|
+
]);
|
|
66
|
+
const LIST = new Set(['trustedProxies']);
|
|
67
|
+
|
|
68
|
+
function loadFromEnv(env = process.env) {
|
|
69
|
+
const opts = {};
|
|
70
|
+
for (const [key, envVar, def] of SPEC) {
|
|
71
|
+
const raw = env[envVar];
|
|
72
|
+
const value = raw !== undefined && raw !== '' ? raw : def;
|
|
73
|
+
if (value === null) continue; // required, missing — leave unset
|
|
74
|
+
if (value === '') continue; // optional with empty default — skip
|
|
75
|
+
if (NUMERIC.has(key)) opts[key] = Number(value);
|
|
76
|
+
else if (BOOLEAN.has(key)) opts[key] = value === 'true' || value === '1';
|
|
77
|
+
else if (LIST.has(key)) opts[key] = value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
78
|
+
else opts[key] = value;
|
|
79
|
+
}
|
|
80
|
+
const serverConfig = {};
|
|
81
|
+
for (const [key, envVar, def] of SERVER_SPEC) {
|
|
82
|
+
const raw = env[envVar];
|
|
83
|
+
const value = raw !== undefined && raw !== '' ? raw : def;
|
|
84
|
+
serverConfig[key] = key === 'port' ? Number(value) : value;
|
|
85
|
+
}
|
|
86
|
+
return { opts, serverConfig };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function printHelp() {
|
|
90
|
+
process.stdout.write(`knowless-server v${PKG.version}
|
|
91
|
+
|
|
92
|
+
Usage: knowless-server [--help | --version | --print-config | --config-check]
|
|
93
|
+
|
|
94
|
+
Configuration is via environment variables. CLI flags do NOT set config.
|
|
95
|
+
|
|
96
|
+
Required env vars:
|
|
97
|
+
`);
|
|
98
|
+
for (const [, envVar, def, purpose] of SPEC) {
|
|
99
|
+
if (def !== null) continue;
|
|
100
|
+
process.stdout.write(` ${envVar}\n ${purpose}\n`);
|
|
101
|
+
}
|
|
102
|
+
process.stdout.write('\nOptional env vars (with defaults):\n');
|
|
103
|
+
for (const [, envVar, def, purpose] of SPEC) {
|
|
104
|
+
if (def === null) continue;
|
|
105
|
+
const shown = def === '' ? '<derived>' : def;
|
|
106
|
+
process.stdout.write(` ${envVar}=${shown}\n ${purpose}\n`);
|
|
107
|
+
}
|
|
108
|
+
for (const [, envVar, def, purpose] of SERVER_SPEC) {
|
|
109
|
+
process.stdout.write(` ${envVar}=${def}\n ${purpose}\n`);
|
|
110
|
+
}
|
|
111
|
+
process.stdout.write(
|
|
112
|
+
'\nLoad a .env file with: node --env-file=knowless.env $(which knowless-server)\n',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatConfig({ opts, serverConfig }) {
|
|
117
|
+
const lines = [];
|
|
118
|
+
for (const [key, envVar, , , isSecret] of SPEC) {
|
|
119
|
+
if (!(key in opts)) {
|
|
120
|
+
lines.push(`${envVar}=<unset>`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
let val = opts[key];
|
|
124
|
+
if (isSecret) val = '<set>';
|
|
125
|
+
else if (Array.isArray(val)) val = val.join(',');
|
|
126
|
+
lines.push(`${envVar}=${val}`);
|
|
127
|
+
}
|
|
128
|
+
for (const [key, envVar] of SERVER_SPEC) {
|
|
129
|
+
lines.push(`${envVar}=${serverConfig[key]}`);
|
|
130
|
+
}
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function checkRequired(opts) {
|
|
135
|
+
const errors = [];
|
|
136
|
+
for (const [key, envVar, def] of SPEC) {
|
|
137
|
+
if (def !== null) continue;
|
|
138
|
+
if (!(key in opts)) errors.push(`${envVar} is missing`);
|
|
139
|
+
}
|
|
140
|
+
if (opts.secret && opts.secret.length < 64) {
|
|
141
|
+
errors.push('KNOWLESS_SECRET must be at least 64 hex chars (32 bytes)');
|
|
142
|
+
}
|
|
143
|
+
return errors;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function checkSmtpReachable(host, port, timeoutMs = 2000) {
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
const sock = net.createConnection({ host, port });
|
|
149
|
+
let done = false;
|
|
150
|
+
const finish = (ok, err) => {
|
|
151
|
+
if (done) return;
|
|
152
|
+
done = true;
|
|
153
|
+
try { sock.destroy(); } catch { /* tolerate */ }
|
|
154
|
+
resolve({ ok, err });
|
|
155
|
+
};
|
|
156
|
+
sock.setTimeout(timeoutMs);
|
|
157
|
+
sock.once('connect', () => finish(true));
|
|
158
|
+
sock.once('timeout', () => finish(false, new Error('connection timed out')));
|
|
159
|
+
sock.once('error', (e) => finish(false, e));
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function checkDbWritable(dbPath) {
|
|
164
|
+
if (dbPath === ':memory:') return { ok: true };
|
|
165
|
+
try {
|
|
166
|
+
const dir = dirname(dbPath);
|
|
167
|
+
fs.accessSync(dir, fs.constants.W_OK);
|
|
168
|
+
return { ok: true };
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return { ok: false, err };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function runConfigCheck({ opts, serverConfig }, { print } = { print: true }) {
|
|
175
|
+
if (print) process.stdout.write(formatConfig({ opts, serverConfig }) + '\n');
|
|
176
|
+
const errors = checkRequired(opts);
|
|
177
|
+
if (errors.length) {
|
|
178
|
+
for (const e of errors) process.stderr.write(`config error: ${e}\n`);
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
const smtp = await checkSmtpReachable(opts.smtpHost, opts.smtpPort);
|
|
182
|
+
if (!smtp.ok) {
|
|
183
|
+
process.stderr.write(
|
|
184
|
+
`config error: SMTP ${opts.smtpHost}:${opts.smtpPort} not reachable: ${smtp.err.message}\n`,
|
|
185
|
+
);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
process.stdout.write(`SMTP ${opts.smtpHost}:${opts.smtpPort} reachable: OK\n`);
|
|
189
|
+
const db = checkDbWritable(opts.dbPath);
|
|
190
|
+
if (!db.ok) {
|
|
191
|
+
process.stderr.write(
|
|
192
|
+
`config error: DB path ${opts.dbPath} not writable: ${db.err.message}\n`,
|
|
193
|
+
);
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
process.stdout.write(`DB path ${opts.dbPath} writable: OK\n`);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildRouter(auth, cfg) {
|
|
201
|
+
const map = new Map([
|
|
202
|
+
[`POST ${cfg.loginPath}`, auth.login],
|
|
203
|
+
[`GET ${cfg.loginPath}`, auth.loginForm],
|
|
204
|
+
[`GET ${cfg.linkPath}`, auth.callback],
|
|
205
|
+
[`GET ${cfg.verifyPath}`, auth.verify],
|
|
206
|
+
[`POST ${cfg.logoutPath}`, auth.logout],
|
|
207
|
+
]);
|
|
208
|
+
return (req, res) => {
|
|
209
|
+
const url = new URL(req.url, 'http://placeholder.invalid');
|
|
210
|
+
const handler = map.get(`${req.method} ${url.pathname}`);
|
|
211
|
+
if (!handler) {
|
|
212
|
+
res.statusCode = 404;
|
|
213
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
214
|
+
res.end('not found\n');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
Promise.resolve()
|
|
218
|
+
.then(() => handler(req, res))
|
|
219
|
+
.catch((err) => {
|
|
220
|
+
process.stderr.write(`[knowless] handler error: ${err.stack || err.message}\n`);
|
|
221
|
+
if (!res.headersSent) {
|
|
222
|
+
res.statusCode = 500;
|
|
223
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
224
|
+
res.end('internal error\n');
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function runServer({ opts, serverConfig }) {
|
|
231
|
+
const errors = checkRequired(opts);
|
|
232
|
+
if (errors.length) {
|
|
233
|
+
for (const e of errors) process.stderr.write(`config error: ${e}\n`);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
const auth = knowless(opts);
|
|
237
|
+
const router = buildRouter(auth, auth.config);
|
|
238
|
+
const server = createServer(router);
|
|
239
|
+
|
|
240
|
+
await new Promise((resolve, reject) => {
|
|
241
|
+
server.once('error', reject);
|
|
242
|
+
server.listen(serverConfig.port, serverConfig.host, () => {
|
|
243
|
+
server.off('error', reject);
|
|
244
|
+
resolve();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// FR-54: single startup log block.
|
|
249
|
+
const block =
|
|
250
|
+
'--- knowless-server started ---\n' +
|
|
251
|
+
formatConfig({ opts, serverConfig }) +
|
|
252
|
+
`\nlistening: http://${serverConfig.host}:${serverConfig.port}\n` +
|
|
253
|
+
'-------------------------------\n';
|
|
254
|
+
process.stdout.write(block);
|
|
255
|
+
|
|
256
|
+
const shutdown = (sig) => {
|
|
257
|
+
process.stdout.write(`[knowless] ${sig}, shutting down\n`);
|
|
258
|
+
server.close(() => {
|
|
259
|
+
try { auth.close(); } catch { /* tolerate */ }
|
|
260
|
+
process.exit(0);
|
|
261
|
+
});
|
|
262
|
+
setTimeout(() => process.exit(1), 5000).unref();
|
|
263
|
+
};
|
|
264
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
265
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function main(argv = process.argv.slice(2)) {
|
|
270
|
+
let parsed;
|
|
271
|
+
try {
|
|
272
|
+
parsed = parseArgs({
|
|
273
|
+
args: argv,
|
|
274
|
+
options: {
|
|
275
|
+
help: { type: 'boolean' },
|
|
276
|
+
version: { type: 'boolean' },
|
|
277
|
+
'print-config': { type: 'boolean' },
|
|
278
|
+
'config-check': { type: 'boolean' },
|
|
279
|
+
},
|
|
280
|
+
strict: true,
|
|
281
|
+
allowPositionals: false,
|
|
282
|
+
});
|
|
283
|
+
} catch (err) {
|
|
284
|
+
process.stderr.write(`error: ${err.message}\nrun with --help for usage\n`);
|
|
285
|
+
return 2;
|
|
286
|
+
}
|
|
287
|
+
const { values } = parsed;
|
|
288
|
+
if (values.help) { printHelp(); return 0; }
|
|
289
|
+
if (values.version) { process.stdout.write(PKG.version + '\n'); return 0; }
|
|
290
|
+
|
|
291
|
+
const cfg = loadFromEnv();
|
|
292
|
+
if (values['print-config']) {
|
|
293
|
+
process.stdout.write(formatConfig(cfg) + '\n');
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
if (values['config-check']) {
|
|
297
|
+
return runConfigCheck(cfg);
|
|
298
|
+
}
|
|
299
|
+
const code = await runServer(cfg);
|
|
300
|
+
return code === null ? undefined : code;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const isEntrypoint = import.meta.url === `file://${process.argv[1]}`;
|
|
304
|
+
if (isEntrypoint) {
|
|
305
|
+
main().then((code) => {
|
|
306
|
+
if (code !== undefined) process.exit(code);
|
|
307
|
+
}).catch((err) => {
|
|
308
|
+
process.stderr.write(`fatal: ${err.stack || err.message}\n`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export { loadFromEnv, formatConfig, checkRequired, SPEC, SERVER_SPEC, main };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# knowless-server — example configuration. PRD FR-56.
|
|
2
|
+
#
|
|
3
|
+
# Copy to e.g. /etc/knowless/knowless.env (mode 0600) and load via:
|
|
4
|
+
# node --env-file=/etc/knowless/knowless.env $(which knowless-server)
|
|
5
|
+
# or systemd:
|
|
6
|
+
# EnvironmentFile=/etc/knowless/knowless.env
|
|
7
|
+
#
|
|
8
|
+
# This file is documentation, NOT loaded automatically by the library.
|
|
9
|
+
|
|
10
|
+
# --- REQUIRED ---
|
|
11
|
+
|
|
12
|
+
# HMAC secret for handle derivation and session signing.
|
|
13
|
+
# Generate with: openssl rand -hex 32
|
|
14
|
+
KNOWLESS_SECRET=
|
|
15
|
+
|
|
16
|
+
# Public base URL of this auth server.
|
|
17
|
+
KNOWLESS_BASE_URL=https://auth.example.com
|
|
18
|
+
|
|
19
|
+
# Sender address for outgoing magic-link mail.
|
|
20
|
+
KNOWLESS_FROM=auth@example.com
|
|
21
|
+
|
|
22
|
+
# --- COMMON ---
|
|
23
|
+
|
|
24
|
+
# SQLite database path. Parent dir must be writable.
|
|
25
|
+
KNOWLESS_DB_PATH=/var/lib/knowless/knowless.db
|
|
26
|
+
|
|
27
|
+
# Cookie domain. Defaults to baseUrl hostname; usually set to your eTLD+1
|
|
28
|
+
# (e.g. example.com) so the session cookie is shared across subdomains.
|
|
29
|
+
# KNOWLESS_COOKIE_DOMAIN=example.com
|
|
30
|
+
|
|
31
|
+
# Set "false" only for http://localhost development. Library logs a warning.
|
|
32
|
+
KNOWLESS_COOKIE_SECURE=true
|
|
33
|
+
|
|
34
|
+
# Allow new-handle creation on first email. Default false (closed-reg).
|
|
35
|
+
KNOWLESS_OPEN_REGISTRATION=false
|
|
36
|
+
|
|
37
|
+
# --- SERVER ---
|
|
38
|
+
|
|
39
|
+
KNOWLESS_HOST=0.0.0.0
|
|
40
|
+
KNOWLESS_PORT=8080
|
|
41
|
+
|
|
42
|
+
# --- SMTP ---
|
|
43
|
+
# Default assumes a local Postfix on the same host (recommended).
|
|
44
|
+
|
|
45
|
+
KNOWLESS_SMTP_HOST=localhost
|
|
46
|
+
KNOWLESS_SMTP_PORT=25
|
|
47
|
+
|
|
48
|
+
# --- TIMINGS (seconds) ---
|
|
49
|
+
|
|
50
|
+
KNOWLESS_TOKEN_TTL_SECONDS=900
|
|
51
|
+
KNOWLESS_SESSION_TTL_SECONDS=2592000
|
|
52
|
+
|
|
53
|
+
# --- PATHS ---
|
|
54
|
+
|
|
55
|
+
KNOWLESS_LOGIN_PATH=/login
|
|
56
|
+
KNOWLESS_LINK_PATH=/auth/callback
|
|
57
|
+
KNOWLESS_VERIFY_PATH=/verify
|
|
58
|
+
KNOWLESS_LOGOUT_PATH=/logout
|
|
59
|
+
|
|
60
|
+
# --- ABUSE LIMITS (0 disables) ---
|
|
61
|
+
|
|
62
|
+
KNOWLESS_MAX_ACTIVE_TOKENS_PER_HANDLE=5
|
|
63
|
+
KNOWLESS_MAX_LOGIN_REQUESTS_PER_IP_PER_HOUR=30
|
|
64
|
+
KNOWLESS_MAX_NEW_HANDLES_PER_IP_PER_HOUR=3
|
|
65
|
+
KNOWLESS_HONEYPOT_FIELD_NAME=website
|
|
66
|
+
|
|
67
|
+
# Comma-separated. Reverse proxy must be in this list for X-Forwarded-For
|
|
68
|
+
# to be honored.
|
|
69
|
+
KNOWLESS_TRUSTED_PROXIES=127.0.0.1,::1
|
|
70
|
+
|
|
71
|
+
# --- EMAIL CONTENT ---
|
|
72
|
+
|
|
73
|
+
KNOWLESS_SUBJECT=Sign in
|
|
74
|
+
KNOWLESS_INCLUDE_LAST_LOGIN_IN_EMAIL=true
|
package/package.json
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knowless",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.js"
|
|
9
9
|
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"knowless-server": "./bin/knowless-server"
|
|
12
|
+
},
|
|
10
13
|
"files": [
|
|
11
14
|
"src/",
|
|
15
|
+
"bin/",
|
|
16
|
+
"config.example.env",
|
|
12
17
|
"LICENSE",
|
|
13
18
|
"NOTICE",
|
|
14
19
|
"README.md",
|
|
15
20
|
"CHANGELOG.md",
|
|
16
21
|
"GUIDE.md",
|
|
22
|
+
"OPS.md",
|
|
17
23
|
"knowless.context.md"
|
|
18
24
|
],
|
|
19
25
|
"engines": {
|
package/src/abuse.js
CHANGED
|
@@ -1,3 +1,62 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a peer-IP matcher from a list of plain IPs and/or CIDR ranges.
|
|
5
|
+
* AF-6.3 — CIDR support so docker / k8s / cgnat ranges can be trusted
|
|
6
|
+
* without enumerating every address.
|
|
7
|
+
*
|
|
8
|
+
* Accepts:
|
|
9
|
+
* - bare IPv4/IPv6 ("127.0.0.1", "::1")
|
|
10
|
+
* - CIDR ("10.0.0.0/8", "fd00::/8")
|
|
11
|
+
* - a Set or array of either
|
|
12
|
+
* - a `node:net` BlockList (passed through)
|
|
13
|
+
*
|
|
14
|
+
* @param {Set<string>|string[]|net.BlockList} trustedProxies
|
|
15
|
+
* @returns {{ has: (ip: string) => boolean }}
|
|
16
|
+
*/
|
|
17
|
+
export function buildTrustedPeers(trustedProxies) {
|
|
18
|
+
if (trustedProxies && typeof trustedProxies.check === 'function') {
|
|
19
|
+
return { has: (ip) => safeBlockListCheck(trustedProxies, ip) };
|
|
20
|
+
}
|
|
21
|
+
const list = Array.isArray(trustedProxies)
|
|
22
|
+
? trustedProxies
|
|
23
|
+
: trustedProxies instanceof Set
|
|
24
|
+
? [...trustedProxies]
|
|
25
|
+
: [];
|
|
26
|
+
const exact = new Set();
|
|
27
|
+
const block = new net.BlockList();
|
|
28
|
+
for (const entry of list) {
|
|
29
|
+
if (typeof entry !== 'string' || !entry) continue;
|
|
30
|
+
const slash = entry.indexOf('/');
|
|
31
|
+
if (slash >= 0) {
|
|
32
|
+
const addr = entry.slice(0, slash);
|
|
33
|
+
const prefix = Number(entry.slice(slash + 1));
|
|
34
|
+
const family = net.isIPv6(addr) ? 'ipv6' : 'ipv4';
|
|
35
|
+
try {
|
|
36
|
+
block.addSubnet(addr, prefix, family);
|
|
37
|
+
} catch {
|
|
38
|
+
/* skip malformed CIDR rather than crash */
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
exact.add(entry);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
has: (ip) => exact.has(ip) || safeBlockListCheck(block, ip),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function safeBlockListCheck(block, ip) {
|
|
50
|
+
if (typeof ip !== 'string' || ip.length === 0) return false;
|
|
51
|
+
const family = net.isIPv6(ip) ? 'ipv6' : net.isIPv4(ip) ? 'ipv4' : null;
|
|
52
|
+
if (!family) return false;
|
|
53
|
+
try {
|
|
54
|
+
return block.check(ip, family);
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
1
60
|
/**
|
|
2
61
|
* Determine the source IP of a request per FR-42 and SPEC §7.6.
|
|
3
62
|
*
|
|
@@ -6,19 +65,20 @@
|
|
|
6
65
|
* fall back to the connection's remote address. This prevents IP
|
|
7
66
|
* spoofing from clients while supporting forward-auth deployments.
|
|
8
67
|
*
|
|
68
|
+
* `trustedProxies` accepts plain IPs and CIDR ranges (AF-6.3).
|
|
69
|
+
*
|
|
9
70
|
* @param {{
|
|
10
71
|
* socket?: { remoteAddress?: string },
|
|
11
72
|
* connection?: { remoteAddress?: string },
|
|
12
73
|
* headers?: Record<string, string|string[]|undefined>
|
|
13
74
|
* }} req a node:http request (or shape-compatible)
|
|
14
|
-
* @param {Set<string>|string[]} trustedProxies
|
|
75
|
+
* @param {Set<string>|string[]|net.BlockList} trustedProxies trusted peer IPs / CIDRs
|
|
15
76
|
* @returns {string} the determined IP, or '' if undeterminable
|
|
16
77
|
*/
|
|
17
78
|
export function determineSourceIp(req, trustedProxies) {
|
|
18
79
|
const peer =
|
|
19
80
|
req?.socket?.remoteAddress ?? req?.connection?.remoteAddress ?? '';
|
|
20
|
-
const trusted =
|
|
21
|
-
trustedProxies instanceof Set ? trustedProxies : new Set(trustedProxies ?? []);
|
|
81
|
+
const trusted = buildTrustedPeers(trustedProxies);
|
|
22
82
|
if (!trusted.has(peer)) {
|
|
23
83
|
return peer;
|
|
24
84
|
}
|
package/src/form.js
CHANGED
|
@@ -56,10 +56,15 @@ export function renderLoginForm(args) {
|
|
|
56
56
|
next,
|
|
57
57
|
} = args;
|
|
58
58
|
|
|
59
|
+
// confirmationMessage is operator-supplied config, not user input — but
|
|
60
|
+
// operators may naively interpolate user data into it. Escape the whole
|
|
61
|
+
// message before substituting {email} (which is itself escaped). The
|
|
62
|
+
// contract is "confirmationMessage is plain text + {email} placeholder";
|
|
63
|
+
// operators who want HTML can pre-render upstream. Closes AF-6.5.
|
|
59
64
|
const messageBlock =
|
|
60
65
|
confirmationMessage != null
|
|
61
66
|
? `<div class="msg" role="status">${
|
|
62
|
-
confirmationMessage.replace(
|
|
67
|
+
htmlEscape(confirmationMessage).replace(
|
|
63
68
|
/\{email\}/g,
|
|
64
69
|
htmlEscape(echoedEmail ?? ''),
|
|
65
70
|
)
|
package/src/handlers.js
CHANGED
|
@@ -5,6 +5,7 @@ import { newSid, signSession, verifySessionSignature } from './session.js';
|
|
|
5
5
|
import { composeBody } from './mailer.js';
|
|
6
6
|
import { renderLoginForm } from './form.js';
|
|
7
7
|
import {
|
|
8
|
+
buildTrustedPeers,
|
|
8
9
|
determineSourceIp,
|
|
9
10
|
rateLimitExceeded,
|
|
10
11
|
rateLimitIncrement,
|
|
@@ -178,7 +179,8 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
178
179
|
}
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
|
|
182
|
+
// Build once at handler creation; supports plain IPs and CIDRs (AF-6.3).
|
|
183
|
+
const trustedProxies = buildTrustedPeers(cfg.trustedProxies);
|
|
182
184
|
|
|
183
185
|
// SPEC §5.4 / FR-30: build the cookie-attribute suffix once. Secure is
|
|
184
186
|
// emitted by default and omitted only when cookieSecure: false (localhost
|
|
@@ -319,6 +321,14 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
319
321
|
} catch (err) {
|
|
320
322
|
// Per NFR-10: SMTP failure logged, NEVER leaked to response shape.
|
|
321
323
|
console.error('[knowless] mail submit failed:', err.message);
|
|
324
|
+
// AF-6.2: dev-mode fallback. When SMTP is unreachable in local
|
|
325
|
+
// development the operator otherwise has no way to obtain the magic
|
|
326
|
+
// link. Print it to stderr only when explicitly opted in. Sham
|
|
327
|
+
// submissions are NOT logged (would leak silent-miss outcome).
|
|
328
|
+
if (cfg.devLogMagicLinks && !isSham) {
|
|
329
|
+
const link = `${cfg.baseUrl}${cfg.linkPath}?t=${token.raw}`;
|
|
330
|
+
process.stderr.write(`[knowless dev] magic link: ${link}\n`);
|
|
331
|
+
}
|
|
322
332
|
}
|
|
323
333
|
|
|
324
334
|
rateLimitIncrement(store, 'login_ip', ip, HOUR_MS);
|
|
@@ -400,6 +410,15 @@ export function createHandlers({ store, mailer, config }) {
|
|
|
400
410
|
}
|
|
401
411
|
|
|
402
412
|
async function logout(req, res) {
|
|
413
|
+
// CSRF defense — same Origin/Referer check as POST /login (AF-4.3).
|
|
414
|
+
// Without this, a third-party page can force-logout a victim. Closes
|
|
415
|
+
// AF-6.4. Browser-absent (curl/programmatic) is allowed.
|
|
416
|
+
if (!validateOrigin(req, cfg.cookieDomain)) {
|
|
417
|
+
res.statusCode = 403;
|
|
418
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
419
|
+
res.end('forbidden\n');
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
403
422
|
const cookie = getCookie(req, cfg.cookieName);
|
|
404
423
|
if (cookie) {
|
|
405
424
|
const sid = verifySessionSignature(cookie, cfg.secret);
|
package/src/index.js
CHANGED
|
@@ -137,6 +137,10 @@ export function knowless(options = {}) {
|
|
|
137
137
|
handleFromRequest: handlers.handleFromRequest,
|
|
138
138
|
/** Delete a handle + all tokens + all sessions atomically (FR-37a). */
|
|
139
139
|
deleteHandle: (handle) => store.deleteHandle(handle),
|
|
140
|
+
/** Revoke every session for `handle` without deleting the handle.
|
|
141
|
+
* "Log out everywhere." Returns the number of sessions removed.
|
|
142
|
+
* AF-6.1. */
|
|
143
|
+
revokeSessions: (handle) => store.revokeSessions(handle),
|
|
140
144
|
/** Effective config (with defaults applied), useful for routing. */
|
|
141
145
|
config: handlers._config,
|
|
142
146
|
/** Run a sweep tick on demand. Useful for tests and operator scripts. */
|
package/src/store.js
CHANGED
|
@@ -278,6 +278,11 @@ export function createStore(dbPath = ':memory:') {
|
|
|
278
278
|
assertHexHash(sidHash, 'sidHash');
|
|
279
279
|
return stmt.deleteSession.run(sidHash).changes > 0;
|
|
280
280
|
},
|
|
281
|
+
/** Delete every session for `handle`. Returns rows-deleted. AF-6.1. */
|
|
282
|
+
revokeSessions(handle) {
|
|
283
|
+
assertHexHash(handle, 'handle');
|
|
284
|
+
return stmt.deleteHandleSessions.run(handle).changes;
|
|
285
|
+
},
|
|
281
286
|
sweepSessions(now = Date.now()) {
|
|
282
287
|
return stmt.sweepSessions.run(now).changes;
|
|
283
288
|
},
|