@antasphere/chassis-server 1.2.0 → 1.4.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/dist/app.d.ts.map +1 -1
- package/dist/app.js +11 -5
- package/dist/app.js.map +1 -1
- package/dist/boot.d.ts.map +1 -1
- package/dist/boot.js +5 -0
- package/dist/boot.js.map +1 -1
- package/dist/identity/hub-grant.d.ts +70 -0
- package/dist/identity/hub-grant.d.ts.map +1 -1
- package/dist/identity/hub-grant.js +119 -14
- package/dist/identity/hub-grant.js.map +1 -1
- package/dist/identity/index.d.ts +1 -1
- package/dist/identity/index.d.ts.map +1 -1
- package/dist/identity/index.js +1 -1
- package/dist/identity/index.js.map +1 -1
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.d.ts.map +1 -1
- package/dist/middleware/index.js +1 -0
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/static-assets.d.ts +63 -0
- package/dist/middleware/static-assets.d.ts.map +1 -0
- package/dist/middleware/static-assets.js +125 -0
- package/dist/middleware/static-assets.js.map +1 -0
- package/package.json +3 -3
- package/src/app.ts +17 -4
- package/src/boot.ts +5 -0
- package/src/identity/hub-grant.ts +187 -15
- package/src/identity/index.ts +6 -1
- package/src/middleware/index.ts +11 -0
- package/src/middleware/static-assets.ts +144 -0
- package/test/integration/hub-grant.test.ts +97 -0
- package/test/unit/hub-grant-refusal.test.ts +29 -0
- package/test/unit/hub-grant-sweep.test.ts +151 -0
- package/test/unit/static-assets.test.ts +227 -0
|
@@ -103,6 +103,46 @@ export const DEFAULT_GRANT_DIALS: HubGrantDials = {
|
|
|
103
103
|
lockWatchdogMs: 12_000
|
|
104
104
|
};
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The background renewal of grants about to expire (PRDCT-3584). Hub access
|
|
108
|
+
* tokens live 15 minutes, so a person who comes back to a tool after a
|
|
109
|
+
* coffee met the token endpoint INSIDE their first request: the refresh
|
|
110
|
+
* (the hub's rotation and the row write) stood between the click and the
|
|
111
|
+
* page. The sweep renews, off any request, the token of every person seen
|
|
112
|
+
* on this replica within `activeWithinMs` whose cached token expires within
|
|
113
|
+
* `renewAheadMs`; a person not seen for an hour is forgotten and renews on
|
|
114
|
+
* demand as before. It goes through `renew()`, the same acquisition as a
|
|
115
|
+
* request's (the single-flight, the advisory lock, the re-read and the
|
|
116
|
+
* presentation ledger stay the only writers of the refresh chain), with two
|
|
117
|
+
* differences the verifier of 18 October 2026 asked for: the cached token is
|
|
118
|
+
* served until the rotation's result is known, and a sibling replica's
|
|
119
|
+
* fresh token is adopted instead of rotated again. The timer never holds
|
|
120
|
+
* the process open and skips while the replica drains.
|
|
121
|
+
*/
|
|
122
|
+
export interface GrantSweepDials {
|
|
123
|
+
/** How often the sweep runs. */
|
|
124
|
+
intervalMs: number;
|
|
125
|
+
/** Renew a cached token this long before it expires. */
|
|
126
|
+
renewAheadMs: number;
|
|
127
|
+
/** Only people seen on this replica within this window are renewed. */
|
|
128
|
+
activeWithinMs: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const DEFAULT_GRANT_SWEEP_DIALS: GrantSweepDials = {
|
|
132
|
+
intervalMs: 60_000,
|
|
133
|
+
renewAheadMs: 3 * 60_000,
|
|
134
|
+
activeWithinMs: 60 * 60_000
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** What `startSweep` hands back: the timer (never holding the process open) and its stop. */
|
|
138
|
+
export interface GrantSweepHandle {
|
|
139
|
+
timer: NodeJS.Timeout;
|
|
140
|
+
stop(): void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Bound the seen-map — a user-id flood must never balloon memory. */
|
|
144
|
+
const MAX_SEEN_USERS = 10_000;
|
|
145
|
+
|
|
106
146
|
/**
|
|
107
147
|
* One answer about a user's hub access.
|
|
108
148
|
* - 'ok': a hub-callable access token.
|
|
@@ -175,12 +215,38 @@ export function isLikelyEncrypted(token: string): boolean {
|
|
|
175
215
|
return token.length % 2 === 0 && /^[0-9a-f]+$/i.test(token);
|
|
176
216
|
}
|
|
177
217
|
|
|
218
|
+
/**
|
|
219
|
+
* What the hub said when it refused: the RFC 6749 `error_description` and
|
|
220
|
+
* the HTTP status, kept for the log line and nothing else (PRDCT-3584). A
|
|
221
|
+
* refusal read as `invalid_grant` has four different causes at the pinned
|
|
222
|
+
* plugin (an unknown token, an expired one, a rotated-out one, a refresh
|
|
223
|
+
* the pre-flight refused), and only this sentence tells them apart.
|
|
224
|
+
*/
|
|
225
|
+
export interface RefusalReason {
|
|
226
|
+
status: number;
|
|
227
|
+
description: string | null;
|
|
228
|
+
}
|
|
229
|
+
|
|
178
230
|
type RefreshOutcome =
|
|
179
231
|
| { kind: 'ok'; accessToken: string; expiresAt: Date; refreshToken: string | null }
|
|
180
|
-
| { kind: 'invalid_grant' }
|
|
181
|
-
| { kind: 'invalid_client' }
|
|
232
|
+
| ({ kind: 'invalid_grant' } & RefusalReason)
|
|
233
|
+
| ({ kind: 'invalid_client' } & RefusalReason)
|
|
182
234
|
| { kind: 'transient' };
|
|
183
235
|
|
|
236
|
+
/** The `error_description` of an RFC 6749 error payload, when it is a non-empty string. */
|
|
237
|
+
export function refusalDescription(payload: unknown): string | null {
|
|
238
|
+
const d = (payload as { error_description?: unknown } | null)?.error_description;
|
|
239
|
+
return typeof d === 'string' && d.trim() ? d.trim().slice(0, 300) : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* How a token is acquired: on `demand` (serve a fresh stored one, else
|
|
244
|
+
* present); by `force` (the hub refused the current one: present, after the
|
|
245
|
+
* cache was dropped by the caller); or as a `renew` (the background sweep:
|
|
246
|
+
* nothing dropped up front, a sibling's fresh stored token adopted).
|
|
247
|
+
*/
|
|
248
|
+
type AcquireMode = 'demand' | 'force' | 'renew';
|
|
249
|
+
|
|
184
250
|
/** The RFC 7662 probe's answer about a refresh token the hub never confirmed consuming. */
|
|
185
251
|
type ProbeOutcome = 'active' | 'inactive' | 'transient';
|
|
186
252
|
|
|
@@ -188,6 +254,8 @@ export class HubGrantService {
|
|
|
188
254
|
/** userId → fresh access token (in-memory only; the row is the durable store). */
|
|
189
255
|
private readonly cache = new Map<string, { token: string; expiresAtMs: number }>();
|
|
190
256
|
private readonly inFlight = new Map<string, Promise<GrantAccess>>();
|
|
257
|
+
/** userId → when this replica last read a token for them (the sweep's population). */
|
|
258
|
+
private readonly lastSeen = new Map<string, number>();
|
|
191
259
|
private readonly tokenUrl: string;
|
|
192
260
|
private readonly introspectUrl: string;
|
|
193
261
|
private readonly fetchImpl: typeof fetch;
|
|
@@ -302,11 +370,12 @@ export class HubGrantService {
|
|
|
302
370
|
|
|
303
371
|
/** A hub-callable access token for the user, refresh-on-demand. */
|
|
304
372
|
async accessToken(userId: string): Promise<GrantAccess> {
|
|
373
|
+
this.markSeen(userId);
|
|
305
374
|
const cached = this.cache.get(userId);
|
|
306
375
|
if (cached && cached.expiresAtMs - this.now() > this.dials.accessSkewMs) {
|
|
307
376
|
return { kind: 'ok', accessToken: cached.token };
|
|
308
377
|
}
|
|
309
|
-
return this.acquire(userId,
|
|
378
|
+
return this.acquire(userId, 'demand');
|
|
310
379
|
}
|
|
311
380
|
|
|
312
381
|
/**
|
|
@@ -318,13 +387,95 @@ export class HubGrantService {
|
|
|
318
387
|
this.cache.delete(userId);
|
|
319
388
|
const existing = this.inFlight.get(userId);
|
|
320
389
|
if (existing) await existing.catch(() => {});
|
|
321
|
-
return this.acquire(userId,
|
|
390
|
+
return this.acquire(userId, 'force');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private markSeen(userId: string): void {
|
|
394
|
+
if (!this.lastSeen.has(userId) && this.lastSeen.size >= MAX_SEEN_USERS) {
|
|
395
|
+
// Drop the oldest entry (Map keeps insertion order); a re-seen user is
|
|
396
|
+
// re-inserted at the end below.
|
|
397
|
+
const oldest = this.lastSeen.keys().next().value;
|
|
398
|
+
if (oldest !== undefined) this.lastSeen.delete(oldest);
|
|
399
|
+
}
|
|
400
|
+
this.lastSeen.delete(userId);
|
|
401
|
+
this.lastSeen.set(userId, this.now());
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* One pass of the background renewal (GrantSweepDials): renew the cached
|
|
406
|
+
* token of every recently seen person whose token expires within the
|
|
407
|
+
* window, one at a time, through `renew()`. Answers how many renewals
|
|
408
|
+
* were attempted. Never throws: a failed renewal is the next request's
|
|
409
|
+
* on-demand refresh, exactly as before the sweep existed.
|
|
410
|
+
*/
|
|
411
|
+
async sweepExpiring(
|
|
412
|
+
dials: Omit<GrantSweepDials, 'intervalMs'> = DEFAULT_GRANT_SWEEP_DIALS
|
|
413
|
+
): Promise<number> {
|
|
414
|
+
const now = this.now();
|
|
415
|
+
let attempted = 0;
|
|
416
|
+
for (const [userId, seenAt] of [...this.lastSeen]) {
|
|
417
|
+
if (now - seenAt > dials.activeWithinMs) {
|
|
418
|
+
this.lastSeen.delete(userId);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const cached = this.cache.get(userId);
|
|
422
|
+
if (!cached || cached.expiresAtMs - now > dials.renewAheadMs) continue;
|
|
423
|
+
if (this.inFlight.has(userId)) continue;
|
|
424
|
+
attempted += 1;
|
|
425
|
+
try {
|
|
426
|
+
const out = await this.renew(userId, dials.renewAheadMs);
|
|
427
|
+
if (out.kind !== 'ok') {
|
|
428
|
+
this.opts.logger.info({ userId, outcome: out.kind }, 'hub grant: background renewal did not renew');
|
|
429
|
+
}
|
|
430
|
+
} catch (err) {
|
|
431
|
+
this.opts.logger.warn(
|
|
432
|
+
{ err, userId },
|
|
433
|
+
'hub grant: background renewal failed — the next request refreshes on demand'
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return attempted;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Run the sweep on a timer that never holds the process open and skips
|
|
442
|
+
* while the replica drains. Returns the function that stops it.
|
|
443
|
+
*/
|
|
444
|
+
startSweep(
|
|
445
|
+
options: { isDraining?: () => boolean; dials?: Partial<GrantSweepDials> } = {}
|
|
446
|
+
): GrantSweepHandle {
|
|
447
|
+
const dials = { ...DEFAULT_GRANT_SWEEP_DIALS, ...options.dials };
|
|
448
|
+
let running = false;
|
|
449
|
+
const timer = setInterval(() => {
|
|
450
|
+
// One pass at a time: a slow hub must never stack passes.
|
|
451
|
+
if (running || options.isDraining?.()) return;
|
|
452
|
+
running = true;
|
|
453
|
+
void this.sweepExpiring(dials).finally(() => {
|
|
454
|
+
running = false;
|
|
455
|
+
});
|
|
456
|
+
}, dials.intervalMs);
|
|
457
|
+
timer.unref();
|
|
458
|
+
return { timer, stop: () => clearInterval(timer) };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Renew the grant in the background (the sweep's path, PRDCT-3584): the
|
|
463
|
+
* cached token keeps being served until the rotation's result is known
|
|
464
|
+
* (nothing is evicted up front, unlike `refresh()`), and a token a sibling
|
|
465
|
+
* replica stored with at least `minLeftMs` of life left is adopted instead
|
|
466
|
+
* of rotating a second time. Joins an acquisition already in flight.
|
|
467
|
+
*/
|
|
468
|
+
async renew(
|
|
469
|
+
userId: string,
|
|
470
|
+
minLeftMs: number = DEFAULT_GRANT_SWEEP_DIALS.renewAheadMs
|
|
471
|
+
): Promise<GrantAccess> {
|
|
472
|
+
return this.acquire(userId, 'renew', minLeftMs);
|
|
322
473
|
}
|
|
323
474
|
|
|
324
|
-
private acquire(userId: string,
|
|
475
|
+
private acquire(userId: string, mode: AcquireMode, minLeftMs?: number): Promise<GrantAccess> {
|
|
325
476
|
let flight = this.inFlight.get(userId);
|
|
326
477
|
if (!flight) {
|
|
327
|
-
flight = this.doAcquire(userId,
|
|
478
|
+
flight = this.doAcquire(userId, mode, minLeftMs).finally(() => {
|
|
328
479
|
if (this.inFlight.get(userId) === flight) this.inFlight.delete(userId);
|
|
329
480
|
});
|
|
330
481
|
this.inFlight.set(userId, flight);
|
|
@@ -332,15 +483,21 @@ export class HubGrantService {
|
|
|
332
483
|
return flight;
|
|
333
484
|
}
|
|
334
485
|
|
|
335
|
-
private async doAcquire(userId: string,
|
|
486
|
+
private async doAcquire(userId: string, mode: AcquireMode, minLeftMs?: number): Promise<GrantAccess> {
|
|
336
487
|
try {
|
|
337
488
|
const first = await this.readRow(userId);
|
|
338
489
|
if (!first) return { kind: 'no_link' };
|
|
339
490
|
if (!first.refreshToken) return { kind: 'grant_dead' };
|
|
340
491
|
|
|
341
|
-
if (
|
|
492
|
+
if (mode === 'demand') {
|
|
342
493
|
const stored = await this.storedAccess(userId, first);
|
|
343
494
|
if (stored) return { kind: 'ok', accessToken: stored };
|
|
495
|
+
} else if (mode === 'renew') {
|
|
496
|
+
// A sibling replica (or a login) already renewed: adopt its token
|
|
497
|
+
// without taking the lock (verifier round 6: the common case on
|
|
498
|
+
// several replicas was paying a dedicated connection for nothing).
|
|
499
|
+
const stored = await this.storedAccess(userId, first, minLeftMs);
|
|
500
|
+
if (stored) return { kind: 'ok', accessToken: stored };
|
|
344
501
|
}
|
|
345
502
|
|
|
346
503
|
// Slow path: rotate under the cross-replica lock.
|
|
@@ -354,9 +511,15 @@ export class HubGrantService {
|
|
|
354
511
|
const row = await this.readRow(userId);
|
|
355
512
|
if (!row) return { kind: 'no_link' };
|
|
356
513
|
if (!row.refreshToken) return { kind: 'grant_dead' };
|
|
357
|
-
if (
|
|
514
|
+
if (mode === 'demand' || row.refreshToken !== first.refreshToken) {
|
|
358
515
|
const stored = await this.storedAccess(userId, row);
|
|
359
516
|
if (stored) return { kind: 'ok', accessToken: stored };
|
|
517
|
+
} else if (mode === 'renew') {
|
|
518
|
+
// A renewal rotates only what is about to expire: a stored token
|
|
519
|
+
// with more life than the renewal window is a sibling replica's
|
|
520
|
+
// fresh one (or a login's), adopted rather than rotated again.
|
|
521
|
+
const stored = await this.storedAccess(userId, row, minLeftMs);
|
|
522
|
+
if (stored) return { kind: 'ok', accessToken: stored };
|
|
360
523
|
}
|
|
361
524
|
|
|
362
525
|
let refreshToken: string;
|
|
@@ -416,7 +579,7 @@ export class HubGrantService {
|
|
|
416
579
|
this.cache.delete(userId);
|
|
417
580
|
this.refreshes.inc({ outcome: 'invalid_grant' });
|
|
418
581
|
this.opts.logger.warn(
|
|
419
|
-
{ userId },
|
|
582
|
+
{ userId, status: outcome.status, reason: outcome.description },
|
|
420
583
|
'hub grant: token endpoint answered invalid_grant — grant marked dead; a browser SSO login heals it'
|
|
421
584
|
);
|
|
422
585
|
return { kind: 'grant_dead' };
|
|
@@ -429,6 +592,7 @@ export class HubGrantService {
|
|
|
429
592
|
await this.clearPresentation(row.id);
|
|
430
593
|
this.refreshes.inc({ outcome: 'invalid_client' });
|
|
431
594
|
this.opts.logger.error(
|
|
595
|
+
{ status: outcome.status, reason: outcome.description },
|
|
432
596
|
'hub grant: token endpoint answered invalid_client — HUB_CLIENT_ID/HUB_CLIENT_SECRET are broken; ' +
|
|
433
597
|
'refreshes are failing transient (grants are NOT being killed); fix the client credentials'
|
|
434
598
|
);
|
|
@@ -564,10 +728,14 @@ export class HubGrantService {
|
|
|
564
728
|
}
|
|
565
729
|
|
|
566
730
|
/** Decrypt-and-cache the row's access token when it is still fresh. */
|
|
567
|
-
private async storedAccess(
|
|
731
|
+
private async storedAccess(
|
|
732
|
+
userId: string,
|
|
733
|
+
row: GrantRow,
|
|
734
|
+
minLeftMs: number = this.dials.accessSkewMs
|
|
735
|
+
): Promise<string | null> {
|
|
568
736
|
if (!row.accessToken || !row.accessTokenExpiresAt) return null;
|
|
569
737
|
const expiresAtMs = row.accessTokenExpiresAt.getTime();
|
|
570
|
-
if (expiresAtMs - this.now() <=
|
|
738
|
+
if (expiresAtMs - this.now() <= minLeftMs) return null;
|
|
571
739
|
try {
|
|
572
740
|
const token = await this.decrypt(row.accessToken);
|
|
573
741
|
this.cache.set(userId, { token, expiresAtMs });
|
|
@@ -657,9 +825,13 @@ export class HubGrantService {
|
|
|
657
825
|
// RFC 6749 errors ride 400/401; anything else (5xx…) is transient.
|
|
658
826
|
if (res.status === 400 || res.status === 401) {
|
|
659
827
|
const error = (payload as { error?: unknown } | null)?.error;
|
|
660
|
-
|
|
661
|
-
if (error === '
|
|
662
|
-
|
|
828
|
+
const description = refusalDescription(payload);
|
|
829
|
+
if (error === 'invalid_grant') return { kind: 'invalid_grant', status: res.status, description };
|
|
830
|
+
if (error === 'invalid_client') return { kind: 'invalid_client', status: res.status, description };
|
|
831
|
+
this.opts.logger.warn(
|
|
832
|
+
{ status: res.status, error, description },
|
|
833
|
+
'hub grant: unrecognized token-endpoint error'
|
|
834
|
+
);
|
|
663
835
|
return { kind: 'transient' };
|
|
664
836
|
}
|
|
665
837
|
this.opts.logger.warn({ status: res.status }, 'hub grant: token endpoint answered non-2xx');
|
package/src/identity/index.ts
CHANGED
|
@@ -23,9 +23,14 @@ export {
|
|
|
23
23
|
} from './demo-pass-rules.js';
|
|
24
24
|
export {
|
|
25
25
|
DEFAULT_GRANT_DIALS,
|
|
26
|
+
DEFAULT_GRANT_SWEEP_DIALS,
|
|
26
27
|
type GrantAccess,
|
|
28
|
+
type GrantSweepDials,
|
|
29
|
+
type GrantSweepHandle,
|
|
27
30
|
HubGrantService,
|
|
28
|
-
LOCK_WATCHDOG_HEADROOM_MS
|
|
31
|
+
LOCK_WATCHDOG_HEADROOM_MS,
|
|
32
|
+
type RefusalReason,
|
|
33
|
+
refusalDescription
|
|
29
34
|
} from './hub-grant.js';
|
|
30
35
|
export { HubJwtVerifier } from './hub-jwt.js';
|
|
31
36
|
export { HubLogoutService, hintCookieClearHeader } from './hub-logout.js';
|
package/src/middleware/index.ts
CHANGED
|
@@ -31,3 +31,14 @@ export { MAX_JSON_DEPTH, exceedsJsonDepth, jsonDepthLimit } from './json-depth.j
|
|
|
31
31
|
export { noStoreAuthenticated } from './no-store.js';
|
|
32
32
|
export { isPublicOauthPath, oauthPublicEndpoints } from './oauth-public.js';
|
|
33
33
|
export { buildCsp, hstsValue, inlineScriptHashes, securityHeaders } from './security-headers.js';
|
|
34
|
+
export {
|
|
35
|
+
IMMUTABLE_ASSET_PREFIX,
|
|
36
|
+
IMMUTABLE_CACHE_CONTROL,
|
|
37
|
+
REVALIDATE_CACHE_CONTROL,
|
|
38
|
+
type SpaFallback,
|
|
39
|
+
type StaticAssetsOptions,
|
|
40
|
+
acceptedEncodings,
|
|
41
|
+
loadSpaFallback,
|
|
42
|
+
staticAssets,
|
|
43
|
+
staticCacheControl
|
|
44
|
+
} from './static-assets.js';
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
2
|
+
import { serveStatic } from '@hono/node-server/serve-static';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The dashboard build, served so the browser keeps it (PRDCT-3584).
|
|
9
|
+
*
|
|
10
|
+
* Before this middleware the SPA's files went out with no `Cache-Control`,
|
|
11
|
+
* no validator and no compression: every visit to a tool re-downloaded the
|
|
12
|
+
* whole dashboard (about 1.2 MB of JavaScript in a hundred files on the
|
|
13
|
+
* team stack, measured 2026-10-17), and the splash the person stared at
|
|
14
|
+
* was that download. Two rules fix it, both on the build's own shape:
|
|
15
|
+
*
|
|
16
|
+
* - `/_app/immutable/*` is CONTENT-HASHED by SvelteKit: a changed file has a
|
|
17
|
+
* new name, so the cached copy can never be stale. It is served with
|
|
18
|
+
* `public, max-age=31536000, immutable`: a second visit transfers none of
|
|
19
|
+
* it. Every other static path (the HTML, the icons, the manifest) is
|
|
20
|
+
* `no-cache`: kept, but revalidated on every use, so a release reaches
|
|
21
|
+
* the browser on its next load.
|
|
22
|
+
* - A `.br`, `.zst` or `.gz` sibling written at build time (the dashboard's
|
|
23
|
+
* adapter has `precompress: true`) is served in place of the file when
|
|
24
|
+
* the request accepts that encoding, with `Content-Encoding` and `Vary:
|
|
25
|
+
* Accept-Encoding`. The server compresses nothing at runtime, and the
|
|
26
|
+
* API is untouched: this middleware runs on the static files alone,
|
|
27
|
+
* after every credentialed route has answered.
|
|
28
|
+
*
|
|
29
|
+
* Nothing here is a security input. `immutable` never reaches a response
|
|
30
|
+
* that is not a hashed asset, so no user-bearing answer can be cached; the
|
|
31
|
+
* authenticated JSON keeps its `no-store` (middleware/no-store.ts).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The content-hashed prefix of a SvelteKit build. */
|
|
35
|
+
export const IMMUTABLE_ASSET_PREFIX = '/_app/immutable/';
|
|
36
|
+
export const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
37
|
+
export const REVALIDATE_CACHE_CONTROL = 'no-cache';
|
|
38
|
+
|
|
39
|
+
/** The `Cache-Control` a static path of the build is served with. */
|
|
40
|
+
export function staticCacheControl(requestPath: string): string {
|
|
41
|
+
return requestPath.startsWith(IMMUTABLE_ASSET_PREFIX) ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface StaticAssetsOptions {
|
|
45
|
+
/** The directory holding the built dashboard (`index.html`, `_app/`). */
|
|
46
|
+
publicDir: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Serve `publicDir` with the cache policy above and the precompressed
|
|
51
|
+
* siblings. Falls through (`next()`) on a path that is not a file there, so
|
|
52
|
+
* the SPA fallback behind it answers the application routes.
|
|
53
|
+
*/
|
|
54
|
+
export function staticAssets(options: StaticAssetsOptions): MiddlewareHandler {
|
|
55
|
+
const serve = serveStatic({ root: options.publicDir, precompressed: true });
|
|
56
|
+
return async (c, next) => {
|
|
57
|
+
// The application's HTML itself (`/`, `/index.html`) is the SPA
|
|
58
|
+
// fallback's to answer, with its validator and its sibling; the static
|
|
59
|
+
// server would hand it out with neither (verifier round 2).
|
|
60
|
+
if (c.req.path === '/' || c.req.path === '/index.html') return next();
|
|
61
|
+
const res = await serve(c, next);
|
|
62
|
+
// serveStatic answers a Response for a file it found and the result of
|
|
63
|
+
// `next()` otherwise; only the file gets the policy.
|
|
64
|
+
if (res instanceof Response) {
|
|
65
|
+
res.headers.set('Cache-Control', staticCacheControl(c.req.path));
|
|
66
|
+
if (!res.headers.has('Vary')) res.headers.set('Vary', 'Accept-Encoding');
|
|
67
|
+
}
|
|
68
|
+
return res;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── The SPA fallback ────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/** The encodings a precompressed sibling may carry, in the order they are preferred. */
|
|
75
|
+
const FALLBACK_ENCODINGS = [
|
|
76
|
+
{ name: 'br', ext: '.br' },
|
|
77
|
+
{ name: 'gzip', ext: '.gz' }
|
|
78
|
+
] as const;
|
|
79
|
+
type FallbackEncoding = (typeof FALLBACK_ENCODINGS)[number]['name'];
|
|
80
|
+
|
|
81
|
+
/** The encodings a request accepts, read from `Accept-Encoding` with any `;q=` weight ignored. */
|
|
82
|
+
export function acceptedEncodings(header: string | undefined): Set<string> {
|
|
83
|
+
return new Set(
|
|
84
|
+
(header ?? '')
|
|
85
|
+
.split(',')
|
|
86
|
+
.map((part) => part.split(';')[0]!.trim().toLowerCase())
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SpaFallback {
|
|
92
|
+
/** The strong ETag of the plain HTML (the variants derive theirs from it). */
|
|
93
|
+
readonly etag: string;
|
|
94
|
+
/** Answer the application's HTML for a route the build has no file for. */
|
|
95
|
+
respond(c: Context): Response;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The application's HTML under any route the build has no file for, served
|
|
100
|
+
* with the two things the bare `c.html()` lacked (the tier 1 verifier's two
|
|
101
|
+
* caveats, PRDCT-3584): a strong `ETag` so `no-cache` means "revalidate"
|
|
102
|
+
* (a `304` costs no bytes), and the `.br` / `.gz` sibling of `index.html`
|
|
103
|
+
* when the build wrote one and the request accepts it, so a deep link costs
|
|
104
|
+
* the same as the root on a front that compresses nothing (Cloud Run, a
|
|
105
|
+
* self-hosted instance with no proxy). The HTML is read once at boot, like
|
|
106
|
+
* `indexHtml` itself.
|
|
107
|
+
*/
|
|
108
|
+
export async function loadSpaFallback(publicDir: string, html: string): Promise<SpaFallback> {
|
|
109
|
+
const plain = Buffer.from(html, 'utf8');
|
|
110
|
+
const etag = `"${createHash('sha256').update(plain).digest('base64url').slice(0, 27)}"`;
|
|
111
|
+
const variants = new Map<FallbackEncoding, Buffer>();
|
|
112
|
+
for (const { name, ext } of FALLBACK_ENCODINGS) {
|
|
113
|
+
try {
|
|
114
|
+
variants.set(name, await readFile(join(publicDir, `index.html${ext}`)));
|
|
115
|
+
} catch {
|
|
116
|
+
// no sibling of that encoding: the plain HTML answers those requests
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
etag,
|
|
121
|
+
respond(c) {
|
|
122
|
+
const accepted = acceptedEncodings(c.req.header('accept-encoding'));
|
|
123
|
+
const encoding = FALLBACK_ENCODINGS.find((e) => accepted.has(e.name) && variants.has(e.name))?.name;
|
|
124
|
+
// One validator per representation: a cached brotli body is never
|
|
125
|
+
// confirmed against a plain one.
|
|
126
|
+
const variantEtag = encoding ? etag.replace(/"$/, `-${encoding}"`) : etag;
|
|
127
|
+
// The type rides the 304 too: the node server would otherwise stamp a
|
|
128
|
+
// text/plain default on it (verifier round 2), and RFC 9110 allows the
|
|
129
|
+
// 304 to repeat what the 200 carries.
|
|
130
|
+
const headers: Record<string, string> = {
|
|
131
|
+
'Cache-Control': REVALIDATE_CACHE_CONTROL,
|
|
132
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
133
|
+
ETag: variantEtag,
|
|
134
|
+
Vary: 'Accept-Encoding'
|
|
135
|
+
};
|
|
136
|
+
if (c.req.header('if-none-match') === variantEtag) {
|
|
137
|
+
return c.body(null, 304, headers);
|
|
138
|
+
}
|
|
139
|
+
if (encoding) headers['Content-Encoding'] = encoding;
|
|
140
|
+
const body = encoding ? variants.get(encoding)! : plain;
|
|
141
|
+
return c.body(new Uint8Array(body), 200, headers);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -503,3 +503,100 @@ describe('ambiguous outcomes: the presentation record + the RFC 7662 probe (PRDC
|
|
|
503
503
|
expect((await makeService(FAST).accessToken(aliceId)).kind).toBe('ok');
|
|
504
504
|
});
|
|
505
505
|
});
|
|
506
|
+
|
|
507
|
+
/** Bring the stored access token to the renewal window (two minutes left) — the row is what a renewal judges. */
|
|
508
|
+
async function nearExpiryStoredAccess(): Promise<void> {
|
|
509
|
+
await app.db.pool.query(
|
|
510
|
+
`UPDATE account SET access_token_expires_at = now() + interval '2 minutes'
|
|
511
|
+
WHERE provider_id = 'antasphere' AND user_id = $1`,
|
|
512
|
+
[aliceId]
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
describe('the background renewal (PRDCT-3584, verifier round 5)', () => {
|
|
517
|
+
it('keeps serving the cached token while the hub answers the rotation', async () => {
|
|
518
|
+
const service = makeService();
|
|
519
|
+
await sso.ssoLogin(app, hub, alice);
|
|
520
|
+
const row = await grantRow();
|
|
521
|
+
const current = await symmetricDecrypt({ key: AUTH_SECRET, data: row.access_token! });
|
|
522
|
+
// the token is about to expire: inside the renewal window, outside the on-demand skew
|
|
523
|
+
service.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
|
|
524
|
+
await nearExpiryStoredAccess();
|
|
525
|
+
hub.tokenDelayMs = 1_200;
|
|
526
|
+
try {
|
|
527
|
+
const before = hub.refreshCount();
|
|
528
|
+
const renewal = service.renew(aliceId);
|
|
529
|
+
const t0 = Date.now();
|
|
530
|
+
const during = await service.accessToken(aliceId);
|
|
531
|
+
const waited = Date.now() - t0;
|
|
532
|
+
expect(during).toEqual({ kind: 'ok', accessToken: current });
|
|
533
|
+
expect(waited).toBeLessThan(300);
|
|
534
|
+
const out = await renewal;
|
|
535
|
+
expect(out.kind).toBe('ok');
|
|
536
|
+
expect(hub.refreshCount()).toBe(before + 1);
|
|
537
|
+
// the renewed token is what the next demand serves, without a hub call
|
|
538
|
+
const after = await service.accessToken(aliceId);
|
|
539
|
+
expect(after.kind).toBe('ok');
|
|
540
|
+
expect((after as { accessToken: string }).accessToken).not.toBe(current);
|
|
541
|
+
expect(hub.refreshCount()).toBe(before + 1);
|
|
542
|
+
} finally {
|
|
543
|
+
hub.tokenDelayMs = 0;
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('a transient answer leaves the cached token in place', async () => {
|
|
548
|
+
const service = makeService();
|
|
549
|
+
await sso.ssoLogin(app, hub, alice);
|
|
550
|
+
const row = await grantRow();
|
|
551
|
+
const current = await symmetricDecrypt({ key: AUTH_SECRET, data: row.access_token! });
|
|
552
|
+
service.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
|
|
553
|
+
await nearExpiryStoredAccess();
|
|
554
|
+
hub.tokenMode = 'http500';
|
|
555
|
+
try {
|
|
556
|
+
expect(await service.renew(aliceId)).toEqual({ kind: 'inconclusive' });
|
|
557
|
+
} finally {
|
|
558
|
+
hub.tokenMode = 'ok';
|
|
559
|
+
}
|
|
560
|
+
const before = hub.refreshCount();
|
|
561
|
+
expect(await service.accessToken(aliceId)).toEqual({ kind: 'ok', accessToken: current });
|
|
562
|
+
expect(hub.refreshCount()).toBe(before);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('a sibling replica adopts the fresh token the first one stored instead of rotating again', async () => {
|
|
566
|
+
const a = makeService();
|
|
567
|
+
const b = makeService();
|
|
568
|
+
await sso.ssoLogin(app, hub, alice);
|
|
569
|
+
const row = await grantRow();
|
|
570
|
+
const current = await symmetricDecrypt({ key: AUTH_SECRET, data: row.access_token! });
|
|
571
|
+
a.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
|
|
572
|
+
b.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
|
|
573
|
+
await nearExpiryStoredAccess();
|
|
574
|
+
const before = hub.refreshCount();
|
|
575
|
+
expect((await a.renew(aliceId)).kind).toBe('ok');
|
|
576
|
+
expect(hub.refreshCount()).toBe(before + 1);
|
|
577
|
+
const adopted = await b.renew(aliceId);
|
|
578
|
+
expect(adopted.kind).toBe('ok');
|
|
579
|
+
expect(hub.refreshCount()).toBe(before + 1); // no second rotation
|
|
580
|
+
const fresh = await grantRow();
|
|
581
|
+
expect((adopted as { accessToken: string }).accessToken).toBe(
|
|
582
|
+
await symmetricDecrypt({ key: AUTH_SECRET, data: fresh.access_token! })
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('a refused renewal marks the grant dead like a refused refresh', async () => {
|
|
587
|
+
const service = makeService();
|
|
588
|
+
await sso.ssoLogin(app, hub, alice);
|
|
589
|
+
const row = await grantRow();
|
|
590
|
+
const current = await symmetricDecrypt({ key: AUTH_SECRET, data: row.access_token! });
|
|
591
|
+
service.prime(aliceId, current, new Date(Date.now() + 2 * 60_000));
|
|
592
|
+
await nearExpiryStoredAccess();
|
|
593
|
+
hub.tokenMode = 'invalid_grant';
|
|
594
|
+
try {
|
|
595
|
+
expect(await service.renew(aliceId)).toEqual({ kind: 'grant_dead' });
|
|
596
|
+
} finally {
|
|
597
|
+
hub.tokenMode = 'ok';
|
|
598
|
+
}
|
|
599
|
+
expect(await service.accessToken(aliceId)).toEqual({ kind: 'grant_dead' });
|
|
600
|
+
await sso.ssoLogin(app, hub, alice); // heal for any later suite
|
|
601
|
+
});
|
|
602
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { refusalDescription } from '../../src/identity/hub-grant.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PRDCT-3584. The hub's `invalid_grant` has four causes at the pinned
|
|
6
|
+
* plugin (unknown token, expired, rotated out, refused by the pre-flight)
|
|
7
|
+
* and the tool used to drop the sentence that tells them apart. The reader
|
|
8
|
+
* keeps it for the log line, bounded and trimmed, and nothing else.
|
|
9
|
+
*/
|
|
10
|
+
describe('refusalDescription', () => {
|
|
11
|
+
it('reads a non-empty error_description', () => {
|
|
12
|
+
expect(refusalDescription({ error: 'invalid_grant', error_description: 'session not found' })).toBe(
|
|
13
|
+
'session not found'
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
it('trims and bounds it', () => {
|
|
17
|
+
expect(refusalDescription({ error_description: ' invalid refresh token \n' })).toBe(
|
|
18
|
+
'invalid refresh token'
|
|
19
|
+
);
|
|
20
|
+
expect(refusalDescription({ error_description: 'x'.repeat(1000) })?.length).toBe(300);
|
|
21
|
+
});
|
|
22
|
+
it('answers null for anything else', () => {
|
|
23
|
+
expect(refusalDescription({ error: 'invalid_grant' })).toBeNull();
|
|
24
|
+
expect(refusalDescription({ error_description: '' })).toBeNull();
|
|
25
|
+
expect(refusalDescription({ error_description: 42 })).toBeNull();
|
|
26
|
+
expect(refusalDescription(null)).toBeNull();
|
|
27
|
+
expect(refusalDescription('invalid_grant')).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
});
|