keryx 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/initializers/oauth.ts +18 -481
- package/package.json +6 -2
- package/servers/web.ts +3 -2
- package/templates/oauth-authorize.html +10 -29
- package/util/oauthHandlers.ts +453 -0
- package/util/oauthTemplates.ts +237 -0
- package/util/scaffold.ts +30 -0
- package/util/upgrade.ts +4 -0
- package/util/webCompression.ts +4 -0
package/initializers/oauth.ts
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keryx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"description": "
|
|
7
|
+
"description": "The fullstack TypeScript framework for MCP and APIs — write one action, serve HTTP, WebSocket, CLI, background tasks, and MCP tools.",
|
|
8
8
|
"author": "Evan Tahler <evan@evantahler.com>",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
"websocket",
|
|
24
24
|
"realtime",
|
|
25
25
|
"mcp",
|
|
26
|
+
"model-context-protocol",
|
|
26
27
|
"cli",
|
|
28
|
+
"ai",
|
|
29
|
+
"fullstack",
|
|
30
|
+
"transport-agnostic",
|
|
27
31
|
"actionhero"
|
|
28
32
|
],
|
|
29
33
|
"files": [
|
package/servers/web.ts
CHANGED
|
@@ -222,8 +222,9 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
|
|
|
222
222
|
});
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
// Don't route .well-known paths to actions
|
|
226
|
-
|
|
225
|
+
// Don't route .well-known paths to actions (covers both root and
|
|
226
|
+
// sub-path variants like /mcp/.well-known/openid-configuration)
|
|
227
|
+
if (parsedUrl.pathname?.includes("/.well-known/")) {
|
|
227
228
|
return new Response(null, { status: 404 });
|
|
228
229
|
}
|
|
229
230
|
|
|
@@ -11,52 +11,33 @@
|
|
|
11
11
|
<body>
|
|
12
12
|
<div class="container">
|
|
13
13
|
<h2>Authorize Application</h2>
|
|
14
|
-
{{{errorHtml}}}
|
|
14
|
+
{{{errorHtml}}} {{#hasSignin}}{{#hasSignup}}
|
|
15
15
|
<div class="tabs">
|
|
16
16
|
<div class="tab active" onclick="switchTab('signin')">Sign In</div>
|
|
17
17
|
<div class="tab" onclick="switchTab('signup')">Sign Up</div>
|
|
18
18
|
</div>
|
|
19
|
+
{{/hasSignup}}{{/hasSignin}} {{#hasSignin}}
|
|
19
20
|
<div id="signin-form" class="form-section active">
|
|
20
21
|
<form method="POST" action="/oauth/authorize">
|
|
21
22
|
{{{hiddenFields}}}
|
|
22
23
|
<input type="hidden" name="mode" value="signin" />
|
|
23
|
-
|
|
24
|
-
<input type="email" id="signin-email" name="email" required />
|
|
25
|
-
<label for="signin-password">Password</label>
|
|
26
|
-
<input
|
|
27
|
-
type="password"
|
|
28
|
-
id="signin-password"
|
|
29
|
-
name="password"
|
|
30
|
-
required
|
|
31
|
-
/>
|
|
24
|
+
{{{signinFieldsHtml}}}
|
|
32
25
|
<button type="submit">Sign In</button>
|
|
33
26
|
</form>
|
|
34
27
|
</div>
|
|
35
|
-
|
|
28
|
+
{{/hasSignin}} {{#hasSignup}}
|
|
29
|
+
<div
|
|
30
|
+
id="signup-form"
|
|
31
|
+
class="form-section{{^hasSignin}} active{{/hasSignin}}"
|
|
32
|
+
>
|
|
36
33
|
<form method="POST" action="/oauth/authorize">
|
|
37
34
|
{{{hiddenFields}}}
|
|
38
35
|
<input type="hidden" name="mode" value="signup" />
|
|
39
|
-
|
|
40
|
-
<input
|
|
41
|
-
type="text"
|
|
42
|
-
id="signup-name"
|
|
43
|
-
name="name"
|
|
44
|
-
required
|
|
45
|
-
minlength="3"
|
|
46
|
-
/>
|
|
47
|
-
<label for="signup-email">Email</label>
|
|
48
|
-
<input type="email" id="signup-email" name="email" required />
|
|
49
|
-
<label for="signup-password">Password</label>
|
|
50
|
-
<input
|
|
51
|
-
type="password"
|
|
52
|
-
id="signup-password"
|
|
53
|
-
name="password"
|
|
54
|
-
required
|
|
55
|
-
minlength="8"
|
|
56
|
-
/>
|
|
36
|
+
{{{signupFieldsHtml}}}
|
|
57
37
|
<button type="submit">Sign Up</button>
|
|
58
38
|
</form>
|
|
59
39
|
</div>
|
|
40
|
+
{{/hasSignup}}
|
|
60
41
|
</div>
|
|
61
42
|
<div class="lion">{{> lionSvg}}</div>
|
|
62
43
|
<script>
|