@bongos/core 1.19.1076 → 1.19.1077
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/.bongos-core.json +31 -26
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +7 -0
- package/docs/api/openapi.json +22 -1
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/auth.js +8 -1
- package/src/bongos/middleware/rate-limit.js +1 -1
- package/src/bongos/routes/auth.js +271 -73
- package/src/module-api.js +1 -1
- package/tests/hub_cookie_host_only.mjs +279 -0
- package/tests/landing_page.mjs +4 -1
- package/tests/session_rename_fallback.mjs +19 -11
- package/tests/subdomain_auth.mjs +17 -15
|
@@ -210,9 +210,41 @@ function renderAccessPendingPage(login, startHref) {
|
|
|
210
210
|
// SESSION_COOKIE_NAMES, because the request-level token extractor there has to
|
|
211
211
|
// accept exactly the names this router sets and clears: two lists would drift,
|
|
212
212
|
// and the only symptom of the drift is a builder being silently signed out.
|
|
213
|
-
// Head = the
|
|
213
|
+
// Head = the name we SET over https: the host-only `__Host-bongos_session`
|
|
214
|
+
// (task 1004357). The second name is what we SET over plain http (localhost dev),
|
|
215
|
+
// where a browser refuses any `__Host-` cookie. Every other name is accepted on
|
|
216
|
+
// read and cleared on write.
|
|
214
217
|
const SESSION_COOKIE = auth.SESSION_COOKIE_NAMES[0];
|
|
215
|
-
const
|
|
218
|
+
const SESSION_COOKIE_INSECURE = auth.SESSION_COOKIE_NAMES[1];
|
|
219
|
+
|
|
220
|
+
function isSecureReq(req) {
|
|
221
|
+
return !!(req.secure || req.headers['x-forwarded-proto'] === 'https');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The one session-cookie name this request should carry.
|
|
225
|
+
function sessionCookieName(req) {
|
|
226
|
+
return isSecureReq(req) ? SESSION_COOKIE : SESSION_COOKIE_INSECURE;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Set the session cookie. ALWAYS host-only — no Domain attribute, on any host
|
|
230
|
+
// (task 1004357, ADR 0145 Finding 1). The hub used to scope it to ".<apex>" so
|
|
231
|
+
// one sign-in spanned builders./status., but a Domain cookie reaches EVERY
|
|
232
|
+
// subdomain, and a project's hall at <handle>.<apex> runs code from the
|
|
233
|
+
// project's own repository: any visiting hub builder handed that code their hub
|
|
234
|
+
// session. The subdomains now receive their own copy through the one-time
|
|
235
|
+
// handoff below (mintHandoffCode / GET /auth/web/handoff) instead.
|
|
236
|
+
function setSessionCookie(req, res, token, expires) {
|
|
237
|
+
res.cookie(sessionCookieName(req), token, {
|
|
238
|
+
httpOnly: true,
|
|
239
|
+
sameSite: 'lax',
|
|
240
|
+
secure: isSecureReq(req),
|
|
241
|
+
path: '/',
|
|
242
|
+
...(expires ? { expires } : {}),
|
|
243
|
+
});
|
|
244
|
+
// A browser arriving with an older session cookie must not keep it — above
|
|
245
|
+
// all the apex-scoped one, which is still being sent to every project hall.
|
|
246
|
+
clearSupersededSessionCookies(req, res);
|
|
247
|
+
}
|
|
216
248
|
|
|
217
249
|
// The session token off a request's cookies, trying each accepted name newest
|
|
218
250
|
// first. Replaces the hand-written `parseCookie(new) || parseCookie(legacy)`
|
|
@@ -225,16 +257,25 @@ function sessionCookieToken(req) {
|
|
|
225
257
|
return null;
|
|
226
258
|
}
|
|
227
259
|
|
|
228
|
-
// Clear every SUPERSEDED session cookie, in both the host-only and
|
|
229
|
-
// forms (a clearCookie only matches the cookie whose domain attribute
|
|
230
|
-
// Called wherever we set the current cookie, so a browser that arrived
|
|
231
|
-
// `gds_session`
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
260
|
+
// Clear every SUPERSEDED session cookie, in both the host-only and the legacy
|
|
261
|
+
// apex-scoped forms (a clearCookie only matches the cookie whose domain attribute
|
|
262
|
+
// it names). Called wherever we set the current cookie, so a browser that arrived
|
|
263
|
+
// with a `gds_session` or an apex-scoped `bongos_session` leaves holding only the
|
|
264
|
+
// current host-only name — otherwise the stale one lingers until its own expiry,
|
|
265
|
+
// every later request carries two tokens, and the apex-scoped one keeps reaching
|
|
266
|
+
// project halls. `{ all: true }` clears the current name too (sign-out).
|
|
267
|
+
function clearSupersededSessionCookies(req, res, { all = false } = {}) {
|
|
268
|
+
const keep = all ? null : sessionCookieName(req);
|
|
269
|
+
const legacy = legacyApexCookieOpts(req);
|
|
270
|
+
for (const name of auth.SESSION_COOKIE_NAMES) {
|
|
271
|
+
if (name === keep) continue;
|
|
272
|
+
if (name.startsWith('__Host-')) {
|
|
273
|
+
// A __Host- cookie only matches a clear that is itself Secure + Path=/.
|
|
274
|
+
res.clearCookie(name, { path: '/', secure: true });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
236
277
|
res.clearCookie(name);
|
|
237
|
-
if (
|
|
278
|
+
if (legacy.domain) res.clearCookie(name, legacy);
|
|
238
279
|
}
|
|
239
280
|
}
|
|
240
281
|
const STATE_COOKIE = 'gds_oauth_state';
|
|
@@ -293,29 +334,138 @@ function reqHost(req) {
|
|
|
293
334
|
return String(raw).toLowerCase().split(',')[0].trim().split(':')[0];
|
|
294
335
|
}
|
|
295
336
|
|
|
296
|
-
// The
|
|
297
|
-
// (not "any *.example.com")
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
337
|
+
// The hub's own hosts — the ones that share one sign-in. Deliberately enumerated
|
|
338
|
+
// (not "any *.example.com"): every OTHER subdomain may be a project's hall
|
|
339
|
+
// running that project's code (task 1004357), or staging.example.com — a
|
|
340
|
+
// SEPARATE process + DB. When COOKIE_APEX is "" (localhost / no configured
|
|
341
|
+
// cookieDomain) the set is EMPTY and there is nothing to share.
|
|
342
|
+
//
|
|
343
|
+
// Nothing is SET with a Domain attribute any more. The set decides two things
|
|
344
|
+
// only: which hosts may be handed a session through the one-time handoff, and
|
|
345
|
+
// where the LEGACY apex-scoped cookies (set before task 1004357) get cleared.
|
|
346
|
+
const HUB_HOSTS = new Set(
|
|
303
347
|
COOKIE_APEX
|
|
304
348
|
? [COOKIE_APEX, 'www.' + COOKIE_APEX, 'builders.' + COOKIE_APEX, 'status.' + COOKIE_APEX]
|
|
305
349
|
: []
|
|
306
350
|
);
|
|
307
351
|
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
function
|
|
313
|
-
if (
|
|
352
|
+
// The Domain attribute the hub USED to scope its cookies to on its own hosts
|
|
353
|
+
// (#726/#734) — kept ONLY so a clearCookie can match and expire those legacy
|
|
354
|
+
// cookies. Never spread into a res.cookie(): that is the bug task 1004357 fixed.
|
|
355
|
+
// Returns a spread-able object.
|
|
356
|
+
function legacyApexCookieOpts(req) {
|
|
357
|
+
if (HUB_HOSTS.has(reqHost(req))) {
|
|
314
358
|
return { domain: '.' + COOKIE_APEX };
|
|
315
359
|
}
|
|
316
360
|
return {};
|
|
317
361
|
}
|
|
318
362
|
|
|
363
|
+
// Host of the pinned sign-in origin (WEB_REDIRECT_ORIGIN): the one host the
|
|
364
|
+
// GitHub callback lands on, so the one host whose cookies the handshake reads.
|
|
365
|
+
function canonicalHost() {
|
|
366
|
+
try {
|
|
367
|
+
return WEB_REDIRECT_ORIGIN ? new URL(WEB_REDIRECT_ORIGIN).hostname.toLowerCase() : '';
|
|
368
|
+
} catch {
|
|
369
|
+
return '';
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// A hub host OTHER than the canonical one (builders., status., www.). Its
|
|
374
|
+
// sign-in has to begin on the canonical host and come back via the handoff.
|
|
375
|
+
function isSatelliteHubHost(host) {
|
|
376
|
+
const canon = canonicalHost();
|
|
377
|
+
return !!canon && HUB_HOSTS.has(canon) && HUB_HOSTS.has(host) && host !== canon;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── the one-time session handoff (task 1004357) ──
|
|
381
|
+
// A host-only cookie set on the apex never reaches builders.<apex>, so after a
|
|
382
|
+
// sign-in (which always completes on the apex) the apex hands the subdomain a
|
|
383
|
+
// ONE-TIME code in a redirect, and the subdomain trades it for its own host-only
|
|
384
|
+
// copy of the same session. The code is bound to the one target host, single-use
|
|
385
|
+
// and lives HANDOFF_TTL_MS. It is only ever minted for a HUB_HOSTS member, so a
|
|
386
|
+
// project hall on another subdomain can never be handed one. Same token, not a
|
|
387
|
+
// fresh session: one sign-out revokes it everywhere.
|
|
388
|
+
//
|
|
389
|
+
// In-memory: the hub is one process serving all its hosts, and a code outlives
|
|
390
|
+
// a restart by at most HANDOFF_TTL_MS — a builder caught by one signs in again
|
|
391
|
+
// from the handoff page's door.
|
|
392
|
+
const HANDOFF_TTL_MS = 60 * 1000;
|
|
393
|
+
const HANDOFF_MAX = 10000;
|
|
394
|
+
const handoffCodes = new Map();
|
|
395
|
+
function mintHandoffCode({ token, host, expires, now = Date.now() }) {
|
|
396
|
+
for (const [c, v] of handoffCodes) if (v.until <= now) handoffCodes.delete(c);
|
|
397
|
+
if (handoffCodes.size >= HANDOFF_MAX) handoffCodes.delete(handoffCodes.keys().next().value);
|
|
398
|
+
const code = crypto.randomBytes(32).toString('hex');
|
|
399
|
+
handoffCodes.set(code, { token, host, expires: expires || null, until: now + HANDOFF_TTL_MS });
|
|
400
|
+
return code;
|
|
401
|
+
}
|
|
402
|
+
function redeemHandoffCode(code, host, now = Date.now()) {
|
|
403
|
+
if (typeof code !== 'string' || !/^[0-9a-f]{64}$/.test(code)) return null;
|
|
404
|
+
const v = handoffCodes.get(code);
|
|
405
|
+
if (!v) return null;
|
|
406
|
+
handoffCodes.delete(code); // single-use, even when the host does not match
|
|
407
|
+
if (v.until <= now || v.host !== host) return null;
|
|
408
|
+
return v;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// The API prefix this router is mounted under (/api/gds or /api/bongos).
|
|
412
|
+
function apiBase(req) {
|
|
413
|
+
return req.baseUrl || '/api/bongos';
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// The URL that hands the session to `target` — a validated return URL on a
|
|
417
|
+
// satellite hub host — or null when `target` is anything else. The redirect
|
|
418
|
+
// carries only the code; the path the builder was headed for rides as ?return=
|
|
419
|
+
// and is re-validated (relative paths only) on the far side.
|
|
420
|
+
function handoffUrlFor(req, target, token, expires) {
|
|
421
|
+
let u;
|
|
422
|
+
try { u = new URL(target); } catch { return null; }
|
|
423
|
+
const host = u.hostname.toLowerCase();
|
|
424
|
+
if (u.protocol !== 'https:' || u.port || !isSatelliteHubHost(host)) return null;
|
|
425
|
+
const code = mintHandoffCode({ token, host, expires });
|
|
426
|
+
const back = (u.pathname || '/') + (u.search || '');
|
|
427
|
+
return `https://${host}${apiBase(req)}/auth/web/handoff?code=${code}&return=${encodeURIComponent(back)}`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// The live session token (and its expiry) this request carries, or null.
|
|
431
|
+
async function liveSessionToken(req) {
|
|
432
|
+
for (const token of auth.extractTokens(req)) {
|
|
433
|
+
const session = await db.lookupSession(token);
|
|
434
|
+
if (session) return { token, expires: session.expires_at || null };
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Move a browser still holding a pre-1004357 session cookie onto the host-only
|
|
440
|
+
// one (task 1004357). Every builder signed in before this shipped carries an
|
|
441
|
+
// apex-scoped `bongos_session` that their browser still sends to every project
|
|
442
|
+
// hall; waiting for them to sign in again would leave it live for the session's
|
|
443
|
+
// whole lifetime. So on the first https request that carries an old name and not
|
|
444
|
+
// the current one, re-issue the same token host-only and expire the old cookie
|
|
445
|
+
// in both forms — or, when no old token is live any more, just expire it. A
|
|
446
|
+
// lookup that THROWS changes nothing: never sign a builder out on a DB blip.
|
|
447
|
+
async function migrateLegacySessionCookie(req, res, next) {
|
|
448
|
+
try {
|
|
449
|
+
if (isSecureReq(req) && !parseCookie(req, SESSION_COOKIE)
|
|
450
|
+
&& auth.SESSION_COOKIE_NAMES.slice(1).some((n) => parseCookie(req, n))) {
|
|
451
|
+
const live = await liveSessionToken(req);
|
|
452
|
+
if (live) setSessionCookie(req, res, live.token, live.expires);
|
|
453
|
+
else clearSupersededSessionCookies(req, res, { all: true });
|
|
454
|
+
}
|
|
455
|
+
} catch (err) {
|
|
456
|
+
log.error('[auth] legacy session-cookie migration failed (non-blocking)', err);
|
|
457
|
+
}
|
|
458
|
+
next();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// A Discord link begun on a satellite hub host restarts on the canonical host,
|
|
462
|
+
// where its callback lands and its host-only state cookie can be read back.
|
|
463
|
+
function discordStartOnCanonicalHost(req, res, next) {
|
|
464
|
+
if (!isSatelliteHubHost(reqHost(req))) return next();
|
|
465
|
+
const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
|
|
466
|
+
return res.redirect(`${WEB_REDIRECT_ORIGIN.replace(/\/+$/, '')}${apiBase(req)}/auth/discord/start${qs}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
319
469
|
// Validate a caller-supplied post-sign-in return target. Accept ONLY:
|
|
320
470
|
// - a relative path beginning with a single '/' (not '//', no backslash), or
|
|
321
471
|
// - an absolute https URL whose host is the apex or a *.example.com sub.
|
|
@@ -433,6 +583,9 @@ module.exports = function buildAuthRouter() {
|
|
|
433
583
|
registerSeams();
|
|
434
584
|
const router = express.Router();
|
|
435
585
|
|
|
586
|
+
// Runs ahead of every API route on this router (task 1004357) — see the helper.
|
|
587
|
+
router.use(migrateLegacySessionCookie);
|
|
588
|
+
|
|
436
589
|
// /auth/device — CLI flow
|
|
437
590
|
// rank: public — auth flow itself; identity establishment, not authorization.
|
|
438
591
|
router.post('/auth/device/start', async (_req, res) => {
|
|
@@ -524,12 +677,43 @@ module.exports = function buildAuthRouter() {
|
|
|
524
677
|
|
|
525
678
|
// /auth/web — browser flow (used by the /builders page).
|
|
526
679
|
// rank: public — entry into the GitHub OAuth handshake.
|
|
527
|
-
router.get('/auth/web/start', (req, res) => {
|
|
680
|
+
router.get('/auth/web/start', async (req, res) => {
|
|
528
681
|
// Either direct-GitHub creds OR platform-IdP federation must be configured
|
|
529
682
|
// (ADR 0141 §1) — a federated instance has no GitHub app of its own.
|
|
530
683
|
if (!auth.authConfigured() && !auth.idpConfigured()) {
|
|
531
684
|
return res.status(503).type('text/plain').send('auth not configured');
|
|
532
685
|
}
|
|
686
|
+
const wantsRepos = req.query.repos === '1' || req.query.repos === 'true';
|
|
687
|
+
// A sign-in begun on a satellite hub host (builders./status.) restarts on
|
|
688
|
+
// the canonical host (task 1004357). The handshake cookies are host-only now,
|
|
689
|
+
// and the callback always lands on the canonical host, so a state cookie set
|
|
690
|
+
// here could never be read back there. The return target is made absolute
|
|
691
|
+
// first, so the builder still lands back on THIS host — via the handoff.
|
|
692
|
+
const host = reqHost(req);
|
|
693
|
+
if (isSatelliteHubHost(host)) {
|
|
694
|
+
const raw = safeReturnTo(req.query.return) || safeReturnTo(req.headers.referer) || '/';
|
|
695
|
+
const back = raw.startsWith('/') ? `https://${host}${raw}` : raw;
|
|
696
|
+
const params = new URLSearchParams({ return: back });
|
|
697
|
+
if (wantsRepos) params.set('repos', '1');
|
|
698
|
+
return res.redirect(`${WEB_REDIRECT_ORIGIN.replace(/\/+$/, '')}${apiBase(req)}/auth/web/start?${params}`);
|
|
699
|
+
}
|
|
700
|
+
// Already signed in on the canonical host and headed for a satellite hub
|
|
701
|
+
// host (a builder who signed in on the apex, then opened builders.): no
|
|
702
|
+
// GitHub round-trip, just hand that host its copy. A repo-scope sign-in
|
|
703
|
+
// still goes to GitHub — it exists to ask for the broader grant.
|
|
704
|
+
if (!wantsRepos) {
|
|
705
|
+
const target = safeReturnTo(req.query.return);
|
|
706
|
+
if (target && !target.startsWith('/')) {
|
|
707
|
+
try {
|
|
708
|
+
const live = await liveSessionToken(req);
|
|
709
|
+
const url = live && handoffUrlFor(req, target, live.token, live.expires);
|
|
710
|
+
if (url) return res.redirect(url);
|
|
711
|
+
} catch (err) {
|
|
712
|
+
// A lookup failure falls through to an ordinary sign-in.
|
|
713
|
+
log.error('[auth] web/start: session lookup failed', err);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
533
717
|
const state = crypto.randomBytes(16).toString('hex');
|
|
534
718
|
// redirect_uri is PINNED to the canonical origin (#734) — never derived from
|
|
535
719
|
// the request host. GitHub only knows the apex callback, so a sign-in begun
|
|
@@ -541,9 +725,9 @@ module.exports = function buildAuthRouter() {
|
|
|
541
725
|
sameSite: 'lax',
|
|
542
726
|
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
|
|
543
727
|
maxAge: HANDSHAKE_COOKIE_MS,
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
|
|
728
|
+
// Host-only (task 1004357). It used to be apex-scoped (#734) so a start on
|
|
729
|
+
// a subdomain could reach the apex callback; that start now restarts on
|
|
730
|
+
// the canonical host instead (above), so the state never crosses a host.
|
|
547
731
|
});
|
|
548
732
|
// Post-sign-in return target (#726/#734): an explicit ?return= wins (set by
|
|
549
733
|
// the page gates); otherwise fall back to the Referer (the page that kicked
|
|
@@ -557,22 +741,19 @@ module.exports = function buildAuthRouter() {
|
|
|
557
741
|
sameSite: 'lax',
|
|
558
742
|
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
|
|
559
743
|
maxAge: HANDSHAKE_COOKIE_MS,
|
|
560
|
-
...sessionCookieDomainOpts(req),
|
|
561
744
|
});
|
|
562
745
|
}
|
|
563
746
|
// Opt-in repo access (task 1002099 / ADR 0143): the onboarding wizard passes
|
|
564
747
|
// ?repos=1 so it can list/create the user's repos. Remember the intent in a
|
|
565
748
|
// short-lived cookie (same recipe as the state cookie) and request the broader
|
|
566
749
|
// scope on the direct-GitHub leg. Absent the flag this is a no-op — hall + CLI
|
|
567
|
-
// sign-ins stay identity-only.
|
|
568
|
-
const wantsRepos = req.query.repos === '1' || req.query.repos === 'true';
|
|
750
|
+
// sign-ins stay identity-only. (wantsRepos is read at the top of the handler.)
|
|
569
751
|
if (wantsRepos) {
|
|
570
752
|
res.cookie(REPOS_COOKIE, '1', {
|
|
571
753
|
httpOnly: true,
|
|
572
754
|
sameSite: 'lax',
|
|
573
755
|
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
|
|
574
756
|
maxAge: HANDSHAKE_COOKIE_MS,
|
|
575
|
-
...sessionCookieDomainOpts(req),
|
|
576
757
|
});
|
|
577
758
|
}
|
|
578
759
|
// Federate through the hub when configured (ADR 0141 §1); otherwise the direct
|
|
@@ -583,6 +764,32 @@ module.exports = function buildAuthRouter() {
|
|
|
583
764
|
: auth.buildAuthorizeUrl(state, redirectUri, { repos: wantsRepos }));
|
|
584
765
|
});
|
|
585
766
|
|
|
767
|
+
// GET /auth/web/handoff?code=&return= — the far side of the one-time session
|
|
768
|
+
// handoff (task 1004357). The canonical host minted `code` for THIS host after
|
|
769
|
+
// a sign-in (or for a builder already signed in there); trading it sets this
|
|
770
|
+
// host's own host-only copy of the session, then lands on `return` — a
|
|
771
|
+
// relative path only, so the handoff can never become an open redirect.
|
|
772
|
+
//
|
|
773
|
+
// rank: public — auth establishment; the one-time code is the bearer, bound to
|
|
774
|
+
// this exact host, single-use and alive for HANDOFF_TTL_MS.
|
|
775
|
+
router.get('/auth/web/handoff', (req, res) => {
|
|
776
|
+
res.set('Cache-Control', 'no-store');
|
|
777
|
+
// The URL carries a bearer code: never let it ride out in a Referer.
|
|
778
|
+
res.set('Referrer-Policy', 'no-referrer');
|
|
779
|
+
const host = reqHost(req);
|
|
780
|
+
const grant = isSatelliteHubHost(host) ? redeemHandoffCode(req.query.code, host) : null;
|
|
781
|
+
if (!grant) {
|
|
782
|
+
return res.status(400).type('text/html').send(renderAuthErrorPage(
|
|
783
|
+
'Sign-in link expired',
|
|
784
|
+
`This one-time sign-in link was already used, has expired (they last ${HANDOFF_TTL_MS / 1000} seconds) or belongs to another address. Sign in again.`,
|
|
785
|
+
{ startHref: `${apiBase(req)}/auth/web/start` },
|
|
786
|
+
));
|
|
787
|
+
}
|
|
788
|
+
setSessionCookie(req, res, grant.token, grant.expires ? new Date(grant.expires) : undefined);
|
|
789
|
+
const back = safeReturnTo(req.query.return);
|
|
790
|
+
res.redirect(back && back.startsWith('/') ? back : '/');
|
|
791
|
+
});
|
|
792
|
+
|
|
586
793
|
// GET /auth/web/admission-status?login=X — the probe renderAccessPendingPage
|
|
587
794
|
// polls (task 1002097). Answers ONE question: would this login be admitted
|
|
588
795
|
// right now? It mirrors the real gate via auth.webAdmissionStatus so the page
|
|
@@ -667,20 +874,10 @@ module.exports = function buildAuthRouter() {
|
|
|
667
874
|
const { token, expiresAt, builder, githubToken, githubScope } = auth.idpConfigured()
|
|
668
875
|
? await auth.completeIdpFlow({ code, redirectUri, userAgent: req.headers['user-agent'] })
|
|
669
876
|
: await auth.completeWebFlow({ code, redirectUri, userAgent: req.headers['user-agent'], captureRepoToken: wantsRepos });
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
path: '/',
|
|
675
|
-
expires: expiresAt,
|
|
676
|
-
// Apex-scoped on the production hosts so one sign-in spans
|
|
677
|
-
// example.com + builders. + status. (#726); host-only elsewhere.
|
|
678
|
-
...sessionCookieDomainOpts(req),
|
|
679
|
-
});
|
|
680
|
-
// A browser arriving with an older session name must not keep it: two
|
|
681
|
-
// session cookies means every later request carries two candidate tokens,
|
|
682
|
-
// and the stale one outlives this sign-in.
|
|
683
|
-
clearSupersededSessionCookies(req, res);
|
|
877
|
+
// Host-only on every host (task 1004357) — setSessionCookie also clears
|
|
878
|
+
// every older name and the legacy apex-scoped copy, so a browser arriving
|
|
879
|
+
// with either leaves holding only the host-only cookie.
|
|
880
|
+
setSessionCookie(req, res, token, expiresAt);
|
|
684
881
|
// Persist the wizard's repo token (task 1002099 / ADR 0143): encrypted at rest,
|
|
685
882
|
// short-TTL, keyed to the builder — the repo picker/creator reads it back over
|
|
686
883
|
// the next few minutes. Gated on secret-box being configured (upsertGithubToken
|
|
@@ -692,10 +889,9 @@ module.exports = function buildAuthRouter() {
|
|
|
692
889
|
log.error('[gds] web/callback: repo-token capture failed (non-blocking):', e.message);
|
|
693
890
|
}
|
|
694
891
|
}
|
|
695
|
-
// The
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
const domainOpts = sessionCookieDomainOpts(req);
|
|
892
|
+
// The handshake cookies are host-only now (task 1004357). Clear the legacy
|
|
893
|
+
// apex-scoped form too, for a handshake begun before that change.
|
|
894
|
+
const domainOpts = legacyApexCookieOpts(req);
|
|
699
895
|
res.clearCookie(STATE_COOKIE);
|
|
700
896
|
res.clearCookie(STATE_COOKIE_LEGACY);
|
|
701
897
|
res.clearCookie(REPOS_COOKIE);
|
|
@@ -709,8 +905,12 @@ module.exports = function buildAuthRouter() {
|
|
|
709
905
|
// configured post-login landing (default '/builders'; cloudbongos.com →
|
|
710
906
|
// '/', its landing page — task 1002240).
|
|
711
907
|
const returnTo = safeReturnTo(parseCookie(req, RETURN_COOKIE));
|
|
712
|
-
res.clearCookie(RETURN_COOKIE
|
|
713
|
-
res.
|
|
908
|
+
res.clearCookie(RETURN_COOKIE);
|
|
909
|
+
if (domainOpts.domain) res.clearCookie(RETURN_COOKIE, domainOpts);
|
|
910
|
+
// Headed for builders./status.? Their cookie cannot be set from here, so
|
|
911
|
+
// hand them the session through the one-time code (task 1004357).
|
|
912
|
+
const handoff = returnTo && !returnTo.startsWith('/') && handoffUrlFor(req, returnTo, token, expiresAt);
|
|
913
|
+
res.redirect(handoff || returnTo || postLoginLanding());
|
|
714
914
|
} catch (err) {
|
|
715
915
|
// Approval gate (2026-05-30): a first-time GitHub account that hasn't been
|
|
716
916
|
// approved by an Archon can't enter. Send them to the hall to request
|
|
@@ -804,7 +1004,7 @@ module.exports = function buildAuthRouter() {
|
|
|
804
1004
|
// (503) until the server has Discord client creds. Mirrors the GitHub web
|
|
805
1005
|
// flow's CSRF-state discipline above — a hard requirement (ADR 0033 §2).
|
|
806
1006
|
// rank: any-builder — links to your OWN identity; no rank gate.
|
|
807
|
-
router.get('/auth/discord/start', auth.requireBuilder, (req, res) => {
|
|
1007
|
+
router.get('/auth/discord/start', discordStartOnCanonicalHost, auth.requireBuilder, (req, res) => {
|
|
808
1008
|
if (!auth.discordConfigured()) {
|
|
809
1009
|
return res.status(503).type('text/plain').send('Discord linking not configured on this server yet.');
|
|
810
1010
|
}
|
|
@@ -815,12 +1015,9 @@ module.exports = function buildAuthRouter() {
|
|
|
815
1015
|
sameSite: 'lax',
|
|
816
1016
|
secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
|
|
817
1017
|
maxAge: 10 * 60 * 1000,
|
|
818
|
-
//
|
|
819
|
-
// link
|
|
820
|
-
//
|
|
821
|
-
// dropped on the hop and the state check would fail ("Link handshake
|
|
822
|
-
// expired"). Mirrors the GitHub web-flow state cookie above.
|
|
823
|
-
...sessionCookieDomainOpts(req),
|
|
1018
|
+
// Host-only (task 1004357). DISCORD_REDIRECT_URI always lands the callback
|
|
1019
|
+
// on the canonical host, so a link begun on builders. first restarts
|
|
1020
|
+
// there (discordStartOnCanonicalHost) — the state never crosses a host.
|
|
824
1021
|
});
|
|
825
1022
|
res.redirect(auth.buildDiscordAuthorizeUrl(state, redirectUri));
|
|
826
1023
|
});
|
|
@@ -832,10 +1029,8 @@ module.exports = function buildAuthRouter() {
|
|
|
832
1029
|
}
|
|
833
1030
|
const expectedState = parseCookie(req, DISCORD_STATE_COOKIE);
|
|
834
1031
|
const { state, code, error } = req.query;
|
|
835
|
-
//
|
|
836
|
-
|
|
837
|
-
// cookie set before this change.
|
|
838
|
-
const stateDomainOpts = sessionCookieDomainOpts(req);
|
|
1032
|
+
// Host-only now (task 1004357); clear the legacy apex-scoped form too.
|
|
1033
|
+
const stateDomainOpts = legacyApexCookieOpts(req);
|
|
839
1034
|
res.clearCookie(DISCORD_STATE_COOKIE);
|
|
840
1035
|
if (stateDomainOpts.domain) res.clearCookie(DISCORD_STATE_COOKIE, stateDomainOpts);
|
|
841
1036
|
// CSRF FIRST: validate the state cookie against the state Discord echoes
|
|
@@ -962,15 +1157,12 @@ module.exports = function buildAuthRouter() {
|
|
|
962
1157
|
const token = (req.headers['authorization'] || '').replace(/^Bearer\s+/, '')
|
|
963
1158
|
|| sessionCookieToken(req);
|
|
964
1159
|
if (token) await db.revokeSession(token);
|
|
965
|
-
// Clear
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
// …and every superseded name, or a sign-out leaves the older cookie behind
|
|
972
|
-
// and the next request re-authenticates on it.
|
|
973
|
-
clearSupersededSessionCookies(req, res);
|
|
1160
|
+
// Clear every session name, host-only AND the legacy apex-scoped form — a
|
|
1161
|
+
// clearCookie only matches the cookie whose domain attribute it names, and a
|
|
1162
|
+
// sign-out that left one behind would re-authenticate the next request.
|
|
1163
|
+
// The token itself is revoked above, which also kills the copies the
|
|
1164
|
+
// handoff gave the hub's other hosts (they share it).
|
|
1165
|
+
clearSupersededSessionCookies(req, res, { all: true });
|
|
974
1166
|
// Federated single-logout (ADR 0144, task 1002238): announce the logout so the
|
|
975
1167
|
// hub (platform-identity, enabled only on cloudbongos.com) can fan a signed
|
|
976
1168
|
// back-channel logout out to every federated project this builder entered. A
|
|
@@ -1170,7 +1362,13 @@ module.exports.safeReturnTo = safeReturnTo;
|
|
|
1170
1362
|
module.exports.postLoginLanding = postLoginLanding;
|
|
1171
1363
|
module.exports.recoveryStartHref = recoveryStartHref;
|
|
1172
1364
|
module.exports.HANDSHAKE_COOKIE_MS = HANDSHAKE_COOKIE_MS;
|
|
1173
|
-
module.exports.
|
|
1365
|
+
module.exports.legacyApexCookieOpts = legacyApexCookieOpts;
|
|
1366
|
+
module.exports.setSessionCookie = setSessionCookie;
|
|
1367
|
+
module.exports.sessionCookieName = sessionCookieName;
|
|
1368
|
+
module.exports.mintHandoffCode = mintHandoffCode;
|
|
1369
|
+
module.exports.redeemHandoffCode = redeemHandoffCode;
|
|
1370
|
+
module.exports.handoffUrlFor = handoffUrlFor;
|
|
1371
|
+
module.exports.HANDOFF_TTL_MS = HANDOFF_TTL_MS;
|
|
1174
1372
|
module.exports.reqHost = reqHost;
|
|
1175
1373
|
// The auth-error page renderer (task 1439): pure over the branding pack, so a
|
|
1176
1374
|
// test can prove every visible string is brand-owned (no OTB lore baked in).
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.19.
|
|
78
|
+
const CORE_VERSION = '1.19.1077'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|