@openparachute/hub 0.7.2 → 0.7.3-rc.10

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.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-agent-grants.test.ts +113 -0
  3. package/src/__tests__/admin-vaults.test.ts +20 -5
  4. package/src/__tests__/api-modules.test.ts +65 -10
  5. package/src/__tests__/api-vault-caps.test.ts +232 -0
  6. package/src/__tests__/cli.test.ts +20 -0
  7. package/src/__tests__/hub-command.test.ts +136 -0
  8. package/src/__tests__/hub-origins-env-set.test.ts +273 -0
  9. package/src/__tests__/init.test.ts +148 -0
  10. package/src/__tests__/jwt-sign.test.ts +79 -0
  11. package/src/__tests__/module-manifest.test.ts +3 -1
  12. package/src/__tests__/oauth-client.test.ts +75 -0
  13. package/src/__tests__/oauth-handlers.test.ts +413 -5
  14. package/src/__tests__/public-signup.test.ts +619 -0
  15. package/src/__tests__/rate-limit.test.ts +86 -0
  16. package/src/__tests__/scribe-config.test.ts +117 -0
  17. package/src/__tests__/serve-boot.test.ts +45 -0
  18. package/src/__tests__/serve.test.ts +67 -1
  19. package/src/__tests__/service-spec-discovery.test.ts +22 -2
  20. package/src/__tests__/setup-wizard.test.ts +18 -0
  21. package/src/__tests__/setup.test.ts +129 -15
  22. package/src/__tests__/two-factor-flow.test.ts +94 -0
  23. package/src/__tests__/users.test.ts +33 -0
  24. package/src/__tests__/vault-caps.test.ts +89 -0
  25. package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
  26. package/src/__tests__/wizard-transcription.test.ts +334 -0
  27. package/src/__tests__/wizard.test.ts +146 -0
  28. package/src/account-setup.ts +118 -32
  29. package/src/admin-agent-grants.ts +51 -3
  30. package/src/admin-handlers.ts +53 -16
  31. package/src/admin-login-ui.ts +30 -4
  32. package/src/admin-vaults.ts +9 -1
  33. package/src/api-invites.ts +163 -3
  34. package/src/api-modules-ops.ts +12 -0
  35. package/src/api-modules.ts +47 -13
  36. package/src/api-users.ts +3 -0
  37. package/src/api-vault-caps.ts +206 -0
  38. package/src/cli.ts +35 -1
  39. package/src/commands/hub.ts +173 -0
  40. package/src/commands/init.ts +73 -2
  41. package/src/commands/serve-boot.ts +16 -2
  42. package/src/commands/serve.ts +39 -3
  43. package/src/commands/setup.ts +31 -6
  44. package/src/commands/wizard-transcription.ts +296 -0
  45. package/src/commands/wizard.ts +73 -3
  46. package/src/help.ts +8 -0
  47. package/src/hub-db.ts +108 -1
  48. package/src/hub-origin.ts +64 -0
  49. package/src/hub-server.ts +27 -0
  50. package/src/invites.ts +155 -31
  51. package/src/jwt-sign.ts +72 -3
  52. package/src/module-manifest.ts +23 -9
  53. package/src/oauth-client.ts +102 -12
  54. package/src/oauth-flows-store.ts +12 -0
  55. package/src/oauth-handlers.ts +145 -20
  56. package/src/rate-limit.ts +111 -20
  57. package/src/scribe-config.ts +145 -0
  58. package/src/service-spec.ts +31 -12
  59. package/src/setup-wizard.ts +37 -4
  60. package/src/users.ts +62 -3
  61. package/src/vault-caps.ts +109 -0
  62. package/src/vault-hub-origin-env.ts +109 -16
  63. package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
  64. package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
  65. package/web/ui/dist/index.html +2 -2
  66. package/web/ui/dist/assets/index-B5AUE359.js +0 -61
@@ -66,16 +66,26 @@ export interface ConfigSchema {
66
66
  }
67
67
 
68
68
  /**
69
- * Discovery tier (2026-06-09 modular-UI architecture). `core` modules are the
70
- * product surface (vault / scribe / hub / surface); `experimental` modules
71
- * (channel / runner / others) render in a de-emphasized group on the Modules
72
- * screen. **Show all; never hide** `focus` only sorts + labels.
69
+ * Discovery tier (2026-06-09 modular-UI architecture). Three tiers today:
70
+ * - `core` — the product surface (vault / scribe / hub / surface).
71
+ * - `experimental` legit previews (agent) that render in a de-emphasized
72
+ * group but ARE offered on a fresh install.
73
+ * - `deprecated` — modules retained for back-compat (notes-daemon, runner)
74
+ * that stay resolvable + shown-if-installed (so an existing operator can
75
+ * still manage / uninstall them) but are NOT offered on a fresh setup
76
+ * (2026-06-25). Distinct from a fully retired module (`RETIRED_MODULES`),
77
+ * whose services.json row is GC'd on load.
78
+ *
79
+ * **Show all installed; never hide** — `focus` only sorts + labels for an
80
+ * installed/known module. The one behavioral lever it pulls is the fresh-install
81
+ * OFFER: `deprecated` shorts are dropped from the setup wizard + the admin SPA's
82
+ * "available to install" set.
73
83
  *
74
84
  * Absent in a `module.json` ⇒ the hub falls back to its default map (see
75
85
  * `service-spec.focusForShort`), which defaults unlisted modules to
76
86
  * `experimental`.
77
87
  */
78
- export type ModuleFocus = "core" | "experimental";
88
+ export type ModuleFocus = "core" | "experimental" | "deprecated";
79
89
 
80
90
  /**
81
91
  * An event a module EMITS — the left-hand side of a Connection (2026-06-09
@@ -324,8 +334,10 @@ export interface ModuleManifest {
324
334
  * Discovery tier (2026-06-09 modular-UI architecture). When a module
325
335
  * declares `focus`, the hub's Modules screen uses it verbatim; otherwise it
326
336
  * falls back to `service-spec.focusForShort` (vault/scribe/hub/surface →
327
- * `core`, everything else → `experimental`). **Show all; never hide** —
328
- * `focus` only groups + de-emphasizes. Additive + back-compatible.
337
+ * `core`, notes/runner → `deprecated`, everything else → `experimental`).
338
+ * **Show all installed; never hide** — `focus` groups + de-emphasizes; the
339
+ * one behavioral lever is the fresh-install OFFER, which excludes
340
+ * `deprecated`. Additive + back-compatible.
329
341
  */
330
342
  readonly focus?: ModuleFocus;
331
343
  /**
@@ -711,12 +723,14 @@ function asCredentials(v: unknown, where: string): readonly ModuleCredential[] |
711
723
  });
712
724
  }
713
725
 
714
- const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental"]);
726
+ const MODULE_FOCUS_VALUES = new Set<ModuleFocus>(["core", "experimental", "deprecated"]);
715
727
 
716
728
  function asFocus(v: unknown, where: string): ModuleFocus | undefined {
717
729
  if (v === undefined) return undefined;
718
730
  if (typeof v !== "string" || !MODULE_FOCUS_VALUES.has(v as ModuleFocus)) {
719
- throw new ModuleManifestError(`${where}: "focus" must be "core" | "experimental" if present`);
731
+ throw new ModuleManifestError(
732
+ `${where}: "focus" must be "core" | "experimental" | "deprecated" if present`,
733
+ );
720
734
  }
721
735
  return v as ModuleFocus;
722
736
  }
@@ -28,6 +28,15 @@ export type FetchFn = typeof fetch;
28
28
  /** Default outbound timeout for every OAuth-client request. */
29
29
  const DEFAULT_TIMEOUT_MS = 15_000;
30
30
 
31
+ /**
32
+ * Shorter timeout for best-effort DISCOVERY probes. Discovery tries several
33
+ * well-known candidates in priority order (path-inserted, host-root, OIDC), so a
34
+ * full 15s per probe could stack into a long wall-clock wait on a slow/firewalled
35
+ * remote. These metadata GETs are quick when they exist; a tight ceiling bounds
36
+ * the worst case while still tolerating a sluggish responder.
37
+ */
38
+ const DISCOVERY_PROBE_TIMEOUT_MS = 6_000;
39
+
31
40
  /**
32
41
  * `fetch` with an AbortController timeout. The hub has no shared fetch wrapper,
33
42
  * so this lives here for all outbound OAuth-client calls — a slow/hung remote
@@ -93,10 +102,15 @@ async function fetchJson(
93
102
  url: string,
94
103
  fetchFn: FetchFn,
95
104
  what: string,
105
+ timeoutMs?: number,
96
106
  ): Promise<Record<string, unknown>> {
97
107
  let res: Response;
98
108
  try {
99
- res = await fetchWithTimeout(url, { headers: { accept: "application/json" } }, fetchFn);
109
+ res = await fetchWithTimeout(
110
+ url,
111
+ { headers: { accept: "application/json" }, ...(timeoutMs ? { timeout: timeoutMs } : {}) },
112
+ fetchFn,
113
+ );
100
114
  } catch (err) {
101
115
  throw new OAuthClientError(`${what}: request to ${url} failed`, err);
102
116
  }
@@ -125,44 +139,120 @@ function asStringArray(v: unknown): string[] | undefined {
125
139
  return out.length > 0 ? out : undefined;
126
140
  }
127
141
 
142
+ /**
143
+ * RFC 9728 §3.1 / RFC 8414 §3.1 well-known URL candidates for a resource or
144
+ * issuer URL. The spec INSERTS the well-known segment after the host while
145
+ * PRESERVING the URL's path: `https://h/p` → `https://h/.well-known/<seg>/p`.
146
+ * We try the path-inserted form FIRST (correct when the resource/issuer carries
147
+ * a path — e.g. an MCP served at `/mcp`, or a multi-tenant issuer at `/tenant`),
148
+ * then the host-root form (`https://h/.well-known/<seg>`) as a fallback for
149
+ * path-less deployments + older servers. De-duplicated, in priority order.
150
+ */
151
+ function wellKnownCandidates(url: string, segment: string): string[] {
152
+ const u = new URL(url);
153
+ // The query string is intentionally excluded (u.pathname omits it) — RFC 9728
154
+ // §3.1 builds the well-known URL from the resource PATH only.
155
+ const path = u.pathname.replace(/\/+$/, ""); // drop trailing slash(es)
156
+ const root = `${u.origin}/.well-known/${segment}`;
157
+ const out = path ? [`${u.origin}/.well-known/${segment}${path}`, root] : [root];
158
+ return [...new Set(out)];
159
+ }
160
+
161
+ /**
162
+ * Fetch the first candidate URL (in priority order) that returns a valid JSON
163
+ * object. Lets discovery probe the path-inserted well-known location first, then
164
+ * fall back to the host root. If EVERY candidate fails, throws an
165
+ * OAuthClientError naming all tried locations (so a failure points at every
166
+ * probe, not just the last).
167
+ */
168
+ async function fetchJsonFirst(
169
+ urls: string[],
170
+ fetchFn: FetchFn,
171
+ what: string,
172
+ timeoutMs?: number,
173
+ ): Promise<Record<string, unknown>> {
174
+ const errors: string[] = [];
175
+ for (const url of urls) {
176
+ try {
177
+ return await fetchJson(url, fetchFn, what, timeoutMs);
178
+ } catch (err) {
179
+ errors.push(err instanceof Error ? err.message : String(err));
180
+ }
181
+ }
182
+ throw new OAuthClientError(
183
+ `${what}: no metadata found — tried ${urls.length} location(s): ${errors.join(" | ")}`,
184
+ );
185
+ }
186
+
128
187
  /**
129
188
  * Discover the issuer + endpoints for a remote MCP URL.
130
189
  *
131
- * 1. RFC 9728: GET <mcp-origin>/.well-known/oauth-protected-resource →
132
- * authorization_servers[0] = issuer (+ scopes_supported).
133
- * 2. RFC 8414: GET <issuer>/.well-known/oauth-authorization-server
190
+ * 1. RFC 9728: GET <mcp>/.well-known/oauth-protected-resource[/<path>]
191
+ * authorization_servers[0] = issuer (+ scopes_supported). The path-inserted
192
+ * location is tried first (the modern MCP shape: an MCP at `/mcp` advertises
193
+ * at `/.well-known/oauth-protected-resource/mcp`, and its auth server often
194
+ * lives on a SEPARATE host), then the host root.
195
+ * 2. RFC 8414 / OIDC: GET <issuer>/.well-known/oauth-authorization-server then
196
+ * /.well-known/openid-configuration (each path-inserted then host-root) →
134
197
  * authorization_endpoint, token_endpoint, registration_endpoint,
135
198
  * revocation_endpoint (+ scopes_supported fallback).
136
199
  *
137
200
  * If the resource has no 9728 doc, falls back to treating the MCP origin itself
138
- * as the issuer and reading its 8414 doc directly (8414-only resources).
201
+ * as the issuer and reading its 8414/OIDC doc directly (8414-only resources).
139
202
  */
140
203
  export async function discover(mcpUrl: string, fetchFn: FetchFn = fetch): Promise<DiscoveryResult> {
141
204
  const origin = originOf(mcpUrl);
142
205
 
143
- // Step 1 — RFC 9728 (best-effort; some resources only expose 8414).
206
+ // Step 1 — RFC 9728 protected-resource metadata (best-effort; some resources
207
+ // only expose 8414). Probe the PATH-INSERTED location first (an MCP served at
208
+ // `/mcp` advertises at `/.well-known/oauth-protected-resource/mcp`), then the
209
+ // host root — this is what lets a resource whose auth server lives on a
210
+ // SEPARATE host be discovered at all.
144
211
  let issuer: string | undefined;
145
212
  let prScopes: string[] | undefined;
146
213
  try {
147
- const pr = await fetchJson(
148
- `${origin}/.well-known/oauth-protected-resource`,
214
+ const pr = await fetchJsonFirst(
215
+ wellKnownCandidates(mcpUrl, "oauth-protected-resource"),
149
216
  fetchFn,
150
217
  "protected-resource discovery",
218
+ DISCOVERY_PROBE_TIMEOUT_MS,
151
219
  );
152
220
  const servers = asStringArray(pr.authorization_servers);
153
221
  issuer = servers?.[0];
154
222
  prScopes = asStringArray(pr.scopes_supported);
155
223
  } catch {
156
- // No 9728 doc — fall back to the MCP origin as issuer.
224
+ // No 9728 doc anywhere — fall back to the MCP origin as issuer.
157
225
  issuer = undefined;
158
226
  }
159
227
  if (!issuer) issuer = origin;
160
228
 
161
- // Step 2 RFC 8414 on the issuer.
162
- const as = await fetchJson(
163
- `${issuer.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`,
229
+ // The issuer comes from the (attacker-influenceable) PRM doc — validate it
230
+ // parses as a URL so a malformed value surfaces as an OAuthClientError rather
231
+ // than a raw TypeError out of the candidate construction below.
232
+ let issuerUrl: URL;
233
+ try {
234
+ issuerUrl = new URL(issuer);
235
+ } catch {
236
+ throw new OAuthClientError(`authorization_servers[0] is not a valid URL: ${issuer}`);
237
+ }
238
+
239
+ // Step 2 — RFC 8414 / OIDC metadata on the issuer. Path-inserted before host
240
+ // root, and `oauth-authorization-server` before `openid-configuration`, so an
241
+ // issuer that sits at a path or only serves OIDC discovery still resolves. Plus
242
+ // the OIDC Discovery 1.0 §4.1 APPEND form (`<issuer-with-path>/.well-known/
243
+ // openid-configuration`) for a path-ful issuer — distinct from the 8414 INSERT.
244
+ const asCandidates = [
245
+ ...wellKnownCandidates(issuer, "oauth-authorization-server"),
246
+ ...wellKnownCandidates(issuer, "openid-configuration"),
247
+ ];
248
+ if (issuerUrl.pathname.replace(/\/+$/, "")) {
249
+ asCandidates.push(`${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`);
250
+ }
251
+ const as = await fetchJsonFirst(
252
+ [...new Set(asCandidates)],
164
253
  fetchFn,
165
254
  "authorization-server discovery",
255
+ DISCOVERY_PROBE_TIMEOUT_MS,
166
256
  );
167
257
 
168
258
  const authorizationEndpoint = asString(as.authorization_endpoint);
@@ -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" &&
@@ -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,
@@ -68,6 +72,12 @@ import {
68
72
  } from "./oauth-ui.ts";
69
73
  import { isSameOriginRequest } from "./origin-check.ts";
70
74
  import { buildPendingLoginCookie, createPendingLogin } from "./pending-login.ts";
75
+ import {
76
+ authIpCeilingRateLimiter,
77
+ clientIpFromRequest,
78
+ compositeKey,
79
+ loginRateLimiter,
80
+ } from "./rate-limit.ts";
71
81
  import { isHttpsRequest } from "./request-protocol.ts";
72
82
  import { narrowResourceVaultScopes, resolveResourceVault } from "./resource-binding.ts";
73
83
  import { isNonRequestableScope, isRequestableScope, scopeIsAdmin } from "./scope-explanations.ts";
@@ -1382,12 +1392,40 @@ async function handleLoginSubmit(
1382
1392
  db: Database,
1383
1393
  req: Request,
1384
1394
  form: Awaited<ReturnType<Request["formData"]>>,
1385
- _deps: OAuthDeps,
1395
+ deps: OAuthDeps,
1386
1396
  csrfToken: string,
1387
1397
  ): Promise<Response> {
1388
1398
  const username = String(form.get("username") ?? "");
1389
1399
  const password = String(form.get("password") ?? "");
1390
1400
  const params = paramsFromForm(form);
1401
+ // Rate-limit gate — AFTER CSRF (verified by the `handleAuthorizePost` caller)
1402
+ // and BEFORE the credential check. This `/oauth/authorize` password door had
1403
+ // NO rate-limit before; without it an attacker got an unbounded brute-force
1404
+ // channel that bypassed `/login`'s floor entirely. Two tiers, reusing the
1405
+ // SAME `loginRateLimiter` instance + the SAME `compositeKey(ip, username)`
1406
+ // scheme as `/login`, so the two password doors share ONE per-account bucket
1407
+ // (an attacker can't get 5 tries at `/login` PLUS another 5 here). The coarse
1408
+ // per-IP CEILING backstops username-rotation; the per-(ip,username) FLOOR is
1409
+ // the brute-force floor.
1410
+ const clientIp = clientIpFromRequest(req);
1411
+ const now = deps.now ? deps.now() : new Date();
1412
+ const ceiling = authIpCeilingRateLimiter.checkAndRecord(clientIp, now);
1413
+ const floor = loginRateLimiter.checkAndRecord(compositeKey(clientIp, username), now);
1414
+ if (!ceiling.allowed || !floor.allowed) {
1415
+ const retryAfterSeconds = Math.max(
1416
+ ceiling.allowed ? 0 : (ceiling.retryAfterSeconds ?? 1),
1417
+ floor.allowed ? 0 : (floor.retryAfterSeconds ?? 1),
1418
+ );
1419
+ return htmlResponse(
1420
+ renderLogin({
1421
+ params,
1422
+ csrfToken,
1423
+ errorMessage: `Too many login attempts. Try again in ${retryAfterSeconds} seconds.`,
1424
+ }),
1425
+ 429,
1426
+ { "retry-after": String(retryAfterSeconds) },
1427
+ );
1428
+ }
1391
1429
  if (!username || !password) {
1392
1430
  return htmlResponse(
1393
1431
  renderLogin({ params, csrfToken, errorMessage: "Username and password are required." }),
@@ -1883,6 +1921,39 @@ export async function handleApproveClientPost(
1883
1921
  return redirectResponse(returnTo);
1884
1922
  }
1885
1923
 
1924
+ /**
1925
+ * The load-bearing open-redirect guard, shared by every hub `return_to` gate.
1926
+ * A safe hub-relative target is a SINGLE-slash root-relative path: same-origin
1927
+ * to the hub, no scheme, no `//host` scheme-relative escape. Empty string is
1928
+ * rejected.
1929
+ *
1930
+ * This is the same-origin core that `isSafeAuthorizeReturnTo` (OAuth-resume)
1931
+ * builds on, and that broader same-origin gates (the agent-grant OAuth
1932
+ * callback's `returnTo`) reuse directly — they need to return the operator to
1933
+ * a `/admin/...` page, not specifically `/oauth/authorize`. Single source of
1934
+ * truth for "is this an open redirect?" — do NOT reimplement the
1935
+ * `startsWith("/") && !startsWith("//")` check elsewhere.
1936
+ */
1937
+ export function isSafeHubReturnTo(value: string): boolean {
1938
+ if (!value) return false;
1939
+ // Reject scheme-relative ("//evil.example/foo") and absolute URLs. Only
1940
+ // single-slash root-relative paths are allowed.
1941
+ if (!value.startsWith("/") || value.startsWith("//")) return false;
1942
+ // Reject `..` traversal sequences — literal AND percent-encoded (`%2e%2e`),
1943
+ // since redirectToReturn's `new URL()` parse decodes them before emitting the
1944
+ // path. They stay same-origin but a target that walks the path tree is never a
1945
+ // legitimate return-to and lets a crafted value reach an unexpected hub path.
1946
+ // (`/oauth/authorize?…` callers never carry `..`, so this is free for them.)
1947
+ if (value.includes("..")) return false;
1948
+ let decoded: string;
1949
+ try {
1950
+ decoded = decodeURIComponent(value);
1951
+ } catch {
1952
+ return false; // malformed percent-encoding → reject rather than guess
1953
+ }
1954
+ return !decoded.includes("..");
1955
+ }
1956
+
1886
1957
  /**
1887
1958
  * Validate a form-submitted `return_to` value. Must be a hub-relative URL
1888
1959
  * (no scheme, no double-slash) targeting `/oauth/authorize` with a query
@@ -1895,13 +1966,10 @@ export async function handleApproveClientPost(
1895
1966
  * valid OAuth-resume target?" for the whole hub.
1896
1967
  */
1897
1968
  export function isSafeAuthorizeReturnTo(value: string): boolean {
1898
- if (!value) return false;
1899
- // Reject scheme-relative ("//evil.example/foo") and absolute URLs. Only
1900
- // single-slash root-relative paths are allowed.
1901
- if (!value.startsWith("/") || value.startsWith("//")) return false;
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.
1969
+ // Same-origin open-redirect guard first (shared single source of truth)…
1970
+ if (!isSafeHubReturnTo(value)) return false;
1971
+ // …then the authorize-specific path constraint: the OAuth flow re-enters
1972
+ // via GET /oauth/authorize?<original-params>; anything off-path is a misuse.
1905
1973
  return value.startsWith("/oauth/authorize?");
1906
1974
  }
1907
1975
 
@@ -2193,10 +2261,46 @@ async function handleTokenRefresh(
2193
2261
  // Replay of an already-rotated refresh token. Per RFC 6819 §5.2.2.3 the
2194
2262
  // working assumption is theft — the legitimate client received a new
2195
2263
  // refresh token at the prior rotation, so anyone presenting the old one
2196
- // either lost a race (rare) or stole it (the case we must defend
2197
- // against). Either way: revoke every descendant in the family so the
2198
- // attacker can't keep refreshing, and force the legitimate client to
2199
- // re-authorize. Cheaper than tracking which call was first.
2264
+ // either lost a race or stole it (the case we must defend against).
2265
+ //
2266
+ // ONE-GENERATION GRACE WINDOW (hub#685). Benign concurrent/retried
2267
+ // refreshes — a multi-tab SPA, a bfcache/stale-tab resume, a network
2268
+ // retry — legitimately present the *just-rotated* token a moment after
2269
+ // it rotated. To avoid forcing those real clients to re-login, we let
2270
+ // through the SINGLE immediately-previous token, and ONLY it, for
2271
+ // ~REFRESH_GRACE_MS. The grace case rotates the live tip and converges
2272
+ // the replaying client onto the current token; it does NOT mint a
2273
+ // second live token into a forked lineage.
2274
+ //
2275
+ // The window is narrow ON PURPOSE — both conditions must hold:
2276
+ // (a) WINDOW: this row was revoked within the last REFRESH_GRACE_MS.
2277
+ // (b) IMMEDIATE-PREDECESSOR: this row's recorded successor (rotated_to)
2278
+ // IS the family's single live refresh row. That proves it's the
2279
+ // direct parent of the current tip — not an older ancestor (whose
2280
+ // successor is itself revoked) and not a sibling fork.
2281
+ //
2282
+ // HARD SECURITY INVARIANT: anything outside (a)∧(b) — an OLDER/ancestor
2283
+ // token, any replay past the window, a family with zero or multiple live
2284
+ // tips (already a theft-revoked or forked family) — falls through to the
2285
+ // unchanged zero-tolerance path: revokeFamily + invalid_grant. The grace
2286
+ // window NEVER tolerates more than the one immediate predecessor, and
2287
+ // never beyond REFRESH_GRACE_MS.
2288
+ const live = liveFamilyRefreshRows(db, row.familyId, now);
2289
+ const revokedAtMs = new Date(row.revokedAt).getTime();
2290
+ const withinWindow =
2291
+ now.getTime() - revokedAtMs <= REFRESH_GRACE_MS && now.getTime() >= revokedAtMs;
2292
+ const tip = live.length === 1 ? live[0]! : null;
2293
+ const isImmediatePredecessor =
2294
+ tip !== null && row.rotatedTo !== null && row.rotatedTo === tip.jti;
2295
+ if (withinWindow && isImmediatePredecessor && tip) {
2296
+ // Benign replay of the immediate predecessor. Rotate the live tip and
2297
+ // hand the client the new pair — it converges on the current lineage
2298
+ // (single live token preserved), no family revocation. Equivalent to
2299
+ // what the client would have received had it presented the tip itself.
2300
+ return await rotateAndRespond(db, tip, deps, now);
2301
+ }
2302
+ // Genuine theft (or a too-late replay): revoke every descendant so the
2303
+ // attacker can't keep refreshing, and force re-authorization.
2200
2304
  revokeFamily(db, row.familyId, now);
2201
2305
  return jsonResponse(
2202
2306
  { error: "invalid_grant", error_description: "refresh_token revoked" },
@@ -2209,17 +2313,34 @@ async function handleTokenRefresh(
2209
2313
  400,
2210
2314
  );
2211
2315
  }
2212
- // Rotate: revoke the old refresh row, mint a new access + refresh pair
2213
- // bound to the same family so a future replay of *any* descendant can
2214
- // walk the chain.
2215
- //
2316
+ return await rotateAndRespond(db, row, deps, now);
2317
+ }
2318
+
2319
+ /**
2320
+ * Rotate a single live refresh-token row: revoke it, mint + insert a new
2321
+ * access + refresh pair bound to the same family, and link old→new
2322
+ * (`rotated_to`) so a future grace-window check can identify the direct
2323
+ * predecessor (hub#685). Returns the spec-shaped token response.
2324
+ *
2325
+ * Both call sites pass a row that is live at the moment of the call: the
2326
+ * normal path passes the presented (non-revoked) token; the grace path
2327
+ * passes the family's single live tip. Used for both so the benign-replay
2328
+ * client receives exactly what a direct refresh of the tip would yield.
2329
+ */
2330
+ async function rotateAndRespond(
2331
+ db: Database,
2332
+ row: RefreshTokenRow,
2333
+ deps: OAuthDeps,
2334
+ now: Date,
2335
+ ): Promise<Response> {
2336
+ const refreshUserId = row.userId ?? "";
2216
2337
  // Mint the access token *before* opening the rotation transaction. JWT
2217
2338
  // signing is async (jose returns a Promise) and bun:sqlite's
2218
2339
  // `db.transaction()` is sync — running async work inside the closure
2219
2340
  // would silently break atomicity. Once we have the JWT, the UPDATE
2220
- // (revoke old) + INSERT (mint new refresh row) commit or roll back as
2221
- // a unit, so a mid-rotation crash can't dead-old-without-replacement
2222
- // (#107).
2341
+ // (revoke old) + INSERT (mint new refresh row) + link (rotated_to) commit
2342
+ // or roll back as a unit, so a mid-rotation crash can't
2343
+ // dead-old-without-replacement (#107).
2223
2344
  const audience = inferAudience(row.scopes);
2224
2345
  const access = await signAccessToken(db, {
2225
2346
  sub: refreshUserId,
@@ -2241,7 +2362,7 @@ async function handleTokenRefresh(
2241
2362
  try {
2242
2363
  refresh = db.transaction(() => {
2243
2364
  db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
2244
- return signRefreshToken(db, {
2365
+ const minted = signRefreshToken(db, {
2245
2366
  jti: access.jti,
2246
2367
  userId: refreshUserId,
2247
2368
  clientId: row.clientId,
@@ -2249,6 +2370,10 @@ async function handleTokenRefresh(
2249
2370
  familyId: row.familyId,
2250
2371
  now: deps.now,
2251
2372
  });
2373
+ // Link old→new so the grace-window check (hub#685) can tell the
2374
+ // immediate predecessor apart from an older ancestor on replay.
2375
+ linkRotation(db, row.jti, access.jti);
2376
+ return minted;
2252
2377
  })();
2253
2378
  } catch (err) {
2254
2379
  // Concurrent rotation: a sibling refresh of the same row already