@openparachute/hub 0.7.3-rc.2 → 0.7.3-rc.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.3-rc.2",
3
+ "version": "0.7.3-rc.3",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -10,7 +10,9 @@ import {
10
10
  RefreshTokenInsertError,
11
11
  findRefreshToken,
12
12
  findTokenRowByJti,
13
+ linkRotation,
13
14
  listActiveRevocations,
15
+ liveFamilyRefreshRows,
14
16
  recordTokenMint,
15
17
  revokeTokenByJti,
16
18
  signAccessToken,
@@ -623,3 +625,80 @@ describe("token registry (hub#212 Phase 1)", () => {
623
625
  }
624
626
  });
625
627
  });
628
+
629
+ // hub#685 — rotation-grace primitives. The grace decision in
630
+ // `handleTokenRefresh` is built on these two helpers; unit-cover them
631
+ // directly so the security-load-bearing predecessor check has a tight test.
632
+ describe("rotation grace primitives (hub#685)", () => {
633
+ test("rotatedTo is null on a freshly minted row, set by linkRotation", async () => {
634
+ const { db, cleanup } = makeDb();
635
+ try {
636
+ rotateSigningKey(db);
637
+ const u = await createUser(db, "owner", "pw");
638
+ signRefreshToken(db, {
639
+ jti: "pred",
640
+ userId: u.id,
641
+ clientId: "parachute-hub",
642
+ scopes: ["vault:read"],
643
+ familyId: "fam-1",
644
+ });
645
+ expect(findTokenRowByJti(db, "pred")?.rotatedTo).toBeNull();
646
+
647
+ signRefreshToken(db, {
648
+ jti: "succ",
649
+ userId: u.id,
650
+ clientId: "parachute-hub",
651
+ scopes: ["vault:read"],
652
+ familyId: "fam-1",
653
+ });
654
+ linkRotation(db, "pred", "succ");
655
+ expect(findTokenRowByJti(db, "pred")?.rotatedTo).toBe("succ");
656
+ // Successor itself has not rotated yet.
657
+ expect(findTokenRowByJti(db, "succ")?.rotatedTo).toBeNull();
658
+ } finally {
659
+ cleanup();
660
+ }
661
+ });
662
+
663
+ test("liveFamilyRefreshRows returns only un-revoked, un-expired refresh rows in the family", async () => {
664
+ const { db, cleanup } = makeDb();
665
+ try {
666
+ rotateSigningKey(db);
667
+ const u = await createUser(db, "owner", "pw");
668
+ const t0 = new Date("2026-06-24T00:00:00Z");
669
+ for (const jti of ["a", "b", "c"]) {
670
+ signRefreshToken(db, {
671
+ jti,
672
+ userId: u.id,
673
+ clientId: "parachute-hub",
674
+ scopes: ["vault:read"],
675
+ familyId: "fam-x",
676
+ now: () => t0,
677
+ });
678
+ }
679
+ // A different family — must never leak in.
680
+ signRefreshToken(db, {
681
+ jti: "other",
682
+ userId: u.id,
683
+ clientId: "parachute-hub",
684
+ scopes: ["vault:read"],
685
+ familyId: "fam-y",
686
+ now: () => t0,
687
+ });
688
+ // Revoke one of the three.
689
+ revokeTokenByJti(db, "b", t0);
690
+
691
+ const live = liveFamilyRefreshRows(db, "fam-x", t0)
692
+ .map((r) => r.jti)
693
+ .sort();
694
+ expect(live).toEqual(["a", "c"]);
695
+
696
+ // Evaluated PAST the 30-day TTL: every row in the family is expired,
697
+ // so none count as a live tip — the grace path can't rotate into one.
698
+ const afterExpiry = new Date(t0.getTime() + REFRESH_TOKEN_TTL_MS + 1);
699
+ expect(liveFamilyRefreshRows(db, "fam-x", afterExpiry)).toEqual([]);
700
+ } finally {
701
+ cleanup();
702
+ }
703
+ });
704
+ });
@@ -14,7 +14,12 @@ import {
14
14
  openFirstClientAutoApproveWindow,
15
15
  setSetting,
16
16
  } from "../hub-settings.ts";
17
- import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
17
+ import {
18
+ REFRESH_GRACE_MS,
19
+ findTokenRowByJti,
20
+ signRefreshToken,
21
+ validateAccessToken,
22
+ } from "../jwt-sign.ts";
18
23
  import {
19
24
  authorizationServerMetadata,
20
25
  buildServicesCatalog,
@@ -2262,6 +2267,7 @@ describe("handleToken — full OAuth dance", () => {
2262
2267
  refresh_token: initial.refresh_token,
2263
2268
  client_id: reg.client.clientId,
2264
2269
  });
2270
+ const rotateAt = new Date("2026-06-24T00:00:00Z");
2265
2271
  const refreshRes = await handleToken(
2266
2272
  db,
2267
2273
  new Request(`${ISSUER}/oauth/token`, {
@@ -2269,13 +2275,16 @@ describe("handleToken — full OAuth dance", () => {
2269
2275
  body: refreshForm,
2270
2276
  headers: { "content-type": "application/x-www-form-urlencoded" },
2271
2277
  }),
2272
- { issuer: ISSUER },
2278
+ { issuer: ISSUER, now: () => rotateAt },
2273
2279
  );
2274
2280
  expect(refreshRes.status).toBe(200);
2275
2281
  const rotated = (await refreshRes.json()) as { refresh_token: string };
2276
2282
  expect(rotated.refresh_token).not.toBe(initial.refresh_token);
2277
2283
 
2278
- // Old refresh token should now fail (revoked).
2284
+ // Old refresh token replayed PAST the one-generation grace window
2285
+ // should fail (revoked) — the immediate-predecessor grace (hub#685)
2286
+ // only tolerates a replay within REFRESH_GRACE_MS. Replay an hour
2287
+ // later: genuine zero-tolerance rejection.
2279
2288
  const replayRes = await handleToken(
2280
2289
  db,
2281
2290
  new Request(`${ISSUER}/oauth/token`, {
@@ -2283,7 +2292,7 @@ describe("handleToken — full OAuth dance", () => {
2283
2292
  body: refreshForm,
2284
2293
  headers: { "content-type": "application/x-www-form-urlencoded" },
2285
2294
  }),
2286
- { issuer: ISSUER },
2295
+ { issuer: ISSUER, now: () => new Date(rotateAt.getTime() + 60 * 60 * 1000) },
2287
2296
  );
2288
2297
  expect(replayRes.status).toBe(400);
2289
2298
  const err = (await replayRes.json()) as Record<string, unknown>;
@@ -4125,6 +4134,284 @@ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
4125
4134
  }
4126
4135
  });
4127
4136
 
4137
+ // hub#685 — one-generation rotation grace window. A benign concurrent /
4138
+ // retried refresh of the *immediately-previous* (just-rotated) token within
4139
+ // REFRESH_GRACE_MS must NOT revoke the family (multi-tab SPA, bfcache/
4140
+ // stale-tab resume, network retry). Genuine theft — an older ancestor, or
4141
+ // any replay past the window — MUST still revoke the family.
4142
+ describe("rotation grace window (hub#685)", () => {
4143
+ function liveRefreshCount(
4144
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
4145
+ familyId: string,
4146
+ ): number {
4147
+ const r = db
4148
+ .query<{ n: number }, [string]>(
4149
+ "SELECT COUNT(*) AS n FROM tokens WHERE family_id = ? AND revoked_at IS NULL AND refresh_token_hash IS NOT NULL",
4150
+ )
4151
+ .get(familyId);
4152
+ return r?.n ?? -1;
4153
+ }
4154
+
4155
+ async function refreshAt(
4156
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
4157
+ clientId: string,
4158
+ refreshToken: string,
4159
+ at: Date,
4160
+ ): Promise<Response> {
4161
+ return handleToken(
4162
+ db,
4163
+ tokenRequest(
4164
+ new URLSearchParams({
4165
+ grant_type: "refresh_token",
4166
+ refresh_token: refreshToken,
4167
+ client_id: clientId,
4168
+ }),
4169
+ ),
4170
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest, now: () => at },
4171
+ );
4172
+ }
4173
+
4174
+ test("benign concurrent refresh of the immediate predecessor within the window succeeds, no family revocation", async () => {
4175
+ const { db, cleanup } = await makeDb();
4176
+ try {
4177
+ const user = await createUser(db, "owner", "pw");
4178
+ const session = createSession(db, { userId: user.id });
4179
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4180
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4181
+ const family = familyIdFor(db, initial.refresh_token);
4182
+
4183
+ const t0 = new Date("2026-06-24T00:00:00Z");
4184
+ // First (legitimate) rotation at t0 — `initial` becomes the immediate
4185
+ // predecessor; rotated1 is the single live tip.
4186
+ const r1 = await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4187
+ expect(r1.status).toBe(200);
4188
+ const rotated1 = (await r1.json()) as { refresh_token: string };
4189
+ expect(liveRefreshCount(db, family)).toBe(1);
4190
+
4191
+ // A moment later (5s, well within REFRESH_GRACE_MS) a stale tab /
4192
+ // retry replays `initial`. Benign: succeeds, and converges the client
4193
+ // onto the lineage rather than revoking the family.
4194
+ const replay = await refreshAt(
4195
+ db,
4196
+ reg.client.clientId,
4197
+ initial.refresh_token,
4198
+ new Date(t0.getTime() + 5_000),
4199
+ );
4200
+ expect(replay.status).toBe(200);
4201
+ const replayed = (await replay.json()) as { refresh_token: string; access_token: string };
4202
+ expect(replayed.refresh_token).toBeTruthy();
4203
+ expect(replayed.access_token).toBeTruthy();
4204
+
4205
+ // No family-wide revocation: exactly one live refresh row remains
4206
+ // (the grace path rotated the tip → its successor is the new tip).
4207
+ expect(liveRefreshCount(db, family)).toBe(1);
4208
+
4209
+ // The token handed back is usable for a normal subsequent refresh.
4210
+ const next = await refreshAt(
4211
+ db,
4212
+ reg.client.clientId,
4213
+ replayed.refresh_token,
4214
+ new Date(t0.getTime() + 10_000),
4215
+ );
4216
+ expect(next.status).toBe(200);
4217
+ } finally {
4218
+ cleanup();
4219
+ }
4220
+ });
4221
+
4222
+ test("genuine reuse of an OLDER ancestor (two generations back) revokes the family even within the window", async () => {
4223
+ const { db, cleanup } = await makeDb();
4224
+ try {
4225
+ const user = await createUser(db, "owner", "pw");
4226
+ const session = createSession(db, { userId: user.id });
4227
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4228
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4229
+ const family = familyIdFor(db, initial.refresh_token);
4230
+
4231
+ const t0 = new Date("2026-06-24T00:00:00Z");
4232
+ // Two legitimate rotations: initial → rotated1 → rotated2. `initial`
4233
+ // is now an ancestor (its successor rotated1 is itself revoked).
4234
+ const r1 = await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4235
+ const rotated1 = (await r1.json()) as { refresh_token: string };
4236
+ const r2 = await refreshAt(
4237
+ db,
4238
+ reg.client.clientId,
4239
+ rotated1.refresh_token,
4240
+ new Date(t0.getTime() + 1_000),
4241
+ );
4242
+ expect(r2.status).toBe(200);
4243
+
4244
+ // Replay the ANCESTOR `initial` still within the window. Condition (b)
4245
+ // fails — its successor (rotated1) is not the live tip — so this is
4246
+ // treated as theft: family revoked.
4247
+ const replay = await refreshAt(
4248
+ db,
4249
+ reg.client.clientId,
4250
+ initial.refresh_token,
4251
+ new Date(t0.getTime() + 2_000),
4252
+ );
4253
+ expect(replay.status).toBe(400);
4254
+ expect(((await replay.json()) as Record<string, unknown>).error).toBe("invalid_grant");
4255
+ expect(liveRefreshCount(db, family)).toBe(0);
4256
+ } finally {
4257
+ cleanup();
4258
+ }
4259
+ });
4260
+
4261
+ test("replay of the immediate predecessor AFTER the window revokes the family", async () => {
4262
+ const { db, cleanup } = await makeDb();
4263
+ try {
4264
+ const user = await createUser(db, "owner", "pw");
4265
+ const session = createSession(db, { userId: user.id });
4266
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4267
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4268
+ const family = familyIdFor(db, initial.refresh_token);
4269
+
4270
+ const t0 = new Date("2026-06-24T00:00:00Z");
4271
+ const r1 = await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4272
+ expect(r1.status).toBe(200);
4273
+
4274
+ // Replay the immediate predecessor 1ms PAST the window. Condition (a)
4275
+ // fails: zero-tolerance theft handling applies.
4276
+ const replay = await refreshAt(
4277
+ db,
4278
+ reg.client.clientId,
4279
+ initial.refresh_token,
4280
+ new Date(t0.getTime() + REFRESH_GRACE_MS + 1),
4281
+ );
4282
+ expect(replay.status).toBe(400);
4283
+ expect(((await replay.json()) as Record<string, unknown>).error).toBe("invalid_grant");
4284
+ expect(liveRefreshCount(db, family)).toBe(0);
4285
+ } finally {
4286
+ cleanup();
4287
+ }
4288
+ });
4289
+
4290
+ test("window boundary: exactly at the edge succeeds, one ms past fails", async () => {
4291
+ const { db, cleanup } = await makeDb();
4292
+ try {
4293
+ const user = await createUser(db, "owner", "pw");
4294
+ const session = createSession(db, { userId: user.id });
4295
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4296
+
4297
+ // Just inside (=== REFRESH_GRACE_MS): benign success, family intact.
4298
+ const aPair = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4299
+ const aFamily = familyIdFor(db, aPair.refresh_token);
4300
+ const a0 = new Date("2026-06-24T00:00:00Z");
4301
+ await refreshAt(db, reg.client.clientId, aPair.refresh_token, a0);
4302
+ const inEdge = await refreshAt(
4303
+ db,
4304
+ reg.client.clientId,
4305
+ aPair.refresh_token,
4306
+ new Date(a0.getTime() + REFRESH_GRACE_MS),
4307
+ );
4308
+ expect(inEdge.status).toBe(200);
4309
+ expect(liveRefreshCount(db, aFamily)).toBe(1);
4310
+
4311
+ // One ms past the edge: theft handling, family revoked.
4312
+ const bPair = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4313
+ const bFamily = familyIdFor(db, bPair.refresh_token);
4314
+ const b0 = new Date("2026-06-24T01:00:00Z");
4315
+ await refreshAt(db, reg.client.clientId, bPair.refresh_token, b0);
4316
+ const outEdge = await refreshAt(
4317
+ db,
4318
+ reg.client.clientId,
4319
+ bPair.refresh_token,
4320
+ new Date(b0.getTime() + REFRESH_GRACE_MS + 1),
4321
+ );
4322
+ expect(outEdge.status).toBe(400);
4323
+ expect(liveRefreshCount(db, bFamily)).toBe(0);
4324
+ } finally {
4325
+ cleanup();
4326
+ }
4327
+ });
4328
+
4329
+ test("repeated benign replays of the same predecessor each converge on a live tip (idempotent-equivalent)", async () => {
4330
+ const { db, cleanup } = await makeDb();
4331
+ try {
4332
+ const user = await createUser(db, "owner", "pw");
4333
+ const session = createSession(db, { userId: user.id });
4334
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4335
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4336
+ const family = familyIdFor(db, initial.refresh_token);
4337
+
4338
+ const t0 = new Date("2026-06-24T00:00:00Z");
4339
+ await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4340
+
4341
+ // First benign replay of `initial`: succeeds, rotates the tip.
4342
+ const replay1 = await refreshAt(
4343
+ db,
4344
+ reg.client.clientId,
4345
+ initial.refresh_token,
4346
+ new Date(t0.getTime() + 1_000),
4347
+ );
4348
+ expect(replay1.status).toBe(200);
4349
+ expect(liveRefreshCount(db, family)).toBe(1);
4350
+
4351
+ // A SECOND replay of the SAME predecessor `initial`. Its successor is
4352
+ // now revoked (the first replay rotated the tip), so `initial` is no
4353
+ // longer the direct predecessor of the single live tip → theft. The
4354
+ // grace window protects exactly ONE generation, not unbounded replay.
4355
+ const replay2 = await refreshAt(
4356
+ db,
4357
+ reg.client.clientId,
4358
+ initial.refresh_token,
4359
+ new Date(t0.getTime() + 2_000),
4360
+ );
4361
+ expect(replay2.status).toBe(400);
4362
+ expect(liveRefreshCount(db, family)).toBe(0);
4363
+ } finally {
4364
+ cleanup();
4365
+ }
4366
+ });
4367
+
4368
+ test("already-forked family (multiple live tips) does NOT take the grace path — family revoked", async () => {
4369
+ const { db, cleanup } = await makeDb();
4370
+ try {
4371
+ const user = await createUser(db, "owner", "pw");
4372
+ const session = createSession(db, { userId: user.id });
4373
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
4374
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
4375
+ const family = familyIdFor(db, initial.refresh_token);
4376
+
4377
+ const t0 = new Date("2026-06-24T00:00:00Z");
4378
+ // One legitimate rotation → `initial` is the immediate predecessor of
4379
+ // the single live tip (live count == 1, the benign precondition).
4380
+ const r1 = await refreshAt(db, reg.client.clientId, initial.refresh_token, t0);
4381
+ expect(r1.status).toBe(200);
4382
+ expect(liveRefreshCount(db, family)).toBe(1);
4383
+
4384
+ // Inject a SECOND live refresh row into the same family — simulating a
4385
+ // family that has already forked into multiple live lineages (an
4386
+ // already-compromised state). Now live count != 1, so `tip` is null.
4387
+ signRefreshToken(db, {
4388
+ jti: "injected-fork-jti",
4389
+ userId: user.id,
4390
+ clientId: reg.client.clientId,
4391
+ scopes: ["vault:default:read"],
4392
+ familyId: family,
4393
+ now: () => t0,
4394
+ });
4395
+ expect(liveRefreshCount(db, family)).toBe(2);
4396
+
4397
+ // Replay the immediate predecessor WITHIN the window. Even though (a)
4398
+ // holds and `initial.rotatedTo` is set, the single-live-tip check
4399
+ // fails (2 live rows) → no grace → zero-tolerance family revocation.
4400
+ const replay = await refreshAt(
4401
+ db,
4402
+ reg.client.clientId,
4403
+ initial.refresh_token,
4404
+ new Date(t0.getTime() + 1_000),
4405
+ );
4406
+ expect(replay.status).toBe(400);
4407
+ expect(((await replay.json()) as Record<string, unknown>).error).toBe("invalid_grant");
4408
+ expect(liveRefreshCount(db, family)).toBe(0);
4409
+ } finally {
4410
+ cleanup();
4411
+ }
4412
+ });
4413
+ });
4414
+
4128
4415
  test("/oauth/revoke refresh_token: revokes the row, second use rejected", async () => {
4129
4416
  const { db, cleanup } = await makeDb();
4130
4417
  try {
package/src/hub-db.ts CHANGED
@@ -451,6 +451,29 @@ const MIGRATIONS: readonly Migration[] = [
451
451
  ALTER TABLE invites ADD COLUMN username TEXT;
452
452
  `,
453
453
  },
454
+ {
455
+ version: 14,
456
+ sql: `
457
+ -- Refresh-token rotation grace window (hub#685). Records the successor
458
+ -- a refresh-token row rotated INTO, so the refresh handler can tell the
459
+ -- *immediately-previous* token (the single benign concurrent/retried
460
+ -- refresh a multi-tab / bfcache / network-retry client legitimately
461
+ -- presents) apart from a genuine replay of an older ancestor (theft).
462
+ --
463
+ -- \`rotated_to\` is the jti of the row minted when this row rotated. NULL
464
+ -- on every row that has not (yet) rotated: live tokens, revoked-by-
465
+ -- /oauth/revoke tokens, revoked-by-family-sweep tokens, and every
466
+ -- pre-v14 row (no backfill — pre-v14 rotations left no successor link,
467
+ -- so they fall through to the zero-tolerance theft path exactly as
468
+ -- before; the grace window only ever helps rows minted post-v14).
469
+ --
470
+ -- The successor link is the ONLY structural ordering signal we need:
471
+ -- created_at alone can't distinguish the direct predecessor from an
472
+ -- older ancestor under burst issuance (ties), and the grace decision is
473
+ -- security-load-bearing, so it must be exact, not heuristic.
474
+ ALTER TABLE tokens ADD COLUMN rotated_to TEXT;
475
+ `,
476
+ },
454
477
  ];
455
478
 
456
479
  export function openHubDb(path: string = hubDbPath()): Database {
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
+ }
@@ -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 (rare) or stole it (the case we must defend
2227
- // against). Either way: revoke every descendant in the family so the
2228
- // attacker can't keep refreshing, and force the legitimate client to
2229
- // re-authorize. Cheaper than tracking which call was first.
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
- // Rotate: revoke the old refresh row, mint a new access + refresh pair
2243
- // bound to the same family so a future replay of *any* descendant can
2244
- // walk the chain.
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) commit or roll back as
2251
- // a unit, so a mid-rotation crash can't dead-old-without-replacement
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
- return signRefreshToken(db, {
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