@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/.parachute/module.json +1 -0
- package/README.md +34 -31
- package/core/src/schema.ts +8 -3
- package/package.json +1 -1
- package/src/auth.test.ts +5 -112
- package/src/cli.ts +38 -18
- package/src/oauth-discovery.ts +95 -0
- package/src/owner-auth.ts +22 -149
- package/src/routing.test.ts +98 -97
- package/src/routing.ts +29 -43
- package/src/self-register.test.ts +33 -0
- package/src/self-register.ts +15 -2
- package/src/server.ts +1 -12
- package/src/vault-name.ts +3 -2
- package/src/oauth.test.ts +0 -2156
- package/src/oauth.ts +0 -973
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
-
}
|