keryx 0.22.0 → 0.23.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/config/server/mcp.ts +4 -0
- package/initializers/oauth.ts +22 -10
- package/package.json +1 -1
- package/testing/index.ts +8 -0
- package/testing/websocket.ts +171 -0
- package/util/oauthHandlers/authorize.ts +170 -0
- package/util/oauthHandlers/index.ts +17 -0
- package/util/oauthHandlers/introspect.ts +59 -0
- package/util/oauthHandlers/keys.ts +19 -0
- package/util/oauthHandlers/metadata.ts +38 -0
- package/util/oauthHandlers/register.ts +58 -0
- package/util/oauthHandlers/responses.ts +56 -0
- package/util/oauthHandlers/revoke.ts +49 -0
- package/util/oauthHandlers/token.ts +148 -0
- package/util/oauthHandlers/types.ts +44 -0
- package/util/webStaticFiles.ts +34 -0
- package/util/oauthHandlers.ts +0 -463
package/util/oauthHandlers.ts
DELETED
|
@@ -1,463 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "crypto";
|
|
2
|
-
import { api } from "../api";
|
|
3
|
-
import type { Action, OAuthActionResponse } from "../classes/Action";
|
|
4
|
-
import { Connection } from "../classes/Connection";
|
|
5
|
-
import { config } from "../config";
|
|
6
|
-
import {
|
|
7
|
-
base64UrlEncode,
|
|
8
|
-
redirectUrisMatch,
|
|
9
|
-
validateRedirectUri,
|
|
10
|
-
} from "./oauth";
|
|
11
|
-
import {
|
|
12
|
-
type AuthPageParams,
|
|
13
|
-
type OAuthTemplates,
|
|
14
|
-
renderAuthPage,
|
|
15
|
-
renderSuccessPage,
|
|
16
|
-
} from "./oauthTemplates";
|
|
17
|
-
|
|
18
|
-
export type OAuthClient = {
|
|
19
|
-
client_id: string;
|
|
20
|
-
redirect_uris: string[];
|
|
21
|
-
client_name?: string;
|
|
22
|
-
grant_types?: string[];
|
|
23
|
-
response_types?: string[];
|
|
24
|
-
token_endpoint_auth_method?: string;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type AuthCode = {
|
|
28
|
-
clientId: string;
|
|
29
|
-
userId: number;
|
|
30
|
-
codeChallenge: string;
|
|
31
|
-
redirectUri: string;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export type TokenData = {
|
|
35
|
-
userId: number;
|
|
36
|
-
clientId: string;
|
|
37
|
-
scopes: string[];
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/** OAuth protocol fields that should not be forwarded to login/signup actions. */
|
|
41
|
-
const OAUTH_FIELDS = new Set([
|
|
42
|
-
"mode",
|
|
43
|
-
"client_id",
|
|
44
|
-
"redirect_uri",
|
|
45
|
-
"code_challenge",
|
|
46
|
-
"code_challenge_method",
|
|
47
|
-
"response_type",
|
|
48
|
-
"state",
|
|
49
|
-
]);
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* RFC 9728 — Protected Resource Metadata.
|
|
53
|
-
* MCP clients fetch this first to discover the authorization server.
|
|
54
|
-
*/
|
|
55
|
-
export function handleProtectedResourceMetadata(
|
|
56
|
-
origin: string,
|
|
57
|
-
resourcePath: string,
|
|
58
|
-
): Response {
|
|
59
|
-
const resource = resourcePath ? `${origin}${resourcePath}` : origin;
|
|
60
|
-
return new Response(
|
|
61
|
-
JSON.stringify({
|
|
62
|
-
resource,
|
|
63
|
-
authorization_servers: [origin],
|
|
64
|
-
scopes_supported: ["mcp"],
|
|
65
|
-
}),
|
|
66
|
-
{
|
|
67
|
-
status: 200,
|
|
68
|
-
headers: { "Content-Type": "application/json" },
|
|
69
|
-
},
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** OAuth 2.1 authorization server metadata endpoint. */
|
|
74
|
-
export function handleMetadata(origin: string): Response {
|
|
75
|
-
const issuer = origin;
|
|
76
|
-
return new Response(
|
|
77
|
-
JSON.stringify({
|
|
78
|
-
issuer,
|
|
79
|
-
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
80
|
-
token_endpoint: `${issuer}/oauth/token`,
|
|
81
|
-
registration_endpoint: `${issuer}/oauth/register`,
|
|
82
|
-
response_types_supported: ["code"],
|
|
83
|
-
grant_types_supported: ["authorization_code"],
|
|
84
|
-
code_challenge_methods_supported: ["S256"],
|
|
85
|
-
token_endpoint_auth_methods_supported: ["none"],
|
|
86
|
-
client_id_metadata_document_supported: false,
|
|
87
|
-
}),
|
|
88
|
-
{
|
|
89
|
-
status: 200,
|
|
90
|
-
headers: { "Content-Type": "application/json" },
|
|
91
|
-
},
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Dynamic client registration endpoint (RFC 7591). */
|
|
96
|
-
export async function handleRegister(req: Request): Promise<Response> {
|
|
97
|
-
let body: any;
|
|
98
|
-
try {
|
|
99
|
-
body = await req.json();
|
|
100
|
-
} catch {
|
|
101
|
-
return new Response(
|
|
102
|
-
JSON.stringify({
|
|
103
|
-
error: "invalid_request",
|
|
104
|
-
error_description: "Invalid JSON body",
|
|
105
|
-
}),
|
|
106
|
-
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
!body.redirect_uris ||
|
|
112
|
-
!Array.isArray(body.redirect_uris) ||
|
|
113
|
-
body.redirect_uris.length === 0
|
|
114
|
-
) {
|
|
115
|
-
return new Response(
|
|
116
|
-
JSON.stringify({
|
|
117
|
-
error: "invalid_request",
|
|
118
|
-
error_description: "redirect_uris is required",
|
|
119
|
-
}),
|
|
120
|
-
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
for (const uri of body.redirect_uris) {
|
|
125
|
-
if (typeof uri !== "string") {
|
|
126
|
-
return new Response(
|
|
127
|
-
JSON.stringify({
|
|
128
|
-
error: "invalid_request",
|
|
129
|
-
error_description: "Each redirect_uri must be a string",
|
|
130
|
-
}),
|
|
131
|
-
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
const validation = validateRedirectUri(uri);
|
|
135
|
-
if (!validation.valid) {
|
|
136
|
-
return new Response(
|
|
137
|
-
JSON.stringify({
|
|
138
|
-
error: "invalid_request",
|
|
139
|
-
error_description: validation.error,
|
|
140
|
-
}),
|
|
141
|
-
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const clientId = randomUUID();
|
|
147
|
-
const client: OAuthClient = {
|
|
148
|
-
client_id: clientId,
|
|
149
|
-
redirect_uris: body.redirect_uris,
|
|
150
|
-
client_name: body.client_name,
|
|
151
|
-
grant_types: body.grant_types ?? ["authorization_code"],
|
|
152
|
-
response_types: body.response_types ?? ["code"],
|
|
153
|
-
token_endpoint_auth_method: body.token_endpoint_auth_method ?? "none",
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
await api.redis.redis.set(
|
|
157
|
-
`oauth:client:${clientId}`,
|
|
158
|
-
JSON.stringify(client),
|
|
159
|
-
"EX",
|
|
160
|
-
config.server.mcp.oauthClientTtl,
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
return new Response(JSON.stringify(client), {
|
|
164
|
-
status: 201,
|
|
165
|
-
headers: { "Content-Type": "application/json" },
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Render the OAuth authorize page (GET). */
|
|
170
|
-
export function handleAuthorizeGet(
|
|
171
|
-
url: URL,
|
|
172
|
-
templates: OAuthTemplates,
|
|
173
|
-
): Response {
|
|
174
|
-
const params: AuthPageParams = {
|
|
175
|
-
clientId: url.searchParams.get("client_id") ?? "",
|
|
176
|
-
redirectUri: url.searchParams.get("redirect_uri") ?? "",
|
|
177
|
-
codeChallenge: url.searchParams.get("code_challenge") ?? "",
|
|
178
|
-
codeChallengeMethod: url.searchParams.get("code_challenge_method") ?? "",
|
|
179
|
-
responseType: url.searchParams.get("response_type") ?? "",
|
|
180
|
-
state: url.searchParams.get("state") ?? "",
|
|
181
|
-
error: "",
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
return renderAuthPage(params, templates, {
|
|
185
|
-
loginAction: api.actions.actions.find((a: Action) => a.mcp?.isLoginAction),
|
|
186
|
-
signupAction: api.actions.actions.find(
|
|
187
|
-
(a: Action) => a.mcp?.isSignupAction,
|
|
188
|
-
),
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** Handle the OAuth authorize form POST (signin/signup). */
|
|
193
|
-
export async function handleAuthorizePost(
|
|
194
|
-
req: Request,
|
|
195
|
-
templates: OAuthTemplates,
|
|
196
|
-
): Promise<Response> {
|
|
197
|
-
let fields: Record<string, string>;
|
|
198
|
-
try {
|
|
199
|
-
const contentType = req.headers.get("content-type") ?? "";
|
|
200
|
-
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
201
|
-
const text = await req.text();
|
|
202
|
-
const params = new URLSearchParams(text);
|
|
203
|
-
fields = Object.fromEntries(params.entries());
|
|
204
|
-
} else {
|
|
205
|
-
const form = await req.formData();
|
|
206
|
-
fields = {};
|
|
207
|
-
form.forEach((value, key) => {
|
|
208
|
-
fields[key] = String(value);
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
} catch {
|
|
212
|
-
return new Response("Bad Request", { status: 400 });
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const mode = fields.mode ?? "";
|
|
216
|
-
const clientId = fields.client_id ?? "";
|
|
217
|
-
const redirectUri = fields.redirect_uri ?? "";
|
|
218
|
-
const codeChallenge = fields.code_challenge ?? "";
|
|
219
|
-
const codeChallengeMethod = fields.code_challenge_method ?? "";
|
|
220
|
-
const responseType = fields.response_type ?? "";
|
|
221
|
-
const state = fields.state ?? "";
|
|
222
|
-
|
|
223
|
-
const oauthParams: AuthPageParams = {
|
|
224
|
-
clientId,
|
|
225
|
-
redirectUri,
|
|
226
|
-
codeChallenge,
|
|
227
|
-
codeChallengeMethod,
|
|
228
|
-
responseType,
|
|
229
|
-
state,
|
|
230
|
-
error: "",
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
const authActions = {
|
|
234
|
-
loginAction: api.actions.actions.find((a: Action) => a.mcp?.isLoginAction),
|
|
235
|
-
signupAction: api.actions.actions.find(
|
|
236
|
-
(a: Action) => a.mcp?.isSignupAction,
|
|
237
|
-
),
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
// Validate client
|
|
241
|
-
const clientRaw = await api.redis.redis.get(`oauth:client:${clientId}`);
|
|
242
|
-
if (!clientRaw) {
|
|
243
|
-
oauthParams.error = "Unknown client";
|
|
244
|
-
return renderAuthPage(oauthParams, templates, authActions);
|
|
245
|
-
}
|
|
246
|
-
const client = JSON.parse(clientRaw) as OAuthClient;
|
|
247
|
-
|
|
248
|
-
const uriMatch = client.redirect_uris.some((registered) =>
|
|
249
|
-
redirectUrisMatch(registered, redirectUri),
|
|
250
|
-
);
|
|
251
|
-
if (!uriMatch) {
|
|
252
|
-
oauthParams.error = "Invalid redirect URI";
|
|
253
|
-
return renderAuthPage(oauthParams, templates, authActions);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
if (codeChallengeMethod !== "S256") {
|
|
257
|
-
oauthParams.error = "code_challenge_method must be S256";
|
|
258
|
-
return renderAuthPage(oauthParams, templates, authActions);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Build action params from all non-OAuth fields
|
|
262
|
-
const actionParams: Record<string, unknown> = {};
|
|
263
|
-
for (const [key, value] of Object.entries(fields)) {
|
|
264
|
-
if (!OAUTH_FIELDS.has(key)) {
|
|
265
|
-
actionParams[key] = value;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
let userId: number;
|
|
270
|
-
|
|
271
|
-
if (mode === "signup") {
|
|
272
|
-
const signupAction = api.actions.actions.find(
|
|
273
|
-
(a: Action) => a.mcp?.isSignupAction,
|
|
274
|
-
);
|
|
275
|
-
if (!signupAction) {
|
|
276
|
-
oauthParams.error = "No signup action configured";
|
|
277
|
-
return renderAuthPage(oauthParams, templates, authActions);
|
|
278
|
-
}
|
|
279
|
-
const connection = new Connection("oauth", "oauth-signup");
|
|
280
|
-
try {
|
|
281
|
-
const { response, error } = await connection.act(
|
|
282
|
-
signupAction.name,
|
|
283
|
-
actionParams,
|
|
284
|
-
);
|
|
285
|
-
if (error) {
|
|
286
|
-
oauthParams.error = error.message;
|
|
287
|
-
return renderAuthPage(oauthParams, templates, authActions);
|
|
288
|
-
}
|
|
289
|
-
userId = (response as OAuthActionResponse).user.id;
|
|
290
|
-
} finally {
|
|
291
|
-
connection.destroy();
|
|
292
|
-
}
|
|
293
|
-
} else {
|
|
294
|
-
const loginAction = api.actions.actions.find(
|
|
295
|
-
(a: Action) => a.mcp?.isLoginAction,
|
|
296
|
-
);
|
|
297
|
-
if (!loginAction) {
|
|
298
|
-
oauthParams.error = "No login action configured";
|
|
299
|
-
return renderAuthPage(oauthParams, templates, authActions);
|
|
300
|
-
}
|
|
301
|
-
const connection = new Connection("oauth", "oauth-login");
|
|
302
|
-
try {
|
|
303
|
-
const { response, error } = await connection.act(
|
|
304
|
-
loginAction.name,
|
|
305
|
-
actionParams,
|
|
306
|
-
);
|
|
307
|
-
if (error) {
|
|
308
|
-
oauthParams.error = error.message;
|
|
309
|
-
return renderAuthPage(oauthParams, templates, authActions);
|
|
310
|
-
}
|
|
311
|
-
userId = (response as OAuthActionResponse).user.id;
|
|
312
|
-
} finally {
|
|
313
|
-
connection.destroy();
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Generate auth code
|
|
318
|
-
const code = randomUUID();
|
|
319
|
-
const codeData: AuthCode = {
|
|
320
|
-
clientId,
|
|
321
|
-
userId,
|
|
322
|
-
codeChallenge,
|
|
323
|
-
redirectUri,
|
|
324
|
-
};
|
|
325
|
-
|
|
326
|
-
await api.redis.redis.set(
|
|
327
|
-
`oauth:code:${code}`,
|
|
328
|
-
JSON.stringify(codeData),
|
|
329
|
-
"EX",
|
|
330
|
-
config.server.mcp.oauthCodeTtl,
|
|
331
|
-
);
|
|
332
|
-
|
|
333
|
-
const redirectUrl = new URL(redirectUri);
|
|
334
|
-
redirectUrl.searchParams.set("code", code);
|
|
335
|
-
if (state) redirectUrl.searchParams.set("state", state);
|
|
336
|
-
|
|
337
|
-
return renderSuccessPage(redirectUrl.toString(), templates);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/** OAuth token exchange endpoint. */
|
|
341
|
-
export async function handleToken(req: Request): Promise<Response> {
|
|
342
|
-
let body: URLSearchParams;
|
|
343
|
-
const contentType = req.headers.get("content-type") ?? "";
|
|
344
|
-
|
|
345
|
-
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
346
|
-
const text = await req.text();
|
|
347
|
-
body = new URLSearchParams(text);
|
|
348
|
-
} else if (contentType.includes("application/json")) {
|
|
349
|
-
const json = await req.json();
|
|
350
|
-
body = new URLSearchParams(json as Record<string, string>);
|
|
351
|
-
} else {
|
|
352
|
-
// Try form-urlencoded as default
|
|
353
|
-
const text = await req.text();
|
|
354
|
-
body = new URLSearchParams(text);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const grantType = body.get("grant_type");
|
|
358
|
-
const code = body.get("code");
|
|
359
|
-
const codeVerifier = body.get("code_verifier");
|
|
360
|
-
const redirectUri = body.get("redirect_uri");
|
|
361
|
-
const clientId = body.get("client_id");
|
|
362
|
-
|
|
363
|
-
if (grantType !== "authorization_code") {
|
|
364
|
-
return new Response(JSON.stringify({ error: "unsupported_grant_type" }), {
|
|
365
|
-
status: 400,
|
|
366
|
-
headers: { "Content-Type": "application/json" },
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
if (!code || !codeVerifier) {
|
|
371
|
-
return new Response(
|
|
372
|
-
JSON.stringify({
|
|
373
|
-
error: "invalid_request",
|
|
374
|
-
error_description: "code and code_verifier are required",
|
|
375
|
-
}),
|
|
376
|
-
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
377
|
-
);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// Look up auth code
|
|
381
|
-
const codeRaw = await api.redis.redis.get(`oauth:code:${code}`);
|
|
382
|
-
if (!codeRaw) {
|
|
383
|
-
return new Response(
|
|
384
|
-
JSON.stringify({
|
|
385
|
-
error: "invalid_grant",
|
|
386
|
-
error_description: "Invalid or expired authorization code",
|
|
387
|
-
}),
|
|
388
|
-
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const codeData = JSON.parse(codeRaw) as AuthCode;
|
|
393
|
-
|
|
394
|
-
// Delete the code immediately (single use)
|
|
395
|
-
await api.redis.redis.del(`oauth:code:${code}`);
|
|
396
|
-
|
|
397
|
-
// Validate client_id matches
|
|
398
|
-
if (clientId !== codeData.clientId) {
|
|
399
|
-
return new Response(
|
|
400
|
-
JSON.stringify({
|
|
401
|
-
error: "invalid_grant",
|
|
402
|
-
error_description: "client_id mismatch",
|
|
403
|
-
}),
|
|
404
|
-
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
405
|
-
);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// Validate redirect_uri matches
|
|
409
|
-
if (redirectUri && redirectUri !== codeData.redirectUri) {
|
|
410
|
-
return new Response(
|
|
411
|
-
JSON.stringify({
|
|
412
|
-
error: "invalid_grant",
|
|
413
|
-
error_description: "redirect_uri mismatch",
|
|
414
|
-
}),
|
|
415
|
-
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Verify PKCE: BASE64URL(SHA256(code_verifier)) === stored code_challenge
|
|
420
|
-
const encoder = new TextEncoder();
|
|
421
|
-
const digest = await crypto.subtle.digest(
|
|
422
|
-
"SHA-256",
|
|
423
|
-
encoder.encode(codeVerifier),
|
|
424
|
-
);
|
|
425
|
-
const computedChallenge = base64UrlEncode(new Uint8Array(digest));
|
|
426
|
-
|
|
427
|
-
if (computedChallenge !== codeData.codeChallenge) {
|
|
428
|
-
return new Response(
|
|
429
|
-
JSON.stringify({
|
|
430
|
-
error: "invalid_grant",
|
|
431
|
-
error_description: "PKCE verification failed",
|
|
432
|
-
}),
|
|
433
|
-
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
434
|
-
);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Generate access token
|
|
438
|
-
const accessToken = randomUUID();
|
|
439
|
-
const tokenData: TokenData = {
|
|
440
|
-
userId: codeData.userId,
|
|
441
|
-
clientId: codeData.clientId,
|
|
442
|
-
scopes: [],
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
await api.redis.redis.set(
|
|
446
|
-
`oauth:token:${accessToken}`,
|
|
447
|
-
JSON.stringify(tokenData),
|
|
448
|
-
"EX",
|
|
449
|
-
config.session.ttl,
|
|
450
|
-
);
|
|
451
|
-
|
|
452
|
-
return new Response(
|
|
453
|
-
JSON.stringify({
|
|
454
|
-
access_token: accessToken,
|
|
455
|
-
token_type: "Bearer",
|
|
456
|
-
expires_in: config.session.ttl,
|
|
457
|
-
}),
|
|
458
|
-
{
|
|
459
|
-
status: 200,
|
|
460
|
-
headers: { "Content-Type": "application/json" },
|
|
461
|
-
},
|
|
462
|
-
);
|
|
463
|
-
}
|