@timqi/pier 0.0.16 → 0.0.17
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/dist/db.js +61 -0
- package/dist/settings.js +2 -11
- package/dist/tools.js +10 -16
- package/dist/web/auth.js +215 -65
- package/dist/web/public/assets/{ghostty-web-C4ivXTBE.js → ghostty-web-CV7F9_iW.js} +1 -1
- package/dist/web/public/assets/{index-2E9_cwpg.css → index-CtR-Ob4T.css} +1 -1
- package/dist/web/public/assets/{index-DVUvzNK1.js → index-DAxKtidH.js} +19 -19
- package/dist/web/public/index.html +2 -2
- package/dist/web/push.js +30 -7
- package/dist/web/terminal.js +19 -9
- package/docs/deploy.md +12 -3
- package/package.json +1 -1
package/dist/db.js
CHANGED
|
@@ -253,8 +253,63 @@ const MIGRATIONS = [
|
|
|
253
253
|
token TEXT NOT NULL,
|
|
254
254
|
heartbeat_at INTEGER NOT NULL
|
|
255
255
|
);
|
|
256
|
+
`,
|
|
257
|
+
// 13 — a signed-in browser can be signed out on its own (web/auth.ts).
|
|
258
|
+
`
|
|
259
|
+
-- One row per signed-in browser. The cookie carries "<id>.<token>" and only
|
|
260
|
+
-- the token's SHA-256 is stored, so a copy of this database cannot be turned
|
|
261
|
+
-- into a session — and deleting a row is what revocation is. seen_at is the
|
|
262
|
+
-- whole lifetime: the session ends one TTL after it, so there is no second
|
|
263
|
+
-- column that can disagree about when.
|
|
264
|
+
CREATE TABLE web_sessions (
|
|
265
|
+
id TEXT PRIMARY KEY,
|
|
266
|
+
token_hash TEXT NOT NULL,
|
|
267
|
+
created_at INTEGER NOT NULL,
|
|
268
|
+
seen_at INTEGER NOT NULL,
|
|
269
|
+
ip TEXT NOT NULL,
|
|
270
|
+
agent TEXT NOT NULL
|
|
271
|
+
);
|
|
272
|
+
`,
|
|
273
|
+
// 14 — signing a browser out also stops notifying it (web/push.ts).
|
|
274
|
+
`
|
|
275
|
+
-- A subscription belongs to the web session that made it, and dies with it:
|
|
276
|
+
-- the cascade is the rule, so no code has to remember to run it — revoking a
|
|
277
|
+
-- session, changing the password and recovering it all reach here for free.
|
|
278
|
+
-- Rebuilt rather than altered because a foreign key cannot be added to an
|
|
279
|
+
-- existing table; nothing is carried over, since migration 13 invalidated
|
|
280
|
+
-- every cookie and each of these rows belongs to a browser that is now
|
|
281
|
+
-- signed out. A browser re-subscribes on its next load.
|
|
282
|
+
DROP TABLE push_subscriptions;
|
|
283
|
+
CREATE TABLE push_subscriptions (
|
|
284
|
+
endpoint TEXT PRIMARY KEY,
|
|
285
|
+
p256dh TEXT NOT NULL,
|
|
286
|
+
auth TEXT NOT NULL,
|
|
287
|
+
label TEXT NOT NULL,
|
|
288
|
+
created_at INTEGER NOT NULL,
|
|
289
|
+
session_id TEXT NOT NULL REFERENCES web_sessions(id) ON DELETE CASCADE
|
|
290
|
+
);
|
|
256
291
|
`,
|
|
257
292
|
];
|
|
293
|
+
/**
|
|
294
|
+
* Several writes as one, or none. `BEGIN IMMEDIATE` because every writer here
|
|
295
|
+
* competes with another Pier process on the same file: taking the write lock
|
|
296
|
+
* up front turns a race into a wait, where deferred would turn it into
|
|
297
|
+
* SQLITE_BUSY halfway through. The rollback is the reason this is shared —
|
|
298
|
+
* three modules had written the same seven lines, and a `catch` that forgets
|
|
299
|
+
* to roll back leaves the connection in a transaction forever.
|
|
300
|
+
*/
|
|
301
|
+
export function transact(db, work) {
|
|
302
|
+
db.exec("BEGIN IMMEDIATE");
|
|
303
|
+
try {
|
|
304
|
+
const result = work();
|
|
305
|
+
db.exec("COMMIT");
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
db.exec("ROLLBACK");
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
258
313
|
let shared;
|
|
259
314
|
/**
|
|
260
315
|
* The process's one connection, opened and migrated on first use. Every store
|
|
@@ -295,6 +350,12 @@ export function openDb(path, migrations = MIGRATIONS) {
|
|
|
295
350
|
// file, and SQLite refuses to change it inside one.
|
|
296
351
|
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
297
352
|
db.exec("PRAGMA journal_mode = WAL");
|
|
353
|
+
// Off by default in SQLite, and a declared relationship nothing enforces is
|
|
354
|
+
// a comment. Set before migrate(): it is a per-connection switch and a no-op
|
|
355
|
+
// inside a transaction. Nothing older declares a key, so this changes the
|
|
356
|
+
// behaviour of exactly one table — push_subscriptions, whose rows must not
|
|
357
|
+
// outlive the session that made them.
|
|
358
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
298
359
|
migrate(db, path, migrations);
|
|
299
360
|
if (path !== ":memory:")
|
|
300
361
|
restrict(path);
|
package/dist/settings.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// too" — the agent is told the URL in its system prompt (core/reply.ts), and
|
|
8
8
|
// nothing outside this process ever opened that file.
|
|
9
9
|
import { isThinkingLevel } from "./core/types.js";
|
|
10
|
-
import { pierDb } from "./db.js";
|
|
10
|
+
import { pierDb, transact } from "./db.js";
|
|
11
11
|
import { logger } from "./log.js";
|
|
12
12
|
// The one place the custom-tool vocabulary lives (names, ubix sources, the
|
|
13
13
|
// names Pier already owns). Imported rather than copied: a second validator
|
|
@@ -201,16 +201,7 @@ export class SettingsStore {
|
|
|
201
201
|
* declared and invisible.
|
|
202
202
|
*/
|
|
203
203
|
transact(work) {
|
|
204
|
-
this.#db
|
|
205
|
-
try {
|
|
206
|
-
const result = work();
|
|
207
|
-
this.#db.exec("COMMIT");
|
|
208
|
-
return result;
|
|
209
|
-
}
|
|
210
|
-
catch (err) {
|
|
211
|
-
this.#db.exec("ROLLBACK");
|
|
212
|
-
throw err;
|
|
213
|
-
}
|
|
204
|
+
return transact(this.#db, work);
|
|
214
205
|
}
|
|
215
206
|
#set(key, value) {
|
|
216
207
|
this.#db.prepare(`
|
package/dist/tools.js
CHANGED
|
@@ -16,7 +16,7 @@ import { execFile } from "node:child_process";
|
|
|
16
16
|
import { createHash, randomUUID } from "node:crypto";
|
|
17
17
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { delimiter, join } from "node:path";
|
|
19
|
-
import { pierDb } from "./db.js";
|
|
19
|
+
import { pierDb, transact } from "./db.js";
|
|
20
20
|
import { logger } from "./log.js";
|
|
21
21
|
import { pierPath, resolveAgentDir } from "./paths.js";
|
|
22
22
|
const log = logger("tools");
|
|
@@ -253,21 +253,15 @@ export class SyncLock {
|
|
|
253
253
|
* in one immediate transaction, so two waiters cannot both win. */
|
|
254
254
|
#acquire(token) {
|
|
255
255
|
const now = Date.now();
|
|
256
|
-
this.#db
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return taken.changes === 1;
|
|
266
|
-
}
|
|
267
|
-
catch (err) {
|
|
268
|
-
this.#db.exec("ROLLBACK");
|
|
269
|
-
throw err;
|
|
270
|
-
}
|
|
256
|
+
const { stale, taken } = transact(this.#db, () => ({
|
|
257
|
+
stale: this.#db.prepare("DELETE FROM tools_sync_lock WHERE heartbeat_at <= ?")
|
|
258
|
+
.run(now - this.#timing.staleMs),
|
|
259
|
+
taken: this.#db.prepare("INSERT OR IGNORE INTO tools_sync_lock (id, token, heartbeat_at) VALUES (1, ?, ?)")
|
|
260
|
+
.run(token, now),
|
|
261
|
+
}));
|
|
262
|
+
if (stale.changes && taken.changes)
|
|
263
|
+
log.warn("took over a tools sync lock whose holder stopped beating");
|
|
264
|
+
return taken.changes === 1;
|
|
271
265
|
}
|
|
272
266
|
/** Still ours? The authority is the row, asked now — not the heartbeat's own
|
|
273
267
|
* bookkeeping, which a stopped process does not get to run either. */
|
package/dist/web/auth.js
CHANGED
|
@@ -10,19 +10,29 @@
|
|
|
10
10
|
// password exists before the listener does — and no env var for an operator to
|
|
11
11
|
// get wrong. Forgot it? Delete the row and restart; a new one is printed.
|
|
12
12
|
//
|
|
13
|
-
// The cookie is
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
13
|
+
// The cookie is "<id>.<token>", and the database keeps the token's SHA-256 —
|
|
14
|
+
// one row per signed-in browser. Two things follow, and both are why this is
|
|
15
|
+
// not the signed expiry it used to be: a copy of pier.db cannot be turned into
|
|
16
|
+
// a session (there is no signing key in it to forge with), and a single
|
|
17
|
+
// browser can be signed out without changing the password everyone shares. A
|
|
18
|
+
// cookie (not a bearer header) because the workbench lives on SSE, and
|
|
19
|
+
// EventSource sends no headers.
|
|
20
|
+
import { createHash, randomBytes, randomInt, scryptSync, timingSafeEqual } from "node:crypto";
|
|
19
21
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
20
22
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
21
|
-
import { pierDb } from "../db.js";
|
|
23
|
+
import { pierDb, transact } from "../db.js";
|
|
22
24
|
import { logger } from "../log.js";
|
|
23
25
|
const log = logger("auth");
|
|
24
26
|
const COOKIE = "pier_session";
|
|
25
|
-
|
|
27
|
+
/** How long an idle browser stays signed in. Sliding: a session in daily use
|
|
28
|
+
* never expires, and a stolen cookie is dead a week after its last use. */
|
|
29
|
+
const TTL_MS = 7 * 24 * 60 * 60_000;
|
|
30
|
+
/** How stale `seen_at` may get before a request writes. Renewal rides on it,
|
|
31
|
+
* so this is also how coarse "last seen" is — one write per browser per five
|
|
32
|
+
* minutes instead of one per request. */
|
|
33
|
+
const TOUCH_MS = 5 * 60_000;
|
|
34
|
+
/** `revoke(ALL)` — not an id any row can have, so it cannot collide with one. */
|
|
35
|
+
export const ALL = "*";
|
|
26
36
|
/** Failed attempts one client may make before it has to wait out the window. */
|
|
27
37
|
const MAX_FAILURES = 10;
|
|
28
38
|
const WINDOW_MS = 15 * 60_000;
|
|
@@ -57,9 +67,7 @@ function generatePassword() {
|
|
|
57
67
|
*/
|
|
58
68
|
export class AuthStore {
|
|
59
69
|
#db;
|
|
60
|
-
|
|
61
|
-
#key;
|
|
62
|
-
#rotationListeners = new Set();
|
|
70
|
+
#revokeListeners = new Set();
|
|
63
71
|
constructor(db = pierDb(), print = (m) => log.info(m)) {
|
|
64
72
|
this.#db = db;
|
|
65
73
|
let row = this.#row();
|
|
@@ -67,53 +75,157 @@ export class AuthStore {
|
|
|
67
75
|
const password = generatePassword();
|
|
68
76
|
const salt = randomBytes(16).toString("hex");
|
|
69
77
|
row = { salt, hash: hash(password, salt), createdAt: Date.now() };
|
|
70
|
-
this
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
// Recovery is "DELETE FROM auth and restart", so this branch is also how
|
|
79
|
+
// a forgotten password is replaced — and the browsers signed in under the
|
|
80
|
+
// old one must not walk through it. Their rows go with the credential,
|
|
81
|
+
// in one transaction: half of this leaves a new password and live old
|
|
82
|
+
// cookies, which is the state recovery exists to end.
|
|
83
|
+
transact(this.#db, () => {
|
|
84
|
+
this.#db
|
|
85
|
+
.prepare("INSERT INTO auth(id, salt, hash, created_at) VALUES (1, ?, ?, ?)")
|
|
86
|
+
.run(salt, hash(password, salt), Date.now());
|
|
87
|
+
this.#dropSessions();
|
|
88
|
+
});
|
|
73
89
|
print(`\nthis instance had no password, so one was generated:\n\n ${password}\n\n` +
|
|
74
90
|
`only its hash is stored — it is not printed again. ` +
|
|
75
91
|
`Lost it? "DELETE FROM auth" in the database, then restart.\n`);
|
|
76
92
|
}
|
|
77
|
-
|
|
93
|
+
// Boot is the one moment that comes around on its own. Without it, a
|
|
94
|
+
// session that expired while Pier was down would sit there notifying a
|
|
95
|
+
// phone until somebody happened to sign in.
|
|
96
|
+
this.sweep();
|
|
78
97
|
}
|
|
79
98
|
#row() {
|
|
80
99
|
return this.#db
|
|
81
100
|
.prepare("SELECT salt, hash, created_at AS createdAt FROM auth WHERE id = 1")
|
|
82
101
|
.get();
|
|
83
102
|
}
|
|
103
|
+
/** Every signed-in browser at once. Private: the callers that mean it also
|
|
104
|
+
* have to tell the listeners, and `revoke(ALL)` is that pair in public. */
|
|
105
|
+
#dropSessions() {
|
|
106
|
+
this.#db.prepare("DELETE FROM web_sessions").run();
|
|
107
|
+
}
|
|
108
|
+
/** Sessions nobody may use any more, deleted rather than merely refused: a
|
|
109
|
+
* row is what a push subscription hangs off, so "expired" has to become
|
|
110
|
+
* "gone" without waiting for the browser to come back and be told. Run at
|
|
111
|
+
* boot and whenever somebody signs in — the two moments the process has a
|
|
112
|
+
* reason to look at this table at all. */
|
|
113
|
+
sweep() {
|
|
114
|
+
const swept = this.#db
|
|
115
|
+
.prepare("DELETE FROM web_sessions WHERE seen_at <= ? RETURNING id")
|
|
116
|
+
.all(Date.now() - TTL_MS);
|
|
117
|
+
for (const row of swept)
|
|
118
|
+
this.#revoked(row.id);
|
|
119
|
+
if (swept.length)
|
|
120
|
+
log.info(`swept ${String(swept.length)} expired session(s)`);
|
|
121
|
+
}
|
|
84
122
|
/** Whether this is the password, compared in constant time. */
|
|
85
123
|
verify(password) {
|
|
86
124
|
const row = this.#row();
|
|
87
125
|
return row ? sameSecret(hash(password, row.salt), row.hash) : false;
|
|
88
126
|
}
|
|
89
127
|
/**
|
|
90
|
-
* Replace the password, salt and all
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* the point: a password is changed because the old one may be known.
|
|
128
|
+
* Replace the password, salt and all — and with it every session, the
|
|
129
|
+
* caller's own included. That is the point: a password is changed because the
|
|
130
|
+
* old one may be known, so nothing that was signed in under it stays signed
|
|
131
|
+
* in. Listeners hear it after the commit, never before.
|
|
95
132
|
*/
|
|
96
133
|
setPassword(password) {
|
|
97
134
|
const salt = randomBytes(16).toString("hex");
|
|
98
|
-
|
|
135
|
+
// Credential and sessions change together or not at all — a crash between
|
|
136
|
+
// the two writes is exactly the state "everyone signs in again" denies.
|
|
137
|
+
transact(this.#db, () => {
|
|
138
|
+
this.#db
|
|
139
|
+
.prepare("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
|
|
140
|
+
.run(salt, hash(password, salt), Date.now());
|
|
141
|
+
this.#dropSessions();
|
|
142
|
+
});
|
|
143
|
+
this.#revoked(ALL);
|
|
144
|
+
}
|
|
145
|
+
/** Sign a browser in: one row, and the cookie value that opens it. */
|
|
146
|
+
open(ip, agent) {
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
this.sweep();
|
|
149
|
+
// The id names the row and the token proves it: 72 bits is plenty for a
|
|
150
|
+
// name, and the 256-bit token is the only part that has to resist guessing.
|
|
151
|
+
const id = randomBytes(9).toString("base64url");
|
|
152
|
+
const token = randomBytes(32).toString("base64url");
|
|
99
153
|
this.#db
|
|
100
|
-
.prepare("
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
listener();
|
|
154
|
+
.prepare("INSERT INTO web_sessions(id, token_hash, created_at, seen_at, ip, agent)" +
|
|
155
|
+
" VALUES (?, ?, ?, ?, ?, ?)")
|
|
156
|
+
.run(id, digest(token), now, now, ip, agent.slice(0, 200));
|
|
157
|
+
return `${id}.${token}`;
|
|
105
158
|
}
|
|
106
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* The row this cookie names, if the token matches and the row is live.
|
|
161
|
+
* `renewed` says the deadline just moved, which is the caller's cue to send
|
|
162
|
+
* the browser a cookie with the new Max-Age — the sliding window has to slide
|
|
163
|
+
* on both sides or the browser drops a cookie the database still honours.
|
|
164
|
+
*/
|
|
165
|
+
check(cookie) {
|
|
166
|
+
const [id, token] = (cookie ?? "").split(".");
|
|
167
|
+
if (!id || !token)
|
|
168
|
+
return undefined;
|
|
169
|
+
const row = this.#db
|
|
170
|
+
.prepare("SELECT token_hash AS tokenHash, seen_at AS seenAt FROM web_sessions WHERE id = ?")
|
|
171
|
+
.get(id);
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
// One clock: last use is the deadline, so there is no second column that
|
|
174
|
+
// can disagree with it about when this session ends. An expired row is
|
|
175
|
+
// deleted here rather than left for the next login to sweep — a session
|
|
176
|
+
// nobody may use must stop being a device Pier notifies at the same moment.
|
|
177
|
+
if (!row)
|
|
178
|
+
return undefined;
|
|
179
|
+
if (now - row.seenAt >= TTL_MS) {
|
|
180
|
+
this.revoke(id);
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
if (!sameSecret(digest(token), row.tokenHash))
|
|
184
|
+
return undefined;
|
|
185
|
+
if (now - row.seenAt < TOUCH_MS)
|
|
186
|
+
return { id, renewed: false };
|
|
187
|
+
this.#db.prepare("UPDATE web_sessions SET seen_at = ? WHERE id = ?").run(now, id);
|
|
188
|
+
return { id, renewed: true };
|
|
189
|
+
}
|
|
190
|
+
/** Sign out one browser, or every one of them (`ALL`). Listeners hear the
|
|
191
|
+
* same id: a revoked cookie must also close what it opened. */
|
|
192
|
+
revoke(id) {
|
|
193
|
+
if (id === ALL)
|
|
194
|
+
this.#dropSessions();
|
|
195
|
+
else
|
|
196
|
+
this.#db.prepare("DELETE FROM web_sessions WHERE id = ?").run(id);
|
|
197
|
+
this.#revoked(id);
|
|
198
|
+
}
|
|
199
|
+
/** Signed-in browsers, most recently seen first. Never the token — the list
|
|
200
|
+
* is shown to whoever is signed in, and it is not a set of credentials. */
|
|
201
|
+
list() {
|
|
202
|
+
return this.#db
|
|
203
|
+
.prepare("SELECT id, created_at AS createdAt, seen_at AS seenAt, ip, agent" +
|
|
204
|
+
" FROM web_sessions WHERE seen_at > ? ORDER BY seen_at DESC")
|
|
205
|
+
.all(Date.now() - TTL_MS);
|
|
206
|
+
}
|
|
207
|
+
/** A long-lived authenticated surface closes itself when a cookie is
|
|
107
208
|
* revoked. The store and listeners share the process lifetime. */
|
|
108
|
-
|
|
109
|
-
this.#
|
|
209
|
+
onRevoke(listener) {
|
|
210
|
+
this.#revokeListeners.add(listener);
|
|
110
211
|
}
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
212
|
+
/** One listener throwing must not cost the next one its notification: the
|
|
213
|
+
* row is already gone, so a surface that never hears about it stays open on
|
|
214
|
+
* a session that no longer exists. */
|
|
215
|
+
#revoked(id) {
|
|
216
|
+
for (const listener of this.#revokeListeners) {
|
|
217
|
+
try {
|
|
218
|
+
listener(id);
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
log.error(`a revocation listener failed for session ${id}`, err);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
114
224
|
}
|
|
115
225
|
}
|
|
116
226
|
const hash = (password, salt) => scryptSync(password, salt, KEY_BYTES).toString("hex");
|
|
227
|
+
/** What the database keeps instead of the cookie's token. */
|
|
228
|
+
const digest = (token) => createHash("sha256").update(token).digest("hex");
|
|
117
229
|
/**
|
|
118
230
|
* What a logged-out visitor must still reach: the login form, published
|
|
119
231
|
* boards, and the stylesheet those boards link — a published board rendering
|
|
@@ -130,18 +242,10 @@ function isPublic(method, path) {
|
|
|
130
242
|
return false;
|
|
131
243
|
return path.startsWith("/p/");
|
|
132
244
|
}
|
|
133
|
-
const sign = (secret, expiresAt) => createHmac("sha256", secret).update(String(expiresAt)).digest("base64url");
|
|
134
245
|
/** Constant-time equality that also hides length: both sides are digested. */
|
|
135
246
|
function sameSecret(a, b) {
|
|
136
|
-
const
|
|
137
|
-
return timingSafeEqual(
|
|
138
|
-
}
|
|
139
|
-
function valid(secret, cookie) {
|
|
140
|
-
const [exp, sig] = (cookie ?? "").split(".");
|
|
141
|
-
const expiresAt = Number(exp);
|
|
142
|
-
if (!sig || !Number.isSafeInteger(expiresAt) || expiresAt <= Date.now())
|
|
143
|
-
return false;
|
|
144
|
-
return sameSecret(sig, sign(secret, expiresAt));
|
|
247
|
+
const bytes = (s) => createHash("sha256").update(s).digest();
|
|
248
|
+
return timingSafeEqual(bytes(a), bytes(b));
|
|
145
249
|
}
|
|
146
250
|
/**
|
|
147
251
|
* Only a same-origin path may be returned to after login. `//evil.example` is a
|
|
@@ -213,22 +317,30 @@ function sameOrigin(c) {
|
|
|
213
317
|
: undefined;
|
|
214
318
|
return originMatches(c.req.header("origin"), forwarded || c.req.header("host") || new URL(c.req.url).host);
|
|
215
319
|
}
|
|
320
|
+
/** Which row this request's cookie names. The boundary already verified the
|
|
321
|
+
* token; this reads the id back off the value it accepted. Exported so a
|
|
322
|
+
* surface that belongs to one browser (its push subscription) names it the
|
|
323
|
+
* same way, rather than parsing the cookie a second way. */
|
|
324
|
+
export const sessionIdOf = (c) => (getCookie(c, COOKIE) ?? "").split(".")[0] ?? "";
|
|
216
325
|
/** The same cookie + Origin boundary for a WebSocket upgrade, where no Hono
|
|
217
|
-
* context exists before the handshake completes.
|
|
326
|
+
* context exists before the handshake completes. The session id comes back
|
|
327
|
+
* with the verdict: a socket outlives the request that opened it, so it has
|
|
328
|
+
* to know which row signing out would close it. */
|
|
218
329
|
export function upgradeAuthorized(store, req) {
|
|
219
330
|
const raw = req.headers.cookie
|
|
220
331
|
?.split(";")
|
|
221
332
|
.map((part) => part.trim())
|
|
222
333
|
.find((part) => part.startsWith(`${COOKIE}=`))
|
|
223
334
|
?.slice(COOKIE.length + 1);
|
|
224
|
-
|
|
225
|
-
|
|
335
|
+
const session = store.check(raw);
|
|
336
|
+
if (!session)
|
|
337
|
+
return undefined;
|
|
226
338
|
const remote = req.socket.remoteAddress ?? "";
|
|
227
339
|
const forwardedHeader = req.headers["x-forwarded-host"];
|
|
228
340
|
const forwarded = loopback(remote) && typeof forwardedHeader === "string"
|
|
229
341
|
? forwardedHeader.split(",").at(-1)?.trim()
|
|
230
342
|
: undefined;
|
|
231
|
-
return originMatches(req.headers.origin, forwarded || req.headers.host);
|
|
343
|
+
return originMatches(req.headers.origin, forwarded || req.headers.host) ? session.id : undefined;
|
|
232
344
|
}
|
|
233
345
|
/** Every route, in one place — no per-route opt-in to forget on the next one. */
|
|
234
346
|
export function requireAuth(store) {
|
|
@@ -238,13 +350,18 @@ export function requireAuth(store) {
|
|
|
238
350
|
c.header("x-frame-options", "DENY");
|
|
239
351
|
if (isPublic(c.req.method, c.req.path))
|
|
240
352
|
return next();
|
|
241
|
-
const
|
|
353
|
+
const cookie = getCookie(c, COOKIE);
|
|
354
|
+
const session = store.check(cookie);
|
|
242
355
|
const unsafe = c.req.method !== "GET" && c.req.method !== "HEAD";
|
|
243
|
-
if (
|
|
356
|
+
if (session && unsafe && !sameOrigin(c)) {
|
|
244
357
|
log.warn(`blocked ${c.req.method} ${c.req.path} from origin ${c.req.header("origin")}`);
|
|
245
358
|
return c.json({ error: "forbidden origin" }, 403);
|
|
246
359
|
}
|
|
247
|
-
if (
|
|
360
|
+
if (session) {
|
|
361
|
+
// The database just moved the deadline; the browser is told the same, or
|
|
362
|
+
// it would drop a cookie that is still good.
|
|
363
|
+
if (session.renewed && cookie)
|
|
364
|
+
setSessionCookie(c, cookie);
|
|
248
365
|
await next();
|
|
249
366
|
// Cookie-authenticated content must not become public in a shared proxy.
|
|
250
367
|
if (!c.res.headers.has("cache-control")) {
|
|
@@ -279,7 +396,15 @@ export function registerAuthRoutes(app, store) {
|
|
|
279
396
|
}
|
|
280
397
|
failures.delete(client);
|
|
281
398
|
log.info(`login from ${client}`);
|
|
282
|
-
|
|
399
|
+
// Signing in again replaces this browser's session rather than adding one:
|
|
400
|
+
// the cookie it is about to drop would otherwise stay valid for a week, as
|
|
401
|
+
// a row nobody can recognize in the device list. Verified first — the id in
|
|
402
|
+
// an unverified cookie is a string the caller chose, and `ALL` is one of
|
|
403
|
+
// the strings they could choose.
|
|
404
|
+
const previous = store.check(getCookie(c, COOKIE));
|
|
405
|
+
if (previous)
|
|
406
|
+
store.revoke(previous.id);
|
|
407
|
+
setSessionCookie(c, store.open(client, c.req.header("user-agent") ?? ""));
|
|
283
408
|
return c.redirect(next);
|
|
284
409
|
});
|
|
285
410
|
// Re-authenticate before rotating the credential. The global boundary also
|
|
@@ -300,33 +425,58 @@ export function registerAuthRoutes(app, store) {
|
|
|
300
425
|
}
|
|
301
426
|
failures.delete(client);
|
|
302
427
|
store.setPassword(next);
|
|
303
|
-
// The rotation
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
428
|
+
// The rotation drops every session row, this caller's included — a password
|
|
429
|
+
// is changed because the old one may be known, and "everyone signs in
|
|
430
|
+
// again" is the whole point. Clear the dead cookie; the client sends the
|
|
431
|
+
// person to the login form with the password they just chose.
|
|
307
432
|
deleteCookie(c, COOKIE, { path: "/" });
|
|
308
433
|
return c.json({ ok: true });
|
|
309
434
|
});
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
|
|
435
|
+
// Signed-in browsers, so "sign out that one" is something an operator can
|
|
436
|
+
// see before doing. Not /api/sessions: that is the agent's sessions, and one
|
|
437
|
+
// vocabulary for two unrelated things is how the wrong one gets ended.
|
|
438
|
+
app.get("/api/devices", (c) => {
|
|
439
|
+
const current = sessionIdOf(c);
|
|
440
|
+
return c.json(store.list().map((d) => ({ ...d, current: d.id === current })));
|
|
441
|
+
});
|
|
442
|
+
// Real revocation: the row goes, and the cookie holding its token opens
|
|
443
|
+
// nothing on the next request. Ending this browser's own session is the same
|
|
444
|
+
// call, so the client clears the cookie it is about to stop being able to use.
|
|
445
|
+
app.post("/api/devices/:id/signout", (c) => {
|
|
446
|
+
const id = c.req.param("id");
|
|
447
|
+
// One row per call. Signing everyone out is the password change above,
|
|
448
|
+
// which is the only thing that also invalidates the password they know.
|
|
449
|
+
if (id === ALL)
|
|
450
|
+
return c.json({ error: "not a session id" }, 400);
|
|
451
|
+
store.revoke(id);
|
|
452
|
+
log.info(`signed out session ${id}`);
|
|
453
|
+
if (id === sessionIdOf(c))
|
|
454
|
+
deleteCookie(c, COOKIE, { path: "/" });
|
|
455
|
+
return c.json({ ok: true });
|
|
456
|
+
});
|
|
457
|
+
// Signs out this browser: the row is deleted, not just the cookie cleared,
|
|
458
|
+
// so a copy of that cookie taken beforehand is dead too. Behind the boundary
|
|
459
|
+
// like every write — only a signed-in browser has anything to end.
|
|
314
460
|
app.post("/logout", (c) => {
|
|
461
|
+
store.revoke(sessionIdOf(c));
|
|
315
462
|
deleteCookie(c, COOKIE, { path: "/" });
|
|
316
463
|
return c.json({ ok: true });
|
|
317
464
|
});
|
|
318
465
|
}
|
|
319
|
-
/** The signed-in cookie
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
setCookie(c, COOKIE,
|
|
466
|
+
/** The signed-in cookie — set at login, and again whenever the sliding window
|
|
467
|
+
* moved, which is why it takes the value rather than making one. */
|
|
468
|
+
function setSessionCookie(c, value) {
|
|
469
|
+
setCookie(c, COOKIE, value, {
|
|
323
470
|
path: "/",
|
|
324
471
|
httpOnly: true,
|
|
325
472
|
sameSite: "Lax",
|
|
326
473
|
// Set only over TLS: a Secure cookie on plain http is dropped, which
|
|
327
|
-
// would lock out the loopback and SSH-tunnel setups.
|
|
328
|
-
|
|
329
|
-
|
|
474
|
+
// would lock out the loopback and SSH-tunnel setups. The forwarded scheme
|
|
475
|
+
// counts only from a local proxy — anywhere else it is a header the client
|
|
476
|
+
// wrote, and a stranger must not get to decide this flag.
|
|
477
|
+
secure: new URL(c.req.url).protocol === "https:" ||
|
|
478
|
+
(c.req.header("x-forwarded-proto")?.split(",").at(-1)?.trim() === "https" &&
|
|
479
|
+
loopback(remoteOf(c) ?? "")),
|
|
330
480
|
maxAge: TTL_MS / 1000,
|
|
331
481
|
});
|
|
332
482
|
}
|