@bkazemi/verily 0.7.0 → 0.8.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.
Files changed (48) hide show
  1. package/README.md +183 -94
  2. package/dist/browser/mark.d.ts +2 -6
  3. package/dist/browser/mark.js +6 -12
  4. package/dist/browser/provider-mark.d.ts +3 -3
  5. package/dist/browser/provider-mark.js +15 -90
  6. package/dist/instance/auth.d.ts +14 -0
  7. package/dist/instance/auth.js +80 -0
  8. package/dist/instance/bin.d.ts +2 -0
  9. package/dist/instance/bin.js +36 -0
  10. package/dist/instance/cloudflare.d.ts +57 -0
  11. package/dist/instance/cloudflare.js +71 -0
  12. package/dist/instance/env.d.ts +51 -0
  13. package/dist/instance/env.js +84 -0
  14. package/dist/instance/index.d.ts +136 -0
  15. package/dist/instance/index.js +777 -0
  16. package/dist/instance/init.d.ts +6 -0
  17. package/dist/instance/init.js +28 -0
  18. package/dist/instance/limits.d.ts +30 -0
  19. package/dist/instance/limits.js +45 -0
  20. package/dist/instance/node.d.ts +13 -0
  21. package/dist/instance/node.js +43 -0
  22. package/dist/instance/page.d.ts +18 -0
  23. package/dist/instance/page.js +30 -0
  24. package/dist/instance/postgres.d.ts +19 -0
  25. package/dist/instance/postgres.js +83 -0
  26. package/dist/instance/routes.d.ts +7 -0
  27. package/dist/instance/routes.js +22 -0
  28. package/dist/instance/serve.d.ts +31 -0
  29. package/dist/instance/serve.js +49 -0
  30. package/dist/instance/sites.d.ts +169 -0
  31. package/dist/instance/sites.js +413 -0
  32. package/dist/instance/starter.d.ts +6 -0
  33. package/dist/instance/starter.js +99 -0
  34. package/dist/instance/store.d.ts +20 -0
  35. package/dist/instance/store.js +11 -0
  36. package/dist/instance/undici.d.ts +8 -0
  37. package/dist/instance/undici.js +9 -0
  38. package/dist/marks.d.ts +44 -0
  39. package/dist/marks.js +98 -0
  40. package/dist/server/index.js +46 -67
  41. package/dist/server/render.d.ts +49 -0
  42. package/dist/server/render.js +104 -0
  43. package/dist/server/style.d.ts +1 -1
  44. package/dist/server/style.js +145 -1
  45. package/dist/verily.js +96 -104
  46. package/dist/version.d.ts +1 -1
  47. package/dist/version.js +1 -1
  48. package/package.json +30 -2
@@ -0,0 +1,777 @@
1
+ import { dialogPath, dialogRoute, lastProved, localSide, routeOf, routes, statusLabel, } from '../core/index.js';
2
+ import { escape } from '../server/escape.js';
3
+ import { card, date, distance, externalCard, marked, page, verdict } from '../server/render.js';
4
+ import { toneOf } from '../marks.js';
5
+ import { version } from '../version.js';
6
+ import { createVerily, generateSigningKey } from '../server/index.js';
7
+ import { recordStorage } from './store.js';
8
+ import { OwnerAuth } from './auth.js';
9
+ import { sessionCookie, Sites, peek } from './sites.js';
10
+ import { allow, client, limits, prune } from './limits.js';
11
+ import { own } from './routes.js';
12
+ import { html, safeHeaders, unavailable } from './page.js';
13
+ export { fromEnv } from './env.js';
14
+ export { registry } from './sites.js';
15
+ export { recordStorage } from './store.js';
16
+ /**
17
+ * The page at the root for anybody not signed in, and the only thing it does. Whoever
18
+ * reaches it either holds the owner key or has no business past it, so it carries one
19
+ * field and no account of what lies behind.
20
+ */
21
+ const signIn = (message = '', status = 200) => html('Sign in', `${message}<form action="/login" method="post"><label>Owner key <input name="key" type="password" autocomplete="current-password" required></label><button>Sign in</button></form>`, status, 'single');
22
+ const redirect = (cookie) => see('/', [cookie]);
23
+ function see(location, cookies = []) {
24
+ const headers = new Headers({ ...safeHeaders, Location: location });
25
+ for (const cookie of cookies)
26
+ headers.append('Set-Cookie', cookie);
27
+ return new Response(null, { status: 303, headers });
28
+ }
29
+ /** Every refused handoff looks the same, so nobody can learn which check it failed. */
30
+ const refused = () => html('Link expired', '<p>This link has expired or was already used. Start again from the site that sent you.</p>', 403, 'single');
31
+ /**
32
+ * Whether a request is for one of the library's own management routes, which a site
33
+ * session does not get. Which routes those are is the library's to say.
34
+ */
35
+ const manages = (method, path) => {
36
+ const name = routeOf(method, path);
37
+ return name !== undefined && 'manages' in routes[name];
38
+ };
39
+ /** GETs that create state, and so are limited like every POST. */
40
+ const limitedGets = ['/handoff/request', '/handoff/accept', '/callback'];
41
+ /** Matches the library's own bound: large enough for a pasted key, small enough to buffer. */
42
+ const maxBodyBytes = 65536;
43
+ /** How a holder may have an account listed, as the library's mark route takes it. */
44
+ const listings = ['preferred', 'current', 'unused', 'retired'];
45
+ /** Said on the page that asks before an account is retired. */
46
+ const retiringNote = 'Retiring keeps this record as history. It will no longer read as verified, and who can read it cannot be changed afterwards.';
47
+ /** A connection's card heading: the provider's mark and name, and anything after the name. */
48
+ const cardHeading = (e, after = '') => marked(`${e.providerName ?? e.provider}${after}`, e.provider, e.attestations.external[0]?.method);
49
+ /** Carries a flow's binding for a page on another origin, where the cookie cannot. */
50
+ const flowHeader = 'X-Verily-Flow';
51
+ /** A session or a binding as this instance mints them, and nothing that could bend a header. */
52
+ const opaque = /^[A-Za-z0-9_-]{16,200}$/;
53
+ /**
54
+ * Lets the page that asked read the answer. These requests carry no cookie and are given
55
+ * none, so naming whichever origin asked gives nothing away, least of all which origins
56
+ * are registered sites: what a caller may do is decided by the token it holds.
57
+ */
58
+ function shared(response, request) {
59
+ const answer = new Response(response.body, response);
60
+ answer.headers.set('Access-Control-Allow-Origin', request.headers.get('origin') ?? '*');
61
+ answer.headers.set('Access-Control-Expose-Headers', flowHeader);
62
+ answer.headers.append('Vary', 'Origin');
63
+ return answer;
64
+ }
65
+ const preflight = (request) => shared(new Response(null, {
66
+ status: 204,
67
+ headers: {
68
+ 'Access-Control-Allow-Methods': 'GET, POST',
69
+ 'Access-Control-Allow-Headers': `Authorization, Content-Type, ${flowHeader}`,
70
+ 'Access-Control-Max-Age': '600',
71
+ },
72
+ }), request);
73
+ const data = (value, headers = {}) => new Response(JSON.stringify(value), {
74
+ headers: { ...safeHeaders, 'Content-Type': 'application/json', ...headers },
75
+ });
76
+ /**
77
+ * A JSON object from a request's body, or an empty one. Only a body sent as JSON is read:
78
+ * a form cannot send that type, so nothing here can be reached by a form on another page.
79
+ */
80
+ async function fields(request) {
81
+ if (!request.headers.get('content-type')?.startsWith('application/json'))
82
+ return {};
83
+ return parsed(await request.text()) ?? {};
84
+ }
85
+ /** The JSON object a text holds, or undefined if it holds anything else. */
86
+ function parsed(text) {
87
+ try {
88
+ const value = JSON.parse(text);
89
+ return value && typeof value === 'object' && !Array.isArray(value)
90
+ ? value
91
+ : undefined;
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ }
97
+ /**
98
+ * Run in the sign-in window a site's dialog opens. The window is on this origin, so the
99
+ * flow's binding can be a cookie here, where the provider's callback will look for it. The
100
+ * page that opened the window sends the binding, and this script says which origin that
101
+ * page is on. The instance takes the binding only if that origin is the site the flow
102
+ * belongs to, so a page elsewhere cannot have someone sign in to a flow it started.
103
+ */
104
+ const enterScript = `const status=document.getElementById('status');const fail=()=>{status.textContent='This window could not start the sign-in. Close it and try again.'};let taken=false;if(!window.opener)fail();else{window.addEventListener('message',async(event)=>{const d=event.data;if(taken||event.source!==window.opener||!d||d.type!=='verily-enter'||typeof d.flow!=='string'||typeof d.binding!=='string'||typeof d.url!=='string')return;taken=true;let target;try{target=new URL(d.url)}catch{return fail()}if(target.protocol!=='https:')return fail();const response=await fetch('/site/enter',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({flow:d.flow,binding:d.binding,origin:event.origin})}).catch(()=>undefined);if(!response||!response.ok)return fail();location.replace(target.href)});window.opener.postMessage({type:'verily-enter'},'*')}`;
105
+ const enterPage = () => new Response(page('', 'Sign in', '<p id="status">Opening the sign-in page.</p>', 'single', '/site/enter.js'), {
106
+ headers: {
107
+ ...safeHeaders,
108
+ 'Content-Type': 'text/html; charset=utf-8',
109
+ 'Content-Security-Policy': "default-src 'none'; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",
110
+ },
111
+ });
112
+ /** Buffer only bounded request bodies before passing them to the library. */
113
+ async function boundedBody(request) {
114
+ if (!request.body)
115
+ return;
116
+ const reader = request.body.getReader();
117
+ const chunks = [];
118
+ let length = 0;
119
+ while (true) {
120
+ const { value, done } = await reader.read();
121
+ if (done)
122
+ break;
123
+ length += value.byteLength;
124
+ if (length > maxBodyBytes) {
125
+ await reader.cancel();
126
+ throw new Error('Body too large');
127
+ }
128
+ chunks.push(value);
129
+ }
130
+ const body = new Uint8Array(length);
131
+ let offset = 0;
132
+ for (const chunk of chunks) {
133
+ body.set(chunk, offset);
134
+ offset += chunk.byteLength;
135
+ }
136
+ return body.buffer;
137
+ }
138
+ /**
139
+ * The checks that need nothing stored: the origin, the method, and a bound on the body.
140
+ * Gives back the response to send, or the request with its body read. `handle` runs this
141
+ * itself. A host that passes requests on to another process can run it first, so that
142
+ * process is not woken for a request this turns away.
143
+ */
144
+ export async function admit(request, origin) {
145
+ const url = new URL(request.url);
146
+ if (url.origin !== origin)
147
+ return unavailable();
148
+ // Asked by a browser before a page on another origin may send the dialog's headers.
149
+ if (request.method === 'OPTIONS' &&
150
+ (dialogPath(url.pathname) || url.pathname === '/site/session'))
151
+ return preflight(request);
152
+ if (!['GET', 'POST'].includes(request.method))
153
+ return unavailable(405);
154
+ let body;
155
+ try {
156
+ body = await boundedBody(request);
157
+ }
158
+ catch {
159
+ return html('Request too large', '', 413, 'single');
160
+ }
161
+ return new Request(request.url, {
162
+ method: request.method,
163
+ headers: request.headers,
164
+ body,
165
+ redirect: 'manual',
166
+ });
167
+ }
168
+ export function createInstance(options) {
169
+ return new Server(options);
170
+ }
171
+ class Server {
172
+ options;
173
+ app;
174
+ store;
175
+ origin;
176
+ auth;
177
+ sites;
178
+ local;
179
+ constructor(options) {
180
+ this.options = options;
181
+ const origin = new URL(options.origin);
182
+ if (origin.protocol !== 'https:' || origin.origin !== options.origin)
183
+ throw new Error('The origin must be an HTTPS origin without a trailing slash');
184
+ this.origin = options.origin;
185
+ this.store = options.store;
186
+ this.auth = new OwnerAuth(this.store, options.ownerKey);
187
+ this.sites = new Sites(this.store, options.sites ?? new Map());
188
+ const siteOrigins = [...this.sites.sites.values()].map((site) => site.origin);
189
+ // A site with no user accounts must not have its subject described as one, so the
190
+ // owner's kind is whatever the host gave, absent included.
191
+ this.local = { id: 'site-owner', ...options.owner };
192
+ this.app = createVerily({
193
+ storage: recordStorage(this.store),
194
+ providers: options.providers,
195
+ sendLimits: { day: options.mailPerDay },
196
+ ...(options.signingKey === false
197
+ ? {}
198
+ : { signingKey: options.signingKey ?? (() => this.signingKey()) }),
199
+ // Whether this origin's DNS names its signing key, and whether a site's names this
200
+ // instance as its verifier. Both go to the resolver the domain method already asks.
201
+ dns: true,
202
+ // The whole origin is this instance, so the library's routes sit at its root.
203
+ baseUrl: this.origin,
204
+ siteName: options.siteName,
205
+ // The verifier is the origin that ran the flow and serves the evidence, which a
206
+ // reader can check. It is not the site name: that host is claimed, not demonstrated.
207
+ // The host alone: how the backend is operated is not something a reader verifies.
208
+ verifierName: origin.host,
209
+ // Each published proof is re-read every six hours, so one taken down is noticed soon.
210
+ recheckMs: options.recheckMs ?? 6 * 3600000,
211
+ // Every site's origin, since the library checks one list. That a subject's profile
212
+ // is on its own site's origin is checked when its handoff arrives.
213
+ profileOrigins: [new URL(options.owner.profileUrl).origin, ...siteOrigins],
214
+ reportUrl: options.reportUrl,
215
+ // A site session is the more specific: it was opened for this holder moments ago.
216
+ authenticate: async (request) => (await this.sites.open(request))?.session.local ??
217
+ ((await this.auth.authenticated(request)) ? this.local : undefined),
218
+ context: async (request) => {
219
+ const open = await this.sites.open(request);
220
+ return open
221
+ ? {
222
+ site: open.session.site,
223
+ purpose: open.session.purpose,
224
+ txn: open.session.txn,
225
+ session: open.key,
226
+ }
227
+ : undefined;
228
+ },
229
+ // A site's holders get the providers that site chose. The owner has no site.
230
+ providersFor: (local) => this.siteOf(local)?.providers,
231
+ visibilityFor: (local) => this.siteOf(local)?.visibility,
232
+ finish: ({ id, context, local, result }) => this.sites.finish(id, context, local, result),
233
+ });
234
+ this.app.service.validateLocal(this.local);
235
+ }
236
+ /** The library's service, for a host that needs more than requests and upkeep. */
237
+ get service() {
238
+ return this.app.service;
239
+ }
240
+ /**
241
+ * What a host runs on a timer, about hourly: drops what has expired, and re-reads the
242
+ * published proofs that are due. The default of 10 per provider each hour stays inside
243
+ * the rate limit a provider gives an unauthenticated caller.
244
+ */
245
+ async upkeep() {
246
+ await this.app.service.prune();
247
+ await this.app.service.recheck(this.options.recheckBudget ?? 10);
248
+ await this.auth.prune();
249
+ await prune(this.store);
250
+ }
251
+ /**
252
+ * Answers one request. `address` is the client's address as the host knows it, which
253
+ * rate limits count by. The instance reads no forwarding header itself, since which one
254
+ * can be trusted depends on what the host sits behind.
255
+ */
256
+ async handle(request, address = null) {
257
+ let response;
258
+ try {
259
+ const admitted = await admit(request, this.origin);
260
+ response = admitted instanceof Response ? admitted : await this.route(admitted, address);
261
+ }
262
+ catch {
263
+ response = html('Temporarily unavailable', '<p>Try again shortly.</p>', 503, 'single');
264
+ }
265
+ // An approval or disconnect form redirects on to the holder's site, and Chromium holds
266
+ // a form's redirects to form-action. Only a holder that site sent here is told its
267
+ // origin, and only that one: named on every response, the policy would tell any visitor
268
+ // which sites this instance serves.
269
+ const policy = response.headers.get('Content-Security-Policy');
270
+ const found = policy ? await this.sites.session(request).catch(() => undefined) : undefined;
271
+ if (policy && found)
272
+ response.headers.set('Content-Security-Policy', policy.replace(/form-action [^;]*/, (allowed) => `${allowed} ${this.site(found.session).origin}`));
273
+ return response;
274
+ }
275
+ async route(request, address) {
276
+ const url = new URL(request.url);
277
+ // The sign-in window's own script, and nothing else. It sets a cookie that decides
278
+ // whose flow this browser signs in to, so it is held to the origin the browser itself
279
+ // reports, before a missing or opaque one is read as ours below: a form in a sandboxed
280
+ // frame has an opaque origin, and can put any origin it likes in its body.
281
+ if (request.method === 'POST' && url.pathname === '/site/enter') {
282
+ if (request.headers.get('origin') !== this.origin ||
283
+ !request.headers.get('content-type')?.startsWith('application/json'))
284
+ return unavailable(403);
285
+ if (!(await this.allowed(request, url, address)))
286
+ return html('Too many requests', '<p>Try again later.</p>', 429, 'single');
287
+ return this.enter(request);
288
+ }
289
+ if (request.method === 'POST' &&
290
+ !own.has(url.pathname) &&
291
+ (!request.headers.has('origin') || request.headers.get('origin') === 'null')) {
292
+ const headers = new Headers(request.headers);
293
+ headers.set('origin', this.origin);
294
+ request = new Request(request, { headers });
295
+ }
296
+ // The connect dialog on a site's own page, which counts its requests once it knows
297
+ // whose they are.
298
+ if (request.headers.has('authorization') && dialogPath(url.pathname))
299
+ return shared(await this.dialog(request, url, address), request);
300
+ if (!(await this.allowed(request, url, address)))
301
+ return html('Too many requests', '<p>Try again later.</p>', 429, 'single');
302
+ if (request.method === 'POST' && url.pathname === '/site/session') {
303
+ const opened = await this.sites.dialog(String((await fields(request)).token ?? ''), request.headers.get('origin'), (local) => this.app.service.validateLocal(local), (site) => allow(this.store, [{ key: `site/${site.id}`, limit: limits.site }]));
304
+ return shared(opened === 'limited'
305
+ ? html('Too many requests', '<p>Try again later.</p>', 429, 'single')
306
+ : opened
307
+ ? data({ session: opened.session })
308
+ : unavailable(), request);
309
+ }
310
+ if (request.method === 'GET' && url.pathname === '/site/enter')
311
+ return enterPage();
312
+ if (request.method === 'GET' && url.pathname === '/site/enter.js')
313
+ return new Response(enterScript, {
314
+ headers: { ...safeHeaders, 'Content-Type': 'text/javascript' },
315
+ });
316
+ if (request.method === 'POST') {
317
+ if (url.pathname === '/login') {
318
+ if (!(await this.auth.allow('login', 10, 15 * 60000)))
319
+ return html('Too many requests', '<p>Try again in fifteen minutes.</p>', 429, 'single');
320
+ const key = new URLSearchParams(await request.text()).get('key') ?? '';
321
+ const cookie = await this.auth.login(key);
322
+ return cookie ? redirect(cookie) : signIn('<p>Incorrect owner key.</p>', 403);
323
+ }
324
+ if (url.pathname === '/logout')
325
+ return redirect(await this.auth.logout(request));
326
+ if (request.headers.get('origin') !== this.origin)
327
+ return unavailable(403);
328
+ if (url.pathname === '/disconnect')
329
+ return this.disconnect(request);
330
+ if (url.pathname === '/mark')
331
+ return this.mark(request);
332
+ }
333
+ if (request.method === 'GET' && url.pathname === '/site/connections')
334
+ return this.read(request, address);
335
+ if (request.method === 'GET' && url.pathname === '/handoff/request') {
336
+ const begun = await this.sites.begin(url.searchParams.get('site'), url.searchParams.get('purpose'));
337
+ return begun ? see(begun.location, [begun.cookie]) : unavailable();
338
+ }
339
+ if (request.method === 'GET' && url.pathname === '/handoff/accept') {
340
+ const started = await this.sites.start(request, url.searchParams.get('token') ?? '', (local) => this.app.service.validateLocal(local));
341
+ // Straight on, so the token leaves the address bar.
342
+ return started
343
+ ? see(started.purpose === 'connect' ? '/verify' : '/', started.cookies)
344
+ : refused();
345
+ }
346
+ // Where a reader who has only this domain's name finds the keys its records are signed by.
347
+ if (request.method === 'GET' && url.pathname === '/.well-known/verily-keys.json')
348
+ return new Response(JSON.stringify({
349
+ keys: await this.app.service.keys(),
350
+ dns: await this.app.service.keyRecords(),
351
+ }), {
352
+ headers: {
353
+ ...safeHeaders,
354
+ 'Content-Type': 'application/json',
355
+ 'Access-Control-Allow-Origin': '*',
356
+ },
357
+ });
358
+ // Every path that is not one of the instance's own is the library's to answer.
359
+ if (!own.has(url.pathname)) {
360
+ // A site session removes a link, or lists an account another way, through this
361
+ // instance, which reports it back to the site, and has no sharing links. The library's
362
+ // routes would do neither.
363
+ if (manages(request.method, url.pathname) && (await this.sites.open(request)))
364
+ return unavailable();
365
+ // Some browsers omit Origin on native same-origin form posts. The request
366
+ // already passed the exact public-origin check above; flow cookies are
367
+ // SameSite=Lax and the library still requires the authenticated owner.
368
+ if (request.method === 'POST' && !request.headers.has('origin')) {
369
+ const headers = new Headers(request.headers);
370
+ headers.set('origin', this.origin);
371
+ request = new Request(request, { headers });
372
+ }
373
+ return this.app.handle(request);
374
+ }
375
+ if (request.method === 'GET' && url.pathname === '/confirm')
376
+ return this.confirm(request, url);
377
+ // Every record on the instance, for whoever holds the owner key and nobody else.
378
+ if (request.method === 'GET' && url.pathname === '/records')
379
+ return (await this.auth.authenticated(request)) ? this.records() : unavailable();
380
+ if (request.method !== 'GET' || url.pathname !== '/')
381
+ return unavailable();
382
+ const found = await this.sites.session(request);
383
+ if (found && !found.session.closed && !found.session.operation)
384
+ return this.settings(found.session.local, this.site(found.session));
385
+ // A disconnect or a mark that failed partway is the one thing left to do with this session.
386
+ if (found?.session.operation && found.session.operation.finishedAt === undefined) {
387
+ const { connection, mark } = found.session.operation;
388
+ return mark
389
+ ? html('Change not finished', `<p>Changing how this account is listed did not finish.</p><form action="/mark" method="post"><input type="hidden" name="connection" value="${escape(connection)}"><input type="hidden" name="as" value="${escape(mark)}"><button>Try again</button></form>`, 200, 'single')
390
+ : html('Disconnect not finished', `<p>Removing this link did not finish.</p><form action="/disconnect" method="post"><input type="hidden" name="connection" value="${escape(connection)}"><button>Try again</button></form>`, 200, 'single');
391
+ }
392
+ if (await this.auth.authenticated(request))
393
+ return this.settings(this.local);
394
+ if (found) {
395
+ const site = this.site(found.session);
396
+ return html('Session ended', `<p>You are finished here. <a href="${escape(site.origin)}">Return to ${escape(site.name)}</a> to start again.</p>`, 200, 'single');
397
+ }
398
+ return signIn();
399
+ }
400
+ /**
401
+ * One request from the connect dialog on a site's own page. The page holds the session
402
+ * and the flow's binding and sends them as headers, because this instance's cookies are
403
+ * another site's on that page and a browser may not send them. Here they are put where
404
+ * the library looks for them, and a binding the library sets comes back as a header. The
405
+ * session works only from its own site's origin, and only for the dialog's requests.
406
+ */
407
+ async dialog(request, url, address) {
408
+ // Counted against the sender's address, as a refused read is: a request turned away
409
+ // here never reaches the counting a served one gets, and each costs a lookup.
410
+ const turnedAway = async () => (await allow(this.store, [{ key: client(address), limit: limits.client }]))
411
+ ? unavailable()
412
+ : html('Too many requests', '<p>Try again later.</p>', 429, 'single');
413
+ const session = request.headers.get('authorization')?.match(/^Bearer (\S+)$/)?.[1] ?? '';
414
+ const binding = request.headers.get(flowHeader) ?? '';
415
+ const post = request.method === 'POST';
416
+ const body = post ? await request.text() : undefined;
417
+ if (!opaque.test(session) || (binding && !opaque.test(binding)))
418
+ return turnedAway();
419
+ // What the dialogs on a registered site's own page may ask for is the library's to say:
420
+ // the methods, a flow's start, state, proof and approval, and the removal or the
421
+ // listing of one of the holder's own accounts. Everything else stays same-origin.
422
+ const route = dialogRoute(request.method, url.pathname, url.searchParams);
423
+ if (!route)
424
+ return turnedAway();
425
+ if (post) {
426
+ if (!request.headers.get('content-type')?.startsWith('application/json'))
427
+ return turnedAway();
428
+ // The dialog connects an account or renews one. Every other kind of flow has a page
429
+ // of its own here. Parsed as the library parses it, so the kind checked is the kind
430
+ // it will run.
431
+ if (route === 'start' && !['connect', 'renew'].includes(String(parsed(body)?.kind)))
432
+ return turnedAway();
433
+ }
434
+ const inner = new Request(request.url, {
435
+ method: request.method,
436
+ headers: {
437
+ cookie: `${sessionCookie}=${session}${binding ? `; ${this.app.flowCookieName}=${binding}` : ''}`,
438
+ origin: this.origin,
439
+ ...(post ? { 'content-type': 'application/json' } : {}),
440
+ },
441
+ body,
442
+ });
443
+ const found = await this.sites.open(inner);
444
+ if (!found || this.site(found.session).origin !== request.headers.get('origin'))
445
+ return turnedAway();
446
+ if (!(await this.allowed(inner, url, address)))
447
+ return html('Too many requests', '<p>Try again later.</p>', 429, 'single');
448
+ const response = await this.app.handle(inner);
449
+ const answer = new Response(response.body, response);
450
+ const set = response.headers
451
+ .getSetCookie()
452
+ .find((cookie) => cookie.startsWith(`${this.app.flowCookieName}=`))
453
+ ?.slice(this.app.flowCookieName.length + 1)
454
+ .split(';')[0];
455
+ answer.headers.delete('Set-Cookie');
456
+ if (set)
457
+ answer.headers.set(flowHeader, set);
458
+ return answer;
459
+ }
460
+ /**
461
+ * Gives the sign-in window the binding of a flow a site's dialog started, as the cookie
462
+ * the provider's callback checks. `origin` is where the page that sent the binding is,
463
+ * as the window's own script saw it. It must be the site the flow's subject belongs to.
464
+ */
465
+ async enter(request) {
466
+ const { flow: id, binding, origin } = await fields(request);
467
+ if (typeof id !== 'string' || typeof binding !== 'string' || !opaque.test(binding))
468
+ return unavailable();
469
+ const flow = await this.app.service.flow(id, binding).catch(() => undefined);
470
+ const site = flow?.local && this.siteOf(flow.local);
471
+ if (!flow || !['connect', 'renew'].includes(flow.kind) || !site || site.origin !== origin)
472
+ return unavailable();
473
+ return new Response(null, {
474
+ status: 204,
475
+ headers: { ...safeHeaders, 'Set-Cookie': this.app.flowCookie(binding) },
476
+ });
477
+ }
478
+ /** The site a subject belongs to, by its id's prefix. The owner's id has none. */
479
+ siteOf(local) {
480
+ return local.id.includes(':') ? this.sites.sites.get(local.id.split(':')[0]) : undefined;
481
+ }
482
+ /**
483
+ * A site's backend reading its own subjects' connections, unlisted ones included, which
484
+ * nobody else can read. This is what lets a site show a link to its own users without the
485
+ * link being public. A refused request counts against the caller's address, and a good
486
+ * one against the site's own allowance, apart from the one its holders' handoffs share.
487
+ */
488
+ async read(request, address) {
489
+ const asked = this.sites.read(request.headers.get('authorization')?.match(/^Bearer (\S+)$/)?.[1] ?? '');
490
+ if (!(await allow(this.store, [
491
+ asked
492
+ ? { key: `read/${asked.site.id}`, limit: limits.read }
493
+ : { key: client(address), limit: limits.client },
494
+ ])))
495
+ return html('Too many requests', '<p>Try again later.</p>', 429, 'single');
496
+ if (!asked)
497
+ return unavailable();
498
+ // No prototype, so an id such as `__proto__` is an entry like any other.
499
+ const connections = Object.create(null);
500
+ for (const id of asked.ids)
501
+ connections[id] = await this.app.service.mine({
502
+ id: `${asked.site.id}:${id}`,
503
+ });
504
+ return new Response(JSON.stringify({ connections }), {
505
+ headers: { ...safeHeaders, 'Content-Type': 'application/json' },
506
+ });
507
+ }
508
+ /** This installation's own signing key, made the first time a record is signed. */
509
+ async signingKey() {
510
+ const kept = await this.store.get('signing/pgp');
511
+ if (kept)
512
+ return kept;
513
+ const made = await generateSigningKey(new URL(this.origin).host);
514
+ await this.store.put('signing/pgp', made);
515
+ return made;
516
+ }
517
+ site(session) {
518
+ return this.sites.sites.get(session.site);
519
+ }
520
+ /**
521
+ * Counts a request that creates state: every POST, and the GETs that start a handoff, a
522
+ * session or a flow. A signed-in holder has a bucket of their own and anyone else is
523
+ * counted by address, so one visitor cannot lock everyone else out. A site's requests
524
+ * also share a ceiling, so one site cannot starve another.
525
+ */
526
+ async allowed(request, url, address) {
527
+ if (request.method !== 'POST' && !limitedGets.includes(url.pathname))
528
+ return true;
529
+ const found = await this.sites.session(request);
530
+ const owner = found ? undefined : await this.auth.session(request);
531
+ const holder = found
532
+ ? { key: `session/${found.key.slice('site/session/'.length)}`, limit: limits.session }
533
+ : owner
534
+ ? { key: `session/${owner}`, limit: limits.session }
535
+ : { key: client(address), limit: limits.client };
536
+ // A handoff is charged to the site it goes to, whatever session this browser holds.
537
+ // Anything else counts against the site whose session is still in use.
538
+ const named = url.pathname === '/handoff/request'
539
+ ? url.searchParams.get('site')
540
+ : url.pathname === '/handoff/accept'
541
+ ? String(peek(url.searchParams.get('token') ?? '')?.site ?? '')
542
+ : found && !found.session.closed
543
+ ? found.session.site
544
+ : undefined;
545
+ const site = named && this.sites.sites.has(named) ? named : undefined;
546
+ return allow(this.store, [
547
+ holder,
548
+ ...(site ? [{ key: `site/${site}`, limit: limits.site }] : []),
549
+ ]);
550
+ }
551
+ /**
552
+ * Removes a link from a site session and reports it back to the site. There is no flow to
553
+ * hold the result, so the session holds it, and success is reported only once the
554
+ * revocation has committed: pending first, then revoked, then complete. A retry resumes
555
+ * wherever the last attempt stopped, and one already complete reads what it stored.
556
+ */
557
+ async disconnect(request) {
558
+ const found = await this.sites.session(request);
559
+ const connection = new URLSearchParams(await request.text()).get('connection') ?? '';
560
+ if (!found || !connection)
561
+ return unavailable();
562
+ const { key, session } = found;
563
+ const held = session.operation;
564
+ let current = held?.connection === connection && !held.mark ? session : undefined;
565
+ if (!current) {
566
+ const owned = (await this.app.service.mine(session.local)).find((e) => e.id === connection);
567
+ if (!owned)
568
+ return unavailable();
569
+ current = await this.sites.pending(key, connection, owned.visibility);
570
+ if (!current)
571
+ return unavailable();
572
+ }
573
+ if (current.operation.finishedAt === undefined) {
574
+ // Idempotent: a connection already revoked is left as it is.
575
+ await this.app.service.revoke(connection, session.local);
576
+ current = await this.sites.completed(key);
577
+ }
578
+ return see(this.sites.returnUrl(this.site(session), session.local, session.txn, {
579
+ operation: 'disconnect',
580
+ outcome: 'complete',
581
+ connection,
582
+ visibility: current.operation.visibility,
583
+ finishedAt: current.operation.finishedAt,
584
+ }));
585
+ }
586
+ /**
587
+ * Lists an account another way, or retires it, from a site session, and reports it back
588
+ * to the site as a disconnect is reported: pending on the session first, then changed,
589
+ * then complete. Asking again for what already holds changes nothing, so a retry is safe.
590
+ */
591
+ async mark(request) {
592
+ const found = await this.sites.session(request);
593
+ const fields = new URLSearchParams(await request.text());
594
+ const connection = fields.get('connection') ?? '';
595
+ const as = listings.find((listing) => listing === fields.get('as'));
596
+ if (!found || !connection || !as)
597
+ return unavailable();
598
+ const { key, session } = found;
599
+ const held = session.operation;
600
+ let current = held?.connection === connection && held.mark === as ? session : undefined;
601
+ if (!current) {
602
+ const owned = (await this.app.service.mine(session.local)).find((e) => e.id === connection);
603
+ // Checked before anything is held: what cannot be done must not be left pending.
604
+ if (!owned || owned.status === 'revoked' || (owned.status === 'retired' && as !== 'retired'))
605
+ return unavailable();
606
+ current = await this.sites.pending(key, connection, owned.visibility, as);
607
+ if (!current)
608
+ return unavailable();
609
+ }
610
+ if (current.operation.finishedAt === undefined) {
611
+ await this.app.service.mark(connection, session.local, as);
612
+ current = await this.sites.completed(key);
613
+ }
614
+ return see(this.sites.returnUrl(this.site(session), session.local, session.txn, {
615
+ operation: 'mark',
616
+ outcome: 'complete',
617
+ connection,
618
+ visibility: current.operation.visibility,
619
+ finishedAt: current.operation.finishedAt,
620
+ }));
621
+ }
622
+ /**
623
+ * The settings page for whoever is signed in: the owner, or a holder a site sent here.
624
+ * The owner's page carries the embed for their own site; a site's holder has the site to
625
+ * show their badge, and removes a link through a route that tells the site so.
626
+ */
627
+ async settings(subject, site) {
628
+ const connections = await this.app.service.mine(subject);
629
+ const local = localSide(subject, this.options.siteName);
630
+ const now = Date.now();
631
+ // The copy button's script, on the one page that has something to copy.
632
+ const embeds = !site && connections.some((e) => e.visibility === 'public');
633
+ return html(site ? 'Your connections' : 'Owner settings', `${card(local.heading, local.value, undefined, subject.reference)}
634
+ <p class="links"><a class="button" href="/verify">Verify an account</a>${site ? '' : '<a href="/records">Every connection on this instance</a>'}</p>
635
+ ${connections.length
636
+ ? `<p class="fine">Renewing a connection extends it and keeps its ID, so anything that shows it goes on working.${site ? '' : ' Only a public connection can be embedded.'}</p>`
637
+ : '<p>No connections yet. Verify an account to make the first one.</p>'}
638
+ ${connections.map((e) => this.connection(e, now, site)).join('')}
639
+ ${site ? `<p><a href="${escape(site.origin)}">Back to ${escape(site.name)}</a></p>` : '<form action="/logout" method="post"><button>Sign out</button></form>'}`, 200, '', embeds ? '/copy.js' : '');
640
+ }
641
+ /**
642
+ * Every record the instance holds, for its operator: each site's subjects, public and
643
+ * unlisted alike, with what each is linked to and how it stands. Read-only. A public
644
+ * record links to its evidence; an unlisted one has no page that is the operator's to open.
645
+ */
646
+ async records() {
647
+ const all = await this.app.service.all();
648
+ const now = Date.now();
649
+ const count = (visibility) => all.filter((e) => e.visibility === visibility).length;
650
+ const live = (e) => ['Verified', 'Unconfirmed'].includes(statusLabel(e, now));
651
+ const sites = [...new Set(all.map((e) => e.siteName))];
652
+ return html('All connections', `<p class="fine">${all.length} on this instance: ${count('public')} public, ${count('unlisted')} unlisted.</p>
653
+ ${sites
654
+ .map((name) => {
655
+ // Live records first. The sort is stable, so each half keeps the stored order.
656
+ const records = all
657
+ .filter((e) => e.siteName === name)
658
+ .sort((a, b) => Number(live(b)) - Number(live(a)));
659
+ return `<h2>${escape(name)} · ${records.length}</h2>${records
660
+ .map((e) => {
661
+ const state = statusLabel(e, now);
662
+ const local = e.local.reference && e.local.reference !== e.local.label
663
+ ? `${e.local.label} · ${e.local.reference}`
664
+ : e.local.label;
665
+ // The card the settings page draws, without its controls: this page only reads.
666
+ return `<div class="connection">${externalCard(cardHeading(e, ` · ${e.visibility}`), e.external, `<span class="state">${verdict(toneOf(e, now), true)}<span>${escape(state)}</span></span>`, e.visibility === 'public'
667
+ ? `<p class="evidence"><a href="${escape(e.evidenceUrl)}">Inspect evidence</a></p>`
668
+ : '', `<dl><dt>Linked to</dt><dd>${escape(local)}</dd><dt>Connected</dt><dd>${escape(date(e.connectedAt))}</dd></dl>`)}</div>`;
669
+ })
670
+ .join('')}`;
671
+ })
672
+ .join('')}
673
+ <p><a href="/">Back to owner settings</a></p>`);
674
+ }
675
+ /**
676
+ * Asks before an account is retired or a connection is removed, since neither can be
677
+ * undone. The page's one button posts to the route that does it.
678
+ */
679
+ async confirm(request, url) {
680
+ const found = await this.sites.session(request);
681
+ const site = found && !found.session.closed && !found.session.operation
682
+ ? this.site(found.session)
683
+ : undefined;
684
+ const subject = site
685
+ ? found.session.local
686
+ : (await this.auth.authenticated(request))
687
+ ? this.local
688
+ : undefined;
689
+ const action = url.searchParams.get('do');
690
+ const id = url.searchParams.get('connection');
691
+ const e = subject && (await this.app.service.mine(subject)).find((e) => e.id === id);
692
+ if (!e || (action !== 'retire' && action !== 'remove'))
693
+ return unavailable();
694
+ // What the settings page offers for this record, and nothing it does not.
695
+ if (action === 'retire' ? ['revoked', 'retired'].includes(e.status) : e.status === 'revoked')
696
+ return unavailable();
697
+ const held = site ? `<input type="hidden" name="connection" value="${escape(e.id)}">` : '';
698
+ const [heading, note, form] = action === 'retire'
699
+ ? [
700
+ 'Retire this account?',
701
+ retiringNote,
702
+ `<form action="${site ? '/mark' : `/connections/${escape(e.id)}/mark`}" method="post">${held}<input type="hidden" name="as" value="retired"><button class="danger">Retire</button></form>`,
703
+ ]
704
+ : site
705
+ ? [
706
+ 'Disconnect this account?',
707
+ `This removes the link between your ${site.name} account and this one. It cannot be undone. To link them again you would verify the account again.`,
708
+ `<form action="/disconnect" method="post">${held}<button class="danger">Disconnect</button></form>`,
709
+ ]
710
+ : [
711
+ 'Revoke this connection?',
712
+ 'This ends the connection, and it will read as revoked wherever it is shown. It cannot be undone. Verifying the account again makes a new connection with a new ID.',
713
+ `<form action="/connections/${escape(e.id)}/disconnect" method="post"><button class="danger">Revoke connection</button></form>`,
714
+ ];
715
+ return html(heading, `${externalCard(cardHeading(e), e.external)}
716
+ <p>${escape(note)}</p>
717
+ <div class="choice">${form}<a class="button secondary" href="/">Cancel</a></div>`, 200, 'single');
718
+ }
719
+ /** One connection: the account's card, its state, and what can be done with it. */
720
+ connection(e, now, site) {
721
+ // The script is the published package's, since a page on another site cannot load a
722
+ // path on this one that the instance does not serve.
723
+ const embed = `<script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@${version.slice(1)}/dist/verily.js" defer></script>\n<verily-badge backend-url="${this.origin}" connection-id="${e.id}"></verily-badge>`;
724
+ const state = statusLabel(e, now);
725
+ const retired = e.status === 'retired';
726
+ const open = !['revoked', 'retired'].includes(e.status);
727
+ const when = (time) => `${date(time)} (${distance(time, now)})`;
728
+ const rows = [
729
+ ['Visibility', e.visibility === 'public' ? 'Public' : 'Unlisted'],
730
+ ...(retired
731
+ ? [
732
+ ['Last verified', date(lastProved(e))],
733
+ ['Retired', date(e.retiredAt)],
734
+ ]
735
+ : [['Expires', when(e.expiresAt)]]),
736
+ ...(e.mark === 'preferred' || e.mark === 'unused'
737
+ ? [['Listed as', e.mark === 'preferred' ? 'Preferred' : 'No longer used']]
738
+ : []),
739
+ ];
740
+ const shown = site
741
+ ? e.visibility === 'public'
742
+ ? `Anyone can view this connection, on ${site.name} or anywhere else.`
743
+ : `Only ${site.name} can read this connection, and it chooses who there sees it.`
744
+ : '';
745
+ // A site's holder cannot act on a record already revoked.
746
+ const acts = !(site && e.status === 'revoked');
747
+ // Renewing and changing visibility start a flow on a page of their own, so they are
748
+ // links, drawn as buttons beside the others that act on the connection.
749
+ const goes = [
750
+ acts ? `<a class="button" href="/renew/${escape(e.id)}">Renew</a>` : '',
751
+ acts && !retired && site?.visibility?.length !== 1
752
+ ? `<a class="button" href="/visibility/${escape(e.id)}">Change visibility</a>`
753
+ : '',
754
+ ].join('');
755
+ // How the holder lists the account, which acts on every record of it they have. A
756
+ // site's holder is sent back to the site with the change, as with a disconnect.
757
+ const listing = open
758
+ ? `<form action="${site ? '/mark' : `/connections/${escape(e.id)}/mark`}" method="post">${site ? `<input type="hidden" name="connection" value="${escape(e.id)}">` : ''}
759
+ ${e.mark === 'preferred' ? '' : '<button name="as" value="preferred">Mark as preferred</button>'}
760
+ ${e.mark === 'unused' ? '' : '<button name="as" value="unused">Mark as unused</button>'}
761
+ ${e.mark === undefined ? '' : '<button name="as" value="current">Mark as current</button>'}</form>`
762
+ : '';
763
+ // Retiring and removing go by a page that asks first, since neither can be undone.
764
+ const asks = (action, label, danger = false) => `<form action="/confirm" method="get"><input type="hidden" name="connection" value="${escape(e.id)}"><button${danger ? ' class="danger"' : ''} name="do" value="${action}">${label}</button></form>`;
765
+ // Retiring and removing cannot be undone, so they sit together on a line of their own
766
+ // below the rest, where nothing else lines up with them.
767
+ const ends = e.status === 'revoked'
768
+ ? ''
769
+ : `${open ? asks('retire', 'Retire', true) : ''}${asks('remove', site ? 'Disconnect' : 'Revoke', true)}`;
770
+ const ending = ends && `<div class="ending">${ends}</div>`;
771
+ return `<div class="connection">${externalCard(cardHeading(e), e.external, `<span class="state">${verdict(toneOf(e, now), true)}<span>${escape(state)}</span></span>`, e.visibility === 'public'
772
+ ? `<p class="evidence"><a href="${escape(e.evidenceUrl)}">Inspect evidence</a></p>`
773
+ : '', `<dl>${rows.map(([label, value]) => `<dt>${label}</dt><dd>${escape(value)}</dd>`).join('')}</dl>`, shown && `<p class="fine">${escape(shown)}</p>`, !site && e.visibility === 'public'
774
+ ? `<p class="fine">Embed on your site</p><pre><code>${escape(embed)}</code></pre>`
775
+ : '', `${goes || listing ? `<div class="actions">${goes}${listing}</div>` : ''}${ending}`)}</div>`;
776
+ }
777
+ }