@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.
- package/.parachute/module.json +15 -0
- package/README.md +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +727 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +1626 -183
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- 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:
|
|
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
|
|
367
|
-
//
|
|
368
|
-
|
|
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 —
|
|
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,
|
|
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
|
|
614
|
-
// column and the OAuth-standard scope list we
|
|
615
|
-
// The consent page only offers read vs full today; full becomes
|
|
616
|
-
// admin-inheriting scope set so hub admin operations keep working.
|
|
617
|
-
const permission: TokenPermission =
|
|
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
|
-
/**
|
|
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
|
|
145
|
-
|
|
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]
|
|
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]
|
|
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]
|
|
157
|
+
return options[0]!.value;
|
|
157
158
|
}
|