@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.
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +113 -0
- package/src/__tests__/admin-vaults.test.ts +20 -5
- package/src/__tests__/api-modules.test.ts +65 -10
- package/src/__tests__/api-vault-caps.test.ts +232 -0
- package/src/__tests__/cli.test.ts +20 -0
- package/src/__tests__/hub-command.test.ts +136 -0
- package/src/__tests__/hub-origins-env-set.test.ts +273 -0
- package/src/__tests__/init.test.ts +148 -0
- package/src/__tests__/jwt-sign.test.ts +79 -0
- package/src/__tests__/module-manifest.test.ts +3 -1
- package/src/__tests__/oauth-client.test.ts +75 -0
- package/src/__tests__/oauth-handlers.test.ts +413 -5
- package/src/__tests__/public-signup.test.ts +619 -0
- package/src/__tests__/rate-limit.test.ts +86 -0
- package/src/__tests__/scribe-config.test.ts +117 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/serve.test.ts +67 -1
- package/src/__tests__/service-spec-discovery.test.ts +22 -2
- package/src/__tests__/setup-wizard.test.ts +18 -0
- package/src/__tests__/setup.test.ts +129 -15
- package/src/__tests__/two-factor-flow.test.ts +94 -0
- package/src/__tests__/users.test.ts +33 -0
- package/src/__tests__/vault-caps.test.ts +89 -0
- package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/__tests__/wizard-transcription.test.ts +334 -0
- package/src/__tests__/wizard.test.ts +146 -0
- package/src/account-setup.ts +118 -32
- package/src/admin-agent-grants.ts +51 -3
- package/src/admin-handlers.ts +53 -16
- package/src/admin-login-ui.ts +30 -4
- package/src/admin-vaults.ts +9 -1
- package/src/api-invites.ts +163 -3
- package/src/api-modules-ops.ts +12 -0
- package/src/api-modules.ts +47 -13
- package/src/api-users.ts +3 -0
- package/src/api-vault-caps.ts +206 -0
- package/src/cli.ts +35 -1
- package/src/commands/hub.ts +173 -0
- package/src/commands/init.ts +73 -2
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/serve.ts +39 -3
- package/src/commands/setup.ts +31 -6
- package/src/commands/wizard-transcription.ts +296 -0
- package/src/commands/wizard.ts +73 -3
- package/src/help.ts +8 -0
- package/src/hub-db.ts +108 -1
- package/src/hub-origin.ts +64 -0
- package/src/hub-server.ts +27 -0
- package/src/invites.ts +155 -31
- package/src/jwt-sign.ts +72 -3
- package/src/module-manifest.ts +23 -9
- package/src/oauth-client.ts +102 -12
- package/src/oauth-flows-store.ts +12 -0
- package/src/oauth-handlers.ts +145 -20
- package/src/rate-limit.ts +111 -20
- package/src/scribe-config.ts +145 -0
- package/src/service-spec.ts +31 -12
- package/src/setup-wizard.ts +37 -4
- package/src/users.ts +62 -3
- package/src/vault-caps.ts +109 -0
- package/src/vault-hub-origin-env.ts +109 -16
- package/web/ui/dist/assets/{index-DR6R8EFf.css → index--728BX3j.css} +1 -1
- package/web/ui/dist/assets/index-DZzX_Enf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-B5AUE359.js +0 -61
package/src/module-manifest.ts
CHANGED
|
@@ -66,16 +66,26 @@ export interface ConfigSchema {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Discovery tier (2026-06-09 modular-UI architecture).
|
|
70
|
-
* product surface (vault / scribe / hub / surface)
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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`).
|
|
328
|
-
* `focus`
|
|
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(
|
|
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
|
}
|
package/src/oauth-client.ts
CHANGED
|
@@ -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(
|
|
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
|
|
132
|
-
* authorization_servers[0] = issuer (+ scopes_supported).
|
|
133
|
-
*
|
|
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
|
|
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
|
|
148
|
-
|
|
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
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
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);
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
2197
|
-
//
|
|
2198
|
-
//
|
|
2199
|
-
//
|
|
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
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
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)
|
|
2221
|
-
// a unit, so a mid-rotation crash can't
|
|
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
|
-
|
|
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
|