@openparachute/hub 0.7.6 → 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.
- package/package.json +6 -3
- package/src/__tests__/account-api.test.ts +511 -0
- package/src/__tests__/account-token.test.ts +292 -0
- package/src/__tests__/door-contract-parity.test.ts +46 -0
- package/src/__tests__/scope-explanations.test.ts +22 -0
- package/src/account-api.ts +632 -0
- package/src/account-token.ts +200 -0
- package/src/hub-server.ts +176 -0
- package/src/scope-explanations.ts +33 -0
|
@@ -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
|