@popcorn245/cornfield 0.5.0 → 0.6.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.
package/core/config.js CHANGED
@@ -66,7 +66,7 @@ export const FORGE_TYPES = ['github', 'forgejo'];
66
66
  /** How a pull request cornfield lands is merged (`forge.mergeMethod`): the way the repo lands its pull requests */
67
67
  export const MERGE_METHODS = ['merge', 'squash', 'rebase'];
68
68
  /** The events a hook may ask for (`hooks[].events`), and what each one means: docs/config.md#hooks */
69
- export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed']);
69
+ export const HOOK_EVENTS = Object.freeze(['spec.ready', 'spec.approved', 'spec.sentBack', 'build.opened', 'pr.landed', 'session.expiring']);
70
70
  /** The most hooks a config lists */
71
71
  export const MAX_HOOKS = 20;
72
72
  /** The bounds of `hookPoll.specs` in milliseconds: not under a minute (the forge is not hammered), not over an hour */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popcorn245/cornfield",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A self-hostable dev dashboard with the topology map at its centre: proposals on the map, scenarios, outages and what-if edits, for any repo.",
5
5
  "license": "MIT",
6
6
  "author": "popcorn245 (https://github.com/popcorn245)",
package/server/auth.js CHANGED
@@ -45,15 +45,50 @@ import { sameOrigin } from './proposals.js';
45
45
 
46
46
  /** How often a session's permission is asked of the forge again */
47
47
  export const RECHECK_MS = 15 * 60 * 1000;
48
- /** How long a session lasts */
49
- export const SESSION_MS = 7 * 24 * 60 * 60 * 1000;
48
+ const DAY_MS = 24 * 60 * 60 * 1000;
49
+ /** How long a session lasts unless `CORNFIELD_SESSION_DAYS` says otherwise */
50
+ export const SESSION_MS = 7 * DAY_MS;
51
+ export const SESSION_DAYS_ENV = 'CORNFIELD_SESSION_DAYS';
52
+ export const DEFAULT_SESSION_DAYS = 7;
53
+ export const MAX_SESSION_DAYS = 30;
54
+ /** A forge token this close to its expiry (or past it) is renewed before it is used */
55
+ export const REFRESH_SKEW_MS = 30_000;
50
56
  /** How long the way out to the forge and back may take */
51
57
  export const STATE_MS = 10 * 60 * 1000;
52
58
  export const MIN_SESSION_SECRET = 32;
53
59
  const TOKEN_TIMEOUT_MS = 15_000;
54
60
  const REVOKE_TIMEOUT_MS = 5_000;
61
+ /** The most of a token endpoint's answer that is read: a token pair is a few hundred bytes */
62
+ const MAX_TOKEN_ANSWER = 64 * 1024;
63
+ /** Statuses that say "not now" (a timeout, a rate limit), never "no": a renewal answered with one ends no session */
64
+ const NOT_NOW = [408, 425, 429];
65
+
66
+ /** A response's text, at most `max` bytes: past that the body is dropped and the read throws */
67
+ async function cappedText(res, max) {
68
+ if (!res.body || typeof res.body.getReader !== 'function') {
69
+ const text = await res.text();
70
+ if (Buffer.byteLength(text) > max) throw new Error('the answer is too large');
71
+ return text;
72
+ }
73
+ const reader = res.body.getReader();
74
+ const chunks = [];
75
+ let size = 0;
76
+ for (;;) {
77
+ const { done, value } = await reader.read();
78
+ if (done) break;
79
+ size += value.byteLength;
80
+ if (size > max) {
81
+ await reader.cancel().catch(() => {});
82
+ throw new Error('the answer is too large');
83
+ }
84
+ chunks.push(Buffer.from(value));
85
+ }
86
+ return Buffer.concat(chunks).toString('utf8');
87
+ }
55
88
  /** What a session row keeps in place of a token when it keeps none (a signed-in non-maintainer) */
56
89
  const NO_TOKEN = '';
90
+ /** What a renewal answers for a session that has no refresh token */
91
+ const NO_REFRESH = Symbol('no refresh token');
57
92
 
58
93
  /** The env each forge's OAuth app is read from */
59
94
  export const OAUTH_ENV = Object.freeze({
@@ -79,15 +114,29 @@ export function oauthEndpoints(forge) {
79
114
  };
80
115
  }
81
116
 
117
+ /**
118
+ * How many days a session lasts: `CORNFIELD_SESSION_DAYS`, a whole number from 1 to 30, 7 when unset. Anything else
119
+ * throws, in words (a value that is quietly ignored would leave a team on 7 days without knowing).
120
+ * @param {Record<string, string | undefined>} [env]
121
+ */
122
+ export function sessionDays(env = process.env) {
123
+ const raw = env[SESSION_DAYS_ENV];
124
+ if (raw === undefined || raw === '') return DEFAULT_SESSION_DAYS;
125
+ const days = /^[0-9]{1,3}$/.test(raw.trim()) ? Number(raw.trim()) : NaN;
126
+ if (!Number.isInteger(days) || days < 1 || days > MAX_SESSION_DAYS) throw new Error(`${SESSION_DAYS_ENV} is ${JSON.stringify(raw.slice(0, 40))}: use a whole number of days from 1 to ${MAX_SESSION_DAYS} (${DEFAULT_SESSION_DAYS} when unset).`);
127
+ return days;
128
+ }
129
+
82
130
  /**
83
131
  * The sign-in settings env gives, or null when sign-in is not configured at all. Throws, in words, when it is
84
132
  * half configured: one of a pair, no session secret, a short one, no public URL, no forge.
85
133
  * @param {{ forge?: { type: string, url?: string, repo: string } | null, publicUrl?: string | null }} config
86
134
  * @param {Record<string, string | undefined>} [env]
87
- * @returns {null | { forge: { type: string, url?: string, repo: string }, forgeName: string, clientId: string, clientSecret: string, sessionSecret: string, publicUrl: URL, secure: boolean }}
135
+ * @returns {null | { forge: { type: string, url?: string, repo: string }, forgeName: string, clientId: string, clientSecret: string, sessionSecret: string, publicUrl: URL, secure: boolean, sessionDays: number }}
88
136
  */
89
137
  export function signInSettings(config, env = process.env) {
90
138
  const set = (name) => typeof env[name] === 'string' && env[name] !== '';
139
+ const days = sessionDays(env);
91
140
  const anyPair = Object.values(OAUTH_ENV).some((p) => set(p.id) || set(p.secret));
92
141
  if (!anyPair) {
93
142
  if (set(SESSION_SECRET_ENV)) throw new Error(`${SESSION_SECRET_ENV} is set but no OAuth app is: set the forge's client id and secret too (docs/sign-in.md), or unset it.`);
@@ -114,7 +163,7 @@ export function signInSettings(config, env = process.env) {
114
163
  if (publicUrl.pathname !== '/' || publicUrl.search || publicUrl.hash) throw new Error('The public URL is an origin, like https://cornfield.example.org: cornfield is served at its root.');
115
164
  return {
116
165
  forge, forgeName: FORGE_NAMES[forge.type], clientId: env[pair.id], clientSecret: env[pair.secret], sessionSecret: env[SESSION_SECRET_ENV],
117
- publicUrl, secure: publicUrl.protocol === 'https:',
166
+ publicUrl, secure: publicUrl.protocol === 'https:', sessionDays: days,
118
167
  };
119
168
  }
120
169
 
@@ -152,19 +201,24 @@ function keysOf(secret) {
152
201
  const macOf = (key, text) => createHmac('sha256', key).update(text).digest('base64url');
153
202
 
154
203
  /** A forge token, encrypted for its row: `v1.<iv>.<tag>.<ciphertext>` */
155
- export function sealToken(key, token) {
204
+ export function sealToken(key, token, aad) {
156
205
  const iv = randomBytes(12);
157
206
  const c = createCipheriv('aes-256-gcm', key, iv);
207
+ // With `aad` (v2) the ciphertext opens only for that row and column: one cannot be moved into another session's row
208
+ if (aad !== undefined) c.setAAD(Buffer.from(String(aad), 'utf8'));
158
209
  const body = Buffer.concat([c.update(token, 'utf8'), c.final()]);
159
- return `v1.${b64url(iv)}.${b64url(c.getAuthTag())}.${b64url(body)}`;
210
+ return `${aad !== undefined ? 'v2' : 'v1'}.${b64url(iv)}.${b64url(c.getAuthTag())}.${b64url(body)}`;
160
211
  }
161
212
 
162
213
  /** The token a row holds, or null when it does not open under this key (another secret, or tampered) */
163
- export function openToken(key, sealed) {
214
+ export function openToken(key, sealed, aad, { legacy = true } = {}) {
164
215
  const [v, iv, tag, body] = String(sealed).split('.');
165
- if (v !== 'v1' || !iv || !tag || body === undefined) return null;
216
+ if (!iv || !tag || body === undefined) return null;
217
+ // v2 is bound to its row and column (`aad`); v1, sealed before that, opens only where a legacy value may still be
218
+ if (v === 'v2' ? aad === undefined : v !== 'v1' || !legacy) return null;
166
219
  try {
167
220
  const d = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url'));
221
+ if (v === 'v2') d.setAAD(Buffer.from(String(aad), 'utf8'));
168
222
  d.setAuthTag(Buffer.from(tag, 'base64url'));
169
223
  return Buffer.concat([d.update(Buffer.from(body, 'base64url')), d.final()]).toString('utf8');
170
224
  } catch {
@@ -255,7 +309,7 @@ function wantsPage(req, reqPath) {
255
309
  }
256
310
 
257
311
  /**
258
- * @typedef {{ id: string, forge: string, user_id: string, login: string, token: string, permission: string, created_at: string, checked_at: string, expires_at: string }} SessionRow
312
+ * @typedef {{ id: string, forge: string, user_id: string, login: string, token: string, permission: string, created_at: string, checked_at: string, expires_at: string, refresh_token: string, token_expires_at: string | null, expiring_sent_at: string | null }} SessionRow
259
313
  * @typedef {{ login: string, userId: string, permission: string, admin: boolean, row: SessionRow }} Session
260
314
  */
261
315
 
@@ -290,6 +344,7 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
290
344
 
291
345
  const title = config.title || 'cornfield';
292
346
  const { forge: forgeConfig, forgeName, clientId, clientSecret, publicUrl, secure } = settings;
347
+ const sessionMs = settings.sessionDays * DAY_MS;
293
348
  const keys = keysOf(settings.sessionSecret);
294
349
  const endpoints = oauthEndpoints(forgeConfig);
295
350
  const redirectUri = new URL('/auth/callback', publicUrl).href;
@@ -305,6 +360,16 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
305
360
  const store = () => (opening ??= import('./db.js').then((m) => (db = m.openStore(config.dataDir))).catch((err) => { opening = null; throw err; }));
306
361
  const iso = (ms) => new Date(ms).toISOString();
307
362
  const forgeWith = (token) => createForge(forgeConfig, { token, ...forgeOptions });
363
+ /** Session ids whose forge token is being renewed right now: one renewal per session, however many calls wait on it */
364
+ const renewals = new Map();
365
+ /**
366
+ * A session's tokens are sealed for their row and column (AES-GCM's additional data): a sealed token copied into
367
+ * another session's row, or from one column to the other, does not open. An access token sealed before this (v1)
368
+ * still opens until its session ends; a refresh token never had a v1.
369
+ */
370
+ const aadOf = (id, column) => `cornfield session ${id} ${column}`;
371
+ const sealFor = (id, column, value) => sealToken(keys.enc, value, aadOf(id, column));
372
+ const openFor = (row, column) => openToken(keys.enc, row[column], aadOf(row.id, column), { legacy: column === 'token' });
308
373
 
309
374
  const send = (res, status, body, headers = {}) => {
310
375
  res.writeHead(status, { ...(typeof body === 'string' ? HTML_HEADERS : JSON_HEADERS), ...headers });
@@ -350,7 +415,7 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
350
415
  /** Ends a session: its row goes, and the token it kept is revoked */
351
416
  async function endRow(row) {
352
417
  (await store()).prepare('DELETE FROM sessions WHERE id = ?').run(row.id);
353
- if (row.token !== NO_TOKEN) await revoke(openToken(keys.enc, row.token));
418
+ if (row.token !== NO_TOKEN) await revoke(openFor(row, 'token'));
354
419
  }
355
420
 
356
421
  /** The session row a request's cookie names, live, or null */
@@ -379,13 +444,13 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
379
444
  const row = await rowOf(req);
380
445
  if (!row) return { session: null };
381
446
  if (row.token === NO_TOKEN || now() - Date.parse(row.checked_at) < RECHECK_MS) return { session: sessionOf(row) };
382
- const token = openToken(keys.enc, row.token);
447
+ const token = openFor(row, 'token');
383
448
  if (token === null) {
384
449
  await endRow(row);
385
450
  return { session: null };
386
451
  }
387
452
  try {
388
- const forge = forgeWith(token);
453
+ const forge = managedForge(row);
389
454
  const user = await forge.me();
390
455
  if (String(user.id) !== row.user_id) {
391
456
  await endRow(row);
@@ -393,9 +458,14 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
393
458
  }
394
459
  const permission = await forge.permission(forgeConfig.repo, user.login);
395
460
  const keep = isMaintainer(permission);
396
- const next = { ...row, login: user.login, permission, checked_at: iso(now()), token: keep ? row.token : NO_TOKEN };
397
- (await store()).prepare('UPDATE sessions SET login = ?, permission = ?, checked_at = ?, token = ? WHERE id = ?').run(next.login, next.permission, next.checked_at, next.token, row.id);
398
- if (!keep) await revoke(token);
461
+ // The token is not written back when it is kept: a renewal during these calls has stored a newer one
462
+ const file = await store();
463
+ const kept = /** @type {SessionRow | undefined} */ (file.prepare('SELECT * FROM sessions WHERE id = ?').get(row.id));
464
+ if (!kept) return { session: null };
465
+ const next = { ...kept, login: user.login, permission, checked_at: iso(now()), ...(keep ? {} : { token: NO_TOKEN, refresh_token: '', token_expires_at: null }) };
466
+ file.prepare('UPDATE sessions SET login = ?, permission = ?, checked_at = ?, token = ?, refresh_token = ?, token_expires_at = ? WHERE id = ?')
467
+ .run(next.login, next.permission, next.checked_at, next.token, next.refresh_token, next.token_expires_at, row.id);
468
+ if (!keep) await revoke(openFor(kept, 'token') ?? token);
399
469
  return { session: sessionOf(next) };
400
470
  } catch (err) {
401
471
  if (err instanceof ForgeError && err.code === 'unauthorized') {
@@ -430,22 +500,141 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
430
500
  return { verifier };
431
501
  }
432
502
 
433
- /** The forge's token for a code, or throws a ForgeError */
434
- async function exchange(code, verifier) {
435
- const body = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri, grant_type: 'authorization_code', code_verifier: verifier });
503
+ /**
504
+ * The forge's token endpoint, for a code (sign-in) or a refresh token (renewal). Answers the pair it gave, or throws
505
+ * a ForgeError: `unauthorized` when the forge refused (a code or refresh token that is bad, revoked or expired),
506
+ * `unavailable` when it did not answer or said it could not now (nothing is ended for that).
507
+ * @returns {Promise<{ accessToken: string, refreshToken: string, expiresAt: string | null }>}
508
+ */
509
+ async function tokenRequest(grant, what) {
510
+ const body = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, ...grant });
436
511
  let res;
437
512
  let parsed = null;
438
513
  try {
439
514
  res = await doFetch(endpoints.token, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'cornfield' }, body, redirect: 'manual', signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS) });
440
- parsed = JSON.parse(await res.text());
515
+ parsed = JSON.parse(await cappedText(res, MAX_TOKEN_ANSWER));
441
516
  } catch (err) {
442
- throw new ForgeError(`${forgeName} did not answer the sign-in; try again in a minute.`, { code: 'unavailable', forge: forgeName, cause: err });
517
+ // A forge that answered 4xx with a body that is not JSON said no; one that did not answer, or broke, did not
518
+ if (res && res.status >= 400 && res.status < 500 && !NOT_NOW.includes(res.status)) throw new ForgeError(`${forgeName} did not give a token for ${what}.`, { code: 'unauthorized', status: res.status, forge: forgeName });
519
+ throw new ForgeError(`${forgeName} did not answer ${what}; try again in a minute.`, { code: 'unavailable', forge: forgeName, cause: err });
520
+ }
521
+ if (res.status >= 500 || NOT_NOW.includes(res.status) || (res.status >= 300 && res.status < 400) || (parsed && (parsed.error === 'temporarily_unavailable' || parsed.error === 'server_error'))) {
522
+ throw new ForgeError(`${forgeName} could not answer ${what} just now; try again in a minute.`, { code: 'unavailable', status: res.status, forge: forgeName });
443
523
  }
444
524
  if (!res.ok || !parsed || typeof parsed.access_token !== 'string' || parsed.access_token === '') {
445
525
  const said = parsed && typeof parsed.error_description === 'string' ? ` ${forgeName} said: "${parsed.error_description.slice(0, 200)}"` : '';
446
- throw new ForgeError(`${forgeName} did not give a token for this sign-in.${said}`, { code: 'unauthorized', status: res.status, forge: forgeName });
526
+ throw new ForgeError(`${forgeName} did not give a token for ${what}.${said}`, { code: 'unauthorized', status: res.status, forge: forgeName });
447
527
  }
448
- return parsed.access_token;
528
+ // Forgejo always says how long an access token lasts (1 hour unless the instance changed it) and gives a refresh
529
+ // token; GitHub does only for an app with token expiration on. A token that says nothing is not expiring
530
+ const seconds = Number(parsed.expires_in);
531
+ return {
532
+ accessToken: parsed.access_token,
533
+ refreshToken: typeof parsed.refresh_token === 'string' ? parsed.refresh_token : '',
534
+ expiresAt: Number.isFinite(seconds) && seconds > 0 ? iso(now() + seconds * 1000) : null,
535
+ };
536
+ }
537
+
538
+ const exchange = (code, verifier) => tokenRequest({ code, redirect_uri: redirectUri, grant_type: 'authorization_code', code_verifier: verifier }, 'this sign-in');
539
+
540
+ /**
541
+ * Renews a session's forge token with its refresh token, once at a time per session. `used` is the sealed token the
542
+ * caller found stale: if the row holds another by now, somebody renewed it already and that is what answers. Persists
543
+ * the new pair together (a rotated refresh token makes the old one useless). Answers the row; `NO_REFRESH` when the
544
+ * session keeps no refresh token (a GitHub app whose tokens do not expire, the usual case: nothing changes for it);
545
+ * or null when the session is over: the refresh token is refused (revoked, expired), and the row is ended so the
546
+ * "sign in again" behaviour applies. Throws a ForgeError `unavailable` when the forge cannot be asked now: the session
547
+ * stays and the next call tries again.
548
+ * @param {string} id the session row's id
549
+ * @param {string} used
550
+ * @returns {Promise<SessionRow | typeof NO_REFRESH | null>}
551
+ */
552
+ function renew(id, used) {
553
+ const going = renewals.get(id);
554
+ if (going) return going;
555
+ const job = (async () => {
556
+ const file = await store();
557
+ const row = /** @type {SessionRow | undefined} */ (file.prepare('SELECT * FROM sessions WHERE id = ?').get(id));
558
+ if (!row || row.token === NO_TOKEN) return null;
559
+ if (row.token !== used) return row;
560
+ const refresh = row.refresh_token === '' ? null : openFor(row, 'refresh_token');
561
+ // Nothing to renew with: the session is left as it is, and the call that found the token stale gets the forge's own answer
562
+ if (refresh === null) return NO_REFRESH;
563
+ // Another process (cornfield restarted, or two behind one data folder) may renew the same row at the same time. The
564
+ // forge rotates the refresh token, so the slower of the two is refused: that is a lost race, not a refused sign-in,
565
+ // and the row the winner wrote is the answer. The write itself only lands on the row as it was read.
566
+ const rowNow = () => /** @type {SessionRow | undefined} */ (file.prepare('SELECT * FROM sessions WHERE id = ?').get(id));
567
+ let got;
568
+ try {
569
+ got = await tokenRequest({ grant_type: 'refresh_token', refresh_token: refresh }, 'a renewed sign-in');
570
+ } catch (err) {
571
+ if (err instanceof ForgeError && err.code === 'unauthorized') {
572
+ const after = rowNow();
573
+ if (after && after.token !== row.token && after.token !== NO_TOKEN) return after;
574
+ if (after) await endRow(after);
575
+ return null;
576
+ }
577
+ throw err;
578
+ }
579
+ const next = { ...row, token: sealFor(id, 'token', got.accessToken), refresh_token: got.refreshToken === '' ? row.refresh_token : sealFor(id, 'refresh_token', got.refreshToken), token_expires_at: got.expiresAt };
580
+ const wrote = file.prepare('UPDATE sessions SET token = ?, refresh_token = ?, token_expires_at = ? WHERE id = ? AND token = ? AND refresh_token = ?').run(next.token, next.refresh_token, next.token_expires_at, id, row.token, row.refresh_token).changes;
581
+ if (wrote === 1) return next;
582
+ // The row changed while the forge was asked: ended (sign-out), or renewed elsewhere. The row as it is now answers
583
+ const after = rowNow();
584
+ return after && after.token !== NO_TOKEN ? after : null;
585
+ })().finally(() => renewals.delete(id));
586
+ renewals.set(id, job);
587
+ return job;
588
+ }
589
+
590
+ /**
591
+ * The forge as the session's owner, with a token that renews itself: past (or about to pass) its expiry it is renewed
592
+ * before the call, and a call the forge answers 401 is renewed once and made again. A renewal that cannot be had
593
+ * ends the session and the call throws the same `unauthorized` ForgeError as a session that is gone.
594
+ * @param {SessionRow} row
595
+ */
596
+ function managedForge(row) {
597
+ let sealed = row.token;
598
+ let expiresAt = row.token_expires_at;
599
+ let forge = forgeWith(/** @type {string} */ (openFor({ id: row.id, token: sealed }, 'token')));
600
+ const over = () => new ForgeError(`${forgeName} sign-in has to be renewed; sign in again.`, { code: 'unauthorized', forge: forgeName });
601
+ /** Whether the token is a new one: false when this session cannot renew (the call goes on as it would have) */
602
+ const renewNow = async () => {
603
+ const next = await renew(row.id, sealed);
604
+ if (next === NO_REFRESH) return false;
605
+ const token = next ? openFor(next, 'token') : null;
606
+ if (!next || token === null) throw over();
607
+ sealed = next.token;
608
+ expiresAt = next.token_expires_at;
609
+ forge = forgeWith(token);
610
+ return true;
611
+ };
612
+ const due = () => expiresAt !== null && Date.parse(expiresAt) - REFRESH_SKEW_MS <= now();
613
+ return new Proxy({}, {
614
+ get(_, prop) {
615
+ const member = forge[prop];
616
+ if (typeof member !== 'function' || prop === 'rateLimit') return member;
617
+ return async (...args) => {
618
+ if (due()) {
619
+ try {
620
+ await renewNow();
621
+ } catch (err) {
622
+ // Renewing ahead of time could not be done now (the forge is down): a token that has not expired yet still works
623
+ const stillGood = expiresAt !== null && Date.parse(expiresAt) > now();
624
+ if (!(err instanceof ForgeError && err.code === 'unavailable' && stillGood)) throw err;
625
+ }
626
+ }
627
+ try {
628
+ return await forge[prop](...args);
629
+ } catch (err) {
630
+ if (!(err instanceof ForgeError) || err.code !== 'unauthorized') throw err;
631
+ // The forge refused the token: renew it (when it can be) and ask once more; else its refusal stands
632
+ if (!(await renewNow())) throw err;
633
+ }
634
+ return forge[prop](...args);
635
+ };
636
+ },
637
+ });
449
638
  }
450
639
 
451
640
  async function callback(req, res, url) {
@@ -457,12 +646,12 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
457
646
  if (url.searchParams.get('error')) return fail(url.searchParams.get('error') === 'access_denied' ? `The sign-in was cancelled on ${forgeName}.` : `${forgeName} did not sign you in.`);
458
647
  const code = url.searchParams.get('code');
459
648
  if (!code || code.length > 1000) return fail(`${forgeName} came back without a sign-in code.`);
460
- let token;
649
+ let given;
461
650
  let user;
462
651
  let permission;
463
652
  try {
464
- token = await exchange(code, checked.verifier);
465
- const forge = forgeWith(token);
653
+ given = await exchange(code, checked.verifier);
654
+ const forge = forgeWith(given.accessToken);
466
655
  user = await forge.me();
467
656
  permission = await forge.permission(forgeConfig.repo, user.login);
468
657
  } catch (err) {
@@ -470,14 +659,17 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
470
659
  }
471
660
  const value = random();
472
661
  const at = now();
662
+ const token = given.accessToken;
473
663
  const file = await store();
474
664
  for (const old of file.prepare('SELECT * FROM sessions WHERE expires_at <= ?').all(iso(at))) await endRow(/** @type {SessionRow} */ (old));
475
665
  // Only a maintainer's token is kept; anyone else's is revoked at once (they are asked again at their next sign-in)
476
666
  const keep = isMaintainer(permission);
477
- file.prepare('INSERT INTO sessions (id, forge, user_id, login, token, permission, created_at, checked_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
478
- .run(macOf(keys.mac, value), forgeConfig.type, String(user.id), user.login, keep ? sealToken(keys.enc, token) : NO_TOKEN, permission, iso(at), iso(at), iso(at + SESSION_MS));
667
+ const id = macOf(keys.mac, value);
668
+ file.prepare('INSERT INTO sessions (id, forge, user_id, login, token, permission, created_at, checked_at, expires_at, refresh_token, token_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
669
+ .run(id, forgeConfig.type, String(user.id), user.login, keep ? sealFor(id, 'token', token) : NO_TOKEN, permission, iso(at), iso(at), iso(at + sessionMs),
670
+ keep && given.refreshToken !== '' ? sealFor(id, 'refresh_token', given.refreshToken) : '', keep ? given.expiresAt : null);
479
671
  if (!keep) await revoke(token);
480
- redirect(res, '/', [cookie(SESSION_COOKIE, value, { maxAge: SESSION_MS / 1000, secure }), clearState]);
672
+ redirect(res, '/', [cookie(SESSION_COOKIE, value, { maxAge: sessionMs / 1000, secure }), clearState]);
481
673
  }
482
674
 
483
675
  async function authRoute(req, res, reqPath, url) {
@@ -575,9 +767,9 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
575
767
  },
576
768
  /** The forge as a signed-in user, with their own token (for the routes that act on the forge) */
577
769
  forgeFor(session) {
578
- const token = session.row.token === NO_TOKEN ? null : openToken(keys.enc, session.row.token);
770
+ const token = session.row.token === NO_TOKEN ? null : openFor(session.row, 'token');
579
771
  if (token === null) throw new ForgeError(`${forgeName} sign-in has to be renewed; sign in again.`, { code: 'unauthorized', forge: forgeName });
580
- return forgeWith(token);
772
+ return managedForge(session.row);
581
773
  },
582
774
  /**
583
775
  * The forge as a user who may not be at the dashboard now, with the token their newest live sign-in keeps (a
@@ -589,9 +781,9 @@ export function createAuth(config, { env = process.env, now = Date.now, fetch: d
589
781
  */
590
782
  async forgeForUser(userId) {
591
783
  const row = /** @type {SessionRow | undefined} */ ((await store()).prepare('SELECT * FROM sessions WHERE user_id = ? AND token <> ? AND expires_at > ? ORDER BY created_at DESC, rowid DESC LIMIT 1').get(String(userId), NO_TOKEN, iso(now())));
592
- const token = row ? openToken(keys.enc, row.token) : null;
784
+ const token = row ? openFor(row, 'token') : null;
593
785
  if (token === null) throw new ForgeError(`The token's owner has no live sign-in to ${forgeName}; they sign in to cornfield again, then the token works again.`, { code: 'unauthorized', forge: forgeName });
594
- return forgeWith(token);
786
+ return managedForge(/** @type {SessionRow} */ (row));
595
787
  },
596
788
  /** The SQLite file (opened on first use), for the routes that keep answers in it (server/forge-routes.js) */
597
789
  store,
package/server/db.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * hook_seen the (pull request, head, proposal) triples whose `spec.ready` event was sent, so a restart sends none twice;
13
13
  * a row with `pr = 0` says the repo has been looked at once (server/hooks.js)
14
14
  * landings the spec PRs an approval is waiting to land (`proposals.approveLands`), so a restart picks them up (server/lander.js)
15
+ * sessions also keep a forge refresh token, the access token's expiry and when `session.expiring` was sent (migration 5)
15
16
  *
16
17
  * The schema is a list of numbered migrations (`MIGRATIONS`); the file's `PRAGMA user_version` is the number of
17
18
  * the last one it has. `openStore` runs the ones a file lacks, each in its own transaction, so a fresh file and one
@@ -125,6 +126,13 @@ export const MIGRATIONS = Object.freeze([
125
126
  PRIMARY KEY (repo, pull)
126
127
  ) STRICT;
127
128
  `,
129
+ // 5: long sessions (server/auth.js, server/hooks.js): the forge's refresh token (encrypted, like the access token) and
130
+ // when the access token expires, so an expiring forge token is renewed; and when \`session.expiring\` was sent for a session
131
+ `
132
+ ALTER TABLE sessions ADD COLUMN refresh_token TEXT NOT NULL DEFAULT ''; -- the forge's refresh token, encrypted like \`token\`; '' when there is none
133
+ ALTER TABLE sessions ADD COLUMN token_expires_at TEXT; -- when the access token stops working; NULL when it does not expire
134
+ ALTER TABLE sessions ADD COLUMN expiring_sent_at TEXT; -- when the \`session.expiring\` hook event was sent; NULL before
135
+ `,
128
136
  ]);
129
137
 
130
138
  /** The version a file is at: its `user_version` */
package/server/hooks.js CHANGED
@@ -13,6 +13,8 @@
13
13
  * spec.approved a maintainer approved a proposal spec.sentBack a maintainer sent one back
14
14
  * build.opened the build issue of an approved proposal was opened
15
15
  * pr.landed a pull request was landed through cornfield (its Land it); a merge made on the forge sends nothing
16
+ * session.expiring a maintainer's sign-in ends within a day (`watchSessions`, below). Its body is its own:
17
+ * `{ event, at, actor, expiresAt, mapUrl }`
16
18
  *
17
19
  * **A hook never gets in the way.** `emit` returns at once and never throws; the delivery runs in the background, from a
18
20
  * queue in memory. A failed delivery (a network error, a timeout, anything but a 2xx) is tried again after 1s, 5s and 30s
@@ -49,6 +51,9 @@ export const BACKOFF_MS = Object.freeze([1_000, 5_000, 30_000]);
49
51
  export const TIMEOUT_MS = 10_000;
50
52
  /** How much of an answer is read before it is dropped */
51
53
  export const MAX_RESPONSE_BYTES = 4096;
54
+ /** How often live sessions are looked at for `session.expiring`, and how far ahead of the end the event is sent */
55
+ export const SESSION_CHECK_MS = 60 * 60 * 1000;
56
+ export const EXPIRING_WITHIN_MS = 24 * 60 * 60 * 1000;
52
57
  /** The most deliveries waiting (or being retried) at once; one past it is dropped, and the audit table says so */
53
58
  export const MAX_PENDING = 500;
54
59
 
@@ -179,6 +184,8 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
179
184
 
180
185
  /** The body of an event: only the fields of the payload, in a fixed order */
181
186
  function bodyOf(event, p) {
187
+ // A session's end has no spec, pull request or proposal: its body is only who, until when, and where cornfield is
188
+ if (event === 'session.expiring') return JSON.stringify({ event, at: new Date(now()).toISOString(), actor: p.actor ?? null, expiresAt: p.expiresAt ?? null, mapUrl: publicUrl || null });
182
189
  const out = {
183
190
  event, at: new Date(now()).toISOString(), slug: p.slug ?? null, pull: p.pull ?? null, headSha: p.headSha ?? null,
184
191
  proposalSha256: p.proposalSha256 ?? null, actor: p.actor ?? null,
@@ -254,13 +261,42 @@ export function createHooks({ hooks, secret, fetch: doFetch = globalThis.fetch,
254
261
  })().catch((err) => console.error(`[cornfield] the spec PRs could not be checked for spec.ready: ${err instanceof Error ? err.message : err}`));
255
262
  }
256
263
 
264
+ /**
265
+ * `session.expiring`: once an hour (and once now), the live sessions of maintainers (the ones that keep a forge token)
266
+ * that end within 24 hours are announced, each once: the row's `expiring_sent_at` is set before the event is
267
+ * emitted, so a restart sends none twice (and a delivery lost for good is not tried again later). A session that was
268
+ * signed out, or ended, is not a row any more and is never announced. A user with several live sessions (a laptop and
269
+ * a phone) gets an event for each, naming the same login and that session's own end. Needs the SQLite file, and only
270
+ * runs when a hook asked for the event. The timer is unref'd and stopped by `close`. Returns at once, never throws.
271
+ */
272
+ function watchSessions() {
273
+ if (closed || !store || !targets.some((t) => t.events.includes('session.expiring'))) return;
274
+ const check = async () => {
275
+ const db = await store();
276
+ const at = now();
277
+ const rows = db.prepare("SELECT id, login, expires_at FROM sessions WHERE token <> '' AND expiring_sent_at IS NULL AND expires_at > ? AND expires_at <= ? ORDER BY expires_at")
278
+ .all(new Date(at).toISOString(), new Date(at + EXPIRING_WITHIN_MS).toISOString());
279
+ const mark = db.prepare('UPDATE sessions SET expiring_sent_at = ? WHERE id = ? AND expiring_sent_at IS NULL');
280
+ for (const r of rows) {
281
+ if (mark.run(new Date(at).toISOString(), r.id).changes === 1) emit('session.expiring', { actor: r.login, expiresAt: r.expires_at });
282
+ }
283
+ };
284
+ const tick = () => {
285
+ if (closed) return;
286
+ check()
287
+ .catch((err) => console.error(`[cornfield] the sessions could not be checked for session.expiring: ${err instanceof Error ? err.message : err}`))
288
+ .finally(() => { if (!closed) later(tick, SESSION_CHECK_MS); });
289
+ };
290
+ later(tick, 0);
291
+ }
292
+
257
293
  function close() {
258
294
  closed = true;
259
295
  for (const t of timers) timer.clearTimeout(t);
260
296
  timers.clear();
261
297
  }
262
298
 
263
- return { emit, specsSeen, close };
299
+ return { emit, specsSeen, watchSessions, close };
264
300
  }
265
301
 
266
302
  /** The SHA-256 of a proposal file as `read` gives it, or null when it cannot be read (an event still goes out) */
package/server/server.js CHANGED
@@ -141,6 +141,7 @@ export function createCornfieldServer(config, opts = {}) {
141
141
  const hooks = hookSettings
142
142
  ? createHooks({ hooks: config.hooks, secret: hookSettings.secret, fetch: opts.hookFetch, record: (entry) => auth.record(entry), store: auth.store, now: opts.now, publicUrl: config.publicUrl, timer: opts.hookTimer })
143
143
  : null;
144
+ hooks?.watchSessions();
144
145
  // Landing on approval needs somebody signed in to land as, like the hooks' events: a half-set key is a startup error
145
146
  if (config.approveLands && !auth.enabled) throw new Error('The config says proposals.approveLands, but sign-in is not set up. An approval is a signed-in maintainer\'s, and it lands as them, so without sign-in nothing would ever land: set up sign-in (docs/sign-in.md), or remove approveLands.');
146
147
  const root = config.root;