@openparachute/vault 0.4.8-rc.8 → 0.4.8

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/src/oauth.ts DELETED
@@ -1,973 +0,0 @@
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 /vault/<name>/oauth/register
8
- * 2. Authorization endpoint (PKCE required) — GET/POST /vault/<name>/oauth/authorize
9
- * 3. Token endpoint (code exchange) — POST /vault/<name>/oauth/token
10
- * 4. Discovery endpoints — GET /vault/<name>/.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
- import { readManifest, ServicesManifestError } from "./services-manifest.ts";
23
- import { legacyPermissionToScopes, SCOPE_READ, serializeScopes } from "./scopes.ts";
24
-
25
- /** Options for handleAuthorizePost. */
26
- export interface AuthorizePostOptions {
27
- vaultName?: string;
28
- /** Client IP address (from Bun server.requestIP). If provided, rate limiting is applied. */
29
- clientIp?: string;
30
- /**
31
- * Bcrypt hash of the owner password. When set, the consent form requires a
32
- * `password` field. When null/undefined, falls back to legacy `owner_token`
33
- * auth (vault token in the consent form).
34
- */
35
- ownerPasswordHash?: string | null;
36
- /**
37
- * Base32-encoded TOTP secret. When set, consent additionally requires a
38
- * `totp_code` (6-digit) or `backup_code` form field.
39
- */
40
- totpSecret?: string | null;
41
- /** Override for testing; defaults to the module singleton. */
42
- rateLimiter?: RateLimiter;
43
- }
44
-
45
- // ---------------------------------------------------------------------------
46
- // Helpers
47
- // ---------------------------------------------------------------------------
48
-
49
- /**
50
- * Today the consent page binds one of two scope strings — "read" or "full" —
51
- * with `read ⊂ full`. `narrowerScope` picks the more-restrictive of two
52
- * inputs (used to floor `selected` by `requested` at /oauth/authorize),
53
- * `isScopeSubset` checks an inbound /oauth/token scope against the bound
54
- * scope. Both default to "full" only if **both** inputs allow "full",
55
- * otherwise narrow to "read". When the consent vocabulary expands beyond
56
- * read/full, both helpers should switch to vault:read|write|admin and the
57
- * inheritance rules in scopes.ts (`hasScope`).
58
- */
59
- function normalizeConsentScope(s: string | null | undefined): "read" | "full" {
60
- return s === "read" ? "read" : "full";
61
- }
62
-
63
- function narrowerScope(a: string, b: string): "read" | "full" {
64
- return normalizeConsentScope(a) === "read" || normalizeConsentScope(b) === "read"
65
- ? "read"
66
- : "full";
67
- }
68
-
69
- function isScopeSubset(requested: string, bound: string): boolean {
70
- // Strict: only "read" / "full" are acceptable on the wire today. Unknown
71
- // scope strings are rejected as out-of-bounds rather than silently
72
- // normalized — otherwise `scope=vault:admin` would coast through when
73
- // bound is "full".
74
- if (requested !== "read" && requested !== "full") return false;
75
- const bnd = normalizeConsentScope(bound);
76
- if (bnd === "full") return requested === "read" || requested === "full";
77
- return requested === "read";
78
- }
79
-
80
- /**
81
- * Public-facing base URL of the server. Honors `x-forwarded-*` headers so a
82
- * Cloudflare Tunnel / Tailscale Funnel / reverse-proxied deployment advertises
83
- * the right external origin in discovery documents (RFC 8414, RFC 9728).
84
- *
85
- * Exported so the router can build `WWW-Authenticate` challenge headers that
86
- * point at the same origin as the `/.well-known/*` metadata documents.
87
- */
88
- export function getBaseUrl(req: Request): string {
89
- const forwardedHost = req.headers.get("x-forwarded-host");
90
- const forwardedProto = req.headers.get("x-forwarded-proto");
91
- if (forwardedHost) {
92
- return `${forwardedProto || "https"}://${forwardedHost}`;
93
- }
94
- // Fall back to the request URL's origin
95
- const url = new URL(req.url);
96
- return url.origin;
97
- }
98
-
99
- /**
100
- * Public origin the client reached vault through. When `PARACHUTE_HUB_ORIGIN`
101
- * is set AND matches the incoming request's base URL, returns the hub; else
102
- * returns the request base. This is the RFC 8414 compliance hinge: discovery
103
- * metadata's `issuer`, token `iss` claims, and the service catalog all stem
104
- * from this, so the issuer view is always self-consistent with the origin the
105
- * client is actually talking to.
106
- */
107
- function resolvePublicOrigin(req: Request): string {
108
- const hub = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
109
- const base = getBaseUrl(req);
110
- return hub && base === hub ? hub : base;
111
- }
112
-
113
- /**
114
- * OAuth endpoint coordinates. Hub-rooted when the request came in through the
115
- * hub origin (`PARACHUTE_HUB_ORIGIN` set AND matches the incoming base URL),
116
- * vault-path-rooted otherwise. The same vault exposes both views concurrently:
117
- * a loopback client gets `issuer = http://127.0.0.1:<port>/vault/<name>`; a
118
- * client reaching vault via the hub reverse proxy gets `issuer = <hub>`.
119
- *
120
- * This is how vault stays RFC 8414 compliant while a single process serves
121
- * both origins — discovery always returns the issuer matching the client's
122
- * origin.
123
- */
124
- export function resolveOAuthCoordinates(
125
- req: Request,
126
- vaultName: string,
127
- ): {
128
- issuer: string;
129
- authorizationEndpoint: string;
130
- tokenEndpoint: string;
131
- registrationEndpoint: string;
132
- } {
133
- const origin = resolvePublicOrigin(req);
134
- const hub = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
135
- if (hub && origin === hub) {
136
- return {
137
- issuer: hub,
138
- authorizationEndpoint: `${hub}/oauth/authorize`,
139
- tokenEndpoint: `${hub}/oauth/token`,
140
- registrationEndpoint: `${hub}/oauth/register`,
141
- };
142
- }
143
- const prefix = `/vault/${vaultName}`;
144
- return {
145
- issuer: `${origin}${prefix}`,
146
- authorizationEndpoint: `${origin}${prefix}/oauth/authorize`,
147
- tokenEndpoint: `${origin}${prefix}/oauth/token`,
148
- registrationEndpoint: `${origin}${prefix}/oauth/register`,
149
- };
150
- }
151
-
152
- /**
153
- * Ecosystem service catalog for the token response (Phase 1 of the
154
- * hub-as-OAuth-issuer design). Reads `~/.parachute/services.json` — the same
155
- * manifest the CLI maintains — and rewrites each entry's canonical path into
156
- * an absolute URL rooted at the origin the client reached vault through. A
157
- * client that came in via the hub gets hub-rooted URLs; a loopback client
158
- * gets loopback URLs. Same vault, same manifest, origin-consistent.
159
- *
160
- * Failure to read the manifest is non-fatal: we log and return an empty
161
- * catalog rather than refusing to issue the token. The token response shape
162
- * is additive — clients that don't expect `services` ignore it.
163
- */
164
- export function buildServiceCatalog(
165
- req: Request,
166
- ): Record<string, { url: string; version: string }> {
167
- let entries: ReturnType<typeof readManifest>["services"];
168
- try {
169
- entries = readManifest().services;
170
- } catch (err) {
171
- if (err instanceof ServicesManifestError) {
172
- console.warn(`[parachute-vault] services.json unreadable: ${err.message}`);
173
- return {};
174
- }
175
- throw err;
176
- }
177
- const origin = resolvePublicOrigin(req);
178
- const catalog: Record<string, { url: string; version: string }> = {};
179
- for (const entry of entries) {
180
- const path = entry.paths[0] ?? "/";
181
- catalog[entry.name] = {
182
- url: `${origin}${path}`,
183
- version: entry.version,
184
- };
185
- }
186
- return catalog;
187
- }
188
-
189
- function escapeHtml(s: string): string {
190
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
191
- }
192
-
193
- // ---------------------------------------------------------------------------
194
- // Discovery endpoints
195
- // ---------------------------------------------------------------------------
196
-
197
- /**
198
- * OAuth 2.0 Protected Resource Metadata (RFC 9728).
199
- *
200
- * @param vaultName — the vault whose MCP endpoint is the protected resource.
201
- * The metadata advertises `resource: {base}/vault/{name}/mcp`
202
- * and the vault's authorization server at
203
- * `{base}/vault/{name}`. Clients discover the AS metadata
204
- * at `{base}/vault/{name}/.well-known/oauth-authorization-server`.
205
- */
206
- export function handleProtectedResource(req: Request, vaultName: string): Response {
207
- const { issuer } = resolveOAuthCoordinates(req, vaultName);
208
- const base = getBaseUrl(req);
209
- const prefix = `/vault/${vaultName}`;
210
- return Response.json({
211
- resource: `${base}${prefix}/mcp`,
212
- // `authorization_servers` points clients at the AS metadata doc. When the
213
- // hub is the issuer (Phase 0), the AS metadata still lives on the vault
214
- // itself — it's the document that tells clients where the hub endpoints
215
- // are. So we use the issuer as the AS locator when set, otherwise the
216
- // vault origin.
217
- authorization_servers: [issuer],
218
- scopes_supported: SCOPES_SUPPORTED,
219
- bearer_methods_supported: ["header"],
220
- });
221
- }
222
-
223
- /**
224
- * OAuth 2.0 Authorization Server Metadata (RFC 8414). Endpoint URLs and
225
- * `issuer` honor `PARACHUTE_HUB_ORIGIN` when set — see
226
- * `resolveOAuthCoordinates` for the hub-vs-standalone contract.
227
- */
228
- export function handleAuthorizationServer(req: Request, vaultName: string): Response {
229
- const coord = resolveOAuthCoordinates(req, vaultName);
230
- return Response.json({
231
- issuer: coord.issuer,
232
- authorization_endpoint: coord.authorizationEndpoint,
233
- token_endpoint: coord.tokenEndpoint,
234
- registration_endpoint: coord.registrationEndpoint,
235
- response_types_supported: ["code"],
236
- code_challenge_methods_supported: ["S256"],
237
- grant_types_supported: ["authorization_code"],
238
- token_endpoint_auth_methods_supported: ["none"],
239
- scopes_supported: SCOPES_SUPPORTED,
240
- });
241
- }
242
-
243
- /**
244
- * Scopes published in OAuth discovery. Phase 2 enforces these at request time
245
- * (`vault:admin` ⊇ `vault:write` ⊇ `vault:read`). `vault:<name>:*` refinements
246
- * are documented as future shape; the scope parser accepts them as synonyms
247
- * for `vault:*` today.
248
- *
249
- * Legacy `full`/`read` remain in the list for back-compat with 0.2.x clients
250
- * that hardcoded those names — they're translated into `vault:*` scopes on the
251
- * way in and out.
252
- */
253
- const SCOPES_SUPPORTED = ["vault:read", "vault:write", "vault:admin", "full", "read"];
254
-
255
- // ---------------------------------------------------------------------------
256
- // Dynamic Client Registration (RFC 7591)
257
- // ---------------------------------------------------------------------------
258
-
259
- export async function handleRegister(req: Request, db: Database): Promise<Response> {
260
- if (req.method !== "POST") {
261
- return Response.json({ error: "method_not_allowed" }, { status: 405 });
262
- }
263
-
264
- let body: any;
265
- try {
266
- body = await req.json();
267
- } catch {
268
- return Response.json({ error: "invalid_request", error_description: "Invalid JSON body" }, { status: 400 });
269
- }
270
-
271
- const redirectUris = body.redirect_uris;
272
- if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
273
- return Response.json(
274
- { error: "invalid_client_metadata", error_description: "redirect_uris is required" },
275
- { status: 400 },
276
- );
277
- }
278
-
279
- const clientId = crypto.randomUUID();
280
- const clientName = body.client_name || "Unknown Client";
281
- const now = new Date().toISOString();
282
-
283
- db.prepare(`
284
- INSERT INTO oauth_clients (client_id, client_name, redirect_uris, created_at)
285
- VALUES (?, ?, ?, ?)
286
- `).run(clientId, clientName, JSON.stringify(redirectUris), now);
287
-
288
- return Response.json({
289
- client_id: clientId,
290
- client_name: clientName,
291
- redirect_uris: redirectUris,
292
- grant_types: ["authorization_code"],
293
- response_types: ["code"],
294
- token_endpoint_auth_method: "none",
295
- }, { status: 201 });
296
- }
297
-
298
- // ---------------------------------------------------------------------------
299
- // Authorization endpoint
300
- // ---------------------------------------------------------------------------
301
-
302
- export function handleAuthorizeGet(
303
- req: Request,
304
- db: Database,
305
- vaultName: string,
306
- ownerPasswordHash?: string | null,
307
- totpEnrolled = false,
308
- ): Response {
309
- const url = new URL(req.url);
310
- const clientId = url.searchParams.get("client_id");
311
- const redirectUri = url.searchParams.get("redirect_uri");
312
- const codeChallenge = url.searchParams.get("code_challenge");
313
- const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "S256";
314
- const responseType = url.searchParams.get("response_type");
315
- const scope = url.searchParams.get("scope") || "full";
316
- const state = url.searchParams.get("state") || "";
317
-
318
- // Validate required params
319
- if (!clientId || !redirectUri || !codeChallenge || responseType !== "code") {
320
- return new Response(renderErrorPage("Missing or invalid parameters. Required: client_id, redirect_uri, code_challenge, response_type=code"), {
321
- status: 400,
322
- headers: { "Content-Type": "text/html; charset=utf-8" },
323
- });
324
- }
325
-
326
- if (codeChallengeMethod !== "S256") {
327
- return new Response(renderErrorPage("Only S256 code challenge method is supported."), {
328
- status: 400,
329
- headers: { "Content-Type": "text/html; charset=utf-8" },
330
- });
331
- }
332
-
333
- // Validate client
334
- const client = db.prepare("SELECT client_id, client_name, redirect_uris FROM oauth_clients WHERE client_id = ?")
335
- .get(clientId) as { client_id: string; client_name: string; redirect_uris: string } | null;
336
-
337
- if (!client) {
338
- return new Response(renderErrorPage("Unknown client."), {
339
- status: 400,
340
- headers: { "Content-Type": "text/html; charset=utf-8" },
341
- });
342
- }
343
-
344
- // Validate redirect_uri matches registration
345
- const registeredUris: string[] = JSON.parse(client.redirect_uris);
346
- if (!registeredUris.includes(redirectUri)) {
347
- return new Response(renderErrorPage("Redirect URI does not match registered client."), {
348
- status: 400,
349
- headers: { "Content-Type": "text/html; charset=utf-8" },
350
- });
351
- }
352
-
353
- // Normalize requested scope. The user can change it via the radio buttons.
354
- const requestedScope: TokenPermission = scope === "read" ? "read" : "full";
355
-
356
- // Render consent page
357
- const html = renderConsentPage({
358
- vaultName,
359
- clientName: client.client_name,
360
- requestedScope,
361
- selectedScope: requestedScope,
362
- clientId,
363
- redirectUri,
364
- codeChallenge,
365
- codeChallengeMethod,
366
- state,
367
- passwordMode: typeof ownerPasswordHash === "string" && ownerPasswordHash.length > 0,
368
- totpEnrolled,
369
- });
370
-
371
- return new Response(html, {
372
- status: 200,
373
- headers: { "Content-Type": "text/html; charset=utf-8" },
374
- });
375
- }
376
-
377
- export async function handleAuthorizePost(
378
- req: Request,
379
- db: Database,
380
- opts: AuthorizePostOptions = {},
381
- ): Promise<Response> {
382
- const { vaultName, clientIp, ownerPasswordHash, totpSecret, rateLimiter = authorizeRateLimit } = opts;
383
- const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
384
-
385
- let form: Awaited<ReturnType<typeof req.formData>>;
386
- try {
387
- form = await req.formData();
388
- } catch {
389
- return Response.json({ error: "invalid_request" }, { status: 400 });
390
- }
391
-
392
- const action = form.get("action") as string;
393
- const clientId = form.get("client_id") as string;
394
- const redirectUri = form.get("redirect_uri") as string;
395
- const codeChallenge = form.get("code_challenge") as string;
396
- const codeChallengeMethod = form.get("code_challenge_method") as string || "S256";
397
- // Requested scope is carried from the GET via a hidden field on the consent
398
- // page; the user's radio-button choice arrives in `selected_scope`. The
399
- // required-ness check runs *after* the deny short-circuit below — a deny
400
- // POST doesn't mint anything and shouldn't need scope to refuse.
401
- const requestedScopeRaw = form.get("scope");
402
- const selectedScopeRaw = form.get("selected_scope") as string | null;
403
- const state = form.get("state") as string || "";
404
-
405
- if (!clientId || !redirectUri || !codeChallenge) {
406
- return Response.json({ error: "invalid_request" }, { status: 400 });
407
- }
408
-
409
- // Validate client and redirect_uri BEFORE constructing any redirect.
410
- // This prevents open-redirect attacks via crafted redirect_uri values.
411
- const client = db.prepare("SELECT redirect_uris FROM oauth_clients WHERE client_id = ?")
412
- .get(clientId) as { redirect_uris: string } | null;
413
-
414
- if (!client) {
415
- return Response.json({ error: "invalid_request", error_description: "Unknown client" }, { status: 400 });
416
- }
417
-
418
- const registeredUris: string[] = JSON.parse(client.redirect_uris);
419
- if (!registeredUris.includes(redirectUri)) {
420
- return Response.json({ error: "invalid_request", error_description: "redirect_uri mismatch" }, { status: 400 });
421
- }
422
-
423
- // Only S256 is supported
424
- if (codeChallengeMethod !== "S256") {
425
- return Response.json({ error: "invalid_request", error_description: "Only S256 code challenge method is supported" }, { status: 400 });
426
- }
427
-
428
- const redirect = new URL(redirectUri);
429
- if (state) redirect.searchParams.set("state", state);
430
-
431
- // User denied
432
- if (action === "deny") {
433
- redirect.searchParams.set("error", "access_denied");
434
- return Response.redirect(redirect.toString(), 302);
435
- }
436
-
437
- // Past this point we're processing consent — scope must be explicitly
438
- // present. Defaulting absent scope to "full" would silently cement a
439
- // grant the user never confirmed (#197).
440
- if (typeof requestedScopeRaw !== "string" || requestedScopeRaw.length === 0) {
441
- return Response.json(
442
- { error: "invalid_request", error_description: "scope is required" },
443
- { status: 400 },
444
- );
445
- }
446
- const requestedScope = requestedScopeRaw;
447
- const selectedScope = selectedScopeRaw === "read" || selectedScopeRaw === "full"
448
- ? selectedScopeRaw
449
- : (requestedScope === "read" ? "read" : "full");
450
-
451
- // Rate-limit the owner-auth step. Applied before any credential check so
452
- // brute-force attempts are capped regardless of which path (password or
453
- // legacy token) is being used.
454
- if (clientIp) {
455
- const gate = rateLimiter.check(clientIp);
456
- if (!gate.allowed) {
457
- return new Response(renderErrorPage(
458
- `Too many failed attempts. Try again in ${Math.ceil(gate.retryAfterSec / 60)} minute(s).`,
459
- ), {
460
- status: 429,
461
- headers: {
462
- "Content-Type": "text/html; charset=utf-8",
463
- "Retry-After": String(gate.retryAfterSec),
464
- },
465
- });
466
- }
467
- }
468
-
469
- // Verify owner identity — password if configured, else legacy vault token.
470
- const passwordMode = typeof ownerPasswordHash === "string" && ownerPasswordHash.length > 0;
471
- let ownerOk = false;
472
- let errorMsg = "";
473
-
474
- if (passwordMode) {
475
- const password = form.get("password") as string;
476
- if (!password) {
477
- errorMsg = "Password is required.";
478
- } else {
479
- ownerOk = await verifyOwnerPassword(password, ownerPasswordHash!);
480
- // Keep failure messages uniform across password / TOTP / backup-code so
481
- // an attacker can't tell which factor was wrong.
482
- if (!ownerOk) errorMsg = "Invalid credentials.";
483
- }
484
- } else {
485
- const ownerToken = form.get("owner_token") as string;
486
- if (!ownerToken) {
487
- errorMsg = "Vault token is required.";
488
- } else {
489
- ownerOk = resolveToken(db, ownerToken) !== null;
490
- if (!ownerOk) errorMsg = "Invalid vault token.";
491
- }
492
- }
493
-
494
- if (!ownerOk) {
495
- if (clientIp) rateLimiter.recordFailure(clientIp);
496
- return renderConsentWithError(db, vaultName || "vault", {
497
- clientId, redirectUri, codeChallenge, codeChallengeMethod,
498
- requestedScope, selectedScope, state, passwordMode, totpEnrolled,
499
- error: errorMsg,
500
- });
501
- }
502
-
503
- // 2FA check — password passed, now verify TOTP or backup code.
504
- if (totpEnrolled) {
505
- const totpCode = ((form.get("totp_code") as string | null) ?? "").trim();
506
- const backupCode = ((form.get("backup_code") as string | null) ?? "").trim();
507
- let twoFaOk = false;
508
- let twoFaError = "";
509
- if (totpCode) {
510
- twoFaOk = verifyTotpCode(totpSecret!, totpCode);
511
- if (!twoFaOk) twoFaError = "Invalid credentials.";
512
- } else if (backupCode) {
513
- twoFaOk = await verifyAndConsumeBackupCode(backupCode);
514
- if (!twoFaOk) twoFaError = "Invalid credentials.";
515
- } else {
516
- twoFaError = "Enter a 6-digit code from your authenticator app, or a backup code.";
517
- }
518
- if (!twoFaOk) {
519
- if (clientIp) rateLimiter.recordFailure(clientIp);
520
- return renderConsentWithError(db, vaultName || "vault", {
521
- clientId, redirectUri, codeChallenge, codeChallengeMethod,
522
- requestedScope, selectedScope, state, passwordMode, totpEnrolled,
523
- error: twoFaError,
524
- });
525
- }
526
- }
527
-
528
- if (clientIp) rateLimiter.recordSuccess(clientIp);
529
-
530
- // Generate auth code — bind the NARROWER of (requested, selected). The
531
- // user can shrink the requested scope at consent time (e.g. flip "full"
532
- // to "read"); they cannot broaden it. Without this floor, a malicious
533
- // form could smuggle `selected_scope=full` even when /authorize?scope=read
534
- // was the original ask, escalating beyond what the client requested at
535
- // authorize time (#94, RFC 6749 §3.3).
536
- const boundScope = narrowerScope(requestedScope, selectedScope);
537
- const code = crypto.randomBytes(32).toString("base64url");
538
- const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); // 10 minutes
539
-
540
- // vault_name pins the code to the issuing vault. handleToken rejects
541
- // any code whose vault_name doesn't match the token-endpoint's vault.
542
- db.prepare(`
543
- INSERT INTO oauth_codes (code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, created_at, vault_name)
544
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
545
- `).run(code, clientId, codeChallenge, codeChallengeMethod, boundScope, redirectUri, expiresAt, new Date().toISOString(), vaultName ?? null);
546
-
547
- redirect.searchParams.set("code", code);
548
- return Response.redirect(redirect.toString(), 302);
549
- }
550
-
551
- // ---------------------------------------------------------------------------
552
- // Token endpoint
553
- // ---------------------------------------------------------------------------
554
-
555
- /**
556
- * OAuth 2.1 token endpoint — exchanges an auth code for a vault token.
557
- *
558
- * @param vaultName — the name of the vault this token is scoped to. Included
559
- * in the response as `vault: <name>` so the client knows
560
- * which vault was just connected. The token itself lives
561
- * in that vault's tokens table.
562
- */
563
- export async function handleToken(
564
- req: Request,
565
- db: Database,
566
- vaultName: string,
567
- ): Promise<Response> {
568
- if (req.method !== "POST") {
569
- return Response.json({ error: "method_not_allowed" }, { status: 405 });
570
- }
571
-
572
- let params: URLSearchParams;
573
- const contentType = req.headers.get("content-type") || "";
574
- if (contentType.includes("application/x-www-form-urlencoded")) {
575
- params = new URLSearchParams(await req.text());
576
- } else if (contentType.includes("application/json")) {
577
- try {
578
- const body = await req.json();
579
- params = new URLSearchParams(body as Record<string, string>);
580
- } catch {
581
- return Response.json({ error: "invalid_request" }, { status: 400 });
582
- }
583
- } else {
584
- params = new URLSearchParams(await req.text());
585
- }
586
-
587
- const grantType = params.get("grant_type");
588
-
589
- if (grantType !== "authorization_code") {
590
- return Response.json({ error: "unsupported_grant_type" }, { status: 400 });
591
- }
592
-
593
- const code = params.get("code");
594
- const codeVerifier = params.get("code_verifier");
595
- const clientId = params.get("client_id");
596
- const redirectUri = params.get("redirect_uri");
597
-
598
- if (!code || !codeVerifier || !clientId || !redirectUri) {
599
- return Response.json({ error: "invalid_request", error_description: "Missing required parameters" }, { status: 400 });
600
- }
601
-
602
- // Look up the auth code
603
- const authCode = db.prepare(`
604
- SELECT code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, used, vault_name
605
- FROM oauth_codes WHERE code = ?
606
- `).get(code) as {
607
- code: string;
608
- client_id: string;
609
- code_challenge: string;
610
- code_challenge_method: string;
611
- scope: string;
612
- redirect_uri: string;
613
- expires_at: string;
614
- used: number;
615
- vault_name: string | null;
616
- } | null;
617
-
618
- if (!authCode) {
619
- return Response.json({ error: "invalid_grant", error_description: "Invalid authorization code" }, { status: 400 });
620
- }
621
-
622
- // Check single-use
623
- if (authCode.used) {
624
- return Response.json({ error: "invalid_grant", error_description: "Authorization code already used" }, { status: 400 });
625
- }
626
-
627
- // Check expiry
628
- if (new Date(authCode.expires_at) < new Date()) {
629
- return Response.json({ error: "invalid_grant", error_description: "Authorization code expired" }, { status: 400 });
630
- }
631
-
632
- // Validate client_id matches
633
- if (authCode.client_id !== clientId) {
634
- return Response.json({ error: "invalid_grant", error_description: "client_id mismatch" }, { status: 400 });
635
- }
636
-
637
- // Validate redirect_uri matches
638
- if (authCode.redirect_uri !== redirectUri) {
639
- return Response.json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, { status: 400 });
640
- }
641
-
642
- // Validate the code was issued for the same vault this token endpoint
643
- // serves. Without this, a code issued under /vault/A/oauth/authorize
644
- // could be presented to /vault/B/oauth/token and the token would be
645
- // minted into B's DB — privilege escalation across vault boundaries.
646
- if (authCode.vault_name !== vaultName) {
647
- return Response.json({ error: "invalid_grant", error_description: "vault mismatch" }, { status: 400 });
648
- }
649
-
650
- // PKCE verification: SHA256(code_verifier) must match stored code_challenge
651
- const expectedChallenge = crypto
652
- .createHash("sha256")
653
- .update(codeVerifier)
654
- .digest("base64url");
655
-
656
- if (expectedChallenge !== authCode.code_challenge) {
657
- return Response.json({ error: "invalid_grant", error_description: "PKCE verification failed" }, { status: 400 });
658
- }
659
-
660
- // RFC 6749 §3.3 / §6: a `scope` parameter at /oauth/token, if present,
661
- // must equal or be a subset of the scope bound to the auth code at
662
- // /oauth/authorize. Reject expansion attempts as `invalid_scope` rather
663
- // than silently honoring the bound scope (#94). Absent param → use bound.
664
- const requestedTokenScopeRaw = params.get("scope");
665
- let effectiveScope = authCode.scope;
666
- if (requestedTokenScopeRaw !== null && requestedTokenScopeRaw.trim().length > 0) {
667
- const requested = requestedTokenScopeRaw.trim();
668
- if (!isScopeSubset(requested, authCode.scope)) {
669
- return Response.json(
670
- {
671
- error: "invalid_scope",
672
- error_description:
673
- "Requested scope exceeds the scope bound at authorization time.",
674
- },
675
- { status: 400 },
676
- );
677
- }
678
- effectiveScope = requested;
679
- }
680
-
681
- // Mark code as used
682
- db.prepare("UPDATE oauth_codes SET used = 1 WHERE code = ?").run(code);
683
-
684
- // Translate the (possibly-narrowed) effective scope into both the legacy
685
- // permission column and the OAuth-standard scope list we persist on the
686
- // token row. The consent page only offers read vs full today; full becomes
687
- // the admin-inheriting scope set so hub admin operations keep working.
688
- const permission: TokenPermission = effectiveScope === "read" ? "read" : "full";
689
- const scopes = legacyPermissionToScopes(permission);
690
- const scopeString = serializeScopes(scopes);
691
-
692
- const { fullToken } = generateToken();
693
- createToken(db, fullToken, {
694
- label: `oauth:${clientId.slice(0, 8)}`,
695
- permission,
696
- scopes,
697
- });
698
-
699
- const { issuer } = resolveOAuthCoordinates(req, vaultName);
700
- return Response.json({
701
- access_token: fullToken,
702
- token_type: "bearer",
703
- // RFC 6749 §5.1: scope is an OAuth-standard whitespace-separated string.
704
- scope: scopeString,
705
- vault: vaultName,
706
- // Phase 0: identify the issuer so tokens validated by downstream services
707
- // can pin trust on the hub-origin URL, not vault's internal address.
708
- iss: issuer,
709
- // Phase 1: bundle the ecosystem service catalog so Notes/clients learn
710
- // all sibling service URLs from the token response and don't need to
711
- // prompt the user for each one. Additive field — older clients ignore.
712
- services: buildServiceCatalog(req),
713
- });
714
- }
715
-
716
- // ---------------------------------------------------------------------------
717
- // Consent page re-render with error
718
- // ---------------------------------------------------------------------------
719
-
720
- function renderConsentWithError(
721
- db: Database,
722
- vaultName: string,
723
- params: {
724
- clientId: string;
725
- redirectUri: string;
726
- codeChallenge: string;
727
- codeChallengeMethod: string;
728
- requestedScope: string;
729
- selectedScope: string;
730
- state: string;
731
- passwordMode: boolean;
732
- totpEnrolled: boolean;
733
- error: string;
734
- },
735
- ): Response {
736
- const client = db.prepare("SELECT client_name FROM oauth_clients WHERE client_id = ?")
737
- .get(params.clientId) as { client_name: string } | null;
738
- const clientName = client?.client_name || "Unknown Client";
739
- const requested: TokenPermission = params.requestedScope === "read" ? "read" : "full";
740
- const selected: TokenPermission = params.selectedScope === "read" ? "read" : "full";
741
-
742
- const html = renderConsentPage({
743
- vaultName,
744
- clientName,
745
- requestedScope: requested,
746
- selectedScope: selected,
747
- clientId: params.clientId,
748
- redirectUri: params.redirectUri,
749
- codeChallenge: params.codeChallenge,
750
- codeChallengeMethod: params.codeChallengeMethod,
751
- state: params.state,
752
- passwordMode: params.passwordMode,
753
- totpEnrolled: params.totpEnrolled,
754
- error: params.error,
755
- });
756
-
757
- return new Response(html, {
758
- status: 200,
759
- headers: { "Content-Type": "text/html; charset=utf-8" },
760
- });
761
- }
762
-
763
- // ---------------------------------------------------------------------------
764
- // Consent page HTML
765
- // ---------------------------------------------------------------------------
766
-
767
- interface ConsentParams {
768
- vaultName: string;
769
- clientName: string;
770
- /** Scope originally requested by the client. */
771
- requestedScope: TokenPermission;
772
- /** Scope currently selected in the radio buttons (defaults to requested). */
773
- selectedScope: TokenPermission;
774
- clientId: string;
775
- redirectUri: string;
776
- codeChallenge: string;
777
- codeChallengeMethod: string;
778
- state: string;
779
- /** When true, render a password field; when false, render a vault-token field (legacy). */
780
- passwordMode: boolean;
781
- /** When true, additionally render TOTP + backup-code fields. */
782
- totpEnrolled?: boolean;
783
- error?: string;
784
- }
785
-
786
- function renderConsentPage(p: ConsentParams): string {
787
- const fullChecked = p.selectedScope === "full" ? " checked" : "";
788
- const readChecked = p.selectedScope === "read" ? " checked" : "";
789
-
790
- const credentialField = p.passwordMode
791
- ? `<div class="cred-field">
792
- <label for="password">Owner password</label>
793
- <input type="password" id="password" name="password" placeholder="Enter your vault password" required autocomplete="current-password">
794
- </div>`
795
- : `<div class="cred-field">
796
- <label for="owner_token">Vault token</label>
797
- <input type="password" id="owner_token" name="owner_token" placeholder="pvt_..." required autocomplete="off">
798
- </div>`;
799
-
800
- return `<!DOCTYPE html>
801
- <html lang="en">
802
- <head>
803
- <meta charset="utf-8">
804
- <meta name="viewport" content="width=device-width, initial-scale=1">
805
- <title>Authorize — ${escapeHtml(p.vaultName)}</title>
806
- <style>
807
- body {
808
- max-width: 28rem;
809
- margin: 4rem auto;
810
- padding: 0 1rem;
811
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
812
- line-height: 1.6;
813
- color: #1a1a1a;
814
- }
815
- .card {
816
- border: 1px solid #e0e0e0;
817
- border-radius: 8px;
818
- padding: 2rem;
819
- }
820
- h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
821
- .client { color: #0066cc; font-weight: 600; }
822
- .scope-options {
823
- background: #f5f5f5;
824
- border-radius: 4px;
825
- padding: 0.75rem 1rem;
826
- margin: 1rem 0;
827
- }
828
- .scope-option {
829
- display: flex;
830
- align-items: flex-start;
831
- gap: 0.6rem;
832
- padding: 0.3rem 0;
833
- cursor: pointer;
834
- }
835
- .scope-option input[type="radio"] {
836
- margin-top: 0.35rem;
837
- }
838
- .scope-option-label { font-weight: 600; }
839
- .scope-option-desc { font-size: 0.85rem; color: #666; }
840
- .cred-field {
841
- margin-top: 1rem;
842
- }
843
- .cred-field label {
844
- display: block;
845
- font-size: 0.9rem;
846
- font-weight: 600;
847
- margin-bottom: 0.3rem;
848
- }
849
- .cred-field input {
850
- width: 100%;
851
- padding: 0.5rem 0.6rem;
852
- border: 1px solid #ccc;
853
- border-radius: 4px;
854
- font-size: 0.9rem;
855
- font-family: monospace;
856
- box-sizing: border-box;
857
- }
858
- .error-msg {
859
- color: #cc3333;
860
- font-size: 0.9rem;
861
- margin-top: 0.75rem;
862
- }
863
- .buttons {
864
- display: flex;
865
- gap: 0.75rem;
866
- margin-top: 1.5rem;
867
- }
868
- button {
869
- flex: 1;
870
- padding: 0.6rem 1rem;
871
- border-radius: 6px;
872
- font-size: 0.95rem;
873
- cursor: pointer;
874
- border: 1px solid #ccc;
875
- background: #fff;
876
- }
877
- button[value="authorize"] {
878
- background: #0066cc;
879
- color: #fff;
880
- border-color: #0066cc;
881
- }
882
- button[value="authorize"]:hover { background: #0055aa; }
883
- button[value="deny"]:hover { background: #f5f5f5; }
884
- @media (prefers-color-scheme: dark) {
885
- body { background: #1a1a1a; color: #e0e0e0; }
886
- .card { border-color: #333; }
887
- .scope-options { background: #2a2a2a; }
888
- .scope-option-desc { color: #999; }
889
- .client { color: #66b3ff; }
890
- .cred-field input { background: #2a2a2a; color: #e0e0e0; border-color: #444; }
891
- .error-msg { color: #ff6666; }
892
- button { background: #2a2a2a; color: #e0e0e0; border-color: #444; }
893
- button[value="authorize"] { background: #0066cc; color: #fff; border-color: #0066cc; }
894
- button[value="deny"]:hover { background: #333; }
895
- }
896
- </style>
897
- </head>
898
- <body>
899
- <div class="card">
900
- <h1>Authorize access</h1>
901
- <p><span class="client">${escapeHtml(p.clientName)}</span> wants to access your <strong>${escapeHtml(p.vaultName)}</strong> vault.</p>
902
- <form method="POST" action="">
903
- <input type="hidden" name="client_id" value="${escapeHtml(p.clientId)}">
904
- <input type="hidden" name="redirect_uri" value="${escapeHtml(p.redirectUri)}">
905
- <input type="hidden" name="code_challenge" value="${escapeHtml(p.codeChallenge)}">
906
- <input type="hidden" name="code_challenge_method" value="${escapeHtml(p.codeChallengeMethod)}">
907
- <input type="hidden" name="scope" value="${escapeHtml(p.requestedScope)}">
908
- <input type="hidden" name="state" value="${escapeHtml(p.state)}">
909
- <div class="scope-options">
910
- <label class="scope-option">
911
- <input type="radio" name="selected_scope" value="full"${fullChecked}>
912
- <span>
913
- <span class="scope-option-label">Full access</span><br>
914
- <span class="scope-option-desc">Read, create, update, and delete notes, tags, and links.</span>
915
- </span>
916
- </label>
917
- <label class="scope-option">
918
- <input type="radio" name="selected_scope" value="read"${readChecked}>
919
- <span>
920
- <span class="scope-option-label">Read-only access</span><br>
921
- <span class="scope-option-desc">Query notes, list tags, and view vault info.</span>
922
- </span>
923
- </label>
924
- </div>
925
- ${credentialField}
926
- ${p.totpEnrolled ? `<div class="cred-field">
927
- <label for="totp_code">Authenticator code</label>
928
- <input type="text" id="totp_code" name="totp_code" placeholder="6-digit code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" maxlength="6">
929
- </div>
930
- <div class="cred-field">
931
- <label for="backup_code">Or a backup code</label>
932
- <input type="text" id="backup_code" name="backup_code" placeholder="single-use backup code" autocomplete="off">
933
- </div>` : ""}
934
- ${p.error ? `<div class="error-msg">${escapeHtml(p.error)}</div>` : ""}
935
- <div class="buttons">
936
- <button type="submit" name="action" value="deny">Deny</button>
937
- <button type="submit" name="action" value="authorize">Authorize</button>
938
- </div>
939
- </form>
940
- </div>
941
- </body>
942
- </html>`;
943
- }
944
-
945
- function renderErrorPage(message: string): string {
946
- return `<!DOCTYPE html>
947
- <html lang="en">
948
- <head>
949
- <meta charset="utf-8">
950
- <meta name="viewport" content="width=device-width, initial-scale=1">
951
- <title>Error — Parachute Vault</title>
952
- <style>
953
- body {
954
- max-width: 28rem;
955
- margin: 4rem auto;
956
- padding: 0 1rem;
957
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
958
- line-height: 1.6;
959
- color: #1a1a1a;
960
- }
961
- .error { color: #cc3333; }
962
- @media (prefers-color-scheme: dark) {
963
- body { background: #1a1a1a; color: #e0e0e0; }
964
- .error { color: #ff6666; }
965
- }
966
- </style>
967
- </head>
968
- <body>
969
- <h1 class="error">Authorization Error</h1>
970
- <p>${escapeHtml(message)}</p>
971
- </body>
972
- </html>`;
973
- }