@openparachute/hub 0.7.3-rc.2 → 0.7.3-rc.4
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/package.json +1 -1
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/jwt-sign.test.ts +79 -0
- package/src/__tests__/oauth-handlers.test.ts +291 -4
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/users.test.ts +33 -0
- package/src/__tests__/vault-caps.test.ts +89 -0
- package/src/account-setup.ts +118 -32
- package/src/admin-login-ui.ts +30 -4
- package/src/admin-vaults.ts +9 -1
- package/src/api-invites.ts +163 -3
- package/src/api-users.ts +3 -0
- package/src/hub-db.ts +108 -1
- package/src/invites.ts +155 -31
- package/src/jwt-sign.ts +58 -0
- package/src/oauth-handlers.ts +73 -12
- package/src/rate-limit.ts +38 -1
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
package/src/invites.ts
CHANGED
|
@@ -37,10 +37,23 @@
|
|
|
37
37
|
* v13): the redemption form shows it read-only and the redeem handler
|
|
38
38
|
* enforces it. NULL = redeemer picks their own.
|
|
39
39
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
40
|
+
* MULTI-USE (migration v15, DEMO-PREP-2026-06-25 Workstream B): an invite
|
|
41
|
+
* carries `max_uses` (how many accounts ONE link may create) + `used_count`
|
|
42
|
+
* (how many it has). A redeem is refused once `used_count >= max_uses`. A
|
|
43
|
+
* LEGACY single-use invite is exactly `max_uses = 1` (the column default), so
|
|
44
|
+
* pre-v15 invites and default-shaped new ones behave identically to before.
|
|
45
|
+
* `used_at` is RETAINED — stamped on the FIRST redeem — so the admin list's
|
|
46
|
+
* "redeemed" status and any single-use lookups keep reading the same signal.
|
|
47
|
+
* Exhaustion is enforced atomically in `recordInviteRedemption` (the
|
|
48
|
+
* `used_count < max_uses` guard in the UPDATE), so two concurrent redeems of
|
|
49
|
+
* the last remaining seat can't both succeed.
|
|
50
|
+
*
|
|
51
|
+
* Revocation is a separate `revoked_at` stamp the admin sets before
|
|
52
|
+
* redemption (terminal regardless of remaining uses). Expiry is enforced at
|
|
53
|
+
* redeem-time. A public-signup link also captures the redeemer's `email`
|
|
54
|
+
* (B2 — the contactable identity, also stored per-account on `users.email`)
|
|
55
|
+
* and may carry a `vault_cap_bytes` to stamp onto each provisioned vault (B4
|
|
56
|
+
* — persisted to `vault_caps` for the Phase-2 enforcement PR to read).
|
|
44
57
|
*/
|
|
45
58
|
import type { Database } from "bun:sqlite";
|
|
46
59
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -70,6 +83,24 @@ export interface Invite {
|
|
|
70
83
|
provisionVault: boolean;
|
|
71
84
|
/** `'internal' | 'off'` mirror knob for the provisioned vault, or null. */
|
|
72
85
|
defaultMirror: string | null;
|
|
86
|
+
/**
|
|
87
|
+
* How many accounts this link may create (v15). 1 = legacy single-use
|
|
88
|
+
* (the column default); >1 = a multi-use public-signup link.
|
|
89
|
+
*/
|
|
90
|
+
maxUses: number;
|
|
91
|
+
/** How many accounts this link HAS created (v15). Redeem refused once == maxUses. */
|
|
92
|
+
usedCount: number;
|
|
93
|
+
/**
|
|
94
|
+
* Most-recent redeemer's email (v15, B2), or null. For a multi-use link
|
|
95
|
+
* the canonical per-account email lives on `users.email`; this column
|
|
96
|
+
* holds the latest redeemer's for the admin's at-a-glance audit.
|
|
97
|
+
*/
|
|
98
|
+
email: string | null;
|
|
99
|
+
/**
|
|
100
|
+
* Per-vault storage cap (bytes) to stamp onto each provisioned vault
|
|
101
|
+
* (v15, B4), or null to provision uncapped (legacy behavior).
|
|
102
|
+
*/
|
|
103
|
+
vaultCapBytes: number | null;
|
|
73
104
|
expiresAt: string;
|
|
74
105
|
usedAt: string | null;
|
|
75
106
|
redeemedUserId: string | null;
|
|
@@ -98,6 +129,20 @@ export class InviteUsedError extends Error {
|
|
|
98
129
|
}
|
|
99
130
|
}
|
|
100
131
|
|
|
132
|
+
/**
|
|
133
|
+
* A multi-use link whose seats are all taken (`used_count >= max_uses`). For
|
|
134
|
+
* a single-use (max_uses=1) link this is the same condition the legacy
|
|
135
|
+
* `InviteUsedError` named, but the redeem path throws `InviteUsedError` for a
|
|
136
|
+
* single-use link (familiar "already used" copy) and `InviteExhaustedError`
|
|
137
|
+
* for a multi-use one ("all signups taken") so the message can differ.
|
|
138
|
+
*/
|
|
139
|
+
export class InviteExhaustedError extends Error {
|
|
140
|
+
constructor() {
|
|
141
|
+
super("invite has reached its maximum number of signups");
|
|
142
|
+
this.name = "InviteExhaustedError";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
101
146
|
export class InviteRevokedError extends Error {
|
|
102
147
|
constructor() {
|
|
103
148
|
super("invite has been revoked");
|
|
@@ -113,6 +158,10 @@ interface Row {
|
|
|
113
158
|
role: string;
|
|
114
159
|
provision_vault: number;
|
|
115
160
|
default_mirror: string | null;
|
|
161
|
+
max_uses: number;
|
|
162
|
+
used_count: number;
|
|
163
|
+
email: string | null;
|
|
164
|
+
vault_cap_bytes: number | null;
|
|
116
165
|
expires_at: string;
|
|
117
166
|
used_at: string | null;
|
|
118
167
|
redeemed_user_id: string | null;
|
|
@@ -129,6 +178,10 @@ function rowToInvite(r: Row): Invite {
|
|
|
129
178
|
role: r.role,
|
|
130
179
|
provisionVault: r.provision_vault === 1,
|
|
131
180
|
defaultMirror: r.default_mirror,
|
|
181
|
+
maxUses: r.max_uses,
|
|
182
|
+
usedCount: r.used_count,
|
|
183
|
+
email: r.email,
|
|
184
|
+
vaultCapBytes: r.vault_cap_bytes,
|
|
132
185
|
expiresAt: r.expires_at,
|
|
133
186
|
usedAt: r.used_at,
|
|
134
187
|
redeemedUserId: r.redeemed_user_id,
|
|
@@ -142,10 +195,19 @@ export function hashInviteToken(rawToken: string): string {
|
|
|
142
195
|
return createHash("sha256").update(rawToken).digest("hex");
|
|
143
196
|
}
|
|
144
197
|
|
|
145
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* Derive an invite's status from its stamps + the current time.
|
|
200
|
+
*
|
|
201
|
+
* Multi-use (v15): an invite is "redeemed" only once every seat is taken
|
|
202
|
+
* (`usedCount >= maxUses`). A partially-used multi-use link (some seats left,
|
|
203
|
+
* not expired/revoked) is still "pending" — it can take more signups. A
|
|
204
|
+
* legacy single-use link (maxUses=1) flips to "redeemed" on its first
|
|
205
|
+
* redeem, exactly as before, because 1 >= 1. Expired wins over a not-yet-
|
|
206
|
+
* exhausted link; revoked + exhausted are checked first (terminal).
|
|
207
|
+
*/
|
|
146
208
|
export function inviteStatus(invite: Invite, now: Date = new Date()): InviteStatus {
|
|
147
209
|
if (invite.revokedAt) return "revoked";
|
|
148
|
-
if (invite.
|
|
210
|
+
if (invite.usedCount >= invite.maxUses) return "redeemed";
|
|
149
211
|
if (now.getTime() > new Date(invite.expiresAt).getTime()) return "expired";
|
|
150
212
|
return "pending";
|
|
151
213
|
}
|
|
@@ -166,6 +228,16 @@ export interface IssueInviteOpts {
|
|
|
166
228
|
provisionVault?: boolean;
|
|
167
229
|
/** `'internal' | 'off'` mirror knob for the provisioned vault. */
|
|
168
230
|
defaultMirror?: string | null;
|
|
231
|
+
/**
|
|
232
|
+
* How many accounts this link may create (v15). Default 1 (single-use,
|
|
233
|
+
* the legacy shape). Caller validates the upper bound.
|
|
234
|
+
*/
|
|
235
|
+
maxUses?: number;
|
|
236
|
+
/**
|
|
237
|
+
* Per-vault storage cap (bytes) to stamp onto each provisioned vault
|
|
238
|
+
* (v15, B4). Omit/null = provision uncapped (legacy behavior).
|
|
239
|
+
*/
|
|
240
|
+
vaultCapBytes?: number | null;
|
|
169
241
|
/** Lifetime in seconds. Default {@link DEFAULT_INVITE_TTL_SECONDS} (7 days). */
|
|
170
242
|
expiresInSeconds?: number;
|
|
171
243
|
now?: () => Date;
|
|
@@ -198,12 +270,15 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
|
|
|
198
270
|
const username = opts.username ?? null;
|
|
199
271
|
const provisionVault = opts.provisionVault ?? true;
|
|
200
272
|
const defaultMirror = opts.defaultMirror ?? null;
|
|
273
|
+
const maxUses = opts.maxUses ?? 1;
|
|
274
|
+
const vaultCapBytes = opts.vaultCapBytes ?? null;
|
|
201
275
|
|
|
202
276
|
db.prepare(
|
|
203
277
|
`INSERT INTO invites
|
|
204
278
|
(token, created_by, vault_name, username, role, provision_vault, default_mirror,
|
|
279
|
+
max_uses, used_count, email, vault_cap_bytes,
|
|
205
280
|
expires_at, used_at, redeemed_user_id, revoked_at, created_at)
|
|
206
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?)`,
|
|
281
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, NULL, NULL, NULL, ?)`,
|
|
207
282
|
).run(
|
|
208
283
|
tokenHash,
|
|
209
284
|
opts.createdBy,
|
|
@@ -212,6 +287,8 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
|
|
|
212
287
|
role,
|
|
213
288
|
provisionVault ? 1 : 0,
|
|
214
289
|
defaultMirror,
|
|
290
|
+
maxUses,
|
|
291
|
+
vaultCapBytes,
|
|
215
292
|
expiresAt,
|
|
216
293
|
createdAt,
|
|
217
294
|
);
|
|
@@ -226,6 +303,10 @@ export function issueInvite(db: Database, opts: IssueInviteOpts): IssuedInvite {
|
|
|
226
303
|
role,
|
|
227
304
|
provisionVault,
|
|
228
305
|
defaultMirror,
|
|
306
|
+
maxUses,
|
|
307
|
+
usedCount: 0,
|
|
308
|
+
email: null,
|
|
309
|
+
vaultCapBytes,
|
|
229
310
|
expiresAt,
|
|
230
311
|
usedAt: null,
|
|
231
312
|
redeemedUserId: null,
|
|
@@ -275,7 +356,7 @@ export function usernameReservedByPendingInvite(
|
|
|
275
356
|
const row = db
|
|
276
357
|
.query<{ token: string }, [string, string]>(
|
|
277
358
|
`SELECT token FROM invites
|
|
278
|
-
WHERE username = ? AND
|
|
359
|
+
WHERE username = ? AND used_count < max_uses AND revoked_at IS NULL AND expires_at > ?
|
|
279
360
|
LIMIT 1`,
|
|
280
361
|
)
|
|
281
362
|
.get(username, now.toISOString());
|
|
@@ -295,11 +376,13 @@ export function listInvites(
|
|
|
295
376
|
}
|
|
296
377
|
|
|
297
378
|
/**
|
|
298
|
-
* Validate an invite for redemption WITHOUT
|
|
299
|
-
* not-redeemable branch (not-found / expired /
|
|
300
|
-
* invite when it's redeemable. The redemption handler calls this FIRST (so
|
|
301
|
-
* bad token is rejected before any account/vault work), then does
|
|
302
|
-
*
|
|
379
|
+
* Validate an invite for redemption WITHOUT recording one. Throws on every
|
|
380
|
+
* not-redeemable branch (not-found / expired / exhausted / revoked). Returns
|
|
381
|
+
* the invite when it's redeemable. The redemption handler calls this FIRST (so
|
|
382
|
+
* a bad token is rejected before any account/vault work), then does createUser,
|
|
383
|
+
* then `recordInviteRedemption` INSIDE the user-row transaction (the atomic
|
|
384
|
+
* seat-lock — the `< max_uses` UPDATE guard is what actually prevents an
|
|
385
|
+
* over-seat under concurrency; this early check is the fast-path rejection).
|
|
303
386
|
*/
|
|
304
387
|
export function assertInviteRedeemable(
|
|
305
388
|
db: Database,
|
|
@@ -308,10 +391,15 @@ export function assertInviteRedeemable(
|
|
|
308
391
|
): Invite {
|
|
309
392
|
const invite = findInviteByRawToken(db, rawToken);
|
|
310
393
|
if (!invite) throw new InviteNotFoundError();
|
|
311
|
-
// Revoked +
|
|
312
|
-
// so a revoked-then-expired invite reports the more specific reason.
|
|
394
|
+
// Revoked + exhausted are terminal regardless of clock; check them before
|
|
395
|
+
// expiry so a revoked-then-expired invite reports the more specific reason.
|
|
313
396
|
if (invite.revokedAt) throw new InviteRevokedError();
|
|
314
|
-
|
|
397
|
+
// Exhaustion (v15): seats all taken. A single-use link (maxUses=1) throws
|
|
398
|
+
// the familiar InviteUsedError ("already used"); a multi-use one throws
|
|
399
|
+
// InviteExhaustedError ("all signups taken") so the copy can differ.
|
|
400
|
+
if (invite.usedCount >= invite.maxUses) {
|
|
401
|
+
throw invite.maxUses > 1 ? new InviteExhaustedError() : new InviteUsedError();
|
|
402
|
+
}
|
|
315
403
|
if (now.getTime() > new Date(invite.expiresAt).getTime()) {
|
|
316
404
|
throw new InviteExpiredError();
|
|
317
405
|
}
|
|
@@ -319,39 +407,75 @@ export function assertInviteRedeemable(
|
|
|
319
407
|
}
|
|
320
408
|
|
|
321
409
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* `used_at
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
410
|
+
* Record ONE redemption against an invite — the multi-use-aware consume
|
|
411
|
+
* (v15). Atomically:
|
|
412
|
+
* - increments `used_count` by 1,
|
|
413
|
+
* - stamps `used_at` on the FIRST redeem only (COALESCE keeps the original),
|
|
414
|
+
* - records the redeemer's `email` (B2) + `redeemed_user_id` as the
|
|
415
|
+
* MOST-RECENT redeemer (the canonical per-account email lives on
|
|
416
|
+
* `users.email`; this is the admin's at-a-glance latest).
|
|
417
|
+
*
|
|
418
|
+
* Exhaustion + revocation are enforced by the `used_count < max_uses AND
|
|
419
|
+
* revoked_at IS NULL` guard in the UPDATE: a racing redeem that would take a
|
|
420
|
+
* seat past the cap — or one racing a revoke — updates zero rows, and the
|
|
421
|
+
* caller treats that as exhausted/revoked. Race-safe because sqlite
|
|
422
|
+
* serializes writes (a legacy single-use link is just the max_uses=1 case:
|
|
423
|
+
* the second concurrent redeem sees `used_count`=1, fails the `< max_uses`
|
|
424
|
+
* guard, and changes zero rows — exactly the old single-use behavior).
|
|
329
425
|
*
|
|
330
|
-
* Returns `true` if THIS call
|
|
426
|
+
* Returns `true` if THIS call recorded a redemption, `false` otherwise.
|
|
331
427
|
*/
|
|
332
|
-
export function
|
|
428
|
+
export function recordInviteRedemption(
|
|
333
429
|
db: Database,
|
|
334
430
|
tokenHash: string,
|
|
335
431
|
redeemedUserId: string,
|
|
432
|
+
email: string | null = null,
|
|
336
433
|
now: Date = new Date(),
|
|
337
434
|
): boolean {
|
|
435
|
+
const stamp = now.toISOString();
|
|
338
436
|
const res = db
|
|
339
437
|
.prepare(
|
|
340
|
-
|
|
438
|
+
`UPDATE invites
|
|
439
|
+
SET used_count = used_count + 1,
|
|
440
|
+
used_at = COALESCE(used_at, ?),
|
|
441
|
+
redeemed_user_id = ?,
|
|
442
|
+
email = ?
|
|
443
|
+
WHERE token = ? AND used_count < max_uses AND revoked_at IS NULL`,
|
|
341
444
|
)
|
|
342
|
-
.run(
|
|
445
|
+
.run(stamp, redeemedUserId, email, tokenHash);
|
|
343
446
|
return res.changes > 0;
|
|
344
447
|
}
|
|
345
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Legacy single-use consume — thin wrapper over {@link recordInviteRedemption}
|
|
451
|
+
* for callers that don't capture email. Retained for backwards compatibility;
|
|
452
|
+
* the redeem path uses `recordInviteRedemption` directly so it can record the
|
|
453
|
+
* email. Race-safe + exhaustion-aware via the underlying function.
|
|
454
|
+
*
|
|
455
|
+
* Returns `true` if THIS call recorded a redemption, `false` otherwise.
|
|
456
|
+
*/
|
|
457
|
+
export function consumeInvite(
|
|
458
|
+
db: Database,
|
|
459
|
+
tokenHash: string,
|
|
460
|
+
redeemedUserId: string,
|
|
461
|
+
now: Date = new Date(),
|
|
462
|
+
): boolean {
|
|
463
|
+
return recordInviteRedemption(db, tokenHash, redeemedUserId, null, now);
|
|
464
|
+
}
|
|
465
|
+
|
|
346
466
|
/**
|
|
347
467
|
* Revoke a pending invite (admin DELETE). Stamps `revoked_at` only when the
|
|
348
|
-
* invite
|
|
349
|
-
*
|
|
468
|
+
* invite still has seats left (`used_count < max_uses`) and isn't already
|
|
469
|
+
* revoked. A multi-use link can be revoked mid-life to cut off its remaining
|
|
470
|
+
* seats; a fully-exhausted link is terminal and revoke is a no-op. (For a
|
|
471
|
+
* legacy single-use link `used_count < max_uses` is equivalent to the old
|
|
472
|
+
* `used_at IS NULL` guard.) Returns `true` if this call revoked it, `false`
|
|
473
|
+
* if it was already exhausted/revoked or not found.
|
|
350
474
|
*/
|
|
351
475
|
export function revokeInvite(db: Database, tokenHash: string, now: Date = new Date()): boolean {
|
|
352
476
|
const res = db
|
|
353
477
|
.prepare(
|
|
354
|
-
"UPDATE invites SET revoked_at = ? WHERE token = ? AND
|
|
478
|
+
"UPDATE invites SET revoked_at = ? WHERE token = ? AND used_count < max_uses AND revoked_at IS NULL",
|
|
355
479
|
)
|
|
356
480
|
.run(now.toISOString(), tokenHash);
|
|
357
481
|
return res.changes > 0;
|
|
@@ -373,7 +497,7 @@ export function revokeInvitesForVault(
|
|
|
373
497
|
): number {
|
|
374
498
|
const res = db
|
|
375
499
|
.prepare(
|
|
376
|
-
"UPDATE invites SET revoked_at = ? WHERE vault_name = ? AND
|
|
500
|
+
"UPDATE invites SET revoked_at = ? WHERE vault_name = ? AND used_count < max_uses AND revoked_at IS NULL",
|
|
377
501
|
)
|
|
378
502
|
.run(now.toISOString(), vaultName);
|
|
379
503
|
return Number(res.changes);
|
package/src/jwt-sign.ts
CHANGED
|
@@ -32,6 +32,18 @@ import { getActiveSigningKey, getAllPublicKeys } from "./signing-keys.ts";
|
|
|
32
32
|
|
|
33
33
|
export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
|
|
34
34
|
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
35
|
+
/**
|
|
36
|
+
* One-generation rotation grace window (hub#685). When a presented refresh
|
|
37
|
+
* token has already been rotated (revoked), a refresh of the *immediately-
|
|
38
|
+
* previous* token within this window is treated as a benign concurrent /
|
|
39
|
+
* retried refresh (multi-tab SPA, bfcache/stale-tab resume, a network retry)
|
|
40
|
+
* rather than theft — it converges the client on the live tip instead of
|
|
41
|
+
* revoking the whole family. The window is deliberately short: it tolerates
|
|
42
|
+
* the brief overlap of a real client racing itself, NOT a delayed replay.
|
|
43
|
+
* Genuine theft (an OLDER ancestor, or ANY replay past the window) still
|
|
44
|
+
* revokes the family per RFC 6819 §5.2.2.3. See `handleTokenRefresh`.
|
|
45
|
+
*/
|
|
46
|
+
export const REFRESH_GRACE_MS = 30_000;
|
|
35
47
|
export const SIGNING_ALGORITHM = "RS256";
|
|
36
48
|
|
|
37
49
|
export interface SignAccessTokenOpts {
|
|
@@ -529,6 +541,14 @@ export interface RefreshTokenRow {
|
|
|
529
541
|
createdVia: TokenCreatedVia;
|
|
530
542
|
/** JSON-encoded fine-grained constraints (auth-architecture-shape.md §11.3). */
|
|
531
543
|
permissions: string | null;
|
|
544
|
+
/**
|
|
545
|
+
* jti of the refresh-token row this row rotated INTO (set by
|
|
546
|
+
* `linkRotation` inside the rotation transaction). NULL until this row
|
|
547
|
+
* rotates, and NULL on every pre-v14 row. Powers the one-generation
|
|
548
|
+
* rotation grace window (hub#685): only the row whose `rotatedTo` is the
|
|
549
|
+
* family's single live tip is the benign immediate-predecessor.
|
|
550
|
+
*/
|
|
551
|
+
rotatedTo: string | null;
|
|
532
552
|
}
|
|
533
553
|
|
|
534
554
|
/**
|
|
@@ -553,6 +573,7 @@ interface TokenRowDb {
|
|
|
553
573
|
permissions: string | null;
|
|
554
574
|
created_via: string;
|
|
555
575
|
subject: string | null;
|
|
576
|
+
rotated_to: string | null;
|
|
556
577
|
}
|
|
557
578
|
|
|
558
579
|
function rowToRefreshToken(row: TokenRowDb): RefreshTokenRow {
|
|
@@ -568,6 +589,7 @@ function rowToRefreshToken(row: TokenRowDb): RefreshTokenRow {
|
|
|
568
589
|
createdAt: row.created_at,
|
|
569
590
|
createdVia: (row.created_via as TokenCreatedVia) ?? "oauth_refresh",
|
|
570
591
|
permissions: row.permissions,
|
|
592
|
+
rotatedTo: row.rotated_to ?? null,
|
|
571
593
|
};
|
|
572
594
|
}
|
|
573
595
|
|
|
@@ -599,3 +621,39 @@ export function revokeFamily(db: Database, familyId: string, now: Date): number
|
|
|
599
621
|
.run(now.toISOString(), familyId);
|
|
600
622
|
return Number(res.changes);
|
|
601
623
|
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Record that `predecessorJti` rotated INTO `successorJti`. Called INSIDE the
|
|
627
|
+
* rotation transaction (alongside the revoke-old + insert-new) so the
|
|
628
|
+
* successor link, the predecessor's `revoked_at`, and the new row commit or
|
|
629
|
+
* roll back as a unit. Powers the one-generation rotation grace window
|
|
630
|
+
* (hub#685): a benign concurrent/retried refresh of the immediate
|
|
631
|
+
* predecessor can be told apart from a genuine replay of an older ancestor.
|
|
632
|
+
*/
|
|
633
|
+
export function linkRotation(db: Database, predecessorJti: string, successorJti: string): void {
|
|
634
|
+
db.prepare("UPDATE tokens SET rotated_to = ? WHERE jti = ?").run(successorJti, predecessorJti);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* The live (un-revoked AND un-expired) refresh-token rows in a family as of
|
|
639
|
+
* `now`. The grace window asks "is there exactly one live tip, and is the
|
|
640
|
+
* presented (revoked) row its direct predecessor?" — so callers inspect this
|
|
641
|
+
* set. Two filters matter:
|
|
642
|
+
* - `refresh_token_hash IS NOT NULL` — the family can also contain
|
|
643
|
+
* access-token rows sharing the jti/family, irrelevant to rotation lineage.
|
|
644
|
+
* - `expires_at > now` — an expired-but-not-revoked tip is not a valid
|
|
645
|
+
* rotation target, so the grace path must never rotate into it. Keeping
|
|
646
|
+
* "live" exhaustive here means the single caller doesn't re-check expiry.
|
|
647
|
+
*/
|
|
648
|
+
export function liveFamilyRefreshRows(
|
|
649
|
+
db: Database,
|
|
650
|
+
familyId: string,
|
|
651
|
+
now: Date,
|
|
652
|
+
): RefreshTokenRow[] {
|
|
653
|
+
const rows = db
|
|
654
|
+
.query<TokenRowDb, [string, string]>(
|
|
655
|
+
"SELECT * FROM tokens WHERE family_id = ? AND revoked_at IS NULL AND refresh_token_hash IS NOT NULL AND expires_at > ?",
|
|
656
|
+
)
|
|
657
|
+
.all(familyId, now.toISOString());
|
|
658
|
+
return rows.map(rowToRefreshToken);
|
|
659
|
+
}
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -51,9 +51,13 @@ import { consumeFirstClientAutoApproveWindow } from "./hub-settings.ts";
|
|
|
51
51
|
import { VAULT_VERBS, inferAudience } from "./jwt-audience.ts";
|
|
52
52
|
import {
|
|
53
53
|
ACCESS_TOKEN_TTL_SECONDS,
|
|
54
|
+
REFRESH_GRACE_MS,
|
|
54
55
|
RefreshTokenInsertError,
|
|
56
|
+
type RefreshTokenRow,
|
|
55
57
|
findRefreshToken,
|
|
56
58
|
findTokenRowByJti,
|
|
59
|
+
linkRotation,
|
|
60
|
+
liveFamilyRefreshRows,
|
|
57
61
|
revokeFamily,
|
|
58
62
|
signAccessToken,
|
|
59
63
|
signRefreshToken,
|
|
@@ -2223,10 +2227,46 @@ async function handleTokenRefresh(
|
|
|
2223
2227
|
// Replay of an already-rotated refresh token. Per RFC 6819 §5.2.2.3 the
|
|
2224
2228
|
// working assumption is theft — the legitimate client received a new
|
|
2225
2229
|
// refresh token at the prior rotation, so anyone presenting the old one
|
|
2226
|
-
// either lost a race
|
|
2227
|
-
//
|
|
2228
|
-
//
|
|
2229
|
-
//
|
|
2230
|
+
// either lost a race or stole it (the case we must defend against).
|
|
2231
|
+
//
|
|
2232
|
+
// ONE-GENERATION GRACE WINDOW (hub#685). Benign concurrent/retried
|
|
2233
|
+
// refreshes — a multi-tab SPA, a bfcache/stale-tab resume, a network
|
|
2234
|
+
// retry — legitimately present the *just-rotated* token a moment after
|
|
2235
|
+
// it rotated. To avoid forcing those real clients to re-login, we let
|
|
2236
|
+
// through the SINGLE immediately-previous token, and ONLY it, for
|
|
2237
|
+
// ~REFRESH_GRACE_MS. The grace case rotates the live tip and converges
|
|
2238
|
+
// the replaying client onto the current token; it does NOT mint a
|
|
2239
|
+
// second live token into a forked lineage.
|
|
2240
|
+
//
|
|
2241
|
+
// The window is narrow ON PURPOSE — both conditions must hold:
|
|
2242
|
+
// (a) WINDOW: this row was revoked within the last REFRESH_GRACE_MS.
|
|
2243
|
+
// (b) IMMEDIATE-PREDECESSOR: this row's recorded successor (rotated_to)
|
|
2244
|
+
// IS the family's single live refresh row. That proves it's the
|
|
2245
|
+
// direct parent of the current tip — not an older ancestor (whose
|
|
2246
|
+
// successor is itself revoked) and not a sibling fork.
|
|
2247
|
+
//
|
|
2248
|
+
// HARD SECURITY INVARIANT: anything outside (a)∧(b) — an OLDER/ancestor
|
|
2249
|
+
// token, any replay past the window, a family with zero or multiple live
|
|
2250
|
+
// tips (already a theft-revoked or forked family) — falls through to the
|
|
2251
|
+
// unchanged zero-tolerance path: revokeFamily + invalid_grant. The grace
|
|
2252
|
+
// window NEVER tolerates more than the one immediate predecessor, and
|
|
2253
|
+
// never beyond REFRESH_GRACE_MS.
|
|
2254
|
+
const live = liveFamilyRefreshRows(db, row.familyId, now);
|
|
2255
|
+
const revokedAtMs = new Date(row.revokedAt).getTime();
|
|
2256
|
+
const withinWindow =
|
|
2257
|
+
now.getTime() - revokedAtMs <= REFRESH_GRACE_MS && now.getTime() >= revokedAtMs;
|
|
2258
|
+
const tip = live.length === 1 ? live[0]! : null;
|
|
2259
|
+
const isImmediatePredecessor =
|
|
2260
|
+
tip !== null && row.rotatedTo !== null && row.rotatedTo === tip.jti;
|
|
2261
|
+
if (withinWindow && isImmediatePredecessor && tip) {
|
|
2262
|
+
// Benign replay of the immediate predecessor. Rotate the live tip and
|
|
2263
|
+
// hand the client the new pair — it converges on the current lineage
|
|
2264
|
+
// (single live token preserved), no family revocation. Equivalent to
|
|
2265
|
+
// what the client would have received had it presented the tip itself.
|
|
2266
|
+
return await rotateAndRespond(db, tip, deps, now);
|
|
2267
|
+
}
|
|
2268
|
+
// Genuine theft (or a too-late replay): revoke every descendant so the
|
|
2269
|
+
// attacker can't keep refreshing, and force re-authorization.
|
|
2230
2270
|
revokeFamily(db, row.familyId, now);
|
|
2231
2271
|
return jsonResponse(
|
|
2232
2272
|
{ error: "invalid_grant", error_description: "refresh_token revoked" },
|
|
@@ -2239,17 +2279,34 @@ async function handleTokenRefresh(
|
|
|
2239
2279
|
400,
|
|
2240
2280
|
);
|
|
2241
2281
|
}
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2282
|
+
return await rotateAndRespond(db, row, deps, now);
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
/**
|
|
2286
|
+
* Rotate a single live refresh-token row: revoke it, mint + insert a new
|
|
2287
|
+
* access + refresh pair bound to the same family, and link old→new
|
|
2288
|
+
* (`rotated_to`) so a future grace-window check can identify the direct
|
|
2289
|
+
* predecessor (hub#685). Returns the spec-shaped token response.
|
|
2290
|
+
*
|
|
2291
|
+
* Both call sites pass a row that is live at the moment of the call: the
|
|
2292
|
+
* normal path passes the presented (non-revoked) token; the grace path
|
|
2293
|
+
* passes the family's single live tip. Used for both so the benign-replay
|
|
2294
|
+
* client receives exactly what a direct refresh of the tip would yield.
|
|
2295
|
+
*/
|
|
2296
|
+
async function rotateAndRespond(
|
|
2297
|
+
db: Database,
|
|
2298
|
+
row: RefreshTokenRow,
|
|
2299
|
+
deps: OAuthDeps,
|
|
2300
|
+
now: Date,
|
|
2301
|
+
): Promise<Response> {
|
|
2302
|
+
const refreshUserId = row.userId ?? "";
|
|
2246
2303
|
// Mint the access token *before* opening the rotation transaction. JWT
|
|
2247
2304
|
// signing is async (jose returns a Promise) and bun:sqlite's
|
|
2248
2305
|
// `db.transaction()` is sync — running async work inside the closure
|
|
2249
2306
|
// would silently break atomicity. Once we have the JWT, the UPDATE
|
|
2250
|
-
// (revoke old) + INSERT (mint new refresh row)
|
|
2251
|
-
// a unit, so a mid-rotation crash can't
|
|
2252
|
-
// (#107).
|
|
2307
|
+
// (revoke old) + INSERT (mint new refresh row) + link (rotated_to) commit
|
|
2308
|
+
// or roll back as a unit, so a mid-rotation crash can't
|
|
2309
|
+
// dead-old-without-replacement (#107).
|
|
2253
2310
|
const audience = inferAudience(row.scopes);
|
|
2254
2311
|
const access = await signAccessToken(db, {
|
|
2255
2312
|
sub: refreshUserId,
|
|
@@ -2271,7 +2328,7 @@ async function handleTokenRefresh(
|
|
|
2271
2328
|
try {
|
|
2272
2329
|
refresh = db.transaction(() => {
|
|
2273
2330
|
db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
|
|
2274
|
-
|
|
2331
|
+
const minted = signRefreshToken(db, {
|
|
2275
2332
|
jti: access.jti,
|
|
2276
2333
|
userId: refreshUserId,
|
|
2277
2334
|
clientId: row.clientId,
|
|
@@ -2279,6 +2336,10 @@ async function handleTokenRefresh(
|
|
|
2279
2336
|
familyId: row.familyId,
|
|
2280
2337
|
now: deps.now,
|
|
2281
2338
|
});
|
|
2339
|
+
// Link old→new so the grace-window check (hub#685) can tell the
|
|
2340
|
+
// immediate predecessor apart from an older ancestor on replay.
|
|
2341
|
+
linkRotation(db, row.jti, access.jti);
|
|
2342
|
+
return minted;
|
|
2282
2343
|
})();
|
|
2283
2344
|
} catch (err) {
|
|
2284
2345
|
// Concurrent rotation: a sibling refresh of the same row already
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Rate-limit primitives for hub auth-surface endpoints.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Each limiter is a floor, not the primary defense. Two are highlighted below
|
|
5
|
+
* for their contrasting threat models; the rest (`/login/2fa`, vault-token
|
|
6
|
+
* mint, public signup) are documented at their `export const` definitions.
|
|
7
|
+
* The public-signup limiter is the deliberate OUTLIER — generous, not tight —
|
|
8
|
+
* because its endpoint is meant to be redeemed by a room of people sharing one
|
|
9
|
+
* egress IP (see `SIGNUP_MAX_ATTEMPTS`).
|
|
5
10
|
*
|
|
6
11
|
* - `/login` (per-IP, hub#187 / hub#188): 5 attempts / 15 min.
|
|
7
12
|
* Lands as a floor under brute-force after hub#187 collapsed the
|
|
@@ -109,6 +114,27 @@ export const VAULT_TOKEN_MINT_WINDOW_MS = 10 * 60 * 1000;
|
|
|
109
114
|
* minutes is generous for a human and still chokes a stolen-cookie flood.
|
|
110
115
|
*/
|
|
111
116
|
export const VAULT_TOKEN_MINT_MAX_ATTEMPTS = 10;
|
|
117
|
+
/**
|
|
118
|
+
* `POST /account/setup/<token>` (public invite redemption / signup) window
|
|
119
|
+
* length: 15 minutes — same window as `/login`, but a MUCH larger cap (below)
|
|
120
|
+
* because the threat model is the opposite. A public multi-use signup link is
|
|
121
|
+
* meant to be redeemed by a ROOM OF PEOPLE, and a demo room shares ONE NAT'd
|
|
122
|
+
* egress IP — so a per-IP `/login`-sized cap (5/15min) would 429 the ~6th
|
|
123
|
+
* legitimate signer. This bucket is deliberately generous so a shared egress
|
|
124
|
+
* IP comfortably handles a cohort, while still being a floor against an
|
|
125
|
+
* attacker scripting account creation against an open link (the invite's
|
|
126
|
+
* own `max_uses` + expiry are the primary bound; this is the abuse floor).
|
|
127
|
+
* Separate bucket from `/login` so a signup flurry never burns the login
|
|
128
|
+
* window, and vice-versa.
|
|
129
|
+
*/
|
|
130
|
+
export const SIGNUP_WINDOW_MS = 15 * 60 * 1000;
|
|
131
|
+
/**
|
|
132
|
+
* `POST /account/setup/<token>` attempts allowed per IP per window. 60 is
|
|
133
|
+
* comfortably above a single demo room behind one NAT (and well above any
|
|
134
|
+
* legitimate human's retries) while still capping a scripted-abuse rate to
|
|
135
|
+
* ~4/min sustained. The 61st attempt within the window is denied.
|
|
136
|
+
*/
|
|
137
|
+
export const SIGNUP_MAX_ATTEMPTS = 60;
|
|
112
138
|
/** Sentinel for the IP-extraction priority chain when nothing parsed. */
|
|
113
139
|
export const UNKNOWN_IP_SENTINEL = "unknown";
|
|
114
140
|
|
|
@@ -246,6 +272,16 @@ export const vaultTokenMintRateLimiter = new RateLimiter(
|
|
|
246
272
|
VAULT_TOKEN_MINT_WINDOW_MS,
|
|
247
273
|
);
|
|
248
274
|
|
|
275
|
+
/**
|
|
276
|
+
* `POST /account/setup/<token>` rate limiter — per-IP, 60 attempts / 15 min
|
|
277
|
+
* (public invite redemption / signup). DELIBERATELY generous: a public
|
|
278
|
+
* multi-use signup link is redeemed by a room of people sharing one NAT'd
|
|
279
|
+
* egress IP, so a `/login`-sized cap would 429 legitimate signers mid-demo.
|
|
280
|
+
* Separate bucket from `/login` so the two never share a window. The invite's
|
|
281
|
+
* own `max_uses` + expiry are the primary bound; this is the abuse floor.
|
|
282
|
+
*/
|
|
283
|
+
export const signupRateLimiter = new RateLimiter(SIGNUP_MAX_ATTEMPTS, SIGNUP_WINDOW_MS);
|
|
284
|
+
|
|
249
285
|
/**
|
|
250
286
|
* Backwards-compat shim for hub#188's call sites: the original
|
|
251
287
|
* top-level `checkAndRecord` was the login limiter. New code should
|
|
@@ -266,6 +302,7 @@ export function __resetForTests(): void {
|
|
|
266
302
|
changePasswordRateLimiter.reset();
|
|
267
303
|
totpRateLimiter.reset();
|
|
268
304
|
vaultTokenMintRateLimiter.reset();
|
|
305
|
+
signupRateLimiter.reset();
|
|
269
306
|
}
|
|
270
307
|
|
|
271
308
|
/**
|