@openparachute/hub 0.7.6-rc.4 → 0.7.7-rc.3

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.
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Tests for `POST /account/token` — the cookie→account-bearer mint (Parachute
3
+ * App campaign, Phase 2 H1). Covers:
4
+ * - 405 on a non-POST method.
5
+ * - 401 when no admin session cookie is present (unauthenticated → refused).
6
+ * - 401 when the cookie names a deleted session.
7
+ * - 403 csrf_failed when the double-submit CSRF token is missing / mismatched.
8
+ * - 403 not_admin when a signed-in non-first-admin (friend) hits the endpoint.
9
+ * - 200 mint carrying the superset {account:self:admin, parachute:host:admin,
10
+ * parachute:host:auth}, aud="account", validating against the hub's keys.
11
+ * - scope-guard admin ⊇ read: account:self:read is inherited by the minted
12
+ * account:self:admin (the RS-side check the `/account/*` validator runs).
13
+ * - the superset actually unlocks a host-admin-gated endpoint (requireScope
14
+ * accepts parachute:host:admin even though aud="account" — SCOPE-b).
15
+ * - sliding session renewal on the success path.
16
+ */
17
+ import type { Database } from "bun:sqlite";
18
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
19
+ import { mkdtempSync, rmSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ // The `/account/*` validator (H2 / cloud) hand-rolls its scope check via
23
+ // scope-guard's hasScope — hub never imports scope-guard at runtime (the
24
+ // issuer/validator boundary), but the test asserts the RS-side inheritance
25
+ // the account grammar must satisfy. Same relative import as account-setup.test.
26
+ import { hasScope } from "../../packages/scope-guard/src/scope.ts";
27
+ import {
28
+ ACCOUNT_TOKEN_AUDIENCE,
29
+ ACCOUNT_TOKEN_TTL_SECONDS,
30
+ handleAccountToken,
31
+ } from "../account-token.ts";
32
+ import { requireScope } from "../admin-auth.ts";
33
+ import { CSRF_FIELD_NAME, buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
34
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
35
+ import { validateAccessToken } from "../jwt-sign.ts";
36
+ import { ACCOUNT_SELF_ADMIN_SCOPE, ACCOUNT_SELF_READ_SCOPE } from "../scope-explanations.ts";
37
+ import {
38
+ SESSION_TTL_MS,
39
+ buildSessionCookie,
40
+ createSession,
41
+ deleteSession,
42
+ findSession,
43
+ } from "../sessions.ts";
44
+ import { rotateSigningKey } from "../signing-keys.ts";
45
+ import { createUser } from "../users.ts";
46
+
47
+ const ISSUER = "https://hub.test";
48
+
49
+ interface Harness {
50
+ db: Database;
51
+ cleanup: () => void;
52
+ }
53
+
54
+ function makeHarness(): Harness {
55
+ const dir = mkdtempSync(join(tmpdir(), "phub-account-token-"));
56
+ const db = openHubDb(hubDbPath(dir));
57
+ return {
58
+ db,
59
+ cleanup: () => {
60
+ db.close();
61
+ rmSync(dir, { recursive: true, force: true });
62
+ },
63
+ };
64
+ }
65
+
66
+ let harness: Harness;
67
+ beforeEach(() => {
68
+ harness = makeHarness();
69
+ });
70
+ afterEach(() => {
71
+ harness.cleanup();
72
+ });
73
+
74
+ /**
75
+ * Build a first-admin session + a matching CSRF token pair. Returns the cookie
76
+ * header (session + csrf) and the CSRF token to place in the JSON body — the
77
+ * double-submit both halves must agree on.
78
+ */
79
+ async function withAdminSession(): Promise<{
80
+ cookie: string;
81
+ csrf: string;
82
+ userId: string;
83
+ }> {
84
+ const user = await createUser(harness.db, "operator", "operator-passphrase");
85
+ const session = createSession(harness.db, { userId: user.id });
86
+ const csrf = generateCsrfToken();
87
+ const cookie = [
88
+ buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)).split(";")[0],
89
+ buildCsrfCookie(csrf).split(";")[0],
90
+ ].join("; ");
91
+ return { cookie, csrf, userId: user.id };
92
+ }
93
+
94
+ /** Seed an admin + a second non-admin friend; return the friend's cookie/csrf. */
95
+ async function withFriendSession(): Promise<{ cookie: string; csrf: string }> {
96
+ await createUser(harness.db, "admin", "admin-passphrase");
97
+ const friend = await createUser(harness.db, "alice", "alice-passphrase", { allowMulti: true });
98
+ const session = createSession(harness.db, { userId: friend.id });
99
+ const csrf = generateCsrfToken();
100
+ const cookie = [
101
+ buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)).split(";")[0],
102
+ buildCsrfCookie(csrf).split(";")[0],
103
+ ].join("; ");
104
+ return { cookie, csrf };
105
+ }
106
+
107
+ function postReq(cookie: string, body: Record<string, unknown>): Request {
108
+ return new Request(`${ISSUER}/account/token`, {
109
+ method: "POST",
110
+ headers: { cookie, "content-type": "application/json" },
111
+ body: JSON.stringify(body),
112
+ });
113
+ }
114
+
115
+ describe("handleAccountToken", () => {
116
+ test("405 on GET", async () => {
117
+ const { cookie } = await withAdminSession();
118
+ const req = new Request(`${ISSUER}/account/token`, { headers: { cookie } });
119
+ const res = await handleAccountToken(req, { db: harness.db, issuer: ISSUER });
120
+ expect(res.status).toBe(405);
121
+ });
122
+
123
+ test("401 when no session cookie is present (unauthenticated → refused)", async () => {
124
+ // No session AND no CSRF: the session gate fires first, so the refusal is a
125
+ // clean 401 rather than a CSRF 403.
126
+ const req = postReq("", { [CSRF_FIELD_NAME]: "anything" });
127
+ const res = await handleAccountToken(req, { db: harness.db, issuer: ISSUER });
128
+ expect(res.status).toBe(401);
129
+ const body = (await res.json()) as { error: string };
130
+ expect(body.error).toBe("unauthenticated");
131
+ });
132
+
133
+ test("401 when the cookie names a deleted session", async () => {
134
+ const { cookie, csrf } = await withAdminSession();
135
+ const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
136
+ deleteSession(harness.db, sid);
137
+ const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
138
+ db: harness.db,
139
+ issuer: ISSUER,
140
+ });
141
+ expect(res.status).toBe(401);
142
+ });
143
+
144
+ test("403 csrf_failed when the CSRF token is absent from the body", async () => {
145
+ const { cookie } = await withAdminSession();
146
+ rotateSigningKey(harness.db);
147
+ const res = await handleAccountToken(postReq(cookie, {}), { db: harness.db, issuer: ISSUER });
148
+ expect(res.status).toBe(403);
149
+ const body = (await res.json()) as { error: string };
150
+ expect(body.error).toBe("csrf_failed");
151
+ });
152
+
153
+ test("403 csrf_failed when the body token doesn't match the cookie token", async () => {
154
+ const { cookie } = await withAdminSession();
155
+ rotateSigningKey(harness.db);
156
+ // A well-formed but wrong token (never the cookie's value).
157
+ const res = await handleAccountToken(
158
+ postReq(cookie, { [CSRF_FIELD_NAME]: generateCsrfToken() }),
159
+ { db: harness.db, issuer: ISSUER },
160
+ );
161
+ expect(res.status).toBe(403);
162
+ expect(((await res.json()) as { error: string }).error).toBe("csrf_failed");
163
+ });
164
+
165
+ test("403 not_admin when a signed-in friend (non-first-admin) mints", async () => {
166
+ // Privesc closure: a valid friend session + valid CSRF must still be
167
+ // refused — the account superset carries host:admin + host:auth, which a
168
+ // non-admin must never hold. The gate keys off first-admin (account ≡ box).
169
+ const { cookie, csrf } = await withFriendSession();
170
+ rotateSigningKey(harness.db);
171
+ const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
172
+ db: harness.db,
173
+ issuer: ISSUER,
174
+ });
175
+ expect(res.status).toBe(403);
176
+ const body = (await res.json()) as { error: string; error_description: string };
177
+ expect(body.error).toBe("not_admin");
178
+ expect(body.error_description).toContain("/account/");
179
+ });
180
+
181
+ test("200 mints the account superset with aud=account, validating against the hub keys", async () => {
182
+ const { cookie, csrf, userId } = await withAdminSession();
183
+ rotateSigningKey(harness.db);
184
+ const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
185
+ db: harness.db,
186
+ issuer: ISSUER,
187
+ });
188
+ expect(res.status).toBe(200);
189
+ expect(res.headers.get("cache-control")).toBe("no-store");
190
+
191
+ const body = (await res.json()) as {
192
+ token: string;
193
+ expires_at: string;
194
+ scopes: string[];
195
+ aud: string;
196
+ };
197
+ // The superset (SCOPE-b): account scope + the host superset.
198
+ expect(body.scopes).toEqual([
199
+ "account:self:admin",
200
+ "parachute:host:admin",
201
+ "parachute:host:auth",
202
+ ]);
203
+ expect(body.aud).toBe(ACCOUNT_TOKEN_AUDIENCE);
204
+
205
+ // TTL is roughly the 10-min window.
206
+ const skew = new Date(body.expires_at).getTime() - Date.now();
207
+ expect(skew).toBeGreaterThan((ACCOUNT_TOKEN_TTL_SECONDS - 30) * 1000);
208
+ expect(skew).toBeLessThan((ACCOUNT_TOKEN_TTL_SECONDS + 30) * 1000);
209
+
210
+ // JWT verifies against the hub's own signing key + issuer, and carries the
211
+ // explicit account audience (SCOPE-a).
212
+ const validated = await validateAccessToken(harness.db, body.token, ISSUER);
213
+ expect(validated.payload.sub).toBe(userId);
214
+ expect(validated.payload.iss).toBe(ISSUER);
215
+ expect(validated.payload.aud).toBe(ACCOUNT_TOKEN_AUDIENCE);
216
+ const scopes = ((validated.payload as { scope?: string }).scope ?? "").split(/\s+/);
217
+ expect(scopes).toContain("account:self:admin");
218
+ expect(scopes).toContain("parachute:host:admin");
219
+ expect(scopes).toContain("parachute:host:auth");
220
+ });
221
+
222
+ test("scope-guard: account:self:read is inherited by the minted account:self:admin", async () => {
223
+ // The `/account/*` validator (H2 / cloud) hand-rolls admin ⊇ read via
224
+ // scope-guard. The minted token carries account:self:admin; a read-gated
225
+ // account endpoint must accept it, an admin-gated one too, and neither a
226
+ // different account id nor an unrelated resource must match.
227
+ const { cookie, csrf } = await withAdminSession();
228
+ rotateSigningKey(harness.db);
229
+ const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
230
+ db: harness.db,
231
+ issuer: ISSUER,
232
+ });
233
+ const { scopes } = (await res.json()) as { scopes: string[] };
234
+
235
+ expect(hasScope(scopes, ACCOUNT_SELF_ADMIN_SCOPE)).toBe(true);
236
+ expect(hasScope(scopes, ACCOUNT_SELF_READ_SCOPE)).toBe(true); // admin ⊇ read
237
+ // A different account id must NOT be satisfied (name-pinned inheritance).
238
+ expect(hasScope(scopes, "account:other:read")).toBe(false);
239
+ // account:self:admin must never satisfy a vault scope.
240
+ expect(hasScope(scopes, "vault:self:read")).toBe(false);
241
+ });
242
+
243
+ test("the minted superset unlocks a host-admin-gated endpoint (SCOPE-b, aud not pinned)", async () => {
244
+ // requireScope validates signature + iss + the scope claim but does NOT pin
245
+ // aud, so the aud="account" token is accepted on the host-scoped surfaces
246
+ // (POST /vaults, /api/auth/*) it wraps — no existing gate needs rewiring.
247
+ const { cookie, csrf } = await withAdminSession();
248
+ rotateSigningKey(harness.db);
249
+ const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
250
+ db: harness.db,
251
+ issuer: ISSUER,
252
+ });
253
+ const { token } = (await res.json()) as { token: string };
254
+
255
+ const bearerReq = new Request(`${ISSUER}/vaults`, {
256
+ method: "POST",
257
+ headers: { authorization: `Bearer ${token}` },
258
+ });
259
+ const ctx = await requireScope(harness.db, bearerReq, "parachute:host:admin", ISSUER);
260
+ expect(ctx.scopes).toContain("account:self:admin");
261
+ expect(ctx.audience).toBe(ACCOUNT_TOKEN_AUDIENCE);
262
+ });
263
+
264
+ test("200 slides the session expiry forward and re-issues the cookie", async () => {
265
+ const user = await createUser(harness.db, "operator", "operator-passphrase");
266
+ const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
267
+ const session = createSession(harness.db, { userId: user.id, now: () => twelveHoursAgo });
268
+ const originalExpiry = new Date(session.expiresAt).getTime();
269
+ const csrf = generateCsrfToken();
270
+ const cookie = [
271
+ buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)).split(";")[0],
272
+ buildCsrfCookie(csrf).split(";")[0],
273
+ ].join("; ");
274
+ rotateSigningKey(harness.db);
275
+
276
+ const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
277
+ db: harness.db,
278
+ issuer: ISSUER,
279
+ });
280
+ expect(res.status).toBe(200);
281
+
282
+ const setCookie = res.headers.get("set-cookie") ?? "";
283
+ expect(setCookie).toContain("parachute_hub_session=");
284
+ expect(setCookie).toContain("HttpOnly");
285
+ expect(setCookie).toContain("Secure"); // ISSUER is https
286
+ expect(setCookie).toContain("SameSite=Lax");
287
+ expect(setCookie.toLowerCase()).not.toContain("domain=");
288
+
289
+ const found = findSession(harness.db, session.id);
290
+ expect(new Date(found?.expiresAt ?? 0).getTime()).toBeGreaterThan(originalExpiry);
291
+ });
292
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ACCESS_TOKEN_TTL_SECONDS as CONTRACT_ACCESS_TTL,
4
+ ACCOUNT_SELF_ADMIN_SCOPE as CONTRACT_ACCOUNT_ADMIN,
5
+ ACCOUNT_SELF_READ_SCOPE as CONTRACT_ACCOUNT_READ,
6
+ REFRESH_GRACE_MS as CONTRACT_REFRESH_GRACE,
7
+ REFRESH_TOKEN_TTL_MS as CONTRACT_REFRESH_TTL,
8
+ hasAccountScope,
9
+ } from "@openparachute/door-contract";
10
+ import { ACCESS_TOKEN_TTL_SECONDS, REFRESH_GRACE_MS, REFRESH_TOKEN_TTL_MS } from "../jwt-sign.ts";
11
+ import { ACCOUNT_SELF_ADMIN_SCOPE, ACCOUNT_SELF_READ_SCOPE } from "../scope-explanations.ts";
12
+
13
+ /**
14
+ * Drift detector for the shared door contract (Cloud+Hub shared-core campaign,
15
+ * parachute-cloud#116, Phase A). The hub's issuer constants + account-scope
16
+ * strings are duplicated in `@openparachute/door-contract` (the hosted cloud
17
+ * door duplicates them too). These assertions bind the hub's LIVE runtime values
18
+ * to the shared canon — no runtime code path is changed by this test, but any
19
+ * future divergence between the hub's issuer and the shared contract fails here,
20
+ * forcing the change through the shared package (and thus the cloud twin).
21
+ *
22
+ * When the hub adopts the contract at runtime (Phase B — `jwt-sign.ts` imports
23
+ * these constants instead of re-declaring them), these become identity checks;
24
+ * until then they are the guardrail.
25
+ */
26
+ describe("door-contract parity — token constants", () => {
27
+ test("the hub's issuer TTL/grace equal the shared contract", () => {
28
+ expect(ACCESS_TOKEN_TTL_SECONDS).toBe(CONTRACT_ACCESS_TTL);
29
+ expect(REFRESH_TOKEN_TTL_MS).toBe(CONTRACT_REFRESH_TTL);
30
+ expect(REFRESH_GRACE_MS).toBe(CONTRACT_REFRESH_GRACE);
31
+ });
32
+ });
33
+
34
+ describe("door-contract parity — account scopes", () => {
35
+ test("the hub's account scope strings equal the shared contract", () => {
36
+ expect(ACCOUNT_SELF_ADMIN_SCOPE).toBe(CONTRACT_ACCOUNT_ADMIN);
37
+ expect(ACCOUNT_SELF_READ_SCOPE).toBe(CONTRACT_ACCOUNT_READ);
38
+ });
39
+
40
+ test("the shared checker agrees with the hub's admin⊇read intent", () => {
41
+ // The hub's `/account/*` gate treats `account:self:admin` as satisfying a
42
+ // read requirement (scope-explanations.ts). The shared checker must too.
43
+ expect(hasAccountScope([ACCOUNT_SELF_ADMIN_SCOPE], "self", "read")).toBe(true);
44
+ expect(hasAccountScope([ACCOUNT_SELF_READ_SCOPE], "self", "admin")).toBe(false);
45
+ });
46
+ });
@@ -22,6 +22,9 @@ describe("SCOPE_EXPLANATIONS", () => {
22
22
  "agent:send",
23
23
  "hub:admin",
24
24
  "parachute:host:admin",
25
+ // Account scopes (Parachute App campaign, Phase 2).
26
+ "account:self:admin",
27
+ "account:self:read",
25
28
  ];
26
29
  for (const s of expected) {
27
30
  expect(SCOPE_EXPLANATIONS[s]).toBeDefined();
@@ -156,6 +159,14 @@ describe("NON_REQUESTABLE_SCOPES (#96)", () => {
156
159
  expect(NON_REQUESTABLE_SCOPES.has("scribe:admin")).toBe(true);
157
160
  });
158
161
 
162
+ test("contains the account scopes (App campaign Phase 2 — cookie-minted only)", () => {
163
+ // account:self:{admin,read} are minted exclusively by POST /account/token
164
+ // (cookie→bearer), never via /oauth/authorize — a third-party app pointed at
165
+ // the hub AS must not be able to consent its way to account authority.
166
+ expect(NON_REQUESTABLE_SCOPES.has("account:self:admin")).toBe(true);
167
+ expect(NON_REQUESTABLE_SCOPES.has("account:self:read")).toBe(true);
168
+ });
169
+
159
170
  test("every non-requestable scope is a known first-party scope", () => {
160
171
  for (const s of NON_REQUESTABLE_SCOPES) {
161
172
  expect(FIRST_PARTY_SCOPES).toContain(s);
@@ -195,6 +206,17 @@ describe("isRequestableScope", () => {
195
206
  expect(isRequestableScope("vault:my-techne_2:admin")).toBe(true);
196
207
  });
197
208
 
209
+ test("account scopes are non-requestable (cookie-minted only, App campaign)", () => {
210
+ expect(isRequestableScope("account:self:admin")).toBe(false);
211
+ expect(isRequestableScope("account:self:read")).toBe(false);
212
+ // explainScope resolves them (direct SCOPE_EXPLANATIONS keys) with the
213
+ // right levels, so scopeIsAdmin recognizes the admin form.
214
+ expect(explainScope("account:self:admin")?.level).toBe("admin");
215
+ expect(explainScope("account:self:read")?.level).toBe("read");
216
+ expect(scopeIsAdmin("account:self:admin")).toBe(true);
217
+ expect(scopeIsAdmin("account:self:read")).toBe(false);
218
+ });
219
+
198
220
  test("host-level operator scopes stay non-requestable", () => {
199
221
  // The asymmetry the single-consent change preserved: per-vault admin is
200
222
  // now requestable (capped at mint), but host-wide operator authority is