@openparachute/hub 0.3.0-rc.1 → 0.5.1

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.
Files changed (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
@@ -0,0 +1,3112 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { registerClient } from "../clients.ts";
7
+ import { CSRF_COOKIE_NAME } from "../csrf.ts";
8
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
9
+ import { validateAccessToken } from "../jwt-sign.ts";
10
+ import {
11
+ authorizationServerMetadata,
12
+ buildServicesCatalog,
13
+ handleAuthorizeGet,
14
+ handleAuthorizePost,
15
+ handleRegister,
16
+ handleRevoke,
17
+ handleToken,
18
+ } from "../oauth-handlers.ts";
19
+ import type { ServicesManifest } from "../services-manifest.ts";
20
+ import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
21
+ import { createUser } from "../users.ts";
22
+
23
+ const ISSUER = "https://hub.example";
24
+ const TEST_CSRF = "csrf-test-token";
25
+ const CSRF_COOKIE = `${CSRF_COOKIE_NAME}=${TEST_CSRF}`;
26
+
27
+ async function makeDb() {
28
+ const configDir = mkdtempSync(join(tmpdir(), "phub-oauth-"));
29
+ const db = openHubDb(hubDbPath(configDir));
30
+ return {
31
+ db,
32
+ cleanup: () => {
33
+ db.close();
34
+ rmSync(configDir, { recursive: true, force: true });
35
+ },
36
+ };
37
+ }
38
+
39
+ function makePkce() {
40
+ const verifier = randomBytes(32).toString("base64url");
41
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
42
+ return { verifier, challenge };
43
+ }
44
+
45
+ function authorizeUrl(params: Record<string, string>): string {
46
+ const u = new URL("/oauth/authorize", ISSUER);
47
+ for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
48
+ return u.toString();
49
+ }
50
+
51
+ const FIXTURE_MANIFEST: ServicesManifest = {
52
+ services: [
53
+ {
54
+ name: "parachute-vault",
55
+ port: 1940,
56
+ paths: ["/vault/default"],
57
+ health: "/health",
58
+ version: "0.3.0",
59
+ },
60
+ {
61
+ name: "parachute-scribe",
62
+ port: 1943,
63
+ paths: ["/scribe"],
64
+ health: "/health",
65
+ version: "0.3.0-rc.1",
66
+ },
67
+ {
68
+ name: "parachute-notes",
69
+ port: 1942,
70
+ paths: ["/notes"],
71
+ health: "/notes/health",
72
+ version: "0.3.0",
73
+ },
74
+ ],
75
+ };
76
+
77
+ function fixtureLoadServicesManifest(): ServicesManifest {
78
+ return FIXTURE_MANIFEST;
79
+ }
80
+
81
+ describe("authorizationServerMetadata", () => {
82
+ test("emits RFC 8414 fields rooted at the issuer", async () => {
83
+ const res = authorizationServerMetadata({ issuer: ISSUER });
84
+ expect(res.status).toBe(200);
85
+ const body = (await res.json()) as Record<string, unknown>;
86
+ expect(body.issuer).toBe(ISSUER);
87
+ expect(body.authorization_endpoint).toBe(`${ISSUER}/oauth/authorize`);
88
+ expect(body.token_endpoint).toBe(`${ISSUER}/oauth/token`);
89
+ expect(body.registration_endpoint).toBe(`${ISSUER}/oauth/register`);
90
+ expect(body.jwks_uri).toBe(`${ISSUER}/.well-known/jwks.json`);
91
+ expect(body.code_challenge_methods_supported).toEqual(["S256"]);
92
+ expect(body.grant_types_supported).toContain("authorization_code");
93
+ expect(body.grant_types_supported).toContain("refresh_token");
94
+ // closes #68 — scopes_supported populated from FIRST_PARTY_SCOPES
95
+ const scopesSupported = body.scopes_supported as string[];
96
+ expect(scopesSupported).toContain("vault:read");
97
+ expect(scopesSupported).toContain("vault:admin");
98
+ expect(scopesSupported).toContain("scribe:transcribe");
99
+ expect(scopesSupported).toContain("hub:admin");
100
+ });
101
+
102
+ test("does NOT advertise non-requestable operator-only scopes", async () => {
103
+ // #96: parachute:host:admin is operator-only. RFC 8414 §2 frames
104
+ // scopes_supported as scopes a client *can* request — advertising what
105
+ // we always reject would mislead clients.
106
+ const res = authorizationServerMetadata({ issuer: ISSUER });
107
+ const body = (await res.json()) as Record<string, unknown>;
108
+ const scopesSupported = body.scopes_supported as string[];
109
+ expect(scopesSupported).not.toContain("parachute:host:admin");
110
+ });
111
+
112
+ test("advertises third-party module scopes from loadDeclaredScopes", async () => {
113
+ // #91: scopes_supported pulls from `loadDeclaredScopes()` (FIRST_PARTY ∪
114
+ // each registered module's `scopes.defines`) so standards-following
115
+ // clients discover third-party scopes the same way they discover
116
+ // first-party ones. The token-issuance path already uses
117
+ // loadDeclaredScopes (#90); the AS metadata had to follow or its public
118
+ // advertisement would be a strict subset of what it'll actually sign.
119
+ const declared = new Set<string>([
120
+ "vault:read",
121
+ "vault:admin",
122
+ "hub:admin",
123
+ "parachute:host:admin", // declared but operator-only — must still be filtered
124
+ "agent:read",
125
+ "agent:write",
126
+ "mymodule:do-thing",
127
+ ]);
128
+ const res = authorizationServerMetadata({
129
+ issuer: ISSUER,
130
+ loadDeclaredScopes: () => declared,
131
+ });
132
+ const body = (await res.json()) as Record<string, unknown>;
133
+ const scopesSupported = body.scopes_supported as string[];
134
+ // Third-party scopes show up
135
+ expect(scopesSupported).toContain("agent:read");
136
+ expect(scopesSupported).toContain("agent:write");
137
+ expect(scopesSupported).toContain("mymodule:do-thing");
138
+ // First-party still advertised — no regression
139
+ expect(scopesSupported).toContain("vault:read");
140
+ expect(scopesSupported).toContain("vault:admin");
141
+ expect(scopesSupported).toContain("hub:admin");
142
+ // NON_REQUESTABLE filter still applies even when the scope is declared
143
+ expect(scopesSupported).not.toContain("parachute:host:admin");
144
+ });
145
+ });
146
+
147
+ describe("handleAuthorizeGet", () => {
148
+ test("renders login form when no session cookie is present", async () => {
149
+ const { db, cleanup } = await makeDb();
150
+ try {
151
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
152
+ const { challenge } = makePkce();
153
+ const req = new Request(
154
+ authorizeUrl({
155
+ client_id: reg.client.clientId,
156
+ redirect_uri: "https://app.example/cb",
157
+ response_type: "code",
158
+ code_challenge: challenge,
159
+ code_challenge_method: "S256",
160
+ scope: "vault:read",
161
+ state: "xyz",
162
+ }),
163
+ );
164
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
165
+ expect(res.status).toBe(200);
166
+ const html = await res.text();
167
+ expect(html).toContain("Sign in");
168
+ expect(html).toContain('name="__action" value="login"');
169
+ // State + redirect_uri must be echoed via hidden inputs.
170
+ expect(html).toContain('name="state" value="xyz"');
171
+ expect(html).toContain('name="redirect_uri" value="https://app.example/cb"');
172
+ } finally {
173
+ cleanup();
174
+ }
175
+ });
176
+
177
+ test("renders consent screen when session is valid", async () => {
178
+ const { db, cleanup } = await makeDb();
179
+ try {
180
+ const user = await createUser(db, "owner", "pw");
181
+ const session = createSession(db, { userId: user.id });
182
+ const reg = registerClient(db, {
183
+ redirectUris: ["https://app.example/cb"],
184
+ clientName: "MyApp",
185
+ });
186
+ const { challenge } = makePkce();
187
+ const req = new Request(
188
+ authorizeUrl({
189
+ client_id: reg.client.clientId,
190
+ redirect_uri: "https://app.example/cb",
191
+ response_type: "code",
192
+ code_challenge: challenge,
193
+ code_challenge_method: "S256",
194
+ scope: "vault:read",
195
+ }),
196
+ {
197
+ headers: {
198
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
199
+ },
200
+ },
201
+ );
202
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
203
+ expect(res.status).toBe(200);
204
+ const html = await res.text();
205
+ expect(html).toContain("Authorize");
206
+ expect(html).toContain("MyApp");
207
+ expect(html).toContain("vault:read");
208
+ expect(html).toContain('name="__action" value="consent"');
209
+ } finally {
210
+ cleanup();
211
+ }
212
+ });
213
+
214
+ test("rejects unknown client_id with 400", async () => {
215
+ const { db, cleanup } = await makeDb();
216
+ try {
217
+ const { challenge } = makePkce();
218
+ const req = new Request(
219
+ authorizeUrl({
220
+ client_id: "no-such-client",
221
+ redirect_uri: "https://app.example/cb",
222
+ response_type: "code",
223
+ code_challenge: challenge,
224
+ code_challenge_method: "S256",
225
+ }),
226
+ );
227
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
228
+ expect(res.status).toBe(400);
229
+ } finally {
230
+ cleanup();
231
+ }
232
+ });
233
+
234
+ test("rejects redirect_uri not registered for this client", async () => {
235
+ const { db, cleanup } = await makeDb();
236
+ try {
237
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
238
+ const { challenge } = makePkce();
239
+ const req = new Request(
240
+ authorizeUrl({
241
+ client_id: reg.client.clientId,
242
+ redirect_uri: "https://evil.example/cb",
243
+ response_type: "code",
244
+ code_challenge: challenge,
245
+ code_challenge_method: "S256",
246
+ }),
247
+ );
248
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
249
+ expect(res.status).toBe(400);
250
+ } finally {
251
+ cleanup();
252
+ }
253
+ });
254
+
255
+ test("rejects parachute:host:admin scope with invalid_scope redirect (#96)", async () => {
256
+ // Operator-only scopes — third-party apps cannot mint them via the
257
+ // public flow. Per RFC 6749 §4.1.2.1, scope failures redirect to the
258
+ // registered redirect_uri with error=invalid_scope, not an HTML error.
259
+ const { db, cleanup } = await makeDb();
260
+ try {
261
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
262
+ const { challenge } = makePkce();
263
+ const req = new Request(
264
+ authorizeUrl({
265
+ client_id: reg.client.clientId,
266
+ redirect_uri: "https://app.example/cb",
267
+ response_type: "code",
268
+ code_challenge: challenge,
269
+ code_challenge_method: "S256",
270
+ scope: "vault:read parachute:host:admin",
271
+ state: "abc",
272
+ }),
273
+ );
274
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
275
+ expect(res.status).toBe(302);
276
+ const loc = new URL(res.headers.get("location") ?? "");
277
+ expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
278
+ expect(loc.searchParams.get("error")).toBe("invalid_scope");
279
+ expect(loc.searchParams.get("error_description")).toContain("parachute:host:admin");
280
+ expect(loc.searchParams.get("state")).toBe("abc");
281
+ } finally {
282
+ cleanup();
283
+ }
284
+ });
285
+
286
+ test("rejects code_challenge_method=plain (PKCE S256 mandatory)", async () => {
287
+ const { db, cleanup } = await makeDb();
288
+ try {
289
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
290
+ const req = new Request(
291
+ authorizeUrl({
292
+ client_id: reg.client.clientId,
293
+ redirect_uri: "https://app.example/cb",
294
+ response_type: "code",
295
+ code_challenge: "challenge",
296
+ code_challenge_method: "plain",
297
+ }),
298
+ );
299
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
300
+ expect(res.status).toBe(302);
301
+ const loc = res.headers.get("location");
302
+ expect(loc).toContain("error=invalid_request");
303
+ } finally {
304
+ cleanup();
305
+ }
306
+ });
307
+ });
308
+
309
+ // Q1 of 2026-04-28-vault-config-and-scopes.md: an unnamed `vault:<verb>` is
310
+ // ambiguous, so the consent screen forces the operator to pick a vault before
311
+ // the JWT is minted. Picked vault rewrites the scope to `vault:<picked>:<verb>`
312
+ // and stamps `aud=vault.<picked>` so vault's strict per-resource enforcement
313
+ // (Phase 1) can match the audience against the URL-derived vault name.
314
+ describe("handleAuthorizeGet — vault picker", () => {
315
+ test("renders the picker when scope is unnamed vault:<verb>", async () => {
316
+ const { db, cleanup } = await makeDb();
317
+ try {
318
+ const user = await createUser(db, "owner", "pw");
319
+ const session = createSession(db, { userId: user.id });
320
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
321
+ const { challenge } = makePkce();
322
+ const req = new Request(
323
+ authorizeUrl({
324
+ client_id: reg.client.clientId,
325
+ redirect_uri: "https://app.example/cb",
326
+ response_type: "code",
327
+ code_challenge: challenge,
328
+ code_challenge_method: "S256",
329
+ scope: "vault:read",
330
+ }),
331
+ {
332
+ headers: {
333
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
334
+ },
335
+ },
336
+ );
337
+ const res = handleAuthorizeGet(db, req, {
338
+ issuer: ISSUER,
339
+ loadServicesManifest: fixtureLoadServicesManifest,
340
+ });
341
+ expect(res.status).toBe(200);
342
+ const html = await res.text();
343
+ expect(html).toContain("Pick a vault");
344
+ // The fixture manifest's `parachute-vault` has paths `["/vault/default"]`
345
+ // — that's the one available vault in the picker.
346
+ expect(html).toContain('name="vault_pick" value="default"');
347
+ } finally {
348
+ cleanup();
349
+ }
350
+ });
351
+
352
+ test("picker is omitted when scope is already named vault:<name>:<verb>", async () => {
353
+ const { db, cleanup } = await makeDb();
354
+ try {
355
+ const user = await createUser(db, "owner", "pw");
356
+ const session = createSession(db, { userId: user.id });
357
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
358
+ const { challenge } = makePkce();
359
+ const req = new Request(
360
+ authorizeUrl({
361
+ client_id: reg.client.clientId,
362
+ redirect_uri: "https://app.example/cb",
363
+ response_type: "code",
364
+ code_challenge: challenge,
365
+ code_challenge_method: "S256",
366
+ scope: "vault:work:read",
367
+ }),
368
+ {
369
+ headers: {
370
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
371
+ },
372
+ },
373
+ );
374
+ const res = handleAuthorizeGet(db, req, {
375
+ issuer: ISSUER,
376
+ loadServicesManifest: fixtureLoadServicesManifest,
377
+ });
378
+ expect(res.status).toBe(200);
379
+ const html = await res.text();
380
+ expect(html).not.toContain("Pick a vault");
381
+ expect(html).not.toContain('name="vault_pick"');
382
+ } finally {
383
+ cleanup();
384
+ }
385
+ });
386
+
387
+ test("picker shows a help message and disables Approve when no vaults exist", async () => {
388
+ const { db, cleanup } = await makeDb();
389
+ try {
390
+ const user = await createUser(db, "owner", "pw");
391
+ const session = createSession(db, { userId: user.id });
392
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
393
+ const { challenge } = makePkce();
394
+ const req = new Request(
395
+ authorizeUrl({
396
+ client_id: reg.client.clientId,
397
+ redirect_uri: "https://app.example/cb",
398
+ response_type: "code",
399
+ code_challenge: challenge,
400
+ code_challenge_method: "S256",
401
+ scope: "vault:read",
402
+ }),
403
+ {
404
+ headers: {
405
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
406
+ },
407
+ },
408
+ );
409
+ const res = handleAuthorizeGet(db, req, {
410
+ issuer: ISSUER,
411
+ loadServicesManifest: () => ({ services: [] }),
412
+ });
413
+ expect(res.status).toBe(200);
414
+ const html = await res.text();
415
+ expect(html).toContain("Pick a vault");
416
+ expect(html).toContain("no vaults exist");
417
+ expect(html).toContain('name="approve" value="yes" class="btn btn-primary" disabled');
418
+ } finally {
419
+ cleanup();
420
+ }
421
+ });
422
+ });
423
+
424
+ describe("handleAuthorizePost — vault picker", () => {
425
+ test("approve with vault_pick narrows vault:read → vault:<picked>:read in the issued JWT", async () => {
426
+ const { db, cleanup } = await makeDb();
427
+ try {
428
+ const user = await createUser(db, "owner", "pw");
429
+ const session = createSession(db, { userId: user.id });
430
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
431
+ const { verifier, challenge } = makePkce();
432
+ const consentForm = new URLSearchParams({
433
+ __action: "consent",
434
+ __csrf: TEST_CSRF,
435
+ approve: "yes",
436
+ client_id: reg.client.clientId,
437
+ redirect_uri: "https://app.example/cb",
438
+ response_type: "code",
439
+ scope: "vault:read",
440
+ code_challenge: challenge,
441
+ code_challenge_method: "S256",
442
+ vault_pick: "default",
443
+ });
444
+ const consentReq = new Request(`${ISSUER}/oauth/authorize`, {
445
+ method: "POST",
446
+ body: consentForm,
447
+ headers: {
448
+ "content-type": "application/x-www-form-urlencoded",
449
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
450
+ },
451
+ });
452
+ const consentRes = await handleAuthorizePost(db, consentReq, {
453
+ issuer: ISSUER,
454
+ loadServicesManifest: fixtureLoadServicesManifest,
455
+ });
456
+ expect(consentRes.status).toBe(302);
457
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
458
+ expect(code).toBeTruthy();
459
+
460
+ const tokenForm = new URLSearchParams({
461
+ grant_type: "authorization_code",
462
+ code: code ?? "",
463
+ client_id: reg.client.clientId,
464
+ redirect_uri: "https://app.example/cb",
465
+ code_verifier: verifier,
466
+ });
467
+ const tokenRes = await handleToken(
468
+ db,
469
+ new Request(`${ISSUER}/oauth/token`, {
470
+ method: "POST",
471
+ body: tokenForm,
472
+ headers: { "content-type": "application/x-www-form-urlencoded" },
473
+ }),
474
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
475
+ );
476
+ expect(tokenRes.status).toBe(200);
477
+ const body = (await tokenRes.json()) as { access_token: string; scope: string };
478
+ expect(body.scope).toBe("vault:default:read");
479
+
480
+ const { payload } = await validateAccessToken(db, body.access_token, ISSUER);
481
+ expect(payload.aud).toBe("vault.default");
482
+ expect(payload.scope).toBe("vault:default:read");
483
+ } finally {
484
+ cleanup();
485
+ }
486
+ });
487
+
488
+ test("approve without vault_pick on unnamed vault scope fails 400", async () => {
489
+ const { db, cleanup } = await makeDb();
490
+ try {
491
+ const user = await createUser(db, "owner", "pw");
492
+ const session = createSession(db, { userId: user.id });
493
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
494
+ const { challenge } = makePkce();
495
+ const consentForm = new URLSearchParams({
496
+ __action: "consent",
497
+ __csrf: TEST_CSRF,
498
+ approve: "yes",
499
+ client_id: reg.client.clientId,
500
+ redirect_uri: "https://app.example/cb",
501
+ response_type: "code",
502
+ scope: "vault:read",
503
+ code_challenge: challenge,
504
+ code_challenge_method: "S256",
505
+ });
506
+ const res = await handleAuthorizePost(
507
+ db,
508
+ new Request(`${ISSUER}/oauth/authorize`, {
509
+ method: "POST",
510
+ body: consentForm,
511
+ headers: {
512
+ "content-type": "application/x-www-form-urlencoded",
513
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
514
+ },
515
+ }),
516
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
517
+ );
518
+ expect(res.status).toBe(400);
519
+ const html = await res.text();
520
+ expect(html).toContain("Pick a vault");
521
+ } finally {
522
+ cleanup();
523
+ }
524
+ });
525
+
526
+ test("approve with vault_pick that names an unknown vault fails 400", async () => {
527
+ const { db, cleanup } = await makeDb();
528
+ try {
529
+ const user = await createUser(db, "owner", "pw");
530
+ const session = createSession(db, { userId: user.id });
531
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
532
+ const { challenge } = makePkce();
533
+ const consentForm = new URLSearchParams({
534
+ __action: "consent",
535
+ __csrf: TEST_CSRF,
536
+ approve: "yes",
537
+ client_id: reg.client.clientId,
538
+ redirect_uri: "https://app.example/cb",
539
+ response_type: "code",
540
+ scope: "vault:read",
541
+ code_challenge: challenge,
542
+ code_challenge_method: "S256",
543
+ vault_pick: "evil-vault",
544
+ });
545
+ const res = await handleAuthorizePost(
546
+ db,
547
+ new Request(`${ISSUER}/oauth/authorize`, {
548
+ method: "POST",
549
+ body: consentForm,
550
+ headers: {
551
+ "content-type": "application/x-www-form-urlencoded",
552
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
553
+ },
554
+ }),
555
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
556
+ );
557
+ expect(res.status).toBe(400);
558
+ const html = await res.text();
559
+ expect(html).toContain("Unknown vault");
560
+ } finally {
561
+ cleanup();
562
+ }
563
+ });
564
+
565
+ test("multiple unnamed verbs are all narrowed to the picked vault", async () => {
566
+ const { db, cleanup } = await makeDb();
567
+ try {
568
+ const user = await createUser(db, "owner", "pw");
569
+ const session = createSession(db, { userId: user.id });
570
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
571
+ const { verifier, challenge } = makePkce();
572
+ const consentForm = new URLSearchParams({
573
+ __action: "consent",
574
+ __csrf: TEST_CSRF,
575
+ approve: "yes",
576
+ client_id: reg.client.clientId,
577
+ redirect_uri: "https://app.example/cb",
578
+ response_type: "code",
579
+ scope: "vault:read vault:write",
580
+ code_challenge: challenge,
581
+ code_challenge_method: "S256",
582
+ vault_pick: "default",
583
+ });
584
+ const consentRes = await handleAuthorizePost(
585
+ db,
586
+ new Request(`${ISSUER}/oauth/authorize`, {
587
+ method: "POST",
588
+ body: consentForm,
589
+ headers: {
590
+ "content-type": "application/x-www-form-urlencoded",
591
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
592
+ },
593
+ }),
594
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
595
+ );
596
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
597
+ const tokenRes = await handleToken(
598
+ db,
599
+ new Request(`${ISSUER}/oauth/token`, {
600
+ method: "POST",
601
+ body: new URLSearchParams({
602
+ grant_type: "authorization_code",
603
+ code: code ?? "",
604
+ client_id: reg.client.clientId,
605
+ redirect_uri: "https://app.example/cb",
606
+ code_verifier: verifier,
607
+ }),
608
+ headers: { "content-type": "application/x-www-form-urlencoded" },
609
+ }),
610
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
611
+ );
612
+ expect(tokenRes.status).toBe(200);
613
+ const body = (await tokenRes.json()) as { scope: string };
614
+ expect(body.scope).toBe("vault:default:read vault:default:write");
615
+ } finally {
616
+ cleanup();
617
+ }
618
+ });
619
+ });
620
+
621
+ describe("handleAuthorizePost — login submit", () => {
622
+ test("sets session cookie and redirects to GET on valid credentials", async () => {
623
+ const { db, cleanup } = await makeDb();
624
+ try {
625
+ await createUser(db, "owner", "hunter2");
626
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
627
+ const { challenge } = makePkce();
628
+ const form = new URLSearchParams({
629
+ __action: "login",
630
+ __csrf: TEST_CSRF,
631
+ username: "owner",
632
+ password: "hunter2",
633
+ client_id: reg.client.clientId,
634
+ redirect_uri: "https://app.example/cb",
635
+ response_type: "code",
636
+ scope: "vault:read",
637
+ code_challenge: challenge,
638
+ code_challenge_method: "S256",
639
+ });
640
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
641
+ method: "POST",
642
+ body: form,
643
+ headers: {
644
+ "content-type": "application/x-www-form-urlencoded",
645
+ cookie: CSRF_COOKIE,
646
+ },
647
+ });
648
+ const res = await handleAuthorizePost(db, req, { issuer: ISSUER });
649
+ expect(res.status).toBe(302);
650
+ expect(res.headers.get("location")).toContain("/oauth/authorize?");
651
+ const cookie = res.headers.get("set-cookie");
652
+ expect(cookie).toContain("parachute_hub_session=");
653
+ expect(cookie).toContain("HttpOnly");
654
+ } finally {
655
+ cleanup();
656
+ }
657
+ });
658
+
659
+ test("rejects bad password with 401, no cookie", async () => {
660
+ const { db, cleanup } = await makeDb();
661
+ try {
662
+ await createUser(db, "owner", "hunter2");
663
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
664
+ const { challenge } = makePkce();
665
+ const form = new URLSearchParams({
666
+ __action: "login",
667
+ __csrf: TEST_CSRF,
668
+ username: "owner",
669
+ password: "wrong",
670
+ client_id: reg.client.clientId,
671
+ redirect_uri: "https://app.example/cb",
672
+ response_type: "code",
673
+ code_challenge: challenge,
674
+ code_challenge_method: "S256",
675
+ });
676
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
677
+ method: "POST",
678
+ body: form,
679
+ headers: {
680
+ "content-type": "application/x-www-form-urlencoded",
681
+ cookie: CSRF_COOKIE,
682
+ },
683
+ });
684
+ const res = await handleAuthorizePost(db, req, { issuer: ISSUER });
685
+ expect(res.status).toBe(401);
686
+ expect(res.headers.get("set-cookie")).toBeNull();
687
+ } finally {
688
+ cleanup();
689
+ }
690
+ });
691
+ });
692
+
693
+ describe("handleAuthorizePost — CSRF protection", () => {
694
+ test("rejects POST when CSRF cookie is absent", async () => {
695
+ const { db, cleanup } = await makeDb();
696
+ try {
697
+ await createUser(db, "owner", "hunter2");
698
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
699
+ const { challenge } = makePkce();
700
+ const form = new URLSearchParams({
701
+ __action: "login",
702
+ __csrf: TEST_CSRF,
703
+ username: "owner",
704
+ password: "hunter2",
705
+ client_id: reg.client.clientId,
706
+ redirect_uri: "https://app.example/cb",
707
+ response_type: "code",
708
+ scope: "vault:read",
709
+ code_challenge: challenge,
710
+ code_challenge_method: "S256",
711
+ });
712
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
713
+ method: "POST",
714
+ body: form,
715
+ headers: { "content-type": "application/x-www-form-urlencoded" },
716
+ });
717
+ const res = await handleAuthorizePost(db, req, { issuer: ISSUER });
718
+ expect(res.status).toBe(400);
719
+ expect(await res.text()).toContain("Invalid form submission");
720
+ } finally {
721
+ cleanup();
722
+ }
723
+ });
724
+
725
+ test("rejects POST when CSRF form field is absent", async () => {
726
+ const { db, cleanup } = await makeDb();
727
+ try {
728
+ await createUser(db, "owner", "hunter2");
729
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
730
+ const { challenge } = makePkce();
731
+ const form = new URLSearchParams({
732
+ __action: "login",
733
+ username: "owner",
734
+ password: "hunter2",
735
+ client_id: reg.client.clientId,
736
+ redirect_uri: "https://app.example/cb",
737
+ response_type: "code",
738
+ scope: "vault:read",
739
+ code_challenge: challenge,
740
+ code_challenge_method: "S256",
741
+ });
742
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
743
+ method: "POST",
744
+ body: form,
745
+ headers: {
746
+ "content-type": "application/x-www-form-urlencoded",
747
+ cookie: CSRF_COOKIE,
748
+ },
749
+ });
750
+ const res = await handleAuthorizePost(db, req, { issuer: ISSUER });
751
+ expect(res.status).toBe(400);
752
+ } finally {
753
+ cleanup();
754
+ }
755
+ });
756
+
757
+ test("rejects POST when CSRF cookie and form field do not match", async () => {
758
+ const { db, cleanup } = await makeDb();
759
+ try {
760
+ await createUser(db, "owner", "hunter2");
761
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
762
+ const { challenge } = makePkce();
763
+ const form = new URLSearchParams({
764
+ __action: "login",
765
+ __csrf: "different-token",
766
+ username: "owner",
767
+ password: "hunter2",
768
+ client_id: reg.client.clientId,
769
+ redirect_uri: "https://app.example/cb",
770
+ response_type: "code",
771
+ scope: "vault:read",
772
+ code_challenge: challenge,
773
+ code_challenge_method: "S256",
774
+ });
775
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
776
+ method: "POST",
777
+ body: form,
778
+ headers: {
779
+ "content-type": "application/x-www-form-urlencoded",
780
+ cookie: CSRF_COOKIE,
781
+ },
782
+ });
783
+ const res = await handleAuthorizePost(db, req, { issuer: ISSUER });
784
+ expect(res.status).toBe(400);
785
+ } finally {
786
+ cleanup();
787
+ }
788
+ });
789
+
790
+ test("GET /oauth/authorize sets CSRF cookie when none is present", async () => {
791
+ const { db, cleanup } = await makeDb();
792
+ try {
793
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
794
+ const { challenge } = makePkce();
795
+ const url = authorizeUrl({
796
+ client_id: reg.client.clientId,
797
+ redirect_uri: "https://app.example/cb",
798
+ response_type: "code",
799
+ scope: "vault:read",
800
+ code_challenge: challenge,
801
+ code_challenge_method: "S256",
802
+ });
803
+ const res = handleAuthorizeGet(db, new Request(url), { issuer: ISSUER });
804
+ expect(res.status).toBe(200);
805
+ const setCookie = res.headers.get("set-cookie") ?? "";
806
+ expect(setCookie).toContain(`${CSRF_COOKIE_NAME}=`);
807
+ expect(setCookie).toContain("HttpOnly");
808
+ // The rendered form must echo the same token as a hidden input.
809
+ const html = await res.text();
810
+ expect(html).toContain('name="__csrf"');
811
+ } finally {
812
+ cleanup();
813
+ }
814
+ });
815
+
816
+ test("GET /oauth/authorize reuses an existing CSRF cookie", async () => {
817
+ const { db, cleanup } = await makeDb();
818
+ try {
819
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
820
+ const { challenge } = makePkce();
821
+ const url = authorizeUrl({
822
+ client_id: reg.client.clientId,
823
+ redirect_uri: "https://app.example/cb",
824
+ response_type: "code",
825
+ scope: "vault:read",
826
+ code_challenge: challenge,
827
+ code_challenge_method: "S256",
828
+ });
829
+ const res = handleAuthorizeGet(db, new Request(url, { headers: { cookie: CSRF_COOKIE } }), {
830
+ issuer: ISSUER,
831
+ });
832
+ expect(res.status).toBe(200);
833
+ // No new cookie minted when one already exists.
834
+ expect(res.headers.get("set-cookie")).toBeNull();
835
+ const html = await res.text();
836
+ expect(html).toContain(`value="${TEST_CSRF}"`);
837
+ } finally {
838
+ cleanup();
839
+ }
840
+ });
841
+ });
842
+
843
+ describe("handleAuthorizePost — consent submit", () => {
844
+ test("approve issues an auth code and redirects to redirect_uri", async () => {
845
+ const { db, cleanup } = await makeDb();
846
+ try {
847
+ const user = await createUser(db, "owner", "pw");
848
+ const session = createSession(db, { userId: user.id });
849
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
850
+ const { challenge } = makePkce();
851
+ const form = new URLSearchParams({
852
+ __action: "consent",
853
+ __csrf: TEST_CSRF,
854
+ approve: "yes",
855
+ client_id: reg.client.clientId,
856
+ redirect_uri: "https://app.example/cb",
857
+ response_type: "code",
858
+ scope: "vault:default:read",
859
+ code_challenge: challenge,
860
+ code_challenge_method: "S256",
861
+ state: "abc123",
862
+ });
863
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
864
+ method: "POST",
865
+ body: form,
866
+ headers: {
867
+ "content-type": "application/x-www-form-urlencoded",
868
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
869
+ },
870
+ });
871
+ const res = await handleAuthorizePost(db, req, { issuer: ISSUER });
872
+ expect(res.status).toBe(302);
873
+ const loc = new URL(res.headers.get("location") ?? "");
874
+ expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
875
+ expect(loc.searchParams.get("code")?.length).toBeGreaterThan(20);
876
+ expect(loc.searchParams.get("state")).toBe("abc123");
877
+ } finally {
878
+ cleanup();
879
+ }
880
+ });
881
+
882
+ test("rejects parachute:host:admin in form scope (defense-in-depth, #96)", async () => {
883
+ // GET-time gate already rejects, but a hand-crafted POST could carry
884
+ // an operator-only scope. Consent submit must independently reject.
885
+ const { db, cleanup } = await makeDb();
886
+ try {
887
+ const user = await createUser(db, "owner", "pw");
888
+ const session = createSession(db, { userId: user.id });
889
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
890
+ const { challenge } = makePkce();
891
+ const form = new URLSearchParams({
892
+ __action: "consent",
893
+ __csrf: TEST_CSRF,
894
+ approve: "yes",
895
+ client_id: reg.client.clientId,
896
+ redirect_uri: "https://app.example/cb",
897
+ response_type: "code",
898
+ scope: "parachute:host:admin",
899
+ code_challenge: challenge,
900
+ code_challenge_method: "S256",
901
+ state: "abc",
902
+ });
903
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
904
+ method: "POST",
905
+ body: form,
906
+ headers: {
907
+ "content-type": "application/x-www-form-urlencoded",
908
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
909
+ },
910
+ });
911
+ const res = await handleAuthorizePost(db, req, { issuer: ISSUER });
912
+ expect(res.status).toBe(302);
913
+ const loc = new URL(res.headers.get("location") ?? "");
914
+ expect(loc.searchParams.get("error")).toBe("invalid_scope");
915
+ expect(loc.searchParams.get("error_description")).toContain("parachute:host:admin");
916
+ } finally {
917
+ cleanup();
918
+ }
919
+ });
920
+
921
+ test("deny returns access_denied with state echoed", async () => {
922
+ const { db, cleanup } = await makeDb();
923
+ try {
924
+ const user = await createUser(db, "owner", "pw");
925
+ const session = createSession(db, { userId: user.id });
926
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
927
+ const { challenge } = makePkce();
928
+ const form = new URLSearchParams({
929
+ __action: "consent",
930
+ __csrf: TEST_CSRF,
931
+ approve: "no",
932
+ client_id: reg.client.clientId,
933
+ redirect_uri: "https://app.example/cb",
934
+ response_type: "code",
935
+ scope: "vault:read",
936
+ code_challenge: challenge,
937
+ code_challenge_method: "S256",
938
+ state: "abc",
939
+ });
940
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
941
+ method: "POST",
942
+ body: form,
943
+ headers: {
944
+ "content-type": "application/x-www-form-urlencoded",
945
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
946
+ },
947
+ });
948
+ const res = await handleAuthorizePost(db, req, { issuer: ISSUER });
949
+ expect(res.status).toBe(302);
950
+ const loc = new URL(res.headers.get("location") ?? "");
951
+ expect(loc.searchParams.get("error")).toBe("access_denied");
952
+ expect(loc.searchParams.get("state")).toBe("abc");
953
+ } finally {
954
+ cleanup();
955
+ }
956
+ });
957
+ });
958
+
959
+ describe("handleToken — full OAuth dance", () => {
960
+ test("authorize → token → validate JWT", async () => {
961
+ const { db, cleanup } = await makeDb();
962
+ try {
963
+ const user = await createUser(db, "owner", "pw");
964
+ const session = createSession(db, { userId: user.id });
965
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
966
+ const { verifier, challenge } = makePkce();
967
+
968
+ // Approve consent → auth code lands in redirect_uri.
969
+ const consentForm = new URLSearchParams({
970
+ __action: "consent",
971
+ __csrf: TEST_CSRF,
972
+ approve: "yes",
973
+ client_id: reg.client.clientId,
974
+ redirect_uri: "https://app.example/cb",
975
+ response_type: "code",
976
+ scope: "vault:default:read",
977
+ code_challenge: challenge,
978
+ code_challenge_method: "S256",
979
+ });
980
+ const consentReq = new Request(`${ISSUER}/oauth/authorize`, {
981
+ method: "POST",
982
+ body: consentForm,
983
+ headers: {
984
+ "content-type": "application/x-www-form-urlencoded",
985
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
986
+ },
987
+ });
988
+ const consentRes = await handleAuthorizePost(db, consentReq, { issuer: ISSUER });
989
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
990
+ expect(code).toBeTruthy();
991
+
992
+ // Redeem.
993
+ const tokenForm = new URLSearchParams({
994
+ grant_type: "authorization_code",
995
+ code: code ?? "",
996
+ client_id: reg.client.clientId,
997
+ redirect_uri: "https://app.example/cb",
998
+ code_verifier: verifier,
999
+ });
1000
+ const tokenReq = new Request(`${ISSUER}/oauth/token`, {
1001
+ method: "POST",
1002
+ body: tokenForm,
1003
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1004
+ });
1005
+ const tokenRes = await handleToken(db, tokenReq, {
1006
+ issuer: ISSUER,
1007
+ loadServicesManifest: fixtureLoadServicesManifest,
1008
+ });
1009
+ expect(tokenRes.status).toBe(200);
1010
+ const tokenBody = (await tokenRes.json()) as {
1011
+ access_token: string;
1012
+ refresh_token: string;
1013
+ token_type: string;
1014
+ expires_in: number;
1015
+ scope: string;
1016
+ services: Record<string, { url: string; version: string }>;
1017
+ };
1018
+ expect(tokenBody.token_type).toBe("Bearer");
1019
+ expect(tokenBody.scope).toBe("vault:default:read");
1020
+ expect(tokenBody.refresh_token.length).toBeGreaterThan(20);
1021
+
1022
+ // JWT must verify against the hub's signing keys, with the right sub +
1023
+ // aud (named `vault:default:read` → "vault.default" — RFC 8707-style
1024
+ // resource binding from the vault-config-and-scopes Phase 1+2 design)
1025
+ // and iss matching the configured issuer (closes #77 — vault rejects
1026
+ // tokens with a missing or mismatched iss).
1027
+ const { payload } = await validateAccessToken(db, tokenBody.access_token, ISSUER);
1028
+ expect(payload.sub).toBe(user.id);
1029
+ expect(payload.aud).toBe("vault.default");
1030
+ expect(payload.iss).toBe(ISSUER);
1031
+ expect(payload.scope).toBe("vault:default:read");
1032
+ expect(payload.client_id).toBe(reg.client.clientId);
1033
+
1034
+ // closes #81 — services catalog tells the client where vault lives so
1035
+ // notes doesn't have to re-probe /.well-known/parachute.json. A
1036
+ // vault:read token only sees the vault entry.
1037
+ expect(tokenBody.services).toEqual({
1038
+ vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1039
+ });
1040
+ } finally {
1041
+ cleanup();
1042
+ }
1043
+ });
1044
+
1045
+ test("auth code is single-use (replay returns invalid_grant)", async () => {
1046
+ const { db, cleanup } = await makeDb();
1047
+ try {
1048
+ const user = await createUser(db, "owner", "pw");
1049
+ const session = createSession(db, { userId: user.id });
1050
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1051
+ const { verifier, challenge } = makePkce();
1052
+ const consentForm = new URLSearchParams({
1053
+ __action: "consent",
1054
+ __csrf: TEST_CSRF,
1055
+ approve: "yes",
1056
+ client_id: reg.client.clientId,
1057
+ redirect_uri: "https://app.example/cb",
1058
+ response_type: "code",
1059
+ scope: "",
1060
+ code_challenge: challenge,
1061
+ code_challenge_method: "S256",
1062
+ });
1063
+ const consentReq = new Request(`${ISSUER}/oauth/authorize`, {
1064
+ method: "POST",
1065
+ body: consentForm,
1066
+ headers: {
1067
+ "content-type": "application/x-www-form-urlencoded",
1068
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
1069
+ },
1070
+ });
1071
+ const consentRes = await handleAuthorizePost(db, consentReq, { issuer: ISSUER });
1072
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
1073
+
1074
+ const exchange = () => {
1075
+ const form = new URLSearchParams({
1076
+ grant_type: "authorization_code",
1077
+ code: code ?? "",
1078
+ client_id: reg.client.clientId,
1079
+ redirect_uri: "https://app.example/cb",
1080
+ code_verifier: verifier,
1081
+ });
1082
+ const req = new Request(`${ISSUER}/oauth/token`, {
1083
+ method: "POST",
1084
+ body: form,
1085
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1086
+ });
1087
+ return handleToken(db, req, { issuer: ISSUER });
1088
+ };
1089
+
1090
+ const first = await exchange();
1091
+ expect(first.status).toBe(200);
1092
+ const second = await exchange();
1093
+ expect(second.status).toBe(400);
1094
+ const err = (await second.json()) as Record<string, unknown>;
1095
+ expect(err.error).toBe("invalid_grant");
1096
+ } finally {
1097
+ cleanup();
1098
+ }
1099
+ });
1100
+
1101
+ test("refresh_token grant rotates the pair and revokes the old refresh", async () => {
1102
+ const { db, cleanup } = await makeDb();
1103
+ try {
1104
+ const user = await createUser(db, "owner", "pw");
1105
+ const session = createSession(db, { userId: user.id });
1106
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1107
+ const { verifier, challenge } = makePkce();
1108
+ const consentForm = new URLSearchParams({
1109
+ __action: "consent",
1110
+ __csrf: TEST_CSRF,
1111
+ approve: "yes",
1112
+ client_id: reg.client.clientId,
1113
+ redirect_uri: "https://app.example/cb",
1114
+ response_type: "code",
1115
+ scope: "vault:default:read",
1116
+ code_challenge: challenge,
1117
+ code_challenge_method: "S256",
1118
+ });
1119
+ const consentReq = new Request(`${ISSUER}/oauth/authorize`, {
1120
+ method: "POST",
1121
+ body: consentForm,
1122
+ headers: {
1123
+ "content-type": "application/x-www-form-urlencoded",
1124
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
1125
+ },
1126
+ });
1127
+ const consentRes = await handleAuthorizePost(db, consentReq, { issuer: ISSUER });
1128
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
1129
+ const tokenForm = new URLSearchParams({
1130
+ grant_type: "authorization_code",
1131
+ code: code ?? "",
1132
+ client_id: reg.client.clientId,
1133
+ redirect_uri: "https://app.example/cb",
1134
+ code_verifier: verifier,
1135
+ });
1136
+ const tokenRes = await handleToken(
1137
+ db,
1138
+ new Request(`${ISSUER}/oauth/token`, {
1139
+ method: "POST",
1140
+ body: tokenForm,
1141
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1142
+ }),
1143
+ { issuer: ISSUER },
1144
+ );
1145
+ const initial = (await tokenRes.json()) as { refresh_token: string };
1146
+
1147
+ const refreshForm = new URLSearchParams({
1148
+ grant_type: "refresh_token",
1149
+ refresh_token: initial.refresh_token,
1150
+ client_id: reg.client.clientId,
1151
+ });
1152
+ const refreshRes = await handleToken(
1153
+ db,
1154
+ new Request(`${ISSUER}/oauth/token`, {
1155
+ method: "POST",
1156
+ body: refreshForm,
1157
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1158
+ }),
1159
+ { issuer: ISSUER },
1160
+ );
1161
+ expect(refreshRes.status).toBe(200);
1162
+ const rotated = (await refreshRes.json()) as { refresh_token: string };
1163
+ expect(rotated.refresh_token).not.toBe(initial.refresh_token);
1164
+
1165
+ // Old refresh token should now fail (revoked).
1166
+ const replayRes = await handleToken(
1167
+ db,
1168
+ new Request(`${ISSUER}/oauth/token`, {
1169
+ method: "POST",
1170
+ body: refreshForm,
1171
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1172
+ }),
1173
+ { issuer: ISSUER },
1174
+ );
1175
+ expect(replayRes.status).toBe(400);
1176
+ const err = (await replayRes.json()) as Record<string, unknown>;
1177
+ expect(err.error).toBe("invalid_grant");
1178
+ } finally {
1179
+ cleanup();
1180
+ }
1181
+ });
1182
+
1183
+ test("client_credentials returns unsupported_grant_type", async () => {
1184
+ const { db, cleanup } = await makeDb();
1185
+ try {
1186
+ const form = new URLSearchParams({ grant_type: "client_credentials" });
1187
+ const req = new Request(`${ISSUER}/oauth/token`, {
1188
+ method: "POST",
1189
+ body: form,
1190
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1191
+ });
1192
+ const res = await handleToken(db, req, { issuer: ISSUER });
1193
+ expect(res.status).toBe(400);
1194
+ const err = (await res.json()) as Record<string, unknown>;
1195
+ expect(err.error).toBe("unsupported_grant_type");
1196
+ } finally {
1197
+ cleanup();
1198
+ }
1199
+ });
1200
+
1201
+ test("PKCE verifier mismatch returns invalid_grant", async () => {
1202
+ const { db, cleanup } = await makeDb();
1203
+ try {
1204
+ const user = await createUser(db, "owner", "pw");
1205
+ const session = createSession(db, { userId: user.id });
1206
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1207
+ const { challenge } = makePkce();
1208
+ const consentForm = new URLSearchParams({
1209
+ __action: "consent",
1210
+ __csrf: TEST_CSRF,
1211
+ approve: "yes",
1212
+ client_id: reg.client.clientId,
1213
+ redirect_uri: "https://app.example/cb",
1214
+ response_type: "code",
1215
+ scope: "",
1216
+ code_challenge: challenge,
1217
+ code_challenge_method: "S256",
1218
+ });
1219
+ const consentRes = await handleAuthorizePost(
1220
+ db,
1221
+ new Request(`${ISSUER}/oauth/authorize`, {
1222
+ method: "POST",
1223
+ body: consentForm,
1224
+ headers: {
1225
+ "content-type": "application/x-www-form-urlencoded",
1226
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
1227
+ },
1228
+ }),
1229
+ { issuer: ISSUER },
1230
+ );
1231
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
1232
+ const tokenForm = new URLSearchParams({
1233
+ grant_type: "authorization_code",
1234
+ code: code ?? "",
1235
+ client_id: reg.client.clientId,
1236
+ redirect_uri: "https://app.example/cb",
1237
+ code_verifier: "wrong-verifier",
1238
+ });
1239
+ const res = await handleToken(
1240
+ db,
1241
+ new Request(`${ISSUER}/oauth/token`, {
1242
+ method: "POST",
1243
+ body: tokenForm,
1244
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1245
+ }),
1246
+ { issuer: ISSUER },
1247
+ );
1248
+ expect(res.status).toBe(400);
1249
+ const err = (await res.json()) as Record<string, unknown>;
1250
+ expect(err.error).toBe("invalid_grant");
1251
+ } finally {
1252
+ cleanup();
1253
+ }
1254
+ });
1255
+
1256
+ // cli#71 — scope-validation gate at /oauth/token. The hub must not sign a
1257
+ // JWT carrying scopes the issuer never declared.
1258
+ test("unknown scope at /oauth/token returns invalid_scope (400)", async () => {
1259
+ const { db, cleanup } = await makeDb();
1260
+ try {
1261
+ const user = await createUser(db, "owner", "pw");
1262
+ const session = createSession(db, { userId: user.id });
1263
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1264
+ const { verifier, challenge } = makePkce();
1265
+ const consentForm = new URLSearchParams({
1266
+ __action: "consent",
1267
+ __csrf: TEST_CSRF,
1268
+ approve: "yes",
1269
+ client_id: reg.client.clientId,
1270
+ redirect_uri: "https://app.example/cb",
1271
+ response_type: "code",
1272
+ scope: "vault:default:read frobnicate:everything",
1273
+ code_challenge: challenge,
1274
+ code_challenge_method: "S256",
1275
+ });
1276
+ const consentRes = await handleAuthorizePost(
1277
+ db,
1278
+ new Request(`${ISSUER}/oauth/authorize`, {
1279
+ method: "POST",
1280
+ body: consentForm,
1281
+ headers: {
1282
+ "content-type": "application/x-www-form-urlencoded",
1283
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
1284
+ },
1285
+ }),
1286
+ { issuer: ISSUER },
1287
+ );
1288
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
1289
+ const tokenForm = new URLSearchParams({
1290
+ grant_type: "authorization_code",
1291
+ code: code ?? "",
1292
+ client_id: reg.client.clientId,
1293
+ redirect_uri: "https://app.example/cb",
1294
+ code_verifier: verifier,
1295
+ });
1296
+ const res = await handleToken(
1297
+ db,
1298
+ new Request(`${ISSUER}/oauth/token`, {
1299
+ method: "POST",
1300
+ body: tokenForm,
1301
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1302
+ }),
1303
+ { issuer: ISSUER },
1304
+ );
1305
+ expect(res.status).toBe(400);
1306
+ const err = (await res.json()) as Record<string, unknown>;
1307
+ expect(err.error).toBe("invalid_scope");
1308
+ expect(err.error_description).toMatch(/frobnicate:everything/);
1309
+ expect(err.invalid_scopes).toEqual(["frobnicate:everything"]);
1310
+ } finally {
1311
+ cleanup();
1312
+ }
1313
+ });
1314
+
1315
+ test("third-party scope from injected declared set is accepted", async () => {
1316
+ const { db, cleanup } = await makeDb();
1317
+ try {
1318
+ const user = await createUser(db, "owner", "pw");
1319
+ const session = createSession(db, { userId: user.id });
1320
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1321
+ const { verifier, challenge } = makePkce();
1322
+ const consentForm = new URLSearchParams({
1323
+ __action: "consent",
1324
+ __csrf: TEST_CSRF,
1325
+ approve: "yes",
1326
+ client_id: reg.client.clientId,
1327
+ redirect_uri: "https://app.example/cb",
1328
+ response_type: "code",
1329
+ scope: "widget:read",
1330
+ code_challenge: challenge,
1331
+ code_challenge_method: "S256",
1332
+ });
1333
+ const consentRes = await handleAuthorizePost(
1334
+ db,
1335
+ new Request(`${ISSUER}/oauth/authorize`, {
1336
+ method: "POST",
1337
+ body: consentForm,
1338
+ headers: {
1339
+ "content-type": "application/x-www-form-urlencoded",
1340
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
1341
+ },
1342
+ }),
1343
+ { issuer: ISSUER },
1344
+ );
1345
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
1346
+ const tokenForm = new URLSearchParams({
1347
+ grant_type: "authorization_code",
1348
+ code: code ?? "",
1349
+ client_id: reg.client.clientId,
1350
+ redirect_uri: "https://app.example/cb",
1351
+ code_verifier: verifier,
1352
+ });
1353
+ const declared = new Set(["widget:read"]);
1354
+ const res = await handleToken(
1355
+ db,
1356
+ new Request(`${ISSUER}/oauth/token`, {
1357
+ method: "POST",
1358
+ body: tokenForm,
1359
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1360
+ }),
1361
+ { issuer: ISSUER, loadDeclaredScopes: () => declared },
1362
+ );
1363
+ expect(res.status).toBe(200);
1364
+ const body = (await res.json()) as { scope: string };
1365
+ expect(body.scope).toBe("widget:read");
1366
+ } finally {
1367
+ cleanup();
1368
+ }
1369
+ });
1370
+
1371
+ test("per-resource narrowing (vault:work:read against declared vault:read)", async () => {
1372
+ const { db, cleanup } = await makeDb();
1373
+ try {
1374
+ const user = await createUser(db, "owner", "pw");
1375
+ const session = createSession(db, { userId: user.id });
1376
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1377
+ const { verifier, challenge } = makePkce();
1378
+ const consentForm = new URLSearchParams({
1379
+ __action: "consent",
1380
+ __csrf: TEST_CSRF,
1381
+ approve: "yes",
1382
+ client_id: reg.client.clientId,
1383
+ redirect_uri: "https://app.example/cb",
1384
+ response_type: "code",
1385
+ scope: "vault:work:read",
1386
+ code_challenge: challenge,
1387
+ code_challenge_method: "S256",
1388
+ });
1389
+ const consentRes = await handleAuthorizePost(
1390
+ db,
1391
+ new Request(`${ISSUER}/oauth/authorize`, {
1392
+ method: "POST",
1393
+ body: consentForm,
1394
+ headers: {
1395
+ "content-type": "application/x-www-form-urlencoded",
1396
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
1397
+ },
1398
+ }),
1399
+ { issuer: ISSUER },
1400
+ );
1401
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
1402
+ const tokenForm = new URLSearchParams({
1403
+ grant_type: "authorization_code",
1404
+ code: code ?? "",
1405
+ client_id: reg.client.clientId,
1406
+ redirect_uri: "https://app.example/cb",
1407
+ code_verifier: verifier,
1408
+ });
1409
+ const res = await handleToken(
1410
+ db,
1411
+ new Request(`${ISSUER}/oauth/token`, {
1412
+ method: "POST",
1413
+ body: tokenForm,
1414
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1415
+ }),
1416
+ { issuer: ISSUER },
1417
+ );
1418
+ expect(res.status).toBe(200);
1419
+ const body = (await res.json()) as { scope: string };
1420
+ expect(body.scope).toBe("vault:work:read");
1421
+ } finally {
1422
+ cleanup();
1423
+ }
1424
+ });
1425
+
1426
+ // closes #81 — services-catalog filtering + multi-service shape.
1427
+ test("services catalog omits services the token has no scope for", async () => {
1428
+ const { db, cleanup } = await makeDb();
1429
+ try {
1430
+ const user = await createUser(db, "owner", "pw");
1431
+ const session = createSession(db, { userId: user.id });
1432
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1433
+ const { verifier, challenge } = makePkce();
1434
+ const consentForm = new URLSearchParams({
1435
+ __action: "consent",
1436
+ __csrf: TEST_CSRF,
1437
+ approve: "yes",
1438
+ client_id: reg.client.clientId,
1439
+ redirect_uri: "https://app.example/cb",
1440
+ response_type: "code",
1441
+ scope: "scribe:transcribe",
1442
+ code_challenge: challenge,
1443
+ code_challenge_method: "S256",
1444
+ });
1445
+ const consentRes = await handleAuthorizePost(
1446
+ db,
1447
+ new Request(`${ISSUER}/oauth/authorize`, {
1448
+ method: "POST",
1449
+ body: consentForm,
1450
+ headers: {
1451
+ "content-type": "application/x-www-form-urlencoded",
1452
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
1453
+ },
1454
+ }),
1455
+ { issuer: ISSUER },
1456
+ );
1457
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
1458
+ const tokenForm = new URLSearchParams({
1459
+ grant_type: "authorization_code",
1460
+ code: code ?? "",
1461
+ client_id: reg.client.clientId,
1462
+ redirect_uri: "https://app.example/cb",
1463
+ code_verifier: verifier,
1464
+ });
1465
+ const res = await handleToken(
1466
+ db,
1467
+ new Request(`${ISSUER}/oauth/token`, {
1468
+ method: "POST",
1469
+ body: tokenForm,
1470
+ headers: { "content-type": "application/x-www-form-urlencoded" },
1471
+ }),
1472
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
1473
+ );
1474
+ expect(res.status).toBe(200);
1475
+ const body = (await res.json()) as {
1476
+ services: Record<string, { url: string; version: string }>;
1477
+ };
1478
+ expect(body.services).toEqual({
1479
+ scribe: { url: `${ISSUER}/scribe`, version: "0.3.0-rc.1" },
1480
+ });
1481
+ expect(body.services.vault).toBeUndefined();
1482
+ } finally {
1483
+ cleanup();
1484
+ }
1485
+ });
1486
+
1487
+ test("services catalog includes every service the token has a scope for", async () => {
1488
+ // buildServicesCatalog is a pure helper — exercise the multi-scope shape
1489
+ // here without re-running the full PKCE dance.
1490
+ const catalog = buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, [
1491
+ "vault:read",
1492
+ "scribe:transcribe",
1493
+ ]);
1494
+ expect(catalog).toEqual({
1495
+ vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1496
+ scribe: { url: `${ISSUER}/scribe`, version: "0.3.0-rc.1" },
1497
+ });
1498
+ });
1499
+
1500
+ test("services catalog is empty when the token has no resource-prefixed scopes", () => {
1501
+ expect(buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, [])).toEqual({});
1502
+ // hub-only scopes don't reference any installed module catalog entry.
1503
+ expect(buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, ["hub:admin"])).toEqual({});
1504
+ });
1505
+
1506
+ // closes #81 — vault URL must follow paths[0] from services.json, NOT a
1507
+ // hardcoded `/vault/default`. Users who installed with `--vault-name work`
1508
+ // have `paths: ["/vault/work"]` and the catalog must reflect that.
1509
+ test("services catalog reads paths[0] verbatim — handles custom vault names", () => {
1510
+ const customManifest: ServicesManifest = {
1511
+ services: [
1512
+ {
1513
+ name: "parachute-vault",
1514
+ port: 1940,
1515
+ paths: ["/vault/work"],
1516
+ health: "/health",
1517
+ version: "0.3.0",
1518
+ },
1519
+ ],
1520
+ };
1521
+ expect(buildServicesCatalog(customManifest, ISSUER, ["vault:read"])).toEqual({
1522
+ vault: { url: `${ISSUER}/vault/work`, version: "0.3.0" },
1523
+ });
1524
+ });
1525
+ });
1526
+
1527
+ // closes #72 — RFC 6749 §3.2.1 + §2.3.1: confidential clients must
1528
+ // authenticate at /oauth/token via Authorization: Basic header (preferred)
1529
+ // or form-body client_secret. Public clients (PKCE-only) are unaffected
1530
+ // because PKCE replaces the secret for them.
1531
+ describe("handleToken — confidential client authentication (#72)", () => {
1532
+ // Helper: drive the consent screen for `clientId` to a fresh auth code.
1533
+ // Returns the code + the verifier so the caller can hit /oauth/token.
1534
+ async function consentAndGetCode(
1535
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
1536
+ clientId: string,
1537
+ sessionId: string,
1538
+ ): Promise<{ code: string; verifier: string }> {
1539
+ const { verifier, challenge } = makePkce();
1540
+ const consentForm = new URLSearchParams({
1541
+ __action: "consent",
1542
+ __csrf: TEST_CSRF,
1543
+ approve: "yes",
1544
+ client_id: clientId,
1545
+ redirect_uri: "https://app.example/cb",
1546
+ response_type: "code",
1547
+ scope: "vault:default:read",
1548
+ code_challenge: challenge,
1549
+ code_challenge_method: "S256",
1550
+ });
1551
+ const consentRes = await handleAuthorizePost(
1552
+ db,
1553
+ new Request(`${ISSUER}/oauth/authorize`, {
1554
+ method: "POST",
1555
+ body: consentForm,
1556
+ headers: {
1557
+ "content-type": "application/x-www-form-urlencoded",
1558
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(sessionId, 86400)}`,
1559
+ },
1560
+ }),
1561
+ { issuer: ISSUER },
1562
+ );
1563
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
1564
+ return { code: code ?? "", verifier };
1565
+ }
1566
+
1567
+ function tokenRequest(form: URLSearchParams, headers: Record<string, string> = {}): Request {
1568
+ return new Request(`${ISSUER}/oauth/token`, {
1569
+ method: "POST",
1570
+ body: form,
1571
+ headers: { "content-type": "application/x-www-form-urlencoded", ...headers },
1572
+ });
1573
+ }
1574
+
1575
+ test("authorization_code: confidential client + correct secret in form body → 200", async () => {
1576
+ const { db, cleanup } = await makeDb();
1577
+ try {
1578
+ const user = await createUser(db, "owner", "pw");
1579
+ const session = createSession(db, { userId: user.id });
1580
+ const reg = registerClient(db, {
1581
+ redirectUris: ["https://app.example/cb"],
1582
+ confidential: true,
1583
+ });
1584
+ expect(reg.clientSecret).not.toBeNull();
1585
+ const { code, verifier } = await consentAndGetCode(db, reg.client.clientId, session.id);
1586
+ const tokenForm = new URLSearchParams({
1587
+ grant_type: "authorization_code",
1588
+ code,
1589
+ client_id: reg.client.clientId,
1590
+ redirect_uri: "https://app.example/cb",
1591
+ code_verifier: verifier,
1592
+ client_secret: reg.clientSecret ?? "",
1593
+ });
1594
+ const res = await handleToken(db, tokenRequest(tokenForm), {
1595
+ issuer: ISSUER,
1596
+ loadServicesManifest: fixtureLoadServicesManifest,
1597
+ });
1598
+ expect(res.status).toBe(200);
1599
+ } finally {
1600
+ cleanup();
1601
+ }
1602
+ });
1603
+
1604
+ test("authorization_code: confidential client + correct secret in Authorization: Basic header → 200", async () => {
1605
+ const { db, cleanup } = await makeDb();
1606
+ try {
1607
+ const user = await createUser(db, "owner", "pw");
1608
+ const session = createSession(db, { userId: user.id });
1609
+ const reg = registerClient(db, {
1610
+ redirectUris: ["https://app.example/cb"],
1611
+ confidential: true,
1612
+ });
1613
+ const { code, verifier } = await consentAndGetCode(db, reg.client.clientId, session.id);
1614
+ const tokenForm = new URLSearchParams({
1615
+ grant_type: "authorization_code",
1616
+ code,
1617
+ client_id: reg.client.clientId,
1618
+ redirect_uri: "https://app.example/cb",
1619
+ code_verifier: verifier,
1620
+ // No client_secret in the body — the header carries it.
1621
+ });
1622
+ // RFC 6749 §2.3.1 requires form-encoding the credentials before base64.
1623
+ const basic = btoa(
1624
+ `${encodeURIComponent(reg.client.clientId)}:${encodeURIComponent(reg.clientSecret ?? "")}`,
1625
+ );
1626
+ const res = await handleToken(
1627
+ db,
1628
+ tokenRequest(tokenForm, { authorization: `Basic ${basic}` }),
1629
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
1630
+ );
1631
+ expect(res.status).toBe(200);
1632
+ } finally {
1633
+ cleanup();
1634
+ }
1635
+ });
1636
+
1637
+ test("authorization_code: confidential client + wrong secret → 401 + WWW-Authenticate Basic", async () => {
1638
+ const { db, cleanup } = await makeDb();
1639
+ try {
1640
+ const user = await createUser(db, "owner", "pw");
1641
+ const session = createSession(db, { userId: user.id });
1642
+ const reg = registerClient(db, {
1643
+ redirectUris: ["https://app.example/cb"],
1644
+ confidential: true,
1645
+ });
1646
+ const { code, verifier } = await consentAndGetCode(db, reg.client.clientId, session.id);
1647
+ const tokenForm = new URLSearchParams({
1648
+ grant_type: "authorization_code",
1649
+ code,
1650
+ client_id: reg.client.clientId,
1651
+ redirect_uri: "https://app.example/cb",
1652
+ code_verifier: verifier,
1653
+ client_secret: "definitely-not-the-real-secret",
1654
+ });
1655
+ const res = await handleToken(db, tokenRequest(tokenForm), { issuer: ISSUER });
1656
+ expect(res.status).toBe(401);
1657
+ expect(res.headers.get("www-authenticate")).toMatch(/^Basic\b/i);
1658
+ const err = (await res.json()) as Record<string, unknown>;
1659
+ expect(err.error).toBe("invalid_client");
1660
+ } finally {
1661
+ cleanup();
1662
+ }
1663
+ });
1664
+
1665
+ test("authorization_code: confidential client + missing secret → 401 + WWW-Authenticate Basic", async () => {
1666
+ const { db, cleanup } = await makeDb();
1667
+ try {
1668
+ const user = await createUser(db, "owner", "pw");
1669
+ const session = createSession(db, { userId: user.id });
1670
+ const reg = registerClient(db, {
1671
+ redirectUris: ["https://app.example/cb"],
1672
+ confidential: true,
1673
+ });
1674
+ const { code, verifier } = await consentAndGetCode(db, reg.client.clientId, session.id);
1675
+ // No client_secret in form, no Authorization header.
1676
+ const tokenForm = new URLSearchParams({
1677
+ grant_type: "authorization_code",
1678
+ code,
1679
+ client_id: reg.client.clientId,
1680
+ redirect_uri: "https://app.example/cb",
1681
+ code_verifier: verifier,
1682
+ });
1683
+ const res = await handleToken(db, tokenRequest(tokenForm), { issuer: ISSUER });
1684
+ expect(res.status).toBe(401);
1685
+ expect(res.headers.get("www-authenticate")).toMatch(/^Basic\b/i);
1686
+ const err = (await res.json()) as Record<string, unknown>;
1687
+ expect(err.error).toBe("invalid_client");
1688
+ expect(err.error_description).toMatch(/required/);
1689
+ } finally {
1690
+ cleanup();
1691
+ }
1692
+ });
1693
+
1694
+ test("authorization_code: Basic header client_id mismatch with body → 401", async () => {
1695
+ // Defensive: a header authenticating as one client while the body claims
1696
+ // another is a confused or hostile request — refuse rather than guess.
1697
+ const { db, cleanup } = await makeDb();
1698
+ try {
1699
+ const user = await createUser(db, "owner", "pw");
1700
+ const session = createSession(db, { userId: user.id });
1701
+ const reg = registerClient(db, {
1702
+ redirectUris: ["https://app.example/cb"],
1703
+ confidential: true,
1704
+ });
1705
+ const { code, verifier } = await consentAndGetCode(db, reg.client.clientId, session.id);
1706
+ const tokenForm = new URLSearchParams({
1707
+ grant_type: "authorization_code",
1708
+ code,
1709
+ client_id: reg.client.clientId,
1710
+ redirect_uri: "https://app.example/cb",
1711
+ code_verifier: verifier,
1712
+ });
1713
+ const basic = btoa(
1714
+ `${encodeURIComponent("some-other-client")}:${encodeURIComponent(reg.clientSecret ?? "")}`,
1715
+ );
1716
+ const res = await handleToken(
1717
+ db,
1718
+ tokenRequest(tokenForm, { authorization: `Basic ${basic}` }),
1719
+ { issuer: ISSUER },
1720
+ );
1721
+ expect(res.status).toBe(401);
1722
+ const err = (await res.json()) as Record<string, unknown>;
1723
+ expect(err.error).toBe("invalid_client");
1724
+ expect(err.error_description).toMatch(/header client_id/);
1725
+ } finally {
1726
+ cleanup();
1727
+ }
1728
+ });
1729
+
1730
+ test("authorization_code: public client unaffected (no secret required) → 200", async () => {
1731
+ // Regression: PKCE-only clients must keep working with no client_secret.
1732
+ const { db, cleanup } = await makeDb();
1733
+ try {
1734
+ const user = await createUser(db, "owner", "pw");
1735
+ const session = createSession(db, { userId: user.id });
1736
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1737
+ expect(reg.clientSecret).toBeNull();
1738
+ const { code, verifier } = await consentAndGetCode(db, reg.client.clientId, session.id);
1739
+ const tokenForm = new URLSearchParams({
1740
+ grant_type: "authorization_code",
1741
+ code,
1742
+ client_id: reg.client.clientId,
1743
+ redirect_uri: "https://app.example/cb",
1744
+ code_verifier: verifier,
1745
+ });
1746
+ const res = await handleToken(db, tokenRequest(tokenForm), {
1747
+ issuer: ISSUER,
1748
+ loadServicesManifest: fixtureLoadServicesManifest,
1749
+ });
1750
+ expect(res.status).toBe(200);
1751
+ } finally {
1752
+ cleanup();
1753
+ }
1754
+ });
1755
+
1756
+ test("refresh_token: confidential client + correct secret rotates the pair", async () => {
1757
+ const { db, cleanup } = await makeDb();
1758
+ try {
1759
+ const user = await createUser(db, "owner", "pw");
1760
+ const session = createSession(db, { userId: user.id });
1761
+ const reg = registerClient(db, {
1762
+ redirectUris: ["https://app.example/cb"],
1763
+ confidential: true,
1764
+ });
1765
+ // Mint an initial refresh token (one full dance with the secret).
1766
+ const { code, verifier } = await consentAndGetCode(db, reg.client.clientId, session.id);
1767
+ const initialTokenRes = await handleToken(
1768
+ db,
1769
+ tokenRequest(
1770
+ new URLSearchParams({
1771
+ grant_type: "authorization_code",
1772
+ code,
1773
+ client_id: reg.client.clientId,
1774
+ redirect_uri: "https://app.example/cb",
1775
+ code_verifier: verifier,
1776
+ client_secret: reg.clientSecret ?? "",
1777
+ }),
1778
+ ),
1779
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
1780
+ );
1781
+ const initial = (await initialTokenRes.json()) as { refresh_token: string };
1782
+
1783
+ // Refresh with secret → 200.
1784
+ const refreshForm = new URLSearchParams({
1785
+ grant_type: "refresh_token",
1786
+ refresh_token: initial.refresh_token,
1787
+ client_id: reg.client.clientId,
1788
+ client_secret: reg.clientSecret ?? "",
1789
+ });
1790
+ const refreshRes = await handleToken(db, tokenRequest(refreshForm), {
1791
+ issuer: ISSUER,
1792
+ loadServicesManifest: fixtureLoadServicesManifest,
1793
+ });
1794
+ expect(refreshRes.status).toBe(200);
1795
+ } finally {
1796
+ cleanup();
1797
+ }
1798
+ });
1799
+
1800
+ test("refresh_token: confidential client + missing secret → 401", async () => {
1801
+ const { db, cleanup } = await makeDb();
1802
+ try {
1803
+ const user = await createUser(db, "owner", "pw");
1804
+ const session = createSession(db, { userId: user.id });
1805
+ const reg = registerClient(db, {
1806
+ redirectUris: ["https://app.example/cb"],
1807
+ confidential: true,
1808
+ });
1809
+ const { code, verifier } = await consentAndGetCode(db, reg.client.clientId, session.id);
1810
+ const initialTokenRes = await handleToken(
1811
+ db,
1812
+ tokenRequest(
1813
+ new URLSearchParams({
1814
+ grant_type: "authorization_code",
1815
+ code,
1816
+ client_id: reg.client.clientId,
1817
+ redirect_uri: "https://app.example/cb",
1818
+ code_verifier: verifier,
1819
+ client_secret: reg.clientSecret ?? "",
1820
+ }),
1821
+ ),
1822
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
1823
+ );
1824
+ const initial = (await initialTokenRes.json()) as { refresh_token: string };
1825
+
1826
+ const refreshForm = new URLSearchParams({
1827
+ grant_type: "refresh_token",
1828
+ refresh_token: initial.refresh_token,
1829
+ client_id: reg.client.clientId,
1830
+ // No client_secret.
1831
+ });
1832
+ const res = await handleToken(db, tokenRequest(refreshForm), { issuer: ISSUER });
1833
+ expect(res.status).toBe(401);
1834
+ expect(res.headers.get("www-authenticate")).toMatch(/^Basic\b/i);
1835
+ const err = (await res.json()) as Record<string, unknown>;
1836
+ expect(err.error).toBe("invalid_client");
1837
+ } finally {
1838
+ cleanup();
1839
+ }
1840
+ });
1841
+
1842
+ test("refresh_token: confidential client + wrong secret → 401", async () => {
1843
+ const { db, cleanup } = await makeDb();
1844
+ try {
1845
+ const user = await createUser(db, "owner", "pw");
1846
+ const session = createSession(db, { userId: user.id });
1847
+ const reg = registerClient(db, {
1848
+ redirectUris: ["https://app.example/cb"],
1849
+ confidential: true,
1850
+ });
1851
+ const { code, verifier } = await consentAndGetCode(db, reg.client.clientId, session.id);
1852
+ const initialTokenRes = await handleToken(
1853
+ db,
1854
+ tokenRequest(
1855
+ new URLSearchParams({
1856
+ grant_type: "authorization_code",
1857
+ code,
1858
+ client_id: reg.client.clientId,
1859
+ redirect_uri: "https://app.example/cb",
1860
+ code_verifier: verifier,
1861
+ client_secret: reg.clientSecret ?? "",
1862
+ }),
1863
+ ),
1864
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
1865
+ );
1866
+ const initial = (await initialTokenRes.json()) as { refresh_token: string };
1867
+
1868
+ const refreshForm = new URLSearchParams({
1869
+ grant_type: "refresh_token",
1870
+ refresh_token: initial.refresh_token,
1871
+ client_id: reg.client.clientId,
1872
+ client_secret: "wrong-secret",
1873
+ });
1874
+ const res = await handleToken(db, tokenRequest(refreshForm), { issuer: ISSUER });
1875
+ expect(res.status).toBe(401);
1876
+ const err = (await res.json()) as Record<string, unknown>;
1877
+ expect(err.error).toBe("invalid_client");
1878
+ } finally {
1879
+ cleanup();
1880
+ }
1881
+ });
1882
+
1883
+ test("refresh_token: public client unaffected (no secret required) → 200", async () => {
1884
+ const { db, cleanup } = await makeDb();
1885
+ try {
1886
+ const user = await createUser(db, "owner", "pw");
1887
+ const session = createSession(db, { userId: user.id });
1888
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1889
+ const { code, verifier } = await consentAndGetCode(db, reg.client.clientId, session.id);
1890
+ const initialTokenRes = await handleToken(
1891
+ db,
1892
+ tokenRequest(
1893
+ new URLSearchParams({
1894
+ grant_type: "authorization_code",
1895
+ code,
1896
+ client_id: reg.client.clientId,
1897
+ redirect_uri: "https://app.example/cb",
1898
+ code_verifier: verifier,
1899
+ }),
1900
+ ),
1901
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
1902
+ );
1903
+ const initial = (await initialTokenRes.json()) as { refresh_token: string };
1904
+
1905
+ const refreshForm = new URLSearchParams({
1906
+ grant_type: "refresh_token",
1907
+ refresh_token: initial.refresh_token,
1908
+ client_id: reg.client.clientId,
1909
+ });
1910
+ const res = await handleToken(db, tokenRequest(refreshForm), {
1911
+ issuer: ISSUER,
1912
+ loadServicesManifest: fixtureLoadServicesManifest,
1913
+ });
1914
+ expect(res.status).toBe(200);
1915
+ } finally {
1916
+ cleanup();
1917
+ }
1918
+ });
1919
+ });
1920
+
1921
+ describe("handleRegister — RFC 7591 DCR", () => {
1922
+ test("registers a public client and returns 201 with client_id (no secret)", async () => {
1923
+ const { db, cleanup } = await makeDb();
1924
+ try {
1925
+ const req = new Request(`${ISSUER}/oauth/register`, {
1926
+ method: "POST",
1927
+ body: JSON.stringify({
1928
+ redirect_uris: ["https://app.example/cb"],
1929
+ scope: "vault:read",
1930
+ client_name: "MyApp",
1931
+ }),
1932
+ headers: { "content-type": "application/json" },
1933
+ });
1934
+ const res = await handleRegister(db, req, { issuer: ISSUER });
1935
+ expect(res.status).toBe(201);
1936
+ const body = (await res.json()) as Record<string, unknown>;
1937
+ expect(typeof body.client_id).toBe("string");
1938
+ expect(body.client_secret).toBeUndefined();
1939
+ expect(body.token_endpoint_auth_method).toBe("none");
1940
+ expect(body.redirect_uris).toEqual(["https://app.example/cb"]);
1941
+ expect(body.client_name).toBe("MyApp");
1942
+ // #74 — unauthenticated DCR lands as pending until an operator approves.
1943
+ expect(body.status).toBe("pending");
1944
+ } finally {
1945
+ cleanup();
1946
+ }
1947
+ });
1948
+
1949
+ test("registers a confidential client and returns plaintext client_secret", async () => {
1950
+ const { db, cleanup } = await makeDb();
1951
+ try {
1952
+ const req = new Request(`${ISSUER}/oauth/register`, {
1953
+ method: "POST",
1954
+ body: JSON.stringify({
1955
+ redirect_uris: ["https://app.example/cb"],
1956
+ token_endpoint_auth_method: "client_secret_post",
1957
+ }),
1958
+ headers: { "content-type": "application/json" },
1959
+ });
1960
+ const res = await handleRegister(db, req, { issuer: ISSUER });
1961
+ expect(res.status).toBe(201);
1962
+ const body = (await res.json()) as Record<string, unknown>;
1963
+ expect(typeof body.client_secret).toBe("string");
1964
+ expect(body.token_endpoint_auth_method).toBe("client_secret_post");
1965
+ } finally {
1966
+ cleanup();
1967
+ }
1968
+ });
1969
+
1970
+ test("rejects empty redirect_uris with invalid_redirect_uri", async () => {
1971
+ const { db, cleanup } = await makeDb();
1972
+ try {
1973
+ const req = new Request(`${ISSUER}/oauth/register`, {
1974
+ method: "POST",
1975
+ body: JSON.stringify({ redirect_uris: [] }),
1976
+ headers: { "content-type": "application/json" },
1977
+ });
1978
+ const res = await handleRegister(db, req, { issuer: ISSUER });
1979
+ expect(res.status).toBe(400);
1980
+ const err = (await res.json()) as Record<string, unknown>;
1981
+ expect(err.error).toBe("invalid_redirect_uri");
1982
+ } finally {
1983
+ cleanup();
1984
+ }
1985
+ });
1986
+
1987
+ test("rejects javascript: redirect_uri", async () => {
1988
+ const { db, cleanup } = await makeDb();
1989
+ try {
1990
+ const req = new Request(`${ISSUER}/oauth/register`, {
1991
+ method: "POST",
1992
+ body: JSON.stringify({ redirect_uris: ["javascript:alert(1)"] }),
1993
+ headers: { "content-type": "application/json" },
1994
+ });
1995
+ const res = await handleRegister(db, req, { issuer: ISSUER });
1996
+ expect(res.status).toBe(400);
1997
+ const err = (await res.json()) as Record<string, unknown>;
1998
+ expect(err.error).toBe("invalid_redirect_uri");
1999
+ } finally {
2000
+ cleanup();
2001
+ }
2002
+ });
2003
+
2004
+ test("rejects non-JSON body", async () => {
2005
+ const { db, cleanup } = await makeDb();
2006
+ try {
2007
+ const req = new Request(`${ISSUER}/oauth/register`, {
2008
+ method: "POST",
2009
+ body: "not json",
2010
+ headers: { "content-type": "application/json" },
2011
+ });
2012
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2013
+ expect(res.status).toBe(400);
2014
+ } finally {
2015
+ cleanup();
2016
+ }
2017
+ });
2018
+ });
2019
+
2020
+ // closes #74 — DCR is now operator-gated. Self-served registrations land as
2021
+ // pending and cannot OAuth; operator-bearer (hub:admin) registrations land
2022
+ // as approved and can OAuth immediately. This block covers all four exposed
2023
+ // gates plus the bearer paths in /oauth/register.
2024
+ describe("DCR approval gate (#74)", () => {
2025
+ async function buildAuthorizeRequest(
2026
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
2027
+ clientId: string,
2028
+ ) {
2029
+ const { challenge } = makePkce();
2030
+ return new Request(
2031
+ authorizeUrl({
2032
+ client_id: clientId,
2033
+ redirect_uri: "https://app.example/cb",
2034
+ response_type: "code",
2035
+ code_challenge: challenge,
2036
+ code_challenge_method: "S256",
2037
+ scope: "vault:read",
2038
+ }),
2039
+ );
2040
+ }
2041
+
2042
+ test("authorize: pending client → 403 HTML 'App not yet approved'", async () => {
2043
+ const { db, cleanup } = await makeDb();
2044
+ try {
2045
+ const reg = registerClient(db, {
2046
+ redirectUris: ["https://app.example/cb"],
2047
+ status: "pending",
2048
+ });
2049
+ const res = handleAuthorizeGet(db, await buildAuthorizeRequest(db, reg.client.clientId), {
2050
+ issuer: ISSUER,
2051
+ });
2052
+ expect(res.status).toBe(403);
2053
+ const html = await res.text();
2054
+ expect(html).toContain("App not yet approved");
2055
+ expect(html).toContain("approve-client");
2056
+ } finally {
2057
+ cleanup();
2058
+ }
2059
+ });
2060
+
2061
+ test("authorize: approved client passes the gate (renders login)", async () => {
2062
+ const { db, cleanup } = await makeDb();
2063
+ try {
2064
+ const reg = registerClient(db, {
2065
+ redirectUris: ["https://app.example/cb"],
2066
+ status: "approved",
2067
+ });
2068
+ const res = handleAuthorizeGet(db, await buildAuthorizeRequest(db, reg.client.clientId), {
2069
+ issuer: ISSUER,
2070
+ });
2071
+ expect(res.status).toBe(200);
2072
+ expect(await res.text()).toContain("Sign in");
2073
+ } finally {
2074
+ cleanup();
2075
+ }
2076
+ });
2077
+
2078
+ test("token: pending client → 401 invalid_client", async () => {
2079
+ const { db, cleanup } = await makeDb();
2080
+ try {
2081
+ const reg = registerClient(db, {
2082
+ redirectUris: ["https://app.example/cb"],
2083
+ status: "pending",
2084
+ });
2085
+ const form = new URLSearchParams({
2086
+ grant_type: "authorization_code",
2087
+ code: "any",
2088
+ client_id: reg.client.clientId,
2089
+ redirect_uri: "https://app.example/cb",
2090
+ code_verifier: "any",
2091
+ });
2092
+ const res = await handleToken(
2093
+ db,
2094
+ new Request(`${ISSUER}/oauth/token`, {
2095
+ method: "POST",
2096
+ body: form,
2097
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2098
+ }),
2099
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
2100
+ );
2101
+ expect(res.status).toBe(401);
2102
+ const body = (await res.json()) as Record<string, unknown>;
2103
+ expect(body.error).toBe("invalid_client");
2104
+ expect(body.error_description).toContain("not been approved");
2105
+ } finally {
2106
+ cleanup();
2107
+ }
2108
+ });
2109
+
2110
+ test("token (refresh): pending client → 401 invalid_client", async () => {
2111
+ const { db, cleanup } = await makeDb();
2112
+ try {
2113
+ const reg = registerClient(db, {
2114
+ redirectUris: ["https://app.example/cb"],
2115
+ status: "pending",
2116
+ });
2117
+ const form = new URLSearchParams({
2118
+ grant_type: "refresh_token",
2119
+ refresh_token: "any",
2120
+ client_id: reg.client.clientId,
2121
+ });
2122
+ const res = await handleToken(
2123
+ db,
2124
+ new Request(`${ISSUER}/oauth/token`, {
2125
+ method: "POST",
2126
+ body: form,
2127
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2128
+ }),
2129
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
2130
+ );
2131
+ expect(res.status).toBe(401);
2132
+ const body = (await res.json()) as Record<string, unknown>;
2133
+ expect(body.error).toBe("invalid_client");
2134
+ } finally {
2135
+ cleanup();
2136
+ }
2137
+ });
2138
+
2139
+ test("register: no Authorization header → status pending (public DCR path)", async () => {
2140
+ const { db, cleanup } = await makeDb();
2141
+ try {
2142
+ const req = new Request(`${ISSUER}/oauth/register`, {
2143
+ method: "POST",
2144
+ body: JSON.stringify({ redirect_uris: ["https://app.example/cb"] }),
2145
+ headers: { "content-type": "application/json" },
2146
+ });
2147
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2148
+ expect(res.status).toBe(201);
2149
+ const body = (await res.json()) as Record<string, unknown>;
2150
+ expect(body.status).toBe("pending");
2151
+ } finally {
2152
+ cleanup();
2153
+ }
2154
+ });
2155
+
2156
+ test("register: operator-bearer with hub:admin → status approved", async () => {
2157
+ // First-party install path. Modules running `parachute install <name>`
2158
+ // present the hub's operator.token; the bearer carries hub:admin so the
2159
+ // self-registration lands as approved without a human follow-up.
2160
+ const { db, cleanup } = await makeDb();
2161
+ try {
2162
+ const { rotateSigningKey } = await import("../signing-keys.ts");
2163
+ const { mintOperatorToken } = await import("../operator-token.ts");
2164
+ rotateSigningKey(db);
2165
+ const user = await createUser(db, "owner", "pw");
2166
+ const operator = await mintOperatorToken(db, user.id, { issuer: ISSUER });
2167
+
2168
+ const req = new Request(`${ISSUER}/oauth/register`, {
2169
+ method: "POST",
2170
+ body: JSON.stringify({ redirect_uris: ["https://app.example/cb"] }),
2171
+ headers: {
2172
+ "content-type": "application/json",
2173
+ authorization: `Bearer ${operator.token}`,
2174
+ },
2175
+ });
2176
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2177
+ expect(res.status).toBe(201);
2178
+ const body = (await res.json()) as Record<string, unknown>;
2179
+ expect(body.status).toBe("approved");
2180
+ // Sanity: the freshly-approved client passes the authorize gate.
2181
+ const aRes = handleAuthorizeGet(
2182
+ db,
2183
+ await buildAuthorizeRequest(db, body.client_id as string),
2184
+ { issuer: ISSUER },
2185
+ );
2186
+ expect(aRes.status).toBe(200);
2187
+ } finally {
2188
+ cleanup();
2189
+ }
2190
+ });
2191
+
2192
+ test("register: bearer without hub:admin → 403 insufficient_scope", async () => {
2193
+ // A consumer access token (vault:read) is not an operator credential.
2194
+ // The endpoint must reject rather than silently downgrading to pending.
2195
+ const { db, cleanup } = await makeDb();
2196
+ try {
2197
+ const { rotateSigningKey } = await import("../signing-keys.ts");
2198
+ const { signAccessToken } = await import("../jwt-sign.ts");
2199
+ rotateSigningKey(db);
2200
+ const user = await createUser(db, "owner", "pw");
2201
+ const consumer = await signAccessToken(db, {
2202
+ sub: user.id,
2203
+ scopes: ["vault:read"],
2204
+ audience: "vault",
2205
+ clientId: "some-client",
2206
+ issuer: ISSUER,
2207
+ });
2208
+
2209
+ const req = new Request(`${ISSUER}/oauth/register`, {
2210
+ method: "POST",
2211
+ body: JSON.stringify({ redirect_uris: ["https://app.example/cb"] }),
2212
+ headers: {
2213
+ "content-type": "application/json",
2214
+ authorization: `Bearer ${consumer.token}`,
2215
+ },
2216
+ });
2217
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2218
+ expect(res.status).toBe(403);
2219
+ const body = (await res.json()) as Record<string, unknown>;
2220
+ expect(body.error).toBe("insufficient_scope");
2221
+ } finally {
2222
+ cleanup();
2223
+ }
2224
+ });
2225
+
2226
+ test("register: malformed bearer → 401 invalid_token", async () => {
2227
+ const { db, cleanup } = await makeDb();
2228
+ try {
2229
+ const req = new Request(`${ISSUER}/oauth/register`, {
2230
+ method: "POST",
2231
+ body: JSON.stringify({ redirect_uris: ["https://app.example/cb"] }),
2232
+ headers: {
2233
+ "content-type": "application/json",
2234
+ authorization: "Bearer not-a-jwt",
2235
+ },
2236
+ });
2237
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2238
+ expect(res.status).toBe(401);
2239
+ const body = (await res.json()) as Record<string, unknown>;
2240
+ expect(body.error).toBe("invalid_token");
2241
+ } finally {
2242
+ cleanup();
2243
+ }
2244
+ });
2245
+ });
2246
+
2247
+ // closes #73 — RFC 6749 §6 refresh-token rotation, RFC 6819 §5.2.2.3 replay
2248
+ // detection (family-wide revocation), RFC 7009 token revocation.
2249
+ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
2250
+ async function consentAndGetCode(
2251
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
2252
+ clientId: string,
2253
+ sessionId: string,
2254
+ scope = "vault:default:read",
2255
+ ): Promise<{ code: string; verifier: string }> {
2256
+ const { verifier, challenge } = makePkce();
2257
+ const consentForm = new URLSearchParams({
2258
+ __action: "consent",
2259
+ __csrf: TEST_CSRF,
2260
+ approve: "yes",
2261
+ client_id: clientId,
2262
+ redirect_uri: "https://app.example/cb",
2263
+ response_type: "code",
2264
+ scope,
2265
+ code_challenge: challenge,
2266
+ code_challenge_method: "S256",
2267
+ });
2268
+ const consentRes = await handleAuthorizePost(
2269
+ db,
2270
+ new Request(`${ISSUER}/oauth/authorize`, {
2271
+ method: "POST",
2272
+ body: consentForm,
2273
+ headers: {
2274
+ "content-type": "application/x-www-form-urlencoded",
2275
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(sessionId, 86400)}`,
2276
+ },
2277
+ }),
2278
+ { issuer: ISSUER },
2279
+ );
2280
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
2281
+ return { code: code ?? "", verifier };
2282
+ }
2283
+
2284
+ function tokenRequest(form: URLSearchParams): Request {
2285
+ return new Request(`${ISSUER}/oauth/token`, {
2286
+ method: "POST",
2287
+ body: form,
2288
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2289
+ });
2290
+ }
2291
+
2292
+ function revokeRequest(form: URLSearchParams): Request {
2293
+ return new Request(`${ISSUER}/oauth/revoke`, {
2294
+ method: "POST",
2295
+ body: form,
2296
+ headers: { "content-type": "application/x-www-form-urlencoded" },
2297
+ });
2298
+ }
2299
+
2300
+ async function mintInitialPair(
2301
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
2302
+ clientId: string,
2303
+ userId: string,
2304
+ sessionId: string,
2305
+ extra: Record<string, string> = {},
2306
+ ): Promise<{ access_token: string; refresh_token: string }> {
2307
+ const { code, verifier } = await consentAndGetCode(db, clientId, sessionId);
2308
+ const form = new URLSearchParams({
2309
+ grant_type: "authorization_code",
2310
+ code,
2311
+ client_id: clientId,
2312
+ redirect_uri: "https://app.example/cb",
2313
+ code_verifier: verifier,
2314
+ ...extra,
2315
+ });
2316
+ const res = await handleToken(db, tokenRequest(form), {
2317
+ issuer: ISSUER,
2318
+ loadServicesManifest: fixtureLoadServicesManifest,
2319
+ });
2320
+ expect(res.status).toBe(200);
2321
+ return (await res.json()) as { access_token: string; refresh_token: string };
2322
+ }
2323
+
2324
+ function familyIdFor(
2325
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
2326
+ refreshTokenPlaintext: string,
2327
+ ): string {
2328
+ const hash = createHash("sha256").update(refreshTokenPlaintext).digest("hex");
2329
+ const row = db
2330
+ .query<{ family_id: string }, [string]>(
2331
+ "SELECT family_id FROM tokens WHERE refresh_token_hash = ?",
2332
+ )
2333
+ .get(hash);
2334
+ if (!row) throw new Error("no row for refresh token");
2335
+ return row.family_id;
2336
+ }
2337
+
2338
+ test("initial auth-code issuance assigns a fresh family_id", async () => {
2339
+ const { db, cleanup } = await makeDb();
2340
+ try {
2341
+ const user = await createUser(db, "owner", "pw");
2342
+ const session = createSession(db, { userId: user.id });
2343
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2344
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
2345
+ const family = familyIdFor(db, initial.refresh_token);
2346
+ // Fresh UUID, not jti — backfill case is for legacy rows only.
2347
+ expect(family).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
2348
+ } finally {
2349
+ cleanup();
2350
+ }
2351
+ });
2352
+
2353
+ test("rotation preserves family_id across the chain", async () => {
2354
+ const { db, cleanup } = await makeDb();
2355
+ try {
2356
+ const user = await createUser(db, "owner", "pw");
2357
+ const session = createSession(db, { userId: user.id });
2358
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2359
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
2360
+ const family = familyIdFor(db, initial.refresh_token);
2361
+
2362
+ const refreshForm = new URLSearchParams({
2363
+ grant_type: "refresh_token",
2364
+ refresh_token: initial.refresh_token,
2365
+ client_id: reg.client.clientId,
2366
+ });
2367
+ const refreshRes = await handleToken(db, tokenRequest(refreshForm), {
2368
+ issuer: ISSUER,
2369
+ loadServicesManifest: fixtureLoadServicesManifest,
2370
+ });
2371
+ expect(refreshRes.status).toBe(200);
2372
+ const rotated = (await refreshRes.json()) as { refresh_token: string };
2373
+ expect(rotated.refresh_token).not.toBe(initial.refresh_token);
2374
+
2375
+ const rotatedFamily = familyIdFor(db, rotated.refresh_token);
2376
+ expect(rotatedFamily).toBe(family);
2377
+ } finally {
2378
+ cleanup();
2379
+ }
2380
+ });
2381
+
2382
+ test("replay of revoked refresh token revokes the entire family", async () => {
2383
+ const { db, cleanup } = await makeDb();
2384
+ try {
2385
+ const user = await createUser(db, "owner", "pw");
2386
+ const session = createSession(db, { userId: user.id });
2387
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2388
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
2389
+ const family = familyIdFor(db, initial.refresh_token);
2390
+
2391
+ // First rotation (legitimate client).
2392
+ const r1 = await handleToken(
2393
+ db,
2394
+ tokenRequest(
2395
+ new URLSearchParams({
2396
+ grant_type: "refresh_token",
2397
+ refresh_token: initial.refresh_token,
2398
+ client_id: reg.client.clientId,
2399
+ }),
2400
+ ),
2401
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
2402
+ );
2403
+ const rotated1 = (await r1.json()) as { refresh_token: string };
2404
+
2405
+ // Second rotation off the rotated token (still legitimate).
2406
+ const r2 = await handleToken(
2407
+ db,
2408
+ tokenRequest(
2409
+ new URLSearchParams({
2410
+ grant_type: "refresh_token",
2411
+ refresh_token: rotated1.refresh_token,
2412
+ client_id: reg.client.clientId,
2413
+ }),
2414
+ ),
2415
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
2416
+ );
2417
+ const rotated2 = (await r2.json()) as { refresh_token: string };
2418
+
2419
+ // Replay the ORIGINAL (already revoked at step 1). Should walk the
2420
+ // family and revoke every descendant — including rotated2, which was
2421
+ // still valid up to this point.
2422
+ const replay = await handleToken(
2423
+ db,
2424
+ tokenRequest(
2425
+ new URLSearchParams({
2426
+ grant_type: "refresh_token",
2427
+ refresh_token: initial.refresh_token,
2428
+ client_id: reg.client.clientId,
2429
+ }),
2430
+ ),
2431
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
2432
+ );
2433
+ expect(replay.status).toBe(400);
2434
+
2435
+ // Every row in the family is revoked.
2436
+ const live = db
2437
+ .query<{ n: number }, [string]>(
2438
+ "SELECT COUNT(*) AS n FROM tokens WHERE family_id = ? AND revoked_at IS NULL",
2439
+ )
2440
+ .get(family);
2441
+ expect(live?.n).toBe(0);
2442
+
2443
+ // The currently-live rotated2 token can no longer mint a new pair —
2444
+ // its row is now revoked, so the next refresh attempt is a replay too.
2445
+ const afterReplay = await handleToken(
2446
+ db,
2447
+ tokenRequest(
2448
+ new URLSearchParams({
2449
+ grant_type: "refresh_token",
2450
+ refresh_token: rotated2.refresh_token,
2451
+ client_id: reg.client.clientId,
2452
+ }),
2453
+ ),
2454
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
2455
+ );
2456
+ expect(afterReplay.status).toBe(400);
2457
+ } finally {
2458
+ cleanup();
2459
+ }
2460
+ });
2461
+
2462
+ test("/oauth/revoke refresh_token: revokes the row, second use rejected", async () => {
2463
+ const { db, cleanup } = await makeDb();
2464
+ try {
2465
+ const user = await createUser(db, "owner", "pw");
2466
+ const session = createSession(db, { userId: user.id });
2467
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2468
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
2469
+
2470
+ const revRes = await handleRevoke(
2471
+ db,
2472
+ revokeRequest(
2473
+ new URLSearchParams({
2474
+ token: initial.refresh_token,
2475
+ token_type_hint: "refresh_token",
2476
+ client_id: reg.client.clientId,
2477
+ }),
2478
+ ),
2479
+ { issuer: ISSUER },
2480
+ );
2481
+ expect(revRes.status).toBe(200);
2482
+
2483
+ // Idempotent — second revoke also 200.
2484
+ const revRes2 = await handleRevoke(
2485
+ db,
2486
+ revokeRequest(
2487
+ new URLSearchParams({
2488
+ token: initial.refresh_token,
2489
+ client_id: reg.client.clientId,
2490
+ }),
2491
+ ),
2492
+ { issuer: ISSUER },
2493
+ );
2494
+ expect(revRes2.status).toBe(200);
2495
+
2496
+ // The revoked refresh token cannot mint a new access token.
2497
+ const refreshAttempt = await handleToken(
2498
+ db,
2499
+ tokenRequest(
2500
+ new URLSearchParams({
2501
+ grant_type: "refresh_token",
2502
+ refresh_token: initial.refresh_token,
2503
+ client_id: reg.client.clientId,
2504
+ }),
2505
+ ),
2506
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
2507
+ );
2508
+ expect(refreshAttempt.status).toBe(400);
2509
+ const err = (await refreshAttempt.json()) as Record<string, unknown>;
2510
+ expect(err.error).toBe("invalid_grant");
2511
+ } finally {
2512
+ cleanup();
2513
+ }
2514
+ });
2515
+
2516
+ test("/oauth/revoke access_token: validateAccessToken rejects after revoke", async () => {
2517
+ const { db, cleanup } = await makeDb();
2518
+ try {
2519
+ const user = await createUser(db, "owner", "pw");
2520
+ const session = createSession(db, { userId: user.id });
2521
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2522
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id);
2523
+
2524
+ // Pre-revoke: token validates.
2525
+ const preCheck = await validateAccessToken(db, initial.access_token, ISSUER);
2526
+ expect(preCheck.payload.sub).toBe(user.id);
2527
+
2528
+ const revRes = await handleRevoke(
2529
+ db,
2530
+ revokeRequest(
2531
+ new URLSearchParams({
2532
+ token: initial.access_token,
2533
+ token_type_hint: "access_token",
2534
+ client_id: reg.client.clientId,
2535
+ }),
2536
+ ),
2537
+ { issuer: ISSUER },
2538
+ );
2539
+ expect(revRes.status).toBe(200);
2540
+
2541
+ // Post-revoke: token is rejected — signature still verifies, but the
2542
+ // jti's tokens row is marked revoked.
2543
+ await expect(validateAccessToken(db, initial.access_token, ISSUER)).rejects.toThrow(
2544
+ /revoked/,
2545
+ );
2546
+ } finally {
2547
+ cleanup();
2548
+ }
2549
+ });
2550
+
2551
+ test("/oauth/revoke unknown token returns 200 (no existence disclosure)", async () => {
2552
+ const { db, cleanup } = await makeDb();
2553
+ try {
2554
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2555
+ const res = await handleRevoke(
2556
+ db,
2557
+ revokeRequest(
2558
+ new URLSearchParams({
2559
+ token: "totally-not-a-real-token",
2560
+ client_id: reg.client.clientId,
2561
+ }),
2562
+ ),
2563
+ { issuer: ISSUER },
2564
+ );
2565
+ expect(res.status).toBe(200);
2566
+ } finally {
2567
+ cleanup();
2568
+ }
2569
+ });
2570
+
2571
+ test("/oauth/revoke missing token returns 400", async () => {
2572
+ const { db, cleanup } = await makeDb();
2573
+ try {
2574
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2575
+ const res = await handleRevoke(
2576
+ db,
2577
+ revokeRequest(new URLSearchParams({ client_id: reg.client.clientId })),
2578
+ { issuer: ISSUER },
2579
+ );
2580
+ expect(res.status).toBe(400);
2581
+ const err = (await res.json()) as Record<string, unknown>;
2582
+ expect(err.error).toBe("invalid_request");
2583
+ } finally {
2584
+ cleanup();
2585
+ }
2586
+ });
2587
+
2588
+ test("/oauth/revoke missing client_id returns 400", async () => {
2589
+ const { db, cleanup } = await makeDb();
2590
+ try {
2591
+ const res = await handleRevoke(
2592
+ db,
2593
+ revokeRequest(new URLSearchParams({ token: "anything" })),
2594
+ { issuer: ISSUER },
2595
+ );
2596
+ expect(res.status).toBe(400);
2597
+ const err = (await res.json()) as Record<string, unknown>;
2598
+ expect(err.error).toBe("invalid_request");
2599
+ } finally {
2600
+ cleanup();
2601
+ }
2602
+ });
2603
+
2604
+ test("/oauth/revoke confidential client without secret → 401", async () => {
2605
+ const { db, cleanup } = await makeDb();
2606
+ try {
2607
+ const user = await createUser(db, "owner", "pw");
2608
+ const session = createSession(db, { userId: user.id });
2609
+ const reg = registerClient(db, {
2610
+ redirectUris: ["https://app.example/cb"],
2611
+ confidential: true,
2612
+ });
2613
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id, {
2614
+ client_secret: reg.clientSecret ?? "",
2615
+ });
2616
+
2617
+ const res = await handleRevoke(
2618
+ db,
2619
+ revokeRequest(
2620
+ new URLSearchParams({
2621
+ token: initial.refresh_token,
2622
+ client_id: reg.client.clientId,
2623
+ // no client_secret
2624
+ }),
2625
+ ),
2626
+ { issuer: ISSUER },
2627
+ );
2628
+ expect(res.status).toBe(401);
2629
+ const err = (await res.json()) as Record<string, unknown>;
2630
+ expect(err.error).toBe("invalid_client");
2631
+ } finally {
2632
+ cleanup();
2633
+ }
2634
+ });
2635
+
2636
+ test("/oauth/revoke confidential client with correct secret → 200", async () => {
2637
+ const { db, cleanup } = await makeDb();
2638
+ try {
2639
+ const user = await createUser(db, "owner", "pw");
2640
+ const session = createSession(db, { userId: user.id });
2641
+ const reg = registerClient(db, {
2642
+ redirectUris: ["https://app.example/cb"],
2643
+ confidential: true,
2644
+ });
2645
+ const initial = await mintInitialPair(db, reg.client.clientId, user.id, session.id, {
2646
+ client_secret: reg.clientSecret ?? "",
2647
+ });
2648
+
2649
+ const res = await handleRevoke(
2650
+ db,
2651
+ revokeRequest(
2652
+ new URLSearchParams({
2653
+ token: initial.refresh_token,
2654
+ client_id: reg.client.clientId,
2655
+ client_secret: reg.clientSecret ?? "",
2656
+ }),
2657
+ ),
2658
+ { issuer: ISSUER },
2659
+ );
2660
+ expect(res.status).toBe(200);
2661
+ } finally {
2662
+ cleanup();
2663
+ }
2664
+ });
2665
+
2666
+ test("/oauth/revoke from a different client: 200 but row stays live", async () => {
2667
+ const { db, cleanup } = await makeDb();
2668
+ try {
2669
+ const user = await createUser(db, "owner", "pw");
2670
+ const session = createSession(db, { userId: user.id });
2671
+ const issuingClient = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2672
+ const otherClient = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2673
+ const initial = await mintInitialPair(db, issuingClient.client.clientId, user.id, session.id);
2674
+
2675
+ const res = await handleRevoke(
2676
+ db,
2677
+ revokeRequest(
2678
+ new URLSearchParams({
2679
+ token: initial.refresh_token,
2680
+ client_id: otherClient.client.clientId,
2681
+ }),
2682
+ ),
2683
+ { issuer: ISSUER },
2684
+ );
2685
+ // Spec-compliant 200, but the row should still be unrevoked.
2686
+ expect(res.status).toBe(200);
2687
+
2688
+ const hash = createHash("sha256").update(initial.refresh_token).digest("hex");
2689
+ const row = db
2690
+ .query<{ revoked_at: string | null }, [string]>(
2691
+ "SELECT revoked_at FROM tokens WHERE refresh_token_hash = ?",
2692
+ )
2693
+ .get(hash);
2694
+ expect(row?.revoked_at).toBeNull();
2695
+
2696
+ // The original client can still rotate it.
2697
+ const refreshRes = await handleToken(
2698
+ db,
2699
+ tokenRequest(
2700
+ new URLSearchParams({
2701
+ grant_type: "refresh_token",
2702
+ refresh_token: initial.refresh_token,
2703
+ client_id: issuingClient.client.clientId,
2704
+ }),
2705
+ ),
2706
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
2707
+ );
2708
+ expect(refreshRes.status).toBe(200);
2709
+ } finally {
2710
+ cleanup();
2711
+ }
2712
+ });
2713
+
2714
+ test("authorizationServerMetadata advertises revocation_endpoint", async () => {
2715
+ const res = authorizationServerMetadata({ issuer: ISSUER });
2716
+ const body = (await res.json()) as Record<string, unknown>;
2717
+ expect(body.revocation_endpoint).toBe(`${ISSUER}/oauth/revoke`);
2718
+ });
2719
+ });
2720
+
2721
+ // closes #75 — once the user has approved a scope-set for a client, the next
2722
+ // /oauth/authorize for the same client and a covered scope-set goes straight
2723
+ // to the auth-code redirect. Strict superset (incremental scope) and
2724
+ // revoked grants still show consent.
2725
+ describe("handleAuthorizeGet — skip consent when scope already granted (#75)", () => {
2726
+ test("first approval records grant; second flow with same scopes skips consent", async () => {
2727
+ const { db, cleanup } = await makeDb();
2728
+ try {
2729
+ const user = await createUser(db, "owner", "pw");
2730
+ const session = createSession(db, { userId: user.id });
2731
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2732
+ const { challenge } = makePkce();
2733
+
2734
+ const consentForm = new URLSearchParams({
2735
+ __action: "consent",
2736
+ __csrf: TEST_CSRF,
2737
+ approve: "yes",
2738
+ client_id: reg.client.clientId,
2739
+ redirect_uri: "https://app.example/cb",
2740
+ response_type: "code",
2741
+ scope: "vault:default:read scribe:transcribe",
2742
+ code_challenge: challenge,
2743
+ code_challenge_method: "S256",
2744
+ });
2745
+ const consentReq = new Request(`${ISSUER}/oauth/authorize`, {
2746
+ method: "POST",
2747
+ body: consentForm,
2748
+ headers: {
2749
+ "content-type": "application/x-www-form-urlencoded",
2750
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
2751
+ },
2752
+ });
2753
+ const consentRes = await handleAuthorizePost(db, consentReq, { issuer: ISSUER });
2754
+ expect(consentRes.status).toBe(302);
2755
+
2756
+ // Second flow, same scopes — skip consent.
2757
+ const getReq = new Request(
2758
+ authorizeUrl({
2759
+ client_id: reg.client.clientId,
2760
+ redirect_uri: "https://app.example/cb",
2761
+ response_type: "code",
2762
+ scope: "vault:default:read scribe:transcribe",
2763
+ code_challenge: challenge,
2764
+ code_challenge_method: "S256",
2765
+ state: "second",
2766
+ }),
2767
+ { headers: { cookie: buildSessionCookie(session.id, 86400) } },
2768
+ );
2769
+ const getRes = handleAuthorizeGet(db, getReq, { issuer: ISSUER });
2770
+ expect(getRes.status).toBe(302);
2771
+ const loc = new URL(getRes.headers.get("location") ?? "");
2772
+ expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
2773
+ expect(loc.searchParams.get("code")?.length).toBeGreaterThan(20);
2774
+ expect(loc.searchParams.get("state")).toBe("second");
2775
+ } finally {
2776
+ cleanup();
2777
+ }
2778
+ });
2779
+
2780
+ test("skip-consent emits an audit log line with client_id, user_id, and scopes (#120)", async () => {
2781
+ const { db, cleanup } = await makeDb();
2782
+ const originalLog = console.log;
2783
+ const lines: string[] = [];
2784
+ console.log = (...args: unknown[]) => {
2785
+ lines.push(args.map((a) => String(a)).join(" "));
2786
+ };
2787
+ try {
2788
+ const user = await createUser(db, "owner", "pw");
2789
+ const session = createSession(db, { userId: user.id });
2790
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2791
+ const { challenge } = makePkce();
2792
+ const { recordGrant } = await import("../grants.ts");
2793
+ recordGrant(db, user.id, reg.client.clientId, ["vault:default:read", "scribe:transcribe"]);
2794
+
2795
+ const getReq = new Request(
2796
+ authorizeUrl({
2797
+ client_id: reg.client.clientId,
2798
+ redirect_uri: "https://app.example/cb",
2799
+ response_type: "code",
2800
+ scope: "vault:default:read scribe:transcribe",
2801
+ code_challenge: challenge,
2802
+ code_challenge_method: "S256",
2803
+ state: "skip",
2804
+ }),
2805
+ { headers: { cookie: buildSessionCookie(session.id, 86400) } },
2806
+ );
2807
+ const getRes = handleAuthorizeGet(db, getReq, { issuer: ISSUER });
2808
+ expect(getRes.status).toBe(302);
2809
+
2810
+ const skip = lines.find((l) => l.startsWith("consent skipped:"));
2811
+ expect(skip).toBeDefined();
2812
+ expect(skip).toContain(`client_id=${reg.client.clientId}`);
2813
+ expect(skip).toContain(`user_id=${user.id}`);
2814
+ expect(skip).toContain("scopes=vault:default:read scribe:transcribe");
2815
+ } finally {
2816
+ console.log = originalLog;
2817
+ cleanup();
2818
+ }
2819
+ });
2820
+
2821
+ test("subset of granted scopes also skips consent", async () => {
2822
+ const { db, cleanup } = await makeDb();
2823
+ try {
2824
+ const user = await createUser(db, "owner", "pw");
2825
+ const session = createSession(db, { userId: user.id });
2826
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2827
+ const { challenge } = makePkce();
2828
+
2829
+ // Grant [a, b, c].
2830
+ const consentForm = new URLSearchParams({
2831
+ __action: "consent",
2832
+ __csrf: TEST_CSRF,
2833
+ approve: "yes",
2834
+ client_id: reg.client.clientId,
2835
+ redirect_uri: "https://app.example/cb",
2836
+ response_type: "code",
2837
+ scope: "vault:default:read vault:default:write scribe:transcribe",
2838
+ code_challenge: challenge,
2839
+ code_challenge_method: "S256",
2840
+ });
2841
+ await handleAuthorizePost(
2842
+ db,
2843
+ new Request(`${ISSUER}/oauth/authorize`, {
2844
+ method: "POST",
2845
+ body: consentForm,
2846
+ headers: {
2847
+ "content-type": "application/x-www-form-urlencoded",
2848
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
2849
+ },
2850
+ }),
2851
+ { issuer: ISSUER },
2852
+ );
2853
+
2854
+ // Re-flow with strict subset [a, c] — must skip.
2855
+ const getReq = new Request(
2856
+ authorizeUrl({
2857
+ client_id: reg.client.clientId,
2858
+ redirect_uri: "https://app.example/cb",
2859
+ response_type: "code",
2860
+ scope: "vault:default:read scribe:transcribe",
2861
+ code_challenge: challenge,
2862
+ code_challenge_method: "S256",
2863
+ }),
2864
+ { headers: { cookie: buildSessionCookie(session.id, 86400) } },
2865
+ );
2866
+ const getRes = handleAuthorizeGet(db, getReq, { issuer: ISSUER });
2867
+ expect(getRes.status).toBe(302);
2868
+ const loc = new URL(getRes.headers.get("location") ?? "");
2869
+ expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
2870
+ expect(loc.searchParams.get("code")?.length).toBeGreaterThan(20);
2871
+ } finally {
2872
+ cleanup();
2873
+ }
2874
+ });
2875
+
2876
+ test("strict superset shows consent (incremental scope grant)", async () => {
2877
+ const { db, cleanup } = await makeDb();
2878
+ try {
2879
+ const user = await createUser(db, "owner", "pw");
2880
+ const session = createSession(db, { userId: user.id });
2881
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2882
+ const { challenge } = makePkce();
2883
+
2884
+ // Grant [vault:default:read].
2885
+ await handleAuthorizePost(
2886
+ db,
2887
+ new Request(`${ISSUER}/oauth/authorize`, {
2888
+ method: "POST",
2889
+ body: new URLSearchParams({
2890
+ __action: "consent",
2891
+ __csrf: TEST_CSRF,
2892
+ approve: "yes",
2893
+ client_id: reg.client.clientId,
2894
+ redirect_uri: "https://app.example/cb",
2895
+ response_type: "code",
2896
+ scope: "vault:default:read",
2897
+ code_challenge: challenge,
2898
+ code_challenge_method: "S256",
2899
+ }),
2900
+ headers: {
2901
+ "content-type": "application/x-www-form-urlencoded",
2902
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
2903
+ },
2904
+ }),
2905
+ { issuer: ISSUER },
2906
+ );
2907
+
2908
+ // Re-flow asking for [vault:default:read, scribe:transcribe] — superset
2909
+ // → must render consent (200 HTML), not redirect with code.
2910
+ const getReq = new Request(
2911
+ authorizeUrl({
2912
+ client_id: reg.client.clientId,
2913
+ redirect_uri: "https://app.example/cb",
2914
+ response_type: "code",
2915
+ scope: "vault:default:read scribe:transcribe",
2916
+ code_challenge: challenge,
2917
+ code_challenge_method: "S256",
2918
+ }),
2919
+ { headers: { cookie: buildSessionCookie(session.id, 86400) } },
2920
+ );
2921
+ const getRes = handleAuthorizeGet(db, getReq, { issuer: ISSUER });
2922
+ expect(getRes.status).toBe(200);
2923
+ expect(getRes.headers.get("content-type")).toContain("text/html");
2924
+ const body = await getRes.text();
2925
+ // Both scopes appear on the consent page so the user knows they're
2926
+ // approving the new addition explicitly.
2927
+ expect(body).toContain("scribe:transcribe");
2928
+ } finally {
2929
+ cleanup();
2930
+ }
2931
+ });
2932
+
2933
+ test("revoke-grant brings consent back", async () => {
2934
+ const { db, cleanup } = await makeDb();
2935
+ try {
2936
+ const user = await createUser(db, "owner", "pw");
2937
+ const session = createSession(db, { userId: user.id });
2938
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
2939
+ const { challenge } = makePkce();
2940
+
2941
+ // Grant + verify skip works.
2942
+ await handleAuthorizePost(
2943
+ db,
2944
+ new Request(`${ISSUER}/oauth/authorize`, {
2945
+ method: "POST",
2946
+ body: new URLSearchParams({
2947
+ __action: "consent",
2948
+ __csrf: TEST_CSRF,
2949
+ approve: "yes",
2950
+ client_id: reg.client.clientId,
2951
+ redirect_uri: "https://app.example/cb",
2952
+ response_type: "code",
2953
+ scope: "vault:default:read",
2954
+ code_challenge: challenge,
2955
+ code_challenge_method: "S256",
2956
+ }),
2957
+ headers: {
2958
+ "content-type": "application/x-www-form-urlencoded",
2959
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
2960
+ },
2961
+ }),
2962
+ { issuer: ISSUER },
2963
+ );
2964
+
2965
+ // Revoke via the grants module directly (CLI runner is exercised in
2966
+ // auth.test.ts; here we just need the row gone).
2967
+ const { revokeGrant } = await import("../grants.ts");
2968
+ const removed = revokeGrant(db, user.id, reg.client.clientId);
2969
+ expect(removed).toBe(true);
2970
+
2971
+ // Now the same flow should render consent, not redirect.
2972
+ const getReq = new Request(
2973
+ authorizeUrl({
2974
+ client_id: reg.client.clientId,
2975
+ redirect_uri: "https://app.example/cb",
2976
+ response_type: "code",
2977
+ scope: "vault:default:read",
2978
+ code_challenge: challenge,
2979
+ code_challenge_method: "S256",
2980
+ }),
2981
+ { headers: { cookie: buildSessionCookie(session.id, 86400) } },
2982
+ );
2983
+ const getRes = handleAuthorizeGet(db, getReq, { issuer: ISSUER });
2984
+ expect(getRes.status).toBe(200);
2985
+ expect(getRes.headers.get("content-type")).toContain("text/html");
2986
+ } finally {
2987
+ cleanup();
2988
+ }
2989
+ });
2990
+
2991
+ test("unnamed vault scope always renders consent (picker required)", async () => {
2992
+ // Even if we somehow stored a grant matching an unnamed `vault:read`,
2993
+ // the picker is the only way to bind the scope to a specific vault.
2994
+ // The skip-consent path must defer to consent for unnamed vault verbs.
2995
+ const { db, cleanup } = await makeDb();
2996
+ try {
2997
+ const user = await createUser(db, "owner", "pw");
2998
+ const session = createSession(db, { userId: user.id });
2999
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
3000
+ const { challenge } = makePkce();
3001
+ // Pre-seed a grant with an unnamed scope — defensive, just in case.
3002
+ const { recordGrant } = await import("../grants.ts");
3003
+ recordGrant(db, user.id, reg.client.clientId, ["vault:read"]);
3004
+
3005
+ const getReq = new Request(
3006
+ authorizeUrl({
3007
+ client_id: reg.client.clientId,
3008
+ redirect_uri: "https://app.example/cb",
3009
+ response_type: "code",
3010
+ scope: "vault:read",
3011
+ code_challenge: challenge,
3012
+ code_challenge_method: "S256",
3013
+ }),
3014
+ { headers: { cookie: buildSessionCookie(session.id, 86400) } },
3015
+ );
3016
+ const getRes = handleAuthorizeGet(db, getReq, {
3017
+ issuer: ISSUER,
3018
+ loadServicesManifest: fixtureLoadServicesManifest,
3019
+ });
3020
+ expect(getRes.status).toBe(200);
3021
+ expect(getRes.headers.get("content-type")).toContain("text/html");
3022
+ } finally {
3023
+ cleanup();
3024
+ }
3025
+ });
3026
+
3027
+ test("re-registered client_id (different uuid) requires fresh consent", async () => {
3028
+ // Re-registration mints a new client_id; the grant row is keyed on
3029
+ // (user, client_id), so the new client has no prior grant. Consent
3030
+ // must show.
3031
+ const { db, cleanup } = await makeDb();
3032
+ try {
3033
+ const user = await createUser(db, "owner", "pw");
3034
+ const session = createSession(db, { userId: user.id });
3035
+ const oldReg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
3036
+ const { challenge } = makePkce();
3037
+
3038
+ // Grant for the old client.
3039
+ const { recordGrant } = await import("../grants.ts");
3040
+ recordGrant(db, user.id, oldReg.client.clientId, ["vault:default:read"]);
3041
+
3042
+ // Re-register — fresh client_id.
3043
+ const newReg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
3044
+ expect(newReg.client.clientId).not.toBe(oldReg.client.clientId);
3045
+
3046
+ const getReq = new Request(
3047
+ authorizeUrl({
3048
+ client_id: newReg.client.clientId,
3049
+ redirect_uri: "https://app.example/cb",
3050
+ response_type: "code",
3051
+ scope: "vault:default:read",
3052
+ code_challenge: challenge,
3053
+ code_challenge_method: "S256",
3054
+ }),
3055
+ { headers: { cookie: buildSessionCookie(session.id, 86400) } },
3056
+ );
3057
+ const getRes = handleAuthorizeGet(db, getReq, { issuer: ISSUER });
3058
+ expect(getRes.status).toBe(200);
3059
+ expect(getRes.headers.get("content-type")).toContain("text/html");
3060
+ } finally {
3061
+ cleanup();
3062
+ }
3063
+ });
3064
+
3065
+ test("consent submit unions new scopes into existing grant", async () => {
3066
+ // Direct check on the storage shape: grant [a, b], later approve [a, c],
3067
+ // the row should hold {a, b, c} so a future flow asking [b] still skips.
3068
+ const { db, cleanup } = await makeDb();
3069
+ try {
3070
+ const user = await createUser(db, "owner", "pw");
3071
+ const session = createSession(db, { userId: user.id });
3072
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
3073
+ const { challenge } = makePkce();
3074
+
3075
+ const submit = (scope: string) =>
3076
+ handleAuthorizePost(
3077
+ db,
3078
+ new Request(`${ISSUER}/oauth/authorize`, {
3079
+ method: "POST",
3080
+ body: new URLSearchParams({
3081
+ __action: "consent",
3082
+ __csrf: TEST_CSRF,
3083
+ approve: "yes",
3084
+ client_id: reg.client.clientId,
3085
+ redirect_uri: "https://app.example/cb",
3086
+ response_type: "code",
3087
+ scope,
3088
+ code_challenge: challenge,
3089
+ code_challenge_method: "S256",
3090
+ }),
3091
+ headers: {
3092
+ "content-type": "application/x-www-form-urlencoded",
3093
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
3094
+ },
3095
+ }),
3096
+ { issuer: ISSUER },
3097
+ );
3098
+
3099
+ await submit("vault:default:read vault:default:write");
3100
+ await submit("vault:default:read scribe:transcribe");
3101
+
3102
+ const { findGrant } = await import("../grants.ts");
3103
+ const grant = findGrant(db, user.id, reg.client.clientId);
3104
+ expect(grant).not.toBeNull();
3105
+ expect(new Set(grant?.scopes)).toEqual(
3106
+ new Set(["vault:default:read", "vault:default:write", "scribe:transcribe"]),
3107
+ );
3108
+ } finally {
3109
+ cleanup();
3110
+ }
3111
+ });
3112
+ });