@openparachute/hub 0.3.0-rc.1 → 0.5.1

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.
Files changed (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
@@ -0,0 +1,1175 @@
1
+ /**
2
+ * Native OAuth handlers for the hub. Each handler is a pure function over
3
+ * `(db, req)` returning a `Response` — no global state, no side channels —
4
+ * so the test harness can drive the full OAuth dance without standing up
5
+ * `Bun.serve` or going near the network.
6
+ *
7
+ * Endpoints implemented:
8
+ * - GET /.well-known/oauth-authorization-server (RFC 8414 metadata)
9
+ * - GET /oauth/authorize (login → consent → code)
10
+ * - POST /oauth/authorize (form posts: login + consent)
11
+ * - POST /oauth/token (grant_type=authorization_code | refresh_token)
12
+ * - POST /oauth/register (RFC 7591 DCR)
13
+ * - POST /oauth/revoke (RFC 7009 token revocation)
14
+ *
15
+ * `client_credentials` is intentionally unimplemented — it's not in the
16
+ * launch surface (no machine-to-machine clients yet); the token endpoint
17
+ * stubs it with `unsupported_grant_type`.
18
+ *
19
+ * HTML for login + consent + error views lives in `oauth-ui.ts` so the
20
+ * handlers stay focused on protocol logic and the templates stay focused
21
+ * on presentation.
22
+ */
23
+ import type { Database } from "bun:sqlite";
24
+ import { AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
25
+ import {
26
+ AuthCodeExpiredError,
27
+ AuthCodeNotFoundError,
28
+ AuthCodePkceMismatchError,
29
+ AuthCodeRedirectMismatchError,
30
+ AuthCodeUsedError,
31
+ issueAuthCode,
32
+ redeemAuthCode,
33
+ } from "./auth-codes.ts";
34
+ import {
35
+ type ClientStatus,
36
+ type OAuthClient,
37
+ type RegisteredClient,
38
+ getClient,
39
+ isValidRedirectUri,
40
+ registerClient,
41
+ requireRegisteredRedirectUri,
42
+ verifyClientSecret,
43
+ } from "./clients.ts";
44
+ import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
45
+ import { isCoveredByGrant, recordGrant } from "./grants.ts";
46
+ import { VAULT_VERBS, inferAudience } from "./jwt-audience.ts";
47
+ import {
48
+ ACCESS_TOKEN_TTL_SECONDS,
49
+ RefreshTokenInsertError,
50
+ findRefreshToken,
51
+ findTokenRowByJti,
52
+ revokeFamily,
53
+ signAccessToken,
54
+ signRefreshToken,
55
+ } from "./jwt-sign.ts";
56
+ import { type AuthorizeFormParams, renderConsent, renderError, renderLogin } from "./oauth-ui.ts";
57
+ import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
58
+ import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
59
+ import {
60
+ type ServicesManifest,
61
+ readManifest as readServicesManifest,
62
+ } from "./services-manifest.ts";
63
+ import {
64
+ SESSION_TTL_MS,
65
+ buildSessionCookie,
66
+ createSession,
67
+ findSession,
68
+ parseSessionCookie,
69
+ } from "./sessions.ts";
70
+ import { getUserByUsername, verifyPassword } from "./users.ts";
71
+ import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
72
+
73
+ /** Verbs whose unnamed `vault:<verb>` form needs picker disambiguation. */
74
+ function unnamedVaultVerbs(scopes: string[]): string[] {
75
+ const verbs: string[] = [];
76
+ for (const s of scopes) {
77
+ const parts = s.split(":");
78
+ const verb = parts[1];
79
+ if (parts.length === 2 && parts[0] === "vault" && verb && VAULT_VERBS.has(verb)) {
80
+ verbs.push(verb);
81
+ }
82
+ }
83
+ return verbs;
84
+ }
85
+
86
+ /**
87
+ * Vault instance names registered on this host, derived from services.json.
88
+ * Walks both manifest shapes — single-entry-multi-path (`paths: ["/vault/work",
89
+ * "/vault/personal"]`) and per-vault entries (`parachute-vault-work`) — by
90
+ * delegating each (name, path) pair to the canonical `vaultInstanceNameFor`
91
+ * helper. Entries with no paths still resolve to a name via the helper's
92
+ * manifest-suffix fallback (#143).
93
+ */
94
+ function listVaultNames(manifest: ServicesManifest): string[] {
95
+ const names = new Set<string>();
96
+ for (const svc of manifest.services) {
97
+ if (!isVaultEntry(svc)) continue;
98
+ const paths = svc.paths.length > 0 ? svc.paths : [undefined];
99
+ for (const path of paths) {
100
+ names.add(vaultInstanceNameFor(svc.name, path));
101
+ }
102
+ }
103
+ return Array.from(names).sort();
104
+ }
105
+
106
+ /** Rewrite each unnamed `vault:<verb>` to `vault:<picked>:<verb>`. */
107
+ function narrowVaultScopes(scopes: string[], pickedVault: string): string[] {
108
+ return scopes.map((s) => {
109
+ const parts = s.split(":");
110
+ const verb = parts[1];
111
+ if (parts.length === 2 && parts[0] === "vault" && verb && VAULT_VERBS.has(verb)) {
112
+ return `vault:${pickedVault}:${verb}`;
113
+ }
114
+ return s;
115
+ });
116
+ }
117
+
118
+ export interface OAuthDeps {
119
+ /** Hub origin used for `iss`, `authorization_endpoint`, etc. */
120
+ issuer: string;
121
+ /** Override the clock for deterministic tests. */
122
+ now?: () => Date;
123
+ /**
124
+ * Resolve the declared-scope set the issuer is willing to sign. Production
125
+ * walks `services.json` + each module's `.parachute/module.json`
126
+ * `scopes.defines` and unions with `FIRST_PARTY_SCOPES`. Tests inject a
127
+ * pinned set so the gate is deterministic without a fixture services.json.
128
+ * See cli#71 + `oauth-scopes.md`.
129
+ */
130
+ loadDeclaredScopes?: () => ReadonlySet<string>;
131
+ /**
132
+ * Resolve the installed-services manifest used to populate the `services`
133
+ * catalog in /oauth/token responses (cli#81). Production reads
134
+ * `~/.parachute/services.json`; tests inject a fixture.
135
+ */
136
+ loadServicesManifest?: () => ServicesManifest;
137
+ }
138
+
139
+ export interface ServicesCatalogEntry {
140
+ url: string;
141
+ version: string;
142
+ }
143
+
144
+ export type ServicesCatalog = Record<string, ServicesCatalogEntry>;
145
+
146
+ /**
147
+ * Build the `services` map embedded in /oauth/token responses. Each entry maps
148
+ * a short service name (`vault`, `scribe`, `notes`, …) to its absolute URL +
149
+ * version, so OAuth clients don't have to re-probe `/.well-known/parachute.json`
150
+ * to know where vault lives.
151
+ *
152
+ * URL source: `entry.paths[0]` from services.json verbatim — never hardcode
153
+ * `/vault/default`. Users who installed with `parachute install vault
154
+ * --vault-name work` have `paths: ["/vault/work"]` in their manifest, and the
155
+ * catalog URL must follow that. The custom-vault-name regression test in
156
+ * oauth-handlers.test.ts pins this.
157
+ *
158
+ * Filtering: only services for which the token has at least one scope are
159
+ * included. A scope `vault:read` admits the `vault` service; a token with only
160
+ * `scribe:transcribe` gets a catalog with no vault entry. The check is on the
161
+ * audience prefix (`<aud>:<verb>`) — same shape `inferAudience` uses.
162
+ *
163
+ * Multi-vault: Phase 1 collapses every vault entry under the single key
164
+ * `vault`, first matching `parachute-vault*` row wins. Per-vault keys
165
+ * (`services.vault.work.url` or `services["vault:work"].url`) are deferred
166
+ * to a future design once notes ships its vault picker; multi-vault clients
167
+ * need to probe `/.well-known/parachute.json` for the full vaults array
168
+ * until then.
169
+ */
170
+ export function buildServicesCatalog(
171
+ manifest: ServicesManifest,
172
+ issuer: string,
173
+ scopes: readonly string[],
174
+ ): ServicesCatalog {
175
+ const audiences = new Set<string>();
176
+ for (const s of scopes) {
177
+ const colon = s.indexOf(":");
178
+ if (colon > 0) audiences.add(s.slice(0, colon));
179
+ }
180
+ const base = issuer.replace(/\/$/, "");
181
+ const catalog: ServicesCatalog = {};
182
+ for (const entry of manifest.services) {
183
+ const path = entry.paths[0] ?? "/";
184
+ const key = isVaultEntry(entry) ? "vault" : shortName(entry.name);
185
+ if (!audiences.has(key)) continue;
186
+ if (catalog[key]) continue; // first vault wins; deterministic for clients
187
+ catalog[key] = { url: `${base}${path}`, version: entry.version };
188
+ }
189
+ return catalog;
190
+ }
191
+
192
+ // --- helpers ---------------------------------------------------------------
193
+
194
+ function jsonResponse(body: unknown, status = 200, extra: Record<string, string> = {}): Response {
195
+ return new Response(JSON.stringify(body), {
196
+ status,
197
+ headers: { "content-type": "application/json", ...extra },
198
+ });
199
+ }
200
+
201
+ function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
202
+ return new Response(body, {
203
+ status,
204
+ headers: { "content-type": "text/html; charset=utf-8", ...extra },
205
+ });
206
+ }
207
+
208
+ function redirectResponse(location: string, extra: Record<string, string> = {}): Response {
209
+ return new Response(null, { status: 302, headers: { location, ...extra } });
210
+ }
211
+
212
+ function htmlError(title: string, message: string, status: number): Response {
213
+ return htmlResponse(renderError({ title, message, status }), status);
214
+ }
215
+
216
+ function oauthErrorRedirect(
217
+ redirectUri: string,
218
+ error: string,
219
+ description: string,
220
+ state: string | null,
221
+ ): Response {
222
+ const u = new URL(redirectUri);
223
+ u.searchParams.set("error", error);
224
+ u.searchParams.set("error_description", description);
225
+ if (state) u.searchParams.set("state", state);
226
+ return redirectResponse(u.toString());
227
+ }
228
+
229
+ // --- /.well-known/oauth-authorization-server -------------------------------
230
+
231
+ export function authorizationServerMetadata(deps: OAuthDeps): Response {
232
+ const iss = deps.issuer;
233
+ // Advertise the full declared-scope set — FIRST_PARTY ∪ each registered
234
+ // module's `scopes.defines` — so standards-following clients discover
235
+ // third-party scopes (e.g. parachute-agent's `agent:*`) the same way they discover
236
+ // first-party ones. The token-issuance path already consults
237
+ // `loadDeclaredScopes` (see #90); metadata had to follow or the issuer's
238
+ // public advertisement would be a strict subset of what it'll actually
239
+ // sign. Closes #91.
240
+ const declared = (deps.loadDeclaredScopes ?? loadDeclaredScopes)();
241
+ return jsonResponse({
242
+ issuer: iss,
243
+ authorization_endpoint: `${iss}/oauth/authorize`,
244
+ token_endpoint: `${iss}/oauth/token`,
245
+ registration_endpoint: `${iss}/oauth/register`,
246
+ revocation_endpoint: `${iss}/oauth/revoke`,
247
+ jwks_uri: `${iss}/.well-known/jwks.json`,
248
+ response_types_supported: ["code"],
249
+ grant_types_supported: ["authorization_code", "refresh_token"],
250
+ code_challenge_methods_supported: ["S256"],
251
+ token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
252
+ // Operator-only scopes (NON_REQUESTABLE_SCOPES) are intentionally absent
253
+ // — RFC 8414 §2 frames `scopes_supported` as "the OAuth 2.0 [...] scope
254
+ // values that this authorization server supports" for clients to request.
255
+ // Advertising what we always reject would mislead clients.
256
+ scopes_supported: Array.from(declared).filter(isRequestableScope),
257
+ });
258
+ }
259
+
260
+ /** Find any requested scopes that the public flow refuses to mint. */
261
+ function findNonRequestableScopes(scopes: readonly string[]): string[] {
262
+ return scopes.filter(isNonRequestableScope);
263
+ }
264
+
265
+ // --- /oauth/authorize ------------------------------------------------------
266
+
267
+ function parseAuthorizeFormParams(url: URL): AuthorizeFormParams | { error: string } {
268
+ const required = (k: string) => {
269
+ const v = url.searchParams.get(k);
270
+ return v && v.length > 0 ? v : null;
271
+ };
272
+ const clientId = required("client_id");
273
+ const redirectUri = required("redirect_uri");
274
+ const responseType = required("response_type");
275
+ const scope = url.searchParams.get("scope") ?? "";
276
+ const codeChallenge = required("code_challenge");
277
+ const codeChallengeMethod = required("code_challenge_method");
278
+ if (!clientId) return { error: "missing client_id" };
279
+ if (!redirectUri) return { error: "missing redirect_uri" };
280
+ if (!responseType) return { error: "missing response_type" };
281
+ if (!codeChallenge) return { error: "missing code_challenge" };
282
+ if (!codeChallengeMethod) return { error: "missing code_challenge_method" };
283
+ return {
284
+ clientId,
285
+ redirectUri,
286
+ responseType,
287
+ scope,
288
+ codeChallenge,
289
+ codeChallengeMethod,
290
+ state: url.searchParams.get("state"),
291
+ };
292
+ }
293
+
294
+ /** HTML response for pending clients hitting /oauth/authorize. */
295
+ function pendingClientHtml(): Response {
296
+ return htmlError(
297
+ "App not yet approved",
298
+ "This client_id is registered but has not been approved by the hub operator. Ask the operator to run `parachute auth approve-client` for this app, then try again.",
299
+ 403,
300
+ );
301
+ }
302
+
303
+ /** JSON response for pending clients hitting /oauth/token. */
304
+ function pendingClientJson(): Response {
305
+ return jsonResponse(
306
+ {
307
+ error: "invalid_client",
308
+ error_description: "client is registered but has not been approved by the hub operator (#74)",
309
+ },
310
+ 401,
311
+ );
312
+ }
313
+
314
+ /**
315
+ * GET /oauth/authorize — entrypoint. Validates client + redirect_uri, then
316
+ * either renders the login form (no session) or the consent screen (session
317
+ * present). All authorize-time params are echoed back via hidden inputs so
318
+ * the form POST keeps the binding intact.
319
+ */
320
+ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps): Response {
321
+ const url = new URL(req.url);
322
+ const parsed = parseAuthorizeFormParams(url);
323
+ if ("error" in parsed) {
324
+ return htmlError("Invalid authorization request", parsed.error, 400);
325
+ }
326
+ if (parsed.responseType !== "code") {
327
+ return oauthErrorRedirect(
328
+ parsed.redirectUri,
329
+ "unsupported_response_type",
330
+ "only response_type=code is supported",
331
+ parsed.state,
332
+ );
333
+ }
334
+ if (parsed.codeChallengeMethod !== "S256") {
335
+ return oauthErrorRedirect(
336
+ parsed.redirectUri,
337
+ "invalid_request",
338
+ "PKCE S256 is required",
339
+ parsed.state,
340
+ );
341
+ }
342
+ const client = getClient(db, parsed.clientId);
343
+ if (!client) {
344
+ // Can't safely redirect — we don't trust the redirect_uri until we've
345
+ // matched it against a registered client. Render an HTML error.
346
+ return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
347
+ }
348
+ if (client.status !== "approved") return pendingClientHtml();
349
+ try {
350
+ requireRegisteredRedirectUri(client, parsed.redirectUri);
351
+ } catch {
352
+ return htmlError(
353
+ "Redirect mismatch",
354
+ "The redirect_uri does not match any URI registered for this app.",
355
+ 400,
356
+ );
357
+ }
358
+
359
+ // Operator-only scope gate (#96). Reject any request that names a scope
360
+ // we'll never mint via this flow — `parachute:host:admin` and friends.
361
+ // Per RFC 6749 §4.1.2.1, errors that aren't redirect-uri-related are
362
+ // delivered by redirect with `error=invalid_scope`.
363
+ const requestedScopes = parsed.scope.split(" ").filter((s) => s.length > 0);
364
+ const blocked = findNonRequestableScopes(requestedScopes);
365
+ if (blocked.length > 0) {
366
+ return oauthErrorRedirect(
367
+ parsed.redirectUri,
368
+ "invalid_scope",
369
+ `requested scopes are not available via the public authorization endpoint: ${blocked.join(", ")}`,
370
+ parsed.state,
371
+ );
372
+ }
373
+
374
+ const sessionId = parseSessionCookie(req.headers.get("cookie"));
375
+ const session = sessionId ? findSession(db, sessionId) : null;
376
+ const csrf = ensureCsrfToken(req);
377
+ const extra: Record<string, string> = csrf.setCookie ? { "set-cookie": csrf.setCookie } : {};
378
+ if (!session) {
379
+ return htmlResponse(renderLogin({ params: parsed, csrfToken: csrf.token }), 200, extra);
380
+ }
381
+
382
+ // Skip-consent gate (#75). If the user has previously granted every
383
+ // requested scope to this client, mint the auth code immediately. Two
384
+ // important constraints:
385
+ // - Unnamed vault verbs (`vault:read`) need the picker even if a prior
386
+ // grant exists, because the operator's vault choice isn't recorded
387
+ // literally — grants store narrowed `vault:<name>:<verb>` scopes, so
388
+ // a fresh unnamed request never matches. Force consent to re-pick.
389
+ // - The grant covers `requestedScopes` exactly when every requested
390
+ // scope appears in the stored set. A strict superset (client wants
391
+ // something new) falls through to the consent screen.
392
+ const hasUnnamedVault = unnamedVaultVerbs(requestedScopes).length > 0;
393
+ if (!hasUnnamedVault && isCoveredByGrant(db, session.userId, client.clientId, requestedScopes)) {
394
+ console.log(
395
+ `consent skipped: existing grant covers requested scope client_id=${client.clientId} user_id=${session.userId} scopes=${requestedScopes.join(" ")}`,
396
+ );
397
+ return issueAuthCodeRedirect(db, parsed, requestedScopes, session.userId, deps);
398
+ }
399
+
400
+ const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
401
+ const vaultNames = listVaultNames(manifest);
402
+ return htmlResponse(
403
+ renderConsent(consentProps(client, parsed, vaultNames, csrf.token)),
404
+ 200,
405
+ extra,
406
+ );
407
+ }
408
+
409
+ /**
410
+ * Mint an auth code and redirect to the client's redirect_uri. Shared by
411
+ * the consent-submit path (`handleConsentSubmit`) and the skip-consent path
412
+ * in `handleAuthorizeGet` (#75). Caller is responsible for having already
413
+ * validated the client + redirect_uri + scopes.
414
+ */
415
+ function issueAuthCodeRedirect(
416
+ db: Database,
417
+ params: AuthorizeFormParams,
418
+ scopes: string[],
419
+ userId: string,
420
+ deps: OAuthDeps,
421
+ ): Response {
422
+ const code = issueAuthCode(db, {
423
+ clientId: params.clientId,
424
+ userId,
425
+ redirectUri: params.redirectUri,
426
+ scopes,
427
+ codeChallenge: params.codeChallenge,
428
+ codeChallengeMethod: params.codeChallengeMethod,
429
+ now: deps.now,
430
+ });
431
+ const u = new URL(params.redirectUri);
432
+ u.searchParams.set("code", code.code);
433
+ if (params.state) u.searchParams.set("state", params.state);
434
+ return redirectResponse(u.toString());
435
+ }
436
+
437
+ /**
438
+ * POST /oauth/authorize — handles two distinct submissions:
439
+ * - login form: `__action=login` with username + password. On success,
440
+ * create a session, set the cookie, redirect back to GET /oauth/authorize
441
+ * so the user lands on the consent screen.
442
+ * - consent submission: `__action=consent` with `approve=yes|no`. On
443
+ * approve, mint an auth code and redirect to the client's redirect_uri.
444
+ * On deny, redirect with `error=access_denied`.
445
+ */
446
+ export async function handleAuthorizePost(
447
+ db: Database,
448
+ req: Request,
449
+ deps: OAuthDeps,
450
+ ): Promise<Response> {
451
+ const form = await req.formData();
452
+ const formCsrf = form.get(CSRF_FIELD_NAME);
453
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
454
+ // Same response shape for missing-cookie, missing-form-field, and mismatch
455
+ // — we don't want to leak which side failed. The browser can recover by
456
+ // GETting /oauth/authorize again, which mints a fresh cookie + token.
457
+ return htmlError(
458
+ "Invalid form submission",
459
+ "The form's CSRF token did not match. Reload the page and try again.",
460
+ 400,
461
+ );
462
+ }
463
+ // Token is already verified above; reuse the form value for re-rendering
464
+ // any error views so the next submit keeps the same cookie/form pairing.
465
+ const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
466
+ const action = String(form.get("__action") ?? "");
467
+ if (action === "login") return await handleLoginSubmit(db, req, form, deps, csrfToken);
468
+ if (action === "consent") return await handleConsentSubmit(db, req, form, deps, csrfToken);
469
+ return htmlError("Invalid form submission", "Unknown form action.", 400);
470
+ }
471
+
472
+ async function handleLoginSubmit(
473
+ db: Database,
474
+ _req: Request,
475
+ form: Awaited<ReturnType<Request["formData"]>>,
476
+ _deps: OAuthDeps,
477
+ csrfToken: string,
478
+ ): Promise<Response> {
479
+ const username = String(form.get("username") ?? "");
480
+ const password = String(form.get("password") ?? "");
481
+ const params = paramsFromForm(form);
482
+ if (!username || !password) {
483
+ return htmlResponse(
484
+ renderLogin({ params, csrfToken, errorMessage: "Username and password are required." }),
485
+ 400,
486
+ );
487
+ }
488
+ const user = getUserByUsername(db, username);
489
+ if (!user) {
490
+ return htmlResponse(
491
+ renderLogin({ params, csrfToken, errorMessage: "Invalid credentials." }),
492
+ 401,
493
+ );
494
+ }
495
+ const ok = await verifyPassword(user, password);
496
+ if (!ok) {
497
+ return htmlResponse(
498
+ renderLogin({ params, csrfToken, errorMessage: "Invalid credentials." }),
499
+ 401,
500
+ );
501
+ }
502
+ const session = createSession(db, { userId: user.id });
503
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
504
+ // Redirect back to GET /oauth/authorize with the original query string so
505
+ // the user lands on the consent screen with full params re-validated.
506
+ const u = new URL("/oauth/authorize", "http://placeholder");
507
+ for (const [k, v] of Object.entries(authorizeParamsToQuery(params))) {
508
+ u.searchParams.set(k, v);
509
+ }
510
+ return redirectResponse(`${u.pathname}${u.search}`, { "set-cookie": cookie });
511
+ }
512
+
513
+ async function handleConsentSubmit(
514
+ db: Database,
515
+ req: Request,
516
+ form: Awaited<ReturnType<Request["formData"]>>,
517
+ deps: OAuthDeps,
518
+ csrfToken: string,
519
+ ): Promise<Response> {
520
+ const params = paramsFromForm(form);
521
+ const approve = String(form.get("approve") ?? "") === "yes";
522
+ const sessionId = parseSessionCookie(req.headers.get("cookie"));
523
+ const session = sessionId ? findSession(db, sessionId) : null;
524
+ if (!session) {
525
+ // Session expired between login and consent submit. Send back to login.
526
+ return htmlResponse(
527
+ renderLogin({
528
+ params,
529
+ csrfToken,
530
+ errorMessage: "Your session expired — please sign in again.",
531
+ }),
532
+ 401,
533
+ );
534
+ }
535
+ const client = getClient(db, params.clientId);
536
+ if (!client) {
537
+ return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
538
+ }
539
+ if (client.status !== "approved") return pendingClientHtml();
540
+ try {
541
+ requireRegisteredRedirectUri(client, params.redirectUri);
542
+ } catch {
543
+ return htmlError(
544
+ "Redirect mismatch",
545
+ "The redirect_uri does not match any URI registered for this app.",
546
+ 400,
547
+ );
548
+ }
549
+ if (!approve) {
550
+ return oauthErrorRedirect(
551
+ params.redirectUri,
552
+ "access_denied",
553
+ "user denied the authorization request",
554
+ params.state,
555
+ );
556
+ }
557
+ let scopes = params.scope.split(" ").filter((s) => s.length > 0);
558
+ // Defense-in-depth (#96). The GET handler already rejects non-requestable
559
+ // scopes before consent renders, but a hand-crafted POST could carry one
560
+ // anyway — block it here too.
561
+ const blockedHere = findNonRequestableScopes(scopes);
562
+ if (blockedHere.length > 0) {
563
+ return oauthErrorRedirect(
564
+ params.redirectUri,
565
+ "invalid_scope",
566
+ `requested scopes are not available via the public authorization endpoint: ${blockedHere.join(", ")}`,
567
+ params.state,
568
+ );
569
+ }
570
+ // Vault picker (Q1 of the vault-config-and-scopes design): an unnamed
571
+ // `vault:<verb>` scope is ambiguous about which vault it grants access to.
572
+ // Force the operator to pick before the JWT is minted, then rewrite the
573
+ // unnamed scope to `vault:<picked>:<verb>` so vault's strict per-resource
574
+ // enforcement (Phase 1) sees a name it can match against the URL.
575
+ const unnamedVerbs = unnamedVaultVerbs(scopes);
576
+ if (unnamedVerbs.length > 0) {
577
+ const pickedVault = String(form.get("vault_pick") ?? "").trim();
578
+ if (!pickedVault) {
579
+ return htmlError(
580
+ "Pick a vault",
581
+ "This app requested vault access without naming a vault. Pick which vault to grant access to and try again.",
582
+ 400,
583
+ );
584
+ }
585
+ const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
586
+ const validNames = listVaultNames(manifest);
587
+ if (!validNames.includes(pickedVault)) {
588
+ return htmlError(
589
+ "Unknown vault",
590
+ `vault "${pickedVault}" is not registered on this host.`,
591
+ 400,
592
+ );
593
+ }
594
+ scopes = narrowVaultScopes(scopes, pickedVault);
595
+ }
596
+ // Record (or extend) the grant so the next /oauth/authorize for this
597
+ // (user, client) with these scopes — or any subset — can skip the consent
598
+ // screen (#75). UNION semantics: if the user previously granted [a, b, c]
599
+ // and now grants [a, d], the row becomes [a, b, c, d]. Subset re-flows
600
+ // still match.
601
+ recordGrant(db, session.userId, client.clientId, scopes, deps.now?.() ?? new Date());
602
+ return issueAuthCodeRedirect(db, params, scopes, session.userId, deps);
603
+ }
604
+
605
+ function paramsFromForm(form: Awaited<ReturnType<Request["formData"]>>): AuthorizeFormParams {
606
+ return {
607
+ clientId: String(form.get("client_id") ?? ""),
608
+ redirectUri: String(form.get("redirect_uri") ?? ""),
609
+ responseType: String(form.get("response_type") ?? "code"),
610
+ scope: String(form.get("scope") ?? ""),
611
+ codeChallenge: String(form.get("code_challenge") ?? ""),
612
+ codeChallengeMethod: String(form.get("code_challenge_method") ?? "S256"),
613
+ state: (form.get("state") as string | null) ?? null,
614
+ };
615
+ }
616
+
617
+ function authorizeParamsToQuery(p: AuthorizeFormParams): Record<string, string> {
618
+ const q: Record<string, string> = {
619
+ client_id: p.clientId,
620
+ redirect_uri: p.redirectUri,
621
+ response_type: p.responseType,
622
+ scope: p.scope,
623
+ code_challenge: p.codeChallenge,
624
+ code_challenge_method: p.codeChallengeMethod,
625
+ };
626
+ if (p.state) q.state = p.state;
627
+ return q;
628
+ }
629
+
630
+ // --- /oauth/token ----------------------------------------------------------
631
+
632
+ /**
633
+ * Extract a presented client_secret from either the `Authorization: Basic`
634
+ * header (RFC 6749 §2.3.1 preferred) or the form-body `client_secret`. If
635
+ * both are present, the header wins — the spec says clients SHOULD use one
636
+ * mechanism per request; when they don't, picking deterministically (header
637
+ * = the more-secure form, harder to log accidentally than a body field)
638
+ * keeps the auth gate predictable.
639
+ *
640
+ * Returns `{ clientId, clientSecret }` so callers can cross-check the body's
641
+ * `client_id` against the header's. RFC §2.3.1 doesn't explicitly require
642
+ * matching, but a mismatch is a client bug we shouldn't paper over.
643
+ *
644
+ * Returns null secret when no credential was presented at all.
645
+ */
646
+ function extractClientCredentials(
647
+ req: Request,
648
+ form: Awaited<ReturnType<Request["formData"]>>,
649
+ ): { headerClientId: string | null; clientSecret: string | null } {
650
+ const auth = req.headers.get("authorization");
651
+ // RFC 7235 §2.1 — auth-scheme is case-insensitive ("Basic" / "basic" / "BASIC").
652
+ if (auth && /^basic\s+/i.test(auth)) {
653
+ try {
654
+ const decoded = atob(auth.replace(/^basic\s+/i, "").trim());
655
+ const colon = decoded.indexOf(":");
656
+ if (colon >= 0) {
657
+ // RFC 6749 §2.3.1 mandates form-encoding the basic-auth values
658
+ // (because client_id may legitimately contain `:`). Decode them
659
+ // back so a client that registered the spec-correct way works.
660
+ const headerClientId = decodeURIComponent(decoded.slice(0, colon));
661
+ const clientSecret = decodeURIComponent(decoded.slice(colon + 1));
662
+ return { headerClientId, clientSecret };
663
+ }
664
+ } catch {
665
+ // Malformed base64 → treat as no header credential, fall through to
666
+ // form body. The auth gate will reject if the client is confidential
667
+ // and didn't also send a body secret.
668
+ }
669
+ }
670
+ const bodySecret = form.get("client_secret");
671
+ return {
672
+ headerClientId: null,
673
+ clientSecret: typeof bodySecret === "string" && bodySecret.length > 0 ? bodySecret : null,
674
+ };
675
+ }
676
+
677
+ /**
678
+ * 401 response shape for token-endpoint client-auth failures. WWW-Authenticate
679
+ * declares Basic per RFC 6749 §5.2 + RFC 7235 — it tells a compliant client
680
+ * "this endpoint accepts Basic auth" so it can retry with credentials.
681
+ */
682
+ function clientAuthFailure(description: string): Response {
683
+ return jsonResponse({ error: "invalid_client", error_description: description }, 401, {
684
+ "www-authenticate": 'Basic realm="hub"',
685
+ });
686
+ }
687
+
688
+ /**
689
+ * Gate the per-grant handlers behind RFC 6749 §3.2.1 client authentication.
690
+ * Public clients (clientSecretHash == null) pass through unchanged — PKCE
691
+ * already binds their auth-code redemption. Confidential clients must
692
+ * present a matching client_secret via Basic header or form body.
693
+ *
694
+ * Returns null on success; a 401 Response on failure for the caller to
695
+ * return directly.
696
+ */
697
+ function authenticateClient(
698
+ client: OAuthClient,
699
+ req: Request,
700
+ form: Awaited<ReturnType<Request["formData"]>>,
701
+ bodyClientId: string,
702
+ ): Response | null {
703
+ if (!client.clientSecretHash) return null; // public client: no secret required
704
+ const { headerClientId, clientSecret } = extractClientCredentials(req, form);
705
+ if (!clientSecret) {
706
+ return clientAuthFailure("client_secret required for confidential client");
707
+ }
708
+ // If the Basic header was used, its client_id must match the body's —
709
+ // RFC 6749 §3.2.1 says the auth identifies the client; a body claiming
710
+ // a different client_id is a bug or an attempt to confuse the gate.
711
+ if (headerClientId !== null && headerClientId !== bodyClientId) {
712
+ return clientAuthFailure("authorization header client_id does not match request body");
713
+ }
714
+ if (!verifyClientSecret(client, clientSecret)) {
715
+ return clientAuthFailure("client_secret mismatch");
716
+ }
717
+ return null;
718
+ }
719
+
720
+ /**
721
+ * POST /oauth/token — supports `authorization_code` + `refresh_token`.
722
+ * Confidential clients (registered with a client_secret) must authenticate
723
+ * via the Authorization: Basic header or a form-body `client_secret` per
724
+ * RFC 6749 §2.3.1; public clients (PKCE-only) need no client_secret because
725
+ * PKCE already binds the redemption. Errors return the RFC 6749 §5.2 shape:
726
+ * 400/401 + `{error, error_description}`.
727
+ */
728
+ export async function handleToken(db: Database, req: Request, deps: OAuthDeps): Promise<Response> {
729
+ const form = await req.formData();
730
+ const grantType = String(form.get("grant_type") ?? "");
731
+ if (grantType === "authorization_code")
732
+ return await handleTokenAuthorizationCode(db, req, form, deps);
733
+ if (grantType === "refresh_token") return await handleTokenRefresh(db, req, form, deps);
734
+ return jsonResponse(
735
+ {
736
+ error: "unsupported_grant_type",
737
+ error_description: `grant_type "${grantType}" is not supported`,
738
+ },
739
+ 400,
740
+ );
741
+ }
742
+
743
+ async function handleTokenAuthorizationCode(
744
+ db: Database,
745
+ req: Request,
746
+ form: Awaited<ReturnType<Request["formData"]>>,
747
+ deps: OAuthDeps,
748
+ ): Promise<Response> {
749
+ const code = String(form.get("code") ?? "");
750
+ const clientId = String(form.get("client_id") ?? "");
751
+ const redirectUri = String(form.get("redirect_uri") ?? "");
752
+ const codeVerifier = String(form.get("code_verifier") ?? "");
753
+ if (!code || !clientId || !redirectUri || !codeVerifier) {
754
+ return jsonResponse(
755
+ { error: "invalid_request", error_description: "missing required parameter" },
756
+ 400,
757
+ );
758
+ }
759
+ const client = getClient(db, clientId);
760
+ if (!client) {
761
+ return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
762
+ }
763
+ if (client.status !== "approved") return pendingClientJson();
764
+ const authFailure = authenticateClient(client, req, form, clientId);
765
+ if (authFailure) return authFailure;
766
+ let redeemed: ReturnType<typeof redeemAuthCode>;
767
+ try {
768
+ redeemed = redeemAuthCode(db, { code, clientId, redirectUri, codeVerifier, now: deps.now });
769
+ } catch (err) {
770
+ return mapAuthCodeError(err);
771
+ }
772
+ // Scope-validation gate (cli#71). Reject any requested scope that the
773
+ // issuer never declared — `FIRST_PARTY_SCOPES` ∪ each module's `module.json`
774
+ // `scopes.defines`. Per RFC 6749 §5.2: `error: "invalid_scope"`. We add
775
+ // `invalid_scopes: [...]` as an extension field so clients can report the
776
+ // exact culprits without re-parsing the description string.
777
+ const declared = (deps.loadDeclaredScopes ?? loadDeclaredScopes)();
778
+ const unknown = findUnknownScopes(redeemed.scopes, declared);
779
+ if (unknown.length > 0) {
780
+ return jsonResponse(
781
+ {
782
+ error: "invalid_scope",
783
+ error_description: `unknown scopes: ${unknown.join(", ")}`,
784
+ invalid_scopes: unknown,
785
+ },
786
+ 400,
787
+ );
788
+ }
789
+ const audience = inferAudience(redeemed.scopes);
790
+ const access = await signAccessToken(db, {
791
+ sub: redeemed.userId,
792
+ scopes: redeemed.scopes,
793
+ audience,
794
+ clientId: redeemed.clientId,
795
+ issuer: deps.issuer,
796
+ now: deps.now,
797
+ });
798
+ const refresh = signRefreshToken(db, {
799
+ jti: access.jti,
800
+ userId: redeemed.userId,
801
+ clientId: redeemed.clientId,
802
+ scopes: redeemed.scopes,
803
+ now: deps.now,
804
+ });
805
+ const services = buildServicesCatalog(
806
+ (deps.loadServicesManifest ?? readServicesManifest)(),
807
+ deps.issuer,
808
+ redeemed.scopes,
809
+ );
810
+ return jsonResponse({
811
+ access_token: access.token,
812
+ token_type: "Bearer",
813
+ expires_in: ACCESS_TOKEN_TTL_SECONDS,
814
+ refresh_token: refresh.token,
815
+ scope: redeemed.scopes.join(" "),
816
+ services,
817
+ });
818
+ }
819
+
820
+ async function handleTokenRefresh(
821
+ db: Database,
822
+ req: Request,
823
+ form: Awaited<ReturnType<Request["formData"]>>,
824
+ deps: OAuthDeps,
825
+ ): Promise<Response> {
826
+ const refreshToken = String(form.get("refresh_token") ?? "");
827
+ const clientId = String(form.get("client_id") ?? "");
828
+ if (!refreshToken || !clientId) {
829
+ return jsonResponse(
830
+ { error: "invalid_request", error_description: "missing required parameter" },
831
+ 400,
832
+ );
833
+ }
834
+ const client = getClient(db, clientId);
835
+ if (!client) {
836
+ return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
837
+ }
838
+ if (client.status !== "approved") return pendingClientJson();
839
+ const authFailure = authenticateClient(client, req, form, clientId);
840
+ if (authFailure) return authFailure;
841
+ const row = findRefreshToken(db, refreshToken);
842
+ if (!row) {
843
+ return jsonResponse(
844
+ { error: "invalid_grant", error_description: "refresh_token not found" },
845
+ 400,
846
+ );
847
+ }
848
+ if (row.clientId !== clientId) {
849
+ return jsonResponse({ error: "invalid_grant", error_description: "client_id mismatch" }, 400);
850
+ }
851
+ const now = deps.now?.() ?? new Date();
852
+ if (row.revokedAt) {
853
+ // Replay of an already-rotated refresh token. Per RFC 6819 §5.2.2.3 the
854
+ // working assumption is theft — the legitimate client received a new
855
+ // refresh token at the prior rotation, so anyone presenting the old one
856
+ // either lost a race (rare) or stole it (the case we must defend
857
+ // against). Either way: revoke every descendant in the family so the
858
+ // attacker can't keep refreshing, and force the legitimate client to
859
+ // re-authorize. Cheaper than tracking which call was first.
860
+ revokeFamily(db, row.familyId, now);
861
+ return jsonResponse(
862
+ { error: "invalid_grant", error_description: "refresh_token revoked" },
863
+ 400,
864
+ );
865
+ }
866
+ if (now.getTime() > new Date(row.expiresAt).getTime()) {
867
+ return jsonResponse(
868
+ { error: "invalid_grant", error_description: "refresh_token expired" },
869
+ 400,
870
+ );
871
+ }
872
+ // Rotate: revoke the old refresh row, mint a new access + refresh pair
873
+ // bound to the same family so a future replay of *any* descendant can
874
+ // walk the chain.
875
+ //
876
+ // Mint the access token *before* opening the rotation transaction. JWT
877
+ // signing is async (jose returns a Promise) and bun:sqlite's
878
+ // `db.transaction()` is sync — running async work inside the closure
879
+ // would silently break atomicity. Once we have the JWT, the UPDATE
880
+ // (revoke old) + INSERT (mint new refresh row) commit or roll back as
881
+ // a unit, so a mid-rotation crash can't dead-old-without-replacement
882
+ // (#107).
883
+ const audience = inferAudience(row.scopes);
884
+ const access = await signAccessToken(db, {
885
+ sub: row.userId,
886
+ scopes: row.scopes,
887
+ audience,
888
+ clientId: row.clientId,
889
+ issuer: deps.issuer,
890
+ now: deps.now,
891
+ });
892
+ let refresh: ReturnType<typeof signRefreshToken>;
893
+ try {
894
+ refresh = db.transaction(() => {
895
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
896
+ return signRefreshToken(db, {
897
+ jti: access.jti,
898
+ userId: row.userId,
899
+ clientId: row.clientId,
900
+ scopes: row.scopes,
901
+ familyId: row.familyId,
902
+ now: deps.now,
903
+ });
904
+ })();
905
+ } catch (err) {
906
+ // Concurrent rotation: a sibling refresh of the same row already
907
+ // committed and ours collides on the `tokens.jti` PRIMARY KEY (or any
908
+ // other INSERT-time DB error). Surface a clean `invalid_grant` 400 —
909
+ // RFC 6749 §5.2 — instead of letting the SQLite error bubble as a 500
910
+ // (#108). The transaction is already rolled back at this point, so
911
+ // the row's revoked_at is unchanged for the losing request.
912
+ if (err instanceof RefreshTokenInsertError) {
913
+ return jsonResponse(
914
+ { error: "invalid_grant", error_description: "refresh_token rotation conflict" },
915
+ 400,
916
+ );
917
+ }
918
+ throw err;
919
+ }
920
+ const services = buildServicesCatalog(
921
+ (deps.loadServicesManifest ?? readServicesManifest)(),
922
+ deps.issuer,
923
+ row.scopes,
924
+ );
925
+ return jsonResponse({
926
+ access_token: access.token,
927
+ token_type: "Bearer",
928
+ expires_in: ACCESS_TOKEN_TTL_SECONDS,
929
+ refresh_token: refresh.token,
930
+ scope: row.scopes.join(" "),
931
+ services,
932
+ });
933
+ }
934
+
935
+ // --- /oauth/revoke ---------------------------------------------------------
936
+
937
+ /**
938
+ * POST /oauth/revoke — RFC 7009 token revocation.
939
+ *
940
+ * Accepts `token` + optional `token_type_hint` (`refresh_token` or
941
+ * `access_token`) form-encoded. Authenticates the client (confidential
942
+ * clients via `client_secret`; public clients pass through with PKCE-style
943
+ * client_id-only auth, same gate as the token endpoint).
944
+ *
945
+ * Lookup strategy: try the refresh-token-hash first when the hint is
946
+ * `refresh_token` or absent (the common case — clients usually revoke
947
+ * refresh tokens), then fall back to JWT decode + jti lookup for access
948
+ * tokens. JWT decode here is unverified-decode of the payload only; we
949
+ * just need the jti to find the row. A signature check would be
950
+ * ceremonial — if the row exists we own it; if it doesn't, we return 200
951
+ * anyway per spec.
952
+ *
953
+ * Response: 200 with empty body on success OR when the token is unknown
954
+ * (RFC 7009 §2.2 — "the authorization server responds with HTTP status
955
+ * code 200 [...] or if the client submitted an invalid token"). We
956
+ * intentionally don't surface "found vs not-found" so a caller probing
957
+ * with random strings can't enumerate live tokens.
958
+ *
959
+ * Closes #73.
960
+ */
961
+ export async function handleRevoke(
962
+ db: Database,
963
+ req: Request,
964
+ _deps: OAuthDeps,
965
+ ): Promise<Response> {
966
+ const form = await req.formData();
967
+ const token = String(form.get("token") ?? "");
968
+ const hint = String(form.get("token_type_hint") ?? "");
969
+ const bodyClientId = String(form.get("client_id") ?? "");
970
+ if (!token || !bodyClientId) {
971
+ return jsonResponse(
972
+ { error: "invalid_request", error_description: "missing required parameter" },
973
+ 400,
974
+ );
975
+ }
976
+ const client = getClient(db, bodyClientId);
977
+ if (!client) {
978
+ return jsonResponse({ error: "invalid_client", error_description: "unknown client_id" }, 401);
979
+ }
980
+ const authFailure = authenticateClient(client, req, form, bodyClientId);
981
+ if (authFailure) return authFailure;
982
+
983
+ // Lookup. Hint is advisory per RFC 7009 §2.1 — clients that get it wrong
984
+ // still expect revocation to succeed, so we always try both shapes.
985
+ const now = new Date();
986
+ let row = hint === "access_token" ? null : findRefreshToken(db, token);
987
+ if (!row) {
988
+ const jti = unverifiedJtiOf(token);
989
+ if (jti) row = findTokenRowByJti(db, jti);
990
+ if (!row && hint === "access_token" && !row) {
991
+ // hint said access_token but the JWT didn't decode; check
992
+ // refresh-token shape as a last resort.
993
+ row = findRefreshToken(db, token);
994
+ }
995
+ }
996
+ if (row && row.clientId !== client.clientId) {
997
+ // RFC 7009 §2.1: revocation must be authenticated to the same client
998
+ // the token was issued to. A different client presenting a valid
999
+ // token is invalid_grant; we collapse it to 200 to avoid existence
1000
+ // disclosure to unrelated clients.
1001
+ return new Response(null, { status: 200 });
1002
+ }
1003
+ if (row && !row.revokedAt) {
1004
+ db.prepare("UPDATE tokens SET revoked_at = ? WHERE jti = ?").run(now.toISOString(), row.jti);
1005
+ }
1006
+ return new Response(null, { status: 200 });
1007
+ }
1008
+
1009
+ /**
1010
+ * Best-effort jti extraction for revocation lookup. Not signature-checked —
1011
+ * we only need the claim to find a row. If the row doesn't exist or the
1012
+ * client doesn't own it, the caller bails out anyway.
1013
+ */
1014
+ function unverifiedJtiOf(token: string): string | null {
1015
+ const parts = token.split(".");
1016
+ if (parts.length !== 3) return null;
1017
+ const payload = parts[1];
1018
+ if (!payload) return null;
1019
+ try {
1020
+ const json = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
1021
+ jti?: unknown;
1022
+ };
1023
+ return typeof json.jti === "string" ? json.jti : null;
1024
+ } catch {
1025
+ return null;
1026
+ }
1027
+ }
1028
+
1029
+ function mapAuthCodeError(err: unknown): Response {
1030
+ if (err instanceof AuthCodeNotFoundError) {
1031
+ return jsonResponse({ error: "invalid_grant", error_description: "code not found" }, 400);
1032
+ }
1033
+ if (err instanceof AuthCodeExpiredError) {
1034
+ return jsonResponse({ error: "invalid_grant", error_description: "code expired" }, 400);
1035
+ }
1036
+ if (err instanceof AuthCodeUsedError) {
1037
+ return jsonResponse(
1038
+ { error: "invalid_grant", error_description: "code already redeemed" },
1039
+ 400,
1040
+ );
1041
+ }
1042
+ if (err instanceof AuthCodePkceMismatchError) {
1043
+ return jsonResponse(
1044
+ { error: "invalid_grant", error_description: "code_verifier mismatch" },
1045
+ 400,
1046
+ );
1047
+ }
1048
+ if (err instanceof AuthCodeRedirectMismatchError) {
1049
+ return jsonResponse(
1050
+ { error: "invalid_grant", error_description: "redirect_uri mismatch" },
1051
+ 400,
1052
+ );
1053
+ }
1054
+ const msg = err instanceof Error ? err.message : String(err);
1055
+ return jsonResponse({ error: "server_error", error_description: msg }, 500);
1056
+ }
1057
+
1058
+ // --- /oauth/register -------------------------------------------------------
1059
+
1060
+ interface RegisterRequestBody {
1061
+ redirect_uris?: string[];
1062
+ scope?: string;
1063
+ client_name?: string;
1064
+ token_endpoint_auth_method?: string;
1065
+ }
1066
+
1067
+ /**
1068
+ * POST /oauth/register — RFC 7591 Dynamic Client Registration.
1069
+ *
1070
+ * Approval gate (closes #74). New rows land as `pending` by default and
1071
+ * cannot participate in OAuth flows until an operator runs
1072
+ * `parachute auth approve-client <id>`. The single bypass is presenting an
1073
+ * `Authorization: Bearer <operator-token>` whose token carries the
1074
+ * `hub:admin` scope — the install-time path used by first-party modules so
1075
+ * `parachute install vault` can self-register without a human follow-up.
1076
+ *
1077
+ * If a bearer is presented but invalid or insufficient, we reject with the
1078
+ * RFC 6750 shape rather than silently downgrading to the public path: a
1079
+ * caller who tried to authenticate but failed wants to know why, not get
1080
+ * `pending` back and wonder why their module can't OAuth.
1081
+ */
1082
+ export async function handleRegister(
1083
+ db: Database,
1084
+ req: Request,
1085
+ deps: OAuthDeps,
1086
+ ): Promise<Response> {
1087
+ let body: RegisterRequestBody;
1088
+ try {
1089
+ body = (await req.json()) as RegisterRequestBody;
1090
+ } catch {
1091
+ return jsonResponse(
1092
+ { error: "invalid_client_metadata", error_description: "body must be JSON" },
1093
+ 400,
1094
+ );
1095
+ }
1096
+ const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
1097
+ if (redirectUris.length === 0) {
1098
+ return jsonResponse(
1099
+ {
1100
+ error: "invalid_redirect_uri",
1101
+ error_description: "redirect_uris is required and must be non-empty",
1102
+ },
1103
+ 400,
1104
+ );
1105
+ }
1106
+ for (const uri of redirectUris) {
1107
+ if (typeof uri !== "string" || !isValidRedirectUri(uri)) {
1108
+ return jsonResponse(
1109
+ { error: "invalid_redirect_uri", error_description: `invalid redirect_uri "${uri}"` },
1110
+ 400,
1111
+ );
1112
+ }
1113
+ }
1114
+ // Operator-bearer auto-approve. No header → public DCR path (status=pending).
1115
+ // Header present → must validate as a hub:admin operator token; any failure
1116
+ // is surfaced (don't silently fall through to pending).
1117
+ let status: ClientStatus = "pending";
1118
+ if (req.headers.get("authorization")) {
1119
+ try {
1120
+ await requireScope(db, req, "hub:admin", deps.issuer);
1121
+ status = "approved";
1122
+ } catch (err) {
1123
+ if (err instanceof AdminAuthError) return adminAuthErrorResponse(err);
1124
+ throw err;
1125
+ }
1126
+ }
1127
+ const confidential = body.token_endpoint_auth_method === "client_secret_post";
1128
+ const scopes = (body.scope ?? "").split(" ").filter((s) => s.length > 0);
1129
+ let registered: RegisteredClient;
1130
+ try {
1131
+ registered = registerClient(db, {
1132
+ redirectUris,
1133
+ scopes,
1134
+ clientName: body.client_name,
1135
+ confidential,
1136
+ status,
1137
+ now: deps.now,
1138
+ });
1139
+ } catch (err) {
1140
+ const msg = err instanceof Error ? err.message : String(err);
1141
+ return jsonResponse({ error: "invalid_client_metadata", error_description: msg }, 400);
1142
+ }
1143
+ const respBody: Record<string, unknown> = {
1144
+ client_id: registered.client.clientId,
1145
+ redirect_uris: registered.client.redirectUris,
1146
+ grant_types: ["authorization_code", "refresh_token"],
1147
+ response_types: ["code"],
1148
+ token_endpoint_auth_method: confidential ? "client_secret_post" : "none",
1149
+ client_id_issued_at: Math.floor(new Date(registered.client.registeredAt).getTime() / 1000),
1150
+ status: registered.client.status,
1151
+ };
1152
+ if (registered.client.scopes.length > 0) respBody.scope = registered.client.scopes.join(" ");
1153
+ if (registered.client.clientName) respBody.client_name = registered.client.clientName;
1154
+ if (registered.clientSecret) respBody.client_secret = registered.clientSecret;
1155
+ return jsonResponse(respBody, 201);
1156
+ }
1157
+
1158
+ function consentProps(
1159
+ client: OAuthClient,
1160
+ params: AuthorizeFormParams,
1161
+ vaultNames: string[],
1162
+ csrfToken: string,
1163
+ ) {
1164
+ const scopes = params.scope.split(" ").filter((s) => s.length > 0);
1165
+ const unnamedVerbs = unnamedVaultVerbs(scopes);
1166
+ return {
1167
+ params,
1168
+ clientId: client.clientId,
1169
+ clientName: client.clientName ?? client.clientId,
1170
+ scopes,
1171
+ csrfToken,
1172
+ vaultPicker:
1173
+ unnamedVerbs.length > 0 ? { unnamedVerbs, availableVaults: vaultNames } : undefined,
1174
+ };
1175
+ }