@openparachute/hub 0.7.4-rc.20 → 0.7.4-rc.22
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__/admin-handlers.test.ts +28 -0
- package/src/__tests__/admin-host-admin-token.test.ts +58 -1
- package/src/__tests__/admin-lock.test.ts +26 -0
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/oauth-handlers.test.ts +69 -21
- package/src/__tests__/scope-explanations.test.ts +20 -9
- package/src/__tests__/sessions.test.ts +80 -0
- package/src/account-setup.ts +2 -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-handlers.ts +2 -0
- package/src/admin-host-admin-token.ts +24 -1
- package/src/admin-lock.ts +16 -0
- 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 +9 -1
- package/src/scope-explanations.ts +23 -9
- package/src/sessions.ts +43 -2
- package/src/setup-wizard.ts +2 -0
|
@@ -5,12 +5,14 @@ import { join } from "node:path";
|
|
|
5
5
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
6
6
|
import {
|
|
7
7
|
SESSION_COOKIE_NAME,
|
|
8
|
+
SESSION_MAX_LIFETIME_MS,
|
|
8
9
|
buildSessionClearCookie,
|
|
9
10
|
buildSessionCookie,
|
|
10
11
|
createSession,
|
|
11
12
|
deleteSession,
|
|
12
13
|
findSession,
|
|
13
14
|
parseSessionCookie,
|
|
15
|
+
touchSession,
|
|
14
16
|
} from "../sessions.ts";
|
|
15
17
|
import { createUser } from "../users.ts";
|
|
16
18
|
|
|
@@ -67,6 +69,84 @@ describe("createSession + findSession", () => {
|
|
|
67
69
|
});
|
|
68
70
|
});
|
|
69
71
|
|
|
72
|
+
describe("touchSession (sliding renewal)", () => {
|
|
73
|
+
const HOUR = 3600 * 1000;
|
|
74
|
+
const DAY = 24 * HOUR;
|
|
75
|
+
|
|
76
|
+
test("slides expires_at forward to now + TTL", async () => {
|
|
77
|
+
const { db, userId, cleanup } = await makeDb();
|
|
78
|
+
try {
|
|
79
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
80
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
81
|
+
// Original expiry: t0 + 24h.
|
|
82
|
+
expect(new Date(s.expiresAt).getTime()).toBe(t0.getTime() + DAY);
|
|
83
|
+
// Touch 1h later → expiry becomes (t0 + 1h) + 24h.
|
|
84
|
+
const t1 = new Date(t0.getTime() + HOUR);
|
|
85
|
+
touchSession(db, s.id, () => t1);
|
|
86
|
+
const found = findSession(db, s.id, () => t1);
|
|
87
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(t1.getTime() + DAY);
|
|
88
|
+
} finally {
|
|
89
|
+
cleanup();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a touched session outlives the ORIGINAL 24h expiry", async () => {
|
|
94
|
+
const { db, userId, cleanup } = await makeDb();
|
|
95
|
+
try {
|
|
96
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
97
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
98
|
+
// Activity at +12h slides expiry to +36h.
|
|
99
|
+
touchSession(db, s.id, () => new Date(t0.getTime() + 12 * HOUR));
|
|
100
|
+
// At +30h — PAST the original +24h — the session is still alive.
|
|
101
|
+
const at30h = new Date(t0.getTime() + 30 * HOUR);
|
|
102
|
+
expect(findSession(db, s.id, () => at30h)?.id).toBe(s.id);
|
|
103
|
+
} finally {
|
|
104
|
+
cleanup();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("an UNtouched session still expires at the original 24h", async () => {
|
|
109
|
+
const { db, userId, cleanup } = await makeDb();
|
|
110
|
+
try {
|
|
111
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
112
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
113
|
+
// No touch — at +25h it's gone (today's absolute-TTL behavior preserved
|
|
114
|
+
// for idle / closed tabs that stop re-minting).
|
|
115
|
+
const at25h = new Date(t0.getTime() + 25 * HOUR);
|
|
116
|
+
expect(findSession(db, s.id, () => at25h)).toBeNull();
|
|
117
|
+
} finally {
|
|
118
|
+
cleanup();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("caps at created_at + SESSION_MAX_LIFETIME_MS (sliding can't run forever)", async () => {
|
|
123
|
+
const { db, userId, cleanup } = await makeDb();
|
|
124
|
+
try {
|
|
125
|
+
const t0 = new Date("2026-01-01T00:00:00Z");
|
|
126
|
+
const s = createSession(db, { userId, now: () => t0 });
|
|
127
|
+
const ceiling = t0.getTime() + SESSION_MAX_LIFETIME_MS;
|
|
128
|
+
// A touch near the ceiling would slide to now + 24h, but the cap pins it.
|
|
129
|
+
const nearCeiling = new Date(ceiling - HOUR); // raw slide would be ceiling + 23h
|
|
130
|
+
touchSession(db, s.id, () => nearCeiling);
|
|
131
|
+
const found = findSession(db, s.id, () => nearCeiling);
|
|
132
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBe(ceiling);
|
|
133
|
+
// Past the ceiling the session is dead even though it was just "active".
|
|
134
|
+
expect(findSession(db, s.id, () => new Date(ceiling + 1000))).toBeNull();
|
|
135
|
+
} finally {
|
|
136
|
+
cleanup();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("no-op on an unknown session id (does not throw)", async () => {
|
|
141
|
+
const { db, cleanup } = await makeDb();
|
|
142
|
+
try {
|
|
143
|
+
expect(() => touchSession(db, "no-such-session")).not.toThrow();
|
|
144
|
+
} finally {
|
|
145
|
+
cleanup();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
70
150
|
describe("deleteSession", () => {
|
|
71
151
|
test("removes the session row", async () => {
|
|
72
152
|
const { db, userId, cleanup } = await makeDb();
|
package/src/account-setup.ts
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
* scope-guard.
|
|
63
63
|
*/
|
|
64
64
|
import type { Database } from "bun:sqlite";
|
|
65
|
+
import { recordLoginUnlock } from "./admin-lock.ts";
|
|
65
66
|
import { renderAdminError, renderInviteSetup } from "./admin-login-ui.ts";
|
|
66
67
|
import { type RunResult, provisionVault } from "./admin-vaults.ts";
|
|
67
68
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
@@ -528,6 +529,7 @@ export async function handleAccountSetupPost(
|
|
|
528
529
|
|
|
529
530
|
// (6) Sign the invitee in + land them on /account/.
|
|
530
531
|
const session = createSession(deps.db, { userId });
|
|
532
|
+
recordLoginUnlock(deps.db, session.id);
|
|
531
533
|
const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
532
534
|
secure: isHttpsRequest(req),
|
|
533
535
|
});
|
|
@@ -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-handlers.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* (`parachute_hub_csrf` cookie + `__csrf` form field, constant-time compare).
|
|
12
12
|
*/
|
|
13
13
|
import type { Database } from "bun:sqlite";
|
|
14
|
+
import { recordLoginUnlock } from "./admin-lock.ts";
|
|
14
15
|
import { renderAdminError, renderAdminLogin, renderTotpChallenge } from "./admin-login-ui.ts";
|
|
15
16
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
16
17
|
import {
|
|
@@ -283,6 +284,7 @@ function mintSessionAndRedirect(
|
|
|
283
284
|
extraCookies: string[] = [],
|
|
284
285
|
): Response {
|
|
285
286
|
const session = createSession(db, { userId: user.id });
|
|
287
|
+
recordLoginUnlock(db, session.id);
|
|
286
288
|
const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
287
289
|
secure: isHttpsRequest(req),
|
|
288
290
|
});
|
|
@@ -49,7 +49,14 @@
|
|
|
49
49
|
import type { Database } from "bun:sqlite";
|
|
50
50
|
import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
|
|
51
51
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
52
|
-
import {
|
|
52
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
53
|
+
import {
|
|
54
|
+
SESSION_TTL_MS,
|
|
55
|
+
buildSessionCookie,
|
|
56
|
+
findSession,
|
|
57
|
+
parseSessionCookie,
|
|
58
|
+
touchSession,
|
|
59
|
+
} from "./sessions.ts";
|
|
53
60
|
import { isFirstAdmin } from "./users.ts";
|
|
54
61
|
|
|
55
62
|
/** Short TTL — page-snapshot threats can't carry the token forever. */
|
|
@@ -115,6 +122,21 @@ export async function handleHostAdminToken(
|
|
|
115
122
|
// sentinel matching admin OAuth tokens.
|
|
116
123
|
vaultScope: [],
|
|
117
124
|
});
|
|
125
|
+
// Sliding session renewal (THE frequent-re-login fix). The SPA re-mints here
|
|
126
|
+
// roughly every ~10 min while a tab is open; each successful mint pushes the
|
|
127
|
+
// session's `expires_at` forward, so an active operator isn't hard-logged-out
|
|
128
|
+
// at the 24h mark. A closed tab stops minting and still expires; the absolute
|
|
129
|
+
// ceiling in `touchSession` bounds a left-open-but-idle tab. The renewed
|
|
130
|
+
// Set-Cookie keeps the EXACT attributes session creation uses — HttpOnly,
|
|
131
|
+
// Secure-when-https, SameSite=Lax, Path=/, host-only (no Domain) — so the
|
|
132
|
+
// cookie's Max-Age tracks the extended expiry without broadening the cookie.
|
|
133
|
+
// This does NOT touch the admin-lock idle window (sliding there is
|
|
134
|
+
// heartbeat-only, by design — see admin-lock.ts); the two windows are
|
|
135
|
+
// independent.
|
|
136
|
+
touchSession(deps.db, sid);
|
|
137
|
+
const sessionCookie = buildSessionCookie(sid, Math.floor(SESSION_TTL_MS / 1000), {
|
|
138
|
+
secure: isHttpsRequest(req),
|
|
139
|
+
});
|
|
118
140
|
return new Response(
|
|
119
141
|
JSON.stringify({
|
|
120
142
|
token: minted.token,
|
|
@@ -128,6 +150,7 @@ export async function handleHostAdminToken(
|
|
|
128
150
|
// No browser cache — token rotates per-fetch, and a stale 200 from a
|
|
129
151
|
// back/forward navigation could hand the SPA a long-expired JWT.
|
|
130
152
|
"cache-control": "no-store",
|
|
153
|
+
"set-cookie": sessionCookie,
|
|
131
154
|
},
|
|
132
155
|
},
|
|
133
156
|
);
|
package/src/admin-lock.ts
CHANGED
|
@@ -174,6 +174,22 @@ export function recordUnlock(
|
|
|
174
174
|
unlockedUntil.set(sessionId, now + idleSeconds * 1000);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Open an unlock window for a session that JUST completed a full auth
|
|
179
|
+
* (password + any 2FA). The PIN's threat model is the idle/grabbed tab, NOT a
|
|
180
|
+
* third gate the instant after the auth boundary — re-prompting for the PIN
|
|
181
|
+
* the moment after a successful login is pure friction with no security gain
|
|
182
|
+
* (the user just proved a stronger factor). No-op when the lock feature is off.
|
|
183
|
+
*
|
|
184
|
+
* Called from every session-mint point (password login, OAuth login,
|
|
185
|
+
* account-setup, setup-wizard) so a freshly-authenticated session always lands
|
|
186
|
+
* working, never on the lock screen. Idle re-entry still re-locks as before.
|
|
187
|
+
*/
|
|
188
|
+
export function recordLoginUnlock(db: Database, sessionId: string, now: number = Date.now()): void {
|
|
189
|
+
if (!isLockConfigured(db)) return;
|
|
190
|
+
recordUnlock(sessionId, getIdleSeconds(db), now);
|
|
191
|
+
}
|
|
192
|
+
|
|
177
193
|
/**
|
|
178
194
|
* Slide the unlock window forward by `idleSeconds` IF the session is currently
|
|
179
195
|
* unlocked. Called on admin activity (heartbeat + every successful mint) so an
|
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
|
}
|