@openparachute/hub 0.7.3-rc.1 → 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 +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +113 -0
- package/src/__tests__/jwt-sign.test.ts +79 -0
- package/src/__tests__/oauth-handlers.test.ts +291 -4
- package/src/admin-agent-grants.ts +51 -3
- package/src/hub-db.ts +23 -0
- package/src/jwt-sign.ts +58 -0
- package/src/oauth-flows-store.ts +12 -0
- package/src/oauth-handlers.ts +110 -19
package/package.json
CHANGED
|
@@ -1353,6 +1353,119 @@ describe("GET /oauth/agent-grant/callback", () => {
|
|
|
1353
1353
|
});
|
|
1354
1354
|
});
|
|
1355
1355
|
|
|
1356
|
+
// === returnTo: send the operator back to where they started ================
|
|
1357
|
+
//
|
|
1358
|
+
// The OAuth-consent round-trip used to dead-end on a "close this tab" page. A
|
|
1359
|
+
// same-origin (hub-relative) `returnTo` on the approve body is stashed on the
|
|
1360
|
+
// flow + 302'd to on success (with `?mcp_connected=1`). The open-redirect guard
|
|
1361
|
+
// (`isSafeHubReturnTo`, reused) is the load-bearing property: an off-origin /
|
|
1362
|
+
// scheme-relative value is dropped at stash time and never lands in Location.
|
|
1363
|
+
describe("approve(mcp) + callback — returnTo round-trip", () => {
|
|
1364
|
+
async function startFlowWithBody(body: Record<string, unknown>): Promise<string> {
|
|
1365
|
+
const bearer = await moduleBearer();
|
|
1366
|
+
const cookie = await operatorCookie();
|
|
1367
|
+
const created = await json(
|
|
1368
|
+
await dispatch(
|
|
1369
|
+
bearerReq("PUT", "/admin/grants", bearer, {
|
|
1370
|
+
agent: "a",
|
|
1371
|
+
connection: { kind: "mcp", target: "https://remote.test/mcp" },
|
|
1372
|
+
}),
|
|
1373
|
+
),
|
|
1374
|
+
);
|
|
1375
|
+
const id = created.id as string;
|
|
1376
|
+
await dispatch(cookieReq("POST", `/admin/grants/${id}/approve`, cookie, body));
|
|
1377
|
+
return id;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
test("approve stashes a valid same-origin returnTo on the flow", async () => {
|
|
1381
|
+
currentOAuth = fakeOAuth();
|
|
1382
|
+
await startFlowWithBody({ returnTo: "/admin/grants" });
|
|
1383
|
+
const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
|
|
1384
|
+
expect(flow?.returnTo).toBe("/admin/grants");
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
test("approve drops an off-origin returnTo (absolute URL) — open-redirect guard", async () => {
|
|
1388
|
+
currentOAuth = fakeOAuth();
|
|
1389
|
+
await startFlowWithBody({ returnTo: "https://evil.example/steal" });
|
|
1390
|
+
const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
|
|
1391
|
+
expect(flow?.returnTo).toBeUndefined();
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
test("approve drops a scheme-relative returnTo (//host) — open-redirect guard", async () => {
|
|
1395
|
+
currentOAuth = fakeOAuth();
|
|
1396
|
+
await startFlowWithBody({ returnTo: "//evil.example/steal" });
|
|
1397
|
+
const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
|
|
1398
|
+
expect(flow?.returnTo).toBeUndefined();
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
test("approve drops a `..` path-traversal returnTo — open-redirect guard", async () => {
|
|
1402
|
+
currentOAuth = fakeOAuth();
|
|
1403
|
+
await startFlowWithBody({ returnTo: "/admin/../../etc/passwd" });
|
|
1404
|
+
const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
|
|
1405
|
+
expect(flow?.returnTo).toBeUndefined();
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
test("approve drops a PERCENT-ENCODED `..` returnTo (%2e%2e) — open-redirect guard", async () => {
|
|
1409
|
+
// `new URL()` decodes %2e%2e before emitting the redirect path, so the guard
|
|
1410
|
+
// must reject the encoded form too, not just the literal `..`.
|
|
1411
|
+
currentOAuth = fakeOAuth();
|
|
1412
|
+
await startFlowWithBody({ returnTo: "/%2e%2e/etc/passwd" });
|
|
1413
|
+
const flow = getFlowByState(harness.flowsStorePath, "fixed-state");
|
|
1414
|
+
expect(flow?.returnTo).toBeUndefined();
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
test("callback 302-redirects to a valid returnTo (with mcp_connected=1) on success", async () => {
|
|
1418
|
+
currentOAuth = fakeOAuth();
|
|
1419
|
+
const id = await startFlowWithBody({ returnTo: "/admin/grants?agent=a" });
|
|
1420
|
+
const res = await callback("?code=ok&state=fixed-state");
|
|
1421
|
+
expect(res.status).toBe(302);
|
|
1422
|
+
const location = res.headers.get("location") ?? "";
|
|
1423
|
+
expect(location).toContain("/admin/grants");
|
|
1424
|
+
expect(location).toContain("agent=a");
|
|
1425
|
+
expect(location).toContain("mcp_connected=1");
|
|
1426
|
+
// Same-origin only — never a host/scheme in Location.
|
|
1427
|
+
expect(location.startsWith("/")).toBe(true);
|
|
1428
|
+
expect(location.startsWith("//")).toBe(false);
|
|
1429
|
+
// The grant still flipped to approved (the success side-effects all ran).
|
|
1430
|
+
expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("approved");
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
test("callback overwrites a pre-existing mcp_connected param rather than duplicating it", async () => {
|
|
1434
|
+
currentOAuth = fakeOAuth();
|
|
1435
|
+
await startFlowWithBody({ returnTo: "/admin/grants?mcp_connected=0" });
|
|
1436
|
+
const res = await callback("?code=ok&state=fixed-state");
|
|
1437
|
+
expect(res.status).toBe(302);
|
|
1438
|
+
const location = res.headers.get("location") ?? "";
|
|
1439
|
+
expect(location).toContain("mcp_connected=1");
|
|
1440
|
+
expect(location).not.toContain("mcp_connected=0");
|
|
1441
|
+
// exactly one occurrence (searchParams.set semantics, not append)
|
|
1442
|
+
expect(location.match(/mcp_connected=/g)?.length).toBe(1);
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
test("callback falls back to the close-tab HTML when no returnTo was stashed", async () => {
|
|
1446
|
+
currentOAuth = fakeOAuth();
|
|
1447
|
+
const id = await startFlowWithBody({}); // no returnTo
|
|
1448
|
+
const res = await callback("?code=ok&state=fixed-state");
|
|
1449
|
+
expect(res.status).toBe(200);
|
|
1450
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
1451
|
+
expect(await res.text()).toContain("Connected");
|
|
1452
|
+
expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("approved");
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
test("callback falls back to the close-tab HTML for an unsafe returnTo (never 302s off-origin)", async () => {
|
|
1456
|
+
currentOAuth = fakeOAuth();
|
|
1457
|
+
// The unsafe value was already dropped at stash time, so the callback has no
|
|
1458
|
+
// returnTo to honor — it renders the back-compat page rather than redirecting.
|
|
1459
|
+
const id = await startFlowWithBody({ returnTo: "https://evil.example/steal" });
|
|
1460
|
+
const res = await callback("?code=ok&state=fixed-state");
|
|
1461
|
+
expect(res.status).toBe(200);
|
|
1462
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
1463
|
+
expect(res.headers.get("location")).toBeNull();
|
|
1464
|
+
expect(await res.text()).toContain("Connected");
|
|
1465
|
+
expect(readGrants(harness.storePath).find((r) => r.id === id)?.status).toBe("approved");
|
|
1466
|
+
});
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1356
1469
|
describe("material(mcp) — auto-refresh", () => {
|
|
1357
1470
|
// Drive a grant to approved-via-OAuth, with expiry in the (near) past/future.
|
|
1358
1471
|
async function approvedViaOAuth(expiresAt: string): Promise<string> {
|
|
@@ -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 {
|
|
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
|
|
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 {
|
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
} from "./jwt-sign.ts";
|
|
81
81
|
import { type OAuthClient, deriveVaultScopeFromMcpUrl, realOAuthClient } from "./oauth-client.ts";
|
|
82
82
|
import { type PendingFlow, deleteFlow, getFlowByState, putFlow } from "./oauth-flows-store.ts";
|
|
83
|
+
import { isSafeHubReturnTo } from "./oauth-handlers.ts";
|
|
83
84
|
import { findSession, parseSessionCookie } from "./sessions.ts";
|
|
84
85
|
import { isFirstAdmin } from "./users.ts";
|
|
85
86
|
import { validateVaultName } from "./vault-name.ts";
|
|
@@ -630,10 +631,15 @@ async function approveGrant(req: Request, id: string, deps: AgentGrantsDeps): Pr
|
|
|
630
631
|
const grant = getGrant(deps.storePath, id);
|
|
631
632
|
if (!grant) return jsonError(404, "not_found", `no grant ${id}`);
|
|
632
633
|
|
|
633
|
-
|
|
634
|
+
// `returnTo` is consumed ONLY by the mcp/OAuth path (approveMcpGrant), which
|
|
635
|
+
// is the one approve flow that hands the browser off to a remote consent
|
|
636
|
+
// screen and needs somewhere to land on return. vault/service approvals
|
|
637
|
+
// complete synchronously and return JSON — there's no redirect, so they
|
|
638
|
+
// ignore `returnTo` by design.
|
|
639
|
+
let body: { token?: unknown; returnTo?: unknown } = {};
|
|
634
640
|
try {
|
|
635
641
|
const raw = await req.text();
|
|
636
|
-
if (raw.trim().length > 0) body = JSON.parse(raw) as { token?: unknown };
|
|
642
|
+
if (raw.trim().length > 0) body = JSON.parse(raw) as { token?: unknown; returnTo?: unknown };
|
|
637
643
|
} catch {
|
|
638
644
|
return jsonError(400, "invalid_request", "body must be JSON when present");
|
|
639
645
|
}
|
|
@@ -748,7 +754,7 @@ async function approveGrant(req: Request, id: string, deps: AgentGrantsDeps): Pr
|
|
|
748
754
|
*/
|
|
749
755
|
async function approveMcpGrant(
|
|
750
756
|
grant: GrantRecord,
|
|
751
|
-
body: { token?: unknown },
|
|
757
|
+
body: { token?: unknown; returnTo?: unknown },
|
|
752
758
|
deps: AgentGrantsDeps,
|
|
753
759
|
approvedAt: string,
|
|
754
760
|
): Promise<Response> {
|
|
@@ -850,6 +856,16 @@ async function approveMcpGrant(
|
|
|
850
856
|
? discovery.scopesSupported.join(" ")
|
|
851
857
|
: undefined;
|
|
852
858
|
|
|
859
|
+
// OPTIONAL `returnTo` — the same-origin (hub-relative) page the operator
|
|
860
|
+
// started from (the agent ops surface / admin grants page). Stash it on the
|
|
861
|
+
// flow ONLY when it passes the shared open-redirect guard; absent/invalid →
|
|
862
|
+
// omit it (the callback then renders the back-compat close-tab page). This is
|
|
863
|
+
// the open-redirect-load-bearing check — reuse, don't reinvent.
|
|
864
|
+
const returnTo =
|
|
865
|
+
typeof body.returnTo === "string" && isSafeHubReturnTo(body.returnTo)
|
|
866
|
+
? body.returnTo
|
|
867
|
+
: undefined;
|
|
868
|
+
|
|
853
869
|
const flow: PendingFlow = {
|
|
854
870
|
state,
|
|
855
871
|
grantId: grant.id,
|
|
@@ -861,6 +877,7 @@ async function approveMcpGrant(
|
|
|
861
877
|
mcpUrl,
|
|
862
878
|
...(scope ? { scope } : {}),
|
|
863
879
|
redirectUri,
|
|
880
|
+
...(returnTo ? { returnTo } : {}),
|
|
864
881
|
createdAt: (deps.now?.() ?? new Date()).toISOString(),
|
|
865
882
|
};
|
|
866
883
|
putFlow(deps.flowsStorePath, flow, (deps.now?.() ?? new Date()).getTime());
|
|
@@ -1040,6 +1057,17 @@ export async function handleOAuthGrantCallback(
|
|
|
1040
1057
|
// NEVER log a token.
|
|
1041
1058
|
console.log(`agent grant approved: id=${grant.id} agent=${grant.agent} kind=mcp mode=oauth`);
|
|
1042
1059
|
|
|
1060
|
+
// If the operator started from a hub page (agent ops surface / admin grants),
|
|
1061
|
+
// send them back there instead of a dead-end "close this tab" screen. Re-run
|
|
1062
|
+
// the open-redirect guard DEFENSIVELY at redirect time (the value was already
|
|
1063
|
+
// gated at stash time; this is belt-and-suspenders against a tampered store).
|
|
1064
|
+
// Append `?mcp_connected=1` (preserving any existing query) so the SPA can
|
|
1065
|
+
// react (toast / refetch the grant). Absent/invalid returnTo → the
|
|
1066
|
+
// back-compat close-tab page.
|
|
1067
|
+
if (flow.returnTo && isSafeHubReturnTo(flow.returnTo)) {
|
|
1068
|
+
return redirectToReturn(flow.returnTo);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1043
1071
|
return htmlPage(
|
|
1044
1072
|
200,
|
|
1045
1073
|
"Connected",
|
|
@@ -1047,6 +1075,26 @@ export async function handleOAuthGrantCallback(
|
|
|
1047
1075
|
);
|
|
1048
1076
|
}
|
|
1049
1077
|
|
|
1078
|
+
/**
|
|
1079
|
+
* 302 back to a same-origin (hub-relative) `returnTo`, appending
|
|
1080
|
+
* `mcp_connected=1` so the SPA can show a success toast / refetch. The caller
|
|
1081
|
+
* MUST have already passed `returnTo` through `isSafeHubReturnTo` — this builds
|
|
1082
|
+
* the URL against a fixed dummy origin purely to merge the query param
|
|
1083
|
+
* correctly, then emits only the path+query (never the origin), so a
|
|
1084
|
+
* scheme-relative value can't leak through into the Location header.
|
|
1085
|
+
*/
|
|
1086
|
+
function redirectToReturn(returnTo: string): Response {
|
|
1087
|
+
// Parse against a fixed base so query-string merging is correct even when
|
|
1088
|
+
// returnTo already carries `?...`. Only `pathname + search + hash` is emitted.
|
|
1089
|
+
const u = new URL(returnTo, "http://hub.invalid");
|
|
1090
|
+
u.searchParams.set("mcp_connected", "1");
|
|
1091
|
+
const location = `${u.pathname}${u.search}${u.hash}`;
|
|
1092
|
+
return new Response(null, {
|
|
1093
|
+
status: 302,
|
|
1094
|
+
headers: { location, "cache-control": "no-store" },
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1050
1098
|
/** Minimal server-rendered HTML — NEVER carries a token. */
|
|
1051
1099
|
function htmlPage(status: number, heading: string, message: string): Response {
|
|
1052
1100
|
const html = `<!doctype html>
|
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
|
+
}
|
package/src/oauth-flows-store.ts
CHANGED
|
@@ -52,6 +52,14 @@ export interface PendingFlow {
|
|
|
52
52
|
readonly scope?: string;
|
|
53
53
|
/** The hub's callback URL registered for this flow. */
|
|
54
54
|
readonly redirectUri: string;
|
|
55
|
+
/**
|
|
56
|
+
* OPTIONAL same-origin (hub-relative) page the operator should be sent back
|
|
57
|
+
* to after a SUCCESSFUL consent — the agent ops surface / admin grants page
|
|
58
|
+
* they started from. Validated with `isSafeHubReturnTo` at stash time and
|
|
59
|
+
* (defensively) again at redirect time. Absent for flows started without one
|
|
60
|
+
* (and for all pre-existing on-disk flows — additive + back-compat).
|
|
61
|
+
*/
|
|
62
|
+
readonly returnTo?: string;
|
|
55
63
|
readonly createdAt: string;
|
|
56
64
|
}
|
|
57
65
|
|
|
@@ -62,6 +70,10 @@ interface FlowsFile {
|
|
|
62
70
|
function isPendingFlow(v: unknown): v is PendingFlow {
|
|
63
71
|
if (!v || typeof v !== "object") return false;
|
|
64
72
|
const f = v as Record<string, unknown>;
|
|
73
|
+
// `returnTo` is OPTIONAL + additive — absent on every pre-existing on-disk
|
|
74
|
+
// flow. Accept undefined; if present it must be a string (validated for
|
|
75
|
+
// open-redirect safety at the call sites, not here).
|
|
76
|
+
if (f.returnTo !== undefined && typeof f.returnTo !== "string") return false;
|
|
65
77
|
return (
|
|
66
78
|
typeof f.state === "string" &&
|
|
67
79
|
typeof f.grantId === "string" &&
|
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,
|
|
@@ -1883,6 +1887,39 @@ export async function handleApproveClientPost(
|
|
|
1883
1887
|
return redirectResponse(returnTo);
|
|
1884
1888
|
}
|
|
1885
1889
|
|
|
1890
|
+
/**
|
|
1891
|
+
* The load-bearing open-redirect guard, shared by every hub `return_to` gate.
|
|
1892
|
+
* A safe hub-relative target is a SINGLE-slash root-relative path: same-origin
|
|
1893
|
+
* to the hub, no scheme, no `//host` scheme-relative escape. Empty string is
|
|
1894
|
+
* rejected.
|
|
1895
|
+
*
|
|
1896
|
+
* This is the same-origin core that `isSafeAuthorizeReturnTo` (OAuth-resume)
|
|
1897
|
+
* builds on, and that broader same-origin gates (the agent-grant OAuth
|
|
1898
|
+
* callback's `returnTo`) reuse directly — they need to return the operator to
|
|
1899
|
+
* a `/admin/...` page, not specifically `/oauth/authorize`. Single source of
|
|
1900
|
+
* truth for "is this an open redirect?" — do NOT reimplement the
|
|
1901
|
+
* `startsWith("/") && !startsWith("//")` check elsewhere.
|
|
1902
|
+
*/
|
|
1903
|
+
export function isSafeHubReturnTo(value: string): boolean {
|
|
1904
|
+
if (!value) return false;
|
|
1905
|
+
// Reject scheme-relative ("//evil.example/foo") and absolute URLs. Only
|
|
1906
|
+
// single-slash root-relative paths are allowed.
|
|
1907
|
+
if (!value.startsWith("/") || value.startsWith("//")) return false;
|
|
1908
|
+
// Reject `..` traversal sequences — literal AND percent-encoded (`%2e%2e`),
|
|
1909
|
+
// since redirectToReturn's `new URL()` parse decodes them before emitting the
|
|
1910
|
+
// path. They stay same-origin but a target that walks the path tree is never a
|
|
1911
|
+
// legitimate return-to and lets a crafted value reach an unexpected hub path.
|
|
1912
|
+
// (`/oauth/authorize?…` callers never carry `..`, so this is free for them.)
|
|
1913
|
+
if (value.includes("..")) return false;
|
|
1914
|
+
let decoded: string;
|
|
1915
|
+
try {
|
|
1916
|
+
decoded = decodeURIComponent(value);
|
|
1917
|
+
} catch {
|
|
1918
|
+
return false; // malformed percent-encoding → reject rather than guess
|
|
1919
|
+
}
|
|
1920
|
+
return !decoded.includes("..");
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1886
1923
|
/**
|
|
1887
1924
|
* Validate a form-submitted `return_to` value. Must be a hub-relative URL
|
|
1888
1925
|
* (no scheme, no double-slash) targeting `/oauth/authorize` with a query
|
|
@@ -1895,13 +1932,10 @@ export async function handleApproveClientPost(
|
|
|
1895
1932
|
* valid OAuth-resume target?" for the whole hub.
|
|
1896
1933
|
*/
|
|
1897
1934
|
export function isSafeAuthorizeReturnTo(value: string): boolean {
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
//
|
|
1901
|
-
|
|
1902
|
-
// Must target the authorize endpoint with a query string. The OAuth flow
|
|
1903
|
-
// re-enters via GET /oauth/authorize?<original-params>; anything off-path
|
|
1904
|
-
// is a misuse.
|
|
1935
|
+
// Same-origin open-redirect guard first (shared single source of truth)…
|
|
1936
|
+
if (!isSafeHubReturnTo(value)) return false;
|
|
1937
|
+
// …then the authorize-specific path constraint: the OAuth flow re-enters
|
|
1938
|
+
// via GET /oauth/authorize?<original-params>; anything off-path is a misuse.
|
|
1905
1939
|
return value.startsWith("/oauth/authorize?");
|
|
1906
1940
|
}
|
|
1907
1941
|
|
|
@@ -2193,10 +2227,46 @@ async function handleTokenRefresh(
|
|
|
2193
2227
|
// Replay of an already-rotated refresh token. Per RFC 6819 §5.2.2.3 the
|
|
2194
2228
|
// working assumption is theft — the legitimate client received a new
|
|
2195
2229
|
// refresh token at the prior rotation, so anyone presenting the old one
|
|
2196
|
-
// either lost a race
|
|
2197
|
-
//
|
|
2198
|
-
//
|
|
2199
|
-
//
|
|
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.
|
|
2200
2270
|
revokeFamily(db, row.familyId, now);
|
|
2201
2271
|
return jsonResponse(
|
|
2202
2272
|
{ error: "invalid_grant", error_description: "refresh_token revoked" },
|
|
@@ -2209,17 +2279,34 @@ async function handleTokenRefresh(
|
|
|
2209
2279
|
400,
|
|
2210
2280
|
);
|
|
2211
2281
|
}
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
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 ?? "";
|
|
2216
2303
|
// Mint the access token *before* opening the rotation transaction. JWT
|
|
2217
2304
|
// signing is async (jose returns a Promise) and bun:sqlite's
|
|
2218
2305
|
// `db.transaction()` is sync — running async work inside the closure
|
|
2219
2306
|
// would silently break atomicity. Once we have the JWT, the UPDATE
|
|
2220
|
-
// (revoke old) + INSERT (mint new refresh row)
|
|
2221
|
-
// a unit, so a mid-rotation crash can't
|
|
2222
|
-
// (#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).
|
|
2223
2310
|
const audience = inferAudience(row.scopes);
|
|
2224
2311
|
const access = await signAccessToken(db, {
|
|
2225
2312
|
sub: refreshUserId,
|
|
@@ -2241,7 +2328,7 @@ async function handleTokenRefresh(
|
|
|
2241
2328
|
try {
|
|
2242
2329
|
refresh = db.transaction(() => {
|
|
2243
2330
|
db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
|
|
2244
|
-
|
|
2331
|
+
const minted = signRefreshToken(db, {
|
|
2245
2332
|
jti: access.jti,
|
|
2246
2333
|
userId: refreshUserId,
|
|
2247
2334
|
clientId: row.clientId,
|
|
@@ -2249,6 +2336,10 @@ async function handleTokenRefresh(
|
|
|
2249
2336
|
familyId: row.familyId,
|
|
2250
2337
|
now: deps.now,
|
|
2251
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;
|
|
2252
2343
|
})();
|
|
2253
2344
|
} catch (err) {
|
|
2254
2345
|
// Concurrent rotation: a sibling refresh of the same row already
|