@openparachute/hub 0.7.4-rc.20 → 0.7.4-rc.21

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.20",
3
+ "version": "0.7.4-rc.21",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -172,6 +172,134 @@ describe("requireScope", () => {
172
172
  });
173
173
  });
174
174
 
175
+ describe("requireScope multi-origin issuer set (hub#516 parity)", () => {
176
+ // The hub answers on several legitimate origins after `parachute hub
177
+ // set-origin` / `expose` (loopback ∪ tunnel ∪ public). A credential minted
178
+ // under a still-valid prior origin must keep validating at admin-auth even
179
+ // when the per-request canonical issuer is now a different member of the set.
180
+ const LOOPBACK = "http://127.0.0.1:1939";
181
+ const TUNNEL = "https://brain.gitcoin.co";
182
+ const FOREIGN = "https://evil.example.com";
183
+
184
+ test("accepts a token whose iss is in the set but ≠ the per-request canonical", async () => {
185
+ const h = makeHarness();
186
+ try {
187
+ const db = openHubDb(hubDbPath(h.dir));
188
+ try {
189
+ rotateSigningKey(db);
190
+ // Token minted under the TUNNEL origin (e.g. before `set-origin`)…
191
+ const token = await mintToken(db, ["parachute:host:admin"], { issuer: TUNNEL });
192
+ // …presented to admin-auth where the canonical per-request issuer is now
193
+ // LOOPBACK, but the bound-origin set still includes TUNNEL.
194
+ const ctx = await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
195
+ LOOPBACK,
196
+ TUNNEL,
197
+ ]);
198
+ expect(ctx.sub).toBe("user-test");
199
+ expect(ctx.scopes).toContain("parachute:host:admin");
200
+ } finally {
201
+ db.close();
202
+ }
203
+ } finally {
204
+ h.cleanup();
205
+ }
206
+ });
207
+
208
+ test("rejects 401 when iss is OUTSIDE the set", async () => {
209
+ const h = makeHarness();
210
+ try {
211
+ const db = openHubDb(hubDbPath(h.dir));
212
+ try {
213
+ rotateSigningKey(db);
214
+ const token = await mintToken(db, ["parachute:host:admin"], { issuer: FOREIGN });
215
+ let caught: AdminAuthError | null = null;
216
+ try {
217
+ await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
218
+ LOOPBACK,
219
+ TUNNEL,
220
+ ]);
221
+ } catch (err) {
222
+ caught = err as AdminAuthError;
223
+ }
224
+ expect(caught?.status).toBe(401);
225
+ } finally {
226
+ db.close();
227
+ }
228
+ } finally {
229
+ h.cleanup();
230
+ }
231
+ });
232
+
233
+ test("rejects 401 on signature failure regardless of the set (signature-first)", async () => {
234
+ const h = makeHarness();
235
+ try {
236
+ const db = openHubDb(hubDbPath(h.dir));
237
+ try {
238
+ rotateSigningKey(db);
239
+ // A hub-signed token, then rotate keys + retire so the original kid no
240
+ // longer verifies — the JWKS signature gate must fire before any iss
241
+ // membership check, so an in-set iss can't rescue an unverifiable token.
242
+ let caught: AdminAuthError | null = null;
243
+ try {
244
+ await requireScope(db, reqWithAuth("Bearer not-a-real-jwt"), "parachute:host:admin", [
245
+ LOOPBACK,
246
+ TUNNEL,
247
+ ]);
248
+ } catch (err) {
249
+ caught = err as AdminAuthError;
250
+ }
251
+ expect(caught?.status).toBe(401);
252
+ } finally {
253
+ db.close();
254
+ }
255
+ } finally {
256
+ h.cleanup();
257
+ }
258
+ });
259
+
260
+ test("single-string back-compat: in-set iss as a lone string still validates", async () => {
261
+ const h = makeHarness();
262
+ try {
263
+ const db = openHubDb(hubDbPath(h.dir));
264
+ try {
265
+ rotateSigningKey(db);
266
+ const token = await mintToken(db, ["parachute:host:admin"], { issuer: ISSUER });
267
+ // A single-element set behaves exactly like the prior single-string form.
268
+ const ctx = await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
269
+ ISSUER,
270
+ ]);
271
+ expect(ctx.sub).toBe("user-test");
272
+ } finally {
273
+ db.close();
274
+ }
275
+ } finally {
276
+ h.cleanup();
277
+ }
278
+ });
279
+
280
+ test("single-element set rejects a non-matching iss (no accidental widening)", async () => {
281
+ const h = makeHarness();
282
+ try {
283
+ const db = openHubDb(hubDbPath(h.dir));
284
+ try {
285
+ rotateSigningKey(db);
286
+ const token = await mintToken(db, ["parachute:host:admin"], { issuer: TUNNEL });
287
+ let caught: AdminAuthError | null = null;
288
+ try {
289
+ await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [ISSUER]);
290
+ } catch (err) {
291
+ caught = err as AdminAuthError;
292
+ }
293
+ expect(caught?.status).toBe(401);
294
+ } finally {
295
+ db.close();
296
+ }
297
+ } finally {
298
+ h.cleanup();
299
+ }
300
+ });
301
+ });
302
+
175
303
  describe("adminAuthErrorResponse", () => {
176
304
  test("403 → insufficient_scope with WWW-Authenticate", async () => {
177
305
  const res = adminAuthErrorResponse(new AdminAuthError(403, "needs admin"));
@@ -172,6 +172,81 @@ describe("POST /api/auth/mint-token (hub#212 Phase 1)", () => {
172
172
  }
173
173
  });
174
174
 
175
+ // hub#516 parity — the live "mint refused" after `parachute hub set-origin`.
176
+ // An operator/agent credential minted under a PRIOR origin (still a member of
177
+ // the hub's bound-origin set) must keep minting after the canonical issuer
178
+ // switches; the minted token still carries the new canonical issuer.
179
+ describe("multi-origin issuer set (set-origin parity)", () => {
180
+ const TUNNEL = "https://brain.gitcoin.co";
181
+
182
+ test("mints when the bearer's iss is in knownIssuers but ≠ the canonical issuer", async () => {
183
+ const h = makeHarness();
184
+ try {
185
+ const { db, userId } = await bootstrap(h.dir);
186
+ try {
187
+ // Operator token minted under the TUNNEL origin (pre-`set-origin`).
188
+ const op = await mintOperatorToken(db, userId, { issuer: TUNNEL });
189
+ const resp = await handleApiMintToken(
190
+ jsonRequest(
191
+ { scope: "scribe:transcribe", expires_in: 3600 },
192
+ { authorization: `Bearer ${op.token}` },
193
+ ),
194
+ // Canonical issuer is now ISSUER (loopback), but the bound set still
195
+ // includes TUNNEL — the still-valid prior origin.
196
+ { db, issuer: ISSUER, knownIssuers: [ISSUER, TUNNEL] },
197
+ );
198
+ expect(resp.status).toBe(200);
199
+ const body = (await resp.json()) as { token: string };
200
+ // The MINTED token carries the canonical issuer, not the bearer's.
201
+ const validated = await validateAccessToken(db, body.token, ISSUER);
202
+ expect(validated.payload.iss).toBe(ISSUER);
203
+ } finally {
204
+ db.close();
205
+ }
206
+ } finally {
207
+ h.cleanup();
208
+ }
209
+ });
210
+
211
+ test("rejects 401 when the bearer's iss is OUTSIDE knownIssuers", async () => {
212
+ const h = makeHarness();
213
+ try {
214
+ const { db, userId } = await bootstrap(h.dir);
215
+ try {
216
+ const op = await mintOperatorToken(db, userId, { issuer: "https://evil.example.com" });
217
+ const resp = await handleApiMintToken(
218
+ jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${op.token}` }),
219
+ { db, issuer: ISSUER, knownIssuers: [ISSUER, TUNNEL] },
220
+ );
221
+ expect(resp.status).toBe(401);
222
+ } finally {
223
+ db.close();
224
+ }
225
+ } finally {
226
+ h.cleanup();
227
+ }
228
+ });
229
+
230
+ test("back-compat: without knownIssuers, a non-canonical iss is still rejected", async () => {
231
+ const h = makeHarness();
232
+ try {
233
+ const { db, userId } = await bootstrap(h.dir);
234
+ try {
235
+ const op = await mintOperatorToken(db, userId, { issuer: TUNNEL });
236
+ const resp = await handleApiMintToken(
237
+ jsonRequest({ scope: "scribe:transcribe" }, { authorization: `Bearer ${op.token}` }),
238
+ { db, issuer: ISSUER }, // no knownIssuers → falls back to [ISSUER]
239
+ );
240
+ expect(resp.status).toBe(401);
241
+ } finally {
242
+ db.close();
243
+ }
244
+ } finally {
245
+ h.cleanup();
246
+ }
247
+ });
248
+ });
249
+
175
250
  test("happy path: --scope-set=auth narrow operator token also passes the scope gate", async () => {
176
251
  const h = makeHarness();
177
252
  try {
@@ -370,6 +370,33 @@ describe("validateAccessToken", () => {
370
370
  }
371
371
  });
372
372
 
373
+ test("expectedIssuer accepts a SET — iss in the set validates, outside rejects", async () => {
374
+ const { db, cleanup } = makeDb();
375
+ try {
376
+ const { token } = await signAccessToken(db, {
377
+ sub: "u",
378
+ scopes: ["s"],
379
+ audience: "operator",
380
+ clientId: "c",
381
+ issuer: "https://tunnel.example",
382
+ });
383
+ // In-set (≠ first element) validates — additive membership on iss.
384
+ const { payload } = await validateAccessToken(db, token, [
385
+ "http://127.0.0.1:1939",
386
+ "https://tunnel.example",
387
+ ]);
388
+ expect(payload.iss).toBe("https://tunnel.example");
389
+ // Outside the set rejects.
390
+ await expect(
391
+ validateAccessToken(db, token, ["http://127.0.0.1:1939", "https://other.example"]),
392
+ ).rejects.toThrow();
393
+ // A single-element set that doesn't match also rejects (no widening).
394
+ await expect(validateAccessToken(db, token, ["http://127.0.0.1:1939"])).rejects.toThrow();
395
+ } finally {
396
+ cleanup();
397
+ }
398
+ });
399
+
373
400
  test("verifies a token signed by a recently-retired key (rotation tolerance)", async () => {
374
401
  const { db, cleanup } = makeDb();
375
402
  try {
@@ -131,6 +131,16 @@ export interface AgentGrantsDeps {
131
131
  * (`<hubOrigin>/oauth/agent-grant/callback`).
132
132
  */
133
133
  hubOrigin: string;
134
+ /**
135
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
136
+ * per-request issuer), built via `buildHubBoundOrigins`. The module's
137
+ * host-admin bearer `iss` is validated against THIS set rather than the
138
+ * single `hubOrigin`, so the agent module's credential minted under a
139
+ * still-valid prior origin keeps working across an origin switch (hub#516
140
+ * parity). Minted tokens still carry `hubOrigin`. Absent → falls back to
141
+ * `[hubOrigin]` (the prior strict per-request behavior).
142
+ */
143
+ knownIssuers?: readonly string[];
134
144
  /** Absolute path to `agent-grants.json` in the hub state dir. */
135
145
  storePath: string;
136
146
  /** Absolute path to `agent-oauth-flows.json` (the in-flight OAuth consents, 4b-2). */
@@ -249,7 +259,12 @@ async function requireModuleAuth(
249
259
  deps: AgentGrantsDeps,
250
260
  ): Promise<AdminAuthContext | Response> {
251
261
  try {
252
- return await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.hubOrigin);
262
+ return await requireScope(
263
+ deps.db,
264
+ req,
265
+ HOST_ADMIN_SCOPE,
266
+ deps.knownIssuers ?? [deps.hubOrigin],
267
+ );
253
268
  } catch (err) {
254
269
  return adminAuthErrorResponse(err as AdminAuthError);
255
270
  }
package/src/admin-auth.ts CHANGED
@@ -59,15 +59,24 @@ export function extractBearerToken(req: Request): string {
59
59
  * and check it carries `requiredScope`. Returns surfaced claims on success;
60
60
  * throws `AdminAuthError` (401 or 403) otherwise.
61
61
  *
62
- * `expectedIssuer` MUST be the hub's own origin — the same value baked into
63
- * tokens we sign. Defense in depth: even though we can only verify our own
64
- * keys, the `iss` mismatch reject keeps cross-issuer confusion impossible.
62
+ * `expectedIssuer` is the hub's own origin(s) — the same value(s) baked into
63
+ * tokens we sign. Pass a single string for a single-origin hub, or the SET of
64
+ * origins the hub legitimately answers on (`buildHubBoundOrigins`: loopback
65
+ * expose-state ∪ platform ∪ per-request issuer) so a credential minted under
66
+ * a still-valid prior origin keeps validating across an origin switch — the
67
+ * same multi-origin posture the OAuth path and `validateHostAdminToken`
68
+ * already use. Defense in depth: even though we can only verify our own keys,
69
+ * the `iss`-∈-set reject keeps cross-issuer confusion impossible. SECURITY:
70
+ * the set is ONLY an additive `iss` membership relaxation — `validateAccessToken`
71
+ * verifies the signature against the hub's own key FIRST, so only tokens this
72
+ * hub minted ever reach the `iss` check; never pass a raw request Host, only a
73
+ * `buildHubBoundOrigins`-derived set.
65
74
  */
66
75
  export async function requireScope(
67
76
  db: Database,
68
77
  req: Request,
69
78
  requiredScope: string,
70
- expectedIssuer: string,
79
+ expectedIssuer: string | readonly string[],
71
80
  ): Promise<AdminAuthContext> {
72
81
  const token = extractBearerToken(req);
73
82
 
@@ -64,6 +64,15 @@ export interface AdminClientsDeps {
64
64
  db: Database;
65
65
  /** Hub origin — passed through to JWT validation as the expected `iss`. */
66
66
  issuer: string;
67
+ /**
68
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
69
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
70
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
71
+ * credential minted under a still-valid prior origin keeps working across an
72
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
73
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
74
+ */
75
+ knownIssuers?: readonly string[];
67
76
  }
68
77
 
69
78
  export interface AdminClientView {
@@ -93,7 +102,7 @@ export async function handleGetClient(
93
102
  return jsonError(405, "method_not_allowed", "use GET");
94
103
  }
95
104
  try {
96
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
105
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
97
106
  } catch (err) {
98
107
  return adminAuthErrorResponse(err as AdminAuthError);
99
108
  }
@@ -129,7 +138,7 @@ export async function handleApproveClient(
129
138
  }
130
139
  let ctx: AdminAuthContext;
131
140
  try {
132
- ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
141
+ ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
133
142
  } catch (err) {
134
143
  return adminAuthErrorResponse(err as AdminAuthError);
135
144
  }
@@ -212,7 +221,7 @@ export async function handleDeleteClient(
212
221
  }
213
222
  let ctx: AdminAuthContext;
214
223
  try {
215
- ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
224
+ ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
216
225
  } catch (err) {
217
226
  return adminAuthErrorResponse(err as AdminAuthError);
218
227
  }
@@ -38,6 +38,15 @@ export interface AdminGrantsDeps {
38
38
  db: Database;
39
39
  /** Hub origin — passed through to JWT validation as the expected `iss`. */
40
40
  issuer: string;
41
+ /**
42
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
43
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
44
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
45
+ * credential minted under a still-valid prior origin keeps working across an
46
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
47
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
48
+ */
49
+ knownIssuers?: readonly string[];
41
50
  }
42
51
 
43
52
  export interface AdminGrantListing {
@@ -55,7 +64,7 @@ export async function handleListGrants(req: Request, deps: AdminGrantsDeps): Pro
55
64
  }
56
65
  let ctx: AdminAuthContext;
57
66
  try {
58
- ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
67
+ ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
59
68
  } catch (err) {
60
69
  return adminAuthErrorResponse(err as AdminAuthError);
61
70
  }
@@ -111,7 +120,7 @@ export async function handleRevokeGrant(
111
120
  }
112
121
  let ctx: AdminAuthContext;
113
122
  try {
114
- ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
123
+ ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
115
124
  } catch (err) {
116
125
  return adminAuthErrorResponse(err as AdminAuthError);
117
126
  }
@@ -129,6 +129,15 @@ export interface CreateVaultDeps {
129
129
  db: Database;
130
130
  /** Hub origin used to validate JWT `iss` and to build the response `url`. */
131
131
  issuer: string;
132
+ /**
133
+ * SET of origins the hub legitimately answers on (loopback ∪ expose-state ∪
134
+ * platform ∪ per-request `issuer`), built via `buildHubBoundOrigins`. The
135
+ * admin bearer's `iss` is validated against THIS set rather than the single
136
+ * `issuer`, so a host-admin credential minted under a still-valid prior
137
+ * origin keeps working across an origin switch (hub#516 parity). Absent →
138
+ * falls back to `[issuer]` (the prior strict per-request behavior).
139
+ */
140
+ knownIssuers?: readonly string[];
132
141
  /** Override the services.json path. Defaults to `~/.parachute/services.json`. */
133
142
  manifestPath?: string;
134
143
  /**
@@ -442,7 +451,7 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
442
451
  // Auth gate: parachute:host:admin scope. Maps an AdminAuthError straight
443
452
  // to an RFC 6750 401/403 — the route handler doesn't care which.
444
453
  try {
445
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
454
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
446
455
  } catch (err) {
447
456
  return adminAuthErrorResponse(err as AdminAuthError);
448
457
  }
@@ -530,6 +539,15 @@ export interface DeleteVaultDeps {
530
539
  db: Database;
531
540
  /** Hub origin — JWT `iss` validation + cascade mint issuer. */
532
541
  issuer: string;
542
+ /**
543
+ * SET of origins the hub legitimately answers on (loopback ∪ expose-state ∪
544
+ * platform ∪ per-request `issuer`), built via `buildHubBoundOrigins`. The
545
+ * admin bearer's `iss` is validated against THIS set rather than the single
546
+ * `issuer`, so a host-admin credential minted under a still-valid prior
547
+ * origin keeps working across an origin switch (hub#516 parity). Absent →
548
+ * falls back to `[issuer]` (the prior strict per-request behavior).
549
+ */
550
+ knownIssuers?: readonly string[];
533
551
  /** Override the services.json path. Defaults to `~/.parachute/services.json`. */
534
552
  manifestPath?: string;
535
553
  /** Absolute path to `connections.json` in the hub state dir. */
@@ -693,7 +711,12 @@ export async function handleDeleteVault(
693
711
  // Auth gate: parachute:host:admin — the same gate as POST /vaults.
694
712
  let adminSub: string;
695
713
  try {
696
- const auth = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
714
+ const auth = await requireScope(
715
+ deps.db,
716
+ req,
717
+ HOST_ADMIN_SCOPE,
718
+ deps.knownIssuers ?? [deps.issuer],
719
+ );
697
720
  adminSub = auth.sub;
698
721
  } catch (err) {
699
722
  return adminAuthErrorResponse(err as AdminAuthError);
@@ -107,6 +107,15 @@ export interface ApiHubUpgradeDeps {
107
107
  db: Database;
108
108
  /** Hub origin — validates the bearer's `iss`. */
109
109
  issuer: string;
110
+ /**
111
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
112
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
113
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
114
+ * credential minted under a still-valid prior origin keeps working across an
115
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
116
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
117
+ */
118
+ knownIssuers?: readonly string[];
110
119
  /** PARACHUTE_HOME — where the status file is read/written. */
111
120
  configDir: string;
112
121
  /**
@@ -155,7 +164,11 @@ async function authorize(req: Request, deps: ApiHubUpgradeDeps): Promise<Respons
155
164
  const bearer = auth.slice("Bearer ".length).trim();
156
165
  if (!bearer) return jsonError(401, "unauthenticated", "empty bearer token");
157
166
  try {
158
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
167
+ const validated = await validateAccessToken(
168
+ deps.db,
169
+ bearer,
170
+ deps.knownIssuers ?? [deps.issuer],
171
+ );
159
172
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
160
173
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
161
174
  }
package/src/api-hub.ts CHANGED
@@ -52,6 +52,15 @@ export interface ApiHubDeps {
52
52
  db: Database;
53
53
  /** Hub origin — used to validate the bearer's `iss`. */
54
54
  issuer: string;
55
+ /**
56
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
57
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
58
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
59
+ * credential minted under a still-valid prior origin keeps working across an
60
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
61
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
62
+ */
63
+ knownIssuers?: readonly string[];
55
64
  /**
56
65
  * Override the directory used to locate the hub's package.json and to
57
66
  * classify install source. Defaults to `dirname(import.meta.url)` —
@@ -96,7 +105,7 @@ export async function handleApiHub(req: Request, deps: ApiHubDeps): Promise<Resp
96
105
  // Bearer-gate on `parachute:host:admin`. Same shape as the other admin
97
106
  // endpoints — SPA mints via /admin/host-admin-token.
98
107
  try {
99
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
108
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
100
109
  } catch (err) {
101
110
  return adminAuthErrorResponse(err);
102
111
  }
@@ -52,6 +52,16 @@ export interface ApiInvitesDeps {
52
52
  db: Database;
53
53
  /** Hub origin — JWT `iss` validation AND the base for the redemption URL. */
54
54
  issuer: string;
55
+ /**
56
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
57
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
58
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
59
+ * credential minted under a still-valid prior origin keeps working across an
60
+ * origin switch (hub#516 parity). The redemption URL still uses the single
61
+ * canonical `issuer`. Absent → falls back to `[issuer]` (the prior strict
62
+ * per-request behavior; tests/non-HTTP callers unaffected).
63
+ */
64
+ knownIssuers?: readonly string[];
55
65
  manifestPath?: string;
56
66
  now?: () => Date;
57
67
  }
@@ -399,7 +409,12 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
399
409
  try {
400
410
  // `requireScope` returns the validated claims; the admin's `sub` is the
401
411
  // `created_by` audit anchor (guaranteed present — it throws otherwise).
402
- const auth = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
412
+ const auth = await requireScope(
413
+ deps.db,
414
+ req,
415
+ HOST_ADMIN_SCOPE,
416
+ deps.knownIssuers ?? [deps.issuer],
417
+ );
403
418
  authUserId = auth.sub;
404
419
  } catch (err) {
405
420
  return adminAuthErrorResponse(err as AdminAuthError);
@@ -544,7 +559,7 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
544
559
  export async function handleListInvites(req: Request, deps: ApiInvitesDeps): Promise<Response> {
545
560
  if (req.method !== "GET") return jsonError(405, "method_not_allowed", "use GET");
546
561
  try {
547
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
562
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
548
563
  } catch (err) {
549
564
  return adminAuthErrorResponse(err as AdminAuthError);
550
565
  }
@@ -564,7 +579,7 @@ export async function handleRevokeInvite(
564
579
  ): Promise<Response> {
565
580
  if (req.method !== "DELETE") return jsonError(405, "method_not_allowed", "use DELETE");
566
581
  try {
567
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
582
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
568
583
  } catch (err) {
569
584
  return adminAuthErrorResponse(err as AdminAuthError);
570
585
  }
@@ -87,6 +87,17 @@ export interface ApiMintTokenDeps {
87
87
  db: Database;
88
88
  /** Hub origin — written into the JWT `iss` of minted tokens AND used to validate the bearer. */
89
89
  issuer: string;
90
+ /**
91
+ * SET of origins the hub legitimately answers on (loopback ∪ expose-state ∪
92
+ * platform ∪ per-request `issuer`), built via `buildHubBoundOrigins`. The
93
+ * caller's bearer `iss` is validated against THIS set rather than the single
94
+ * `issuer`, so a credential minted under a still-valid prior origin keeps
95
+ * minting across an origin switch (hub#516 parity — the live "mint refused"
96
+ * after `set-origin`). Minted tokens still carry the single canonical
97
+ * `issuer` as their `iss`. Absent → falls back to `[issuer]` (the prior
98
+ * strict per-request behavior; tests/non-HTTP callers unaffected).
99
+ */
100
+ knownIssuers?: readonly string[];
90
101
  /**
91
102
  * Names of vault instances currently registered in services.json (item D /
92
103
  * hub#450). When provided, a `vault:<name>:admin` mint whose `<name>` is not
@@ -133,7 +144,11 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
133
144
  let bearerSub: string;
134
145
  let bearerScopes: string[];
135
146
  try {
136
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
147
+ const validated = await validateAccessToken(
148
+ deps.db,
149
+ bearer,
150
+ deps.knownIssuers ?? [deps.issuer],
151
+ );
137
152
  const sub = validated.payload.sub;
138
153
  if (typeof sub !== "string" || sub.length === 0) {
139
154
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
@@ -759,6 +759,15 @@ export const API_MODULES_CHANNEL_REQUIRED_SCOPE = "parachute:host:admin";
759
759
  export interface ApiModulesChannelDeps {
760
760
  db: Database;
761
761
  issuer: string;
762
+ /**
763
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
764
+ * per-request `issuer`), built via `buildHubBoundOrigins` — same posture as
765
+ * {@link ApiModulesDeps.knownIssuers}. The bearer's `iss` is validated
766
+ * against THIS set rather than the single `issuer`, so the operator token
767
+ * (public `iss` after `expose`) is accepted on loopback. Absent → falls back
768
+ * to `[issuer]` (the prior strict per-request behavior).
769
+ */
770
+ knownIssuers?: readonly string[];
762
771
  }
763
772
 
764
773
  export async function handleApiModulesChannel(
@@ -781,7 +790,11 @@ export async function handleApiModulesChannel(
781
790
 
782
791
  // Bearer validation + scope check.
783
792
  try {
784
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
793
+ const validated = await validateAccessToken(
794
+ deps.db,
795
+ bearer,
796
+ deps.knownIssuers ?? [deps.issuer],
797
+ );
785
798
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
786
799
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
787
800
  }
@@ -73,6 +73,15 @@ export interface ApiRevokeTokenDeps {
73
73
  db: Database;
74
74
  /** Hub origin — used to validate the bearer's `iss`. */
75
75
  issuer: string;
76
+ /**
77
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
78
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
79
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
80
+ * credential minted under a still-valid prior origin keeps working across an
81
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
82
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
83
+ */
84
+ knownIssuers?: readonly string[];
76
85
  /** Test seam for time. */
77
86
  now?: () => Date;
78
87
  }
@@ -102,7 +111,11 @@ export async function handleApiRevokeToken(
102
111
  // 2. Bearer validation (signature, issuer, expiry, hub-side revocation).
103
112
  let bearerScopes: string[];
104
113
  try {
105
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
114
+ const validated = await validateAccessToken(
115
+ deps.db,
116
+ bearer,
117
+ deps.knownIssuers ?? [deps.issuer],
118
+ );
106
119
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
107
120
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
108
121
  }
@@ -47,6 +47,15 @@ export const API_SETTINGS_HUB_ORIGIN_REQUIRED_SCOPE = "parachute:host:admin";
47
47
  export interface ApiSettingsHubOriginDeps {
48
48
  db: Database;
49
49
  issuer: string;
50
+ /**
51
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
52
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
53
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
54
+ * credential minted under a still-valid prior origin keeps working across an
55
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
56
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
57
+ */
58
+ knownIssuers?: readonly string[];
50
59
  /**
51
60
  * The currently-resolved issuer + its source layer. Computed by the
52
61
  * dispatcher (which has the request + `configuredIssuer` already in
@@ -186,7 +195,11 @@ export async function handleApiSettingsHubOrigin(
186
195
 
187
196
  // Bearer validation + scope check.
188
197
  try {
189
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
198
+ const validated = await validateAccessToken(
199
+ deps.db,
200
+ bearer,
201
+ deps.knownIssuers ?? [deps.issuer],
202
+ );
190
203
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
191
204
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
192
205
  }
@@ -44,6 +44,15 @@ export interface ApiSettingsRootRedirectDeps {
44
44
  db: Database;
45
45
  /** Issuer the bearer token must validate against (the hub's resolved issuer). */
46
46
  issuer: string;
47
+ /**
48
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
49
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
50
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
51
+ * credential minted under a still-valid prior origin keeps working across an
52
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
53
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
54
+ */
55
+ knownIssuers?: readonly string[];
47
56
  /**
48
57
  * Env seam for the resolver's env layer. Defaults to `process.env`. Threaded
49
58
  * so the dispatcher (and tests) can resolve `PARACHUTE_HUB_ROOT_REDIRECT`
@@ -120,7 +129,11 @@ export async function handleApiSettingsRootRedirect(
120
129
 
121
130
  // Bearer validation + scope check.
122
131
  try {
123
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
132
+ const validated = await validateAccessToken(
133
+ deps.db,
134
+ bearer,
135
+ deps.knownIssuers ?? [deps.issuer],
136
+ );
124
137
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
125
138
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
126
139
  }
package/src/api-tokens.ts CHANGED
@@ -67,6 +67,15 @@ export interface ApiTokensDeps {
67
67
  db: Database;
68
68
  /** Hub origin — used to validate the bearer's `iss`. */
69
69
  issuer: string;
70
+ /**
71
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
72
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
73
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
74
+ * credential minted under a still-valid prior origin keeps working across an
75
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
76
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
77
+ */
78
+ knownIssuers?: readonly string[];
70
79
  }
71
80
 
72
81
  interface TokenWireShape {
@@ -115,7 +124,11 @@ export async function handleApiTokens(req: Request, deps: ApiTokensDeps): Promis
115
124
  // 2. Bearer validation.
116
125
  let bearerScopes: string[];
117
126
  try {
118
- const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
127
+ const validated = await validateAccessToken(
128
+ deps.db,
129
+ bearer,
130
+ deps.knownIssuers ?? [deps.issuer],
131
+ );
119
132
  if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
120
133
  return jsonError(401, "unauthenticated", "bearer token has no sub claim");
121
134
  }
package/src/api-users.ts CHANGED
@@ -69,6 +69,15 @@ export interface ApiUsersDeps {
69
69
  db: Database;
70
70
  /** Hub origin — JWT `iss` validation. */
71
71
  issuer: string;
72
+ /**
73
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
74
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
75
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
76
+ * credential minted under a still-valid prior origin keeps working across an
77
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
78
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
79
+ */
80
+ knownIssuers?: readonly string[];
72
81
  /** Override services.json path. Defaults to `~/.parachute/services.json`. */
73
82
  manifestPath?: string;
74
83
  }
@@ -118,7 +127,7 @@ export async function handleListUsers(req: Request, deps: ApiUsersDeps): Promise
118
127
  return jsonError(405, "method_not_allowed", "use GET");
119
128
  }
120
129
  try {
121
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
130
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
122
131
  } catch (err) {
123
132
  return adminAuthErrorResponse(err as AdminAuthError);
124
133
  }
@@ -262,7 +271,7 @@ export async function handleCreateUser(req: Request, deps: ApiUsersDeps): Promis
262
271
  return jsonError(405, "method_not_allowed", "use POST");
263
272
  }
264
273
  try {
265
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
274
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
266
275
  } catch (err) {
267
276
  return adminAuthErrorResponse(err as AdminAuthError);
268
277
  }
@@ -358,7 +367,7 @@ export async function handleDeleteUser(
358
367
  return jsonError(405, "method_not_allowed", "use DELETE");
359
368
  }
360
369
  try {
361
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
370
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
362
371
  } catch (err) {
363
372
  return adminAuthErrorResponse(err as AdminAuthError);
364
373
  }
@@ -441,7 +450,7 @@ export async function handleListVaults(req: Request, deps: ApiUsersDeps): Promis
441
450
  return jsonError(405, "method_not_allowed", "use GET");
442
451
  }
443
452
  try {
444
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
453
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
445
454
  } catch (err) {
446
455
  return adminAuthErrorResponse(err as AdminAuthError);
447
456
  }
@@ -571,7 +580,7 @@ export async function handleUpdateUserVaults(
571
580
  return jsonError(405, "method_not_allowed", "use PATCH");
572
581
  }
573
582
  try {
574
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
583
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
575
584
  } catch (err) {
576
585
  return adminAuthErrorResponse(err as AdminAuthError);
577
586
  }
@@ -760,7 +769,7 @@ export async function handleResetUserPassword(
760
769
  return jsonError(405, "method_not_allowed", "use POST");
761
770
  }
762
771
  try {
763
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
772
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
764
773
  } catch (err) {
765
774
  return adminAuthErrorResponse(err as AdminAuthError);
766
775
  }
@@ -39,6 +39,15 @@ export interface ApiVaultCapsDeps {
39
39
  db: Database;
40
40
  /** Hub origin — JWT `iss` validation. */
41
41
  issuer: string;
42
+ /**
43
+ * SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
44
+ * per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
45
+ * `iss` is validated against THIS set rather than the single `issuer`, so a
46
+ * credential minted under a still-valid prior origin keeps working across an
47
+ * origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
48
+ * prior strict per-request behavior; tests/non-HTTP callers unaffected).
49
+ */
50
+ knownIssuers?: readonly string[];
42
51
  /** Override services.json path. Defaults to `~/.parachute/services.json`. */
43
52
  manifestPath?: string;
44
53
  }
@@ -71,7 +80,7 @@ export async function handleListVaultCaps(req: Request, deps: ApiVaultCapsDeps):
71
80
  return jsonError(405, "method_not_allowed", "use GET");
72
81
  }
73
82
  try {
74
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
83
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
75
84
  } catch (err) {
76
85
  return adminAuthErrorResponse(err as AdminAuthError);
77
86
  }
@@ -174,7 +183,7 @@ export async function handleSetVaultCap(
174
183
  return jsonError(405, "method_not_allowed", "use PUT");
175
184
  }
176
185
  try {
177
- await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
186
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
178
187
  } catch (err) {
179
188
  return adminAuthErrorResponse(err as AdminAuthError);
180
189
  }
package/src/hub-server.ts CHANGED
@@ -2801,6 +2801,7 @@ export function hubFetch(
2801
2801
  await handleDeleteClient(req, clientId, {
2802
2802
  db: getDb(),
2803
2803
  issuer: oauthDeps(req).issuer,
2804
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
2804
2805
  }),
2805
2806
  );
2806
2807
  }
@@ -2842,6 +2843,7 @@ export function hubFetch(
2842
2843
  return handleCreateVault(req, {
2843
2844
  db: getDb(),
2844
2845
  issuer: oauthDeps(req).issuer,
2846
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
2845
2847
  });
2846
2848
  }
2847
2849
 
@@ -2868,6 +2870,7 @@ export function hubFetch(
2868
2870
  return handleDeleteVault(req, name, {
2869
2871
  db: getDb(),
2870
2872
  issuer: oauthDeps(req).issuer,
2873
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
2871
2874
  manifestPath,
2872
2875
  connectionsStorePath: deps?.connectionsStorePath ?? join(CONFIG_DIR, "connections.json"),
2873
2876
  agentOrigin,
@@ -3024,6 +3027,10 @@ export function hubFetch(
3024
3027
  const agentGrantsDeps: AgentGrantsDeps = {
3025
3028
  db: getDb(),
3026
3029
  hubOrigin: oauthDeps(req).issuer,
3030
+ // hub#516 parity: validate the module's host-admin bearer `iss`
3031
+ // against the hub's known-origin set (PUT /admin/grants is the only
3032
+ // bearer-gated route here; the POST /approve|/revoke are cookie-authed).
3033
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3027
3034
  storePath: deps?.agentGrantsStorePath ?? join(CONFIG_DIR, "agent-grants.json"),
3028
3035
  flowsStorePath:
3029
3036
  deps?.agentOAuthFlowsStorePath ?? join(CONFIG_DIR, "agent-oauth-flows.json"),
@@ -3116,6 +3123,7 @@ export function hubFetch(
3116
3123
  return handleHubUpgrade(req, {
3117
3124
  db: getDb(),
3118
3125
  issuer: oauthDeps(req).issuer,
3126
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3119
3127
  configDir: CONFIG_DIR,
3120
3128
  });
3121
3129
  }
@@ -3124,6 +3132,7 @@ export function hubFetch(
3124
3132
  return handleHubUpgradeStatus(req, {
3125
3133
  db: getDb(),
3126
3134
  issuer: oauthDeps(req).issuer,
3135
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3127
3136
  configDir: CONFIG_DIR,
3128
3137
  });
3129
3138
  }
@@ -3136,6 +3145,7 @@ export function hubFetch(
3136
3145
  return handleApiHub(req, {
3137
3146
  db: getDb(),
3138
3147
  issuer: oauthDeps(req).issuer,
3148
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3139
3149
  });
3140
3150
  }
3141
3151
 
@@ -3171,6 +3181,7 @@ export function hubFetch(
3171
3181
  return handleApiModulesChannel(req, {
3172
3182
  db: getDb(),
3173
3183
  issuer: oauthDeps(req).issuer,
3184
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3174
3185
  });
3175
3186
  }
3176
3187
 
@@ -3184,6 +3195,7 @@ export function hubFetch(
3184
3195
  return handleApiSettingsHubOrigin(req, {
3185
3196
  db,
3186
3197
  issuer: oauthDeps(req).issuer,
3198
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3187
3199
  resolvedIssuer: resolveIssuer(req, db, configuredIssuer, loadExposeHubOrigin),
3188
3200
  resolvedSource: resolveIssuerSource(db, configuredIssuer, loadExposeHubOrigin),
3189
3201
  });
@@ -3198,6 +3210,7 @@ export function hubFetch(
3198
3210
  return handleApiSettingsRootRedirect(req, {
3199
3211
  db: getDb(),
3200
3212
  issuer: oauthDeps(req).issuer,
3213
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3201
3214
  });
3202
3215
  }
3203
3216
 
@@ -3300,6 +3313,7 @@ export function hubFetch(
3300
3313
  return handleApiMintToken(req, {
3301
3314
  db: getDb(),
3302
3315
  issuer: oauthDeps(req).issuer,
3316
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3303
3317
  knownVaultNames: mintKnownVaultNames,
3304
3318
  });
3305
3319
  }
@@ -3309,6 +3323,7 @@ export function hubFetch(
3309
3323
  return handleApiRevokeToken(req, {
3310
3324
  db: getDb(),
3311
3325
  issuer: oauthDeps(req).issuer,
3326
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3312
3327
  });
3313
3328
  }
3314
3329
 
@@ -3317,6 +3332,7 @@ export function hubFetch(
3317
3332
  return handleApiTokens(req, {
3318
3333
  db: getDb(),
3319
3334
  issuer: oauthDeps(req).issuer,
3335
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3320
3336
  });
3321
3337
  }
3322
3338
 
@@ -3325,6 +3341,7 @@ export function hubFetch(
3325
3341
  return handleListGrants(req, {
3326
3342
  db: getDb(),
3327
3343
  issuer: oauthDeps(req).issuer,
3344
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3328
3345
  });
3329
3346
  }
3330
3347
 
@@ -3337,6 +3354,7 @@ export function hubFetch(
3337
3354
  return handleRevokeGrant(req, clientId, {
3338
3355
  db: getDb(),
3339
3356
  issuer: oauthDeps(req).issuer,
3357
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3340
3358
  });
3341
3359
  }
3342
3360
 
@@ -3359,6 +3377,7 @@ export function hubFetch(
3359
3377
  return handleApproveClient(req, clientId, {
3360
3378
  db: getDb(),
3361
3379
  issuer: oauthDeps(req).issuer,
3380
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3362
3381
  });
3363
3382
  }
3364
3383
  const clientId = decodeURIComponent(tail);
@@ -3368,6 +3387,7 @@ export function hubFetch(
3368
3387
  return handleGetClient(req, clientId, {
3369
3388
  db: getDb(),
3370
3389
  issuer: oauthDeps(req).issuer,
3390
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3371
3391
  });
3372
3392
  }
3373
3393
 
@@ -3383,6 +3403,7 @@ export function hubFetch(
3383
3403
  const usersDeps = {
3384
3404
  db: getDb(),
3385
3405
  issuer: oauthDeps(req).issuer,
3406
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3386
3407
  manifestPath,
3387
3408
  };
3388
3409
  if (req.method === "GET") return handleListUsers(req, usersDeps);
@@ -3394,6 +3415,7 @@ export function hubFetch(
3394
3415
  return handleListVaults(req, {
3395
3416
  db: getDb(),
3396
3417
  issuer: oauthDeps(req).issuer,
3418
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3397
3419
  manifestPath,
3398
3420
  });
3399
3421
  }
@@ -3413,6 +3435,7 @@ export function hubFetch(
3413
3435
  return handleResetUserPassword(req, id, {
3414
3436
  db: getDb(),
3415
3437
  issuer: oauthDeps(req).issuer,
3438
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3416
3439
  manifestPath,
3417
3440
  });
3418
3441
  }
@@ -3431,6 +3454,7 @@ export function hubFetch(
3431
3454
  return handleUpdateUserVaults(req, id, {
3432
3455
  db: getDb(),
3433
3456
  issuer: oauthDeps(req).issuer,
3457
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3434
3458
  manifestPath,
3435
3459
  });
3436
3460
  }
@@ -3444,6 +3468,7 @@ export function hubFetch(
3444
3468
  return handleDeleteUser(req, id, {
3445
3469
  db: getDb(),
3446
3470
  issuer: oauthDeps(req).issuer,
3471
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3447
3472
  manifestPath,
3448
3473
  });
3449
3474
  }
@@ -3453,7 +3478,12 @@ export function hubFetch(
3453
3478
  // lists (status-annotated), DELETE /:id revokes by sha256 hash.
3454
3479
  if (pathname === "/api/invites") {
3455
3480
  if (!getDb) return dbNotConfigured();
3456
- const invitesDeps = { db: getDb(), issuer: oauthDeps(req).issuer, manifestPath };
3481
+ const invitesDeps = {
3482
+ db: getDb(),
3483
+ issuer: oauthDeps(req).issuer,
3484
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3485
+ manifestPath,
3486
+ };
3457
3487
  if (req.method === "GET") return handleListInvites(req, invitesDeps);
3458
3488
  if (req.method === "POST") return handleCreateInvite(req, invitesDeps);
3459
3489
  return new Response("method not allowed", { status: 405 });
@@ -3467,6 +3497,7 @@ export function hubFetch(
3467
3497
  return handleRevokeInvite(req, id, {
3468
3498
  db: getDb(),
3469
3499
  issuer: oauthDeps(req).issuer,
3500
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3470
3501
  manifestPath,
3471
3502
  });
3472
3503
  }
@@ -3479,6 +3510,7 @@ export function hubFetch(
3479
3510
  return handleListVaultCaps(req, {
3480
3511
  db: getDb(),
3481
3512
  issuer: oauthDeps(req).issuer,
3513
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3482
3514
  manifestPath,
3483
3515
  });
3484
3516
  }
@@ -3491,6 +3523,7 @@ export function hubFetch(
3491
3523
  return handleSetVaultCap(req, name, {
3492
3524
  db: getDb(),
3493
3525
  issuer: oauthDeps(req).issuer,
3526
+ knownIssuers: oauthDeps(req).hubBoundOrigins(),
3494
3527
  manifestPath,
3495
3528
  });
3496
3529
  }
package/src/jwt-sign.ts CHANGED
@@ -483,11 +483,26 @@ export interface ValidatedAccessToken {
483
483
  * this hub advertises — the same check vault performs against its own
484
484
  * `PARACHUTE_HUB_ORIGIN`. Defense in depth: tokens forged or replayed from
485
485
  * a different issuer get rejected at validation as well as issuance.
486
+ *
487
+ * `expectedIssuer` accepts a single string OR a SET of allowed issuers
488
+ * (`readonly string[]`), handed straight to jose's `issuer` option (which
489
+ * accepts `string | string[]`): the `iss` claim must equal the string, or be
490
+ * a member of the set. The set form is for the hub's own self-issued
491
+ * credentials, whose `iss` may be ANY origin the hub legitimately answers on
492
+ * (loopback ∪ expose-state ∪ platform ∪ per-request issuer — see
493
+ * `buildHubBoundOrigins`), so an origin switch doesn't reject a credential
494
+ * minted under a still-valid prior origin. SECURITY: this is ONLY an additive
495
+ * membership relaxation on `iss`. jose verifies the JWS signature against the
496
+ * hub's own public key FIRST and UNCONDITIONALLY — only tokens this hub
497
+ * minted can verify — before the `iss` claim is ever compared to the set. The
498
+ * set must come only from `buildHubBoundOrigins` (the hub's own origins),
499
+ * never a raw request Host. An empty/omitted value skips the `iss` check
500
+ * (signature-only); a single string is byte-identical to the prior behavior.
486
501
  */
487
502
  export async function validateAccessToken(
488
503
  db: Database,
489
504
  token: string,
490
- expectedIssuer?: string,
505
+ expectedIssuer?: string | readonly string[],
491
506
  ): Promise<ValidatedAccessToken> {
492
507
  const header = decodeProtectedHeader(token);
493
508
  const kid = header.kid;
@@ -495,11 +510,15 @@ export async function validateAccessToken(
495
510
  const match = getAllPublicKeys(db).find((k) => k.kid === kid);
496
511
  if (!match) throw new Error(`validateAccessToken: unknown or expired kid ${kid}`);
497
512
  const pub = await importSPKI(match.publicKeyPem, SIGNING_ALGORITHM);
498
- const { payload } = await jwtVerify(
499
- token,
500
- pub,
501
- expectedIssuer ? { issuer: expectedIssuer } : undefined,
502
- );
513
+ // `undefined` no `iss` pin (signature-only, the internal-caller default).
514
+ // A string or a non-empty set is handed straight to jose, which checks
515
+ // membership AFTER the signature verify above. An empty array is passed
516
+ // through too and so fails closed (no `iss` can match) — same posture as
517
+ // `validateHostAdminToken`; callers offering an empty origin set get a
518
+ // rejection, not a silent widening.
519
+ const issuerOption =
520
+ expectedIssuer === undefined ? undefined : { issuer: expectedIssuer as string | string[] };
521
+ const { payload } = await jwtVerify(token, pub, issuerOption);
503
522
  // RFC 7009 revocation enforcement (#73). OAuth-issued tokens carry a
504
523
  // tokens row keyed by jti; if that row is marked revoked, the JWT is
505
524
  // dead even though its signature + expiry are still valid. Tokens that
@@ -2706,7 +2706,13 @@ export async function handleRegister(
2706
2706
  let sameHub = false;
2707
2707
  if (req.headers.get("authorization")) {
2708
2708
  try {
2709
- await requireScope(db, req, "hub:admin", deps.issuer);
2709
+ // Validate the operator bearer's `iss` against the SET of origins the
2710
+ // hub answers on (`deps.hubBoundOrigins` — loopback ∪ expose-state ∪
2711
+ // platform ∪ per-request issuer), not just the single per-request
2712
+ // `issuer`, so a host-admin credential minted under a still-valid prior
2713
+ // origin keeps auto-approving across an origin switch (hub#516 parity).
2714
+ // Falls back to `[deps.issuer]` when no set getter is wired (tests).
2715
+ await requireScope(db, req, "hub:admin", deps.hubBoundOrigins?.() ?? deps.issuer);
2710
2716
  status = "approved";
2711
2717
  sameHub = true;
2712
2718
  } catch (err) {