@bkazemi/verily 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,224 @@ 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 kept in the store and served, which are the ones whose DNS is checked. */
245
+ stored() {
246
+ return [...this.sites.values()].filter((site) => !this.pinned.has(site.id));
247
+ }
248
+ /**
249
+ * Requests a minute for a site, counted once for its holders and once for its backend's
250
+ * reads. Undefined for a pinned site, which gets the instance's default.
251
+ */
252
+ limit(id) {
253
+ if (this.pinned.has(id))
254
+ return undefined;
255
+ return this.controls.get(id)?.limit ?? limits.registered;
256
+ }
257
+ /**
258
+ * The provider ids a site's holders may use, from those the instance `offers`. A stored
259
+ * site goes without YouTube until the owner approves it. Undefined means every one.
260
+ */
261
+ providers(site, offers) {
262
+ const held = this.withheld(site.id);
263
+ return held.length
264
+ ? (site.providers ?? offers).filter((id) => !held.includes(id))
265
+ : site.providers;
266
+ }
267
+ /**
268
+ * The provider ids the owner has not approved a site for, whichever its operators chose.
269
+ * None for a pinned site.
270
+ */
271
+ withheld(id) {
272
+ return this.pinned.has(id) || this.controls.get(id)?.youtubeApprovedAt ? [] : approvalNeeded;
273
+ }
274
+ /** Changes what the instance owner has set for a stored site. */
275
+ async control(id, change) {
276
+ if (this.pinned.has(id))
277
+ throw new Error(`Site ${id} is set by the host`);
278
+ await this.storage.transaction(async (tx) => {
279
+ if (!(await tx.get(`${registeredPrefix}${id}`)))
280
+ throw new Error(`Site ${id} is not registered`);
281
+ const key = `${controlsPrefix}${id}`;
282
+ await tx.put(key, change((await tx.get(key)) ?? {}));
283
+ });
284
+ await this.refresh();
285
+ }
286
+ /** A site that may start something new: served, and not suspended. */
287
+ active(id) {
288
+ return this.suspended.has(id) ? undefined : this.sites.get(id);
289
+ }
290
+ /**
291
+ * Keeps a new site in the store with its first operator. Refused if the entry is
292
+ * malformed, or its origin belongs to another site, or its id is in use or has ever been.
293
+ */
294
+ async register(site, first) {
295
+ const entry = { ...checked(site), key: site.key };
296
+ if (!validKey(entry.key))
297
+ throw new Error(`Site ${entry.id} needs a base64url key`);
298
+ if (this.pinned.has(entry.id))
299
+ throw new Error(`Site ${entry.id} is set by the host`);
300
+ await this.storage.transaction(async (tx) => {
301
+ if ((await tx.get(`${registeredPrefix}${entry.id}`)) ||
302
+ (await tx.get(`${retiredPrefix}${entry.id}`)))
303
+ throw new Error(`The id ${entry.id} is taken`);
304
+ for (const [, other] of [
305
+ ...this.pinned.entries(),
306
+ ...(await tx.list(registeredPrefix)),
307
+ ])
308
+ if (other.origin === entry.origin)
309
+ throw new Error(`${entry.origin} is already registered`);
310
+ await tx.put(`${registeredPrefix}${entry.id}`, entry);
311
+ await tx.put(`${operatorsPrefix}${entry.id}`, [first]);
312
+ });
313
+ await this.refresh();
314
+ }
315
+ /**
316
+ * Changes a stored site's settings. The rest of the site, its keys above all, is read in
317
+ * the same transaction and kept as it is, so a change made from a page loaded before a
318
+ * key was replaced cannot bring the old key back. Refused if the result is malformed.
319
+ */
320
+ async update(id, settings) {
321
+ if (this.pinned.has(id))
322
+ throw new Error(`Site ${id} is set by the host`);
323
+ await this.storage.transaction(async (tx) => {
324
+ const stored = await tx.get(`${registeredPrefix}${id}`);
325
+ if (!stored)
326
+ throw new Error(`Site ${id} is not registered`);
327
+ const { key, previousKey, previousUntil, providers, visibility, ...rest } = stored;
328
+ const changed = checked({
329
+ ...rest,
330
+ authorizeUrl: settings.authorizeUrl,
331
+ returnUrl: settings.returnUrl,
332
+ ...(settings.providers ? { providers: settings.providers } : {}),
333
+ ...(settings.visibility ? { visibility: settings.visibility } : {}),
334
+ });
335
+ await tx.put(`${registeredPrefix}${id}`, {
336
+ ...changed,
337
+ key,
338
+ ...(previousKey && previousUntil ? { previousKey, previousUntil } : {}),
339
+ });
340
+ });
341
+ await this.refresh();
342
+ }
343
+ /**
344
+ * Gives a stored site a new key and returns it. With `keep`, the old key is still
345
+ * accepted for `rotationMs`, for a routine change. Without it, the old key stops working
346
+ * at once, for a key that may have leaked. Any key before the old one stops either way.
347
+ *
348
+ * With `from`, the hash of the key the caller saw, nothing changes unless that is still
349
+ * the site's key, so a form sent twice replaces the key once. Returns undefined then.
350
+ */
351
+ async rotate(id, keep, from) {
352
+ const key = secret();
353
+ const replaced = await this.storage.transaction(async (tx) => {
354
+ const stored = await tx.get(`${registeredPrefix}${id}`);
355
+ if (!stored)
356
+ throw new Error(`Site ${id} is not registered`);
357
+ if (from !== undefined && hash(stored.key) !== from)
358
+ return false;
359
+ const { previousKey, previousUntil, ...site } = stored;
360
+ await tx.put(`${registeredPrefix}${id}`, {
361
+ ...site,
362
+ key,
363
+ ...(keep ? { previousKey: stored.key, previousUntil: Date.now() + rotationMs } : {}),
364
+ });
365
+ return true;
366
+ });
367
+ await this.refresh();
368
+ return replaced ? key : undefined;
369
+ }
370
+ /** Stops accepting a stored site's previous key, once the new one is deployed. */
371
+ async retirePreviousKey(id) {
372
+ await this.storage.transaction(async (tx) => {
373
+ const stored = await tx.get(`${registeredPrefix}${id}`);
374
+ if (!stored)
375
+ throw new Error(`Site ${id} is not registered`);
376
+ const { previousKey, previousUntil, ...site } = stored;
377
+ await tx.put(`${registeredPrefix}${id}`, site);
378
+ });
379
+ await this.refresh();
380
+ }
381
+ /**
382
+ * Removes a stored site with its operators, DNS state and owner's controls, and keeps its
383
+ * id so no other site is ever given it. Its users' records stay, but their site starts nothing new.
384
+ */
385
+ async retire(id, reason) {
386
+ if (this.pinned.has(id))
387
+ throw new Error(`Site ${id} is set by the host`);
388
+ await this.storage.transaction(async (tx) => {
389
+ if (!(await tx.get(`${registeredPrefix}${id}`)))
390
+ throw new Error(`Site ${id} is not registered`);
391
+ await tx.put(`${retiredPrefix}${id}`, { retiredAt: Date.now(), reason });
392
+ await tx.delete(`${registeredPrefix}${id}`);
393
+ await tx.delete(`${operatorsPrefix}${id}`);
394
+ await tx.delete(`${dnsPrefix}${id}`);
395
+ await tx.delete(`${controlsPrefix}${id}`);
396
+ });
397
+ await this.refresh();
398
+ }
399
+ /** The operators of a stored site. */
400
+ async operators(id) {
401
+ return (await this.storage.get(`${operatorsPrefix}${id}`)) ?? [];
150
402
  }
151
403
  /**
152
404
  * Starts a handoff: a state stored with the site and purpose, set in this browser, and
153
405
  * sent to the site to sign into its token. Returns where to go and the cookie to set.
154
406
  */
155
407
  async begin(site, purpose) {
156
- const registered = this.sites.get(site ?? '');
408
+ const registered = this.active(site ?? '');
157
409
  if (!registered || !['connect', 'manage'].includes(purpose ?? ''))
158
410
  return undefined;
159
411
  const state = secret();
@@ -172,12 +424,12 @@ export class Sites {
172
424
  * check of the subject, which the union of every site's origin would not make alone.
173
425
  */
174
426
  async start(request, token, validate) {
175
- const site = this.sites.get(String(peek(token)?.site ?? ''));
427
+ const site = this.active(String(peek(token)?.site ?? ''));
176
428
  if (!site)
177
429
  return undefined;
178
- const payload = open(site.key, token);
430
+ const { payload, signer } = verified(site, token) ?? {};
179
431
  const now = Date.now();
180
- const held = cookie(request, stateCookie);
432
+ const held = readCookie(request, stateCookie);
181
433
  if (!payload ||
182
434
  payload.site !== site.id ||
183
435
  typeof payload.exp !== 'number' ||
@@ -207,6 +459,7 @@ export class Sites {
207
459
  txn,
208
460
  local,
209
461
  expiresAt: now + sessionMs,
462
+ signer,
210
463
  });
211
464
  return state.purpose;
212
465
  });
@@ -231,10 +484,10 @@ export class Sites {
231
484
  * not admitted is 'limited', and is not used up.
232
485
  */
233
486
  async dialog(token, origin, validate, admit) {
234
- const site = this.sites.get(String(peek(token)?.site ?? ''));
487
+ const site = this.active(String(peek(token)?.site ?? ''));
235
488
  if (!site || origin !== site.origin)
236
489
  return undefined;
237
- const payload = open(site.key, token);
490
+ const { payload, signer } = verified(site, token) ?? {};
238
491
  const now = Date.now();
239
492
  if (!payload ||
240
493
  payload.site !== site.id ||
@@ -264,6 +517,7 @@ export class Sites {
264
517
  local,
265
518
  expiresAt: now + sessionMs,
266
519
  dialog: true,
520
+ signer,
267
521
  });
268
522
  return true;
269
523
  });
@@ -279,7 +533,7 @@ export class Sites {
279
533
  const site = this.sites.get(String(peek(token)?.site ?? ''));
280
534
  if (!site)
281
535
  return undefined;
282
- const payload = open(site.key, token);
536
+ const payload = verified(site, token)?.payload;
283
537
  const now = Date.now();
284
538
  if (!payload ||
285
539
  payload.site !== site.id ||
@@ -296,12 +550,13 @@ export class Sites {
296
550
  }
297
551
  /** This browser's site session, whether or not it has returned yet. */
298
552
  async session(request) {
299
- const token = cookie(request, sessionCookie);
553
+ const token = readCookie(request, sessionCookie);
300
554
  if (!token)
301
555
  return undefined;
302
556
  const key = `site/session/${hash(token)}`;
303
557
  const session = await this.storage.get(key);
304
- if (!session || expired(session, Date.now()) || !this.sites.has(session.site))
558
+ const site = session && this.sites.get(session.site);
559
+ if (!session || !site || expired(session, Date.now()) || !keyAccepted(site, session))
305
560
  return undefined;
306
561
  return { key, session };
307
562
  }
@@ -312,7 +567,12 @@ export class Sites {
312
567
  */
313
568
  async open(request) {
314
569
  const found = await this.session(request);
315
- return found && !found.session.closed && !found.session.operation ? found : undefined;
570
+ return found &&
571
+ !found.session.closed &&
572
+ !found.session.operation &&
573
+ !this.suspended.has(found.session.site)
574
+ ? found
575
+ : undefined;
316
576
  }
317
577
  /**
318
578
  * Where a flow that ended goes, as a function of the stored result alone: the same
@@ -326,9 +586,14 @@ export class Sites {
326
586
  return undefined;
327
587
  if (!['connect', 'renew', 'visibility'].includes(result.kind))
328
588
  return undefined;
589
+ const session = context.session?.startsWith('site/session/')
590
+ ? await this.storage.get(context.session)
591
+ : undefined;
329
592
  // 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)
593
+ if (session?.dialog)
594
+ return undefined;
595
+ // Nothing is signed for a session whose key has since been withdrawn.
596
+ if (session && !keyAccepted(site, session))
332
597
  return undefined;
333
598
  // Another flow's return, or a disconnect, already speaks for this transaction. This
334
599
  // flow ended all the same, however it ended, and its holder sees the result page.
@@ -340,7 +605,7 @@ export class Sites {
340
605
  connection: result.connectionId,
341
606
  visibility: result.visibility,
342
607
  finishedAt: result.finishedAt,
343
- });
608
+ }, session?.signer);
344
609
  }
345
610
  /**
346
611
  * Takes the session's one return for a flow, and closes the session with it. A site
@@ -362,12 +627,16 @@ export class Sites {
362
627
  return true;
363
628
  });
364
629
  }
365
- /** The signed result a holder carries back to their site. */
366
- returnUrl(site, local, txn, result) {
630
+ /**
631
+ * The signed result a holder carries back to their site, under the key that opened its
632
+ * session while that key is still accepted, and under the site's key otherwise.
633
+ */
634
+ returnUrl(site, local, txn, result, signer) {
367
635
  if (!local.id.startsWith(`${site.id}:`))
368
636
  throw new Error('Subject from another site');
369
637
  const complete = result.outcome === 'complete';
370
- const token = sign(site.key, {
638
+ const key = accepted(site).find((candidate) => hash(candidate) === signer) ?? site.key;
639
+ const token = sign(key, {
371
640
  site: site.id,
372
641
  id: local.id.slice(site.id.length + 1),
373
642
  operation: result.operation,