@openparachute/hub 0.5.7 → 0.5.10-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 (85) 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 +70 -323
  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-modules-ops.test.ts +658 -0
  8. package/src/__tests__/api-modules.test.ts +426 -0
  9. package/src/__tests__/api-revocation-list.test.ts +198 -0
  10. package/src/__tests__/api-revoke-token.test.ts +320 -0
  11. package/src/__tests__/api-tokens.test.ts +629 -0
  12. package/src/__tests__/auth.test.ts +680 -16
  13. package/src/__tests__/csrf.test.ts +40 -1
  14. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  15. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  16. package/src/__tests__/expose.test.ts +2 -2
  17. package/src/__tests__/hub-server.test.ts +584 -67
  18. package/src/__tests__/hub-settings.test.ts +377 -0
  19. package/src/__tests__/hub.test.ts +123 -53
  20. package/src/__tests__/install-source.test.ts +249 -0
  21. package/src/__tests__/jwt-sign.test.ts +205 -0
  22. package/src/__tests__/module-manifest.test.ts +48 -0
  23. package/src/__tests__/oauth-handlers.test.ts +522 -5
  24. package/src/__tests__/operator-token.test.ts +427 -3
  25. package/src/__tests__/origin-check.test.ts +220 -0
  26. package/src/__tests__/request-protocol.test.ts +54 -0
  27. package/src/__tests__/serve-boot.test.ts +193 -0
  28. package/src/__tests__/serve.test.ts +100 -0
  29. package/src/__tests__/sessions.test.ts +25 -2
  30. package/src/__tests__/setup-gate.test.ts +222 -0
  31. package/src/__tests__/setup-wizard.test.ts +2089 -0
  32. package/src/__tests__/status.test.ts +199 -0
  33. package/src/__tests__/supervisor.test.ts +482 -0
  34. package/src/__tests__/upgrade.test.ts +247 -4
  35. package/src/__tests__/vault-name.test.ts +79 -0
  36. package/src/__tests__/well-known.test.ts +69 -0
  37. package/src/admin-clients.ts +139 -0
  38. package/src/admin-handlers.ts +37 -254
  39. package/src/admin-host-admin-token.ts +25 -10
  40. package/src/admin-login-ui.ts +256 -0
  41. package/src/admin-vault-admin-token.ts +1 -1
  42. package/src/api-me.ts +124 -0
  43. package/src/api-mint-token.ts +239 -0
  44. package/src/api-modules-ops.ts +585 -0
  45. package/src/api-modules.ts +367 -0
  46. package/src/api-revocation-list.ts +59 -0
  47. package/src/api-revoke-token.ts +153 -0
  48. package/src/api-tokens.ts +224 -0
  49. package/src/cli.ts +28 -0
  50. package/src/commands/auth.ts +408 -51
  51. package/src/commands/expose-2fa-warning.ts +6 -6
  52. package/src/commands/serve-boot.ts +133 -0
  53. package/src/commands/serve.ts +214 -0
  54. package/src/commands/status.ts +74 -10
  55. package/src/commands/upgrade.ts +33 -6
  56. package/src/csrf.ts +34 -13
  57. package/src/help.ts +55 -5
  58. package/src/hub-control.ts +1 -0
  59. package/src/hub-db.ts +87 -0
  60. package/src/hub-server.ts +767 -136
  61. package/src/hub-settings.ts +259 -0
  62. package/src/hub.ts +298 -150
  63. package/src/install-source.ts +291 -0
  64. package/src/jwt-sign.ts +265 -5
  65. package/src/module-manifest.ts +48 -10
  66. package/src/oauth-handlers.ts +262 -56
  67. package/src/oauth-ui.ts +23 -2
  68. package/src/operator-token.ts +349 -18
  69. package/src/origin-check.ts +127 -0
  70. package/src/rate-limit.ts +5 -2
  71. package/src/request-protocol.ts +48 -0
  72. package/src/scope-explanations.ts +33 -2
  73. package/src/sessions.ts +30 -18
  74. package/src/setup-wizard.ts +2009 -0
  75. package/src/supervisor.ts +411 -0
  76. package/src/vault-name.ts +71 -0
  77. package/src/well-known.ts +54 -1
  78. package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
  79. package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
  80. package/web/ui/dist/index.html +2 -2
  81. package/src/__tests__/admin-config.test.ts +0 -281
  82. package/src/admin-config-ui.ts +0 -534
  83. package/src/admin-config.ts +0 -226
  84. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  85. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -114,6 +114,27 @@ export interface ModuleManifest {
114
114
  * as `hasAuth` / `init` / `urlForEntry`.
115
115
  */
116
116
  readonly managementUrl?: string;
117
+ /**
118
+ * Where the module's primary user-facing UI lives. Hub renders a tile on
119
+ * the discovery page (`/`) Services section when set (see
120
+ * `parachute-patterns/patterns/module-json-extensibility.md` and the
121
+ * `loadUiUrls` resolver in `hub-server.ts`).
122
+ *
123
+ * Two shapes — same rules as `managementUrl`:
124
+ * - A relative path (e.g. `"/notes"`, `"/agent"`) — hub resolves
125
+ * against the canonical hub origin.
126
+ * - A full absolute URL — hub uses verbatim.
127
+ *
128
+ * Absent = no Services tile rendered (the module is API-only or surfaces
129
+ * its UI via a sibling module — e.g. vault content browses through Notes,
130
+ * so vault has no `uiUrl`).
131
+ *
132
+ * Read at every discovery render via `installDir/.parachute/module.json`
133
+ * (mirrors how `managementUrl` is sourced for vaults). Not persisted in
134
+ * services.json — that file's "services own the write side" semantics
135
+ * would clobber any install-time copy on the next service boot.
136
+ */
137
+ readonly uiUrl?: string;
117
138
  /**
118
139
  * When `true`, the hub's `/<svc>/*` proxy strips the matched mount prefix
119
140
  * before forwarding (so the backend sees `/health` rather than
@@ -374,6 +395,7 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
374
395
  const dependencies = asDependencies(m.dependencies, where);
375
396
  const configSchema = asConfigSchema(m.configSchema, where);
376
397
  const managementUrl = asManagementUrl(m.managementUrl, where);
398
+ const uiUrl = asUiUrl(m.uiUrl, where);
377
399
  let stripPrefix: boolean | undefined;
378
400
  if (m.stripPrefix !== undefined) {
379
401
  if (typeof m.stripPrefix !== "boolean") {
@@ -396,6 +418,9 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
396
418
  if (managementUrl !== undefined) {
397
419
  (out as { managementUrl?: string }).managementUrl = managementUrl;
398
420
  }
421
+ if (uiUrl !== undefined) {
422
+ (out as { uiUrl?: string }).uiUrl = uiUrl;
423
+ }
399
424
  if (stripPrefix !== undefined) {
400
425
  (out as { stripPrefix?: boolean }).stripPrefix = stripPrefix;
401
426
  }
@@ -403,26 +428,39 @@ export function validateModuleManifest(raw: unknown, where: string): ModuleManif
403
428
  }
404
429
 
405
430
  function asManagementUrl(v: unknown, where: string): string | undefined {
431
+ return asPathOrUrl(v, where, "managementUrl");
432
+ }
433
+
434
+ function asUiUrl(v: unknown, where: string): string | undefined {
435
+ return asPathOrUrl(v, where, "uiUrl");
436
+ }
437
+
438
+ /**
439
+ * Validate a "path or http(s) URL" field. Both `managementUrl` and `uiUrl`
440
+ * follow the same shape per the module-json-extensibility pattern doc;
441
+ * factored so the next URL-shaped field doesn't have to copy-paste.
442
+ */
443
+ function asPathOrUrl(v: unknown, where: string, field: string): string | undefined {
406
444
  if (v === undefined) return undefined;
407
445
  if (typeof v !== "string" || v.length === 0) {
408
- throw new ModuleManifestError(
409
- `${where}: "managementUrl" must be a non-empty string if present`,
410
- );
411
- }
412
- // Two valid shapes: a path starting with "/" or a full http(s) URL.
413
- if (v.startsWith("/")) return v;
446
+ throw new ModuleManifestError(`${where}: "${field}" must be a non-empty string if present`);
447
+ }
448
+ // Two valid shapes: an absolute path starting with a single "/" or a full
449
+ // http(s) URL. Reject protocol-relative forms like "//evil.com" — they
450
+ // start with "/" but `new URL("//evil.com", base)` resolves to the foreign
451
+ // origin, which would let a malicious module render an off-origin tile and
452
+ // turn the discovery page into an open-redirect surface.
453
+ if (v.startsWith("/") && !v.startsWith("//")) return v;
414
454
  try {
415
455
  const u = new URL(v);
416
456
  if (u.protocol !== "http:" && u.protocol !== "https:") {
417
- throw new ModuleManifestError(
418
- `${where}: "managementUrl" absolute form must use http: or https:`,
419
- );
457
+ throw new ModuleManifestError(`${where}: "${field}" absolute form must use http: or https:`);
420
458
  }
421
459
  return v;
422
460
  } catch (err) {
423
461
  if (err instanceof ModuleManifestError) throw err;
424
462
  throw new ModuleManifestError(
425
- `${where}: "managementUrl" must be a path starting with "/" or a full http(s) URL`,
463
+ `${where}: "${field}" must be a path starting with "/" or a full http(s) URL`,
426
464
  );
427
465
  }
428
466
  }
@@ -45,6 +45,7 @@ import {
45
45
  } from "./clients.ts";
46
46
  import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
47
47
  import { isCoveredByGrant, recordGrant } from "./grants.ts";
48
+ import { consumeFirstClientAutoApproveWindow } from "./hub-settings.ts";
48
49
  import { VAULT_VERBS, inferAudience } from "./jwt-audience.ts";
49
50
  import {
50
51
  ACCESS_TOKEN_TTL_SECONDS,
@@ -62,6 +63,8 @@ import {
62
63
  renderError,
63
64
  renderLogin,
64
65
  } from "./oauth-ui.ts";
66
+ import { isSameOriginRequest } from "./origin-check.ts";
67
+ import { isHttpsRequest } from "./request-protocol.ts";
65
68
  import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
66
69
  import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
67
70
  import {
@@ -143,6 +146,18 @@ export interface OAuthDeps {
143
146
  * `~/.parachute/services.json`; tests inject a fixture.
144
147
  */
145
148
  loadServicesManifest?: () => ServicesManifest;
149
+ /**
150
+ * Set of origins (`scheme://host:port`) the hub considers itself bound to.
151
+ * Drives the same-origin defense on cookie-based POST endpoints: a request
152
+ * whose Origin/Referer matches any bound origin is accepted; everything
153
+ * else is rejected as cross-origin. Production wires this from
154
+ * `buildHubBoundOrigins` with the hub's port + expose-state hostname so
155
+ * loopback + tailnet + funnel access all work without restarting hub
156
+ * after `parachute expose`. Tests inject deterministic sets. When absent,
157
+ * the gate falls back to `[issuer]` — pre-#245 behavior — so callers that
158
+ * don't yet thread this through stay correct on a single-origin hub.
159
+ */
160
+ hubBoundOrigins?: () => readonly string[];
146
161
  }
147
162
 
148
163
  export interface ServicesCatalogEntry {
@@ -158,41 +173,129 @@ export type ServicesCatalog = Record<string, ServicesCatalogEntry>;
158
173
  * version, so OAuth clients don't have to re-probe `/.well-known/parachute.json`
159
174
  * to know where vault lives.
160
175
  *
161
- * URL source: `entry.paths[0]` from services.json verbatim — never hardcode
176
+ * URL source: `entry.paths[*]` from services.json verbatim — never hardcode
162
177
  * `/vault/default`. Users who installed with `parachute install vault
163
178
  * --vault-name work` have `paths: ["/vault/work"]` in their manifest, and the
164
179
  * catalog URL must follow that. The custom-vault-name regression test in
165
- * oauth-handlers.test.ts pins this.
180
+ * oauth-handlers.test.ts pins this for single-vault.
166
181
  *
167
182
  * Filtering: only services for which the token has at least one scope are
168
183
  * included. A scope `vault:read` admits the `vault` service; a token with only
169
184
  * `scribe:transcribe` gets a catalog with no vault entry. The check is on the
170
185
  * audience prefix (`<aud>:<verb>`) — same shape `inferAudience` uses.
171
186
  *
172
- * Multi-vault: Phase 1 collapses every vault entry under the single key
173
- * `vault`, first matching `parachute-vault*` row wins. Per-vault keys
174
- * (`services.vault.work.url` or `services["vault:work"].url`) are deferred
175
- * to a future design once notes ships its vault picker; multi-vault clients
176
- * need to probe `/.well-known/parachute.json` for the full vaults array
177
- * until then.
187
+ * Multi-vault (closes #247): emits per-vault keys `vault:<name>` alongside
188
+ * the collapsed `vault` key. A scope `vault:boulder:write` admits only
189
+ * boulder → emits `vault:boulder` (and the legacy `vault` key, pointing at
190
+ * boulder so it resolves consistently). A broad scope `vault:read` admits
191
+ * every vault on the hub → emits `vault:<name>` for each vault path plus
192
+ * the legacy `vault` key (pointing at `entry.paths[0]` of the first vault,
193
+ * unchanged from Phase 1). Notes' OAuthCallback (notes#115 ships the
194
+ * picker; per-vault consumer change is the post-#247 Notes-side PR) reads
195
+ * `services["vault:<name>"]` so it stops collapsing multi-vault grants
196
+ * onto a single VaultRecord URL.
197
+ *
198
+ * Pre-popover clients still see `services.vault` and behave unchanged —
199
+ * that key never goes away. Per-vault keys are additive.
178
200
  */
179
201
  export function buildServicesCatalog(
180
202
  manifest: ServicesManifest,
181
203
  issuer: string,
182
204
  scopes: readonly string[],
183
205
  ): ServicesCatalog {
206
+ // Two scope-derived sets:
207
+ // - audiences: bare service prefix (`vault`, `scribe`) → admits the
208
+ // collapsed key + every per-vault key.
209
+ // - namedVaults: per-vault narrowed scopes (`vault:<name>:<verb>`) →
210
+ // admits only `vault:<name>` and the collapsed `vault`.
211
+ //
212
+ // A token with both `vault:read` and `vault:boulder:write` should land in
213
+ // the "any vault" bucket — the bare scope is permissive, the named one
214
+ // is informational. Detect this via the bare-prefix presence; the named
215
+ // scope's per-vault narrowing still works for clients that prefer it.
184
216
  const audiences = new Set<string>();
217
+ const namedVaults = new Set<string>();
185
218
  for (const s of scopes) {
219
+ const parts = s.split(":");
220
+ if (
221
+ parts.length === 3 &&
222
+ parts[0] === "vault" &&
223
+ parts[1] &&
224
+ parts[2] &&
225
+ VAULT_VERBS.has(parts[2])
226
+ ) {
227
+ namedVaults.add(parts[1]);
228
+ audiences.add("vault");
229
+ continue;
230
+ }
186
231
  const colon = s.indexOf(":");
187
232
  if (colon > 0) audiences.add(s.slice(0, colon));
188
233
  }
234
+ const broadVaultScope =
235
+ audiences.has("vault") &&
236
+ scopes.some((s) => {
237
+ const parts = s.split(":");
238
+ return (
239
+ parts.length === 2 &&
240
+ parts[0] === "vault" &&
241
+ parts[1] !== undefined &&
242
+ VAULT_VERBS.has(parts[1])
243
+ );
244
+ });
245
+
246
+ // Count total admitted vault paths across the manifest. Per-vault keys
247
+ // are only worth emitting when there are >1 admitted vaults to
248
+ // disambiguate (or when the token's own scopes are per-vault narrowed —
249
+ // a per-vault scope is an explicit consumer signal that the per-vault
250
+ // key matters even on a single-vault hub). The check is on admitted
251
+ // paths, not raw vault rows: a broad token on a multi-path vault row
252
+ // sees N paths; a per-vault token sees only its own.
253
+ let admittedVaultPathCount = 0;
254
+ if (audiences.has("vault")) {
255
+ for (const entry of manifest.services) {
256
+ if (!isVaultEntry(entry)) continue;
257
+ const paths = entry.paths.length > 0 ? entry.paths : ["/"];
258
+ for (const path of paths) {
259
+ const instance = vaultInstanceNameFor(entry.name, path);
260
+ if (broadVaultScope || namedVaults.has(instance)) admittedVaultPathCount++;
261
+ }
262
+ }
263
+ }
264
+ const emitPerVaultKeys = admittedVaultPathCount > 1 || namedVaults.size > 0;
265
+
189
266
  const base = issuer.replace(/\/$/, "");
190
267
  const catalog: ServicesCatalog = {};
191
268
  for (const entry of manifest.services) {
192
- const path = entry.paths[0] ?? "/";
193
- const key = isVaultEntry(entry) ? "vault" : shortName(entry.name);
269
+ if (isVaultEntry(entry)) {
270
+ if (!audiences.has("vault")) continue;
271
+ // Walk every path the row exposes. Real multi-vault on the hub is a
272
+ // single `parachute-vault` row with N paths (one per vault instance);
273
+ // legacy per-vault rows (`parachute-vault-<name>`) are handled by the
274
+ // same loop because each contributes one path.
275
+ const paths = entry.paths.length > 0 ? entry.paths : ["/"];
276
+ for (const path of paths) {
277
+ const instance = vaultInstanceNameFor(entry.name, path);
278
+ const admit = broadVaultScope || namedVaults.has(instance);
279
+ if (!admit) continue;
280
+ if (emitPerVaultKeys) {
281
+ const perVaultKey = `vault:${instance}`;
282
+ if (!catalog[perVaultKey]) {
283
+ catalog[perVaultKey] = { url: `${base}${path}`, version: entry.version };
284
+ }
285
+ }
286
+ // Collapsed `vault` key stays for backwards compat. First admitted
287
+ // vault wins (deterministic — `entry.paths[0]` for a broad scope,
288
+ // or the only admitted instance for a per-vault scope).
289
+ if (!catalog.vault) {
290
+ catalog.vault = { url: `${base}${path}`, version: entry.version };
291
+ }
292
+ }
293
+ continue;
294
+ }
295
+ const key = shortName(entry.name);
194
296
  if (!audiences.has(key)) continue;
195
- if (catalog[key]) continue; // first vault wins; deterministic for clients
297
+ if (catalog[key]) continue;
298
+ const path = entry.paths[0] ?? "/";
196
299
  catalog[key] = { url: `${base}${path}`, version: entry.version };
197
300
  }
198
301
  return catalog;
@@ -330,8 +433,14 @@ function pendingClientResponse(
330
433
  const requestedScopes = (authorizeUrl.searchParams.get("scope") ?? "")
331
434
  .split(" ")
332
435
  .filter((s) => s.length > 0);
436
+ // Vault hint (closes #244): Notes' VaultPopover (notes#115) sets this on
437
+ // the authorize URL when kicking OAuth for a specific vault. Empty-string
438
+ // values normalize to undefined so the approve UI omits the row rather
439
+ // than rendering a blank vault label.
440
+ const vaultParam = authorizeUrl.searchParams.get("vault");
441
+ const requestedVault = vaultParam && vaultParam.length > 0 ? vaultParam : undefined;
333
442
  const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
334
- const sameOrigin = originMatchesIssuer(req, deps.issuer);
443
+ const sameOrigin = isSameOriginRequest(req, resolveBoundOrigins(deps));
335
444
  const csrf = ensureCsrfToken(req);
336
445
  const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
337
446
  if (session && sameOrigin) {
@@ -342,6 +451,7 @@ function pendingClientResponse(
342
451
  clientId: client.clientId,
343
452
  redirectUris: client.redirectUris,
344
453
  requestedScopes,
454
+ ...(requestedVault !== undefined && { requestedVault }),
345
455
  approveForm: { csrfToken: csrf.token, returnTo },
346
456
  }),
347
457
  403,
@@ -354,18 +464,39 @@ function pendingClientResponse(
354
464
  clientId: client.clientId,
355
465
  redirectUris: client.redirectUris,
356
466
  requestedScopes,
467
+ ...(requestedVault !== undefined && { requestedVault }),
357
468
  }),
358
469
  403,
359
470
  extra,
360
471
  );
361
472
  }
362
473
 
363
- /** JSON response for pending clients hitting /oauth/token. */
364
- function pendingClientJson(): Response {
474
+ /**
475
+ * JSON response for pending clients hitting /oauth/token. Carries two
476
+ * actionability hints alongside the OAuth error so consumers (Notes, future
477
+ * cross-origin SPAs) can surface an inline approval path instead of dead-
478
+ * ending on a CLI message:
479
+ *
480
+ * - `approve_url` — hub-served SPA route the operator can open in a
481
+ * browser to approve the client in one click. Same-origin to the hub.
482
+ * - `cli_alternative` — the `parachute auth approve-client <id>` shell
483
+ * command, retained for terminal-first operators or scripted flows.
484
+ *
485
+ * Spec note: the OAuth error class stays `invalid_client` per RFC 6749 §5.2
486
+ * — "this client cannot use this endpoint right now" is the semantic match.
487
+ * `access_denied` is reserved for /authorize "user said no" flows; using it
488
+ * here would conflate two distinct error families and break clients doing
489
+ * strict spec-shaped handling. The extra fields are spec-permitted
490
+ * extensions ("other parameters").
491
+ */
492
+ function pendingClientJson(clientId: string, issuer: string): Response {
493
+ const base = issuer.replace(/\/$/, "");
365
494
  return jsonResponse(
366
495
  {
367
496
  error: "invalid_client",
368
497
  error_description: "client is registered but has not been approved by the hub operator (#74)",
498
+ approve_url: `${base}/admin/approve-client/${encodeURIComponent(clientId)}`,
499
+ cli_alternative: `parachute auth approve-client ${clientId}`,
369
500
  },
370
501
  401,
371
502
  );
@@ -376,6 +507,61 @@ function pendingClientJson(): Response {
376
507
  * either renders the login form (no session) or the consent screen (session
377
508
  * present). All authorize-time params are echoed back via hidden inputs so
378
509
  * the form POST keeps the binding intact.
510
+ *
511
+ * ## Silent-approve flow (skip-consent gate, hub#75, hub#236)
512
+ *
513
+ * Cross-surface session smoothness ("first Notes use prompts for consent;
514
+ * subsequent uses are seamless") rides on a single gate further down in
515
+ * this function. The end-to-end flow:
516
+ *
517
+ * 1. **First use.** A client lands on `/oauth/authorize` with scope `S`.
518
+ * The user has a session but no prior `grants` row for this
519
+ * (user, client) pair. `isCoveredByGrant` returns false; the gate
520
+ * falls through; the consent screen renders. User clicks approve →
521
+ * `handleAuthorizePost` records a `grants` row keyed on
522
+ * (user_id, client_id) with the approved scopes, then mints the
523
+ * auth code.
524
+ * 2. **Subsequent use, same scopes.** Same client lands on
525
+ * `/oauth/authorize` with scope `S` again. `isCoveredByGrant` finds
526
+ * the row and returns true. The gate fires: auth code minted
527
+ * directly via `issueAuthCodeRedirect`; no consent screen renders;
528
+ * operator sees a silent redirect. This is the seamless second-use
529
+ * experience.
530
+ * 3. **Subsequent use, subset.** Client asks for scope `S' ⊂ S`. The
531
+ * grant covers every requested scope; gate fires.
532
+ * 4. **Subsequent use, novel scope.** Client asks for scope `S''`
533
+ * where `S'' ⊄ S` (a strict superset, or any new scope). The grant
534
+ * doesn't cover the new ask; gate falls through; consent re-renders
535
+ * with the new scope explicit. User must approve to extend the grant.
536
+ * 5. **Grant revoked.** Operator revokes via `/admin/permissions` or
537
+ * `parachute auth revoke-grant`. The next /authorize re-renders
538
+ * consent — already-minted refresh tokens keep working until they
539
+ * expire (or are revoked separately via `/oauth/revoke`).
540
+ *
541
+ * Two important constraints on the gate itself:
542
+ *
543
+ * - **Unnamed vault verbs (`vault:read`) always render consent.** The
544
+ * vault-picker UI is the only path that binds an unnamed scope to a
545
+ * specific vault (grants store narrowed `vault:<name>:<verb>`, so
546
+ * `vault:read` never matches a stored grant literally). Re-flowing
547
+ * with `vault:read` must always show the picker even if any prior
548
+ * grant exists.
549
+ * - **Client re-registration breaks the grant link.** Dynamic Client
550
+ * Registration mints a fresh `client_id` each time; grants are keyed
551
+ * on `(user_id, client_id)` so a re-registered client looks brand-
552
+ * new and re-prompts for consent. (Intentional: the operator should
553
+ * re-consent to an app whose registration was destroyed and re-made
554
+ * — that's a stronger signal of "this is the same app I trusted"
555
+ * than the redirect URI alone.)
556
+ *
557
+ * The full grant-scope subset semantics live in `grants.ts`
558
+ * `isCoveredByGrant`; the gate itself is the if-block below the
559
+ * "Skip-consent gate" comment in this function.
560
+ *
561
+ * Pinned by the regression test "first-use consent → silent-approve →
562
+ * novel scope re-prompts" in `oauth-handlers.test.ts` (hub#236), plus
563
+ * the per-branch tests in the same describe block (subset / superset /
564
+ * revoke / unnamed-vault / re-registered-client).
379
565
  */
380
566
  export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps): Response {
381
567
  const url = new URL(req.url);
@@ -533,7 +719,7 @@ export async function handleAuthorizePost(
533
719
 
534
720
  async function handleLoginSubmit(
535
721
  db: Database,
536
- _req: Request,
722
+ req: Request,
537
723
  form: Awaited<ReturnType<Request["formData"]>>,
538
724
  _deps: OAuthDeps,
539
725
  csrfToken: string,
@@ -562,7 +748,9 @@ async function handleLoginSubmit(
562
748
  );
563
749
  }
564
750
  const session = createSession(db, { userId: user.id });
565
- const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
751
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
752
+ secure: isHttpsRequest(req),
753
+ });
566
754
  // Redirect back to GET /oauth/authorize with the original query string so
567
755
  // the user lands on the consent screen with full params re-validated.
568
756
  const u = new URL("/oauth/authorize", "http://placeholder");
@@ -692,9 +880,12 @@ async function handleConsentSubmit(
692
880
  * 2. Active operator session (`findActiveSession`). The operator must be
693
881
  * logged into this hub from the browser submitting the form — no
694
882
  * session means no operator authority to approve anything.
695
- * 3. Origin/Referer matches the issuer (`originMatchesIssuer`). Same
696
- * shape as the DCR auto-approve gate (#199, #200): a same-origin POST
697
- * proves the form was rendered by *this hub*, not a forged page.
883
+ * 3. Origin/Referer matches a hub-bound origin (`isSameOriginRequest`).
884
+ * Same shape as the DCR auto-approve gate (#199, #200, #245): a same-
885
+ * origin POST proves the form was rendered by *this hub*, not a forged
886
+ * page. Bound origins include issuer + loopback + tailnet hostname
887
+ * (#245); pre-#245 was issuer-only and rejected legitimate operator
888
+ * paths from loopback / tailnet.
698
889
  *
699
890
  * `return_to` validation: the form embeds the original authorize URL so
700
891
  * the post-approve redirect lands the operator back on `/oauth/authorize`
@@ -725,7 +916,7 @@ export async function handleApproveClientPost(
725
916
  401,
726
917
  );
727
918
  }
728
- if (!originMatchesIssuer(req, deps.issuer)) {
919
+ if (!isSameOriginRequest(req, resolveBoundOrigins(deps))) {
729
920
  return htmlError(
730
921
  "Cross-origin request rejected",
731
922
  "The approve form must be submitted from this hub's own origin.",
@@ -931,7 +1122,7 @@ async function handleTokenAuthorizationCode(
931
1122
  if (!client) {
932
1123
  return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
933
1124
  }
934
- if (client.status !== "approved") return pendingClientJson();
1125
+ if (client.status !== "approved") return pendingClientJson(client.clientId, deps.issuer);
935
1126
  const authFailure = authenticateClient(client, req, form, clientId);
936
1127
  if (authFailure) return authFailure;
937
1128
  let redeemed: ReturnType<typeof redeemAuthCode>;
@@ -966,6 +1157,13 @@ async function handleTokenAuthorizationCode(
966
1157
  issuer: deps.issuer,
967
1158
  now: deps.now,
968
1159
  });
1160
+ // Phase 1 (#212) registry exemption: code-grant access tokens piggyback
1161
+ // on the paired refresh token's `tokens` row (they share `jti` by
1162
+ // design). We don't write a separate access-token row — revocation acts
1163
+ // on the shared jti / family, and the 15-min access TTL bounds the
1164
+ // window before per-jti re-validation is needed. A separate per-jti
1165
+ // access-token row would double registry write volume on every OAuth
1166
+ // grant + every refresh rotation; not worth the trade today.
969
1167
  const refresh = signRefreshToken(db, {
970
1168
  jti: access.jti,
971
1169
  userId: redeemed.userId,
@@ -1006,7 +1204,7 @@ async function handleTokenRefresh(
1006
1204
  if (!client) {
1007
1205
  return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
1008
1206
  }
1009
- if (client.status !== "approved") return pendingClientJson();
1207
+ if (client.status !== "approved") return pendingClientJson(client.clientId, deps.issuer);
1010
1208
  const authFailure = authenticateClient(client, req, form, clientId);
1011
1209
  if (authFailure) return authFailure;
1012
1210
  const row = findRefreshToken(db, refreshToken);
@@ -1019,6 +1217,18 @@ async function handleTokenRefresh(
1019
1217
  if (row.clientId !== clientId) {
1020
1218
  return jsonResponse({ error: "invalid_grant", error_description: "client_id mismatch" }, 400);
1021
1219
  }
1220
+ // Refresh-token rows always have a non-null user_id (the caller's hub
1221
+ // user). Post-v6 the column is nullable to accommodate non-OAuth mints
1222
+ // (operator/cli mints), but those rows have no `refresh_token_hash` so
1223
+ // `findRefreshToken` can't return them. Defensive: surface a clean
1224
+ // invalid_grant if a hand-crafted row shows up here without a user.
1225
+ if (!row.userId) {
1226
+ return jsonResponse(
1227
+ { error: "invalid_grant", error_description: "refresh_token has no associated user" },
1228
+ 400,
1229
+ );
1230
+ }
1231
+ const refreshUserId: string = row.userId;
1022
1232
  const now = deps.now?.() ?? new Date();
1023
1233
  if (row.revokedAt) {
1024
1234
  // Replay of an already-rotated refresh token. Per RFC 6819 §5.2.2.3 the
@@ -1053,7 +1263,7 @@ async function handleTokenRefresh(
1053
1263
  // (#107).
1054
1264
  const audience = inferAudience(row.scopes);
1055
1265
  const access = await signAccessToken(db, {
1056
- sub: row.userId,
1266
+ sub: refreshUserId,
1057
1267
  scopes: row.scopes,
1058
1268
  audience,
1059
1269
  clientId: row.clientId,
@@ -1066,7 +1276,7 @@ async function handleTokenRefresh(
1066
1276
  db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
1067
1277
  return signRefreshToken(db, {
1068
1278
  jti: access.jti,
1069
- userId: row.userId,
1279
+ userId: refreshUserId,
1070
1280
  clientId: row.clientId,
1071
1281
  scopes: row.scopes,
1072
1282
  familyId: row.familyId,
@@ -1236,37 +1446,15 @@ interface RegisterRequestBody {
1236
1446
  }
1237
1447
 
1238
1448
  /**
1239
- * CSRF defense for the cookie-based DCR auto-approve path (closes #199).
1240
- *
1241
- * Compares the request's `Origin` (or `Referer` as fallback) against the
1242
- * configured issuer origin. URL.origin compares scheme + host + port
1243
- * port-only mismatches reject. A request with neither header is treated as
1244
- * suspicious and rejected: cookie-bearing POSTs from same-origin browsers
1245
- * always send Origin (per Fetch standard) and almost always send Referer,
1246
- * so a header-stripped request is more likely a curl probe or a privacy
1247
- * extension on a third-party site than a legitimate same-origin caller.
1248
- *
1249
- * SameSite=Lax on the session cookie (sessions.ts:buildSessionCookie) is the
1250
- * browser-side defense layer; this function is the server-side belt.
1449
+ * Resolve the hub-bound origin set for a given `OAuthDeps`. Pre-#245 this
1450
+ * was implicit (just `deps.issuer`); post-#245 callers can thread a richer
1451
+ * set through `deps.hubBoundOrigins` so loopback + tailnet + funnel access
1452
+ * all match. Fallback to `[issuer]` keeps callers that haven't migrated
1453
+ * correct on single-origin hubs.
1251
1454
  */
1252
- function originMatchesIssuer(req: Request, issuer: string): boolean {
1253
- const origin = req.headers.get("origin");
1254
- if (origin) {
1255
- try {
1256
- return new URL(origin).origin === new URL(issuer).origin;
1257
- } catch {
1258
- return false;
1259
- }
1260
- }
1261
- const referer = req.headers.get("referer");
1262
- if (referer) {
1263
- try {
1264
- return new URL(referer).origin === new URL(issuer).origin;
1265
- } catch {
1266
- return false;
1267
- }
1268
- }
1269
- return false;
1455
+ function resolveBoundOrigins(deps: OAuthDeps): readonly string[] {
1456
+ if (deps.hubBoundOrigins) return deps.hubBoundOrigins();
1457
+ return [deps.issuer];
1270
1458
  }
1271
1459
 
1272
1460
  /**
@@ -1284,7 +1472,7 @@ function originMatchesIssuer(req: Request, issuer: string): boolean {
1284
1472
  * plus a same-origin `Origin`/`Referer` header. The browser path: an
1285
1473
  * operator hitting their own SPA from their own browser is by definition
1286
1474
  * operator-authenticated, so re-requiring approval is friction without
1287
- * benefit. CSRF defense is `originMatchesIssuer` + the cookie's
1475
+ * benefit. CSRF defense is `isSameOriginRequest` + the cookie's
1288
1476
  * `SameSite=Lax` attribute.
1289
1477
  *
1290
1478
  * If a bearer is presented but invalid or insufficient, we reject with the
@@ -1357,10 +1545,23 @@ export async function handleRegister(
1357
1545
  // public-DCR shape.
1358
1546
  if (status === "pending") {
1359
1547
  const session = findActiveSession(db, req, deps.now ?? (() => new Date()));
1360
- if (session && originMatchesIssuer(req, deps.issuer)) {
1548
+ if (session && isSameOriginRequest(req, resolveBoundOrigins(deps))) {
1361
1549
  status = "approved";
1362
1550
  }
1363
1551
  }
1552
+ // First-client auto-approve window (hub#268 Item 3). The wizard's expose
1553
+ // step opens a 60-minute window where the very next registration is
1554
+ // auto-approved. Single-use — the consume call clears the row on
1555
+ // success, so client #2 falls through to the standard pending-approval
1556
+ // flow. Logged so an operator chasing odd behavior can see it fired
1557
+ // and which client got the free pass.
1558
+ let autoApprovedByWizardWindow = false;
1559
+ if (status === "pending") {
1560
+ if (consumeFirstClientAutoApproveWindow(db, deps.now ?? (() => new Date()))) {
1561
+ status = "approved";
1562
+ autoApprovedByWizardWindow = true;
1563
+ }
1564
+ }
1364
1565
  const confidential = body.token_endpoint_auth_method === "client_secret_post";
1365
1566
  const scopes = (body.scope ?? "").split(" ").filter((s) => s.length > 0);
1366
1567
  let registered: RegisteredClient;
@@ -1377,6 +1578,11 @@ export async function handleRegister(
1377
1578
  const msg = err instanceof Error ? err.message : String(err);
1378
1579
  return jsonResponse({ error: "invalid_client_metadata", error_description: msg }, 400);
1379
1580
  }
1581
+ if (autoApprovedByWizardWindow) {
1582
+ console.log(
1583
+ `[oauth] auto-approved first client clientId=${registered.client.clientId} within wizard window (hub#268 Item 3)`,
1584
+ );
1585
+ }
1380
1586
  const respBody: Record<string, unknown> = {
1381
1587
  client_id: registered.client.clientId,
1382
1588
  redirect_uris: registered.client.redirectUris,
package/src/oauth-ui.ts CHANGED
@@ -111,6 +111,14 @@ export interface ApprovePendingViewProps {
111
111
  redirectUris: string[];
112
112
  /** Scopes parsed from the original `/oauth/authorize?scope=` query param. */
113
113
  requestedScopes: string[];
114
+ /**
115
+ * Vault hint from the original `/oauth/authorize?vault=<name>` query param,
116
+ * passed by Notes' VaultPopover (notes#115) when kicking the OAuth flow for
117
+ * a specific vault. Rendered alongside scopes so the operator can tell
118
+ * which vault they're approving access for on a multi-vault hub (closes
119
+ * #244). Single-vault hubs leave this absent and the section omits.
120
+ */
121
+ requestedVault?: string;
114
122
  /**
115
123
  * When set, render the inline approve form. The form posts to
116
124
  * `/oauth/authorize/approve` with the CSRF token + a `return_to` URL the
@@ -245,12 +253,25 @@ function renderVaultPicker(picker: VaultPicker): string {
245
253
  * context). The button is the easy path; the CLI is always-available.
246
254
  */
247
255
  export function renderApprovePending(props: ApprovePendingViewProps): string {
248
- const { clientName, clientId, redirectUris, requestedScopes, approveForm } = props;
256
+ const { clientName, clientId, redirectUris, requestedScopes, requestedVault, approveForm } =
257
+ props;
249
258
  const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
250
259
  const scopeRows =
251
260
  requestedScopes.length === 0
252
261
  ? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
253
262
  : requestedScopes.map(renderScopeRow).join("\n");
263
+ // Vault hint (closes #244): Notes' VaultPopover (notes#115) passes
264
+ // `vault=<name>` on `/oauth/authorize` for per-vault grants. Surface it
265
+ // alongside scopes so a multi-vault operator can tell which vault they're
266
+ // approving for. Missing on single-vault hubs / pre-vault-popover clients —
267
+ // section omits when absent.
268
+ const vaultRow = requestedVault
269
+ ? `
270
+ <p class="approve-meta-row">
271
+ <span class="approve-meta-label">vault</span>
272
+ <code class="approve-meta-value">${escapeHtml(requestedVault)}</code>
273
+ </p>`
274
+ : "";
254
275
  const formSection = approveForm
255
276
  ? `
256
277
  <form method="POST" action="/oauth/authorize/approve" class="auth-form approve-form">
@@ -289,7 +310,7 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
289
310
  <p class="approve-meta-row">
290
311
  <span class="approve-meta-label">client_id</span>
291
312
  <code class="approve-meta-value">${escapeHtml(clientId)}</code>
292
- </p>
313
+ </p>${vaultRow}
293
314
  <div class="approve-meta-row approve-meta-row-block">
294
315
  <span class="approve-meta-label">redirect_uris</span>
295
316
  <ul class="approve-redirect-list">${redirectList}</ul>