@openparachute/vault 0.3.3 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/oauth.test.ts CHANGED
@@ -280,6 +280,28 @@ describe("OAuth authorization", () => {
280
280
  expect(location.searchParams.get("state")).toBe("mystate");
281
281
  });
282
282
 
283
+ test("POST authorize without scope is rejected (no silent default to 'full', #197)", async () => {
284
+ const ownerToken = createOwnerToken();
285
+ const clientId = await registerClient();
286
+ const { codeChallenge } = generatePkce();
287
+ const req = makeRequest("https://vault.test/oauth/authorize", {
288
+ method: "POST",
289
+ body: new URLSearchParams({
290
+ action: "authorize",
291
+ client_id: clientId,
292
+ redirect_uri: "https://example.com/callback",
293
+ code_challenge: codeChallenge,
294
+ code_challenge_method: "S256",
295
+ // No scope field — pre-#197 this silently consented to "full".
296
+ owner_token: ownerToken,
297
+ }),
298
+ });
299
+ const res = await handleAuthorizePost(req, db);
300
+ expect(res.status).toBe(400);
301
+ const body = (await res.json()) as { error?: string };
302
+ expect(body.error).toBe("invalid_request");
303
+ });
304
+
283
305
  test("POST authorize (deny) redirects with error", async () => {
284
306
  const clientId = await registerClient();
285
307
  const { codeChallenge } = generatePkce();
@@ -1809,3 +1831,326 @@ describe("OAuth Phase 0: PARACHUTE_HUB_ORIGIN", () => {
1809
1831
  });
1810
1832
  });
1811
1833
  });
1834
+
1835
+ // ---------------------------------------------------------------------------
1836
+ // Per-vault rate limiter + memory cap (#93)
1837
+ // ---------------------------------------------------------------------------
1838
+
1839
+ describe("OAuth consent — per-vault rate limiting (#93)", () => {
1840
+ test("getAuthorizeRateLimiter returns the same instance for the same vault name", async () => {
1841
+ const { getAuthorizeRateLimiter, resetVaultAuthorizeRateLimiters } =
1842
+ await import("./owner-auth.ts");
1843
+ resetVaultAuthorizeRateLimiters();
1844
+ const a1 = getAuthorizeRateLimiter("alpha");
1845
+ const a2 = getAuthorizeRateLimiter("alpha");
1846
+ expect(a1).toBe(a2);
1847
+ });
1848
+
1849
+ test("getAuthorizeRateLimiter returns distinct instances per vault", async () => {
1850
+ const { getAuthorizeRateLimiter, resetVaultAuthorizeRateLimiters } =
1851
+ await import("./owner-auth.ts");
1852
+ resetVaultAuthorizeRateLimiters();
1853
+ const work = getAuthorizeRateLimiter("work");
1854
+ const personal = getAuthorizeRateLimiter("personal");
1855
+ expect(work).not.toBe(personal);
1856
+ });
1857
+
1858
+ test("a lockout on one vault's limiter does not lock the same IP on another vault's limiter", async () => {
1859
+ const { getAuthorizeRateLimiter, resetVaultAuthorizeRateLimiters } =
1860
+ await import("./owner-auth.ts");
1861
+ resetVaultAuthorizeRateLimiters();
1862
+ const ip = "192.0.2.55";
1863
+ const work = getAuthorizeRateLimiter("work");
1864
+ // Pump enough failures on `work` to trip the default 10-failure threshold.
1865
+ for (let i = 0; i < 10; i++) work.recordFailure(ip);
1866
+ expect(work.check(ip).allowed).toBe(false);
1867
+ // The unrelated vault's limiter should still allow this IP.
1868
+ const personal = getAuthorizeRateLimiter("personal");
1869
+ expect(personal.check(ip).allowed).toBe(true);
1870
+ });
1871
+
1872
+ test("entry count is hard-capped — oldest IP is evicted FIFO when full", async () => {
1873
+ const { RateLimiter } = await import("./owner-auth.ts");
1874
+ // Tiny cap (3) so we don't have to hammer the limiter to prove eviction.
1875
+ const limiter = new RateLimiter(10, 60_000, 60_000, 3);
1876
+ limiter.recordFailure("10.0.0.1");
1877
+ limiter.recordFailure("10.0.0.2");
1878
+ limiter.recordFailure("10.0.0.3");
1879
+ expect(limiter.size()).toBe(3);
1880
+ // Adding a 4th IP must evict the oldest (10.0.0.1) to stay at the cap.
1881
+ limiter.recordFailure("10.0.0.4");
1882
+ expect(limiter.size()).toBe(3);
1883
+ // The evicted IP is treated as untracked → fresh check is allowed.
1884
+ expect(limiter.check("10.0.0.1").allowed).toBe(true);
1885
+ // Newer entries remain locked into their failure state.
1886
+ expect(limiter.check("10.0.0.4").allowed).toBe(true); // still under threshold
1887
+ });
1888
+ });
1889
+
1890
+ // ---------------------------------------------------------------------------
1891
+ // Server-bound scope at /authorize, subset enforcement at /token (#94)
1892
+ // ---------------------------------------------------------------------------
1893
+
1894
+ describe("OAuth scope binding (#94, RFC 6749 §3.3 / §6)", () => {
1895
+ test("/authorize floors selected scope to requested — form cannot smuggle a broader scope", async () => {
1896
+ const ownerToken = createOwnerToken();
1897
+ const clientId = await registerClient();
1898
+ const { codeChallenge } = generatePkce();
1899
+ const redirectUri = "https://example.com/callback";
1900
+
1901
+ const authRes = await handleAuthorizePost(
1902
+ makeRequest("https://vault.test/oauth/authorize", {
1903
+ method: "POST",
1904
+ body: new URLSearchParams({
1905
+ action: "authorize",
1906
+ client_id: clientId,
1907
+ redirect_uri: redirectUri,
1908
+ code_challenge: codeChallenge,
1909
+ code_challenge_method: "S256",
1910
+ scope: "read", // requested = read
1911
+ selected_scope: "full", // smuggled broader value
1912
+ owner_token: ownerToken,
1913
+ }),
1914
+ }),
1915
+ db,
1916
+ { vaultName: "default" },
1917
+ );
1918
+ expect(authRes.status).toBe(302);
1919
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1920
+
1921
+ // The bound scope on the issued auth code must be the narrower of the two.
1922
+ const row = db
1923
+ .prepare("SELECT scope FROM oauth_codes WHERE code = ?")
1924
+ .get(code) as { scope: string };
1925
+ expect(row.scope).toBe("read");
1926
+ });
1927
+
1928
+ test("/token rejects requested scope broader than bound (read → full)", async () => {
1929
+ const ownerToken = createOwnerToken();
1930
+ const clientId = await registerClient();
1931
+ const { codeVerifier, codeChallenge } = generatePkce();
1932
+ const redirectUri = "https://example.com/callback";
1933
+
1934
+ const authRes = await handleAuthorizePost(
1935
+ makeRequest("https://vault.test/oauth/authorize", {
1936
+ method: "POST",
1937
+ body: new URLSearchParams({
1938
+ action: "authorize",
1939
+ client_id: clientId,
1940
+ redirect_uri: redirectUri,
1941
+ code_challenge: codeChallenge,
1942
+ code_challenge_method: "S256",
1943
+ scope: "read",
1944
+ owner_token: ownerToken,
1945
+ }),
1946
+ }),
1947
+ db,
1948
+ { vaultName: "default" },
1949
+ );
1950
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1951
+
1952
+ const tokenRes = await handleToken(
1953
+ makeRequest("https://vault.test/oauth/token", {
1954
+ method: "POST",
1955
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1956
+ body: new URLSearchParams({
1957
+ grant_type: "authorization_code",
1958
+ code,
1959
+ code_verifier: codeVerifier,
1960
+ client_id: clientId,
1961
+ redirect_uri: redirectUri,
1962
+ scope: "full", // attempt to broaden
1963
+ }).toString(),
1964
+ }),
1965
+ db,
1966
+ "default",
1967
+ );
1968
+ expect(tokenRes.status).toBe(400);
1969
+ const body = (await tokenRes.json()) as { error?: string };
1970
+ expect(body.error).toBe("invalid_scope");
1971
+ });
1972
+
1973
+ test("/token accepts a narrower requested scope (full → read) and reflects it on the token", async () => {
1974
+ const ownerToken = createOwnerToken();
1975
+ const clientId = await registerClient();
1976
+ const { codeVerifier, codeChallenge } = generatePkce();
1977
+ const redirectUri = "https://example.com/callback";
1978
+
1979
+ const authRes = await handleAuthorizePost(
1980
+ makeRequest("https://vault.test/oauth/authorize", {
1981
+ method: "POST",
1982
+ body: new URLSearchParams({
1983
+ action: "authorize",
1984
+ client_id: clientId,
1985
+ redirect_uri: redirectUri,
1986
+ code_challenge: codeChallenge,
1987
+ code_challenge_method: "S256",
1988
+ scope: "full",
1989
+ owner_token: ownerToken,
1990
+ }),
1991
+ }),
1992
+ db,
1993
+ { vaultName: "default" },
1994
+ );
1995
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1996
+
1997
+ const tokenRes = await handleToken(
1998
+ makeRequest("https://vault.test/oauth/token", {
1999
+ method: "POST",
2000
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2001
+ body: new URLSearchParams({
2002
+ grant_type: "authorization_code",
2003
+ code,
2004
+ code_verifier: codeVerifier,
2005
+ client_id: clientId,
2006
+ redirect_uri: redirectUri,
2007
+ scope: "read", // narrower than bound
2008
+ }).toString(),
2009
+ }),
2010
+ db,
2011
+ "default",
2012
+ );
2013
+ expect(tokenRes.status).toBe(200);
2014
+ const body = (await tokenRes.json()) as { scope?: string };
2015
+ expect(body.scope).toBe("vault:read");
2016
+ });
2017
+
2018
+ test("/token treats whitespace-only scope as absent and falls through to bound scope (#196)", async () => {
2019
+ // Guard at oauth.ts checks `scope !== null && scope.trim().length > 0`.
2020
+ // A client sending `scope= ` is the same as omitting `scope` — we
2021
+ // must not run subset enforcement against the whitespace string and
2022
+ // reject it as invalid.
2023
+ const ownerToken = createOwnerToken();
2024
+ const clientId = await registerClient();
2025
+ const { codeVerifier, codeChallenge } = generatePkce();
2026
+ const redirectUri = "https://example.com/callback";
2027
+
2028
+ const authRes = await handleAuthorizePost(
2029
+ makeRequest("https://vault.test/oauth/authorize", {
2030
+ method: "POST",
2031
+ body: new URLSearchParams({
2032
+ action: "authorize",
2033
+ client_id: clientId,
2034
+ redirect_uri: redirectUri,
2035
+ code_challenge: codeChallenge,
2036
+ code_challenge_method: "S256",
2037
+ scope: "read",
2038
+ owner_token: ownerToken,
2039
+ }),
2040
+ }),
2041
+ db,
2042
+ { vaultName: "default" },
2043
+ );
2044
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
2045
+
2046
+ const tokenRes = await handleToken(
2047
+ makeRequest("https://vault.test/oauth/token", {
2048
+ method: "POST",
2049
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2050
+ body: new URLSearchParams({
2051
+ grant_type: "authorization_code",
2052
+ code,
2053
+ code_verifier: codeVerifier,
2054
+ client_id: clientId,
2055
+ redirect_uri: redirectUri,
2056
+ scope: " ", // whitespace only — should fall through to bound
2057
+ }).toString(),
2058
+ }),
2059
+ db,
2060
+ "default",
2061
+ );
2062
+ expect(tokenRes.status).toBe(200);
2063
+ const body = (await tokenRes.json()) as { scope?: string };
2064
+ expect(body.scope).toBe("vault:read");
2065
+ });
2066
+
2067
+ test("/token rejects unknown scope strings even when the bound scope is broad", async () => {
2068
+ const ownerToken = createOwnerToken();
2069
+ const clientId = await registerClient();
2070
+ const { codeVerifier, codeChallenge } = generatePkce();
2071
+ const redirectUri = "https://example.com/callback";
2072
+
2073
+ const authRes = await handleAuthorizePost(
2074
+ makeRequest("https://vault.test/oauth/authorize", {
2075
+ method: "POST",
2076
+ body: new URLSearchParams({
2077
+ action: "authorize",
2078
+ client_id: clientId,
2079
+ redirect_uri: redirectUri,
2080
+ code_challenge: codeChallenge,
2081
+ code_challenge_method: "S256",
2082
+ scope: "full",
2083
+ owner_token: ownerToken,
2084
+ }),
2085
+ }),
2086
+ db,
2087
+ { vaultName: "default" },
2088
+ );
2089
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
2090
+
2091
+ const tokenRes = await handleToken(
2092
+ makeRequest("https://vault.test/oauth/token", {
2093
+ method: "POST",
2094
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2095
+ body: new URLSearchParams({
2096
+ grant_type: "authorization_code",
2097
+ code,
2098
+ code_verifier: codeVerifier,
2099
+ client_id: clientId,
2100
+ redirect_uri: redirectUri,
2101
+ scope: "vault:admin", // not in the consent vocabulary
2102
+ }).toString(),
2103
+ }),
2104
+ db,
2105
+ "default",
2106
+ );
2107
+ expect(tokenRes.status).toBe(400);
2108
+ const body = (await tokenRes.json()) as { error?: string };
2109
+ expect(body.error).toBe("invalid_scope");
2110
+ });
2111
+
2112
+ test("/token uses the bound scope when no scope param is sent (regression)", async () => {
2113
+ const ownerToken = createOwnerToken();
2114
+ const clientId = await registerClient();
2115
+ const { codeVerifier, codeChallenge } = generatePkce();
2116
+ const redirectUri = "https://example.com/callback";
2117
+
2118
+ const authRes = await handleAuthorizePost(
2119
+ makeRequest("https://vault.test/oauth/authorize", {
2120
+ method: "POST",
2121
+ body: new URLSearchParams({
2122
+ action: "authorize",
2123
+ client_id: clientId,
2124
+ redirect_uri: redirectUri,
2125
+ code_challenge: codeChallenge,
2126
+ code_challenge_method: "S256",
2127
+ scope: "read",
2128
+ owner_token: ownerToken,
2129
+ }),
2130
+ }),
2131
+ db,
2132
+ { vaultName: "default" },
2133
+ );
2134
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
2135
+
2136
+ const tokenRes = await handleToken(
2137
+ makeRequest("https://vault.test/oauth/token", {
2138
+ method: "POST",
2139
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
2140
+ body: new URLSearchParams({
2141
+ grant_type: "authorization_code",
2142
+ code,
2143
+ code_verifier: codeVerifier,
2144
+ client_id: clientId,
2145
+ redirect_uri: redirectUri,
2146
+ // no scope param
2147
+ }).toString(),
2148
+ }),
2149
+ db,
2150
+ "default",
2151
+ );
2152
+ expect(tokenRes.status).toBe(200);
2153
+ const body = (await tokenRes.json()) as { scope?: string };
2154
+ expect(body.scope).toBe("vault:read");
2155
+ });
2156
+ });
package/src/oauth.ts CHANGED
@@ -46,6 +46,37 @@ export interface AuthorizePostOptions {
46
46
  // Helpers
47
47
  // ---------------------------------------------------------------------------
48
48
 
49
+ /**
50
+ * Today the consent page binds one of two scope strings — "read" or "full" —
51
+ * with `read ⊂ full`. `narrowerScope` picks the more-restrictive of two
52
+ * inputs (used to floor `selected` by `requested` at /oauth/authorize),
53
+ * `isScopeSubset` checks an inbound /oauth/token scope against the bound
54
+ * scope. Both default to "full" only if **both** inputs allow "full",
55
+ * otherwise narrow to "read". When the consent vocabulary expands beyond
56
+ * read/full, both helpers should switch to vault:read|write|admin and the
57
+ * inheritance rules in scopes.ts (`hasScope`).
58
+ */
59
+ function normalizeConsentScope(s: string | null | undefined): "read" | "full" {
60
+ return s === "read" ? "read" : "full";
61
+ }
62
+
63
+ function narrowerScope(a: string, b: string): "read" | "full" {
64
+ return normalizeConsentScope(a) === "read" || normalizeConsentScope(b) === "read"
65
+ ? "read"
66
+ : "full";
67
+ }
68
+
69
+ function isScopeSubset(requested: string, bound: string): boolean {
70
+ // Strict: only "read" / "full" are acceptable on the wire today. Unknown
71
+ // scope strings are rejected as out-of-bounds rather than silently
72
+ // normalized — otherwise `scope=vault:admin` would coast through when
73
+ // bound is "full".
74
+ if (requested !== "read" && requested !== "full") return false;
75
+ const bnd = normalizeConsentScope(bound);
76
+ if (bnd === "full") return requested === "read" || requested === "full";
77
+ return requested === "read";
78
+ }
79
+
49
80
  /**
50
81
  * Public-facing base URL of the server. Honors `x-forwarded-*` headers so a
51
82
  * Cloudflare Tunnel / Tailscale Funnel / reverse-proxied deployment advertises
@@ -351,7 +382,7 @@ export async function handleAuthorizePost(
351
382
  const { vaultName, clientIp, ownerPasswordHash, totpSecret, rateLimiter = authorizeRateLimit } = opts;
352
383
  const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
353
384
 
354
- let form: FormData;
385
+ let form: Awaited<ReturnType<typeof req.formData>>;
355
386
  try {
356
387
  form = await req.formData();
357
388
  } catch {
@@ -363,13 +394,12 @@ export async function handleAuthorizePost(
363
394
  const redirectUri = form.get("redirect_uri") as string;
364
395
  const codeChallenge = form.get("code_challenge") as string;
365
396
  const codeChallengeMethod = form.get("code_challenge_method") as string || "S256";
366
- // Requested scope (from hidden field, carried from GET) and selected scope
367
- // (from radio button on the consent page). Default selected to requested.
368
- const requestedScope = form.get("scope") as string || "full";
397
+ // Requested scope is carried from the GET via a hidden field on the consent
398
+ // page; the user's radio-button choice arrives in `selected_scope`. The
399
+ // required-ness check runs *after* the deny short-circuit below — a deny
400
+ // POST doesn't mint anything and shouldn't need scope to refuse.
401
+ const requestedScopeRaw = form.get("scope");
369
402
  const selectedScopeRaw = form.get("selected_scope") as string | null;
370
- const selectedScope = selectedScopeRaw === "read" || selectedScopeRaw === "full"
371
- ? selectedScopeRaw
372
- : (requestedScope === "read" ? "read" : "full");
373
403
  const state = form.get("state") as string || "";
374
404
 
375
405
  if (!clientId || !redirectUri || !codeChallenge) {
@@ -404,6 +434,20 @@ export async function handleAuthorizePost(
404
434
  return Response.redirect(redirect.toString(), 302);
405
435
  }
406
436
 
437
+ // Past this point we're processing consent — scope must be explicitly
438
+ // present. Defaulting absent scope to "full" would silently cement a
439
+ // grant the user never confirmed (#197).
440
+ if (typeof requestedScopeRaw !== "string" || requestedScopeRaw.length === 0) {
441
+ return Response.json(
442
+ { error: "invalid_request", error_description: "scope is required" },
443
+ { status: 400 },
444
+ );
445
+ }
446
+ const requestedScope = requestedScopeRaw;
447
+ const selectedScope = selectedScopeRaw === "read" || selectedScopeRaw === "full"
448
+ ? selectedScopeRaw
449
+ : (requestedScope === "read" ? "read" : "full");
450
+
407
451
  // Rate-limit the owner-auth step. Applied before any credential check so
408
452
  // brute-force attempts are capped regardless of which path (password or
409
453
  // legacy token) is being used.
@@ -483,7 +527,13 @@ export async function handleAuthorizePost(
483
527
 
484
528
  if (clientIp) rateLimiter.recordSuccess(clientIp);
485
529
 
486
- // Generate auth code — persist the user-selected scope (not the requested one)
530
+ // Generate auth code — bind the NARROWER of (requested, selected). The
531
+ // user can shrink the requested scope at consent time (e.g. flip "full"
532
+ // to "read"); they cannot broaden it. Without this floor, a malicious
533
+ // form could smuggle `selected_scope=full` even when /authorize?scope=read
534
+ // was the original ask, escalating beyond what the client requested at
535
+ // authorize time (#94, RFC 6749 §3.3).
536
+ const boundScope = narrowerScope(requestedScope, selectedScope);
487
537
  const code = crypto.randomBytes(32).toString("base64url");
488
538
  const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); // 10 minutes
489
539
 
@@ -492,7 +542,7 @@ export async function handleAuthorizePost(
492
542
  db.prepare(`
493
543
  INSERT INTO oauth_codes (code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, created_at, vault_name)
494
544
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
495
- `).run(code, clientId, codeChallenge, codeChallengeMethod, selectedScope, redirectUri, expiresAt, new Date().toISOString(), vaultName ?? null);
545
+ `).run(code, clientId, codeChallenge, codeChallengeMethod, boundScope, redirectUri, expiresAt, new Date().toISOString(), vaultName ?? null);
496
546
 
497
547
  redirect.searchParams.set("code", code);
498
548
  return Response.redirect(redirect.toString(), 302);
@@ -607,14 +657,35 @@ export async function handleToken(
607
657
  return Response.json({ error: "invalid_grant", error_description: "PKCE verification failed" }, { status: 400 });
608
658
  }
609
659
 
660
+ // RFC 6749 §3.3 / §6: a `scope` parameter at /oauth/token, if present,
661
+ // must equal or be a subset of the scope bound to the auth code at
662
+ // /oauth/authorize. Reject expansion attempts as `invalid_scope` rather
663
+ // than silently honoring the bound scope (#94). Absent param → use bound.
664
+ const requestedTokenScopeRaw = params.get("scope");
665
+ let effectiveScope = authCode.scope;
666
+ if (requestedTokenScopeRaw !== null && requestedTokenScopeRaw.trim().length > 0) {
667
+ const requested = requestedTokenScopeRaw.trim();
668
+ if (!isScopeSubset(requested, authCode.scope)) {
669
+ return Response.json(
670
+ {
671
+ error: "invalid_scope",
672
+ error_description:
673
+ "Requested scope exceeds the scope bound at authorization time.",
674
+ },
675
+ { status: 400 },
676
+ );
677
+ }
678
+ effectiveScope = requested;
679
+ }
680
+
610
681
  // Mark code as used
611
682
  db.prepare("UPDATE oauth_codes SET used = 1 WHERE code = ?").run(code);
612
683
 
613
- // Translate the consent-time selected scope into both the legacy permission
614
- // column and the OAuth-standard scope list we now persist on the token row.
615
- // The consent page only offers read vs full today; full becomes the
616
- // admin-inheriting scope set so hub admin operations keep working.
617
- const permission: TokenPermission = authCode.scope === "read" ? "read" : "full";
684
+ // Translate the (possibly-narrowed) effective scope into both the legacy
685
+ // permission column and the OAuth-standard scope list we persist on the
686
+ // token row. The consent page only offers read vs full today; full becomes
687
+ // the admin-inheriting scope set so hub admin operations keep working.
688
+ const permission: TokenPermission = effectiveScope === "read" ? "read" : "full";
618
689
  const scopes = legacyPermissionToScopes(permission);
619
690
  const scopeString = serializeScopes(scopes);
620
691
 
package/src/owner-auth.ts CHANGED
@@ -88,6 +88,9 @@ interface RateLimitEntry {
88
88
  * - Up to MAX_FAILURES failed attempts within WINDOW_MS → lockout
89
89
  * - Lockout lasts LOCKOUT_MS
90
90
  * - A successful attempt clears the IP's counter
91
+ * - Hard cap on entry count — when full, the oldest insertion is evicted
92
+ * before a new one is recorded. Prevents memory exhaustion via IP /
93
+ * client_id enumeration (#93).
91
94
  */
92
95
  export class RateLimiter {
93
96
  private entries = new Map<string, RateLimitEntry>();
@@ -96,6 +99,7 @@ export class RateLimiter {
96
99
  private readonly maxFailures = 10,
97
100
  private readonly windowMs = 60_000,
98
101
  private readonly lockoutMs = 15 * 60_000,
102
+ private readonly maxEntries = 10_000,
99
103
  ) {}
100
104
 
101
105
  /**
@@ -130,6 +134,7 @@ export class RateLimiter {
130
134
  const entry = this.entries.get(ip);
131
135
 
132
136
  if (!entry || now - entry.firstFailureAt > this.windowMs) {
137
+ this.evictIfFull();
133
138
  this.entries.set(ip, {
134
139
  failures: 1,
135
140
  firstFailureAt: now,
@@ -153,7 +158,58 @@ export class RateLimiter {
153
158
  reset(): void {
154
159
  this.entries.clear();
155
160
  }
161
+
162
+ /** Current entry count — exposed for tests + observability. */
163
+ size(): number {
164
+ return this.entries.size;
165
+ }
166
+
167
+ /**
168
+ * Evict the oldest insertion(s) until size < maxEntries. Map preserves
169
+ * insertion order, so `keys().next().value` is the oldest. We re-insert
170
+ * on window-rollover (delete + new set), so insertion order tracks
171
+ * recency-of-failure closely enough for FIFO eviction.
172
+ */
173
+ private evictIfFull(): void {
174
+ while (this.entries.size >= this.maxEntries) {
175
+ const oldest = this.entries.keys().next().value;
176
+ if (oldest === undefined) break;
177
+ this.entries.delete(oldest);
178
+ }
179
+ }
156
180
  }
157
181
 
158
- /** Singleton rate limiter for the OAuth consent endpoint. */
182
+ /**
183
+ * Singleton rate limiter — kept for back-compat with callers that don't pass
184
+ * through per-vault routing. Fresh callers should prefer
185
+ * `getAuthorizeRateLimiter(vaultName)` so traffic on one vault's consent flow
186
+ * doesn't lock out IPs on another vault's consent flow (#93).
187
+ *
188
+ * @deprecated Use `getAuthorizeRateLimiter(vaultName)` instead. The singleton
189
+ * cross-pollutes per-vault consent traffic — one vault under brute-force can
190
+ * lock out IPs on every other vault's consent page.
191
+ */
159
192
  export const authorizeRateLimit = new RateLimiter();
193
+
194
+ /**
195
+ * Per-vault rate limiter registry. The vault count is admin-bounded (vaults
196
+ * are created via CLI, not by clients) so this Map can grow only with operator
197
+ * action — no attacker-driven growth path. Each instance carries the
198
+ * default 10,000-entry IP cap, scoped to its vault (#93).
199
+ */
200
+ const vaultAuthorizeRateLimiters = new Map<string, RateLimiter>();
201
+
202
+ /** Lazily get-or-create the rate limiter for a given vault. */
203
+ export function getAuthorizeRateLimiter(vaultName: string): RateLimiter {
204
+ let limiter = vaultAuthorizeRateLimiters.get(vaultName);
205
+ if (!limiter) {
206
+ limiter = new RateLimiter();
207
+ vaultAuthorizeRateLimiters.set(vaultName, limiter);
208
+ }
209
+ return limiter;
210
+ }
211
+
212
+ /** For tests: drop all per-vault limiters. */
213
+ export function resetVaultAuthorizeRateLimiters(): void {
214
+ vaultAuthorizeRateLimiters.clear();
215
+ }
package/src/prompt.ts CHANGED
@@ -141,17 +141,18 @@ export async function askPassword(question: string): Promise<string> {
141
141
  export async function choose(question: string, options: { label: string; value: string; description?: string }[]): Promise<string> {
142
142
  console.log(question);
143
143
  for (let i = 0; i < options.length; i++) {
144
- const desc = options[i].description ? ` — ${options[i].description}` : "";
145
- console.log(` ${i + 1}) ${options[i].label}${desc}`);
144
+ const opt = options[i]!;
145
+ const desc = opt.description ? ` ${opt.description}` : "";
146
+ console.log(` ${i + 1}) ${opt.label}${desc}`);
146
147
  }
147
148
  process.stdout.write(` Choice [1]: `);
148
149
 
149
150
  for await (const line of console) {
150
151
  const answer = line.trim();
151
- if (answer === "") return options[0].value;
152
+ if (answer === "") return options[0]!.value;
152
153
  const idx = parseInt(answer, 10) - 1;
153
- if (idx >= 0 && idx < options.length) return options[idx].value;
154
+ if (idx >= 0 && idx < options.length) return options[idx]!.value;
154
155
  process.stdout.write(` Please enter 1-${options.length}: `);
155
156
  }
156
- return options[0].value;
157
+ return options[0]!.value;
157
158
  }