@openparachute/vault 0.1.0
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/.claude/settings.local.json +31 -0
- package/.dockerignore +8 -0
- package/.env.example +9 -0
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
- package/CLAUDE.md +115 -0
- package/Caddyfile +3 -0
- package/Dockerfile +22 -0
- package/LICENSE +661 -0
- package/README.md +356 -0
- package/bun.lock +219 -0
- package/bunfig.toml +2 -0
- package/core/package.json +7 -0
- package/core/src/core.test.ts +940 -0
- package/core/src/hooks.test.ts +361 -0
- package/core/src/hooks.ts +234 -0
- package/core/src/links.ts +352 -0
- package/core/src/mcp.ts +672 -0
- package/core/src/notes.ts +520 -0
- package/core/src/obsidian.test.ts +380 -0
- package/core/src/obsidian.ts +322 -0
- package/core/src/paths.test.ts +197 -0
- package/core/src/paths.ts +53 -0
- package/core/src/schema.ts +331 -0
- package/core/src/store.ts +303 -0
- package/core/src/tag-schemas.ts +104 -0
- package/core/src/test-preload.ts +8 -0
- package/core/src/types.ts +140 -0
- package/core/src/wikilinks.test.ts +277 -0
- package/core/src/wikilinks.ts +402 -0
- package/deploy/parachute-vault.service +20 -0
- package/docker-compose.yml +50 -0
- package/docs/HTTP_API.md +328 -0
- package/fly.toml +24 -0
- package/package.json +32 -0
- package/railway.json +14 -0
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/scripts/migrate-audio-to-opus.test.ts +237 -0
- package/scripts/migrate-audio-to-opus.ts +499 -0
- package/src/auth.ts +170 -0
- package/src/cli.ts +1131 -0
- package/src/config-triggers.test.ts +83 -0
- package/src/config.test.ts +125 -0
- package/src/config.ts +716 -0
- package/src/db.ts +14 -0
- package/src/launchd.ts +109 -0
- package/src/mcp-http.ts +113 -0
- package/src/mcp-tools.ts +155 -0
- package/src/oauth.test.ts +1242 -0
- package/src/oauth.ts +729 -0
- package/src/owner-auth.ts +159 -0
- package/src/prompt.ts +141 -0
- package/src/published.test.ts +214 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/routes.ts +822 -0
- package/src/server.ts +450 -0
- package/src/systemd.ts +84 -0
- package/src/token-store.test.ts +174 -0
- package/src/token-store.ts +241 -0
- package/src/triggers.test.ts +397 -0
- package/src/triggers.ts +412 -0
- package/src/two-factor.test.ts +246 -0
- package/src/two-factor.ts +222 -0
- package/src/vault-store.ts +47 -0
- package/src/vault.test.ts +1309 -0
- package/tsconfig.json +29 -0
- package/web/README.md +73 -0
- package/web/bun.lock +827 -0
- package/web/eslint.config.js +23 -0
- package/web/index.html +15 -0
- package/web/package.json +36 -0
- package/web/public/favicon.svg +1 -0
- package/web/public/icons.svg +24 -0
- package/web/src/App.tsx +149 -0
- package/web/src/Graph.tsx +200 -0
- package/web/src/NoteView.tsx +155 -0
- package/web/src/Sidebar.tsx +186 -0
- package/web/src/api.ts +21 -0
- package/web/src/index.css +50 -0
- package/web/src/main.tsx +10 -0
- package/web/src/types.ts +37 -0
- package/web/src/utils.ts +107 -0
- package/web/tsconfig.app.json +25 -0
- package/web/tsconfig.json +7 -0
- package/web/tsconfig.node.json +24 -0
- package/web/vite.config.ts +15 -0
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the OAuth 2.1 provider.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
6
|
+
import { Database } from "bun:sqlite";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { initSchema } from "../core/src/schema.ts";
|
|
9
|
+
import { generateToken, createToken, resolveToken } from "./token-store.ts";
|
|
10
|
+
import {
|
|
11
|
+
handleProtectedResource,
|
|
12
|
+
handleAuthorizationServer,
|
|
13
|
+
handleRegister,
|
|
14
|
+
handleAuthorizeGet,
|
|
15
|
+
handleAuthorizePost,
|
|
16
|
+
handleToken,
|
|
17
|
+
} from "./oauth.ts";
|
|
18
|
+
import * as OTPAuth from "otpauth";
|
|
19
|
+
|
|
20
|
+
let db: Database;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
db = new Database(":memory:");
|
|
24
|
+
initSchema(db);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
db.close();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function makeRequest(url: string, init?: RequestInit): Request {
|
|
36
|
+
return new Request(url, init);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Generate a PKCE code_verifier and its S256 code_challenge. */
|
|
40
|
+
function generatePkce(): { codeVerifier: string; codeChallenge: string } {
|
|
41
|
+
const codeVerifier = crypto.randomBytes(32).toString("base64url");
|
|
42
|
+
const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
|
|
43
|
+
return { codeVerifier, codeChallenge };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Create a valid owner token in the DB and return the raw token string. */
|
|
47
|
+
function createOwnerToken(): string {
|
|
48
|
+
const { fullToken } = generateToken();
|
|
49
|
+
createToken(db, fullToken, { label: "owner", permission: "full" });
|
|
50
|
+
return fullToken;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Register a client and return the client_id. */
|
|
54
|
+
async function registerClient(name = "Test Client", redirectUris = ["https://example.com/callback"]): Promise<string> {
|
|
55
|
+
const req = makeRequest("https://vault.test/oauth/register", {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json" },
|
|
58
|
+
body: JSON.stringify({ client_name: name, redirect_uris: redirectUris }),
|
|
59
|
+
});
|
|
60
|
+
const res = await handleRegister(req, db);
|
|
61
|
+
const body = await res.json();
|
|
62
|
+
return body.client_id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Run the full OAuth flow and return the access_token. */
|
|
66
|
+
async function fullOAuthFlow(opts?: { scope?: string }): Promise<string> {
|
|
67
|
+
const ownerToken = createOwnerToken();
|
|
68
|
+
const clientId = await registerClient();
|
|
69
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
70
|
+
const redirectUri = "https://example.com/callback";
|
|
71
|
+
const scope = opts?.scope || "full";
|
|
72
|
+
|
|
73
|
+
// POST authorize (simulate user clicking Authorize with valid owner token)
|
|
74
|
+
const authReq = makeRequest("https://vault.test/oauth/authorize", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
body: new URLSearchParams({
|
|
77
|
+
action: "authorize",
|
|
78
|
+
client_id: clientId,
|
|
79
|
+
redirect_uri: redirectUri,
|
|
80
|
+
code_challenge: codeChallenge,
|
|
81
|
+
code_challenge_method: "S256",
|
|
82
|
+
scope,
|
|
83
|
+
state: "test-state",
|
|
84
|
+
owner_token: ownerToken,
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
const authRes = await handleAuthorizePost(authReq, db);
|
|
88
|
+
expect(authRes.status).toBe(302);
|
|
89
|
+
const location = new URL(authRes.headers.get("location")!);
|
|
90
|
+
const code = location.searchParams.get("code")!;
|
|
91
|
+
expect(code).toBeTruthy();
|
|
92
|
+
|
|
93
|
+
// Exchange code for token
|
|
94
|
+
const tokenReq = makeRequest("https://vault.test/oauth/token", {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
97
|
+
body: new URLSearchParams({
|
|
98
|
+
grant_type: "authorization_code",
|
|
99
|
+
code,
|
|
100
|
+
code_verifier: codeVerifier,
|
|
101
|
+
client_id: clientId,
|
|
102
|
+
redirect_uri: redirectUri,
|
|
103
|
+
}).toString(),
|
|
104
|
+
});
|
|
105
|
+
const tokenRes = await handleToken(tokenReq, db);
|
|
106
|
+
const tokenBody = await tokenRes.json();
|
|
107
|
+
return tokenBody.access_token;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Discovery
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
describe("OAuth discovery", () => {
|
|
115
|
+
test("protected resource metadata", async () => {
|
|
116
|
+
const req = makeRequest("https://vault.test/.well-known/oauth-protected-resource");
|
|
117
|
+
const res = handleProtectedResource(req);
|
|
118
|
+
expect(res.status).toBe(200);
|
|
119
|
+
const body = await res.json();
|
|
120
|
+
expect(body.resource).toBe("https://vault.test/mcp");
|
|
121
|
+
expect(body.scopes_supported).toContain("full");
|
|
122
|
+
expect(body.scopes_supported).toContain("read");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("authorization server metadata", async () => {
|
|
126
|
+
const req = makeRequest("https://vault.test/.well-known/oauth-authorization-server");
|
|
127
|
+
const res = handleAuthorizationServer(req);
|
|
128
|
+
expect(res.status).toBe(200);
|
|
129
|
+
const body = await res.json();
|
|
130
|
+
expect(body.issuer).toBe("https://vault.test");
|
|
131
|
+
expect(body.authorization_endpoint).toBe("https://vault.test/oauth/authorize");
|
|
132
|
+
expect(body.token_endpoint).toBe("https://vault.test/oauth/token");
|
|
133
|
+
expect(body.registration_endpoint).toBe("https://vault.test/oauth/register");
|
|
134
|
+
expect(body.code_challenge_methods_supported).toEqual(["S256"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("resource URL includes custom mcpPath for per-vault", async () => {
|
|
138
|
+
const req = makeRequest("https://vault.test/.well-known/oauth-protected-resource");
|
|
139
|
+
const res = handleProtectedResource(req, "/vaults/work/mcp");
|
|
140
|
+
const body = await res.json();
|
|
141
|
+
expect(body.resource).toBe("https://vault.test/vaults/work/mcp");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("uses x-forwarded-proto and x-forwarded-host", async () => {
|
|
145
|
+
const req = makeRequest("http://localhost:1940/.well-known/oauth-protected-resource", {
|
|
146
|
+
headers: {
|
|
147
|
+
"x-forwarded-proto": "https",
|
|
148
|
+
"x-forwarded-host": "vault.example.com",
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
const res = handleProtectedResource(req);
|
|
152
|
+
const body = await res.json();
|
|
153
|
+
expect(body.resource).toBe("https://vault.example.com/mcp");
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Client Registration
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
describe("OAuth client registration", () => {
|
|
162
|
+
test("registers a client", async () => {
|
|
163
|
+
const req = makeRequest("https://vault.test/oauth/register", {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { "Content-Type": "application/json" },
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
client_name: "Claude Web",
|
|
168
|
+
redirect_uris: ["https://claude.ai/callback"],
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
const res = await handleRegister(req, db);
|
|
172
|
+
expect(res.status).toBe(201);
|
|
173
|
+
const body = await res.json();
|
|
174
|
+
expect(body.client_id).toBeTruthy();
|
|
175
|
+
expect(body.client_name).toBe("Claude Web");
|
|
176
|
+
expect(body.redirect_uris).toEqual(["https://claude.ai/callback"]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("rejects missing redirect_uris", async () => {
|
|
180
|
+
const req = makeRequest("https://vault.test/oauth/register", {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "Content-Type": "application/json" },
|
|
183
|
+
body: JSON.stringify({ client_name: "Bad Client" }),
|
|
184
|
+
});
|
|
185
|
+
const res = await handleRegister(req, db);
|
|
186
|
+
expect(res.status).toBe(400);
|
|
187
|
+
const body = await res.json();
|
|
188
|
+
expect(body.error).toBe("invalid_client_metadata");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("rejects non-POST", async () => {
|
|
192
|
+
const req = makeRequest("https://vault.test/oauth/register");
|
|
193
|
+
const res = await handleRegister(req, db);
|
|
194
|
+
expect(res.status).toBe(405);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("defaults client_name to Unknown Client", async () => {
|
|
198
|
+
const req = makeRequest("https://vault.test/oauth/register", {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { "Content-Type": "application/json" },
|
|
201
|
+
body: JSON.stringify({ redirect_uris: ["https://example.com/cb"] }),
|
|
202
|
+
});
|
|
203
|
+
const res = await handleRegister(req, db);
|
|
204
|
+
const body = await res.json();
|
|
205
|
+
expect(body.client_name).toBe("Unknown Client");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Authorization
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
describe("OAuth authorization", () => {
|
|
214
|
+
test("renders consent page with valid params", async () => {
|
|
215
|
+
const clientId = await registerClient();
|
|
216
|
+
const { codeChallenge } = generatePkce();
|
|
217
|
+
|
|
218
|
+
const req = makeRequest(
|
|
219
|
+
`https://vault.test/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=https://example.com/callback&code_challenge=${codeChallenge}&code_challenge_method=S256&scope=full&state=abc`,
|
|
220
|
+
);
|
|
221
|
+
const res = handleAuthorizeGet(req, db, "default");
|
|
222
|
+
expect(res.status).toBe(200);
|
|
223
|
+
const html = await res.text();
|
|
224
|
+
expect(html).toContain("Authorize access");
|
|
225
|
+
expect(html).toContain("Test Client");
|
|
226
|
+
expect(html).toContain("Full access");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("rejects missing client_id", () => {
|
|
230
|
+
const req = makeRequest("https://vault.test/oauth/authorize?response_type=code&redirect_uri=x&code_challenge=y");
|
|
231
|
+
const res = handleAuthorizeGet(req, db, "default");
|
|
232
|
+
expect(res.status).toBe(400);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("rejects unknown client", () => {
|
|
236
|
+
const req = makeRequest(
|
|
237
|
+
"https://vault.test/oauth/authorize?response_type=code&client_id=unknown&redirect_uri=x&code_challenge=y",
|
|
238
|
+
);
|
|
239
|
+
const res = handleAuthorizeGet(req, db, "default");
|
|
240
|
+
expect(res.status).toBe(400);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("rejects mismatched redirect_uri", async () => {
|
|
244
|
+
const clientId = await registerClient();
|
|
245
|
+
const req = makeRequest(
|
|
246
|
+
`https://vault.test/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=https://evil.com/callback&code_challenge=abc`,
|
|
247
|
+
);
|
|
248
|
+
const res = handleAuthorizeGet(req, db, "default");
|
|
249
|
+
expect(res.status).toBe(400);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("POST authorize (approve) redirects with code", async () => {
|
|
253
|
+
const ownerToken = createOwnerToken();
|
|
254
|
+
const clientId = await registerClient();
|
|
255
|
+
const { codeChallenge } = generatePkce();
|
|
256
|
+
|
|
257
|
+
const req = makeRequest("https://vault.test/oauth/authorize", {
|
|
258
|
+
method: "POST",
|
|
259
|
+
body: new URLSearchParams({
|
|
260
|
+
action: "authorize",
|
|
261
|
+
client_id: clientId,
|
|
262
|
+
redirect_uri: "https://example.com/callback",
|
|
263
|
+
code_challenge: codeChallenge,
|
|
264
|
+
code_challenge_method: "S256",
|
|
265
|
+
scope: "full",
|
|
266
|
+
state: "mystate",
|
|
267
|
+
owner_token: ownerToken,
|
|
268
|
+
}),
|
|
269
|
+
});
|
|
270
|
+
const res = await handleAuthorizePost(req, db);
|
|
271
|
+
expect(res.status).toBe(302);
|
|
272
|
+
const location = new URL(res.headers.get("location")!);
|
|
273
|
+
expect(location.origin).toBe("https://example.com");
|
|
274
|
+
expect(location.pathname).toBe("/callback");
|
|
275
|
+
expect(location.searchParams.get("code")).toBeTruthy();
|
|
276
|
+
expect(location.searchParams.get("state")).toBe("mystate");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("POST authorize (deny) redirects with error", async () => {
|
|
280
|
+
const clientId = await registerClient();
|
|
281
|
+
const { codeChallenge } = generatePkce();
|
|
282
|
+
const req = makeRequest("https://vault.test/oauth/authorize", {
|
|
283
|
+
method: "POST",
|
|
284
|
+
body: new URLSearchParams({
|
|
285
|
+
action: "deny",
|
|
286
|
+
client_id: clientId,
|
|
287
|
+
redirect_uri: "https://example.com/callback",
|
|
288
|
+
code_challenge: codeChallenge,
|
|
289
|
+
code_challenge_method: "S256",
|
|
290
|
+
state: "s",
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
const res = await handleAuthorizePost(req, db);
|
|
294
|
+
expect(res.status).toBe(302);
|
|
295
|
+
const location = new URL(res.headers.get("location")!);
|
|
296
|
+
expect(location.searchParams.get("error")).toBe("access_denied");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("POST authorize rejects unregistered redirect_uri (prevents open redirect)", async () => {
|
|
300
|
+
const clientId = await registerClient();
|
|
301
|
+
const { codeChallenge } = generatePkce();
|
|
302
|
+
const req = makeRequest("https://vault.test/oauth/authorize", {
|
|
303
|
+
method: "POST",
|
|
304
|
+
body: new URLSearchParams({
|
|
305
|
+
action: "deny",
|
|
306
|
+
client_id: clientId,
|
|
307
|
+
redirect_uri: "https://evil.com/steal",
|
|
308
|
+
code_challenge: codeChallenge,
|
|
309
|
+
code_challenge_method: "S256",
|
|
310
|
+
}),
|
|
311
|
+
});
|
|
312
|
+
const res = await handleAuthorizePost(req, db);
|
|
313
|
+
// Should NOT redirect — returns 400 instead
|
|
314
|
+
expect(res.status).toBe(400);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("POST authorize rejects unknown client_id", async () => {
|
|
318
|
+
const { codeChallenge } = generatePkce();
|
|
319
|
+
const req = makeRequest("https://vault.test/oauth/authorize", {
|
|
320
|
+
method: "POST",
|
|
321
|
+
body: new URLSearchParams({
|
|
322
|
+
action: "authorize",
|
|
323
|
+
client_id: "nonexistent",
|
|
324
|
+
redirect_uri: "https://evil.com/steal",
|
|
325
|
+
code_challenge: codeChallenge,
|
|
326
|
+
code_challenge_method: "S256",
|
|
327
|
+
}),
|
|
328
|
+
});
|
|
329
|
+
const res = await handleAuthorizePost(req, db);
|
|
330
|
+
expect(res.status).toBe(400);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("POST authorize rejects missing owner token", async () => {
|
|
334
|
+
const clientId = await registerClient();
|
|
335
|
+
const { codeChallenge } = generatePkce();
|
|
336
|
+
const req = makeRequest("https://vault.test/oauth/authorize", {
|
|
337
|
+
method: "POST",
|
|
338
|
+
body: new URLSearchParams({
|
|
339
|
+
action: "authorize",
|
|
340
|
+
client_id: clientId,
|
|
341
|
+
redirect_uri: "https://example.com/callback",
|
|
342
|
+
code_challenge: codeChallenge,
|
|
343
|
+
code_challenge_method: "S256",
|
|
344
|
+
scope: "full",
|
|
345
|
+
}),
|
|
346
|
+
});
|
|
347
|
+
const res = await handleAuthorizePost(req, db, { vaultName: "default" });
|
|
348
|
+
// Should re-render consent page with error, not redirect
|
|
349
|
+
expect(res.status).toBe(200);
|
|
350
|
+
const html = await res.text();
|
|
351
|
+
expect(html).toContain("Vault token is required");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("POST authorize rejects invalid owner token", async () => {
|
|
355
|
+
const clientId = await registerClient();
|
|
356
|
+
const { codeChallenge } = generatePkce();
|
|
357
|
+
const req = makeRequest("https://vault.test/oauth/authorize", {
|
|
358
|
+
method: "POST",
|
|
359
|
+
body: new URLSearchParams({
|
|
360
|
+
action: "authorize",
|
|
361
|
+
client_id: clientId,
|
|
362
|
+
redirect_uri: "https://example.com/callback",
|
|
363
|
+
code_challenge: codeChallenge,
|
|
364
|
+
code_challenge_method: "S256",
|
|
365
|
+
scope: "full",
|
|
366
|
+
owner_token: "pvt_invalid_token_value",
|
|
367
|
+
}),
|
|
368
|
+
});
|
|
369
|
+
const res = await handleAuthorizePost(req, db, { vaultName: "default" });
|
|
370
|
+
expect(res.status).toBe(200);
|
|
371
|
+
const html = await res.text();
|
|
372
|
+
expect(html).toContain("Invalid vault token");
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// Token exchange
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
describe("OAuth token exchange", () => {
|
|
381
|
+
test("full flow: register → authorize → token", async () => {
|
|
382
|
+
const token = await fullOAuthFlow();
|
|
383
|
+
expect(token.startsWith("pvt_")).toBe(true);
|
|
384
|
+
|
|
385
|
+
// The token should resolve in the vault's token table
|
|
386
|
+
const resolved = resolveToken(db, token);
|
|
387
|
+
expect(resolved).not.toBeNull();
|
|
388
|
+
expect(resolved!.permission).toBe("full");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("read scope produces read-only token", async () => {
|
|
392
|
+
const token = await fullOAuthFlow({ scope: "read" });
|
|
393
|
+
const resolved = resolveToken(db, token);
|
|
394
|
+
expect(resolved!.permission).toBe("read");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("rejects invalid code", async () => {
|
|
398
|
+
const clientId = await registerClient();
|
|
399
|
+
const req = makeRequest("https://vault.test/oauth/token", {
|
|
400
|
+
method: "POST",
|
|
401
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
402
|
+
body: new URLSearchParams({
|
|
403
|
+
grant_type: "authorization_code",
|
|
404
|
+
code: "bogus",
|
|
405
|
+
code_verifier: "whatever",
|
|
406
|
+
client_id: clientId,
|
|
407
|
+
redirect_uri: "https://example.com/callback",
|
|
408
|
+
}).toString(),
|
|
409
|
+
});
|
|
410
|
+
const res = await handleToken(req, db);
|
|
411
|
+
expect(res.status).toBe(400);
|
|
412
|
+
const body = await res.json();
|
|
413
|
+
expect(body.error).toBe("invalid_grant");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("rejects wrong PKCE verifier", async () => {
|
|
417
|
+
const ownerToken = createOwnerToken();
|
|
418
|
+
const clientId = await registerClient();
|
|
419
|
+
const { codeChallenge } = generatePkce();
|
|
420
|
+
const redirectUri = "https://example.com/callback";
|
|
421
|
+
|
|
422
|
+
// Get a real code
|
|
423
|
+
const authReq = makeRequest("https://vault.test/oauth/authorize", {
|
|
424
|
+
method: "POST",
|
|
425
|
+
body: new URLSearchParams({
|
|
426
|
+
action: "authorize",
|
|
427
|
+
client_id: clientId,
|
|
428
|
+
redirect_uri: redirectUri,
|
|
429
|
+
code_challenge: codeChallenge,
|
|
430
|
+
code_challenge_method: "S256",
|
|
431
|
+
scope: "full",
|
|
432
|
+
owner_token: ownerToken,
|
|
433
|
+
}),
|
|
434
|
+
});
|
|
435
|
+
const authRes = await handleAuthorizePost(authReq, db);
|
|
436
|
+
const location = new URL(authRes.headers.get("location")!);
|
|
437
|
+
const code = location.searchParams.get("code")!;
|
|
438
|
+
|
|
439
|
+
// Try to exchange with wrong verifier
|
|
440
|
+
const tokenReq = makeRequest("https://vault.test/oauth/token", {
|
|
441
|
+
method: "POST",
|
|
442
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
443
|
+
body: new URLSearchParams({
|
|
444
|
+
grant_type: "authorization_code",
|
|
445
|
+
code,
|
|
446
|
+
code_verifier: "wrong-verifier",
|
|
447
|
+
client_id: clientId,
|
|
448
|
+
redirect_uri: redirectUri,
|
|
449
|
+
}).toString(),
|
|
450
|
+
});
|
|
451
|
+
const res = await handleToken(tokenReq, db);
|
|
452
|
+
expect(res.status).toBe(400);
|
|
453
|
+
const body = await res.json();
|
|
454
|
+
expect(body.error_description).toContain("PKCE");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("rejects already-used code", async () => {
|
|
458
|
+
const ownerToken = createOwnerToken();
|
|
459
|
+
const clientId = await registerClient();
|
|
460
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
461
|
+
const redirectUri = "https://example.com/callback";
|
|
462
|
+
|
|
463
|
+
// Get code
|
|
464
|
+
const authReq = makeRequest("https://vault.test/oauth/authorize", {
|
|
465
|
+
method: "POST",
|
|
466
|
+
body: new URLSearchParams({
|
|
467
|
+
action: "authorize",
|
|
468
|
+
client_id: clientId,
|
|
469
|
+
redirect_uri: redirectUri,
|
|
470
|
+
code_challenge: codeChallenge,
|
|
471
|
+
code_challenge_method: "S256",
|
|
472
|
+
scope: "full",
|
|
473
|
+
owner_token: ownerToken,
|
|
474
|
+
}),
|
|
475
|
+
});
|
|
476
|
+
const authRes = await handleAuthorizePost(authReq, db);
|
|
477
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
478
|
+
|
|
479
|
+
const tokenParams = new URLSearchParams({
|
|
480
|
+
grant_type: "authorization_code",
|
|
481
|
+
code,
|
|
482
|
+
code_verifier: codeVerifier,
|
|
483
|
+
client_id: clientId,
|
|
484
|
+
redirect_uri: redirectUri,
|
|
485
|
+
}).toString();
|
|
486
|
+
|
|
487
|
+
// First exchange — succeeds
|
|
488
|
+
const res1 = await handleToken(
|
|
489
|
+
makeRequest("https://vault.test/oauth/token", {
|
|
490
|
+
method: "POST",
|
|
491
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
492
|
+
body: tokenParams,
|
|
493
|
+
}),
|
|
494
|
+
db,
|
|
495
|
+
);
|
|
496
|
+
expect(res1.status).toBe(200);
|
|
497
|
+
|
|
498
|
+
// Second exchange — fails (code already used)
|
|
499
|
+
const res2 = await handleToken(
|
|
500
|
+
makeRequest("https://vault.test/oauth/token", {
|
|
501
|
+
method: "POST",
|
|
502
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
503
|
+
body: tokenParams,
|
|
504
|
+
}),
|
|
505
|
+
db,
|
|
506
|
+
);
|
|
507
|
+
expect(res2.status).toBe(400);
|
|
508
|
+
const body = await res2.json();
|
|
509
|
+
expect(body.error_description).toContain("already used");
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("rejects expired code", async () => {
|
|
513
|
+
const clientId = await registerClient();
|
|
514
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
515
|
+
const redirectUri = "https://example.com/callback";
|
|
516
|
+
|
|
517
|
+
// Insert an expired code directly
|
|
518
|
+
const code = crypto.randomBytes(32).toString("base64url");
|
|
519
|
+
db.prepare(`
|
|
520
|
+
INSERT INTO oauth_codes (code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, created_at)
|
|
521
|
+
VALUES (?, ?, ?, 'S256', 'full', ?, ?, ?)
|
|
522
|
+
`).run(code, clientId, codeChallenge, redirectUri, "2020-01-01T00:00:00.000Z", new Date().toISOString());
|
|
523
|
+
|
|
524
|
+
const res = await handleToken(
|
|
525
|
+
makeRequest("https://vault.test/oauth/token", {
|
|
526
|
+
method: "POST",
|
|
527
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
528
|
+
body: new URLSearchParams({
|
|
529
|
+
grant_type: "authorization_code",
|
|
530
|
+
code,
|
|
531
|
+
code_verifier: codeVerifier,
|
|
532
|
+
client_id: clientId,
|
|
533
|
+
redirect_uri: redirectUri,
|
|
534
|
+
}).toString(),
|
|
535
|
+
}),
|
|
536
|
+
db,
|
|
537
|
+
);
|
|
538
|
+
expect(res.status).toBe(400);
|
|
539
|
+
const body = await res.json();
|
|
540
|
+
expect(body.error_description).toContain("expired");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("rejects mismatched client_id", async () => {
|
|
544
|
+
const ownerToken = createOwnerToken();
|
|
545
|
+
const clientId = await registerClient();
|
|
546
|
+
const otherClientId = await registerClient("Other Client");
|
|
547
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
548
|
+
const redirectUri = "https://example.com/callback";
|
|
549
|
+
|
|
550
|
+
// Get code for clientId
|
|
551
|
+
const authRes = await handleAuthorizePost(
|
|
552
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
553
|
+
method: "POST",
|
|
554
|
+
body: new URLSearchParams({
|
|
555
|
+
action: "authorize",
|
|
556
|
+
client_id: clientId,
|
|
557
|
+
redirect_uri: redirectUri,
|
|
558
|
+
code_challenge: codeChallenge,
|
|
559
|
+
code_challenge_method: "S256",
|
|
560
|
+
scope: "full",
|
|
561
|
+
owner_token: ownerToken,
|
|
562
|
+
}),
|
|
563
|
+
}),
|
|
564
|
+
db,
|
|
565
|
+
);
|
|
566
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
567
|
+
|
|
568
|
+
// Try to exchange with different client_id
|
|
569
|
+
const res = await handleToken(
|
|
570
|
+
makeRequest("https://vault.test/oauth/token", {
|
|
571
|
+
method: "POST",
|
|
572
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
573
|
+
body: new URLSearchParams({
|
|
574
|
+
grant_type: "authorization_code",
|
|
575
|
+
code,
|
|
576
|
+
code_verifier: codeVerifier,
|
|
577
|
+
client_id: otherClientId,
|
|
578
|
+
redirect_uri: redirectUri,
|
|
579
|
+
}).toString(),
|
|
580
|
+
}),
|
|
581
|
+
db,
|
|
582
|
+
);
|
|
583
|
+
expect(res.status).toBe(400);
|
|
584
|
+
const body = await res.json();
|
|
585
|
+
expect(body.error).toBe("invalid_grant");
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("rejects unsupported grant_type", async () => {
|
|
589
|
+
const res = await handleToken(
|
|
590
|
+
makeRequest("https://vault.test/oauth/token", {
|
|
591
|
+
method: "POST",
|
|
592
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
593
|
+
body: "grant_type=client_credentials",
|
|
594
|
+
}),
|
|
595
|
+
db,
|
|
596
|
+
);
|
|
597
|
+
expect(res.status).toBe(400);
|
|
598
|
+
const body = await res.json();
|
|
599
|
+
expect(body.error).toBe("unsupported_grant_type");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("accepts JSON body", async () => {
|
|
603
|
+
const token = await fullOAuthFlow();
|
|
604
|
+
// fullOAuthFlow uses form-encoded. Let's also test JSON for token endpoint
|
|
605
|
+
expect(token.startsWith("pvt_")).toBe(true);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("rejects missing redirect_uri", async () => {
|
|
609
|
+
const clientId = await registerClient();
|
|
610
|
+
const res = await handleToken(
|
|
611
|
+
makeRequest("https://vault.test/oauth/token", {
|
|
612
|
+
method: "POST",
|
|
613
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
614
|
+
body: new URLSearchParams({
|
|
615
|
+
grant_type: "authorization_code",
|
|
616
|
+
code: "some-code",
|
|
617
|
+
code_verifier: "some-verifier",
|
|
618
|
+
client_id: clientId,
|
|
619
|
+
}).toString(),
|
|
620
|
+
}),
|
|
621
|
+
db,
|
|
622
|
+
);
|
|
623
|
+
expect(res.status).toBe(400);
|
|
624
|
+
const body = await res.json();
|
|
625
|
+
expect(body.error).toBe("invalid_request");
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("rejects non-POST", async () => {
|
|
629
|
+
const res = await handleToken(
|
|
630
|
+
makeRequest("https://vault.test/oauth/token"),
|
|
631
|
+
db,
|
|
632
|
+
);
|
|
633
|
+
expect(res.status).toBe(405);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
// Password-based owner auth
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
describe("OAuth consent — password mode", () => {
|
|
642
|
+
// Use bcrypt cost 4 in tests to keep them fast
|
|
643
|
+
async function hashPassword(pw: string): Promise<string> {
|
|
644
|
+
return await Bun.password.hash(pw, { algorithm: "bcrypt", cost: 4 });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
test("GET renders password field when password is set", async () => {
|
|
648
|
+
const clientId = await registerClient();
|
|
649
|
+
const { codeChallenge } = generatePkce();
|
|
650
|
+
const url = new URL("https://vault.test/oauth/authorize");
|
|
651
|
+
url.searchParams.set("client_id", clientId);
|
|
652
|
+
url.searchParams.set("redirect_uri", "https://example.com/callback");
|
|
653
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
654
|
+
url.searchParams.set("response_type", "code");
|
|
655
|
+
url.searchParams.set("scope", "full");
|
|
656
|
+
const res = handleAuthorizeGet(makeRequest(url.toString()), db, "default", "$2a$fake");
|
|
657
|
+
expect(res.status).toBe(200);
|
|
658
|
+
const html = await res.text();
|
|
659
|
+
expect(html).toContain('name="password"');
|
|
660
|
+
expect(html).not.toContain('name="owner_token"');
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("GET renders owner_token field when no password is set", async () => {
|
|
664
|
+
const clientId = await registerClient();
|
|
665
|
+
const { codeChallenge } = generatePkce();
|
|
666
|
+
const url = new URL("https://vault.test/oauth/authorize");
|
|
667
|
+
url.searchParams.set("client_id", clientId);
|
|
668
|
+
url.searchParams.set("redirect_uri", "https://example.com/callback");
|
|
669
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
670
|
+
url.searchParams.set("response_type", "code");
|
|
671
|
+
url.searchParams.set("scope", "full");
|
|
672
|
+
const res = handleAuthorizeGet(makeRequest(url.toString()), db, "default", null);
|
|
673
|
+
expect(res.status).toBe(200);
|
|
674
|
+
const html = await res.text();
|
|
675
|
+
expect(html).toContain('name="owner_token"');
|
|
676
|
+
expect(html).not.toContain('name="password"');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("POST accepts correct password and mints a token", async () => {
|
|
680
|
+
const password = "correcthorsebatterystaple";
|
|
681
|
+
const passwordHash = await hashPassword(password);
|
|
682
|
+
const clientId = await registerClient();
|
|
683
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
684
|
+
const redirectUri = "https://example.com/callback";
|
|
685
|
+
|
|
686
|
+
const authRes = await handleAuthorizePost(
|
|
687
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
688
|
+
method: "POST",
|
|
689
|
+
body: new URLSearchParams({
|
|
690
|
+
action: "authorize",
|
|
691
|
+
client_id: clientId,
|
|
692
|
+
redirect_uri: redirectUri,
|
|
693
|
+
code_challenge: codeChallenge,
|
|
694
|
+
code_challenge_method: "S256",
|
|
695
|
+
scope: "full",
|
|
696
|
+
password,
|
|
697
|
+
}),
|
|
698
|
+
}),
|
|
699
|
+
db,
|
|
700
|
+
{ ownerPasswordHash: passwordHash },
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
expect(authRes.status).toBe(302);
|
|
704
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
705
|
+
expect(code).toBeTruthy();
|
|
706
|
+
|
|
707
|
+
const tokenRes = await handleToken(
|
|
708
|
+
makeRequest("https://vault.test/oauth/token", {
|
|
709
|
+
method: "POST",
|
|
710
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
711
|
+
body: new URLSearchParams({
|
|
712
|
+
grant_type: "authorization_code",
|
|
713
|
+
code,
|
|
714
|
+
code_verifier: codeVerifier,
|
|
715
|
+
client_id: clientId,
|
|
716
|
+
redirect_uri: redirectUri,
|
|
717
|
+
}).toString(),
|
|
718
|
+
}),
|
|
719
|
+
db,
|
|
720
|
+
);
|
|
721
|
+
const body = await tokenRes.json();
|
|
722
|
+
expect(body.access_token.startsWith("pvt_")).toBe(true);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("POST rejects wrong password with re-rendered consent", async () => {
|
|
726
|
+
const passwordHash = await hashPassword("correcthorsebatterystaple");
|
|
727
|
+
const clientId = await registerClient();
|
|
728
|
+
const { codeChallenge } = generatePkce();
|
|
729
|
+
|
|
730
|
+
const res = await handleAuthorizePost(
|
|
731
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
732
|
+
method: "POST",
|
|
733
|
+
body: new URLSearchParams({
|
|
734
|
+
action: "authorize",
|
|
735
|
+
client_id: clientId,
|
|
736
|
+
redirect_uri: "https://example.com/callback",
|
|
737
|
+
code_challenge: codeChallenge,
|
|
738
|
+
code_challenge_method: "S256",
|
|
739
|
+
scope: "full",
|
|
740
|
+
password: "wrongpassword",
|
|
741
|
+
}),
|
|
742
|
+
}),
|
|
743
|
+
db,
|
|
744
|
+
{ ownerPasswordHash: passwordHash },
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
expect(res.status).toBe(200);
|
|
748
|
+
const html = await res.text();
|
|
749
|
+
expect(html).toContain("Invalid credentials");
|
|
750
|
+
// Should render password field, not owner_token
|
|
751
|
+
expect(html).toContain('name="password"');
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test("POST rejects missing password in password mode", async () => {
|
|
755
|
+
const passwordHash = await hashPassword("correcthorsebatterystaple");
|
|
756
|
+
const clientId = await registerClient();
|
|
757
|
+
const { codeChallenge } = generatePkce();
|
|
758
|
+
|
|
759
|
+
const res = await handleAuthorizePost(
|
|
760
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
761
|
+
method: "POST",
|
|
762
|
+
body: new URLSearchParams({
|
|
763
|
+
action: "authorize",
|
|
764
|
+
client_id: clientId,
|
|
765
|
+
redirect_uri: "https://example.com/callback",
|
|
766
|
+
code_challenge: codeChallenge,
|
|
767
|
+
code_challenge_method: "S256",
|
|
768
|
+
scope: "full",
|
|
769
|
+
}),
|
|
770
|
+
}),
|
|
771
|
+
db,
|
|
772
|
+
{ ownerPasswordHash: passwordHash },
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
expect(res.status).toBe(200);
|
|
776
|
+
const html = await res.text();
|
|
777
|
+
expect(html).toContain("Password is required");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("owner_token is ignored when password is configured", async () => {
|
|
781
|
+
const passwordHash = await hashPassword("correcthorsebatterystaple");
|
|
782
|
+
const ownerToken = createOwnerToken();
|
|
783
|
+
const clientId = await registerClient();
|
|
784
|
+
const { codeChallenge } = generatePkce();
|
|
785
|
+
|
|
786
|
+
// In password mode, providing a valid owner_token is insufficient —
|
|
787
|
+
// only the password is accepted.
|
|
788
|
+
const res = await handleAuthorizePost(
|
|
789
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
790
|
+
method: "POST",
|
|
791
|
+
body: new URLSearchParams({
|
|
792
|
+
action: "authorize",
|
|
793
|
+
client_id: clientId,
|
|
794
|
+
redirect_uri: "https://example.com/callback",
|
|
795
|
+
code_challenge: codeChallenge,
|
|
796
|
+
code_challenge_method: "S256",
|
|
797
|
+
scope: "full",
|
|
798
|
+
owner_token: ownerToken,
|
|
799
|
+
// no password
|
|
800
|
+
}),
|
|
801
|
+
}),
|
|
802
|
+
db,
|
|
803
|
+
{ ownerPasswordHash: passwordHash },
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
expect(res.status).toBe(200);
|
|
807
|
+
const html = await res.text();
|
|
808
|
+
expect(html).toContain("Password is required");
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
// Rate limiting
|
|
814
|
+
// ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
describe("OAuth consent — rate limiting", () => {
|
|
817
|
+
test("locks out an IP after threshold failures", async () => {
|
|
818
|
+
const { RateLimiter } = await import("./owner-auth.ts");
|
|
819
|
+
const limiter = new RateLimiter(3, 60_000, 60_000); // 3 fails = lock
|
|
820
|
+
const passwordHash = await Bun.password.hash("correcthorsebatterystaple", {
|
|
821
|
+
algorithm: "bcrypt",
|
|
822
|
+
cost: 4,
|
|
823
|
+
});
|
|
824
|
+
const clientId = await registerClient();
|
|
825
|
+
const { codeChallenge } = generatePkce();
|
|
826
|
+
const clientIp = "192.0.2.42";
|
|
827
|
+
|
|
828
|
+
const makeAttempt = () => handleAuthorizePost(
|
|
829
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
830
|
+
method: "POST",
|
|
831
|
+
body: new URLSearchParams({
|
|
832
|
+
action: "authorize",
|
|
833
|
+
client_id: clientId,
|
|
834
|
+
redirect_uri: "https://example.com/callback",
|
|
835
|
+
code_challenge: codeChallenge,
|
|
836
|
+
code_challenge_method: "S256",
|
|
837
|
+
scope: "full",
|
|
838
|
+
password: "wrongwrongwrong",
|
|
839
|
+
}),
|
|
840
|
+
}),
|
|
841
|
+
db,
|
|
842
|
+
{ ownerPasswordHash: passwordHash, clientIp, rateLimiter: limiter },
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
// First 3 attempts: 200 with "Invalid credentials"
|
|
846
|
+
for (let i = 0; i < 3; i++) {
|
|
847
|
+
const res = await makeAttempt();
|
|
848
|
+
expect(res.status).toBe(200);
|
|
849
|
+
}
|
|
850
|
+
// 4th attempt should be locked out with 429
|
|
851
|
+
const res = await makeAttempt();
|
|
852
|
+
expect(res.status).toBe(429);
|
|
853
|
+
expect(res.headers.get("Retry-After")).toBeTruthy();
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("successful auth clears the failure counter", async () => {
|
|
857
|
+
const { RateLimiter } = await import("./owner-auth.ts");
|
|
858
|
+
const limiter = new RateLimiter(3, 60_000, 60_000);
|
|
859
|
+
const password = "correcthorsebatterystaple";
|
|
860
|
+
const passwordHash = await Bun.password.hash(password, { algorithm: "bcrypt", cost: 4 });
|
|
861
|
+
const clientId = await registerClient();
|
|
862
|
+
const { codeChallenge } = generatePkce();
|
|
863
|
+
const clientIp = "192.0.2.43";
|
|
864
|
+
|
|
865
|
+
const attempt = (pw: string) => handleAuthorizePost(
|
|
866
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
867
|
+
method: "POST",
|
|
868
|
+
body: new URLSearchParams({
|
|
869
|
+
action: "authorize",
|
|
870
|
+
client_id: clientId,
|
|
871
|
+
redirect_uri: "https://example.com/callback",
|
|
872
|
+
code_challenge: codeChallenge,
|
|
873
|
+
code_challenge_method: "S256",
|
|
874
|
+
scope: "full",
|
|
875
|
+
password: pw,
|
|
876
|
+
}),
|
|
877
|
+
}),
|
|
878
|
+
db,
|
|
879
|
+
{ ownerPasswordHash: passwordHash, clientIp, rateLimiter: limiter },
|
|
880
|
+
);
|
|
881
|
+
|
|
882
|
+
await attempt("wrong1");
|
|
883
|
+
await attempt("wrong2");
|
|
884
|
+
const good = await attempt(password);
|
|
885
|
+
expect(good.status).toBe(302);
|
|
886
|
+
|
|
887
|
+
// Counter reset — we can still do more wrong attempts without lockout
|
|
888
|
+
const r1 = await attempt("wrong3");
|
|
889
|
+
expect(r1.status).toBe(200);
|
|
890
|
+
const r2 = await attempt("wrong4");
|
|
891
|
+
expect(r2.status).toBe(200);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test("locks out an IP after threshold 2FA failures (valid password, bad TOTP)", async () => {
|
|
895
|
+
const { RateLimiter } = await import("./owner-auth.ts");
|
|
896
|
+
const limiter = new RateLimiter(3, 60_000, 60_000); // 3 fails = lock
|
|
897
|
+
const password = "correcthorsebatterystaple";
|
|
898
|
+
const passwordHash = await Bun.password.hash(password, { algorithm: "bcrypt", cost: 4 });
|
|
899
|
+
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
|
900
|
+
const clientId = await registerClient();
|
|
901
|
+
const { codeChallenge } = generatePkce();
|
|
902
|
+
const clientIp = "192.0.2.44";
|
|
903
|
+
|
|
904
|
+
const makeAttempt = () => handleAuthorizePost(
|
|
905
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
906
|
+
method: "POST",
|
|
907
|
+
body: new URLSearchParams({
|
|
908
|
+
action: "authorize",
|
|
909
|
+
client_id: clientId,
|
|
910
|
+
redirect_uri: "https://example.com/callback",
|
|
911
|
+
code_challenge: codeChallenge,
|
|
912
|
+
code_challenge_method: "S256",
|
|
913
|
+
scope: "full",
|
|
914
|
+
password,
|
|
915
|
+
totp_code: "000000", // always invalid
|
|
916
|
+
}),
|
|
917
|
+
}),
|
|
918
|
+
db,
|
|
919
|
+
{ ownerPasswordHash: passwordHash, totpSecret: secret, clientIp, rateLimiter: limiter },
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
// First 3 attempts: 200 with the unified "Invalid credentials" error
|
|
923
|
+
for (let i = 0; i < 3; i++) {
|
|
924
|
+
const res = await makeAttempt();
|
|
925
|
+
expect(res.status).toBe(200);
|
|
926
|
+
const html = await res.text();
|
|
927
|
+
expect(html).toContain("Invalid credentials");
|
|
928
|
+
}
|
|
929
|
+
// 4th attempt should be locked out with 429
|
|
930
|
+
const res = await makeAttempt();
|
|
931
|
+
expect(res.status).toBe(429);
|
|
932
|
+
expect(res.headers.get("Retry-After")).toBeTruthy();
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
// Scope selection
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
|
|
940
|
+
describe("OAuth consent — scope selection", () => {
|
|
941
|
+
test("user can downgrade from full to read via radio", async () => {
|
|
942
|
+
const ownerToken = createOwnerToken();
|
|
943
|
+
const clientId = await registerClient();
|
|
944
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
945
|
+
const redirectUri = "https://example.com/callback";
|
|
946
|
+
|
|
947
|
+
const authRes = await handleAuthorizePost(
|
|
948
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
949
|
+
method: "POST",
|
|
950
|
+
body: new URLSearchParams({
|
|
951
|
+
action: "authorize",
|
|
952
|
+
client_id: clientId,
|
|
953
|
+
redirect_uri: redirectUri,
|
|
954
|
+
code_challenge: codeChallenge,
|
|
955
|
+
code_challenge_method: "S256",
|
|
956
|
+
scope: "full", // requested
|
|
957
|
+
selected_scope: "read", // user chose read-only
|
|
958
|
+
owner_token: ownerToken,
|
|
959
|
+
}),
|
|
960
|
+
}),
|
|
961
|
+
db,
|
|
962
|
+
);
|
|
963
|
+
expect(authRes.status).toBe(302);
|
|
964
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
965
|
+
|
|
966
|
+
const tokenRes = await handleToken(
|
|
967
|
+
makeRequest("https://vault.test/oauth/token", {
|
|
968
|
+
method: "POST",
|
|
969
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
970
|
+
body: new URLSearchParams({
|
|
971
|
+
grant_type: "authorization_code",
|
|
972
|
+
code,
|
|
973
|
+
code_verifier: codeVerifier,
|
|
974
|
+
client_id: clientId,
|
|
975
|
+
redirect_uri: redirectUri,
|
|
976
|
+
}).toString(),
|
|
977
|
+
}),
|
|
978
|
+
db,
|
|
979
|
+
);
|
|
980
|
+
const body = await tokenRes.json();
|
|
981
|
+
expect(body.scope).toBe("read");
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
test("defaults selected_scope to requested scope when not provided", async () => {
|
|
985
|
+
const ownerToken = createOwnerToken();
|
|
986
|
+
const clientId = await registerClient();
|
|
987
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
988
|
+
const redirectUri = "https://example.com/callback";
|
|
989
|
+
|
|
990
|
+
const authRes = await handleAuthorizePost(
|
|
991
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
992
|
+
method: "POST",
|
|
993
|
+
body: new URLSearchParams({
|
|
994
|
+
action: "authorize",
|
|
995
|
+
client_id: clientId,
|
|
996
|
+
redirect_uri: redirectUri,
|
|
997
|
+
code_challenge: codeChallenge,
|
|
998
|
+
code_challenge_method: "S256",
|
|
999
|
+
scope: "read", // requested only, no radio selection
|
|
1000
|
+
owner_token: ownerToken,
|
|
1001
|
+
}),
|
|
1002
|
+
}),
|
|
1003
|
+
db,
|
|
1004
|
+
);
|
|
1005
|
+
const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
|
|
1006
|
+
|
|
1007
|
+
const tokenRes = await handleToken(
|
|
1008
|
+
makeRequest("https://vault.test/oauth/token", {
|
|
1009
|
+
method: "POST",
|
|
1010
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1011
|
+
body: new URLSearchParams({
|
|
1012
|
+
grant_type: "authorization_code",
|
|
1013
|
+
code,
|
|
1014
|
+
code_verifier: codeVerifier,
|
|
1015
|
+
client_id: clientId,
|
|
1016
|
+
redirect_uri: redirectUri,
|
|
1017
|
+
}).toString(),
|
|
1018
|
+
}),
|
|
1019
|
+
db,
|
|
1020
|
+
);
|
|
1021
|
+
const body = await tokenRes.json();
|
|
1022
|
+
expect(body.scope).toBe("read");
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
test("consent HTML includes both scope radio buttons", async () => {
|
|
1026
|
+
const clientId = await registerClient();
|
|
1027
|
+
const { codeChallenge } = generatePkce();
|
|
1028
|
+
const url = new URL("https://vault.test/oauth/authorize");
|
|
1029
|
+
url.searchParams.set("client_id", clientId);
|
|
1030
|
+
url.searchParams.set("redirect_uri", "https://example.com/callback");
|
|
1031
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
1032
|
+
url.searchParams.set("response_type", "code");
|
|
1033
|
+
url.searchParams.set("scope", "full");
|
|
1034
|
+
const res = handleAuthorizeGet(makeRequest(url.toString()), db, "default");
|
|
1035
|
+
const html = await res.text();
|
|
1036
|
+
expect(html).toContain('name="selected_scope"');
|
|
1037
|
+
expect(html).toContain('value="full"');
|
|
1038
|
+
expect(html).toContain('value="read"');
|
|
1039
|
+
// The requested scope should be pre-checked
|
|
1040
|
+
expect(html).toMatch(/value="full"\s+checked/);
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// ---------------------------------------------------------------------------
|
|
1045
|
+
// 2FA (TOTP) on consent
|
|
1046
|
+
// ---------------------------------------------------------------------------
|
|
1047
|
+
|
|
1048
|
+
describe("OAuth consent — 2FA (TOTP)", () => {
|
|
1049
|
+
async function hashPassword(pw: string): Promise<string> {
|
|
1050
|
+
return await Bun.password.hash(pw, { algorithm: "bcrypt", cost: 4 });
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function makeTotp(secretBase32: string) {
|
|
1054
|
+
return new OTPAuth.TOTP({
|
|
1055
|
+
issuer: "Parachute Vault",
|
|
1056
|
+
label: "owner",
|
|
1057
|
+
algorithm: "SHA1",
|
|
1058
|
+
digits: 6,
|
|
1059
|
+
period: 30,
|
|
1060
|
+
secret: OTPAuth.Secret.fromBase32(secretBase32),
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
test("GET renders TOTP field when 2FA enrolled", async () => {
|
|
1065
|
+
const passwordHash = await hashPassword("correcthorsebatterystaple");
|
|
1066
|
+
const clientId = await registerClient();
|
|
1067
|
+
const { codeChallenge } = generatePkce();
|
|
1068
|
+
const url = new URL("https://vault.test/oauth/authorize");
|
|
1069
|
+
url.searchParams.set("client_id", clientId);
|
|
1070
|
+
url.searchParams.set("redirect_uri", "https://example.com/callback");
|
|
1071
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
1072
|
+
url.searchParams.set("response_type", "code");
|
|
1073
|
+
|
|
1074
|
+
const res = handleAuthorizeGet(makeRequest(url.toString()), db, "default", passwordHash, true);
|
|
1075
|
+
const html = await res.text();
|
|
1076
|
+
expect(html).toContain('name="totp_code"');
|
|
1077
|
+
expect(html).toContain('name="backup_code"');
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
test("GET omits TOTP field when 2FA not enrolled", async () => {
|
|
1081
|
+
const passwordHash = await hashPassword("correcthorsebatterystaple");
|
|
1082
|
+
const clientId = await registerClient();
|
|
1083
|
+
const { codeChallenge } = generatePkce();
|
|
1084
|
+
const url = new URL("https://vault.test/oauth/authorize");
|
|
1085
|
+
url.searchParams.set("client_id", clientId);
|
|
1086
|
+
url.searchParams.set("redirect_uri", "https://example.com/callback");
|
|
1087
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
1088
|
+
url.searchParams.set("response_type", "code");
|
|
1089
|
+
|
|
1090
|
+
const res = handleAuthorizeGet(makeRequest(url.toString()), db, "default", passwordHash, false);
|
|
1091
|
+
const html = await res.text();
|
|
1092
|
+
expect(html).not.toContain('name="totp_code"');
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
test("POST accepts valid TOTP + password and mints a token", async () => {
|
|
1096
|
+
const password = "correcthorsebatterystaple";
|
|
1097
|
+
const passwordHash = await hashPassword(password);
|
|
1098
|
+
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
|
1099
|
+
const code = makeTotp(secret).generate();
|
|
1100
|
+
|
|
1101
|
+
const clientId = await registerClient();
|
|
1102
|
+
const { codeVerifier, codeChallenge } = generatePkce();
|
|
1103
|
+
const redirectUri = "https://example.com/callback";
|
|
1104
|
+
|
|
1105
|
+
const res = await handleAuthorizePost(
|
|
1106
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
1107
|
+
method: "POST",
|
|
1108
|
+
body: new URLSearchParams({
|
|
1109
|
+
action: "authorize",
|
|
1110
|
+
client_id: clientId,
|
|
1111
|
+
redirect_uri: redirectUri,
|
|
1112
|
+
code_challenge: codeChallenge,
|
|
1113
|
+
code_challenge_method: "S256",
|
|
1114
|
+
scope: "full",
|
|
1115
|
+
state: "",
|
|
1116
|
+
password,
|
|
1117
|
+
totp_code: code,
|
|
1118
|
+
}),
|
|
1119
|
+
}),
|
|
1120
|
+
db,
|
|
1121
|
+
{ ownerPasswordHash: passwordHash, totpSecret: secret },
|
|
1122
|
+
);
|
|
1123
|
+
expect(res.status).toBe(302);
|
|
1124
|
+
const authCode = new URL(res.headers.get("location")!).searchParams.get("code")!;
|
|
1125
|
+
expect(authCode).toBeTruthy();
|
|
1126
|
+
|
|
1127
|
+
// Exchange works end-to-end
|
|
1128
|
+
const tokenRes = await handleToken(
|
|
1129
|
+
makeRequest("https://vault.test/oauth/token", {
|
|
1130
|
+
method: "POST",
|
|
1131
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1132
|
+
body: new URLSearchParams({
|
|
1133
|
+
grant_type: "authorization_code",
|
|
1134
|
+
code: authCode,
|
|
1135
|
+
code_verifier: codeVerifier,
|
|
1136
|
+
client_id: clientId,
|
|
1137
|
+
redirect_uri: redirectUri,
|
|
1138
|
+
}).toString(),
|
|
1139
|
+
}),
|
|
1140
|
+
db,
|
|
1141
|
+
);
|
|
1142
|
+
expect(tokenRes.status).toBe(200);
|
|
1143
|
+
const body = await tokenRes.json();
|
|
1144
|
+
expect(body.access_token).toBeTruthy();
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
test("POST rejects wrong TOTP with re-rendered consent (no code issued)", async () => {
|
|
1148
|
+
const password = "correcthorsebatterystaple";
|
|
1149
|
+
const passwordHash = await hashPassword(password);
|
|
1150
|
+
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
|
1151
|
+
|
|
1152
|
+
const clientId = await registerClient();
|
|
1153
|
+
const { codeChallenge } = generatePkce();
|
|
1154
|
+
|
|
1155
|
+
const res = await handleAuthorizePost(
|
|
1156
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
1157
|
+
method: "POST",
|
|
1158
|
+
body: new URLSearchParams({
|
|
1159
|
+
action: "authorize",
|
|
1160
|
+
client_id: clientId,
|
|
1161
|
+
redirect_uri: "https://example.com/callback",
|
|
1162
|
+
code_challenge: codeChallenge,
|
|
1163
|
+
code_challenge_method: "S256",
|
|
1164
|
+
scope: "full",
|
|
1165
|
+
state: "",
|
|
1166
|
+
password,
|
|
1167
|
+
totp_code: "000000",
|
|
1168
|
+
}),
|
|
1169
|
+
}),
|
|
1170
|
+
db,
|
|
1171
|
+
{ ownerPasswordHash: passwordHash, totpSecret: secret },
|
|
1172
|
+
);
|
|
1173
|
+
expect(res.status).toBe(200);
|
|
1174
|
+
const html = await res.text();
|
|
1175
|
+
expect(html).toContain("Invalid credentials");
|
|
1176
|
+
// No auth code was created
|
|
1177
|
+
const rows = db.prepare("SELECT COUNT(*) as n FROM oauth_codes").get() as { n: number };
|
|
1178
|
+
expect(rows.n).toBe(0);
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
test("POST rejects missing TOTP when 2FA enrolled", async () => {
|
|
1182
|
+
const password = "correcthorsebatterystaple";
|
|
1183
|
+
const passwordHash = await hashPassword(password);
|
|
1184
|
+
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
|
1185
|
+
|
|
1186
|
+
const clientId = await registerClient();
|
|
1187
|
+
const { codeChallenge } = generatePkce();
|
|
1188
|
+
|
|
1189
|
+
const res = await handleAuthorizePost(
|
|
1190
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
1191
|
+
method: "POST",
|
|
1192
|
+
body: new URLSearchParams({
|
|
1193
|
+
action: "authorize",
|
|
1194
|
+
client_id: clientId,
|
|
1195
|
+
redirect_uri: "https://example.com/callback",
|
|
1196
|
+
code_challenge: codeChallenge,
|
|
1197
|
+
code_challenge_method: "S256",
|
|
1198
|
+
scope: "full",
|
|
1199
|
+
state: "",
|
|
1200
|
+
password,
|
|
1201
|
+
// no totp_code, no backup_code
|
|
1202
|
+
}),
|
|
1203
|
+
}),
|
|
1204
|
+
db,
|
|
1205
|
+
{ ownerPasswordHash: passwordHash, totpSecret: secret },
|
|
1206
|
+
);
|
|
1207
|
+
expect(res.status).toBe(200);
|
|
1208
|
+
const html = await res.text();
|
|
1209
|
+
expect(html).toContain("Enter a 6-digit code");
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
test("POST rejects TOTP when password itself is wrong (TOTP not consulted)", async () => {
|
|
1213
|
+
const passwordHash = await hashPassword("correcthorsebatterystaple");
|
|
1214
|
+
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
|
1215
|
+
const validCode = makeTotp(secret).generate();
|
|
1216
|
+
|
|
1217
|
+
const clientId = await registerClient();
|
|
1218
|
+
const { codeChallenge } = generatePkce();
|
|
1219
|
+
|
|
1220
|
+
const res = await handleAuthorizePost(
|
|
1221
|
+
makeRequest("https://vault.test/oauth/authorize", {
|
|
1222
|
+
method: "POST",
|
|
1223
|
+
body: new URLSearchParams({
|
|
1224
|
+
action: "authorize",
|
|
1225
|
+
client_id: clientId,
|
|
1226
|
+
redirect_uri: "https://example.com/callback",
|
|
1227
|
+
code_challenge: codeChallenge,
|
|
1228
|
+
code_challenge_method: "S256",
|
|
1229
|
+
scope: "full",
|
|
1230
|
+
state: "",
|
|
1231
|
+
password: "wrongwrongwrong",
|
|
1232
|
+
totp_code: validCode,
|
|
1233
|
+
}),
|
|
1234
|
+
}),
|
|
1235
|
+
db,
|
|
1236
|
+
{ ownerPasswordHash: passwordHash, totpSecret: secret },
|
|
1237
|
+
);
|
|
1238
|
+
expect(res.status).toBe(200);
|
|
1239
|
+
const html = await res.text();
|
|
1240
|
+
expect(html).toContain("Invalid credentials");
|
|
1241
|
+
});
|
|
1242
|
+
});
|