@bkazemi/verily 0.8.1 → 0.9.1

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.
@@ -1,5 +1,7 @@
1
1
  import { createHmac, timingSafeEqual } from 'node:crypto';
2
2
  import { expired, } from '../core/index.js';
3
+ import { limits } from './limits.js';
4
+ import { readCookie, setCookie } from './page.js';
3
5
  import { hash, secret } from '../server/service.js';
4
6
  /** How long a handoff may take, from `/handoff/request` to `/handoff/accept`, and how far ahead a token may run. */
5
7
  export const handoffMs = 5 * 60000;
@@ -18,6 +20,56 @@ function secureOrigin(value) {
18
20
  (url.protocol === 'https:' ||
19
21
  (url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname))));
20
22
  }
23
+ /** Where a site registered on the instance itself is kept, under its id. */
24
+ const registeredPrefix = 'site/registered/';
25
+ /** Where a site's operators are kept, under its id. */
26
+ const operatorsPrefix = 'site/operators/';
27
+ /** Where an id that has been used is kept once its site is gone, so it is never reused. */
28
+ const retiredPrefix = 'site/retired/';
29
+ /** How long a stored site stays suspended before it is deleted. */
30
+ export const lapseMs = 30 * 24 * 3600000;
31
+ /** Where the instance owner's controls over a stored site are kept, under its id. */
32
+ const controlsPrefix = 'site/controls/';
33
+ /** Providers a stored site's holders get only once the owner approves the site. */
34
+ const approvalNeeded = ['youtube'];
35
+ /** Where the last DNS check of a stored site is kept, under its id. */
36
+ export const dnsPrefix = 'site/dns/';
37
+ /** A site's id: what prefixes its subjects and names it in every token. */
38
+ export const validId = (id) => typeof id === 'string' && /^[a-z0-9-]{1,32}$/.test(id);
39
+ /** A key: base64url, of at least 32 bytes. */
40
+ export const validKey = (key) => typeof key === 'string' && /^[A-Za-z0-9_-]{43,}$/.test(key);
41
+ /** A site entry checked field by field, without its key. Throws on the first thing wrong. */
42
+ export function checked(entry) {
43
+ const { id, name, origin, authorizeUrl, returnUrl, providers, visibility } = entry ?? {};
44
+ if (!validId(id))
45
+ throw new Error('Each site needs an id of lowercase letters, digits and hyphens');
46
+ if (typeof name !== 'string' || !name || name.length > 100)
47
+ throw new Error(`Site ${id} needs a name`);
48
+ if (typeof origin !== 'string' || !secureOrigin(origin))
49
+ throw new Error(`Site ${id} needs an HTTPS origin without a trailing slash`);
50
+ for (const url of [authorizeUrl, returnUrl])
51
+ if (typeof url !== 'string' || new URL(url).origin !== origin)
52
+ throw new Error(`Site ${id} must authorize and return on its own origin`);
53
+ if (providers !== undefined &&
54
+ (!Array.isArray(providers) ||
55
+ !providers.length ||
56
+ providers.some((p) => typeof p !== 'string' || !p)))
57
+ throw new Error(`Site ${id} must list at least one provider id, or none at all`);
58
+ if (visibility !== undefined &&
59
+ (!Array.isArray(visibility) ||
60
+ !visibility.length ||
61
+ visibility.some((v) => v !== 'public' && v !== 'unlisted')))
62
+ throw new Error(`Site ${id} must list "public", "unlisted" or both, or no visibility at all`);
63
+ return {
64
+ id,
65
+ name,
66
+ origin,
67
+ authorizeUrl: authorizeUrl,
68
+ returnUrl: returnUrl,
69
+ ...(providers ? { providers: providers } : {}),
70
+ ...(visibility ? { visibility: visibility } : {}),
71
+ };
72
+ }
21
73
  /**
22
74
  * The registered sites, from the `SITES` variable, each with its key from the secret
23
75
  * `SITE_<ID>_KEY`. Anything wrong in the list refuses to start rather than serving a site
@@ -29,43 +81,17 @@ export function registry(config, secrets) {
29
81
  throw new Error('SITES must be a list');
30
82
  const sites = new Map();
31
83
  for (const entry of list) {
32
- const { id, name, origin, authorizeUrl, returnUrl, providers, visibility } = entry ?? {};
33
- if (typeof id !== 'string' || !/^[a-z0-9-]{1,32}$/.test(id) || sites.has(id))
34
- throw new Error('Each site needs a unique id of lowercase letters, digits and hyphens');
35
- const key = secrets[`SITE_${id.toUpperCase().replace(/-/g, '_')}_KEY`];
36
- if (typeof name !== 'string' || !name || name.length > 100)
37
- throw new Error(`Site ${id} needs a name`);
38
- if (typeof origin !== 'string' || !secureOrigin(origin))
39
- throw new Error(`Site ${id} needs an HTTPS origin without a trailing slash`);
40
- for (const url of [authorizeUrl, returnUrl])
41
- if (typeof url !== 'string' || new URL(url).origin !== origin)
42
- throw new Error(`Site ${id} must authorize and return on its own origin`);
43
- if (providers !== undefined &&
44
- (!Array.isArray(providers) ||
45
- !providers.length ||
46
- providers.some((p) => typeof p !== 'string' || !p)))
47
- throw new Error(`Site ${id} must list at least one provider id, or none at all`);
48
- if (visibility !== undefined &&
49
- (!Array.isArray(visibility) ||
50
- !visibility.length ||
51
- visibility.some((v) => v !== 'public' && v !== 'unlisted')))
52
- throw new Error(`Site ${id} must list "public", "unlisted" or both, or no visibility at all`);
84
+ const site = checked(entry);
85
+ if (sites.has(site.id))
86
+ throw new Error(`Site ${site.id} is listed twice`);
87
+ const key = secrets[`SITE_${site.id.toUpperCase().replace(/-/g, '_')}_KEY`];
53
88
  // A secret is set apart from a deployment and cannot be checked before one. A wrong
54
89
  // one leaves its own site unserved, and never takes the rest of the instance with it.
55
- if (typeof key !== 'string' || !/^[A-Za-z0-9_-]{43,}$/.test(key)) {
56
- console.error(`Site ${id} is not served: it needs a base64url key of at least 32 bytes`);
90
+ if (!validKey(key)) {
91
+ console.error(`Site ${site.id} is not served: it needs a base64url key of at least 32 bytes`);
57
92
  continue;
58
93
  }
59
- sites.set(id, {
60
- id,
61
- name,
62
- origin,
63
- authorizeUrl: authorizeUrl,
64
- returnUrl: returnUrl,
65
- key,
66
- ...(providers ? { providers: providers } : {}),
67
- ...(visibility ? { visibility: visibility } : {}),
68
- });
94
+ sites.set(site.id, { ...site, key });
69
95
  }
70
96
  return sites;
71
97
  }
@@ -99,13 +125,32 @@ export function open(key, token) {
99
125
  return undefined;
100
126
  return peek(token);
101
127
  }
102
- const cookie = (request, name) => request.headers
103
- .get('cookie')
104
- ?.split(';')
105
- .map((part) => part.trim())
106
- .find((part) => part.startsWith(`${name}=`))
107
- ?.slice(name.length + 1);
108
- const setCookie = (name, value, ms) => `${name}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${ms / 1000}`;
128
+ /** How long a replaced site key is still accepted, so the site has time to deploy the new one. */
129
+ export const rotationMs = 24 * 3600000;
130
+ /** The site's keys that are accepted now: its key, and its previous key while in date. */
131
+ const accepted = (site) => [
132
+ site.key,
133
+ ...(site.previousKey && (site.previousUntil ?? 0) > Date.now() ? [site.previousKey] : []),
134
+ ];
135
+ /**
136
+ * Whether the key a session was opened with is still accepted. A key replaced at once, or
137
+ * whose 24 hours are over or were ended early, takes its sessions with it, so a leaked key
138
+ * is no use to whoever already opened a session with it. A session from before sessions
139
+ * named their key has none, and lasts its hour.
140
+ */
141
+ const keyAccepted = (site, session) => session.signer === undefined || accepted(site).some((key) => hash(key) === session.signer);
142
+ /**
143
+ * The payload of a token signed by one of the site's accepted keys, and the hash of the
144
+ * key that signed it.
145
+ */
146
+ function verified(site, token) {
147
+ for (const key of accepted(site)) {
148
+ const payload = open(key, token);
149
+ if (payload)
150
+ return { payload, signer: hash(key) };
151
+ }
152
+ return undefined;
153
+ }
109
154
  const string = (value, max = 500) => typeof value === 'string' && value.length > 0 && value.length <= max;
110
155
  /**
111
156
  * The subject a handoff vouches for, as the library will hold it, or undefined if the
@@ -143,17 +188,228 @@ function subject(site, payload, validate) {
143
188
  */
144
189
  export class Sites {
145
190
  storage;
191
+ pinned;
192
+ /**
193
+ * Every site served: the pinned ones, then the ones kept in the store, as `refresh()`
194
+ * last read them.
195
+ */
146
196
  sites;
147
- constructor(storage, sites) {
197
+ /**
198
+ * Stored sites whose DNS no longer names this instance, or that the owner suspended. They
199
+ * start nothing new, but their backends can still read their connections, so they can
200
+ * move elsewhere.
201
+ */
202
+ suspended = new Set();
203
+ /** What the instance owner has set for each stored site, as `refresh()` last read it. */
204
+ controls = new Map();
205
+ /**
206
+ * `pinned` are the sites the host configures, from `SITES`. They cannot be changed here,
207
+ * and a stored site with the same id or origin is not served.
208
+ */
209
+ constructor(storage, pinned) {
148
210
  this.storage = storage;
211
+ this.pinned = pinned;
212
+ this.sites = new Map(pinned);
213
+ }
214
+ /**
215
+ * Reads the stored sites again. The instance does this on every request, so a site
216
+ * registered, changed or removed by another process is served as it now is.
217
+ */
218
+ async refresh() {
219
+ const sites = new Map(this.pinned);
220
+ const origins = new Set([...this.pinned.values()].map((site) => site.origin));
221
+ for (const [, site] of await this.storage.list(registeredPrefix)) {
222
+ if (sites.has(site.id) || origins.has(site.origin))
223
+ continue;
224
+ sites.set(site.id, site);
225
+ origins.add(site.origin);
226
+ }
227
+ const suspended = new Set();
228
+ const controls = new Map();
229
+ for (const [key, set] of await this.storage.list(controlsPrefix)) {
230
+ const id = key.slice(controlsPrefix.length);
231
+ controls.set(id, set);
232
+ if (set.suspendedAt !== undefined && !this.pinned.has(id))
233
+ suspended.add(id);
234
+ }
235
+ for (const [key, check] of await this.storage.list(dnsPrefix)) {
236
+ const id = key.slice(dnsPrefix.length);
237
+ if (check.absentSince !== undefined && !this.pinned.has(id))
238
+ suspended.add(id);
239
+ }
149
240
  this.sites = sites;
241
+ this.suspended = suspended;
242
+ this.controls = controls;
243
+ }
244
+ /** The sites the host configures in `SITES`. */
245
+ configured() {
246
+ return [...this.pinned.values()];
247
+ }
248
+ /** The sites kept in the store and served, which are the ones whose DNS is checked. */
249
+ stored() {
250
+ return [...this.sites.values()].filter((site) => !this.pinned.has(site.id));
251
+ }
252
+ /**
253
+ * Requests a minute for a site, counted once for its holders and once for its backend's
254
+ * reads. Undefined for a pinned site, which gets the instance's default.
255
+ */
256
+ limit(id) {
257
+ if (this.pinned.has(id))
258
+ return undefined;
259
+ return this.controls.get(id)?.limit ?? limits.registered;
260
+ }
261
+ /**
262
+ * The provider ids a site's holders may use, from those the instance `offers`. A stored
263
+ * site goes without YouTube until the owner approves it. Undefined means every one.
264
+ */
265
+ providers(site, offers) {
266
+ const held = this.withheld(site.id);
267
+ return held.length
268
+ ? (site.providers ?? offers).filter((id) => !held.includes(id))
269
+ : site.providers;
270
+ }
271
+ /**
272
+ * The provider ids the owner has not approved a site for, whichever its operators chose.
273
+ * None for a pinned site.
274
+ */
275
+ withheld(id) {
276
+ return this.pinned.has(id) || this.controls.get(id)?.youtubeApprovedAt ? [] : approvalNeeded;
277
+ }
278
+ /** Changes what the instance owner has set for a stored site. */
279
+ async control(id, change) {
280
+ if (this.pinned.has(id))
281
+ throw new Error(`Site ${id} is set by the host`);
282
+ await this.storage.transaction(async (tx) => {
283
+ if (!(await tx.get(`${registeredPrefix}${id}`)))
284
+ throw new Error(`Site ${id} is not registered`);
285
+ const key = `${controlsPrefix}${id}`;
286
+ await tx.put(key, change((await tx.get(key)) ?? {}));
287
+ });
288
+ await this.refresh();
289
+ }
290
+ /** A site that may start something new: served, and not suspended. */
291
+ active(id) {
292
+ return this.suspended.has(id) ? undefined : this.sites.get(id);
293
+ }
294
+ /**
295
+ * Keeps a new site in the store with its first operator. Refused if the entry is
296
+ * malformed, or its origin belongs to another site, or its id is in use or has ever been.
297
+ */
298
+ async register(site, first) {
299
+ const entry = { ...checked(site), key: site.key };
300
+ if (!validKey(entry.key))
301
+ throw new Error(`Site ${entry.id} needs a base64url key`);
302
+ if (this.pinned.has(entry.id))
303
+ throw new Error(`Site ${entry.id} is set by the host`);
304
+ await this.storage.transaction(async (tx) => {
305
+ if ((await tx.get(`${registeredPrefix}${entry.id}`)) ||
306
+ (await tx.get(`${retiredPrefix}${entry.id}`)))
307
+ throw new Error(`The id ${entry.id} is taken`);
308
+ for (const [, other] of [
309
+ ...this.pinned.entries(),
310
+ ...(await tx.list(registeredPrefix)),
311
+ ])
312
+ if (other.origin === entry.origin)
313
+ throw new Error(`${entry.origin} is already registered`);
314
+ await tx.put(`${registeredPrefix}${entry.id}`, entry);
315
+ await tx.put(`${operatorsPrefix}${entry.id}`, [first]);
316
+ });
317
+ await this.refresh();
318
+ }
319
+ /**
320
+ * Changes a stored site's settings. The rest of the site, its keys above all, is read in
321
+ * the same transaction and kept as it is, so a change made from a page loaded before a
322
+ * key was replaced cannot bring the old key back. Refused if the result is malformed.
323
+ */
324
+ async update(id, settings) {
325
+ if (this.pinned.has(id))
326
+ throw new Error(`Site ${id} is set by the host`);
327
+ await this.storage.transaction(async (tx) => {
328
+ const stored = await tx.get(`${registeredPrefix}${id}`);
329
+ if (!stored)
330
+ throw new Error(`Site ${id} is not registered`);
331
+ const { key, previousKey, previousUntil, providers, visibility, ...rest } = stored;
332
+ const changed = checked({
333
+ ...rest,
334
+ authorizeUrl: settings.authorizeUrl,
335
+ returnUrl: settings.returnUrl,
336
+ ...(settings.providers ? { providers: settings.providers } : {}),
337
+ ...(settings.visibility ? { visibility: settings.visibility } : {}),
338
+ });
339
+ await tx.put(`${registeredPrefix}${id}`, {
340
+ ...changed,
341
+ key,
342
+ ...(previousKey && previousUntil ? { previousKey, previousUntil } : {}),
343
+ });
344
+ });
345
+ await this.refresh();
346
+ }
347
+ /**
348
+ * Gives a stored site a new key and returns it. With `keep`, the old key is still
349
+ * accepted for `rotationMs`, for a routine change. Without it, the old key stops working
350
+ * at once, for a key that may have leaked. Any key before the old one stops either way.
351
+ *
352
+ * With `from`, the hash of the key the caller saw, nothing changes unless that is still
353
+ * the site's key, so a form sent twice replaces the key once. Returns undefined then.
354
+ */
355
+ async rotate(id, keep, from) {
356
+ const key = secret();
357
+ const replaced = await this.storage.transaction(async (tx) => {
358
+ const stored = await tx.get(`${registeredPrefix}${id}`);
359
+ if (!stored)
360
+ throw new Error(`Site ${id} is not registered`);
361
+ if (from !== undefined && hash(stored.key) !== from)
362
+ return false;
363
+ const { previousKey, previousUntil, ...site } = stored;
364
+ await tx.put(`${registeredPrefix}${id}`, {
365
+ ...site,
366
+ key,
367
+ ...(keep ? { previousKey: stored.key, previousUntil: Date.now() + rotationMs } : {}),
368
+ });
369
+ return true;
370
+ });
371
+ await this.refresh();
372
+ return replaced ? key : undefined;
373
+ }
374
+ /** Stops accepting a stored site's previous key, once the new one is deployed. */
375
+ async retirePreviousKey(id) {
376
+ await this.storage.transaction(async (tx) => {
377
+ const stored = await tx.get(`${registeredPrefix}${id}`);
378
+ if (!stored)
379
+ throw new Error(`Site ${id} is not registered`);
380
+ const { previousKey, previousUntil, ...site } = stored;
381
+ await tx.put(`${registeredPrefix}${id}`, site);
382
+ });
383
+ await this.refresh();
384
+ }
385
+ /**
386
+ * Removes a stored site with its operators, DNS state and owner's controls, and keeps its
387
+ * id so no other site is ever given it. Its users' records stay, but their site starts nothing new.
388
+ */
389
+ async retire(id, reason) {
390
+ if (this.pinned.has(id))
391
+ throw new Error(`Site ${id} is set by the host`);
392
+ await this.storage.transaction(async (tx) => {
393
+ if (!(await tx.get(`${registeredPrefix}${id}`)))
394
+ throw new Error(`Site ${id} is not registered`);
395
+ await tx.put(`${retiredPrefix}${id}`, { retiredAt: Date.now(), reason });
396
+ await tx.delete(`${registeredPrefix}${id}`);
397
+ await tx.delete(`${operatorsPrefix}${id}`);
398
+ await tx.delete(`${dnsPrefix}${id}`);
399
+ await tx.delete(`${controlsPrefix}${id}`);
400
+ });
401
+ await this.refresh();
402
+ }
403
+ /** The operators of a stored site. */
404
+ async operators(id) {
405
+ return (await this.storage.get(`${operatorsPrefix}${id}`)) ?? [];
150
406
  }
151
407
  /**
152
408
  * Starts a handoff: a state stored with the site and purpose, set in this browser, and
153
409
  * sent to the site to sign into its token. Returns where to go and the cookie to set.
154
410
  */
155
411
  async begin(site, purpose) {
156
- const registered = this.sites.get(site ?? '');
412
+ const registered = this.active(site ?? '');
157
413
  if (!registered || !['connect', 'manage'].includes(purpose ?? ''))
158
414
  return undefined;
159
415
  const state = secret();
@@ -172,12 +428,12 @@ export class Sites {
172
428
  * check of the subject, which the union of every site's origin would not make alone.
173
429
  */
174
430
  async start(request, token, validate) {
175
- const site = this.sites.get(String(peek(token)?.site ?? ''));
431
+ const site = this.active(String(peek(token)?.site ?? ''));
176
432
  if (!site)
177
433
  return undefined;
178
- const payload = open(site.key, token);
434
+ const { payload, signer } = verified(site, token) ?? {};
179
435
  const now = Date.now();
180
- const held = cookie(request, stateCookie);
436
+ const held = readCookie(request, stateCookie);
181
437
  if (!payload ||
182
438
  payload.site !== site.id ||
183
439
  typeof payload.exp !== 'number' ||
@@ -207,6 +463,7 @@ export class Sites {
207
463
  txn,
208
464
  local,
209
465
  expiresAt: now + sessionMs,
466
+ signer,
210
467
  });
211
468
  return state.purpose;
212
469
  });
@@ -231,10 +488,10 @@ export class Sites {
231
488
  * not admitted is 'limited', and is not used up.
232
489
  */
233
490
  async dialog(token, origin, validate, admit) {
234
- const site = this.sites.get(String(peek(token)?.site ?? ''));
491
+ const site = this.active(String(peek(token)?.site ?? ''));
235
492
  if (!site || origin !== site.origin)
236
493
  return undefined;
237
- const payload = open(site.key, token);
494
+ const { payload, signer } = verified(site, token) ?? {};
238
495
  const now = Date.now();
239
496
  if (!payload ||
240
497
  payload.site !== site.id ||
@@ -264,6 +521,7 @@ export class Sites {
264
521
  local,
265
522
  expiresAt: now + sessionMs,
266
523
  dialog: true,
524
+ signer,
267
525
  });
268
526
  return true;
269
527
  });
@@ -279,7 +537,7 @@ export class Sites {
279
537
  const site = this.sites.get(String(peek(token)?.site ?? ''));
280
538
  if (!site)
281
539
  return undefined;
282
- const payload = open(site.key, token);
540
+ const payload = verified(site, token)?.payload;
283
541
  const now = Date.now();
284
542
  if (!payload ||
285
543
  payload.site !== site.id ||
@@ -296,12 +554,13 @@ export class Sites {
296
554
  }
297
555
  /** This browser's site session, whether or not it has returned yet. */
298
556
  async session(request) {
299
- const token = cookie(request, sessionCookie);
557
+ const token = readCookie(request, sessionCookie);
300
558
  if (!token)
301
559
  return undefined;
302
560
  const key = `site/session/${hash(token)}`;
303
561
  const session = await this.storage.get(key);
304
- if (!session || expired(session, Date.now()) || !this.sites.has(session.site))
562
+ const site = session && this.sites.get(session.site);
563
+ if (!session || !site || expired(session, Date.now()) || !keyAccepted(site, session))
305
564
  return undefined;
306
565
  return { key, session };
307
566
  }
@@ -312,7 +571,12 @@ export class Sites {
312
571
  */
313
572
  async open(request) {
314
573
  const found = await this.session(request);
315
- return found && !found.session.closed && !found.session.operation ? found : undefined;
574
+ return found &&
575
+ !found.session.closed &&
576
+ !found.session.operation &&
577
+ !this.suspended.has(found.session.site)
578
+ ? found
579
+ : undefined;
316
580
  }
317
581
  /**
318
582
  * Where a flow that ended goes, as a function of the stored result alone: the same
@@ -326,9 +590,14 @@ export class Sites {
326
590
  return undefined;
327
591
  if (!['connect', 'renew', 'visibility'].includes(result.kind))
328
592
  return undefined;
593
+ const session = context.session?.startsWith('site/session/')
594
+ ? await this.storage.get(context.session)
595
+ : undefined;
329
596
  // The sign-in window of a dialog shows the flow's own result page and goes nowhere.
330
- if (context.session?.startsWith('site/session/') &&
331
- (await this.storage.get(context.session))?.dialog)
597
+ if (session?.dialog)
598
+ return undefined;
599
+ // Nothing is signed for a session whose key has since been withdrawn.
600
+ if (session && !keyAccepted(site, session))
332
601
  return undefined;
333
602
  // Another flow's return, or a disconnect, already speaks for this transaction. This
334
603
  // flow ended all the same, however it ended, and its holder sees the result page.
@@ -340,7 +609,7 @@ export class Sites {
340
609
  connection: result.connectionId,
341
610
  visibility: result.visibility,
342
611
  finishedAt: result.finishedAt,
343
- });
612
+ }, session?.signer);
344
613
  }
345
614
  /**
346
615
  * Takes the session's one return for a flow, and closes the session with it. A site
@@ -362,12 +631,16 @@ export class Sites {
362
631
  return true;
363
632
  });
364
633
  }
365
- /** The signed result a holder carries back to their site. */
366
- returnUrl(site, local, txn, result) {
634
+ /**
635
+ * The signed result a holder carries back to their site, under the key that opened its
636
+ * session while that key is still accepted, and under the site's key otherwise.
637
+ */
638
+ returnUrl(site, local, txn, result, signer) {
367
639
  if (!local.id.startsWith(`${site.id}:`))
368
640
  throw new Error('Subject from another site');
369
641
  const complete = result.outcome === 'complete';
370
- const token = sign(site.key, {
642
+ const key = accepted(site).find((candidate) => hash(candidate) === signer) ?? site.key;
643
+ const token = sign(key, {
371
644
  site: site.id,
372
645
  id: local.id.slice(site.id.length + 1),
373
646
  operation: result.operation,