@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 +1 -1
- package/src/__tests__/admin-auth.test.ts +128 -0
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +12 -3
- package/src/admin-grants.ts +11 -2
- package/src/admin-vaults.ts +25 -2
- package/src/api-hub-upgrade.ts +14 -1
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +14 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +14 -1
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/hub-server.ts +34 -1
- package/src/jwt-sign.ts +25 -6
- package/src/oauth-handlers.ts +7 -1
package/package.json
CHANGED
|
@@ -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(
|
|
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`
|
|
63
|
-
* tokens we sign.
|
|
64
|
-
*
|
|
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
|
|
package/src/admin-clients.ts
CHANGED
|
@@ -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
|
}
|
package/src/admin-grants.ts
CHANGED
|
@@ -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
|
}
|
package/src/admin-vaults.ts
CHANGED
|
@@ -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(
|
|
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);
|
package/src/api-hub-upgrade.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/src/api-invites.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/src/api-mint-token.ts
CHANGED
|
@@ -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(
|
|
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");
|
package/src/api-modules.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|
package/src/api-revoke-token.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
}
|
package/src/api-vault-caps.ts
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -2706,7 +2706,13 @@ export async function handleRegister(
|
|
|
2706
2706
|
let sameHub = false;
|
|
2707
2707
|
if (req.headers.get("authorization")) {
|
|
2708
2708
|
try {
|
|
2709
|
-
|
|
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) {
|