@openparachute/hub 0.7.6 → 0.7.7-rc.12
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/README.md +7 -7
- package/package.json +8 -13
- package/src/__tests__/account-api.test.ts +798 -0
- package/src/__tests__/account-session.test.ts +252 -0
- package/src/__tests__/account-token.test.ts +316 -0
- package/src/__tests__/admin-connections-credentials.test.ts +41 -0
- package/src/__tests__/admin-handlers.test.ts +25 -0
- package/src/__tests__/admin-lock.test.ts +3 -14
- package/src/__tests__/admin-module-token.test.ts +10 -30
- package/src/__tests__/admin-surfaces.test.ts +21 -0
- package/src/__tests__/api-hub-upgrade.test.ts +11 -0
- package/src/__tests__/api-mint-token.test.ts +25 -0
- package/src/__tests__/api-modules-ops.test.ts +34 -29
- package/src/__tests__/api-modules.test.ts +50 -58
- package/src/__tests__/api-revoke-token.test.ts +23 -0
- package/src/__tests__/api-settings-hub-origin.test.ts +12 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +12 -0
- package/src/__tests__/api-tokens.test.ts +44 -0
- package/src/__tests__/audience-gate.test.ts +24 -0
- package/src/__tests__/bearer-scheme-casing.test.ts +110 -0
- package/src/__tests__/chrome-strip.test.ts +18 -1
- package/src/__tests__/doctor.test.ts +10 -17
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/hub-server.test.ts +37 -0
- package/src/__tests__/hub.test.ts +29 -0
- package/src/__tests__/install.test.ts +279 -5
- package/src/__tests__/migrate.test.ts +3 -1
- package/src/__tests__/notes-serve.test.ts +216 -0
- package/src/__tests__/oauth-handlers.test.ts +19 -8
- package/src/__tests__/operator-token.test.ts +1 -2
- package/src/__tests__/port-assign.test.ts +37 -17
- package/src/__tests__/scope-explanations.test.ts +22 -2
- package/src/__tests__/serve-boot.test.ts +25 -36
- package/src/__tests__/service-spec-discovery.test.ts +30 -35
- package/src/__tests__/services-manifest.test.ts +372 -132
- package/src/__tests__/sessions.test.ts +75 -28
- package/src/__tests__/setup-wizard.test.ts +7 -10
- package/src/__tests__/setup.test.ts +13 -14
- package/src/__tests__/status.test.ts +0 -5
- package/src/__tests__/surface-notes-alias.test.ts +296 -0
- package/src/account-api.ts +677 -0
- package/src/account-session.ts +132 -0
- package/src/account-token.ts +200 -0
- package/src/admin-connections.ts +4 -2
- package/src/admin-lock.ts +1 -2
- package/src/admin-module-token.ts +13 -8
- package/src/admin-surfaces.ts +2 -1
- package/src/api-hub-upgrade.ts +2 -1
- package/src/api-invites.ts +19 -0
- package/src/api-mint-token.ts +2 -1
- package/src/api-modules-ops.ts +2 -1
- package/src/api-modules.ts +10 -8
- package/src/api-revoke-token.ts +2 -1
- package/src/api-settings-hub-origin.ts +2 -1
- package/src/api-settings-root-redirect.ts +2 -1
- package/src/api-tokens.ts +2 -1
- package/src/audience-gate.ts +2 -1
- package/src/chrome-strip.ts +16 -4
- package/src/commands/install.ts +86 -8
- package/src/commands/migrate.ts +5 -1
- package/src/commands/setup.ts +9 -6
- package/src/help.ts +6 -6
- package/src/hub-server.ts +247 -52
- package/src/hub-settings.ts +25 -1
- package/src/hub.ts +64 -31
- package/src/invites.ts +42 -0
- package/src/module-ops-client.ts +2 -1
- package/src/notes-serve.ts +73 -31
- package/src/oauth-handlers.ts +1 -11
- package/src/operator-token.ts +0 -1
- package/src/origin-check.ts +2 -2
- package/src/scope-explanations.ts +35 -5
- package/src/service-spec.ts +128 -74
- package/src/services-manifest.ts +112 -52
- package/src/sessions.ts +66 -30
- package/src/surface-notes-alias.ts +126 -0
- package/src/__tests__/admin-agent-token.test.ts +0 -173
- package/src/admin-agent-token.ts +0 -147
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { ACCOUNT_ERROR_CODES, checkAccountSessionResponse } from "@openparachute/door-contract";
|
|
7
|
+
import { handleAccountSession } from "../account-session.ts";
|
|
8
|
+
import { CSRF_COOKIE_NAME, buildCsrfCookie, generateCsrfToken, parseCsrfCookie } from "../csrf.ts";
|
|
9
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
10
|
+
import {
|
|
11
|
+
SESSION_COOKIE_NAME,
|
|
12
|
+
SESSION_SLIDE_THRESHOLD_MS,
|
|
13
|
+
SESSION_TTL_MS,
|
|
14
|
+
buildSessionCookie,
|
|
15
|
+
createSession,
|
|
16
|
+
findSession,
|
|
17
|
+
} from "../sessions.ts";
|
|
18
|
+
import { createUser } from "../users.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Tests for `GET /account/session` — the same-origin boot oracle (hub-parity
|
|
22
|
+
* P1, the twin of cloud's `account-session.ts`). Covers:
|
|
23
|
+
* - both branches' body shape (signed-out / signed-in).
|
|
24
|
+
* - CSRF minted when absent, reused (no re-Set-Cookie) when present — on
|
|
25
|
+
* BOTH branches (the G2 anonymous-CSRF invariant).
|
|
26
|
+
* - a deleted-user session row falls back to signed-out.
|
|
27
|
+
* - the bounded slide: an old-but-live session rolls + re-issues the
|
|
28
|
+
* cookie; a fresh session does neither.
|
|
29
|
+
* - 405 on non-GET, `cache-control: no-store`, no CORS headers.
|
|
30
|
+
* - drift: `checkAccountSessionResponse` reports zero issues against the
|
|
31
|
+
* live handler on both branches.
|
|
32
|
+
*/
|
|
33
|
+
const ISSUER = "https://hub.test";
|
|
34
|
+
|
|
35
|
+
interface Harness {
|
|
36
|
+
db: Database;
|
|
37
|
+
cleanup: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeHarness(): Harness {
|
|
41
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-account-session-"));
|
|
42
|
+
const db = openHubDb(hubDbPath(dir));
|
|
43
|
+
return {
|
|
44
|
+
db,
|
|
45
|
+
cleanup: () => {
|
|
46
|
+
db.close();
|
|
47
|
+
rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let harness: Harness;
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
harness = makeHarness();
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
harness.cleanup();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function getReq(cookie?: string, method = "GET"): Request {
|
|
61
|
+
return new Request(`${ISSUER}/account/session`, {
|
|
62
|
+
method,
|
|
63
|
+
...(cookie ? { headers: { cookie } } : {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sessionCookiePair(sid: string): string {
|
|
68
|
+
return buildSessionCookie(sid, Math.floor(SESSION_TTL_MS / 1000));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("handleAccountSession — signed-out branch", () => {
|
|
72
|
+
test("no cookie at all → {signed_in:false, csrf} + CSRF Set-Cookie minted", async () => {
|
|
73
|
+
const res = handleAccountSession(getReq(), { db: harness.db });
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
const body = (await res.json()) as { signed_in: boolean; csrf: string };
|
|
76
|
+
expect(body.signed_in).toBe(false);
|
|
77
|
+
expect(typeof body.csrf).toBe("string");
|
|
78
|
+
expect(body.csrf.length).toBeGreaterThan(0);
|
|
79
|
+
|
|
80
|
+
const setCookie = res.headers.getSetCookie();
|
|
81
|
+
const csrfSet = setCookie.find((c) => c.includes(CSRF_COOKIE_NAME));
|
|
82
|
+
expect(csrfSet).toBeDefined();
|
|
83
|
+
expect(parseCsrfCookie(csrfSet ?? null)).toBe(body.csrf);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("reuses an existing CSRF cookie without re-minting (no Set-Cookie)", async () => {
|
|
87
|
+
const existing = generateCsrfToken();
|
|
88
|
+
const cookie = buildCsrfCookie(existing).split(";")[0] ?? "";
|
|
89
|
+
const res = handleAccountSession(getReq(cookie), { db: harness.db });
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
const body = (await res.json()) as { signed_in: boolean; csrf: string };
|
|
92
|
+
expect(body.signed_in).toBe(false);
|
|
93
|
+
expect(body.csrf).toBe(existing);
|
|
94
|
+
expect(res.headers.get("set-cookie")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a session cookie naming a deleted user → signed-out", async () => {
|
|
98
|
+
// The on-disk FK normally guards against a session outliving its user, so
|
|
99
|
+
// forge the orphaned row directly with `PRAGMA foreign_keys=OFF` (the
|
|
100
|
+
// `api-account.test.ts` precedent) — simulating a delete-user-via-SQL-shell
|
|
101
|
+
// race, not something reachable through the app's own APIs.
|
|
102
|
+
const sessionId = "test-orphaned-session-id-base64url";
|
|
103
|
+
harness.db.exec("PRAGMA foreign_keys = OFF");
|
|
104
|
+
try {
|
|
105
|
+
harness.db
|
|
106
|
+
.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)")
|
|
107
|
+
.run(
|
|
108
|
+
sessionId,
|
|
109
|
+
"nonexistent-user-uuid",
|
|
110
|
+
new Date(Date.now() + SESSION_TTL_MS).toISOString(),
|
|
111
|
+
new Date().toISOString(),
|
|
112
|
+
);
|
|
113
|
+
} finally {
|
|
114
|
+
harness.db.exec("PRAGMA foreign_keys = ON");
|
|
115
|
+
}
|
|
116
|
+
const cookie = sessionCookiePair(sessionId);
|
|
117
|
+
const res = handleAccountSession(getReq(cookie), { db: harness.db });
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
const body = (await res.json()) as { signed_in: boolean };
|
|
120
|
+
expect(body.signed_in).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("drift: checkAccountSessionResponse reports zero issues (signed-out)", async () => {
|
|
124
|
+
const res = handleAccountSession(getReq(), { db: harness.db });
|
|
125
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
126
|
+
expect(checkAccountSessionResponse(body, { signedIn: false })).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("handleAccountSession — signed-in branch", () => {
|
|
131
|
+
test("username always present; no email when the user row has none", async () => {
|
|
132
|
+
const user = await createUser(harness.db, "operator", "pw", { passwordChanged: true });
|
|
133
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
134
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
135
|
+
expect(res.status).toBe(200);
|
|
136
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
137
|
+
expect(body.signed_in).toBe(true);
|
|
138
|
+
expect(body.username).toBe("operator");
|
|
139
|
+
expect(body.email).toBeUndefined();
|
|
140
|
+
expect(body.account_created_at).toBe(user.createdAt);
|
|
141
|
+
expect(body.password_change_required).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("email present when the user row has one", async () => {
|
|
145
|
+
const user = await createUser(harness.db, "withmail", "pw", {
|
|
146
|
+
passwordChanged: true,
|
|
147
|
+
email: "friend@example.com",
|
|
148
|
+
});
|
|
149
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
150
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
151
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
152
|
+
expect(body.email).toBe("friend@example.com");
|
|
153
|
+
expect(body.username).toBe("withmail");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("password_change_required:true for a not-yet-rotated (passwordChanged:false) user", async () => {
|
|
157
|
+
// createUser defaults passwordChanged to false — the admin-created-user
|
|
158
|
+
// posture (force-redirect on first sign-in).
|
|
159
|
+
const user = await createUser(harness.db, "temp", "pw");
|
|
160
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
161
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
162
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
163
|
+
expect(body.signed_in).toBe(true);
|
|
164
|
+
expect(body.password_change_required).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("mints CSRF when absent even on the signed-in branch", async () => {
|
|
168
|
+
const user = await createUser(harness.db, "operator2", "pw", { passwordChanged: true });
|
|
169
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
170
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
171
|
+
const body = (await res.json()) as { csrf: string };
|
|
172
|
+
expect(typeof body.csrf).toBe("string");
|
|
173
|
+
expect(body.csrf.length).toBeGreaterThan(0);
|
|
174
|
+
const setCookie = res.headers.getSetCookie();
|
|
175
|
+
expect(setCookie.some((c) => c.includes(CSRF_COOKIE_NAME))).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("drift: checkAccountSessionResponse reports zero issues (signed-in)", async () => {
|
|
179
|
+
const user = await createUser(harness.db, "driftcheck", "pw", { passwordChanged: true });
|
|
180
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
181
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
182
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
183
|
+
expect(checkAccountSessionResponse(body, { signedIn: true })).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("handleAccountSession — bounded slide", () => {
|
|
188
|
+
test("an old-but-live session (within the slide threshold of expiry) rolls + re-issues the cookie", async () => {
|
|
189
|
+
const user = await createUser(harness.db, "slider", "pw", { passwordChanged: true });
|
|
190
|
+
const t0 = new Date();
|
|
191
|
+
// Created far enough in the past that remaining life < TTL - threshold.
|
|
192
|
+
const createdAt = new Date(
|
|
193
|
+
t0.getTime() - (SESSION_TTL_MS - SESSION_SLIDE_THRESHOLD_MS + 60_000),
|
|
194
|
+
);
|
|
195
|
+
const session = createSession(harness.db, { userId: user.id, now: () => createdAt });
|
|
196
|
+
const originalExpiry = new Date(session.expiresAt).getTime();
|
|
197
|
+
|
|
198
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
199
|
+
expect(res.status).toBe(200);
|
|
200
|
+
const setCookie = res.headers.getSetCookie();
|
|
201
|
+
const sessionSet = setCookie.find((c) => c.includes(SESSION_COOKIE_NAME));
|
|
202
|
+
expect(sessionSet).toBeDefined();
|
|
203
|
+
expect(sessionSet).toContain("HttpOnly");
|
|
204
|
+
expect(sessionSet).toContain("SameSite=Lax");
|
|
205
|
+
|
|
206
|
+
const found = findSession(harness.db, session.id);
|
|
207
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBeGreaterThan(originalExpiry);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("a fresh session does NOT slide — no session Set-Cookie, no DB write", async () => {
|
|
211
|
+
const user = await createUser(harness.db, "fresh", "pw", { passwordChanged: true });
|
|
212
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
213
|
+
const originalExpiry = session.expiresAt;
|
|
214
|
+
|
|
215
|
+
const res = handleAccountSession(getReq(sessionCookiePair(session.id)), { db: harness.db });
|
|
216
|
+
expect(res.status).toBe(200);
|
|
217
|
+
const setCookie = res.headers.getSetCookie();
|
|
218
|
+
expect(setCookie.some((c) => c.includes(SESSION_COOKIE_NAME))).toBe(false);
|
|
219
|
+
|
|
220
|
+
const found = findSession(harness.db, session.id);
|
|
221
|
+
expect(found?.expiresAt).toBe(originalExpiry);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("handleAccountSession — method + headers", () => {
|
|
226
|
+
test("405 on POST", async () => {
|
|
227
|
+
const res = handleAccountSession(getReq(undefined, "POST"), { db: harness.db });
|
|
228
|
+
expect(res.status).toBe(405);
|
|
229
|
+
expect(res.headers.get("allow")).toBe("GET");
|
|
230
|
+
// H1.3 — pin the CURRENT shape: resource-level failures on /account/*
|
|
231
|
+
// emit {error, message} (door-contract account-contract.ts:244-246). This
|
|
232
|
+
// is account-session.ts's own `methodNotAllowed`, distinct from
|
|
233
|
+
// account-token.ts's {error, error_description} 405 (see H1.1/H1.3 PR
|
|
234
|
+
// notes — a decisions-needed inconsistency, not fixed here).
|
|
235
|
+
const body = (await res.json()) as { error: string; message: string };
|
|
236
|
+
expect(body).toEqual({ error: "method_not_allowed", message: "use GET" });
|
|
237
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("cache-control: no-store on the 200 path", async () => {
|
|
241
|
+
const res = handleAccountSession(getReq(), { db: harness.db });
|
|
242
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("no access-control-allow-origin (same-origin only, no CORS)", async () => {
|
|
246
|
+
const req = new Request(`${ISSUER}/account/session`, {
|
|
247
|
+
headers: { origin: "https://evil.example" },
|
|
248
|
+
});
|
|
249
|
+
const res = handleAccountSession(req, { db: harness.db });
|
|
250
|
+
expect(res.headers.get("access-control-allow-origin")).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -0,0 +1,316 @@
|
|
|
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
|
+
import { ACCOUNT_ERROR_CODES, checkAccountTokenMintResponse } from "@openparachute/door-contract";
|
|
23
|
+
// The `/account/*` validator (H2 / cloud) hand-rolls its scope check via
|
|
24
|
+
// scope-guard's hasScope — hub never imports scope-guard at runtime (the
|
|
25
|
+
// issuer/validator boundary), but the test asserts the RS-side inheritance
|
|
26
|
+
// the account grammar must satisfy. Same relative import as account-setup.test.
|
|
27
|
+
import { hasScope } from "../../packages/scope-guard/src/scope.ts";
|
|
28
|
+
import {
|
|
29
|
+
ACCOUNT_TOKEN_AUDIENCE,
|
|
30
|
+
ACCOUNT_TOKEN_TTL_SECONDS,
|
|
31
|
+
handleAccountToken,
|
|
32
|
+
} from "../account-token.ts";
|
|
33
|
+
import { requireScope } from "../admin-auth.ts";
|
|
34
|
+
import { CSRF_FIELD_NAME, buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
|
|
35
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
36
|
+
import { validateAccessToken } from "../jwt-sign.ts";
|
|
37
|
+
import { ACCOUNT_SELF_ADMIN_SCOPE, ACCOUNT_SELF_READ_SCOPE } from "../scope-explanations.ts";
|
|
38
|
+
import {
|
|
39
|
+
SESSION_TTL_MS,
|
|
40
|
+
buildSessionCookie,
|
|
41
|
+
createSession,
|
|
42
|
+
deleteSession,
|
|
43
|
+
findSession,
|
|
44
|
+
} from "../sessions.ts";
|
|
45
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
46
|
+
import { createUser } from "../users.ts";
|
|
47
|
+
|
|
48
|
+
const ISSUER = "https://hub.test";
|
|
49
|
+
|
|
50
|
+
interface Harness {
|
|
51
|
+
db: Database;
|
|
52
|
+
cleanup: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeHarness(): Harness {
|
|
56
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-account-token-"));
|
|
57
|
+
const db = openHubDb(hubDbPath(dir));
|
|
58
|
+
return {
|
|
59
|
+
db,
|
|
60
|
+
cleanup: () => {
|
|
61
|
+
db.close();
|
|
62
|
+
rmSync(dir, { recursive: true, force: true });
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let harness: Harness;
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
harness = makeHarness();
|
|
70
|
+
});
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
harness.cleanup();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a first-admin session + a matching CSRF token pair. Returns the cookie
|
|
77
|
+
* header (session + csrf) and the CSRF token to place in the JSON body — the
|
|
78
|
+
* double-submit both halves must agree on.
|
|
79
|
+
*/
|
|
80
|
+
async function withAdminSession(): Promise<{
|
|
81
|
+
cookie: string;
|
|
82
|
+
csrf: string;
|
|
83
|
+
userId: string;
|
|
84
|
+
}> {
|
|
85
|
+
const user = await createUser(harness.db, "operator", "operator-passphrase");
|
|
86
|
+
const session = createSession(harness.db, { userId: user.id });
|
|
87
|
+
const csrf = generateCsrfToken();
|
|
88
|
+
const cookie = [
|
|
89
|
+
buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)).split(";")[0],
|
|
90
|
+
buildCsrfCookie(csrf).split(";")[0],
|
|
91
|
+
].join("; ");
|
|
92
|
+
return { cookie, csrf, userId: user.id };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Seed an admin + a second non-admin friend; return the friend's cookie/csrf. */
|
|
96
|
+
async function withFriendSession(): Promise<{ cookie: string; csrf: string }> {
|
|
97
|
+
await createUser(harness.db, "admin", "admin-passphrase");
|
|
98
|
+
const friend = await createUser(harness.db, "alice", "alice-passphrase", { allowMulti: true });
|
|
99
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
100
|
+
const csrf = generateCsrfToken();
|
|
101
|
+
const cookie = [
|
|
102
|
+
buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)).split(";")[0],
|
|
103
|
+
buildCsrfCookie(csrf).split(";")[0],
|
|
104
|
+
].join("; ");
|
|
105
|
+
return { cookie, csrf };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function postReq(cookie: string, body: Record<string, unknown>): Request {
|
|
109
|
+
return new Request(`${ISSUER}/account/token`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { cookie, "content-type": "application/json" },
|
|
112
|
+
body: JSON.stringify(body),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("handleAccountToken", () => {
|
|
117
|
+
test("405 on GET", async () => {
|
|
118
|
+
const { cookie } = await withAdminSession();
|
|
119
|
+
const req = new Request(`${ISSUER}/account/token`, { headers: { cookie } });
|
|
120
|
+
const res = await handleAccountToken(req, { db: harness.db, issuer: ISSUER });
|
|
121
|
+
expect(res.status).toBe(405);
|
|
122
|
+
// H1.3 — pin the CURRENT shape: account-token.ts emits {error,
|
|
123
|
+
// error_description} uniformly (its own local `jsonError`), including for
|
|
124
|
+
// this 405 — DIFFERENT from account-session.ts's 405, which emits {error,
|
|
125
|
+
// message} (see that file's test). A decisions-needed inconsistency
|
|
126
|
+
// across /account/*, not fixed here.
|
|
127
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
128
|
+
expect(body).toEqual({ error: "method_not_allowed", error_description: "use POST" });
|
|
129
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("401 when no session cookie is present (unauthenticated → refused)", async () => {
|
|
133
|
+
// No session AND no CSRF: the session gate fires first, so the refusal is a
|
|
134
|
+
// clean 401 rather than a CSRF 403.
|
|
135
|
+
const req = postReq("", { [CSRF_FIELD_NAME]: "anything" });
|
|
136
|
+
const res = await handleAccountToken(req, { db: harness.db, issuer: ISSUER });
|
|
137
|
+
expect(res.status).toBe(401);
|
|
138
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
139
|
+
expect(body.error).toBe("unauthenticated");
|
|
140
|
+
// H1.3 — pin the CURRENT shape: auth-gate failures on /account/* emit
|
|
141
|
+
// {error, error_description} (door-contract account-contract.ts:244-246).
|
|
142
|
+
expect(typeof body.error_description).toBe("string");
|
|
143
|
+
expect(body.error_description.length).toBeGreaterThan(0);
|
|
144
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("401 when the cookie names a deleted session", async () => {
|
|
148
|
+
const { cookie, csrf } = await withAdminSession();
|
|
149
|
+
const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
|
|
150
|
+
deleteSession(harness.db, sid);
|
|
151
|
+
const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
|
|
152
|
+
db: harness.db,
|
|
153
|
+
issuer: ISSUER,
|
|
154
|
+
});
|
|
155
|
+
expect(res.status).toBe(401);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("403 csrf_failed when the CSRF token is absent from the body", async () => {
|
|
159
|
+
const { cookie } = await withAdminSession();
|
|
160
|
+
rotateSigningKey(harness.db);
|
|
161
|
+
const res = await handleAccountToken(postReq(cookie, {}), { db: harness.db, issuer: ISSUER });
|
|
162
|
+
expect(res.status).toBe(403);
|
|
163
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
164
|
+
expect(body.error).toBe("csrf_failed");
|
|
165
|
+
// H1.3 — pin the {error, error_description} shape + ACCOUNT_ERROR_CODES membership.
|
|
166
|
+
expect(typeof body.error_description).toBe("string");
|
|
167
|
+
expect(ACCOUNT_ERROR_CODES as readonly string[]).toContain(body.error);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("403 csrf_failed when the body token doesn't match the cookie token", async () => {
|
|
171
|
+
const { cookie } = await withAdminSession();
|
|
172
|
+
rotateSigningKey(harness.db);
|
|
173
|
+
// A well-formed but wrong token (never the cookie's value).
|
|
174
|
+
const res = await handleAccountToken(
|
|
175
|
+
postReq(cookie, { [CSRF_FIELD_NAME]: generateCsrfToken() }),
|
|
176
|
+
{ db: harness.db, issuer: ISSUER },
|
|
177
|
+
);
|
|
178
|
+
expect(res.status).toBe(403);
|
|
179
|
+
expect(((await res.json()) as { error: string }).error).toBe("csrf_failed");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("403 not_admin when a signed-in friend (non-first-admin) mints", async () => {
|
|
183
|
+
// Privesc closure: a valid friend session + valid CSRF must still be
|
|
184
|
+
// refused — the account superset carries host:admin + host:auth, which a
|
|
185
|
+
// non-admin must never hold. The gate keys off first-admin (account ≡ box).
|
|
186
|
+
const { cookie, csrf } = await withFriendSession();
|
|
187
|
+
rotateSigningKey(harness.db);
|
|
188
|
+
const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
|
|
189
|
+
db: harness.db,
|
|
190
|
+
issuer: ISSUER,
|
|
191
|
+
});
|
|
192
|
+
expect(res.status).toBe(403);
|
|
193
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
194
|
+
expect(body.error).toBe("not_admin");
|
|
195
|
+
expect(body.error_description).toContain("/account/");
|
|
196
|
+
// H1.3 — DECISIONS-NEEDED (cataloged, not fixed here): "not_admin" is a
|
|
197
|
+
// real emitted /account/* error code but is NOT a member of door-contract's
|
|
198
|
+
// ACCOUNT_ERROR_CODES union — a gap in the shared vocabulary. See the PR
|
|
199
|
+
// body's decisions-needed section rather than asserting membership here.
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("200 mints the account superset with aud=account, validating against the hub keys", async () => {
|
|
203
|
+
const { cookie, csrf, userId } = await withAdminSession();
|
|
204
|
+
rotateSigningKey(harness.db);
|
|
205
|
+
const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
|
|
206
|
+
db: harness.db,
|
|
207
|
+
issuer: ISSUER,
|
|
208
|
+
});
|
|
209
|
+
expect(res.status).toBe(200);
|
|
210
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
211
|
+
|
|
212
|
+
const body = (await res.json()) as {
|
|
213
|
+
token: string;
|
|
214
|
+
expires_at: string;
|
|
215
|
+
scopes: string[];
|
|
216
|
+
aud: string;
|
|
217
|
+
};
|
|
218
|
+
// The superset (SCOPE-b): account scope + the host superset.
|
|
219
|
+
expect(body.scopes).toEqual([
|
|
220
|
+
"account:self:admin",
|
|
221
|
+
"parachute:host:admin",
|
|
222
|
+
"parachute:host:auth",
|
|
223
|
+
]);
|
|
224
|
+
expect(body.aud).toBe(ACCOUNT_TOKEN_AUDIENCE);
|
|
225
|
+
// H1.2 — door-contract conformance against the live `POST /account/token`
|
|
226
|
+
// success body (V1.4/C1.4 twin coverage, hub half).
|
|
227
|
+
expect(checkAccountTokenMintResponse(body)).toEqual([]);
|
|
228
|
+
|
|
229
|
+
// TTL is roughly the 10-min window.
|
|
230
|
+
const skew = new Date(body.expires_at).getTime() - Date.now();
|
|
231
|
+
expect(skew).toBeGreaterThan((ACCOUNT_TOKEN_TTL_SECONDS - 30) * 1000);
|
|
232
|
+
expect(skew).toBeLessThan((ACCOUNT_TOKEN_TTL_SECONDS + 30) * 1000);
|
|
233
|
+
|
|
234
|
+
// JWT verifies against the hub's own signing key + issuer, and carries the
|
|
235
|
+
// explicit account audience (SCOPE-a).
|
|
236
|
+
const validated = await validateAccessToken(harness.db, body.token, ISSUER);
|
|
237
|
+
expect(validated.payload.sub).toBe(userId);
|
|
238
|
+
expect(validated.payload.iss).toBe(ISSUER);
|
|
239
|
+
expect(validated.payload.aud).toBe(ACCOUNT_TOKEN_AUDIENCE);
|
|
240
|
+
const scopes = ((validated.payload as { scope?: string }).scope ?? "").split(/\s+/);
|
|
241
|
+
expect(scopes).toContain("account:self:admin");
|
|
242
|
+
expect(scopes).toContain("parachute:host:admin");
|
|
243
|
+
expect(scopes).toContain("parachute:host:auth");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("scope-guard: account:self:read is inherited by the minted account:self:admin", async () => {
|
|
247
|
+
// The `/account/*` validator (H2 / cloud) hand-rolls admin ⊇ read via
|
|
248
|
+
// scope-guard. The minted token carries account:self:admin; a read-gated
|
|
249
|
+
// account endpoint must accept it, an admin-gated one too, and neither a
|
|
250
|
+
// different account id nor an unrelated resource must match.
|
|
251
|
+
const { cookie, csrf } = await withAdminSession();
|
|
252
|
+
rotateSigningKey(harness.db);
|
|
253
|
+
const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
|
|
254
|
+
db: harness.db,
|
|
255
|
+
issuer: ISSUER,
|
|
256
|
+
});
|
|
257
|
+
const { scopes } = (await res.json()) as { scopes: string[] };
|
|
258
|
+
|
|
259
|
+
expect(hasScope(scopes, ACCOUNT_SELF_ADMIN_SCOPE)).toBe(true);
|
|
260
|
+
expect(hasScope(scopes, ACCOUNT_SELF_READ_SCOPE)).toBe(true); // admin ⊇ read
|
|
261
|
+
// A different account id must NOT be satisfied (name-pinned inheritance).
|
|
262
|
+
expect(hasScope(scopes, "account:other:read")).toBe(false);
|
|
263
|
+
// account:self:admin must never satisfy a vault scope.
|
|
264
|
+
expect(hasScope(scopes, "vault:self:read")).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("the minted superset unlocks a host-admin-gated endpoint (SCOPE-b, aud not pinned)", async () => {
|
|
268
|
+
// requireScope validates signature + iss + the scope claim but does NOT pin
|
|
269
|
+
// aud, so the aud="account" token is accepted on the host-scoped surfaces
|
|
270
|
+
// (POST /vaults, /api/auth/*) it wraps — no existing gate needs rewiring.
|
|
271
|
+
const { cookie, csrf } = await withAdminSession();
|
|
272
|
+
rotateSigningKey(harness.db);
|
|
273
|
+
const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
|
|
274
|
+
db: harness.db,
|
|
275
|
+
issuer: ISSUER,
|
|
276
|
+
});
|
|
277
|
+
const { token } = (await res.json()) as { token: string };
|
|
278
|
+
|
|
279
|
+
const bearerReq = new Request(`${ISSUER}/vaults`, {
|
|
280
|
+
method: "POST",
|
|
281
|
+
headers: { authorization: `Bearer ${token}` },
|
|
282
|
+
});
|
|
283
|
+
const ctx = await requireScope(harness.db, bearerReq, "parachute:host:admin", ISSUER);
|
|
284
|
+
expect(ctx.scopes).toContain("account:self:admin");
|
|
285
|
+
expect(ctx.audience).toBe(ACCOUNT_TOKEN_AUDIENCE);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("200 slides the session expiry forward and re-issues the cookie", async () => {
|
|
289
|
+
const user = await createUser(harness.db, "operator", "operator-passphrase");
|
|
290
|
+
const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
|
|
291
|
+
const session = createSession(harness.db, { userId: user.id, now: () => twelveHoursAgo });
|
|
292
|
+
const originalExpiry = new Date(session.expiresAt).getTime();
|
|
293
|
+
const csrf = generateCsrfToken();
|
|
294
|
+
const cookie = [
|
|
295
|
+
buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)).split(";")[0],
|
|
296
|
+
buildCsrfCookie(csrf).split(";")[0],
|
|
297
|
+
].join("; ");
|
|
298
|
+
rotateSigningKey(harness.db);
|
|
299
|
+
|
|
300
|
+
const res = await handleAccountToken(postReq(cookie, { [CSRF_FIELD_NAME]: csrf }), {
|
|
301
|
+
db: harness.db,
|
|
302
|
+
issuer: ISSUER,
|
|
303
|
+
});
|
|
304
|
+
expect(res.status).toBe(200);
|
|
305
|
+
|
|
306
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
307
|
+
expect(setCookie).toContain("parachute_hub_session=");
|
|
308
|
+
expect(setCookie).toContain("HttpOnly");
|
|
309
|
+
expect(setCookie).toContain("Secure"); // ISSUER is https
|
|
310
|
+
expect(setCookie).toContain("SameSite=Lax");
|
|
311
|
+
expect(setCookie.toLowerCase()).not.toContain("domain=");
|
|
312
|
+
|
|
313
|
+
const found = findSession(harness.db, session.id);
|
|
314
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBeGreaterThan(originalExpiry);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
@@ -556,6 +556,26 @@ describe("credential connection — renewal (proof of possession)", () => {
|
|
|
556
556
|
expect(rec.provisioned.mintedJtis).toEqual([out.credential.jti!]);
|
|
557
557
|
});
|
|
558
558
|
|
|
559
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
560
|
+
test("lowercase bearer scheme authenticates identically to canonical Bearer", async () => {
|
|
561
|
+
const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
|
|
562
|
+
const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
|
|
563
|
+
const cred = await provision(deps, calls);
|
|
564
|
+
|
|
565
|
+
const res = await handleConnections(
|
|
566
|
+
new Request(`http://127.0.0.1/admin/connections/${cred.connection_id}/renew`, {
|
|
567
|
+
method: "POST",
|
|
568
|
+
headers: { authorization: `bearer ${cred.token}` },
|
|
569
|
+
}),
|
|
570
|
+
`/${cred.connection_id}/renew`,
|
|
571
|
+
deps,
|
|
572
|
+
);
|
|
573
|
+
expect(res.status).toBe(200);
|
|
574
|
+
const out = (await res.json()) as { ok: boolean; credential: DeliveredCredential };
|
|
575
|
+
expect(out.ok).toBe(true);
|
|
576
|
+
expect(out.credential.op).toBe("renewed");
|
|
577
|
+
});
|
|
578
|
+
|
|
559
579
|
test("renewed credential can renew again (the chain extends)", async () => {
|
|
560
580
|
const { fetchImpl, calls } = mockFetch({ "POST /api/credential": () => ok({ ok: true }) });
|
|
561
581
|
const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
|
|
@@ -941,6 +961,27 @@ describe("credential connection — claim/reconcile (surface#113)", () => {
|
|
|
941
961
|
]);
|
|
942
962
|
});
|
|
943
963
|
|
|
964
|
+
// H1.1 — Bearer scheme is case-insensitive per RFC 7235 (V1.4/C1.3 parity).
|
|
965
|
+
test("claim: mixed-case bearer scheme (BeArEr) authenticates identically to canonical Bearer", async () => {
|
|
966
|
+
const { fetchImpl } = mockFetch({});
|
|
967
|
+
const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
|
|
968
|
+
const direct = await mintDirectDelivered({ vault: "default", verb: "read", tags: ["boulder"] });
|
|
969
|
+
|
|
970
|
+
const claim = await handleConnections(
|
|
971
|
+
new Request(`http://127.0.0.1/admin/connections/${CLAIM_ID}/claim`, {
|
|
972
|
+
method: "POST",
|
|
973
|
+
headers: { "content-type": "application/json", authorization: `BeArEr ${direct.token}` },
|
|
974
|
+
body: JSON.stringify(SURFACE_CLAIM),
|
|
975
|
+
}),
|
|
976
|
+
`/${CLAIM_ID}/claim`,
|
|
977
|
+
deps,
|
|
978
|
+
);
|
|
979
|
+
expect(claim.status).toBe(202);
|
|
980
|
+
const claimBody = (await claim.json()) as { ok: boolean; status: string };
|
|
981
|
+
expect(claimBody.ok).toBe(true);
|
|
982
|
+
expect(claimBody.status).toBe("pending");
|
|
983
|
+
});
|
|
984
|
+
|
|
944
985
|
test("the live docs shape: an UNTAGGED write credential is claimable verbatim (create's write-requires-tags guards NEW grants; the operator's approve sanctions the existing shape)", async () => {
|
|
945
986
|
const { fetchImpl } = mockFetch({});
|
|
946
987
|
const deps = credDeps(fetchImpl, modulesOf(SURFACE_MANIFEST));
|
|
@@ -603,4 +603,29 @@ describe("handleAdminLogoutPost (#113)", () => {
|
|
|
603
603
|
expect(res.headers.get("location")).toBe("/login");
|
|
604
604
|
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=;");
|
|
605
605
|
});
|
|
606
|
+
|
|
607
|
+
// App-wire pin (hub-parity P1, §8-M4): the app's `logout()` posts form-
|
|
608
|
+
// encoded, not JSON (`parachute-app/src/lib/account/client.ts:246-258`:
|
|
609
|
+
// `content-type: application/x-www-form-urlencoded`, body
|
|
610
|
+
// `new URLSearchParams({ __csrf: csrf }).toString()`, credentials included).
|
|
611
|
+
// No hub code changed for this — the existing formData()-based handler
|
|
612
|
+
// already accepts exactly this shape; this test freezes the byte-shape so a
|
|
613
|
+
// future refactor of the handler can't silently drop form-body support out
|
|
614
|
+
// from under the app.
|
|
615
|
+
test("app-wire pin — form-encoded body matching client.ts's exact logout() shape", async () => {
|
|
616
|
+
const cookie = await cookieForUser(harness.db, "appuser", "pw");
|
|
617
|
+
const sid = cookie.match(/parachute_hub_session=([^;]+)/)?.[1] ?? "";
|
|
618
|
+
const req = new Request("http://hub.test/logout", {
|
|
619
|
+
method: "POST",
|
|
620
|
+
headers: {
|
|
621
|
+
cookie,
|
|
622
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
623
|
+
},
|
|
624
|
+
body: new URLSearchParams({ [CSRF_FIELD_NAME]: TEST_CSRF }).toString(),
|
|
625
|
+
});
|
|
626
|
+
const res = await handleAdminLogoutPost(harness.db, req);
|
|
627
|
+
expect(res.status).toBe(302);
|
|
628
|
+
expect(res.headers.get("location")).toBe("/login");
|
|
629
|
+
expect(findSession(harness.db, sid)).toBeNull();
|
|
630
|
+
});
|
|
606
631
|
});
|