@openparachute/hub 0.5.2 → 0.5.9-rc.6

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -3,13 +3,14 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { registerClient } from "../clients.ts";
6
+ import { getClient, registerClient } from "../clients.ts";
7
7
  import { CSRF_COOKIE_NAME } from "../csrf.ts";
8
8
  import { hubDbPath, openHubDb } from "../hub-db.ts";
9
- import { validateAccessToken } from "../jwt-sign.ts";
9
+ import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
10
10
  import {
11
11
  authorizationServerMetadata,
12
12
  buildServicesCatalog,
13
+ handleApproveClientPost,
13
14
  handleAuthorizeGet,
14
15
  handleAuthorizePost,
15
16
  handleRegister,
@@ -1033,10 +1034,37 @@ describe("handleToken — full OAuth dance", () => {
1033
1034
 
1034
1035
  // closes #81 — services catalog tells the client where vault lives so
1035
1036
  // notes doesn't have to re-probe /.well-known/parachute.json. A
1036
- // vault:read token only sees the vault entry.
1037
+ // `vault:default:read` token sees both the collapsed `vault` key
1038
+ // (backwards compat) AND the per-vault `vault:default` key (closes
1039
+ // #247 — pre-#247 only the collapsed key was emitted; consumers on
1040
+ // multi-vault hubs were forced to assume `/vault/default` and
1041
+ // collided).
1037
1042
  expect(tokenBody.services).toEqual({
1038
1043
  vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1044
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1039
1045
  });
1046
+
1047
+ // closes #215 reviewer F2 — Phase 1 code-grant access-token registry
1048
+ // exemption pinning. The access token and refresh token share `jti`
1049
+ // by design (signRefreshToken({ jti: access.jti, ... }) at the mint
1050
+ // site), so the `tokens` row keyed by the access-token jti IS the
1051
+ // shared row — refresh_token_hash is non-null, created_via is
1052
+ // 'oauth_refresh'. We deliberately don't write a separate per-jti
1053
+ // access-token row; revocation acts on the shared jti / family,
1054
+ // bounded by the 15-min access TTL.
1055
+ expect(payload.jti).toBeTruthy();
1056
+ const row = findTokenRowByJti(db, payload.jti as string);
1057
+ expect(row).not.toBeNull();
1058
+ expect(row?.createdVia).toBe("oauth_refresh");
1059
+ expect(row?.familyId).toBeTruthy();
1060
+ // Verify the registry has exactly one row for this code-grant
1061
+ // (not two — no separate access-token row).
1062
+ const rowCount = (
1063
+ db
1064
+ .query<{ n: number }, [string]>("SELECT COUNT(*) as n FROM tokens WHERE jti = ?")
1065
+ .get(payload.jti as string) ?? { n: 0 }
1066
+ ).n;
1067
+ expect(rowCount).toBe(1);
1040
1068
  } finally {
1041
1069
  cleanup();
1042
1070
  }
@@ -1522,6 +1550,156 @@ describe("handleToken — full OAuth dance", () => {
1522
1550
  vault: { url: `${ISSUER}/vault/work`, version: "0.3.0" },
1523
1551
  });
1524
1552
  });
1553
+
1554
+ // closes #247 — multi-vault correctness. Pre-#247 every vault collapsed
1555
+ // under the single `vault` key, so Notes' OAuthCallback always wrote
1556
+ // VaultRecord URL = paths[0] of the first vault row regardless of which
1557
+ // vault the token actually granted. Per-vault `vault:<name>` keys let
1558
+ // consumers route each grant to the correct vault URL.
1559
+ describe("services catalog — multi-vault per-vault keys (#247)", () => {
1560
+ // Real shape from a multi-vault hub: one `parachute-vault` row with N
1561
+ // paths, each path naming an instance. Aaron's setup verbatim (4
1562
+ // vaults: default, boulder, gitcoin, techne).
1563
+ const multiVaultManifest: ServicesManifest = {
1564
+ services: [
1565
+ {
1566
+ name: "parachute-vault",
1567
+ port: 1940,
1568
+ paths: ["/vault/default", "/vault/boulder", "/vault/gitcoin", "/vault/techne"],
1569
+ health: "/health",
1570
+ version: "0.4.4",
1571
+ },
1572
+ ],
1573
+ };
1574
+
1575
+ test("single-vault hub with broad scope: only collapsed `vault` key (unchanged)", () => {
1576
+ // Per-vault keys are noise on single-vault hubs — no disambiguation
1577
+ // is needed. Backwards compat for pre-popover clients matters here.
1578
+ expect(buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, ["vault:read"])).toEqual({
1579
+ vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1580
+ });
1581
+ });
1582
+
1583
+ test("single-vault hub with per-vault-narrowed scope: emits per-vault key too", () => {
1584
+ // A `vault:default:read` token is an explicit consumer signal that
1585
+ // the per-vault key matters — emit it even on a single-vault hub so
1586
+ // the consumer's `services["vault:default"]` lookup works uniformly
1587
+ // regardless of how many vaults the hub has.
1588
+ expect(buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, ["vault:default:read"])).toEqual({
1589
+ vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1590
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1591
+ });
1592
+ });
1593
+
1594
+ test("multi-vault hub with broad scope: emits every per-vault key + collapsed `vault`", () => {
1595
+ // Broad `vault:read` admits every vault on the hub. Per-#247
1596
+ // guidance: emit per-vault keys for all admitted vaults so the
1597
+ // consumer (Notes popover) can pick its target by name without
1598
+ // re-probing /.well-known/parachute.json.
1599
+ expect(buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:read"])).toEqual({
1600
+ // Collapsed key still emitted (first admitted path); backwards compat.
1601
+ vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1602
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1603
+ "vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1604
+ "vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
1605
+ "vault:techne": { url: `${ISSUER}/vault/techne`, version: "0.4.4" },
1606
+ });
1607
+ });
1608
+
1609
+ test("multi-vault hub with per-vault scope: only that vault's per-vault key", () => {
1610
+ // Aaron's "Connect boulder" flow: token has `vault:boulder:write`,
1611
+ // scope admits only boulder. Pre-#247 the catalog said `vault.url =
1612
+ // /vault/default` (WRONG), so Notes stored a /vault/default record
1613
+ // with scope `vault:boulder:write` — collision city as more vaults
1614
+ // got connected. Post-#247 the consumer reads
1615
+ // `services["vault:boulder"].url` which correctly says /vault/boulder.
1616
+ expect(buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:boulder:write"])).toEqual({
1617
+ // Collapsed `vault` points at boulder too — the only admitted
1618
+ // vault — so legacy consumers happen to land on the right URL even
1619
+ // though they have no per-vault awareness.
1620
+ vault: { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1621
+ "vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1622
+ });
1623
+ });
1624
+
1625
+ test("multi-vault hub with mixed scopes: per-vault keys for each narrowed vault", () => {
1626
+ // A token granting both `vault:boulder:read` and `vault:gitcoin:write`
1627
+ // admits exactly those two vaults; default and techne aren't reachable.
1628
+ expect(
1629
+ buildServicesCatalog(multiVaultManifest, ISSUER, [
1630
+ "vault:boulder:read",
1631
+ "vault:gitcoin:write",
1632
+ ]),
1633
+ ).toEqual({
1634
+ vault: { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1635
+ "vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1636
+ "vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
1637
+ });
1638
+ });
1639
+
1640
+ test("multi-vault hub: broad + per-vault scopes coexist; broad opens all vaults", () => {
1641
+ // A token that carries BOTH `vault:read` (broad) AND
1642
+ // `vault:boulder:write` (narrow) should land in the broad bucket
1643
+ // because the broad scope is more permissive — narrowing one verb
1644
+ // can't take away access the unnamed scope already granted.
1645
+ expect(
1646
+ buildServicesCatalog(multiVaultManifest, ISSUER, ["vault:read", "vault:boulder:write"]),
1647
+ ).toEqual({
1648
+ vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1649
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1650
+ "vault:boulder": { url: `${ISSUER}/vault/boulder`, version: "0.4.4" },
1651
+ "vault:gitcoin": { url: `${ISSUER}/vault/gitcoin`, version: "0.4.4" },
1652
+ "vault:techne": { url: `${ISSUER}/vault/techne`, version: "0.4.4" },
1653
+ });
1654
+ });
1655
+
1656
+ test("legacy per-vault rows (parachute-vault-<name>) also produce per-vault keys", () => {
1657
+ // Older multi-vault layout — one row per vault — should produce the
1658
+ // same catalog shape as the single-row-multi-path layout. The
1659
+ // `vaultInstanceNameFor` helper handles both via its
1660
+ // manifest-suffix fallback.
1661
+ const legacyManifest: ServicesManifest = {
1662
+ services: [
1663
+ {
1664
+ name: "parachute-vault",
1665
+ port: 1940,
1666
+ paths: ["/vault/default"],
1667
+ health: "/health",
1668
+ version: "0.4.4",
1669
+ },
1670
+ {
1671
+ name: "parachute-vault-work",
1672
+ port: 1941,
1673
+ paths: ["/vault/work"],
1674
+ health: "/health",
1675
+ version: "0.4.4",
1676
+ },
1677
+ ],
1678
+ };
1679
+ expect(buildServicesCatalog(legacyManifest, ISSUER, ["vault:read"])).toEqual({
1680
+ vault: { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1681
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.4.4" },
1682
+ "vault:work": { url: `${ISSUER}/vault/work`, version: "0.4.4" },
1683
+ });
1684
+ });
1685
+
1686
+ test("non-vault services unaffected — only one key per service, no per-instance variant", () => {
1687
+ // The per-vault-key expansion is vault-specific. scribe / notes /
1688
+ // third-party rows still emit one key per service.
1689
+ expect(
1690
+ buildServicesCatalog(FIXTURE_MANIFEST, ISSUER, [
1691
+ "vault:default:read",
1692
+ "scribe:transcribe",
1693
+ "notes:read",
1694
+ ]),
1695
+ ).toEqual({
1696
+ vault: { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1697
+ "vault:default": { url: `${ISSUER}/vault/default`, version: "0.3.0" },
1698
+ scribe: { url: `${ISSUER}/scribe`, version: "0.3.0-rc.1" },
1699
+ notes: { url: `${ISSUER}/notes`, version: "0.3.0" },
1700
+ });
1701
+ });
1702
+ });
1525
1703
  });
1526
1704
 
1527
1705
  // closes #72 — RFC 6749 §3.2.1 + §2.3.1: confidential clients must
@@ -2053,6 +2231,76 @@ describe("DCR approval gate (#74)", () => {
2053
2231
  const html = await res.text();
2054
2232
  expect(html).toContain("App not yet approved");
2055
2233
  expect(html).toContain("approve-client");
2234
+ // No vault hint → no vault row in approve-meta. Single-vault hubs +
2235
+ // pre-vault-popover clients leave the section omitted (#244).
2236
+ expect(html).not.toContain('approve-meta-label">vault');
2237
+ } finally {
2238
+ cleanup();
2239
+ }
2240
+ });
2241
+
2242
+ // closes #244 — vault hint surfaced in approve-pending UI. Notes#115
2243
+ // passes `vault=<name>` on `/oauth/authorize` for per-vault grants; hub's
2244
+ // approve page now displays it alongside the other client metadata so a
2245
+ // multi-vault operator can tell which vault they're approving for.
2246
+ test("authorize: pending client with vault hint → approve UI renders 'vault: <name>'", async () => {
2247
+ const { db, cleanup } = await makeDb();
2248
+ try {
2249
+ const reg = registerClient(db, {
2250
+ redirectUris: ["https://app.example/cb"],
2251
+ status: "pending",
2252
+ });
2253
+ const { challenge } = makePkce();
2254
+ const req = new Request(
2255
+ authorizeUrl({
2256
+ client_id: reg.client.clientId,
2257
+ redirect_uri: "https://app.example/cb",
2258
+ response_type: "code",
2259
+ code_challenge: challenge,
2260
+ code_challenge_method: "S256",
2261
+ scope: "vault:read",
2262
+ vault: "boulder",
2263
+ }),
2264
+ );
2265
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
2266
+ expect(res.status).toBe(403);
2267
+ const html = await res.text();
2268
+ expect(html).toContain("App not yet approved");
2269
+ // The vault hint surfaces as a labeled row in the approve-meta block.
2270
+ expect(html).toContain('approve-meta-label">vault');
2271
+ expect(html).toContain("boulder");
2272
+ } finally {
2273
+ cleanup();
2274
+ }
2275
+ });
2276
+
2277
+ test("authorize: pending client with empty vault param → no vault row", async () => {
2278
+ // Defensive: `vault=` with empty value normalizes to undefined so the
2279
+ // UI doesn't render a blank vault label. Easy to hit if a client builds
2280
+ // the URL via URLSearchParams.set("vault", someMaybeEmptyVar).
2281
+ const { db, cleanup } = await makeDb();
2282
+ try {
2283
+ const reg = registerClient(db, {
2284
+ redirectUris: ["https://app.example/cb"],
2285
+ status: "pending",
2286
+ });
2287
+ const { challenge } = makePkce();
2288
+ const req = new Request(
2289
+ authorizeUrl({
2290
+ client_id: reg.client.clientId,
2291
+ redirect_uri: "https://app.example/cb",
2292
+ response_type: "code",
2293
+ code_challenge: challenge,
2294
+ code_challenge_method: "S256",
2295
+ scope: "vault:read",
2296
+ vault: "",
2297
+ }),
2298
+ );
2299
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
2300
+ expect(res.status).toBe(403);
2301
+ const html = await res.text();
2302
+ expect(html).toContain("App not yet approved");
2303
+ expect(html).not.toContain('approve-meta-label">vault');
2056
2304
  } finally {
2057
2305
  cleanup();
2058
2306
  }
@@ -2102,6 +2350,13 @@ describe("DCR approval gate (#74)", () => {
2102
2350
  const body = (await res.json()) as Record<string, unknown>;
2103
2351
  expect(body.error).toBe("invalid_client");
2104
2352
  expect(body.error_description).toContain("not been approved");
2353
+ // Surface the inline-approval affordances so consumers (Notes, future
2354
+ // cross-origin SPAs) can deep-link the operator to a browser-based
2355
+ // approve flow without dropping to a terminal.
2356
+ expect(body.approve_url).toBe(
2357
+ `${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
2358
+ );
2359
+ expect(body.cli_alternative).toBe(`parachute auth approve-client ${reg.client.clientId}`);
2105
2360
  } finally {
2106
2361
  cleanup();
2107
2362
  }
@@ -2131,6 +2386,13 @@ describe("DCR approval gate (#74)", () => {
2131
2386
  expect(res.status).toBe(401);
2132
2387
  const body = (await res.json()) as Record<string, unknown>;
2133
2388
  expect(body.error).toBe("invalid_client");
2389
+ // Same pending-affordance shape on the refresh path: a long-lived
2390
+ // OAuth client whose row was unapproved between issuance and refresh
2391
+ // hits this branch and surfaces the same approve_url + cli_alternative.
2392
+ expect(body.approve_url).toBe(
2393
+ `${ISSUER}/admin/approve-client/${encodeURIComponent(reg.client.clientId)}`,
2394
+ );
2395
+ expect(body.cli_alternative).toBe(`parachute auth approve-client ${reg.client.clientId}`);
2134
2396
  } finally {
2135
2397
  cleanup();
2136
2398
  }
@@ -2244,6 +2506,226 @@ describe("DCR approval gate (#74)", () => {
2244
2506
  });
2245
2507
  });
2246
2508
 
2509
+ // closes #199 — DCR auto-approve for the operator's own browser. A valid
2510
+ // `parachute_hub_session` cookie indicates the operator is authenticated as
2511
+ // themselves; combined with a same-origin Origin/Referer (the CSRF gate)
2512
+ // that's enough to skip the manual `parachute auth approve-client` step.
2513
+ describe("DCR auto-approve via session cookie (#199)", () => {
2514
+ const SESSION_COOKIE_TTL_S = Math.floor(SESSION_TTL_MS / 1000);
2515
+
2516
+ function registerRequest(
2517
+ headers: Record<string, string>,
2518
+ bodyExtra: Record<string, unknown> = {},
2519
+ ): Request {
2520
+ return new Request(`${ISSUER}/oauth/register`, {
2521
+ method: "POST",
2522
+ body: JSON.stringify({
2523
+ redirect_uris: ["https://app.example/cb"],
2524
+ ...bodyExtra,
2525
+ }),
2526
+ headers: { "content-type": "application/json", ...headers },
2527
+ });
2528
+ }
2529
+
2530
+ test("valid session cookie + matching Origin → status approved (response + DB)", async () => {
2531
+ const { db, cleanup } = await makeDb();
2532
+ try {
2533
+ const user = await createUser(db, "owner", "pw");
2534
+ const session = createSession(db, { userId: user.id });
2535
+ const req = registerRequest({
2536
+ cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
2537
+ origin: ISSUER,
2538
+ });
2539
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2540
+ expect(res.status).toBe(201);
2541
+ const body = (await res.json()) as Record<string, unknown>;
2542
+ expect(body.status).toBe("approved");
2543
+ // Persisted, not just response-shaped.
2544
+ const row = getClient(db, body.client_id as string);
2545
+ expect(row?.status).toBe("approved");
2546
+ } finally {
2547
+ cleanup();
2548
+ }
2549
+ });
2550
+
2551
+ test("valid session cookie + cross-origin Origin → status pending (CSRF defense)", async () => {
2552
+ const { db, cleanup } = await makeDb();
2553
+ try {
2554
+ const user = await createUser(db, "owner", "pw");
2555
+ const session = createSession(db, { userId: user.id });
2556
+ const req = registerRequest({
2557
+ cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
2558
+ origin: "https://attacker.example",
2559
+ });
2560
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2561
+ expect(res.status).toBe(201);
2562
+ const body = (await res.json()) as Record<string, unknown>;
2563
+ expect(body.status).toBe("pending");
2564
+ } finally {
2565
+ cleanup();
2566
+ }
2567
+ });
2568
+
2569
+ test("valid session cookie + Origin: 'null' (opaque/sandbox iframe) → pending", async () => {
2570
+ // Sandbox iframes (`<iframe sandbox>` without `allow-same-origin`),
2571
+ // `data:`/`file:` documents, and some privacy contexts send the literal
2572
+ // string `Origin: null` rather than omitting the header. `new URL("null")`
2573
+ // throws → isSameOriginRequest's try/catch returns false → DCR stays
2574
+ // pending. This test pins that invariant: an opaque-origin caller does
2575
+ // NOT ride the cookie path even with a valid session, because we can't
2576
+ // prove the request came from the issuer's own origin.
2577
+ const { db, cleanup } = await makeDb();
2578
+ try {
2579
+ const user = await createUser(db, "owner", "pw");
2580
+ const session = createSession(db, { userId: user.id });
2581
+ const req = registerRequest({
2582
+ cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
2583
+ origin: "null",
2584
+ });
2585
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2586
+ expect(res.status).toBe(201);
2587
+ const body = (await res.json()) as Record<string, unknown>;
2588
+ expect(body.status).toBe("pending");
2589
+ // Persisted, not just response-shaped.
2590
+ const row = getClient(db, body.client_id as string);
2591
+ expect(row?.status).toBe("pending");
2592
+ } finally {
2593
+ cleanup();
2594
+ }
2595
+ });
2596
+
2597
+ test("valid session cookie + Origin matching exact origin (port included) → approved", async () => {
2598
+ // URL.origin includes scheme + host + port, so a port-mismatched Origin
2599
+ // must NOT match. https://hub.example:8443 ≠ https://hub.example.
2600
+ const { db, cleanup } = await makeDb();
2601
+ try {
2602
+ const issuer = "https://hub.example:8443";
2603
+ const user = await createUser(db, "owner", "pw");
2604
+ const session = createSession(db, { userId: user.id });
2605
+
2606
+ // Exact match (scheme + host + port) → approved.
2607
+ const okReq = registerRequest({
2608
+ cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
2609
+ origin: "https://hub.example:8443",
2610
+ });
2611
+ const okRes = await handleRegister(db, okReq, { issuer });
2612
+ expect(((await okRes.json()) as Record<string, unknown>).status).toBe("approved");
2613
+
2614
+ // Port-mismatched Origin (default 443 vs 8443) → pending.
2615
+ const badReq = registerRequest({
2616
+ cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
2617
+ origin: "https://hub.example",
2618
+ });
2619
+ const badRes = await handleRegister(db, badReq, { issuer });
2620
+ expect(((await badRes.json()) as Record<string, unknown>).status).toBe("pending");
2621
+ } finally {
2622
+ cleanup();
2623
+ }
2624
+ });
2625
+
2626
+ test("valid session cookie + matching Referer (no Origin) → approved (Referer fallback)", async () => {
2627
+ const { db, cleanup } = await makeDb();
2628
+ try {
2629
+ const user = await createUser(db, "owner", "pw");
2630
+ const session = createSession(db, { userId: user.id });
2631
+ const req = registerRequest({
2632
+ cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
2633
+ referer: `${ISSUER}/notes/`,
2634
+ });
2635
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2636
+ const body = (await res.json()) as Record<string, unknown>;
2637
+ expect(body.status).toBe("approved");
2638
+ } finally {
2639
+ cleanup();
2640
+ }
2641
+ });
2642
+
2643
+ test("valid session cookie + no Origin AND no Referer → pending (deny without proof of origin)", async () => {
2644
+ const { db, cleanup } = await makeDb();
2645
+ try {
2646
+ const user = await createUser(db, "owner", "pw");
2647
+ const session = createSession(db, { userId: user.id });
2648
+ const req = registerRequest({
2649
+ cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
2650
+ });
2651
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2652
+ const body = (await res.json()) as Record<string, unknown>;
2653
+ expect(body.status).toBe("pending");
2654
+ } finally {
2655
+ cleanup();
2656
+ }
2657
+ });
2658
+
2659
+ test("expired session cookie + matching Origin → pending (expiry check)", async () => {
2660
+ const { db, cleanup } = await makeDb();
2661
+ try {
2662
+ const user = await createUser(db, "owner", "pw");
2663
+ // Session created in the "now()" frame, but handleRegister sees a much
2664
+ // later clock — findSession (via findActiveSession) treats it as expired.
2665
+ const session = createSession(db, { userId: user.id });
2666
+ const future = new Date(Date.now() + SESSION_TTL_MS + 60_000);
2667
+ const req = registerRequest({
2668
+ cookie: buildSessionCookie(session.id, SESSION_COOKIE_TTL_S),
2669
+ origin: ISSUER,
2670
+ });
2671
+ const res = await handleRegister(db, req, { issuer: ISSUER, now: () => future });
2672
+ const body = (await res.json()) as Record<string, unknown>;
2673
+ expect(body.status).toBe("pending");
2674
+ } finally {
2675
+ cleanup();
2676
+ }
2677
+ });
2678
+
2679
+ test("invalid session cookie (id not in DB) + matching Origin → pending", async () => {
2680
+ const { db, cleanup } = await makeDb();
2681
+ try {
2682
+ const req = registerRequest({
2683
+ cookie: buildSessionCookie("not-a-real-session-id", SESSION_COOKIE_TTL_S),
2684
+ origin: ISSUER,
2685
+ });
2686
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2687
+ const body = (await res.json()) as Record<string, unknown>;
2688
+ expect(body.status).toBe("pending");
2689
+ } finally {
2690
+ cleanup();
2691
+ }
2692
+ });
2693
+
2694
+ test("no cookie at all → pending (current public-DCR behavior)", async () => {
2695
+ const { db, cleanup } = await makeDb();
2696
+ try {
2697
+ const req = registerRequest({ origin: ISSUER });
2698
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2699
+ const body = (await res.json()) as Record<string, unknown>;
2700
+ expect(body.status).toBe("pending");
2701
+ } finally {
2702
+ cleanup();
2703
+ }
2704
+ });
2705
+
2706
+ test("operator-bearer header (existing path) still → approved (regression)", async () => {
2707
+ // The new cookie-based path must not regress the bearer-based path that
2708
+ // first-party install (#74) depends on. Same setup as the #74 test, no
2709
+ // cookie supplied — bearer alone must continue to land approved.
2710
+ const { db, cleanup } = await makeDb();
2711
+ try {
2712
+ const { rotateSigningKey } = await import("../signing-keys.ts");
2713
+ const { mintOperatorToken } = await import("../operator-token.ts");
2714
+ rotateSigningKey(db);
2715
+ const user = await createUser(db, "owner", "pw");
2716
+ const operator = await mintOperatorToken(db, user.id, { issuer: ISSUER });
2717
+
2718
+ const req = registerRequest({ authorization: `Bearer ${operator.token}` });
2719
+ const res = await handleRegister(db, req, { issuer: ISSUER });
2720
+ expect(res.status).toBe(201);
2721
+ const body = (await res.json()) as Record<string, unknown>;
2722
+ expect(body.status).toBe("approved");
2723
+ } finally {
2724
+ cleanup();
2725
+ }
2726
+ });
2727
+ });
2728
+
2247
2729
  // closes #73 — RFC 6749 §6 refresh-token rotation, RFC 6819 §5.2.2.3 replay
2248
2730
  // detection (family-wide revocation), RFC 7009 token revocation.
2249
2731
  describe("refresh-token rotation + /oauth/revoke (#73)", () => {
@@ -3110,3 +3592,518 @@ describe("handleAuthorizeGet — skip consent when scope already granted (#75)",
3110
3592
  }
3111
3593
  });
3112
3594
  });
3595
+
3596
+ // closes #208 — inline "Approve this app" form on the pending-client page
3597
+ // (cross-origin SPA recovery). Same security model as #199/#200 DCR
3598
+ // auto-approve: valid session + matching Origin = trusted operator. The
3599
+ // CSRF token is the third belt — a cross-origin POST with a leaked session
3600
+ // cookie still fails because the rendered token won't match.
3601
+ describe("inline approve button on pending /oauth/authorize (#208)", () => {
3602
+ const SESSION_COOKIE_TTL_S = Math.floor(SESSION_TTL_MS / 1000);
3603
+
3604
+ function pendingAuthorizeUrl(clientId: string): string {
3605
+ const { challenge } = makePkce();
3606
+ return authorizeUrl({
3607
+ client_id: clientId,
3608
+ redirect_uri: "https://app.example/cb",
3609
+ response_type: "code",
3610
+ code_challenge: challenge,
3611
+ code_challenge_method: "S256",
3612
+ scope: "vault:read",
3613
+ state: "rt-208",
3614
+ });
3615
+ }
3616
+
3617
+ test("session absent → page renders WITHOUT approve form (CLI-only fallback)", async () => {
3618
+ // Regression: pre-#208 behavior preserved when no session cookie is
3619
+ // present. The CLI-fallback message must still be visible so an operator
3620
+ // who arrived from a fresh browser knows what to do.
3621
+ const { db, cleanup } = await makeDb();
3622
+ try {
3623
+ const reg = registerClient(db, {
3624
+ redirectUris: ["https://app.example/cb"],
3625
+ clientName: "MyApp",
3626
+ status: "pending",
3627
+ });
3628
+ const req = new Request(pendingAuthorizeUrl(reg.client.clientId));
3629
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
3630
+ expect(res.status).toBe(403);
3631
+ const html = await res.text();
3632
+ expect(html).toContain("App not yet approved");
3633
+ // CLI-fallback message present — the only way to recover without a session.
3634
+ expect(html).toContain("approve-client");
3635
+ // No form element pointing at the approve endpoint.
3636
+ expect(html).not.toContain('action="/oauth/authorize/approve"');
3637
+ } finally {
3638
+ cleanup();
3639
+ }
3640
+ });
3641
+
3642
+ test("session valid + matching Origin → page renders WITH approve form + CSRF token", async () => {
3643
+ const { db, cleanup } = await makeDb();
3644
+ try {
3645
+ const user = await createUser(db, "owner", "pw");
3646
+ const session = createSession(db, { userId: user.id });
3647
+ const reg = registerClient(db, {
3648
+ redirectUris: ["https://app.example/cb"],
3649
+ clientName: "MyApp",
3650
+ status: "pending",
3651
+ });
3652
+ const req = new Request(pendingAuthorizeUrl(reg.client.clientId), {
3653
+ headers: {
3654
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
3655
+ origin: ISSUER,
3656
+ },
3657
+ });
3658
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
3659
+ expect(res.status).toBe(403);
3660
+ const html = await res.text();
3661
+ expect(html).toContain("App not yet approved");
3662
+ // The form posts to the approve endpoint
3663
+ expect(html).toContain('action="/oauth/authorize/approve"');
3664
+ expect(html).toContain('name="client_id"');
3665
+ expect(html).toContain(`value="${reg.client.clientId}"`);
3666
+ // CSRF token present in the form
3667
+ expect(html).toContain(`value="${TEST_CSRF}"`);
3668
+ // return_to carries the original authorize URL so the post-approve
3669
+ // redirect lands the operator back on the same flow.
3670
+ expect(html).toContain('name="return_to"');
3671
+ expect(html).toContain("/oauth/authorize?");
3672
+ expect(html).toContain("rt-208"); // state echoed via return_to URL
3673
+ // Display fields present so operator can verify what they're approving.
3674
+ expect(html).toContain("MyApp");
3675
+ expect(html).toContain(reg.client.clientId);
3676
+ expect(html).toContain("https://app.example/cb");
3677
+ // CLI fallback still visible.
3678
+ expect(html).toContain("approve-client");
3679
+ } finally {
3680
+ cleanup();
3681
+ }
3682
+ });
3683
+
3684
+ test("approve POST happy path: CSRF + session + matching Origin → DB flips approved + 302 to authorize URL", async () => {
3685
+ const { db, cleanup } = await makeDb();
3686
+ try {
3687
+ const user = await createUser(db, "owner", "pw");
3688
+ const session = createSession(db, { userId: user.id });
3689
+ const reg = registerClient(db, {
3690
+ redirectUris: ["https://app.example/cb"],
3691
+ status: "pending",
3692
+ });
3693
+ const returnTo = `/oauth/authorize?client_id=${reg.client.clientId}&state=rt-208`;
3694
+ const form = new URLSearchParams({
3695
+ __csrf: TEST_CSRF,
3696
+ client_id: reg.client.clientId,
3697
+ return_to: returnTo,
3698
+ });
3699
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
3700
+ method: "POST",
3701
+ body: form,
3702
+ headers: {
3703
+ "content-type": "application/x-www-form-urlencoded",
3704
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
3705
+ origin: ISSUER,
3706
+ },
3707
+ });
3708
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
3709
+ expect(res.status).toBe(302);
3710
+ expect(res.headers.get("location")).toBe(returnTo);
3711
+ // DB row flipped, not just response-shaped.
3712
+ const row = getClient(db, reg.client.clientId);
3713
+ expect(row?.status).toBe("approved");
3714
+ } finally {
3715
+ cleanup();
3716
+ }
3717
+ });
3718
+
3719
+ test("approve POST: invalid CSRF → 403", async () => {
3720
+ const { db, cleanup } = await makeDb();
3721
+ try {
3722
+ const user = await createUser(db, "owner", "pw");
3723
+ const session = createSession(db, { userId: user.id });
3724
+ const reg = registerClient(db, {
3725
+ redirectUris: ["https://app.example/cb"],
3726
+ status: "pending",
3727
+ });
3728
+ const form = new URLSearchParams({
3729
+ __csrf: "wrong-token",
3730
+ client_id: reg.client.clientId,
3731
+ return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
3732
+ });
3733
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
3734
+ method: "POST",
3735
+ body: form,
3736
+ headers: {
3737
+ "content-type": "application/x-www-form-urlencoded",
3738
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
3739
+ origin: ISSUER,
3740
+ },
3741
+ });
3742
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
3743
+ expect(res.status).toBe(403);
3744
+ // Row stays pending.
3745
+ const row = getClient(db, reg.client.clientId);
3746
+ expect(row?.status).toBe("pending");
3747
+ } finally {
3748
+ cleanup();
3749
+ }
3750
+ });
3751
+
3752
+ test("approve POST: no session cookie → 401", async () => {
3753
+ const { db, cleanup } = await makeDb();
3754
+ try {
3755
+ const reg = registerClient(db, {
3756
+ redirectUris: ["https://app.example/cb"],
3757
+ status: "pending",
3758
+ });
3759
+ const form = new URLSearchParams({
3760
+ __csrf: TEST_CSRF,
3761
+ client_id: reg.client.clientId,
3762
+ return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
3763
+ });
3764
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
3765
+ method: "POST",
3766
+ body: form,
3767
+ headers: {
3768
+ "content-type": "application/x-www-form-urlencoded",
3769
+ cookie: CSRF_COOKIE,
3770
+ origin: ISSUER,
3771
+ },
3772
+ });
3773
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
3774
+ expect(res.status).toBe(401);
3775
+ // Row stays pending.
3776
+ const row = getClient(db, reg.client.clientId);
3777
+ expect(row?.status).toBe("pending");
3778
+ } finally {
3779
+ cleanup();
3780
+ }
3781
+ });
3782
+
3783
+ test("approve POST: cross-origin Origin → 403 (CSRF defense)", async () => {
3784
+ const { db, cleanup } = await makeDb();
3785
+ try {
3786
+ const user = await createUser(db, "owner", "pw");
3787
+ const session = createSession(db, { userId: user.id });
3788
+ const reg = registerClient(db, {
3789
+ redirectUris: ["https://app.example/cb"],
3790
+ status: "pending",
3791
+ });
3792
+ const form = new URLSearchParams({
3793
+ __csrf: TEST_CSRF,
3794
+ client_id: reg.client.clientId,
3795
+ return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
3796
+ });
3797
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
3798
+ method: "POST",
3799
+ body: form,
3800
+ headers: {
3801
+ "content-type": "application/x-www-form-urlencoded",
3802
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
3803
+ origin: "https://attacker.example",
3804
+ },
3805
+ });
3806
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
3807
+ expect(res.status).toBe(403);
3808
+ // Row stays pending.
3809
+ const row = getClient(db, reg.client.clientId);
3810
+ expect(row?.status).toBe("pending");
3811
+ } finally {
3812
+ cleanup();
3813
+ }
3814
+ });
3815
+
3816
+ test("approve POST: Origin: 'null' (sandbox iframe / opaque origin) → 403", async () => {
3817
+ // Opaque-origin contexts (sandboxed iframes, some `data:` and `file:`
3818
+ // pages) send the literal string "null" as the Origin header. The DCR
3819
+ // /register path covers this; the inline-approve endpoint must reject it
3820
+ // too. isSameOriginRequest() handles this correctly because new URL("null")
3821
+ // throws → returns false; this test pins that contract.
3822
+ const { db, cleanup } = await makeDb();
3823
+ try {
3824
+ const user = await createUser(db, "owner", "pw");
3825
+ const session = createSession(db, { userId: user.id });
3826
+ const reg = registerClient(db, {
3827
+ redirectUris: ["https://app.example/cb"],
3828
+ status: "pending",
3829
+ });
3830
+ const form = new URLSearchParams({
3831
+ __csrf: TEST_CSRF,
3832
+ client_id: reg.client.clientId,
3833
+ return_to: `/oauth/authorize?client_id=${reg.client.clientId}`,
3834
+ });
3835
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
3836
+ method: "POST",
3837
+ body: form,
3838
+ headers: {
3839
+ "content-type": "application/x-www-form-urlencoded",
3840
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
3841
+ origin: "null",
3842
+ },
3843
+ });
3844
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
3845
+ expect(res.status).toBe(403);
3846
+ // Row stays pending.
3847
+ const row = getClient(db, reg.client.clientId);
3848
+ expect(row?.status).toBe("pending");
3849
+ } finally {
3850
+ cleanup();
3851
+ }
3852
+ });
3853
+
3854
+ test("approve POST: idempotent on already-approved client (double-click / refresh)", async () => {
3855
+ // approveClient() short-circuits if the row is already approved
3856
+ // (clients.ts:153). A double-click or page refresh should not error —
3857
+ // the second POST also succeeds with a 302 to return_to and the row
3858
+ // stays approved. This pins idempotency end-to-end.
3859
+ const { db, cleanup } = await makeDb();
3860
+ try {
3861
+ const user = await createUser(db, "owner", "pw");
3862
+ const session = createSession(db, { userId: user.id });
3863
+ const reg = registerClient(db, {
3864
+ redirectUris: ["https://app.example/cb"],
3865
+ status: "pending",
3866
+ });
3867
+ const returnTo = `/oauth/authorize?client_id=${reg.client.clientId}&state=rt-208`;
3868
+ const buildReq = () => {
3869
+ const form = new URLSearchParams({
3870
+ __csrf: TEST_CSRF,
3871
+ client_id: reg.client.clientId,
3872
+ return_to: returnTo,
3873
+ });
3874
+ return new Request(`${ISSUER}/oauth/authorize/approve`, {
3875
+ method: "POST",
3876
+ body: form,
3877
+ headers: {
3878
+ "content-type": "application/x-www-form-urlencoded",
3879
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
3880
+ origin: ISSUER,
3881
+ },
3882
+ });
3883
+ };
3884
+
3885
+ // First POST: pending → approved.
3886
+ const first = await handleApproveClientPost(db, buildReq(), { issuer: ISSUER });
3887
+ expect(first.status).toBe(302);
3888
+ expect(first.headers.get("location")).toBe(returnTo);
3889
+ expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
3890
+
3891
+ // Second POST (same client_id, same form): also succeeds, no error.
3892
+ const second = await handleApproveClientPost(db, buildReq(), { issuer: ISSUER });
3893
+ expect(second.status).toBe(302);
3894
+ expect(second.headers.get("location")).toBe(returnTo);
3895
+ expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
3896
+ } finally {
3897
+ cleanup();
3898
+ }
3899
+ });
3900
+
3901
+ test("approve POST: unknown client_id → 404", async () => {
3902
+ const { db, cleanup } = await makeDb();
3903
+ try {
3904
+ const user = await createUser(db, "owner", "pw");
3905
+ const session = createSession(db, { userId: user.id });
3906
+ const form = new URLSearchParams({
3907
+ __csrf: TEST_CSRF,
3908
+ client_id: "no-such-client-id",
3909
+ return_to: "/oauth/authorize?client_id=no-such-client-id",
3910
+ });
3911
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
3912
+ method: "POST",
3913
+ body: form,
3914
+ headers: {
3915
+ "content-type": "application/x-www-form-urlencoded",
3916
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
3917
+ origin: ISSUER,
3918
+ },
3919
+ });
3920
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
3921
+ expect(res.status).toBe(404);
3922
+ } finally {
3923
+ cleanup();
3924
+ }
3925
+ });
3926
+
3927
+ test("approve POST: malicious return_to (absolute URL) → 400 (open-redirect defense)", async () => {
3928
+ // The form must always supply a hub-relative /oauth/authorize?... URL.
3929
+ // Anything else is either an open-redirect attempt or a misuse — refuse
3930
+ // to follow it. return_to is validated BEFORE the DB mutation, so a bad
3931
+ // value also leaves the client row at status=pending.
3932
+ const { db, cleanup } = await makeDb();
3933
+ try {
3934
+ const user = await createUser(db, "owner", "pw");
3935
+ const session = createSession(db, { userId: user.id });
3936
+ const reg = registerClient(db, {
3937
+ redirectUris: ["https://app.example/cb"],
3938
+ status: "pending",
3939
+ });
3940
+ const form = new URLSearchParams({
3941
+ __csrf: TEST_CSRF,
3942
+ client_id: reg.client.clientId,
3943
+ return_to: "https://evil.example/steal",
3944
+ });
3945
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
3946
+ method: "POST",
3947
+ body: form,
3948
+ headers: {
3949
+ "content-type": "application/x-www-form-urlencoded",
3950
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
3951
+ origin: ISSUER,
3952
+ },
3953
+ });
3954
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
3955
+ expect(res.status).toBe(400);
3956
+ // No redirect to evil.example.
3957
+ expect(res.headers.get("location")).toBeNull();
3958
+ // DB row remains pending — validate-before-mutate ordering.
3959
+ const row = getClient(db, reg.client.clientId);
3960
+ expect(row?.status).toBe("pending");
3961
+ } finally {
3962
+ cleanup();
3963
+ }
3964
+ });
3965
+
3966
+ test("approve POST: scheme-relative return_to (//evil.example) → 400", async () => {
3967
+ // `//evil.example/foo` is a scheme-relative URL — browsers resolve it
3968
+ // against the current scheme to land at https://evil.example/foo.
3969
+ // Reject anything that doesn't start with a single `/`.
3970
+ const { db, cleanup } = await makeDb();
3971
+ try {
3972
+ const user = await createUser(db, "owner", "pw");
3973
+ const session = createSession(db, { userId: user.id });
3974
+ const reg = registerClient(db, {
3975
+ redirectUris: ["https://app.example/cb"],
3976
+ status: "pending",
3977
+ });
3978
+ const form = new URLSearchParams({
3979
+ __csrf: TEST_CSRF,
3980
+ client_id: reg.client.clientId,
3981
+ return_to: "//evil.example/foo",
3982
+ });
3983
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
3984
+ method: "POST",
3985
+ body: form,
3986
+ headers: {
3987
+ "content-type": "application/x-www-form-urlencoded",
3988
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
3989
+ origin: ISSUER,
3990
+ },
3991
+ });
3992
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
3993
+ expect(res.status).toBe(400);
3994
+ // DB row remains pending — validate-before-mutate ordering.
3995
+ const row = getClient(db, reg.client.clientId);
3996
+ expect(row?.status).toBe("pending");
3997
+ } finally {
3998
+ cleanup();
3999
+ }
4000
+ });
4001
+
4002
+ test("approve POST: return_to off /oauth/authorize path (e.g. /admin/config) → 400", async () => {
4003
+ // Even hub-relative paths must target the authorize endpoint. A
4004
+ // hand-crafted form trying to redirect to /admin/config or any other
4005
+ // hub surface is misuse — this endpoint exists to re-enter the OAuth
4006
+ // flow, nothing else.
4007
+ const { db, cleanup } = await makeDb();
4008
+ try {
4009
+ const user = await createUser(db, "owner", "pw");
4010
+ const session = createSession(db, { userId: user.id });
4011
+ const reg = registerClient(db, {
4012
+ redirectUris: ["https://app.example/cb"],
4013
+ status: "pending",
4014
+ });
4015
+ const form = new URLSearchParams({
4016
+ __csrf: TEST_CSRF,
4017
+ client_id: reg.client.clientId,
4018
+ return_to: "/admin/config",
4019
+ });
4020
+ const req = new Request(`${ISSUER}/oauth/authorize/approve`, {
4021
+ method: "POST",
4022
+ body: form,
4023
+ headers: {
4024
+ "content-type": "application/x-www-form-urlencoded",
4025
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`,
4026
+ origin: ISSUER,
4027
+ },
4028
+ });
4029
+ const res = await handleApproveClientPost(db, req, { issuer: ISSUER });
4030
+ expect(res.status).toBe(400);
4031
+ // DB row remains pending — validate-before-mutate ordering.
4032
+ const row = getClient(db, reg.client.clientId);
4033
+ expect(row?.status).toBe("pending");
4034
+ } finally {
4035
+ cleanup();
4036
+ }
4037
+ });
4038
+
4039
+ test("end-to-end: GET (pending) → POST approve → GET (now approved) renders consent", async () => {
4040
+ // The full redirect chain. Sessions and CSRF carry across all three
4041
+ // requests in the same cookie. The final GET sees status=approved and
4042
+ // renders the consent screen.
4043
+ const { db, cleanup } = await makeDb();
4044
+ try {
4045
+ const user = await createUser(db, "owner", "pw");
4046
+ const session = createSession(db, { userId: user.id });
4047
+ const reg = registerClient(db, {
4048
+ redirectUris: ["https://app.example/cb"],
4049
+ clientName: "RoundTrip",
4050
+ status: "pending",
4051
+ });
4052
+ const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`;
4053
+ const authorizeHref = pendingAuthorizeUrl(reg.client.clientId);
4054
+
4055
+ // Step 1: GET /oauth/authorize on a pending client renders the approve form.
4056
+ const getRes = handleAuthorizeGet(
4057
+ db,
4058
+ new Request(authorizeHref, { headers: { cookie, origin: ISSUER } }),
4059
+ { issuer: ISSUER },
4060
+ );
4061
+ expect(getRes.status).toBe(403);
4062
+ const getHtml = await getRes.text();
4063
+ expect(getHtml).toContain('action="/oauth/authorize/approve"');
4064
+
4065
+ // Pull the return_to value the form would submit. It's the path+search
4066
+ // of the authorize URL.
4067
+ const authorizeUrlParsed = new URL(authorizeHref);
4068
+ const returnTo = `${authorizeUrlParsed.pathname}${authorizeUrlParsed.search}`;
4069
+
4070
+ // Step 2: POST the approve form.
4071
+ const postForm = new URLSearchParams({
4072
+ __csrf: TEST_CSRF,
4073
+ client_id: reg.client.clientId,
4074
+ return_to: returnTo,
4075
+ });
4076
+ const postRes = await handleApproveClientPost(
4077
+ db,
4078
+ new Request(`${ISSUER}/oauth/authorize/approve`, {
4079
+ method: "POST",
4080
+ body: postForm,
4081
+ headers: {
4082
+ "content-type": "application/x-www-form-urlencoded",
4083
+ cookie,
4084
+ origin: ISSUER,
4085
+ },
4086
+ }),
4087
+ { issuer: ISSUER },
4088
+ );
4089
+ expect(postRes.status).toBe(302);
4090
+ expect(postRes.headers.get("location")).toBe(returnTo);
4091
+
4092
+ // Step 3: GET /oauth/authorize again — now the client is approved, so
4093
+ // the operator lands on the consent screen.
4094
+ const reentryRes = handleAuthorizeGet(
4095
+ db,
4096
+ new Request(authorizeHref, { headers: { cookie, origin: ISSUER } }),
4097
+ { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
4098
+ );
4099
+ expect(reentryRes.status).toBe(200);
4100
+ const consentHtml = await reentryRes.text();
4101
+ // Consent screen markers (renderConsent uses these).
4102
+ expect(consentHtml).toContain('name="__action" value="consent"');
4103
+ expect(consentHtml).toContain("Authorize");
4104
+ expect(consentHtml).toContain("RoundTrip");
4105
+ } finally {
4106
+ cleanup();
4107
+ }
4108
+ });
4109
+ });