@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 +1 -1
- package/package.json +1 -1
- package/server/auth.js +224 -32
- package/server/db.js +8 -0
- package/server/hooks.js +37 -1
- package/server/server.js +1 -0
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.
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
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 (
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
/**
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
515
|
+
parsed = JSON.parse(await cappedText(res, MAX_TOKEN_ANSWER));
|
|
441
516
|
} catch (err) {
|
|
442
|
-
|
|
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
|
|
526
|
+
throw new ForgeError(`${forgeName} did not give a token for ${what}.${said}`, { code: 'unauthorized', status: res.status, forge: forgeName });
|
|
447
527
|
}
|
|
448
|
-
|
|
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
|
|
649
|
+
let given;
|
|
461
650
|
let user;
|
|
462
651
|
let permission;
|
|
463
652
|
try {
|
|
464
|
-
|
|
465
|
-
const forge = forgeWith(
|
|
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
|
-
|
|
478
|
-
|
|
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:
|
|
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 :
|
|
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
|
|
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 ?
|
|
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
|
|
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;
|