@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
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 provider for Parachute Vault.
|
|
3
|
+
*
|
|
4
|
+
* Implements the subset of OAuth 2.1 needed for MCP clients (Claude Web,
|
|
5
|
+
* Claude Desktop, etc.) to connect via the standard browser-based flow:
|
|
6
|
+
*
|
|
7
|
+
* 1. Dynamic Client Registration (RFC 7591) — POST /oauth/register
|
|
8
|
+
* 2. Authorization endpoint (PKCE required) — GET/POST /oauth/authorize
|
|
9
|
+
* 3. Token endpoint (code exchange) — POST /oauth/token
|
|
10
|
+
* 4. Discovery endpoints — GET /.well-known/*
|
|
11
|
+
*
|
|
12
|
+
* The flow produces a standard `pvt_` token stored in the vault's tokens table.
|
|
13
|
+
* After the OAuth handshake, all requests use the same Bearer token auth path.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import type { Database } from "bun:sqlite";
|
|
18
|
+
import { generateToken, createToken, resolveToken } from "./token-store.ts";
|
|
19
|
+
import type { TokenPermission } from "./token-store.ts";
|
|
20
|
+
import { verifyOwnerPassword, authorizeRateLimit, type RateLimiter } from "./owner-auth.ts";
|
|
21
|
+
import { verifyTotpCode, verifyAndConsumeBackupCode } from "./two-factor.ts";
|
|
22
|
+
|
|
23
|
+
/** Options for handleAuthorizePost. */
|
|
24
|
+
export interface AuthorizePostOptions {
|
|
25
|
+
vaultName?: string;
|
|
26
|
+
/** Client IP address (from Bun server.requestIP). If provided, rate limiting is applied. */
|
|
27
|
+
clientIp?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Bcrypt hash of the owner password. When set, the consent form requires a
|
|
30
|
+
* `password` field. When null/undefined, falls back to legacy `owner_token`
|
|
31
|
+
* auth (vault token in the consent form).
|
|
32
|
+
*/
|
|
33
|
+
ownerPasswordHash?: string | null;
|
|
34
|
+
/**
|
|
35
|
+
* Base32-encoded TOTP secret. When set, consent additionally requires a
|
|
36
|
+
* `totp_code` (6-digit) or `backup_code` form field.
|
|
37
|
+
*/
|
|
38
|
+
totpSecret?: string | null;
|
|
39
|
+
/** Override for testing; defaults to the module singleton. */
|
|
40
|
+
rateLimiter?: RateLimiter;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Helpers
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
function getBaseUrl(req: Request): string {
|
|
48
|
+
const forwardedHost = req.headers.get("x-forwarded-host");
|
|
49
|
+
const forwardedProto = req.headers.get("x-forwarded-proto");
|
|
50
|
+
if (forwardedHost) {
|
|
51
|
+
return `${forwardedProto || "https"}://${forwardedHost}`;
|
|
52
|
+
}
|
|
53
|
+
// Fall back to the request URL's origin
|
|
54
|
+
const url = new URL(req.url);
|
|
55
|
+
return url.origin;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function escapeHtml(s: string): string {
|
|
59
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Discovery endpoints
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export function handleProtectedResource(req: Request, mcpPath = "/mcp"): Response {
|
|
67
|
+
const base = getBaseUrl(req);
|
|
68
|
+
return Response.json({
|
|
69
|
+
resource: `${base}${mcpPath}`,
|
|
70
|
+
authorization_servers: [base],
|
|
71
|
+
scopes_supported: ["full", "read"],
|
|
72
|
+
bearer_methods_supported: ["header"],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function handleAuthorizationServer(req: Request): Response {
|
|
77
|
+
const base = getBaseUrl(req);
|
|
78
|
+
return Response.json({
|
|
79
|
+
issuer: base,
|
|
80
|
+
authorization_endpoint: `${base}/oauth/authorize`,
|
|
81
|
+
token_endpoint: `${base}/oauth/token`,
|
|
82
|
+
registration_endpoint: `${base}/oauth/register`,
|
|
83
|
+
response_types_supported: ["code"],
|
|
84
|
+
code_challenge_methods_supported: ["S256"],
|
|
85
|
+
grant_types_supported: ["authorization_code"],
|
|
86
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
87
|
+
scopes_supported: ["full", "read"],
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Dynamic Client Registration (RFC 7591)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export async function handleRegister(req: Request, db: Database): Promise<Response> {
|
|
96
|
+
if (req.method !== "POST") {
|
|
97
|
+
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let body: any;
|
|
101
|
+
try {
|
|
102
|
+
body = await req.json();
|
|
103
|
+
} catch {
|
|
104
|
+
return Response.json({ error: "invalid_request", error_description: "Invalid JSON body" }, { status: 400 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const redirectUris = body.redirect_uris;
|
|
108
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
|
|
109
|
+
return Response.json(
|
|
110
|
+
{ error: "invalid_client_metadata", error_description: "redirect_uris is required" },
|
|
111
|
+
{ status: 400 },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const clientId = crypto.randomUUID();
|
|
116
|
+
const clientName = body.client_name || "Unknown Client";
|
|
117
|
+
const now = new Date().toISOString();
|
|
118
|
+
|
|
119
|
+
db.prepare(`
|
|
120
|
+
INSERT INTO oauth_clients (client_id, client_name, redirect_uris, created_at)
|
|
121
|
+
VALUES (?, ?, ?, ?)
|
|
122
|
+
`).run(clientId, clientName, JSON.stringify(redirectUris), now);
|
|
123
|
+
|
|
124
|
+
return Response.json({
|
|
125
|
+
client_id: clientId,
|
|
126
|
+
client_name: clientName,
|
|
127
|
+
redirect_uris: redirectUris,
|
|
128
|
+
grant_types: ["authorization_code"],
|
|
129
|
+
response_types: ["code"],
|
|
130
|
+
token_endpoint_auth_method: "none",
|
|
131
|
+
}, { status: 201 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Authorization endpoint
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
export function handleAuthorizeGet(
|
|
139
|
+
req: Request,
|
|
140
|
+
db: Database,
|
|
141
|
+
vaultName: string,
|
|
142
|
+
ownerPasswordHash?: string | null,
|
|
143
|
+
totpEnrolled = false,
|
|
144
|
+
): Response {
|
|
145
|
+
const url = new URL(req.url);
|
|
146
|
+
const clientId = url.searchParams.get("client_id");
|
|
147
|
+
const redirectUri = url.searchParams.get("redirect_uri");
|
|
148
|
+
const codeChallenge = url.searchParams.get("code_challenge");
|
|
149
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "S256";
|
|
150
|
+
const responseType = url.searchParams.get("response_type");
|
|
151
|
+
const scope = url.searchParams.get("scope") || "full";
|
|
152
|
+
const state = url.searchParams.get("state") || "";
|
|
153
|
+
|
|
154
|
+
// Validate required params
|
|
155
|
+
if (!clientId || !redirectUri || !codeChallenge || responseType !== "code") {
|
|
156
|
+
return new Response(renderErrorPage("Missing or invalid parameters. Required: client_id, redirect_uri, code_challenge, response_type=code"), {
|
|
157
|
+
status: 400,
|
|
158
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (codeChallengeMethod !== "S256") {
|
|
163
|
+
return new Response(renderErrorPage("Only S256 code challenge method is supported."), {
|
|
164
|
+
status: 400,
|
|
165
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Validate client
|
|
170
|
+
const client = db.prepare("SELECT client_id, client_name, redirect_uris FROM oauth_clients WHERE client_id = ?")
|
|
171
|
+
.get(clientId) as { client_id: string; client_name: string; redirect_uris: string } | null;
|
|
172
|
+
|
|
173
|
+
if (!client) {
|
|
174
|
+
return new Response(renderErrorPage("Unknown client."), {
|
|
175
|
+
status: 400,
|
|
176
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Validate redirect_uri matches registration
|
|
181
|
+
const registeredUris: string[] = JSON.parse(client.redirect_uris);
|
|
182
|
+
if (!registeredUris.includes(redirectUri)) {
|
|
183
|
+
return new Response(renderErrorPage("Redirect URI does not match registered client."), {
|
|
184
|
+
status: 400,
|
|
185
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Normalize requested scope. The user can change it via the radio buttons.
|
|
190
|
+
const requestedScope: TokenPermission = scope === "read" ? "read" : "full";
|
|
191
|
+
|
|
192
|
+
// Render consent page
|
|
193
|
+
const html = renderConsentPage({
|
|
194
|
+
vaultName,
|
|
195
|
+
clientName: client.client_name,
|
|
196
|
+
requestedScope,
|
|
197
|
+
selectedScope: requestedScope,
|
|
198
|
+
clientId,
|
|
199
|
+
redirectUri,
|
|
200
|
+
codeChallenge,
|
|
201
|
+
codeChallengeMethod,
|
|
202
|
+
state,
|
|
203
|
+
passwordMode: typeof ownerPasswordHash === "string" && ownerPasswordHash.length > 0,
|
|
204
|
+
totpEnrolled,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return new Response(html, {
|
|
208
|
+
status: 200,
|
|
209
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function handleAuthorizePost(
|
|
214
|
+
req: Request,
|
|
215
|
+
db: Database,
|
|
216
|
+
opts: AuthorizePostOptions = {},
|
|
217
|
+
): Promise<Response> {
|
|
218
|
+
const { vaultName, clientIp, ownerPasswordHash, totpSecret, rateLimiter = authorizeRateLimit } = opts;
|
|
219
|
+
const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
|
|
220
|
+
|
|
221
|
+
let form: FormData;
|
|
222
|
+
try {
|
|
223
|
+
form = await req.formData();
|
|
224
|
+
} catch {
|
|
225
|
+
return Response.json({ error: "invalid_request" }, { status: 400 });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const action = form.get("action") as string;
|
|
229
|
+
const clientId = form.get("client_id") as string;
|
|
230
|
+
const redirectUri = form.get("redirect_uri") as string;
|
|
231
|
+
const codeChallenge = form.get("code_challenge") as string;
|
|
232
|
+
const codeChallengeMethod = form.get("code_challenge_method") as string || "S256";
|
|
233
|
+
// Requested scope (from hidden field, carried from GET) and selected scope
|
|
234
|
+
// (from radio button on the consent page). Default selected to requested.
|
|
235
|
+
const requestedScope = form.get("scope") as string || "full";
|
|
236
|
+
const selectedScopeRaw = form.get("selected_scope") as string | null;
|
|
237
|
+
const selectedScope = selectedScopeRaw === "read" || selectedScopeRaw === "full"
|
|
238
|
+
? selectedScopeRaw
|
|
239
|
+
: (requestedScope === "read" ? "read" : "full");
|
|
240
|
+
const state = form.get("state") as string || "";
|
|
241
|
+
|
|
242
|
+
if (!clientId || !redirectUri || !codeChallenge) {
|
|
243
|
+
return Response.json({ error: "invalid_request" }, { status: 400 });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Validate client and redirect_uri BEFORE constructing any redirect.
|
|
247
|
+
// This prevents open-redirect attacks via crafted redirect_uri values.
|
|
248
|
+
const client = db.prepare("SELECT redirect_uris FROM oauth_clients WHERE client_id = ?")
|
|
249
|
+
.get(clientId) as { redirect_uris: string } | null;
|
|
250
|
+
|
|
251
|
+
if (!client) {
|
|
252
|
+
return Response.json({ error: "invalid_request", error_description: "Unknown client" }, { status: 400 });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const registeredUris: string[] = JSON.parse(client.redirect_uris);
|
|
256
|
+
if (!registeredUris.includes(redirectUri)) {
|
|
257
|
+
return Response.json({ error: "invalid_request", error_description: "redirect_uri mismatch" }, { status: 400 });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Only S256 is supported
|
|
261
|
+
if (codeChallengeMethod !== "S256") {
|
|
262
|
+
return Response.json({ error: "invalid_request", error_description: "Only S256 code challenge method is supported" }, { status: 400 });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const redirect = new URL(redirectUri);
|
|
266
|
+
if (state) redirect.searchParams.set("state", state);
|
|
267
|
+
|
|
268
|
+
// User denied
|
|
269
|
+
if (action === "deny") {
|
|
270
|
+
redirect.searchParams.set("error", "access_denied");
|
|
271
|
+
return Response.redirect(redirect.toString(), 302);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Rate-limit the owner-auth step. Applied before any credential check so
|
|
275
|
+
// brute-force attempts are capped regardless of which path (password or
|
|
276
|
+
// legacy token) is being used.
|
|
277
|
+
if (clientIp) {
|
|
278
|
+
const gate = rateLimiter.check(clientIp);
|
|
279
|
+
if (!gate.allowed) {
|
|
280
|
+
return new Response(renderErrorPage(
|
|
281
|
+
`Too many failed attempts. Try again in ${Math.ceil(gate.retryAfterSec / 60)} minute(s).`,
|
|
282
|
+
), {
|
|
283
|
+
status: 429,
|
|
284
|
+
headers: {
|
|
285
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
286
|
+
"Retry-After": String(gate.retryAfterSec),
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Verify owner identity — password if configured, else legacy vault token.
|
|
293
|
+
const passwordMode = typeof ownerPasswordHash === "string" && ownerPasswordHash.length > 0;
|
|
294
|
+
let ownerOk = false;
|
|
295
|
+
let errorMsg = "";
|
|
296
|
+
|
|
297
|
+
if (passwordMode) {
|
|
298
|
+
const password = form.get("password") as string;
|
|
299
|
+
if (!password) {
|
|
300
|
+
errorMsg = "Password is required.";
|
|
301
|
+
} else {
|
|
302
|
+
ownerOk = await verifyOwnerPassword(password, ownerPasswordHash!);
|
|
303
|
+
// Keep failure messages uniform across password / TOTP / backup-code so
|
|
304
|
+
// an attacker can't tell which factor was wrong.
|
|
305
|
+
if (!ownerOk) errorMsg = "Invalid credentials.";
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
const ownerToken = form.get("owner_token") as string;
|
|
309
|
+
if (!ownerToken) {
|
|
310
|
+
errorMsg = "Vault token is required.";
|
|
311
|
+
} else {
|
|
312
|
+
ownerOk = resolveToken(db, ownerToken) !== null;
|
|
313
|
+
if (!ownerOk) errorMsg = "Invalid vault token.";
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!ownerOk) {
|
|
318
|
+
if (clientIp) rateLimiter.recordFailure(clientIp);
|
|
319
|
+
return renderConsentWithError(db, vaultName || "vault", {
|
|
320
|
+
clientId, redirectUri, codeChallenge, codeChallengeMethod,
|
|
321
|
+
requestedScope, selectedScope, state, passwordMode, totpEnrolled,
|
|
322
|
+
error: errorMsg,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 2FA check — password passed, now verify TOTP or backup code.
|
|
327
|
+
if (totpEnrolled) {
|
|
328
|
+
const totpCode = ((form.get("totp_code") as string | null) ?? "").trim();
|
|
329
|
+
const backupCode = ((form.get("backup_code") as string | null) ?? "").trim();
|
|
330
|
+
let twoFaOk = false;
|
|
331
|
+
let twoFaError = "";
|
|
332
|
+
if (totpCode) {
|
|
333
|
+
twoFaOk = verifyTotpCode(totpSecret!, totpCode);
|
|
334
|
+
if (!twoFaOk) twoFaError = "Invalid credentials.";
|
|
335
|
+
} else if (backupCode) {
|
|
336
|
+
twoFaOk = await verifyAndConsumeBackupCode(backupCode);
|
|
337
|
+
if (!twoFaOk) twoFaError = "Invalid credentials.";
|
|
338
|
+
} else {
|
|
339
|
+
twoFaError = "Enter a 6-digit code from your authenticator app, or a backup code.";
|
|
340
|
+
}
|
|
341
|
+
if (!twoFaOk) {
|
|
342
|
+
if (clientIp) rateLimiter.recordFailure(clientIp);
|
|
343
|
+
return renderConsentWithError(db, vaultName || "vault", {
|
|
344
|
+
clientId, redirectUri, codeChallenge, codeChallengeMethod,
|
|
345
|
+
requestedScope, selectedScope, state, passwordMode, totpEnrolled,
|
|
346
|
+
error: twoFaError,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (clientIp) rateLimiter.recordSuccess(clientIp);
|
|
352
|
+
|
|
353
|
+
// Generate auth code — persist the user-selected scope (not the requested one)
|
|
354
|
+
const code = crypto.randomBytes(32).toString("base64url");
|
|
355
|
+
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); // 10 minutes
|
|
356
|
+
|
|
357
|
+
db.prepare(`
|
|
358
|
+
INSERT INTO oauth_codes (code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, created_at)
|
|
359
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
360
|
+
`).run(code, clientId, codeChallenge, codeChallengeMethod, selectedScope, redirectUri, expiresAt, new Date().toISOString());
|
|
361
|
+
|
|
362
|
+
redirect.searchParams.set("code", code);
|
|
363
|
+
return Response.redirect(redirect.toString(), 302);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// Token endpoint
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
export async function handleToken(req: Request, db: Database): Promise<Response> {
|
|
371
|
+
if (req.method !== "POST") {
|
|
372
|
+
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let params: URLSearchParams;
|
|
376
|
+
const contentType = req.headers.get("content-type") || "";
|
|
377
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
378
|
+
params = new URLSearchParams(await req.text());
|
|
379
|
+
} else if (contentType.includes("application/json")) {
|
|
380
|
+
try {
|
|
381
|
+
const body = await req.json();
|
|
382
|
+
params = new URLSearchParams(body as Record<string, string>);
|
|
383
|
+
} catch {
|
|
384
|
+
return Response.json({ error: "invalid_request" }, { status: 400 });
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
params = new URLSearchParams(await req.text());
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const grantType = params.get("grant_type");
|
|
391
|
+
|
|
392
|
+
if (grantType !== "authorization_code") {
|
|
393
|
+
return Response.json({ error: "unsupported_grant_type" }, { status: 400 });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const code = params.get("code");
|
|
397
|
+
const codeVerifier = params.get("code_verifier");
|
|
398
|
+
const clientId = params.get("client_id");
|
|
399
|
+
const redirectUri = params.get("redirect_uri");
|
|
400
|
+
|
|
401
|
+
if (!code || !codeVerifier || !clientId || !redirectUri) {
|
|
402
|
+
return Response.json({ error: "invalid_request", error_description: "Missing required parameters" }, { status: 400 });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Look up the auth code
|
|
406
|
+
const authCode = db.prepare(`
|
|
407
|
+
SELECT code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, used
|
|
408
|
+
FROM oauth_codes WHERE code = ?
|
|
409
|
+
`).get(code) as {
|
|
410
|
+
code: string;
|
|
411
|
+
client_id: string;
|
|
412
|
+
code_challenge: string;
|
|
413
|
+
code_challenge_method: string;
|
|
414
|
+
scope: string;
|
|
415
|
+
redirect_uri: string;
|
|
416
|
+
expires_at: string;
|
|
417
|
+
used: number;
|
|
418
|
+
} | null;
|
|
419
|
+
|
|
420
|
+
if (!authCode) {
|
|
421
|
+
return Response.json({ error: "invalid_grant", error_description: "Invalid authorization code" }, { status: 400 });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Check single-use
|
|
425
|
+
if (authCode.used) {
|
|
426
|
+
return Response.json({ error: "invalid_grant", error_description: "Authorization code already used" }, { status: 400 });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Check expiry
|
|
430
|
+
if (new Date(authCode.expires_at) < new Date()) {
|
|
431
|
+
return Response.json({ error: "invalid_grant", error_description: "Authorization code expired" }, { status: 400 });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Validate client_id matches
|
|
435
|
+
if (authCode.client_id !== clientId) {
|
|
436
|
+
return Response.json({ error: "invalid_grant", error_description: "client_id mismatch" }, { status: 400 });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Validate redirect_uri matches
|
|
440
|
+
if (authCode.redirect_uri !== redirectUri) {
|
|
441
|
+
return Response.json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, { status: 400 });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// PKCE verification: SHA256(code_verifier) must match stored code_challenge
|
|
445
|
+
const expectedChallenge = crypto
|
|
446
|
+
.createHash("sha256")
|
|
447
|
+
.update(codeVerifier)
|
|
448
|
+
.digest("base64url");
|
|
449
|
+
|
|
450
|
+
if (expectedChallenge !== authCode.code_challenge) {
|
|
451
|
+
return Response.json({ error: "invalid_grant", error_description: "PKCE verification failed" }, { status: 400 });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Mark code as used
|
|
455
|
+
db.prepare("UPDATE oauth_codes SET used = 1 WHERE code = ?").run(code);
|
|
456
|
+
|
|
457
|
+
// Create a real pvt_ token
|
|
458
|
+
const permission: TokenPermission = authCode.scope === "read" ? "read" : "full";
|
|
459
|
+
const { fullToken } = generateToken();
|
|
460
|
+
createToken(db, fullToken, {
|
|
461
|
+
label: `oauth:${clientId.slice(0, 8)}`,
|
|
462
|
+
permission,
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
return Response.json({
|
|
466
|
+
access_token: fullToken,
|
|
467
|
+
token_type: "bearer",
|
|
468
|
+
scope: permission,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
// Consent page re-render with error
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
function renderConsentWithError(
|
|
477
|
+
db: Database,
|
|
478
|
+
vaultName: string,
|
|
479
|
+
params: {
|
|
480
|
+
clientId: string;
|
|
481
|
+
redirectUri: string;
|
|
482
|
+
codeChallenge: string;
|
|
483
|
+
codeChallengeMethod: string;
|
|
484
|
+
requestedScope: string;
|
|
485
|
+
selectedScope: string;
|
|
486
|
+
state: string;
|
|
487
|
+
passwordMode: boolean;
|
|
488
|
+
totpEnrolled: boolean;
|
|
489
|
+
error: string;
|
|
490
|
+
},
|
|
491
|
+
): Response {
|
|
492
|
+
const client = db.prepare("SELECT client_name FROM oauth_clients WHERE client_id = ?")
|
|
493
|
+
.get(params.clientId) as { client_name: string } | null;
|
|
494
|
+
const clientName = client?.client_name || "Unknown Client";
|
|
495
|
+
const requested: TokenPermission = params.requestedScope === "read" ? "read" : "full";
|
|
496
|
+
const selected: TokenPermission = params.selectedScope === "read" ? "read" : "full";
|
|
497
|
+
|
|
498
|
+
const html = renderConsentPage({
|
|
499
|
+
vaultName,
|
|
500
|
+
clientName,
|
|
501
|
+
requestedScope: requested,
|
|
502
|
+
selectedScope: selected,
|
|
503
|
+
clientId: params.clientId,
|
|
504
|
+
redirectUri: params.redirectUri,
|
|
505
|
+
codeChallenge: params.codeChallenge,
|
|
506
|
+
codeChallengeMethod: params.codeChallengeMethod,
|
|
507
|
+
state: params.state,
|
|
508
|
+
passwordMode: params.passwordMode,
|
|
509
|
+
totpEnrolled: params.totpEnrolled,
|
|
510
|
+
error: params.error,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
return new Response(html, {
|
|
514
|
+
status: 200,
|
|
515
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
// Consent page HTML
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
interface ConsentParams {
|
|
524
|
+
vaultName: string;
|
|
525
|
+
clientName: string;
|
|
526
|
+
/** Scope originally requested by the client. */
|
|
527
|
+
requestedScope: TokenPermission;
|
|
528
|
+
/** Scope currently selected in the radio buttons (defaults to requested). */
|
|
529
|
+
selectedScope: TokenPermission;
|
|
530
|
+
clientId: string;
|
|
531
|
+
redirectUri: string;
|
|
532
|
+
codeChallenge: string;
|
|
533
|
+
codeChallengeMethod: string;
|
|
534
|
+
state: string;
|
|
535
|
+
/** When true, render a password field; when false, render a vault-token field (legacy). */
|
|
536
|
+
passwordMode: boolean;
|
|
537
|
+
/** When true, additionally render TOTP + backup-code fields. */
|
|
538
|
+
totpEnrolled?: boolean;
|
|
539
|
+
error?: string;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function renderConsentPage(p: ConsentParams): string {
|
|
543
|
+
const fullChecked = p.selectedScope === "full" ? " checked" : "";
|
|
544
|
+
const readChecked = p.selectedScope === "read" ? " checked" : "";
|
|
545
|
+
|
|
546
|
+
const credentialField = p.passwordMode
|
|
547
|
+
? `<div class="cred-field">
|
|
548
|
+
<label for="password">Owner password</label>
|
|
549
|
+
<input type="password" id="password" name="password" placeholder="Enter your vault password" required autocomplete="current-password">
|
|
550
|
+
</div>`
|
|
551
|
+
: `<div class="cred-field">
|
|
552
|
+
<label for="owner_token">Vault token</label>
|
|
553
|
+
<input type="password" id="owner_token" name="owner_token" placeholder="pvt_..." required autocomplete="off">
|
|
554
|
+
</div>`;
|
|
555
|
+
|
|
556
|
+
return `<!DOCTYPE html>
|
|
557
|
+
<html lang="en">
|
|
558
|
+
<head>
|
|
559
|
+
<meta charset="utf-8">
|
|
560
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
561
|
+
<title>Authorize — ${escapeHtml(p.vaultName)}</title>
|
|
562
|
+
<style>
|
|
563
|
+
body {
|
|
564
|
+
max-width: 28rem;
|
|
565
|
+
margin: 4rem auto;
|
|
566
|
+
padding: 0 1rem;
|
|
567
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
568
|
+
line-height: 1.6;
|
|
569
|
+
color: #1a1a1a;
|
|
570
|
+
}
|
|
571
|
+
.card {
|
|
572
|
+
border: 1px solid #e0e0e0;
|
|
573
|
+
border-radius: 8px;
|
|
574
|
+
padding: 2rem;
|
|
575
|
+
}
|
|
576
|
+
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
|
|
577
|
+
.client { color: #0066cc; font-weight: 600; }
|
|
578
|
+
.scope-options {
|
|
579
|
+
background: #f5f5f5;
|
|
580
|
+
border-radius: 4px;
|
|
581
|
+
padding: 0.75rem 1rem;
|
|
582
|
+
margin: 1rem 0;
|
|
583
|
+
}
|
|
584
|
+
.scope-option {
|
|
585
|
+
display: flex;
|
|
586
|
+
align-items: flex-start;
|
|
587
|
+
gap: 0.6rem;
|
|
588
|
+
padding: 0.3rem 0;
|
|
589
|
+
cursor: pointer;
|
|
590
|
+
}
|
|
591
|
+
.scope-option input[type="radio"] {
|
|
592
|
+
margin-top: 0.35rem;
|
|
593
|
+
}
|
|
594
|
+
.scope-option-label { font-weight: 600; }
|
|
595
|
+
.scope-option-desc { font-size: 0.85rem; color: #666; }
|
|
596
|
+
.cred-field {
|
|
597
|
+
margin-top: 1rem;
|
|
598
|
+
}
|
|
599
|
+
.cred-field label {
|
|
600
|
+
display: block;
|
|
601
|
+
font-size: 0.9rem;
|
|
602
|
+
font-weight: 600;
|
|
603
|
+
margin-bottom: 0.3rem;
|
|
604
|
+
}
|
|
605
|
+
.cred-field input {
|
|
606
|
+
width: 100%;
|
|
607
|
+
padding: 0.5rem 0.6rem;
|
|
608
|
+
border: 1px solid #ccc;
|
|
609
|
+
border-radius: 4px;
|
|
610
|
+
font-size: 0.9rem;
|
|
611
|
+
font-family: monospace;
|
|
612
|
+
box-sizing: border-box;
|
|
613
|
+
}
|
|
614
|
+
.error-msg {
|
|
615
|
+
color: #cc3333;
|
|
616
|
+
font-size: 0.9rem;
|
|
617
|
+
margin-top: 0.75rem;
|
|
618
|
+
}
|
|
619
|
+
.buttons {
|
|
620
|
+
display: flex;
|
|
621
|
+
gap: 0.75rem;
|
|
622
|
+
margin-top: 1.5rem;
|
|
623
|
+
}
|
|
624
|
+
button {
|
|
625
|
+
flex: 1;
|
|
626
|
+
padding: 0.6rem 1rem;
|
|
627
|
+
border-radius: 6px;
|
|
628
|
+
font-size: 0.95rem;
|
|
629
|
+
cursor: pointer;
|
|
630
|
+
border: 1px solid #ccc;
|
|
631
|
+
background: #fff;
|
|
632
|
+
}
|
|
633
|
+
button[value="authorize"] {
|
|
634
|
+
background: #0066cc;
|
|
635
|
+
color: #fff;
|
|
636
|
+
border-color: #0066cc;
|
|
637
|
+
}
|
|
638
|
+
button[value="authorize"]:hover { background: #0055aa; }
|
|
639
|
+
button[value="deny"]:hover { background: #f5f5f5; }
|
|
640
|
+
@media (prefers-color-scheme: dark) {
|
|
641
|
+
body { background: #1a1a1a; color: #e0e0e0; }
|
|
642
|
+
.card { border-color: #333; }
|
|
643
|
+
.scope-options { background: #2a2a2a; }
|
|
644
|
+
.scope-option-desc { color: #999; }
|
|
645
|
+
.client { color: #66b3ff; }
|
|
646
|
+
.cred-field input { background: #2a2a2a; color: #e0e0e0; border-color: #444; }
|
|
647
|
+
.error-msg { color: #ff6666; }
|
|
648
|
+
button { background: #2a2a2a; color: #e0e0e0; border-color: #444; }
|
|
649
|
+
button[value="authorize"] { background: #0066cc; color: #fff; border-color: #0066cc; }
|
|
650
|
+
button[value="deny"]:hover { background: #333; }
|
|
651
|
+
}
|
|
652
|
+
</style>
|
|
653
|
+
</head>
|
|
654
|
+
<body>
|
|
655
|
+
<div class="card">
|
|
656
|
+
<h1>Authorize access</h1>
|
|
657
|
+
<p><span class="client">${escapeHtml(p.clientName)}</span> wants to access your <strong>${escapeHtml(p.vaultName)}</strong> vault.</p>
|
|
658
|
+
<form method="POST" action="/oauth/authorize">
|
|
659
|
+
<input type="hidden" name="client_id" value="${escapeHtml(p.clientId)}">
|
|
660
|
+
<input type="hidden" name="redirect_uri" value="${escapeHtml(p.redirectUri)}">
|
|
661
|
+
<input type="hidden" name="code_challenge" value="${escapeHtml(p.codeChallenge)}">
|
|
662
|
+
<input type="hidden" name="code_challenge_method" value="${escapeHtml(p.codeChallengeMethod)}">
|
|
663
|
+
<input type="hidden" name="scope" value="${escapeHtml(p.requestedScope)}">
|
|
664
|
+
<input type="hidden" name="state" value="${escapeHtml(p.state)}">
|
|
665
|
+
<div class="scope-options">
|
|
666
|
+
<label class="scope-option">
|
|
667
|
+
<input type="radio" name="selected_scope" value="full"${fullChecked}>
|
|
668
|
+
<span>
|
|
669
|
+
<span class="scope-option-label">Full access</span><br>
|
|
670
|
+
<span class="scope-option-desc">Read, create, update, and delete notes, tags, and links.</span>
|
|
671
|
+
</span>
|
|
672
|
+
</label>
|
|
673
|
+
<label class="scope-option">
|
|
674
|
+
<input type="radio" name="selected_scope" value="read"${readChecked}>
|
|
675
|
+
<span>
|
|
676
|
+
<span class="scope-option-label">Read-only access</span><br>
|
|
677
|
+
<span class="scope-option-desc">Query notes, list tags, and view vault info.</span>
|
|
678
|
+
</span>
|
|
679
|
+
</label>
|
|
680
|
+
</div>
|
|
681
|
+
${credentialField}
|
|
682
|
+
${p.totpEnrolled ? `<div class="cred-field">
|
|
683
|
+
<label for="totp_code">Authenticator code</label>
|
|
684
|
+
<input type="text" id="totp_code" name="totp_code" placeholder="6-digit code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" maxlength="6">
|
|
685
|
+
</div>
|
|
686
|
+
<div class="cred-field">
|
|
687
|
+
<label for="backup_code">Or a backup code</label>
|
|
688
|
+
<input type="text" id="backup_code" name="backup_code" placeholder="single-use backup code" autocomplete="off">
|
|
689
|
+
</div>` : ""}
|
|
690
|
+
${p.error ? `<div class="error-msg">${escapeHtml(p.error)}</div>` : ""}
|
|
691
|
+
<div class="buttons">
|
|
692
|
+
<button type="submit" name="action" value="deny">Deny</button>
|
|
693
|
+
<button type="submit" name="action" value="authorize">Authorize</button>
|
|
694
|
+
</div>
|
|
695
|
+
</form>
|
|
696
|
+
</div>
|
|
697
|
+
</body>
|
|
698
|
+
</html>`;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function renderErrorPage(message: string): string {
|
|
702
|
+
return `<!DOCTYPE html>
|
|
703
|
+
<html lang="en">
|
|
704
|
+
<head>
|
|
705
|
+
<meta charset="utf-8">
|
|
706
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
707
|
+
<title>Error — Parachute Vault</title>
|
|
708
|
+
<style>
|
|
709
|
+
body {
|
|
710
|
+
max-width: 28rem;
|
|
711
|
+
margin: 4rem auto;
|
|
712
|
+
padding: 0 1rem;
|
|
713
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
714
|
+
line-height: 1.6;
|
|
715
|
+
color: #1a1a1a;
|
|
716
|
+
}
|
|
717
|
+
.error { color: #cc3333; }
|
|
718
|
+
@media (prefers-color-scheme: dark) {
|
|
719
|
+
body { background: #1a1a1a; color: #e0e0e0; }
|
|
720
|
+
.error { color: #ff6666; }
|
|
721
|
+
}
|
|
722
|
+
</style>
|
|
723
|
+
</head>
|
|
724
|
+
<body>
|
|
725
|
+
<h1 class="error">Authorization Error</h1>
|
|
726
|
+
<p>${escapeHtml(message)}</p>
|
|
727
|
+
</body>
|
|
728
|
+
</html>`;
|
|
729
|
+
}
|