@openparachute/hub 0.5.10-rc.6 → 0.5.10
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-handlers.test.ts +141 -6
- package/src/__tests__/api-account.test.ts +463 -0
- package/src/__tests__/api-modules-ops.test.ts +139 -0
- package/src/__tests__/api-modules.test.ts +134 -0
- package/src/__tests__/api-users.test.ts +522 -0
- package/src/__tests__/cors.test.ts +587 -0
- package/src/__tests__/hub-db.test.ts +126 -1
- package/src/__tests__/hub-server.test.ts +29 -4
- package/src/__tests__/hub-settings.test.ts +377 -0
- package/src/__tests__/hub.test.ts +17 -0
- package/src/__tests__/jwt-sign.test.ts +59 -0
- package/src/__tests__/oauth-handlers.test.ts +1059 -10
- package/src/__tests__/oauth-ui.test.ts +210 -0
- package/src/__tests__/scope-explanations.test.ts +23 -0
- package/src/__tests__/serve.test.ts +8 -1
- package/src/__tests__/setup-wizard.test.ts +1500 -13
- package/src/__tests__/supervisor.test.ts +76 -2
- package/src/__tests__/users.test.ts +196 -0
- package/src/__tests__/vault-name.test.ts +79 -0
- package/src/__tests__/vault-names.test.ts +172 -0
- package/src/account-change-password-ui.ts +379 -0
- package/src/admin-handlers.ts +68 -2
- package/src/admin-host-admin-token.ts +5 -0
- package/src/admin-vault-admin-token.ts +7 -0
- package/src/api-account.ts +443 -0
- package/src/api-mint-token.ts +6 -0
- package/src/api-modules-ops.ts +30 -6
- package/src/api-modules.ts +101 -0
- package/src/api-users.ts +393 -0
- package/src/commands/auth.ts +10 -1
- package/src/commands/serve.ts +5 -1
- package/src/cors.ts +263 -0
- package/src/hub-db.ts +54 -0
- package/src/hub-server.ts +162 -18
- package/src/hub-settings.ts +259 -0
- package/src/hub.ts +34 -9
- package/src/jwt-sign.ts +17 -1
- package/src/oauth-handlers.ts +256 -29
- package/src/oauth-ui.ts +451 -38
- package/src/operator-token.ts +4 -0
- package/src/scope-explanations.ts +26 -1
- package/src/setup-wizard.ts +1100 -56
- package/src/supervisor.ts +66 -14
- package/src/users.ts +210 -3
- package/src/vault-name.ts +71 -0
- package/src/vault-names.ts +57 -0
- package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
- package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
|
@@ -6,6 +6,12 @@ import { join } from "node:path";
|
|
|
6
6
|
import { getClient, registerClient } from "../clients.ts";
|
|
7
7
|
import { CSRF_COOKIE_NAME } from "../csrf.ts";
|
|
8
8
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
9
|
+
import {
|
|
10
|
+
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
11
|
+
getSetting,
|
|
12
|
+
openFirstClientAutoApproveWindow,
|
|
13
|
+
setSetting,
|
|
14
|
+
} from "../hub-settings.ts";
|
|
9
15
|
import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
|
|
10
16
|
import {
|
|
11
17
|
authorizationServerMetadata,
|
|
@@ -227,6 +233,142 @@ describe("handleAuthorizeGet", () => {
|
|
|
227
233
|
);
|
|
228
234
|
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
229
235
|
expect(res.status).toBe(400);
|
|
236
|
+
const body = await res.text();
|
|
237
|
+
expect(body).toContain("Unknown application");
|
|
238
|
+
// Cross-origin redirect_uri → no recovery affordance. The page must
|
|
239
|
+
// not include the inline JS reset block; we can't safely interact
|
|
240
|
+
// with a third-party SPA's storage from this page.
|
|
241
|
+
expect(body).not.toContain("unknown-client-reset");
|
|
242
|
+
expect(body).not.toContain("lens:dcr:");
|
|
243
|
+
} finally {
|
|
244
|
+
cleanup();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("unknown client_id with self-origin redirect_uri renders recovery affordance (hub#fresh-machine-connect)", async () => {
|
|
249
|
+
// The canonical fresh-machine-stale-localStorage repro: notes' SPA
|
|
250
|
+
// is mounted at the hub's own origin, holds a cached client_id from
|
|
251
|
+
// a previous hub.db, and lands on /oauth/authorize with the dangling
|
|
252
|
+
// id. Hub recognizes the redirect_uri as one of its bound origins and
|
|
253
|
+
// surfaces a one-click recovery: the inline JS clears the SPA's DCR
|
|
254
|
+
// localStorage cache (any `lens:dcr:*` key) and navigates to the
|
|
255
|
+
// redirect_uri's pathname for a fresh DCR pass.
|
|
256
|
+
const { db, cleanup } = await makeDb();
|
|
257
|
+
try {
|
|
258
|
+
const { challenge } = makePkce();
|
|
259
|
+
const selfRedirect = `${ISSUER}/notes/oauth/callback`;
|
|
260
|
+
const req = new Request(
|
|
261
|
+
authorizeUrl({
|
|
262
|
+
client_id: "stale-dangling-id",
|
|
263
|
+
redirect_uri: selfRedirect,
|
|
264
|
+
response_type: "code",
|
|
265
|
+
code_challenge: challenge,
|
|
266
|
+
code_challenge_method: "S256",
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
const res = handleAuthorizeGet(db, req, {
|
|
270
|
+
issuer: ISSUER,
|
|
271
|
+
hubBoundOrigins: () => [ISSUER],
|
|
272
|
+
});
|
|
273
|
+
expect(res.status).toBe(400);
|
|
274
|
+
const body = await res.text();
|
|
275
|
+
expect(body).toContain("Unknown application");
|
|
276
|
+
expect(body).toContain("stale-dangling-id");
|
|
277
|
+
// Recovery affordance is present.
|
|
278
|
+
expect(body).toContain("unknown-client-reset");
|
|
279
|
+
// The reset target is the redirect_uri's pathname only (not the
|
|
280
|
+
// full URL — we never surface a cross-origin redirect even when
|
|
281
|
+
// redirect_uri claims to be ours).
|
|
282
|
+
expect(body).toContain('data-target="/notes/oauth/callback"');
|
|
283
|
+
// The inline JS clears the SPA's known DCR cache prefix.
|
|
284
|
+
expect(body).toContain("lens:dcr:");
|
|
285
|
+
} finally {
|
|
286
|
+
cleanup();
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("unknown client_id with redirect_uri on unbound origin falls back to static error", async () => {
|
|
291
|
+
// hubBoundOrigins lists only the canonical hub origin; a redirect_uri
|
|
292
|
+
// pointing somewhere else (third-party SPA, attacker probe) MUST NOT
|
|
293
|
+
// surface the recovery JS — that JS only makes sense for SPAs we
|
|
294
|
+
// ourselves host.
|
|
295
|
+
const { db, cleanup } = await makeDb();
|
|
296
|
+
try {
|
|
297
|
+
const { challenge } = makePkce();
|
|
298
|
+
const req = new Request(
|
|
299
|
+
authorizeUrl({
|
|
300
|
+
client_id: "stale-id",
|
|
301
|
+
redirect_uri: "https://attacker.example/cb",
|
|
302
|
+
response_type: "code",
|
|
303
|
+
code_challenge: challenge,
|
|
304
|
+
code_challenge_method: "S256",
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
const res = handleAuthorizeGet(db, req, {
|
|
308
|
+
issuer: ISSUER,
|
|
309
|
+
hubBoundOrigins: () => [ISSUER],
|
|
310
|
+
});
|
|
311
|
+
expect(res.status).toBe(400);
|
|
312
|
+
const body = await res.text();
|
|
313
|
+
expect(body).toContain("Unknown application");
|
|
314
|
+
expect(body).not.toContain("unknown-client-reset");
|
|
315
|
+
expect(body).not.toContain("lens:dcr:");
|
|
316
|
+
expect(body).not.toContain("attacker.example");
|
|
317
|
+
} finally {
|
|
318
|
+
cleanup();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("unknown client_id with malformed redirect_uri falls back to static error", async () => {
|
|
323
|
+
const { db, cleanup } = await makeDb();
|
|
324
|
+
try {
|
|
325
|
+
const { challenge } = makePkce();
|
|
326
|
+
const req = new Request(
|
|
327
|
+
authorizeUrl({
|
|
328
|
+
client_id: "stale-id",
|
|
329
|
+
// Validated as non-empty by parseAuthorizeFormParams but not
|
|
330
|
+
// URL-parsed there; the unknown-client renderer must handle
|
|
331
|
+
// its own parsing safely.
|
|
332
|
+
redirect_uri: "not-a-valid-url",
|
|
333
|
+
response_type: "code",
|
|
334
|
+
code_challenge: challenge,
|
|
335
|
+
code_challenge_method: "S256",
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
const res = handleAuthorizeGet(db, req, {
|
|
339
|
+
issuer: ISSUER,
|
|
340
|
+
hubBoundOrigins: () => [ISSUER],
|
|
341
|
+
});
|
|
342
|
+
expect(res.status).toBe(400);
|
|
343
|
+
const body = await res.text();
|
|
344
|
+
expect(body).toContain("Unknown application");
|
|
345
|
+
expect(body).not.toContain("unknown-client-reset");
|
|
346
|
+
} finally {
|
|
347
|
+
cleanup();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("unknown client_id falls back to static error when hubBoundOrigins is unset", async () => {
|
|
352
|
+
// Pre-#245 callers don't thread hubBoundOrigins; the gate falls back
|
|
353
|
+
// to `[issuer]` so a single-origin hub still surfaces the recovery
|
|
354
|
+
// affordance for its own redirect_uris. Verify that path.
|
|
355
|
+
const { db, cleanup } = await makeDb();
|
|
356
|
+
try {
|
|
357
|
+
const { challenge } = makePkce();
|
|
358
|
+
const req = new Request(
|
|
359
|
+
authorizeUrl({
|
|
360
|
+
client_id: "stale-id",
|
|
361
|
+
redirect_uri: `${ISSUER}/notes/oauth/callback`,
|
|
362
|
+
response_type: "code",
|
|
363
|
+
code_challenge: challenge,
|
|
364
|
+
code_challenge_method: "S256",
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
// No hubBoundOrigins → falls back to [issuer], which still matches.
|
|
368
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
369
|
+
expect(res.status).toBe(400);
|
|
370
|
+
const body = await res.text();
|
|
371
|
+
expect(body).toContain("unknown-client-reset");
|
|
230
372
|
} finally {
|
|
231
373
|
cleanup();
|
|
232
374
|
}
|
|
@@ -880,6 +1022,58 @@ describe("handleAuthorizePost — consent submit", () => {
|
|
|
880
1022
|
}
|
|
881
1023
|
});
|
|
882
1024
|
|
|
1025
|
+
test("race-condition branch (client un-approved between GET and POST) — error points at web approval path, no CLI mention", async () => {
|
|
1026
|
+
// Defensive branch in handleConsentSubmit: consent only renders for
|
|
1027
|
+
// approved clients, but a row can flip back to pending between GET and
|
|
1028
|
+
// POST (operator revoke / hand-crafted POST). Pre-rc.19 follow-up the
|
|
1029
|
+
// error said "Run `parachute auth approve-client <id>` from a terminal";
|
|
1030
|
+
// rc.19 retired every browser-visible CLI mention, so this branch now
|
|
1031
|
+
// surfaces the same /admin/approve-client/<id> path the unauth GET-on-
|
|
1032
|
+
// pending page advertises.
|
|
1033
|
+
const { db, cleanup } = await makeDb();
|
|
1034
|
+
try {
|
|
1035
|
+
const user = await createUser(db, "owner", "pw");
|
|
1036
|
+
const session = createSession(db, { userId: user.id });
|
|
1037
|
+
const reg = registerClient(db, {
|
|
1038
|
+
redirectUris: ["https://app.example/cb"],
|
|
1039
|
+
status: "pending",
|
|
1040
|
+
});
|
|
1041
|
+
const { challenge } = makePkce();
|
|
1042
|
+
const form = new URLSearchParams({
|
|
1043
|
+
__action: "consent",
|
|
1044
|
+
__csrf: TEST_CSRF,
|
|
1045
|
+
approve: "yes",
|
|
1046
|
+
client_id: reg.client.clientId,
|
|
1047
|
+
redirect_uri: "https://app.example/cb",
|
|
1048
|
+
response_type: "code",
|
|
1049
|
+
scope: "vault:default:read",
|
|
1050
|
+
code_challenge: challenge,
|
|
1051
|
+
code_challenge_method: "S256",
|
|
1052
|
+
state: "race",
|
|
1053
|
+
});
|
|
1054
|
+
const req = new Request(`${ISSUER}/oauth/authorize`, {
|
|
1055
|
+
method: "POST",
|
|
1056
|
+
body: form,
|
|
1057
|
+
headers: {
|
|
1058
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1059
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
|
|
1060
|
+
},
|
|
1061
|
+
});
|
|
1062
|
+
const res = await handleAuthorizePost(db, req, { issuer: ISSUER });
|
|
1063
|
+
expect(res.status).toBe(403);
|
|
1064
|
+
const html = await res.text();
|
|
1065
|
+
expect(html).toContain("App not yet approved");
|
|
1066
|
+
// Web path advertised, with the client_id rendered inline.
|
|
1067
|
+
expect(html).toContain(`/admin/approve-client/${reg.client.clientId}`);
|
|
1068
|
+
expect(html).toContain("Sign in as admin");
|
|
1069
|
+
// CLI mention retired from every browser-visible surface in rc.19.
|
|
1070
|
+
expect(html).not.toContain("parachute auth approve-client");
|
|
1071
|
+
expect(html).not.toContain("from a terminal");
|
|
1072
|
+
} finally {
|
|
1073
|
+
cleanup();
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
|
|
883
1077
|
test("rejects parachute:host:admin in form scope (defense-in-depth, #96)", async () => {
|
|
884
1078
|
// GET-time gate already rejects, but a hand-crafted POST could carry
|
|
885
1079
|
// an operator-only scope. Consent submit must independently reject.
|
|
@@ -955,6 +1149,57 @@ describe("handleAuthorizePost — consent submit", () => {
|
|
|
955
1149
|
cleanup();
|
|
956
1150
|
}
|
|
957
1151
|
});
|
|
1152
|
+
|
|
1153
|
+
test("consent POST with unknown client_id + self-origin redirect_uri renders recovery affordance", async () => {
|
|
1154
|
+
// Symmetry with the GET-path coverage of the same hub#277 recovery
|
|
1155
|
+
// affordance. handleAuthorizePost's consent submit routes the
|
|
1156
|
+
// `getClient = null` branch through the same `unknownClientResponse`
|
|
1157
|
+
// helper as the GET path; pin it explicitly so a future refactor
|
|
1158
|
+
// can't silently drop the recovery path here. Reaching this branch
|
|
1159
|
+
// on the consent POST means the client_id was deleted between
|
|
1160
|
+
// render and submit (vanishingly rare in practice — exercised here
|
|
1161
|
+
// by registering nothing for the carried `client_id`).
|
|
1162
|
+
const { db, cleanup } = await makeDb();
|
|
1163
|
+
try {
|
|
1164
|
+
const user = await createUser(db, "owner", "pw");
|
|
1165
|
+
const session = createSession(db, { userId: user.id });
|
|
1166
|
+
const { challenge } = makePkce();
|
|
1167
|
+
const form = new URLSearchParams({
|
|
1168
|
+
__action: "consent",
|
|
1169
|
+
__csrf: TEST_CSRF,
|
|
1170
|
+
approve: "yes",
|
|
1171
|
+
client_id: "stale-dangling-id",
|
|
1172
|
+
redirect_uri: `${ISSUER}/notes/oauth/callback`,
|
|
1173
|
+
response_type: "code",
|
|
1174
|
+
scope: "vault:read",
|
|
1175
|
+
code_challenge: challenge,
|
|
1176
|
+
code_challenge_method: "S256",
|
|
1177
|
+
state: "abc",
|
|
1178
|
+
});
|
|
1179
|
+
const req = new Request(`${ISSUER}/oauth/authorize`, {
|
|
1180
|
+
method: "POST",
|
|
1181
|
+
body: form,
|
|
1182
|
+
headers: {
|
|
1183
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1184
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
|
|
1185
|
+
},
|
|
1186
|
+
});
|
|
1187
|
+
const res = await handleAuthorizePost(db, req, {
|
|
1188
|
+
issuer: ISSUER,
|
|
1189
|
+
hubBoundOrigins: () => [ISSUER],
|
|
1190
|
+
});
|
|
1191
|
+
expect(res.status).toBe(400);
|
|
1192
|
+
const body = await res.text();
|
|
1193
|
+
expect(body).toContain("Unknown application");
|
|
1194
|
+
expect(body).toContain("stale-dangling-id");
|
|
1195
|
+
// Recovery affordance — same shape as the GET-path tests above.
|
|
1196
|
+
expect(body).toContain("unknown-client-reset");
|
|
1197
|
+
expect(body).toContain('data-target="/notes/oauth/callback"');
|
|
1198
|
+
expect(body).toContain("lens:dcr:");
|
|
1199
|
+
} finally {
|
|
1200
|
+
cleanup();
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
958
1203
|
});
|
|
959
1204
|
|
|
960
1205
|
describe("handleToken — full OAuth dance", () => {
|
|
@@ -2230,7 +2475,9 @@ describe("DCR approval gate (#74)", () => {
|
|
|
2230
2475
|
expect(res.status).toBe(403);
|
|
2231
2476
|
const html = await res.text();
|
|
2232
2477
|
expect(html).toContain("App not yet approved");
|
|
2233
|
-
|
|
2478
|
+
// /admin/approve-client/<id> deep link is the canonical recovery now
|
|
2479
|
+
// (the pre-rc.19 CLI message was retired in favor of the web path).
|
|
2480
|
+
expect(html).toContain(`/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`);
|
|
2234
2481
|
// No vault hint → no vault row in approve-meta. Single-vault hubs +
|
|
2235
2482
|
// pre-vault-popover clients leave the section omitted (#244).
|
|
2236
2483
|
expect(html).not.toContain('approve-meta-label">vault');
|
|
@@ -3723,10 +3970,13 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
|
3723
3970
|
});
|
|
3724
3971
|
}
|
|
3725
3972
|
|
|
3726
|
-
test("session absent → page renders
|
|
3727
|
-
//
|
|
3728
|
-
//
|
|
3729
|
-
//
|
|
3973
|
+
test("session absent → page renders Sign-in CTA + shareable deep link (no CLI message)", async () => {
|
|
3974
|
+
// Approval-UX rc.19: the unauthenticated viewer no longer sees a
|
|
3975
|
+
// "Ask the operator to run `parachute auth approve-client <id>`"
|
|
3976
|
+
// message. The web approval path (#277) is the canonical recovery
|
|
3977
|
+
// now — render a primary Sign-in CTA wired to /login?next=/admin/...
|
|
3978
|
+
// and a shareable deep link the operator can send to whoever runs
|
|
3979
|
+
// the hub.
|
|
3730
3980
|
const { db, cleanup } = await makeDb();
|
|
3731
3981
|
try {
|
|
3732
3982
|
const reg = registerClient(db, {
|
|
@@ -3739,9 +3989,23 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
|
3739
3989
|
expect(res.status).toBe(403);
|
|
3740
3990
|
const html = await res.text();
|
|
3741
3991
|
expect(html).toContain("App not yet approved");
|
|
3742
|
-
//
|
|
3743
|
-
|
|
3744
|
-
|
|
3992
|
+
// Primary CTA: Sign-in link wired to land the admin directly on
|
|
3993
|
+
// the approval page after authenticating.
|
|
3994
|
+
expect(html).toContain("Sign in as admin to approve");
|
|
3995
|
+
const expectedLoginHref = `/login?next=${encodeURIComponent(
|
|
3996
|
+
`/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
|
|
3997
|
+
)}`;
|
|
3998
|
+
expect(html).toContain(`href="${expectedLoginHref}"`);
|
|
3999
|
+
// Secondary CTA: shareable, fully-qualified deep link + Copy button.
|
|
4000
|
+
expect(html).toContain(
|
|
4001
|
+
`${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
|
|
4002
|
+
);
|
|
4003
|
+
expect(html).toContain('id="approve-share-copy"');
|
|
4004
|
+
expect(html).toContain("navigator.clipboard");
|
|
4005
|
+
// Retired CLI hint must not appear anywhere in the body.
|
|
4006
|
+
expect(html).not.toContain("parachute auth approve-client");
|
|
4007
|
+
expect(html).not.toContain("from a terminal");
|
|
4008
|
+
// No form element pointing at the approve endpoint (un-authed branch).
|
|
3745
4009
|
expect(html).not.toContain('action="/oauth/authorize/approve"');
|
|
3746
4010
|
} finally {
|
|
3747
4011
|
cleanup();
|
|
@@ -3783,8 +4047,12 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
|
3783
4047
|
expect(html).toContain("MyApp");
|
|
3784
4048
|
expect(html).toContain(reg.client.clientId);
|
|
3785
4049
|
expect(html).toContain("https://app.example/cb");
|
|
3786
|
-
//
|
|
3787
|
-
|
|
4050
|
+
// Authed branch shows only the one-click Approve form — the unauth
|
|
4051
|
+
// Sign-in CTA and shareable-link block do NOT render here.
|
|
4052
|
+
expect(html).not.toContain("Sign in as admin to approve");
|
|
4053
|
+
expect(html).not.toContain("Or send this link to your hub admin");
|
|
4054
|
+
// CLI hint also gone in this branch (approval-UX rc.19).
|
|
4055
|
+
expect(html).not.toContain("parachute auth approve-client");
|
|
3788
4056
|
} finally {
|
|
3789
4057
|
cleanup();
|
|
3790
4058
|
}
|
|
@@ -4216,3 +4484,784 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
|
|
|
4216
4484
|
}
|
|
4217
4485
|
});
|
|
4218
4486
|
});
|
|
4487
|
+
|
|
4488
|
+
// DCR first-client auto-approve window (hub#268 Item 3). The wizard's
|
|
4489
|
+
// expose-step POST opens a 60-minute window where the very next
|
|
4490
|
+
// `/oauth/register` registration is auto-approved + the window cleared.
|
|
4491
|
+
// Single-use: client #2 within the same window falls through to the
|
|
4492
|
+
// standard pending-approval flow.
|
|
4493
|
+
describe("DCR first-client auto-approve window (hub#268 Item 3)", () => {
|
|
4494
|
+
function registerRequest(): Request {
|
|
4495
|
+
return new Request(`${ISSUER}/oauth/register`, {
|
|
4496
|
+
method: "POST",
|
|
4497
|
+
body: JSON.stringify({
|
|
4498
|
+
redirect_uris: ["https://app.example/cb"],
|
|
4499
|
+
client_name: "first-client",
|
|
4500
|
+
}),
|
|
4501
|
+
headers: { "content-type": "application/json" },
|
|
4502
|
+
});
|
|
4503
|
+
}
|
|
4504
|
+
|
|
4505
|
+
test("client registered within the open window → status approved + window cleared", async () => {
|
|
4506
|
+
const { db, cleanup } = await makeDb();
|
|
4507
|
+
try {
|
|
4508
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
4509
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
4510
|
+
const res = await handleRegister(db, registerRequest(), {
|
|
4511
|
+
issuer: ISSUER,
|
|
4512
|
+
now: () => t0,
|
|
4513
|
+
});
|
|
4514
|
+
expect(res.status).toBe(201);
|
|
4515
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4516
|
+
expect(body.status).toBe("approved");
|
|
4517
|
+
// Persisted, not just response-shaped.
|
|
4518
|
+
const row = getClient(db, body.client_id as string);
|
|
4519
|
+
expect(row?.status).toBe("approved");
|
|
4520
|
+
// Window cleared on consume (single-use).
|
|
4521
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
|
|
4522
|
+
} finally {
|
|
4523
|
+
cleanup();
|
|
4524
|
+
}
|
|
4525
|
+
});
|
|
4526
|
+
|
|
4527
|
+
test("client registered AFTER the window has expired → status pending", async () => {
|
|
4528
|
+
const { db, cleanup } = await makeDb();
|
|
4529
|
+
try {
|
|
4530
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
4531
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
4532
|
+
const past = new Date(t0.getTime() + FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS + 1);
|
|
4533
|
+
const res = await handleRegister(db, registerRequest(), {
|
|
4534
|
+
issuer: ISSUER,
|
|
4535
|
+
now: () => past,
|
|
4536
|
+
});
|
|
4537
|
+
expect(res.status).toBe(201);
|
|
4538
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4539
|
+
expect(body.status).toBe("pending");
|
|
4540
|
+
} finally {
|
|
4541
|
+
cleanup();
|
|
4542
|
+
}
|
|
4543
|
+
});
|
|
4544
|
+
|
|
4545
|
+
test("second client within window after first auto-approved → status pending (single-use)", async () => {
|
|
4546
|
+
const { db, cleanup } = await makeDb();
|
|
4547
|
+
try {
|
|
4548
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
4549
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
4550
|
+
// Client #1: approved.
|
|
4551
|
+
const res1 = await handleRegister(db, registerRequest(), {
|
|
4552
|
+
issuer: ISSUER,
|
|
4553
|
+
now: () => t0,
|
|
4554
|
+
});
|
|
4555
|
+
const body1 = (await res1.json()) as Record<string, unknown>;
|
|
4556
|
+
expect(body1.status).toBe("approved");
|
|
4557
|
+
// Client #2 within the (still-not-expired) window: pending.
|
|
4558
|
+
const stillWithinWindow = new Date(t0.getTime() + 30 * 60 * 1000);
|
|
4559
|
+
const res2 = await handleRegister(db, registerRequest(), {
|
|
4560
|
+
issuer: ISSUER,
|
|
4561
|
+
now: () => stillWithinWindow,
|
|
4562
|
+
});
|
|
4563
|
+
const body2 = (await res2.json()) as Record<string, unknown>;
|
|
4564
|
+
expect(body2.status).toBe("pending");
|
|
4565
|
+
} finally {
|
|
4566
|
+
cleanup();
|
|
4567
|
+
}
|
|
4568
|
+
});
|
|
4569
|
+
|
|
4570
|
+
test("no window set → status pending (default public-DCR flow)", async () => {
|
|
4571
|
+
const { db, cleanup } = await makeDb();
|
|
4572
|
+
try {
|
|
4573
|
+
const res = await handleRegister(db, registerRequest(), { issuer: ISSUER });
|
|
4574
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4575
|
+
expect(body.status).toBe("pending");
|
|
4576
|
+
// Settings row untouched.
|
|
4577
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeUndefined();
|
|
4578
|
+
} finally {
|
|
4579
|
+
cleanup();
|
|
4580
|
+
}
|
|
4581
|
+
});
|
|
4582
|
+
|
|
4583
|
+
test("operator-bearer auto-approve still takes precedence over the window (no double-consume)", async () => {
|
|
4584
|
+
// Bearer-authenticated registration approves directly; the
|
|
4585
|
+
// auto-approve window should NOT be consumed in that case — it's
|
|
4586
|
+
// still available for the first un-authenticated client.
|
|
4587
|
+
const { db, cleanup } = await makeDb();
|
|
4588
|
+
try {
|
|
4589
|
+
const t0 = new Date("2026-05-19T00:00:00.000Z");
|
|
4590
|
+
openFirstClientAutoApproveWindow(db, () => t0);
|
|
4591
|
+
// We can't easily mint an operator bearer in this test layer, so
|
|
4592
|
+
// simulate by using the session-cookie path (issuer-trusted) which
|
|
4593
|
+
// also auto-approves before falling through to the window check.
|
|
4594
|
+
const user = await createUser(db, "owner", "pw");
|
|
4595
|
+
const session = createSession(db, { userId: user.id });
|
|
4596
|
+
const req = new Request(`${ISSUER}/oauth/register`, {
|
|
4597
|
+
method: "POST",
|
|
4598
|
+
body: JSON.stringify({ redirect_uris: ["https://app.example/cb"] }),
|
|
4599
|
+
headers: {
|
|
4600
|
+
"content-type": "application/json",
|
|
4601
|
+
cookie: buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)),
|
|
4602
|
+
origin: ISSUER,
|
|
4603
|
+
},
|
|
4604
|
+
});
|
|
4605
|
+
const res = await handleRegister(db, req, { issuer: ISSUER, now: () => t0 });
|
|
4606
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4607
|
+
expect(body.status).toBe("approved");
|
|
4608
|
+
// Window NOT consumed — still set, still open. The session-cookie
|
|
4609
|
+
// path approved first, never reaching the window-consume code.
|
|
4610
|
+
expect(getSetting(db, "pending_first_client_auto_approve_until")).toBeDefined();
|
|
4611
|
+
} finally {
|
|
4612
|
+
cleanup();
|
|
4613
|
+
}
|
|
4614
|
+
});
|
|
4615
|
+
|
|
4616
|
+
test("malformed timestamp in the setting → treated as no-window, status pending", async () => {
|
|
4617
|
+
const { db, cleanup } = await makeDb();
|
|
4618
|
+
try {
|
|
4619
|
+
setSetting(db, "pending_first_client_auto_approve_until", "not-a-real-iso-string");
|
|
4620
|
+
const res = await handleRegister(db, registerRequest(), { issuer: ISSUER });
|
|
4621
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
4622
|
+
expect(body.status).toBe("pending");
|
|
4623
|
+
} finally {
|
|
4624
|
+
cleanup();
|
|
4625
|
+
}
|
|
4626
|
+
});
|
|
4627
|
+
});
|
|
4628
|
+
|
|
4629
|
+
// Multi-user Phase 1, PR 4 (design 2026-05-20-multi-user-phase-1.md, hub#252):
|
|
4630
|
+
// non-admin users (with `assigned_vault` non-null) see the consent picker
|
|
4631
|
+
// locked, and the OAuth issuer mints tokens carrying `vault_scope: [<assigned>]`.
|
|
4632
|
+
// Server-side defense refuses any mint whose picked vault disagrees.
|
|
4633
|
+
describe("handleAuthorizeGet — multi-user assigned vault picker lock (PR 4)", () => {
|
|
4634
|
+
test("admin user (assigned_vault null) sees the free dropdown", async () => {
|
|
4635
|
+
const { db, cleanup } = await makeDb();
|
|
4636
|
+
try {
|
|
4637
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
4638
|
+
const session = createSession(db, { userId: admin.id });
|
|
4639
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
4640
|
+
const { challenge } = makePkce();
|
|
4641
|
+
const req = new Request(
|
|
4642
|
+
authorizeUrl({
|
|
4643
|
+
client_id: reg.client.clientId,
|
|
4644
|
+
redirect_uri: "https://app.example/cb",
|
|
4645
|
+
response_type: "code",
|
|
4646
|
+
code_challenge: challenge,
|
|
4647
|
+
code_challenge_method: "S256",
|
|
4648
|
+
scope: "vault:read",
|
|
4649
|
+
}),
|
|
4650
|
+
{
|
|
4651
|
+
headers: {
|
|
4652
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
4653
|
+
},
|
|
4654
|
+
},
|
|
4655
|
+
);
|
|
4656
|
+
const res = handleAuthorizeGet(db, req, {
|
|
4657
|
+
issuer: ISSUER,
|
|
4658
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
4659
|
+
});
|
|
4660
|
+
expect(res.status).toBe(200);
|
|
4661
|
+
const html = await res.text();
|
|
4662
|
+
// Free dropdown for admin: radio inputs present, no "Assigned vault" lock.
|
|
4663
|
+
expect(html).toContain('name="vault_pick" value="default"');
|
|
4664
|
+
expect(html).not.toContain("Assigned vault");
|
|
4665
|
+
expect(html).not.toContain("admin-managed");
|
|
4666
|
+
} finally {
|
|
4667
|
+
cleanup();
|
|
4668
|
+
}
|
|
4669
|
+
});
|
|
4670
|
+
|
|
4671
|
+
test("non-admin user (assigned_vault set) sees the locked picker with admin-managed note", async () => {
|
|
4672
|
+
const { db, cleanup } = await makeDb();
|
|
4673
|
+
try {
|
|
4674
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
4675
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
4676
|
+
allowMulti: true,
|
|
4677
|
+
assignedVault: "default",
|
|
4678
|
+
});
|
|
4679
|
+
void admin;
|
|
4680
|
+
const session = createSession(db, { userId: bob.id });
|
|
4681
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
4682
|
+
const { challenge } = makePkce();
|
|
4683
|
+
const req = new Request(
|
|
4684
|
+
authorizeUrl({
|
|
4685
|
+
client_id: reg.client.clientId,
|
|
4686
|
+
redirect_uri: "https://app.example/cb",
|
|
4687
|
+
response_type: "code",
|
|
4688
|
+
code_challenge: challenge,
|
|
4689
|
+
code_challenge_method: "S256",
|
|
4690
|
+
scope: "vault:read",
|
|
4691
|
+
}),
|
|
4692
|
+
{
|
|
4693
|
+
headers: {
|
|
4694
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
4695
|
+
},
|
|
4696
|
+
},
|
|
4697
|
+
);
|
|
4698
|
+
const res = handleAuthorizeGet(db, req, {
|
|
4699
|
+
issuer: ISSUER,
|
|
4700
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
4701
|
+
});
|
|
4702
|
+
expect(res.status).toBe(200);
|
|
4703
|
+
const html = await res.text();
|
|
4704
|
+
expect(html).toContain("vault-picker-locked");
|
|
4705
|
+
expect(html).toContain("Assigned vault");
|
|
4706
|
+
expect(html).toContain("admin-managed");
|
|
4707
|
+
// Hidden input carries the assigned vault as the picker value.
|
|
4708
|
+
expect(html).toContain('<input type="hidden" name="vault_pick" value="default"');
|
|
4709
|
+
// No free-choice radio inputs.
|
|
4710
|
+
expect(html).not.toContain('type="radio" name="vault_pick"');
|
|
4711
|
+
} finally {
|
|
4712
|
+
cleanup();
|
|
4713
|
+
}
|
|
4714
|
+
});
|
|
4715
|
+
});
|
|
4716
|
+
|
|
4717
|
+
// Approval-UX rc.19 (Issue 2 in Aaron's bundle): the consent screen now
|
|
4718
|
+
// renders the *resolved* scope shape — `vault:<name>:<verb>` — instead of
|
|
4719
|
+
// the raw OAuth request `vault:<verb>`. The raw form was confusing because
|
|
4720
|
+
// it implied vault-wide unrestricted access, when hub actually narrows to
|
|
4721
|
+
// a specific vault at token-mint via the picker (or the user's
|
|
4722
|
+
// assigned_vault for multi-user setups).
|
|
4723
|
+
describe("handleAuthorizeGet — resolved scope display (approval-UX rc.19)", () => {
|
|
4724
|
+
test("non-admin user (assigned_vault set) sees vault:<assigned>:read on consent, not raw vault:read", async () => {
|
|
4725
|
+
const { db, cleanup } = await makeDb();
|
|
4726
|
+
try {
|
|
4727
|
+
const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
|
|
4728
|
+
const session = createSession(db, { userId: bob.id });
|
|
4729
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
4730
|
+
const { challenge } = makePkce();
|
|
4731
|
+
const req = new Request(
|
|
4732
|
+
authorizeUrl({
|
|
4733
|
+
client_id: reg.client.clientId,
|
|
4734
|
+
redirect_uri: "https://app.example/cb",
|
|
4735
|
+
response_type: "code",
|
|
4736
|
+
code_challenge: challenge,
|
|
4737
|
+
code_challenge_method: "S256",
|
|
4738
|
+
scope: "vault:read",
|
|
4739
|
+
}),
|
|
4740
|
+
{
|
|
4741
|
+
headers: {
|
|
4742
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
4743
|
+
},
|
|
4744
|
+
},
|
|
4745
|
+
);
|
|
4746
|
+
const res = handleAuthorizeGet(db, req, {
|
|
4747
|
+
issuer: ISSUER,
|
|
4748
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
4749
|
+
});
|
|
4750
|
+
expect(res.status).toBe(200);
|
|
4751
|
+
const html = await res.text();
|
|
4752
|
+
// Resolved form rendered in the scope-row code block.
|
|
4753
|
+
expect(html).toContain('<code class="scope-name">vault:default:read</code>');
|
|
4754
|
+
// Raw unnamed form must NOT appear inside a scope row (it still
|
|
4755
|
+
// appears in the hidden form-roundtrip inputs as `name="scope" value="vault:read"`).
|
|
4756
|
+
expect(html).not.toContain('<code class="scope-name">vault:read</code>');
|
|
4757
|
+
} finally {
|
|
4758
|
+
cleanup();
|
|
4759
|
+
}
|
|
4760
|
+
});
|
|
4761
|
+
|
|
4762
|
+
test("admin user with picker — single-vault hub pre-checks and consent shows that vault", async () => {
|
|
4763
|
+
const { db, cleanup } = await makeDb();
|
|
4764
|
+
try {
|
|
4765
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
4766
|
+
const session = createSession(db, { userId: admin.id });
|
|
4767
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
4768
|
+
const { challenge } = makePkce();
|
|
4769
|
+
const req = new Request(
|
|
4770
|
+
authorizeUrl({
|
|
4771
|
+
client_id: reg.client.clientId,
|
|
4772
|
+
redirect_uri: "https://app.example/cb",
|
|
4773
|
+
response_type: "code",
|
|
4774
|
+
code_challenge: challenge,
|
|
4775
|
+
code_challenge_method: "S256",
|
|
4776
|
+
scope: "vault:read",
|
|
4777
|
+
}),
|
|
4778
|
+
{
|
|
4779
|
+
headers: {
|
|
4780
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
4781
|
+
},
|
|
4782
|
+
},
|
|
4783
|
+
);
|
|
4784
|
+
const res = handleAuthorizeGet(db, req, {
|
|
4785
|
+
issuer: ISSUER,
|
|
4786
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
4787
|
+
});
|
|
4788
|
+
expect(res.status).toBe(200);
|
|
4789
|
+
const html = await res.text();
|
|
4790
|
+
// The fixture services manifest has a single vault named "default" — the
|
|
4791
|
+
// picker pre-checks it and the consent screen renders the resolved form.
|
|
4792
|
+
expect(html).toContain('<code class="scope-name">vault:default:read</code>');
|
|
4793
|
+
} finally {
|
|
4794
|
+
cleanup();
|
|
4795
|
+
}
|
|
4796
|
+
});
|
|
4797
|
+
});
|
|
4798
|
+
|
|
4799
|
+
describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", () => {
|
|
4800
|
+
test("non-admin happy path: token carries vault_scope=[assigned] and narrowed scope", async () => {
|
|
4801
|
+
const { db, cleanup } = await makeDb();
|
|
4802
|
+
try {
|
|
4803
|
+
const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
|
|
4804
|
+
const session = createSession(db, { userId: bob.id });
|
|
4805
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
4806
|
+
const { verifier, challenge } = makePkce();
|
|
4807
|
+
const consentForm = new URLSearchParams({
|
|
4808
|
+
__action: "consent",
|
|
4809
|
+
__csrf: TEST_CSRF,
|
|
4810
|
+
approve: "yes",
|
|
4811
|
+
client_id: reg.client.clientId,
|
|
4812
|
+
redirect_uri: "https://app.example/cb",
|
|
4813
|
+
response_type: "code",
|
|
4814
|
+
scope: "vault:read",
|
|
4815
|
+
code_challenge: challenge,
|
|
4816
|
+
code_challenge_method: "S256",
|
|
4817
|
+
vault_pick: "default",
|
|
4818
|
+
});
|
|
4819
|
+
const consentRes = await handleAuthorizePost(
|
|
4820
|
+
db,
|
|
4821
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
4822
|
+
method: "POST",
|
|
4823
|
+
body: consentForm,
|
|
4824
|
+
headers: {
|
|
4825
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
4826
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
|
|
4827
|
+
},
|
|
4828
|
+
}),
|
|
4829
|
+
{ issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
|
|
4830
|
+
);
|
|
4831
|
+
expect(consentRes.status).toBe(302);
|
|
4832
|
+
const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
|
|
4833
|
+
const tokenRes = await handleToken(
|
|
4834
|
+
db,
|
|
4835
|
+
new Request(`${ISSUER}/oauth/token`, {
|
|
4836
|
+
method: "POST",
|
|
4837
|
+
body: new URLSearchParams({
|
|
4838
|
+
grant_type: "authorization_code",
|
|
4839
|
+
code: code ?? "",
|
|
4840
|
+
client_id: reg.client.clientId,
|
|
4841
|
+
redirect_uri: "https://app.example/cb",
|
|
4842
|
+
code_verifier: verifier,
|
|
4843
|
+
}),
|
|
4844
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
4845
|
+
}),
|
|
4846
|
+
{ issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
|
|
4847
|
+
);
|
|
4848
|
+
expect(tokenRes.status).toBe(200);
|
|
4849
|
+
const body = (await tokenRes.json()) as { access_token: string; scope: string };
|
|
4850
|
+
expect(body.scope).toBe("vault:default:read");
|
|
4851
|
+
const { payload } = await validateAccessToken(db, body.access_token, ISSUER);
|
|
4852
|
+
expect(payload.scope).toBe("vault:default:read");
|
|
4853
|
+
expect(payload.vault_scope).toEqual(["default"]);
|
|
4854
|
+
} finally {
|
|
4855
|
+
cleanup();
|
|
4856
|
+
}
|
|
4857
|
+
});
|
|
4858
|
+
|
|
4859
|
+
test("admin user (assigned_vault null) mints with vault_scope=[]", async () => {
|
|
4860
|
+
const { db, cleanup } = await makeDb();
|
|
4861
|
+
try {
|
|
4862
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
4863
|
+
const session = createSession(db, { userId: admin.id });
|
|
4864
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
4865
|
+
const { verifier, challenge } = makePkce();
|
|
4866
|
+
const consentForm = new URLSearchParams({
|
|
4867
|
+
__action: "consent",
|
|
4868
|
+
__csrf: TEST_CSRF,
|
|
4869
|
+
approve: "yes",
|
|
4870
|
+
client_id: reg.client.clientId,
|
|
4871
|
+
redirect_uri: "https://app.example/cb",
|
|
4872
|
+
response_type: "code",
|
|
4873
|
+
scope: "vault:read",
|
|
4874
|
+
code_challenge: challenge,
|
|
4875
|
+
code_challenge_method: "S256",
|
|
4876
|
+
vault_pick: "default",
|
|
4877
|
+
});
|
|
4878
|
+
const consentRes = await handleAuthorizePost(
|
|
4879
|
+
db,
|
|
4880
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
4881
|
+
method: "POST",
|
|
4882
|
+
body: consentForm,
|
|
4883
|
+
headers: {
|
|
4884
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
4885
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
|
|
4886
|
+
},
|
|
4887
|
+
}),
|
|
4888
|
+
{ issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
|
|
4889
|
+
);
|
|
4890
|
+
const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
|
|
4891
|
+
const tokenRes = await handleToken(
|
|
4892
|
+
db,
|
|
4893
|
+
new Request(`${ISSUER}/oauth/token`, {
|
|
4894
|
+
method: "POST",
|
|
4895
|
+
body: new URLSearchParams({
|
|
4896
|
+
grant_type: "authorization_code",
|
|
4897
|
+
code: code ?? "",
|
|
4898
|
+
client_id: reg.client.clientId,
|
|
4899
|
+
redirect_uri: "https://app.example/cb",
|
|
4900
|
+
code_verifier: verifier,
|
|
4901
|
+
}),
|
|
4902
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
4903
|
+
}),
|
|
4904
|
+
{ issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
|
|
4905
|
+
);
|
|
4906
|
+
const body = (await tokenRes.json()) as { access_token: string };
|
|
4907
|
+
const { payload } = await validateAccessToken(db, body.access_token, ISSUER);
|
|
4908
|
+
expect(payload.vault_scope).toEqual([]);
|
|
4909
|
+
} finally {
|
|
4910
|
+
cleanup();
|
|
4911
|
+
}
|
|
4912
|
+
});
|
|
4913
|
+
|
|
4914
|
+
test("non-admin with disagreeing vault_pick → 400 vault_scope_mismatch", async () => {
|
|
4915
|
+
const { db, cleanup } = await makeDb();
|
|
4916
|
+
try {
|
|
4917
|
+
const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
|
|
4918
|
+
const session = createSession(db, { userId: bob.id });
|
|
4919
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
4920
|
+
const { challenge } = makePkce();
|
|
4921
|
+
// The fixture also has a vault "default"; build a manifest that has
|
|
4922
|
+
// two valid vault names so the mismatch isn't conflated with
|
|
4923
|
+
// "unknown vault."
|
|
4924
|
+
const twoVaultManifest: ServicesManifest = {
|
|
4925
|
+
services: [
|
|
4926
|
+
{
|
|
4927
|
+
name: "parachute-vault",
|
|
4928
|
+
port: 1940,
|
|
4929
|
+
paths: ["/vault/default", "/vault/other"],
|
|
4930
|
+
health: "/health",
|
|
4931
|
+
version: "0.3.0",
|
|
4932
|
+
},
|
|
4933
|
+
],
|
|
4934
|
+
};
|
|
4935
|
+
const consentForm = new URLSearchParams({
|
|
4936
|
+
__action: "consent",
|
|
4937
|
+
__csrf: TEST_CSRF,
|
|
4938
|
+
approve: "yes",
|
|
4939
|
+
client_id: reg.client.clientId,
|
|
4940
|
+
redirect_uri: "https://app.example/cb",
|
|
4941
|
+
response_type: "code",
|
|
4942
|
+
scope: "vault:read",
|
|
4943
|
+
code_challenge: challenge,
|
|
4944
|
+
code_challenge_method: "S256",
|
|
4945
|
+
vault_pick: "other",
|
|
4946
|
+
});
|
|
4947
|
+
const res = await handleAuthorizePost(
|
|
4948
|
+
db,
|
|
4949
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
4950
|
+
method: "POST",
|
|
4951
|
+
body: consentForm,
|
|
4952
|
+
headers: {
|
|
4953
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
4954
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
|
|
4955
|
+
},
|
|
4956
|
+
}),
|
|
4957
|
+
{ issuer: ISSUER, loadServicesManifest: () => twoVaultManifest },
|
|
4958
|
+
);
|
|
4959
|
+
expect(res.status).toBe(400);
|
|
4960
|
+
const html = await res.text();
|
|
4961
|
+
expect(html).toContain("vault_scope_mismatch");
|
|
4962
|
+
// Echo back the picked-but-rejected vault (HTML-escaped), but DON'T
|
|
4963
|
+
// leak the assigned one (post-N1 nit-fold). "your vault assignment"
|
|
4964
|
+
// is the soft phrase replacing the prior `your assigned vault "..."`.
|
|
4965
|
+
expect(html).toContain(""other"");
|
|
4966
|
+
expect(html).toContain("your vault assignment");
|
|
4967
|
+
expect(html).not.toContain(""default"");
|
|
4968
|
+
} finally {
|
|
4969
|
+
cleanup();
|
|
4970
|
+
}
|
|
4971
|
+
});
|
|
4972
|
+
|
|
4973
|
+
test("non-admin requesting named scope for the wrong vault → 400 vault_scope_mismatch", async () => {
|
|
4974
|
+
const { db, cleanup } = await makeDb();
|
|
4975
|
+
try {
|
|
4976
|
+
const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
|
|
4977
|
+
const session = createSession(db, { userId: bob.id });
|
|
4978
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
4979
|
+
const { challenge } = makePkce();
|
|
4980
|
+
const consentForm = new URLSearchParams({
|
|
4981
|
+
__action: "consent",
|
|
4982
|
+
__csrf: TEST_CSRF,
|
|
4983
|
+
approve: "yes",
|
|
4984
|
+
client_id: reg.client.clientId,
|
|
4985
|
+
redirect_uri: "https://app.example/cb",
|
|
4986
|
+
response_type: "code",
|
|
4987
|
+
// Explicit named scope targeting a vault other than bob's assigned one.
|
|
4988
|
+
scope: "vault:other:read",
|
|
4989
|
+
code_challenge: challenge,
|
|
4990
|
+
code_challenge_method: "S256",
|
|
4991
|
+
});
|
|
4992
|
+
const res = await handleAuthorizePost(
|
|
4993
|
+
db,
|
|
4994
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
4995
|
+
method: "POST",
|
|
4996
|
+
body: consentForm,
|
|
4997
|
+
headers: {
|
|
4998
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
4999
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
|
|
5000
|
+
},
|
|
5001
|
+
}),
|
|
5002
|
+
{ issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
|
|
5003
|
+
);
|
|
5004
|
+
expect(res.status).toBe(400);
|
|
5005
|
+
const html = await res.text();
|
|
5006
|
+
expect(html).toContain("vault_scope_mismatch");
|
|
5007
|
+
expect(html).toContain("vault:other:read");
|
|
5008
|
+
} finally {
|
|
5009
|
+
cleanup();
|
|
5010
|
+
}
|
|
5011
|
+
});
|
|
5012
|
+
|
|
5013
|
+
test("non-admin requesting named scope for the assigned vault → happy path", async () => {
|
|
5014
|
+
const { db, cleanup } = await makeDb();
|
|
5015
|
+
try {
|
|
5016
|
+
const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
|
|
5017
|
+
const session = createSession(db, { userId: bob.id });
|
|
5018
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
5019
|
+
const { verifier, challenge } = makePkce();
|
|
5020
|
+
const consentForm = new URLSearchParams({
|
|
5021
|
+
__action: "consent",
|
|
5022
|
+
__csrf: TEST_CSRF,
|
|
5023
|
+
approve: "yes",
|
|
5024
|
+
client_id: reg.client.clientId,
|
|
5025
|
+
redirect_uri: "https://app.example/cb",
|
|
5026
|
+
response_type: "code",
|
|
5027
|
+
// Named scope matching bob's assigned vault — should pass.
|
|
5028
|
+
scope: "vault:default:read",
|
|
5029
|
+
code_challenge: challenge,
|
|
5030
|
+
code_challenge_method: "S256",
|
|
5031
|
+
});
|
|
5032
|
+
const consentRes = await handleAuthorizePost(
|
|
5033
|
+
db,
|
|
5034
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
5035
|
+
method: "POST",
|
|
5036
|
+
body: consentForm,
|
|
5037
|
+
headers: {
|
|
5038
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
5039
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
|
|
5040
|
+
},
|
|
5041
|
+
}),
|
|
5042
|
+
{ issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
|
|
5043
|
+
);
|
|
5044
|
+
expect(consentRes.status).toBe(302);
|
|
5045
|
+
const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
|
|
5046
|
+
const tokenRes = await handleToken(
|
|
5047
|
+
db,
|
|
5048
|
+
new Request(`${ISSUER}/oauth/token`, {
|
|
5049
|
+
method: "POST",
|
|
5050
|
+
body: new URLSearchParams({
|
|
5051
|
+
grant_type: "authorization_code",
|
|
5052
|
+
code: code ?? "",
|
|
5053
|
+
client_id: reg.client.clientId,
|
|
5054
|
+
redirect_uri: "https://app.example/cb",
|
|
5055
|
+
code_verifier: verifier,
|
|
5056
|
+
}),
|
|
5057
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
5058
|
+
}),
|
|
5059
|
+
{ issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
|
|
5060
|
+
);
|
|
5061
|
+
const body = (await tokenRes.json()) as { access_token: string };
|
|
5062
|
+
const { payload } = await validateAccessToken(db, body.access_token, ISSUER);
|
|
5063
|
+
expect(payload.scope).toBe("vault:default:read");
|
|
5064
|
+
expect(payload.vault_scope).toEqual(["default"]);
|
|
5065
|
+
} finally {
|
|
5066
|
+
cleanup();
|
|
5067
|
+
}
|
|
5068
|
+
});
|
|
5069
|
+
|
|
5070
|
+
test("refresh flow re-derives vault_scope from current assigned_vault", async () => {
|
|
5071
|
+
const { db, cleanup } = await makeDb();
|
|
5072
|
+
try {
|
|
5073
|
+
const bob = await createUser(db, "bob", "pw", { assignedVault: "default" });
|
|
5074
|
+
const session = createSession(db, { userId: bob.id });
|
|
5075
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
5076
|
+
const { verifier, challenge } = makePkce();
|
|
5077
|
+
|
|
5078
|
+
// Step 1: complete the OAuth dance to obtain a refresh token.
|
|
5079
|
+
const consentForm = new URLSearchParams({
|
|
5080
|
+
__action: "consent",
|
|
5081
|
+
__csrf: TEST_CSRF,
|
|
5082
|
+
approve: "yes",
|
|
5083
|
+
client_id: reg.client.clientId,
|
|
5084
|
+
redirect_uri: "https://app.example/cb",
|
|
5085
|
+
response_type: "code",
|
|
5086
|
+
scope: "vault:read",
|
|
5087
|
+
code_challenge: challenge,
|
|
5088
|
+
code_challenge_method: "S256",
|
|
5089
|
+
vault_pick: "default",
|
|
5090
|
+
});
|
|
5091
|
+
const consentRes = await handleAuthorizePost(
|
|
5092
|
+
db,
|
|
5093
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
5094
|
+
method: "POST",
|
|
5095
|
+
body: consentForm,
|
|
5096
|
+
headers: {
|
|
5097
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
5098
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
|
|
5099
|
+
},
|
|
5100
|
+
}),
|
|
5101
|
+
{ issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
|
|
5102
|
+
);
|
|
5103
|
+
const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
|
|
5104
|
+
const tokenRes = await handleToken(
|
|
5105
|
+
db,
|
|
5106
|
+
new Request(`${ISSUER}/oauth/token`, {
|
|
5107
|
+
method: "POST",
|
|
5108
|
+
body: new URLSearchParams({
|
|
5109
|
+
grant_type: "authorization_code",
|
|
5110
|
+
code: code ?? "",
|
|
5111
|
+
client_id: reg.client.clientId,
|
|
5112
|
+
redirect_uri: "https://app.example/cb",
|
|
5113
|
+
code_verifier: verifier,
|
|
5114
|
+
}),
|
|
5115
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
5116
|
+
}),
|
|
5117
|
+
{ issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
|
|
5118
|
+
);
|
|
5119
|
+
const tokenBody = (await tokenRes.json()) as {
|
|
5120
|
+
access_token: string;
|
|
5121
|
+
refresh_token: string;
|
|
5122
|
+
};
|
|
5123
|
+
const firstValidated = await validateAccessToken(db, tokenBody.access_token, ISSUER);
|
|
5124
|
+
expect(firstValidated.payload.vault_scope).toEqual(["default"]);
|
|
5125
|
+
|
|
5126
|
+
// Step 2: refresh the token; vault_scope should still be ["default"].
|
|
5127
|
+
const refreshRes = await handleToken(
|
|
5128
|
+
db,
|
|
5129
|
+
new Request(`${ISSUER}/oauth/token`, {
|
|
5130
|
+
method: "POST",
|
|
5131
|
+
body: new URLSearchParams({
|
|
5132
|
+
grant_type: "refresh_token",
|
|
5133
|
+
refresh_token: tokenBody.refresh_token,
|
|
5134
|
+
client_id: reg.client.clientId,
|
|
5135
|
+
}),
|
|
5136
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
5137
|
+
}),
|
|
5138
|
+
{ issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
|
|
5139
|
+
);
|
|
5140
|
+
const refreshBody = (await refreshRes.json()) as { access_token: string };
|
|
5141
|
+
const refreshedValidated = await validateAccessToken(db, refreshBody.access_token, ISSUER);
|
|
5142
|
+
expect(refreshedValidated.payload.vault_scope).toEqual(["default"]);
|
|
5143
|
+
} finally {
|
|
5144
|
+
cleanup();
|
|
5145
|
+
}
|
|
5146
|
+
});
|
|
5147
|
+
|
|
5148
|
+
// Reviewer nit N3 (PR #283): the previous test only verified that
|
|
5149
|
+
// `vault_scope` SURVIVES refresh — it didn't prove the claim is re-derived
|
|
5150
|
+
// mid-session if an admin changes the user's `assigned_vault`. This test
|
|
5151
|
+
// pins the actual "re-derived at refresh time" invariant by mutating the
|
|
5152
|
+
// assignment between mint and refresh, then asserting the new token
|
|
5153
|
+
// carries the post-mutation value. The `scope` claim itself stays
|
|
5154
|
+
// narrowed to the original vault (it was set at consent time and stored
|
|
5155
|
+
// on the refresh-token row); only the informational `vault_scope` claim
|
|
5156
|
+
// tracks the live row.
|
|
5157
|
+
test("refresh flow picks up a mid-session assigned_vault change", async () => {
|
|
5158
|
+
const { db, cleanup } = await makeDb();
|
|
5159
|
+
try {
|
|
5160
|
+
const bob = await createUser(db, "bob", "pw", { assignedVault: "vault-a" });
|
|
5161
|
+
const session = createSession(db, { userId: bob.id });
|
|
5162
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
5163
|
+
const { verifier, challenge } = makePkce();
|
|
5164
|
+
|
|
5165
|
+
// Manifest fixture: both vault-a (initial assignment) and vault-b
|
|
5166
|
+
// (post-admin-update assignment) are registered. PR 4 doesn't ship
|
|
5167
|
+
// a PATCH endpoint, so we use the same direct UPDATE the design
|
|
5168
|
+
// anticipates an admin path would call.
|
|
5169
|
+
const twoVaultManifest: ServicesManifest = {
|
|
5170
|
+
services: [
|
|
5171
|
+
{
|
|
5172
|
+
name: "parachute-vault",
|
|
5173
|
+
port: 1940,
|
|
5174
|
+
paths: ["/vault/vault-a", "/vault/vault-b"],
|
|
5175
|
+
health: "/health",
|
|
5176
|
+
version: "0.3.0",
|
|
5177
|
+
},
|
|
5178
|
+
],
|
|
5179
|
+
};
|
|
5180
|
+
|
|
5181
|
+
// Step 1: initial OAuth dance + token mint. Asserts vault_scope=["vault-a"].
|
|
5182
|
+
const consentForm = new URLSearchParams({
|
|
5183
|
+
__action: "consent",
|
|
5184
|
+
__csrf: TEST_CSRF,
|
|
5185
|
+
approve: "yes",
|
|
5186
|
+
client_id: reg.client.clientId,
|
|
5187
|
+
redirect_uri: "https://app.example/cb",
|
|
5188
|
+
response_type: "code",
|
|
5189
|
+
scope: "vault:read",
|
|
5190
|
+
code_challenge: challenge,
|
|
5191
|
+
code_challenge_method: "S256",
|
|
5192
|
+
vault_pick: "vault-a",
|
|
5193
|
+
});
|
|
5194
|
+
const consentRes = await handleAuthorizePost(
|
|
5195
|
+
db,
|
|
5196
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
5197
|
+
method: "POST",
|
|
5198
|
+
body: consentForm,
|
|
5199
|
+
headers: {
|
|
5200
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
5201
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
|
|
5202
|
+
},
|
|
5203
|
+
}),
|
|
5204
|
+
{ issuer: ISSUER, loadServicesManifest: () => twoVaultManifest },
|
|
5205
|
+
);
|
|
5206
|
+
const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
|
|
5207
|
+
const tokenRes = await handleToken(
|
|
5208
|
+
db,
|
|
5209
|
+
new Request(`${ISSUER}/oauth/token`, {
|
|
5210
|
+
method: "POST",
|
|
5211
|
+
body: new URLSearchParams({
|
|
5212
|
+
grant_type: "authorization_code",
|
|
5213
|
+
code: code ?? "",
|
|
5214
|
+
client_id: reg.client.clientId,
|
|
5215
|
+
redirect_uri: "https://app.example/cb",
|
|
5216
|
+
code_verifier: verifier,
|
|
5217
|
+
}),
|
|
5218
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
5219
|
+
}),
|
|
5220
|
+
{ issuer: ISSUER, loadServicesManifest: () => twoVaultManifest },
|
|
5221
|
+
);
|
|
5222
|
+
const tokenBody = (await tokenRes.json()) as {
|
|
5223
|
+
access_token: string;
|
|
5224
|
+
refresh_token: string;
|
|
5225
|
+
};
|
|
5226
|
+
const initial = await validateAccessToken(db, tokenBody.access_token, ISSUER);
|
|
5227
|
+
expect(initial.payload.vault_scope).toEqual(["vault-a"]);
|
|
5228
|
+
expect(initial.payload.scope).toBe("vault:vault-a:read");
|
|
5229
|
+
|
|
5230
|
+
// Step 2: admin updates bob's assigned_vault to vault-b. Direct UPDATE
|
|
5231
|
+
// because Phase 1 has no PATCH endpoint; same effect a future admin
|
|
5232
|
+
// path would have. The refresh path reads the live row at mint time
|
|
5233
|
+
// (`vaultScopeForUser`), so the next refresh should pick up the new
|
|
5234
|
+
// value.
|
|
5235
|
+
db.prepare("UPDATE users SET assigned_vault = ? WHERE id = ?").run("vault-b", bob.id);
|
|
5236
|
+
|
|
5237
|
+
// Step 3: refresh the token. vault_scope should be ["vault-b"] (the
|
|
5238
|
+
// new live value); the `scope` claim stays narrowed to the original
|
|
5239
|
+
// vault (auth-code grant snapshotted it onto the refresh-token row).
|
|
5240
|
+
const refreshRes = await handleToken(
|
|
5241
|
+
db,
|
|
5242
|
+
new Request(`${ISSUER}/oauth/token`, {
|
|
5243
|
+
method: "POST",
|
|
5244
|
+
body: new URLSearchParams({
|
|
5245
|
+
grant_type: "refresh_token",
|
|
5246
|
+
refresh_token: tokenBody.refresh_token,
|
|
5247
|
+
client_id: reg.client.clientId,
|
|
5248
|
+
}),
|
|
5249
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
5250
|
+
}),
|
|
5251
|
+
{ issuer: ISSUER, loadServicesManifest: () => twoVaultManifest },
|
|
5252
|
+
);
|
|
5253
|
+
expect(refreshRes.status).toBe(200);
|
|
5254
|
+
const refreshBody = (await refreshRes.json()) as { access_token: string };
|
|
5255
|
+
const refreshed = await validateAccessToken(db, refreshBody.access_token, ISSUER);
|
|
5256
|
+
expect(refreshed.payload.vault_scope).toEqual(["vault-b"]);
|
|
5257
|
+
// The `scope` claim is still bound to the original consent — the
|
|
5258
|
+
// refresh-token row carries `vault:vault-a:read` and the rotation
|
|
5259
|
+
// preserves it. PR 5 will be the side that enforces "your access
|
|
5260
|
+
// tokens for the old vault stop working when the assignment moves";
|
|
5261
|
+
// PR 4 just emits the informational claim correctly.
|
|
5262
|
+
expect(refreshed.payload.scope).toBe("vault:vault-a:read");
|
|
5263
|
+
} finally {
|
|
5264
|
+
cleanup();
|
|
5265
|
+
}
|
|
5266
|
+
});
|
|
5267
|
+
});
|