keryx 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,4 @@
1
- import { randomUUID } from "crypto";
2
- import Mustache from "mustache";
3
1
  import { api } from "../api";
4
- import type { OAuthActionResponse } from "../classes/Action";
5
- import { Connection } from "../classes/Connection";
6
2
  import { Initializer } from "../classes/Initializer";
7
3
  import { config } from "../config";
8
4
  import { checkRateLimit } from "../middleware/rateLimit";
@@ -12,17 +8,18 @@ import {
12
8
  getExternalOrigin,
13
9
  } from "../util/http";
14
10
  import {
15
- base64UrlEncode,
16
- escapeHtml,
17
- redirectUrisMatch,
18
- validateRedirectUri,
19
- } from "../util/oauth";
20
-
21
- const templatesDir = import.meta.dir + "/../templates";
22
- let authTemplate: string;
23
- let successTemplate: string;
24
- let commonCss: string;
25
- let lionSvg: string;
11
+ handleAuthorizeGet,
12
+ handleAuthorizePost,
13
+ handleMetadata,
14
+ handleProtectedResourceMetadata,
15
+ handleRegister,
16
+ handleToken,
17
+ type TokenData,
18
+ } from "../util/oauthHandlers";
19
+ import {
20
+ loadOAuthTemplates,
21
+ type OAuthTemplates,
22
+ } from "../util/oauthTemplates";
26
23
 
27
24
  const namespace = "oauth";
28
25
 
@@ -32,28 +29,6 @@ declare module "../classes/API" {
32
29
  }
33
30
  }
34
31
 
35
- type OAuthClient = {
36
- client_id: string;
37
- redirect_uris: string[];
38
- client_name?: string;
39
- grant_types?: string[];
40
- response_types?: string[];
41
- token_endpoint_auth_method?: string;
42
- };
43
-
44
- type AuthCode = {
45
- clientId: string;
46
- userId: number;
47
- codeChallenge: string;
48
- redirectUri: string;
49
- };
50
-
51
- type TokenData = {
52
- userId: number;
53
- clientId: string;
54
- scopes: string[];
55
- };
56
-
57
32
  export class OAuthInitializer extends Initializer {
58
33
  constructor() {
59
34
  super(namespace);
@@ -62,14 +37,10 @@ export class OAuthInitializer extends Initializer {
62
37
  }
63
38
 
64
39
  async initialize() {
65
- authTemplate = await Bun.file(
66
- `${templatesDir}/oauth-authorize.html`,
67
- ).text();
68
- successTemplate = await Bun.file(
69
- `${templatesDir}/oauth-success.html`,
70
- ).text();
71
- commonCss = await Bun.file(`${templatesDir}/oauth-common.css`).text();
72
- lionSvg = await Bun.file(`${templatesDir}/lion.svg`).text();
40
+ const templates: OAuthTemplates = await loadOAuthTemplates(
41
+ api.rootDir,
42
+ api.packageDir,
43
+ );
73
44
 
74
45
  async function verifyAccessToken(token: string): Promise<TokenData | null> {
75
46
  const raw = await api.redis.redis.get(`oauth:token:${token}`);
@@ -156,10 +127,10 @@ export class OAuthInitializer extends Initializer {
156
127
  return appendHeaders(await handleRegister(req), corsHeaders);
157
128
  }
158
129
  if (path === "/oauth/authorize" && method === "GET") {
159
- return handleAuthorizeGet(url);
130
+ return handleAuthorizeGet(url, templates);
160
131
  }
161
132
  if (path === "/oauth/authorize" && method === "POST") {
162
- return handleAuthorizePost(req);
133
+ return handleAuthorizePost(req, templates);
163
134
  }
164
135
  if (path === "/oauth/token" && method === "POST") {
165
136
  return appendHeaders(await handleToken(req), corsHeaders);
@@ -174,437 +145,3 @@ export class OAuthInitializer extends Initializer {
174
145
  };
175
146
  }
176
147
  }
177
-
178
- /**
179
- * RFC 9728 — Protected Resource Metadata.
180
- * MCP clients fetch this first to discover the authorization server.
181
- */
182
- function handleProtectedResourceMetadata(
183
- origin: string,
184
- resourcePath: string,
185
- ): Response {
186
- const resource = resourcePath ? `${origin}${resourcePath}` : origin;
187
- return new Response(
188
- JSON.stringify({
189
- resource,
190
- authorization_servers: [origin],
191
- }),
192
- {
193
- status: 200,
194
- headers: { "Content-Type": "application/json" },
195
- },
196
- );
197
- }
198
-
199
- function handleMetadata(origin: string): Response {
200
- const issuer = origin;
201
- return new Response(
202
- JSON.stringify({
203
- issuer,
204
- authorization_endpoint: `${issuer}/oauth/authorize`,
205
- token_endpoint: `${issuer}/oauth/token`,
206
- registration_endpoint: `${issuer}/oauth/register`,
207
- response_types_supported: ["code"],
208
- grant_types_supported: ["authorization_code"],
209
- code_challenge_methods_supported: ["S256"],
210
- token_endpoint_auth_methods_supported: ["none"],
211
- }),
212
- {
213
- status: 200,
214
- headers: { "Content-Type": "application/json" },
215
- },
216
- );
217
- }
218
-
219
- async function handleRegister(req: Request): Promise<Response> {
220
- let body: any;
221
- try {
222
- body = await req.json();
223
- } catch {
224
- return new Response(
225
- JSON.stringify({
226
- error: "invalid_request",
227
- error_description: "Invalid JSON body",
228
- }),
229
- { status: 400, headers: { "Content-Type": "application/json" } },
230
- );
231
- }
232
-
233
- if (
234
- !body.redirect_uris ||
235
- !Array.isArray(body.redirect_uris) ||
236
- body.redirect_uris.length === 0
237
- ) {
238
- return new Response(
239
- JSON.stringify({
240
- error: "invalid_request",
241
- error_description: "redirect_uris is required",
242
- }),
243
- { status: 400, headers: { "Content-Type": "application/json" } },
244
- );
245
- }
246
-
247
- for (const uri of body.redirect_uris) {
248
- if (typeof uri !== "string") {
249
- return new Response(
250
- JSON.stringify({
251
- error: "invalid_request",
252
- error_description: "Each redirect_uri must be a string",
253
- }),
254
- { status: 400, headers: { "Content-Type": "application/json" } },
255
- );
256
- }
257
- const validation = validateRedirectUri(uri);
258
- if (!validation.valid) {
259
- return new Response(
260
- JSON.stringify({
261
- error: "invalid_request",
262
- error_description: validation.error,
263
- }),
264
- { status: 400, headers: { "Content-Type": "application/json" } },
265
- );
266
- }
267
- }
268
-
269
- const clientId = randomUUID();
270
- const client: OAuthClient = {
271
- client_id: clientId,
272
- redirect_uris: body.redirect_uris,
273
- client_name: body.client_name,
274
- grant_types: body.grant_types ?? ["authorization_code"],
275
- response_types: body.response_types ?? ["code"],
276
- token_endpoint_auth_method: body.token_endpoint_auth_method ?? "none",
277
- };
278
-
279
- await api.redis.redis.set(
280
- `oauth:client:${clientId}`,
281
- JSON.stringify(client),
282
- "EX",
283
- config.server.mcp.oauthClientTtl,
284
- );
285
-
286
- return new Response(JSON.stringify(client), {
287
- status: 201,
288
- headers: { "Content-Type": "application/json" },
289
- });
290
- }
291
-
292
- function handleAuthorizeGet(url: URL): Response {
293
- const clientId = url.searchParams.get("client_id") ?? "";
294
- const redirectUri = url.searchParams.get("redirect_uri") ?? "";
295
- const codeChallenge = url.searchParams.get("code_challenge") ?? "";
296
- const codeChallengeMethod =
297
- url.searchParams.get("code_challenge_method") ?? "";
298
- const responseType = url.searchParams.get("response_type") ?? "";
299
- const state = url.searchParams.get("state") ?? "";
300
-
301
- return renderAuthPage({
302
- clientId,
303
- redirectUri,
304
- codeChallenge,
305
- codeChallengeMethod,
306
- responseType,
307
- state,
308
- error: "",
309
- });
310
- }
311
-
312
- async function handleAuthorizePost(req: Request): Promise<Response> {
313
- let fields: Record<string, string>;
314
- try {
315
- const contentType = req.headers.get("content-type") ?? "";
316
- if (contentType.includes("application/x-www-form-urlencoded")) {
317
- const text = await req.text();
318
- const params = new URLSearchParams(text);
319
- fields = Object.fromEntries(params.entries());
320
- } else {
321
- const form = await req.formData();
322
- fields = {};
323
- form.forEach((value, key) => {
324
- fields[key] = String(value);
325
- });
326
- }
327
- } catch {
328
- return new Response("Bad Request", { status: 400 });
329
- }
330
-
331
- const mode = fields.mode ?? "";
332
- const email = (fields.email ?? "").toLowerCase();
333
- const password = fields.password ?? "";
334
- const name = fields.name ?? "";
335
- const clientId = fields.client_id ?? "";
336
- const redirectUri = fields.redirect_uri ?? "";
337
- const codeChallenge = fields.code_challenge ?? "";
338
- const codeChallengeMethod = fields.code_challenge_method ?? "";
339
- const responseType = fields.response_type ?? "";
340
- const state = fields.state ?? "";
341
-
342
- const oauthParams = {
343
- clientId,
344
- redirectUri,
345
- codeChallenge,
346
- codeChallengeMethod,
347
- responseType,
348
- state,
349
- error: "",
350
- };
351
-
352
- // Validate client
353
- const clientRaw = await api.redis.redis.get(`oauth:client:${clientId}`);
354
- if (!clientRaw) {
355
- oauthParams.error = "Unknown client";
356
- return renderAuthPage(oauthParams);
357
- }
358
- const client = JSON.parse(clientRaw) as OAuthClient;
359
-
360
- const uriMatch = client.redirect_uris.some((registered) =>
361
- redirectUrisMatch(registered, redirectUri),
362
- );
363
- if (!uriMatch) {
364
- oauthParams.error = "Invalid redirect URI";
365
- return renderAuthPage(oauthParams);
366
- }
367
-
368
- if (codeChallengeMethod !== "S256") {
369
- oauthParams.error = "code_challenge_method must be S256";
370
- return renderAuthPage(oauthParams);
371
- }
372
-
373
- let userId: number;
374
-
375
- if (mode === "signup") {
376
- const signupAction = api.actions.actions.find((a) => a.mcp?.isSignupAction);
377
- const connection = new Connection("oauth", "oauth-signup");
378
- try {
379
- const params = new FormData();
380
- params.set("name", name);
381
- params.set("email", email);
382
- params.set("password", password);
383
- const { response, error } = await connection.act(
384
- signupAction!.name,
385
- params,
386
- );
387
- if (error) {
388
- oauthParams.error = error.message;
389
- return renderAuthPage(oauthParams);
390
- }
391
- userId = (response as OAuthActionResponse).user.id;
392
- } finally {
393
- connection.destroy();
394
- }
395
- } else {
396
- const loginAction = api.actions.actions.find((a) => a.mcp?.isLoginAction);
397
- const connection = new Connection("oauth", "oauth-login");
398
- try {
399
- const params = new FormData();
400
- params.set("email", email);
401
- params.set("password", password);
402
- const { response, error } = await connection.act(
403
- loginAction!.name,
404
- params,
405
- );
406
- if (error) {
407
- oauthParams.error = "Invalid email or password";
408
- return renderAuthPage(oauthParams);
409
- }
410
- userId = (response as OAuthActionResponse).user.id;
411
- } finally {
412
- connection.destroy();
413
- }
414
- }
415
-
416
- // Generate auth code
417
- const code = randomUUID();
418
- const codeData: AuthCode = {
419
- clientId,
420
- userId,
421
- codeChallenge,
422
- redirectUri,
423
- };
424
-
425
- await api.redis.redis.set(
426
- `oauth:code:${code}`,
427
- JSON.stringify(codeData),
428
- "EX",
429
- config.server.mcp.oauthCodeTtl,
430
- );
431
-
432
- const redirectUrl = new URL(redirectUri);
433
- redirectUrl.searchParams.set("code", code);
434
- if (state) redirectUrl.searchParams.set("state", state);
435
-
436
- return renderSuccessPage(redirectUrl.toString());
437
- }
438
-
439
- async function handleToken(req: Request): Promise<Response> {
440
- let body: URLSearchParams;
441
- const contentType = req.headers.get("content-type") ?? "";
442
-
443
- if (contentType.includes("application/x-www-form-urlencoded")) {
444
- const text = await req.text();
445
- body = new URLSearchParams(text);
446
- } else if (contentType.includes("application/json")) {
447
- const json = await req.json();
448
- body = new URLSearchParams(json as Record<string, string>);
449
- } else {
450
- // Try form-urlencoded as default
451
- const text = await req.text();
452
- body = new URLSearchParams(text);
453
- }
454
-
455
- const grantType = body.get("grant_type");
456
- const code = body.get("code");
457
- const codeVerifier = body.get("code_verifier");
458
- const redirectUri = body.get("redirect_uri");
459
- const clientId = body.get("client_id");
460
-
461
- if (grantType !== "authorization_code") {
462
- return new Response(JSON.stringify({ error: "unsupported_grant_type" }), {
463
- status: 400,
464
- headers: { "Content-Type": "application/json" },
465
- });
466
- }
467
-
468
- if (!code || !codeVerifier) {
469
- return new Response(
470
- JSON.stringify({
471
- error: "invalid_request",
472
- error_description: "code and code_verifier are required",
473
- }),
474
- { status: 400, headers: { "Content-Type": "application/json" } },
475
- );
476
- }
477
-
478
- // Look up auth code
479
- const codeRaw = await api.redis.redis.get(`oauth:code:${code}`);
480
- if (!codeRaw) {
481
- return new Response(
482
- JSON.stringify({
483
- error: "invalid_grant",
484
- error_description: "Invalid or expired authorization code",
485
- }),
486
- { status: 400, headers: { "Content-Type": "application/json" } },
487
- );
488
- }
489
-
490
- const codeData = JSON.parse(codeRaw) as AuthCode;
491
-
492
- // Delete the code immediately (single use)
493
- await api.redis.redis.del(`oauth:code:${code}`);
494
-
495
- // Validate client_id matches
496
- if (clientId && clientId !== codeData.clientId) {
497
- return new Response(
498
- JSON.stringify({
499
- error: "invalid_grant",
500
- error_description: "client_id mismatch",
501
- }),
502
- { status: 400, headers: { "Content-Type": "application/json" } },
503
- );
504
- }
505
-
506
- // Validate redirect_uri matches
507
- if (redirectUri && redirectUri !== codeData.redirectUri) {
508
- return new Response(
509
- JSON.stringify({
510
- error: "invalid_grant",
511
- error_description: "redirect_uri mismatch",
512
- }),
513
- { status: 400, headers: { "Content-Type": "application/json" } },
514
- );
515
- }
516
-
517
- // Verify PKCE: BASE64URL(SHA256(code_verifier)) === stored code_challenge
518
- const encoder = new TextEncoder();
519
- const digest = await crypto.subtle.digest(
520
- "SHA-256",
521
- encoder.encode(codeVerifier),
522
- );
523
- const computedChallenge = base64UrlEncode(new Uint8Array(digest));
524
-
525
- if (computedChallenge !== codeData.codeChallenge) {
526
- return new Response(
527
- JSON.stringify({
528
- error: "invalid_grant",
529
- error_description: "PKCE verification failed",
530
- }),
531
- { status: 400, headers: { "Content-Type": "application/json" } },
532
- );
533
- }
534
-
535
- // Generate access token
536
- const accessToken = randomUUID();
537
- const tokenData: TokenData = {
538
- userId: codeData.userId,
539
- clientId: codeData.clientId,
540
- scopes: [],
541
- };
542
-
543
- await api.redis.redis.set(
544
- `oauth:token:${accessToken}`,
545
- JSON.stringify(tokenData),
546
- "EX",
547
- config.session.ttl,
548
- );
549
-
550
- return new Response(
551
- JSON.stringify({
552
- access_token: accessToken,
553
- token_type: "Bearer",
554
- expires_in: config.session.ttl,
555
- }),
556
- {
557
- status: 200,
558
- headers: { "Content-Type": "application/json" },
559
- },
560
- );
561
- }
562
-
563
- type AuthPageParams = {
564
- clientId: string;
565
- redirectUri: string;
566
- codeChallenge: string;
567
- codeChallengeMethod: string;
568
- responseType: string;
569
- state: string;
570
- error: string;
571
- };
572
-
573
- function renderAuthPage(params: AuthPageParams): Response {
574
- const errorHtml = params.error
575
- ? `<div class="error">${escapeHtml(params.error)}</div>`
576
- : "";
577
-
578
- const hiddenFields = `
579
- <input type="hidden" name="client_id" value="${escapeHtml(params.clientId)}">
580
- <input type="hidden" name="redirect_uri" value="${escapeHtml(params.redirectUri)}">
581
- <input type="hidden" name="code_challenge" value="${escapeHtml(params.codeChallenge)}">
582
- <input type="hidden" name="code_challenge_method" value="${escapeHtml(params.codeChallengeMethod)}">
583
- <input type="hidden" name="response_type" value="${escapeHtml(params.responseType)}">
584
- <input type="hidden" name="state" value="${escapeHtml(params.state)}">
585
- `;
586
-
587
- const html = Mustache.render(
588
- authTemplate,
589
- { errorHtml, hiddenFields },
590
- { commonCss, lionSvg },
591
- );
592
-
593
- return new Response(html, {
594
- status: 200,
595
- headers: { "Content-Type": "text/html; charset=utf-8" },
596
- });
597
- }
598
-
599
- function renderSuccessPage(redirectUrl: string): Response {
600
- const html = Mustache.render(
601
- successTemplate,
602
- { redirectUrl },
603
- { commonCss, lionSvg },
604
- );
605
-
606
- return new Response(html, {
607
- status: 200,
608
- headers: { "Content-Type": "text/html; charset=utf-8" },
609
- });
610
- }
package/keryx.ts CHANGED
@@ -1,165 +1,12 @@
1
1
  #! /usr/bin/env bun
2
2
 
3
- import { Command } from "commander";
4
- import path from "path";
5
- import { Action, api } from "./api";
6
3
  import pkg from "./package.json";
7
- import { addActionToProgram } from "./util/cli";
8
- import { generateComponent } from "./util/generate";
9
- import { globLoader } from "./util/glob";
10
- import {
11
- interactiveScaffold,
12
- scaffoldProject,
13
- type ScaffoldOptions,
14
- } from "./util/scaffold";
15
- import { upgradeProject } from "./util/upgrade";
4
+ import { buildProgram } from "./util/cli";
16
5
 
17
- const program = new Command();
18
- program.name(pkg.name).description(pkg.description).version(pkg.version);
19
-
20
- program
21
- .command("new [project-name]")
22
- .summary("Create a new Keryx project")
23
- .description("Scaffold a new Keryx application with project boilerplate")
24
- .option("-y, --yes", "Skip prompts and use defaults")
25
- .option("--no-interactive", "Skip prompts and use defaults")
26
- .option("--no-db", "Skip database setup files")
27
- .option("--no-example", "Skip example action")
28
- .action(async (projectName: string | undefined, opts) => {
29
- let options: ScaffoldOptions;
30
-
31
- if (opts.yes || opts.interactive === false) {
32
- // --no-interactive: use defaults
33
- projectName = projectName || "my-keryx-app";
34
- options = {
35
- includeDb: opts.db !== false,
36
- includeExample: opts.example !== false,
37
- };
38
- } else {
39
- const result = await interactiveScaffold(projectName);
40
- projectName = result.projectName;
41
- options = result.options;
42
- }
43
-
44
- const targetDir = path.resolve(process.cwd(), projectName);
45
-
46
- console.log(`\nCreating new Keryx project: ${projectName}\n`);
47
-
48
- const files = await scaffoldProject(projectName, targetDir, options);
49
- files.forEach((f) => console.log(` ${f}`));
50
-
51
- console.log(`
52
- Done! To get started:
53
-
54
- cd ${projectName}
55
- cp .env.example .env
56
- bun install
57
- bun dev
58
- `);
59
- process.exit(0);
60
- });
61
-
62
- program
63
- .command("upgrade")
64
- .summary("Update framework-owned files to match the installed keryx version")
65
- .option("--dry-run", "Show what would change without writing files")
66
- .option("--force", "Overwrite all framework files without confirmation")
67
- .option("-y, --yes", "Overwrite all framework files without confirmation")
68
- .action(async (opts) => {
69
- try {
70
- await upgradeProject(process.cwd(), {
71
- dryRun: opts.dryRun || false,
72
- force: opts.force || opts.yes || false,
73
- });
74
- process.exit(0);
75
- } catch (e) {
76
- console.error((e as Error).message);
77
- process.exit(1);
78
- }
79
- });
80
-
81
- program
82
- .command("generate <type> <name>")
83
- .alias("g")
84
- .summary("Generate a new component")
85
- .description(
86
- "Scaffold a new action, initializer, middleware, channel, or ops file.\n\n" +
87
- "Examples:\n" +
88
- " keryx generate action user:delete\n" +
89
- " keryx generate initializer cache\n" +
90
- " keryx generate middleware auth\n" +
91
- " keryx generate channel notifications\n" +
92
- " keryx generate ops UserOps\n" +
93
- " keryx g action hello",
94
- )
95
- .option("--dry-run", "Show what would be generated without writing files")
96
- .option("--force", "Overwrite existing files")
97
- .option("--no-test", "Skip generating a test file")
98
- .action(
99
- async (
100
- type: string,
101
- name: string,
102
- opts: { dryRun?: boolean; force?: boolean; test?: boolean },
103
- ) => {
104
- try {
105
- const rootDir = process.cwd();
106
- const files = await generateComponent(type, name, rootDir, {
107
- dryRun: opts.dryRun,
108
- force: opts.force,
109
- noTest: opts.test === false,
110
- });
111
-
112
- if (!opts.dryRun) {
113
- console.log("\nGenerated:");
114
- files.forEach((f) => console.log(` ${f}`));
115
- console.log();
116
- }
117
-
118
- process.exit(0);
119
- } catch (e) {
120
- console.error((e as Error).message);
121
- process.exit(1);
122
- }
123
- },
124
- );
125
-
126
- program
127
- .command("start")
128
- .summary("Run the server")
129
- .description("Start the Keryx server")
130
- .action(async () => {
131
- await api.start();
132
- });
133
-
134
- // Load actions from the project directory
135
- let actions: Action[] = [];
136
- try {
137
- actions = await globLoader<Action>(path.join(api.rootDir, "actions"));
138
- } catch {
139
- // project may not have actions yet
140
- }
141
- actions.forEach((action) => addActionToProgram(program, action));
142
-
143
- program
144
- .command("actions")
145
- .summary("List all actions")
146
- .action(async () => {
147
- const actionSpacing =
148
- actions.map((a) => a.name.length).reduce((a, b) => Math.max(a, b), 0) + 2;
149
- const routeSpacing =
150
- actions
151
- .map((a) =>
152
- a.web ? a.web.route.toString().length + a.web.method.length : 0,
153
- )
154
- .reduce((a, b) => Math.max(a, b), 0) + 2;
155
-
156
- actions
157
- .sort((a, b) => a.name.localeCompare(b.name))
158
- .forEach((action) => {
159
- console.log(
160
- `${action.name}${" ".repeat(actionSpacing - action.name.length)} ${action.web ? `[${action.web.method}] ${action.web.route}` : " "}${" ".repeat(routeSpacing - (action.web ? action.web.method.length + action.web.route.toString().length + 2 : 0))} ${action.description ?? ""}`,
161
- );
162
- });
163
- });
6
+ const program = await buildProgram({
7
+ name: pkg.name,
8
+ description: pkg.description,
9
+ version: pkg.version,
10
+ });
164
11
 
165
12
  program.parse();