@openparachute/hub 0.5.2 → 0.5.9-rc.6

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -8,6 +8,7 @@
8
8
  * - GET /.well-known/oauth-authorization-server (RFC 8414 metadata)
9
9
  * - GET /oauth/authorize (login → consent → code)
10
10
  * - POST /oauth/authorize (form posts: login + consent)
11
+ * - POST /oauth/authorize/approve (operator-driven inline DCR approval, #208)
11
12
  * - POST /oauth/token (grant_type=authorization_code | refresh_token)
12
13
  * - POST /oauth/register (RFC 7591 DCR)
13
14
  * - POST /oauth/revoke (RFC 7009 token revocation)
@@ -35,6 +36,7 @@ import {
35
36
  type ClientStatus,
36
37
  type OAuthClient,
37
38
  type RegisteredClient,
39
+ approveClient,
38
40
  getClient,
39
41
  isValidRedirectUri,
40
42
  registerClient,
@@ -53,7 +55,14 @@ import {
53
55
  signAccessToken,
54
56
  signRefreshToken,
55
57
  } from "./jwt-sign.ts";
56
- import { type AuthorizeFormParams, renderConsent, renderError, renderLogin } from "./oauth-ui.ts";
58
+ import {
59
+ type AuthorizeFormParams,
60
+ renderApprovePending,
61
+ renderConsent,
62
+ renderError,
63
+ renderLogin,
64
+ } from "./oauth-ui.ts";
65
+ import { isSameOriginRequest } from "./origin-check.ts";
57
66
  import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
58
67
  import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
59
68
  import {
@@ -64,6 +73,7 @@ import {
64
73
  SESSION_TTL_MS,
65
74
  buildSessionCookie,
66
75
  createSession,
76
+ findActiveSession,
67
77
  findSession,
68
78
  parseSessionCookie,
69
79
  } from "./sessions.ts";
@@ -134,6 +144,18 @@ export interface OAuthDeps {
134
144
  * `~/.parachute/services.json`; tests inject a fixture.
135
145
  */
136
146
  loadServicesManifest?: () => ServicesManifest;
147
+ /**
148
+ * Set of origins (`scheme://host:port`) the hub considers itself bound to.
149
+ * Drives the same-origin defense on cookie-based POST endpoints: a request
150
+ * whose Origin/Referer matches any bound origin is accepted; everything
151
+ * else is rejected as cross-origin. Production wires this from
152
+ * `buildHubBoundOrigins` with the hub's port + expose-state hostname so
153
+ * loopback + tailnet + funnel access all work without restarting hub
154
+ * after `parachute expose`. Tests inject deterministic sets. When absent,
155
+ * the gate falls back to `[issuer]` — pre-#245 behavior — so callers that
156
+ * don't yet thread this through stay correct on a single-origin hub.
157
+ */
158
+ hubBoundOrigins?: () => readonly string[];
137
159
  }
138
160
 
139
161
  export interface ServicesCatalogEntry {
@@ -149,41 +171,129 @@ export type ServicesCatalog = Record<string, ServicesCatalogEntry>;
149
171
  * version, so OAuth clients don't have to re-probe `/.well-known/parachute.json`
150
172
  * to know where vault lives.
151
173
  *
152
- * URL source: `entry.paths[0]` from services.json verbatim — never hardcode
174
+ * URL source: `entry.paths[*]` from services.json verbatim — never hardcode
153
175
  * `/vault/default`. Users who installed with `parachute install vault
154
176
  * --vault-name work` have `paths: ["/vault/work"]` in their manifest, and the
155
177
  * catalog URL must follow that. The custom-vault-name regression test in
156
- * oauth-handlers.test.ts pins this.
178
+ * oauth-handlers.test.ts pins this for single-vault.
157
179
  *
158
180
  * Filtering: only services for which the token has at least one scope are
159
181
  * included. A scope `vault:read` admits the `vault` service; a token with only
160
182
  * `scribe:transcribe` gets a catalog with no vault entry. The check is on the
161
183
  * audience prefix (`<aud>:<verb>`) — same shape `inferAudience` uses.
162
184
  *
163
- * Multi-vault: Phase 1 collapses every vault entry under the single key
164
- * `vault`, first matching `parachute-vault*` row wins. Per-vault keys
165
- * (`services.vault.work.url` or `services["vault:work"].url`) are deferred
166
- * to a future design once notes ships its vault picker; multi-vault clients
167
- * need to probe `/.well-known/parachute.json` for the full vaults array
168
- * until then.
185
+ * Multi-vault (closes #247): emits per-vault keys `vault:<name>` alongside
186
+ * the collapsed `vault` key. A scope `vault:boulder:write` admits only
187
+ * boulder → emits `vault:boulder` (and the legacy `vault` key, pointing at
188
+ * boulder so it resolves consistently). A broad scope `vault:read` admits
189
+ * every vault on the hub → emits `vault:<name>` for each vault path plus
190
+ * the legacy `vault` key (pointing at `entry.paths[0]` of the first vault,
191
+ * unchanged from Phase 1). Notes' OAuthCallback (notes#115 ships the
192
+ * picker; per-vault consumer change is the post-#247 Notes-side PR) reads
193
+ * `services["vault:<name>"]` so it stops collapsing multi-vault grants
194
+ * onto a single VaultRecord URL.
195
+ *
196
+ * Pre-popover clients still see `services.vault` and behave unchanged —
197
+ * that key never goes away. Per-vault keys are additive.
169
198
  */
170
199
  export function buildServicesCatalog(
171
200
  manifest: ServicesManifest,
172
201
  issuer: string,
173
202
  scopes: readonly string[],
174
203
  ): ServicesCatalog {
204
+ // Two scope-derived sets:
205
+ // - audiences: bare service prefix (`vault`, `scribe`) → admits the
206
+ // collapsed key + every per-vault key.
207
+ // - namedVaults: per-vault narrowed scopes (`vault:<name>:<verb>`) →
208
+ // admits only `vault:<name>` and the collapsed `vault`.
209
+ //
210
+ // A token with both `vault:read` and `vault:boulder:write` should land in
211
+ // the "any vault" bucket — the bare scope is permissive, the named one
212
+ // is informational. Detect this via the bare-prefix presence; the named
213
+ // scope's per-vault narrowing still works for clients that prefer it.
175
214
  const audiences = new Set<string>();
215
+ const namedVaults = new Set<string>();
176
216
  for (const s of scopes) {
217
+ const parts = s.split(":");
218
+ if (
219
+ parts.length === 3 &&
220
+ parts[0] === "vault" &&
221
+ parts[1] &&
222
+ parts[2] &&
223
+ VAULT_VERBS.has(parts[2])
224
+ ) {
225
+ namedVaults.add(parts[1]);
226
+ audiences.add("vault");
227
+ continue;
228
+ }
177
229
  const colon = s.indexOf(":");
178
230
  if (colon > 0) audiences.add(s.slice(0, colon));
179
231
  }
232
+ const broadVaultScope =
233
+ audiences.has("vault") &&
234
+ scopes.some((s) => {
235
+ const parts = s.split(":");
236
+ return (
237
+ parts.length === 2 &&
238
+ parts[0] === "vault" &&
239
+ parts[1] !== undefined &&
240
+ VAULT_VERBS.has(parts[1])
241
+ );
242
+ });
243
+
244
+ // Count total admitted vault paths across the manifest. Per-vault keys
245
+ // are only worth emitting when there are >1 admitted vaults to
246
+ // disambiguate (or when the token's own scopes are per-vault narrowed —
247
+ // a per-vault scope is an explicit consumer signal that the per-vault
248
+ // key matters even on a single-vault hub). The check is on admitted
249
+ // paths, not raw vault rows: a broad token on a multi-path vault row
250
+ // sees N paths; a per-vault token sees only its own.
251
+ let admittedVaultPathCount = 0;
252
+ if (audiences.has("vault")) {
253
+ for (const entry of manifest.services) {
254
+ if (!isVaultEntry(entry)) continue;
255
+ const paths = entry.paths.length > 0 ? entry.paths : ["/"];
256
+ for (const path of paths) {
257
+ const instance = vaultInstanceNameFor(entry.name, path);
258
+ if (broadVaultScope || namedVaults.has(instance)) admittedVaultPathCount++;
259
+ }
260
+ }
261
+ }
262
+ const emitPerVaultKeys = admittedVaultPathCount > 1 || namedVaults.size > 0;
263
+
180
264
  const base = issuer.replace(/\/$/, "");
181
265
  const catalog: ServicesCatalog = {};
182
266
  for (const entry of manifest.services) {
183
- const path = entry.paths[0] ?? "/";
184
- const key = isVaultEntry(entry) ? "vault" : shortName(entry.name);
267
+ if (isVaultEntry(entry)) {
268
+ if (!audiences.has("vault")) continue;
269
+ // Walk every path the row exposes. Real multi-vault on the hub is a
270
+ // single `parachute-vault` row with N paths (one per vault instance);
271
+ // legacy per-vault rows (`parachute-vault-<name>`) are handled by the
272
+ // same loop because each contributes one path.
273
+ const paths = entry.paths.length > 0 ? entry.paths : ["/"];
274
+ for (const path of paths) {
275
+ const instance = vaultInstanceNameFor(entry.name, path);
276
+ const admit = broadVaultScope || namedVaults.has(instance);
277
+ if (!admit) continue;
278
+ if (emitPerVaultKeys) {
279
+ const perVaultKey = `vault:${instance}`;
280
+ if (!catalog[perVaultKey]) {
281
+ catalog[perVaultKey] = { url: `${base}${path}`, version: entry.version };
282
+ }
283
+ }
284
+ // Collapsed `vault` key stays for backwards compat. First admitted
285
+ // vault wins (deterministic — `entry.paths[0]` for a broad scope,
286
+ // or the only admitted instance for a per-vault scope).
287
+ if (!catalog.vault) {
288
+ catalog.vault = { url: `${base}${path}`, version: entry.version };
289
+ }
290
+ }
291
+ continue;
292
+ }
293
+ const key = shortName(entry.name);
185
294
  if (!audiences.has(key)) continue;
186
- if (catalog[key]) continue; // first vault wins; deterministic for clients
295
+ if (catalog[key]) continue;
296
+ const path = entry.paths[0] ?? "/";
187
297
  catalog[key] = { url: `${base}${path}`, version: entry.version };
188
298
  }
189
299
  return catalog;
@@ -291,21 +401,100 @@ function parseAuthorizeFormParams(url: URL): AuthorizeFormParams | { error: stri
291
401
  };
292
402
  }
293
403
 
294
- /** HTML response for pending clients hitting /oauth/authorize. */
295
- function pendingClientHtml(): Response {
296
- return htmlError(
297
- "App not yet approved",
298
- "This client_id is registered but has not been approved by the hub operator. Ask the operator to run `parachute auth approve-client` for this app, then try again.",
404
+ /**
405
+ * "App not yet approved" page (#74) for /oauth/authorize. When the request
406
+ * carries a valid operator session AND a same-origin Origin/Referer, render
407
+ * the inline approve form (#208) so one click flips the client to `approved`
408
+ * and the OAuth flow re-enters at consent. Otherwise fall back to the
409
+ * pre-#208 CLI-only message ("ask operator to run `parachute auth
410
+ * approve-client <id>`").
411
+ *
412
+ * The session-bound approve gate mirrors the same-origin DCR auto-approve
413
+ * gate on `/oauth/register` (#199, #200): valid session cookie + matching
414
+ * Origin/Referer = trusted operator action. Cross-origin or session-less
415
+ * GETs see the CLI-fallback message; the button never renders for them, so
416
+ * the POST handler can't be tricked into approving via a hand-crafted form
417
+ * either (CSRF token won't match).
418
+ *
419
+ * The form's `return_to` carries the original `/oauth/authorize?...` URL so
420
+ * the post-approve redirect lands the operator back on the same flow with
421
+ * the now-approved client. The POST handler validates `return_to` is a
422
+ * hub-relative path before following it (open-redirect defense).
423
+ */
424
+ function pendingClientResponse(
425
+ db: Database,
426
+ req: Request,
427
+ client: OAuthClient,
428
+ authorizeUrl: URL,
429
+ deps: OAuthDeps,
430
+ ): Response {
431
+ const requestedScopes = (authorizeUrl.searchParams.get("scope") ?? "")
432
+ .split(" ")
433
+ .filter((s) => s.length > 0);
434
+ // Vault hint (closes #244): Notes' VaultPopover (notes#115) sets this on
435
+ // the authorize URL when kicking OAuth for a specific vault. Empty-string
436
+ // values normalize to undefined so the approve UI omits the row rather
437
+ // than rendering a blank vault label.
438
+ const vaultParam = authorizeUrl.searchParams.get("vault");
439
+ const requestedVault = vaultParam && vaultParam.length > 0 ? vaultParam : undefined;
440
+ const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
441
+ const sameOrigin = isSameOriginRequest(req, resolveBoundOrigins(deps));
442
+ const csrf = ensureCsrfToken(req);
443
+ const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
444
+ if (session && sameOrigin) {
445
+ const returnTo = `${authorizeUrl.pathname}${authorizeUrl.search}`;
446
+ return htmlResponse(
447
+ renderApprovePending({
448
+ clientName: client.clientName ?? client.clientId,
449
+ clientId: client.clientId,
450
+ redirectUris: client.redirectUris,
451
+ requestedScopes,
452
+ ...(requestedVault !== undefined && { requestedVault }),
453
+ approveForm: { csrfToken: csrf.token, returnTo },
454
+ }),
455
+ 403,
456
+ extra,
457
+ );
458
+ }
459
+ return htmlResponse(
460
+ renderApprovePending({
461
+ clientName: client.clientName ?? client.clientId,
462
+ clientId: client.clientId,
463
+ redirectUris: client.redirectUris,
464
+ requestedScopes,
465
+ ...(requestedVault !== undefined && { requestedVault }),
466
+ }),
299
467
  403,
468
+ extra,
300
469
  );
301
470
  }
302
471
 
303
- /** JSON response for pending clients hitting /oauth/token. */
304
- function pendingClientJson(): Response {
472
+ /**
473
+ * JSON response for pending clients hitting /oauth/token. Carries two
474
+ * actionability hints alongside the OAuth error so consumers (Notes, future
475
+ * cross-origin SPAs) can surface an inline approval path instead of dead-
476
+ * ending on a CLI message:
477
+ *
478
+ * - `approve_url` — hub-served SPA route the operator can open in a
479
+ * browser to approve the client in one click. Same-origin to the hub.
480
+ * - `cli_alternative` — the `parachute auth approve-client <id>` shell
481
+ * command, retained for terminal-first operators or scripted flows.
482
+ *
483
+ * Spec note: the OAuth error class stays `invalid_client` per RFC 6749 §5.2
484
+ * — "this client cannot use this endpoint right now" is the semantic match.
485
+ * `access_denied` is reserved for /authorize "user said no" flows; using it
486
+ * here would conflate two distinct error families and break clients doing
487
+ * strict spec-shaped handling. The extra fields are spec-permitted
488
+ * extensions ("other parameters").
489
+ */
490
+ function pendingClientJson(clientId: string, issuer: string): Response {
491
+ const base = issuer.replace(/\/$/, "");
305
492
  return jsonResponse(
306
493
  {
307
494
  error: "invalid_client",
308
495
  error_description: "client is registered but has not been approved by the hub operator (#74)",
496
+ approve_url: `${base}/admin/approve-client/${encodeURIComponent(clientId)}`,
497
+ cli_alternative: `parachute auth approve-client ${clientId}`,
309
498
  },
310
499
  401,
311
500
  );
@@ -345,7 +534,9 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
345
534
  // matched it against a registered client. Render an HTML error.
346
535
  return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
347
536
  }
348
- if (client.status !== "approved") return pendingClientHtml();
537
+ if (client.status !== "approved") {
538
+ return pendingClientResponse(db, req, client, url, deps);
539
+ }
349
540
  try {
350
541
  requireRegisteredRedirectUri(client, parsed.redirectUri);
351
542
  } catch {
@@ -536,7 +727,18 @@ async function handleConsentSubmit(
536
727
  if (!client) {
537
728
  return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
538
729
  }
539
- if (client.status !== "approved") return pendingClientHtml();
730
+ if (client.status !== "approved") {
731
+ // Defensive: consent only renders for approved clients, so a non-approved
732
+ // status here means the row was unapproved between render and submit (or
733
+ // the form was hand-crafted). The approve UI requires a known authorize
734
+ // URL to round-trip via `return_to`, which we don't reconstruct here —
735
+ // surface the static error and let the operator restart from the SPA.
736
+ return htmlError(
737
+ "App not yet approved",
738
+ `This client_id is registered but has not been approved. Run \`parachute auth approve-client ${client.clientId}\` from a terminal, then try again.`,
739
+ 403,
740
+ );
741
+ }
540
742
  try {
541
743
  requireRegisteredRedirectUri(client, params.redirectUri);
542
744
  } catch {
@@ -602,6 +804,107 @@ async function handleConsentSubmit(
602
804
  return issueAuthCodeRedirect(db, params, scopes, session.userId, deps);
603
805
  }
604
806
 
807
+ /**
808
+ * POST /oauth/authorize/approve — operator-driven inline approval of a
809
+ * pending DCR client (closes #208). The cross-origin SPA case the
810
+ * same-origin DCR auto-approve (#199, #200) doesn't cover: an SPA on a
811
+ * different origin can't ride the cookie path during DCR, so its
812
+ * freshly-registered client_id lands `pending` and the operator hits
813
+ * "App not yet approved" on /oauth/authorize. This endpoint flips that
814
+ * client to `approved` in one click and redirects back into the OAuth flow.
815
+ *
816
+ * Three-belt security model. All three must pass:
817
+ *
818
+ * 1. Valid CSRF token (double-submit cookie). Defends against a malicious
819
+ * cross-origin POST that rides the session cookie's SameSite=Lax.
820
+ * Token was minted at GET render time and embedded in the form.
821
+ * 2. Active operator session (`findActiveSession`). The operator must be
822
+ * logged into this hub from the browser submitting the form — no
823
+ * session means no operator authority to approve anything.
824
+ * 3. Origin/Referer matches a hub-bound origin (`isSameOriginRequest`).
825
+ * Same shape as the DCR auto-approve gate (#199, #200, #245): a same-
826
+ * origin POST proves the form was rendered by *this hub*, not a forged
827
+ * page. Bound origins include issuer + loopback + tailnet hostname
828
+ * (#245); pre-#245 was issuer-only and rejected legitimate operator
829
+ * paths from loopback / tailnet.
830
+ *
831
+ * `return_to` validation: the form embeds the original authorize URL so
832
+ * the post-approve redirect lands the operator back on `/oauth/authorize`
833
+ * with the now-approved client. We refuse anything that doesn't start with
834
+ * `/oauth/authorize?` — open-redirect defense, plus a hand-crafted form
835
+ * trying to use this endpoint as a generic redirect-after-approve gadget
836
+ * shouldn't succeed at smuggling an off-path target.
837
+ */
838
+ export async function handleApproveClientPost(
839
+ db: Database,
840
+ req: Request,
841
+ deps: OAuthDeps,
842
+ ): Promise<Response> {
843
+ const form = await req.formData();
844
+ const formCsrf = form.get(CSRF_FIELD_NAME);
845
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
846
+ return htmlError(
847
+ "Invalid form submission",
848
+ "The form's CSRF token did not match. Reload the page and try again.",
849
+ 403,
850
+ );
851
+ }
852
+ const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
853
+ if (!session) {
854
+ return htmlError(
855
+ "Sign in required",
856
+ "You must be signed in to this hub to approve an app. Sign in and try again.",
857
+ 401,
858
+ );
859
+ }
860
+ if (!isSameOriginRequest(req, resolveBoundOrigins(deps))) {
861
+ return htmlError(
862
+ "Cross-origin request rejected",
863
+ "The approve form must be submitted from this hub's own origin.",
864
+ 403,
865
+ );
866
+ }
867
+ const clientId = String(form.get("client_id") ?? "");
868
+ if (!clientId) {
869
+ return htmlError("Invalid form submission", "Missing client_id.", 400);
870
+ }
871
+ const client = getClient(db, clientId);
872
+ if (!client) {
873
+ return htmlError("Unknown application", "This client_id is not registered with this hub.", 404);
874
+ }
875
+ // Validate return_to BEFORE the DB mutation: if an authenticated operator
876
+ // submits a hand-crafted form with a bad return_to, we refuse without
877
+ // committing the client to `approved`. Practical risk is low (all three
878
+ // belts already passed), but ordering matters — validate, then mutate.
879
+ const returnTo = String(form.get("return_to") ?? "");
880
+ if (!isSafeAuthorizeReturnTo(returnTo)) {
881
+ return htmlError(
882
+ "Invalid form submission",
883
+ "The return_to value is not a hub-relative /oauth/authorize URL.",
884
+ 400,
885
+ );
886
+ }
887
+ approveClient(db, clientId);
888
+ return redirectResponse(returnTo);
889
+ }
890
+
891
+ /**
892
+ * Validate a form-submitted `return_to` value. Must be a hub-relative URL
893
+ * (no scheme, no double-slash) targeting `/oauth/authorize` with a query
894
+ * string — anything else is either an open-redirect attempt or a misuse of
895
+ * the endpoint. Empty string is rejected (the form always supplies one).
896
+ */
897
+ function isSafeAuthorizeReturnTo(value: string): boolean {
898
+ if (!value) return false;
899
+ // Reject scheme-relative ("//evil.example/foo") and absolute URLs. Only
900
+ // single-slash root-relative paths are allowed.
901
+ if (!value.startsWith("/") || value.startsWith("//")) return false;
902
+ // Must target the authorize endpoint with a query string. The OAuth flow
903
+ // re-enters via GET /oauth/authorize?<original-params>; anything off-path
904
+ // is a misuse.
905
+ return value.startsWith("/oauth/authorize?");
906
+ }
907
+
605
908
  function paramsFromForm(form: Awaited<ReturnType<Request["formData"]>>): AuthorizeFormParams {
606
909
  return {
607
910
  clientId: String(form.get("client_id") ?? ""),
@@ -760,7 +1063,7 @@ async function handleTokenAuthorizationCode(
760
1063
  if (!client) {
761
1064
  return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
762
1065
  }
763
- if (client.status !== "approved") return pendingClientJson();
1066
+ if (client.status !== "approved") return pendingClientJson(client.clientId, deps.issuer);
764
1067
  const authFailure = authenticateClient(client, req, form, clientId);
765
1068
  if (authFailure) return authFailure;
766
1069
  let redeemed: ReturnType<typeof redeemAuthCode>;
@@ -795,6 +1098,13 @@ async function handleTokenAuthorizationCode(
795
1098
  issuer: deps.issuer,
796
1099
  now: deps.now,
797
1100
  });
1101
+ // Phase 1 (#212) registry exemption: code-grant access tokens piggyback
1102
+ // on the paired refresh token's `tokens` row (they share `jti` by
1103
+ // design). We don't write a separate access-token row — revocation acts
1104
+ // on the shared jti / family, and the 15-min access TTL bounds the
1105
+ // window before per-jti re-validation is needed. A separate per-jti
1106
+ // access-token row would double registry write volume on every OAuth
1107
+ // grant + every refresh rotation; not worth the trade today.
798
1108
  const refresh = signRefreshToken(db, {
799
1109
  jti: access.jti,
800
1110
  userId: redeemed.userId,
@@ -835,7 +1145,7 @@ async function handleTokenRefresh(
835
1145
  if (!client) {
836
1146
  return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
837
1147
  }
838
- if (client.status !== "approved") return pendingClientJson();
1148
+ if (client.status !== "approved") return pendingClientJson(client.clientId, deps.issuer);
839
1149
  const authFailure = authenticateClient(client, req, form, clientId);
840
1150
  if (authFailure) return authFailure;
841
1151
  const row = findRefreshToken(db, refreshToken);
@@ -848,6 +1158,18 @@ async function handleTokenRefresh(
848
1158
  if (row.clientId !== clientId) {
849
1159
  return jsonResponse({ error: "invalid_grant", error_description: "client_id mismatch" }, 400);
850
1160
  }
1161
+ // Refresh-token rows always have a non-null user_id (the caller's hub
1162
+ // user). Post-v6 the column is nullable to accommodate non-OAuth mints
1163
+ // (operator/cli mints), but those rows have no `refresh_token_hash` so
1164
+ // `findRefreshToken` can't return them. Defensive: surface a clean
1165
+ // invalid_grant if a hand-crafted row shows up here without a user.
1166
+ if (!row.userId) {
1167
+ return jsonResponse(
1168
+ { error: "invalid_grant", error_description: "refresh_token has no associated user" },
1169
+ 400,
1170
+ );
1171
+ }
1172
+ const refreshUserId: string = row.userId;
851
1173
  const now = deps.now?.() ?? new Date();
852
1174
  if (row.revokedAt) {
853
1175
  // Replay of an already-rotated refresh token. Per RFC 6819 §5.2.2.3 the
@@ -882,7 +1204,7 @@ async function handleTokenRefresh(
882
1204
  // (#107).
883
1205
  const audience = inferAudience(row.scopes);
884
1206
  const access = await signAccessToken(db, {
885
- sub: row.userId,
1207
+ sub: refreshUserId,
886
1208
  scopes: row.scopes,
887
1209
  audience,
888
1210
  clientId: row.clientId,
@@ -895,7 +1217,7 @@ async function handleTokenRefresh(
895
1217
  db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
896
1218
  return signRefreshToken(db, {
897
1219
  jti: access.jti,
898
- userId: row.userId,
1220
+ userId: refreshUserId,
899
1221
  clientId: row.clientId,
900
1222
  scopes: row.scopes,
901
1223
  familyId: row.familyId,
@@ -1064,20 +1386,50 @@ interface RegisterRequestBody {
1064
1386
  token_endpoint_auth_method?: string;
1065
1387
  }
1066
1388
 
1389
+ /**
1390
+ * Resolve the hub-bound origin set for a given `OAuthDeps`. Pre-#245 this
1391
+ * was implicit (just `deps.issuer`); post-#245 callers can thread a richer
1392
+ * set through `deps.hubBoundOrigins` so loopback + tailnet + funnel access
1393
+ * all match. Fallback to `[issuer]` keeps callers that haven't migrated
1394
+ * correct on single-origin hubs.
1395
+ */
1396
+ function resolveBoundOrigins(deps: OAuthDeps): readonly string[] {
1397
+ if (deps.hubBoundOrigins) return deps.hubBoundOrigins();
1398
+ return [deps.issuer];
1399
+ }
1400
+
1067
1401
  /**
1068
1402
  * POST /oauth/register — RFC 7591 Dynamic Client Registration.
1069
1403
  *
1070
1404
  * Approval gate (closes #74). New rows land as `pending` by default and
1071
1405
  * cannot participate in OAuth flows until an operator runs
1072
- * `parachute auth approve-client <id>`. The single bypass is presenting an
1073
- * `Authorization: Bearer <operator-token>` whose token carries the
1074
- * `hub:admin` scope — the install-time path used by first-party modules so
1075
- * `parachute install vault` can self-register without a human follow-up.
1406
+ * `parachute auth approve-client <id>`. Two bypass paths:
1407
+ *
1408
+ * 1. **Operator-bearer** (#74). `Authorization: Bearer <operator-token>` whose
1409
+ * token carries the `hub:admin` scope — the install-time path used by
1410
+ * first-party modules so `parachute install vault` can self-register
1411
+ * without a human follow-up.
1412
+ * 2. **Operator-session** (#199). A valid `parachute_hub_session` cookie
1413
+ * plus a same-origin `Origin`/`Referer` header. The browser path: an
1414
+ * operator hitting their own SPA from their own browser is by definition
1415
+ * operator-authenticated, so re-requiring approval is friction without
1416
+ * benefit. CSRF defense is `isSameOriginRequest` + the cookie's
1417
+ * `SameSite=Lax` attribute.
1076
1418
  *
1077
1419
  * If a bearer is presented but invalid or insufficient, we reject with the
1078
1420
  * RFC 6750 shape rather than silently downgrading to the public path: a
1079
1421
  * caller who tried to authenticate but failed wants to know why, not get
1080
1422
  * `pending` back and wonder why their module can't OAuth.
1423
+ *
1424
+ * Access-control matrix:
1425
+ * no auth → pending
1426
+ * bearer (hub:admin) → approved (#74)
1427
+ * bearer (other scope) → 403 insufficient_scope
1428
+ * bearer (malformed) → 401 invalid_token
1429
+ * session cookie + same-origin → approved (#199)
1430
+ * session cookie + cross-origin → pending (CSRF defense)
1431
+ * session cookie + no Origin/Referer → pending
1432
+ * expired/unknown session → pending
1081
1433
  */
1082
1434
  export async function handleRegister(
1083
1435
  db: Database,
@@ -1124,6 +1476,20 @@ export async function handleRegister(
1124
1476
  throw err;
1125
1477
  }
1126
1478
  }
1479
+ // Operator-session auto-approve (closes #199). The browser path:
1480
+ // operator-authenticated SPA on the hub's own origin can self-register a
1481
+ // client without dropping to a terminal. Two gates: (1) a live (un-expired)
1482
+ // session row keyed by the cookie, (2) Origin/Referer matches the issuer
1483
+ // origin so a cross-site forgery can't ride the cookie. Quietly stays
1484
+ // `pending` on any failure — unlike the bearer path, we don't surface an
1485
+ // error, because absence of session/origin is the *normal* unauthenticated
1486
+ // public-DCR shape.
1487
+ if (status === "pending") {
1488
+ const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
1489
+ if (session && isSameOriginRequest(req, resolveBoundOrigins(deps))) {
1490
+ status = "approved";
1491
+ }
1492
+ }
1127
1493
  const confidential = body.token_endpoint_auth_method === "client_secret_post";
1128
1494
  const scopes = (body.scope ?? "").split(" ").filter((s) => s.length > 0);
1129
1495
  let registered: RegisteredClient;