keryx 0.0.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.
- package/LICENSE +21 -0
- package/actions/status.ts +25 -0
- package/actions/swagger.ts +170 -0
- package/api.ts +45 -0
- package/classes/API.ts +168 -0
- package/classes/Action.ts +128 -0
- package/classes/Channel.ts +81 -0
- package/classes/Connection.ts +282 -0
- package/classes/ExitCode.ts +4 -0
- package/classes/Initializer.ts +45 -0
- package/classes/Logger.ts +132 -0
- package/classes/Server.ts +16 -0
- package/classes/TypedError.ts +91 -0
- package/config/channels.ts +9 -0
- package/config/database.ts +6 -0
- package/config/index.ts +23 -0
- package/config/logger.ts +8 -0
- package/config/process.ts +9 -0
- package/config/rateLimit.ts +22 -0
- package/config/redis.ts +8 -0
- package/config/server/cli.ts +9 -0
- package/config/server/mcp.ts +11 -0
- package/config/server/web.ts +68 -0
- package/config/session.ts +18 -0
- package/config/tasks.ts +26 -0
- package/index.ts +29 -0
- package/initializers/actionts.ts +669 -0
- package/initializers/channels.ts +284 -0
- package/initializers/connections.ts +37 -0
- package/initializers/db.ts +158 -0
- package/initializers/mcp.ts +477 -0
- package/initializers/oauth.ts +610 -0
- package/initializers/process.ts +25 -0
- package/initializers/pubsub.ts +86 -0
- package/initializers/redis.ts +77 -0
- package/initializers/resque.ts +354 -0
- package/initializers/servers.ts +66 -0
- package/initializers/session.ts +84 -0
- package/initializers/signals.ts +60 -0
- package/initializers/swagger.ts +317 -0
- package/keryx.ts +61 -0
- package/lua/add-presence.lua +13 -0
- package/lua/refresh-presence.lua +8 -0
- package/lua/remove-presence.lua +16 -0
- package/middleware/rateLimit.ts +92 -0
- package/migrations.ts +5 -0
- package/package.json +97 -0
- package/servers/web.ts +721 -0
- package/templates/lion.svg +102 -0
- package/templates/oauth-authorize.html +75 -0
- package/templates/oauth-common.css +140 -0
- package/templates/oauth-success.html +38 -0
- package/tsconfig.json +24 -0
- package/util/cli.ts +135 -0
- package/util/config.ts +24 -0
- package/util/connectionString.ts +5 -0
- package/util/glob.ts +41 -0
- package/util/http.ts +86 -0
- package/util/oauth.ts +69 -0
- package/util/zodMixins.ts +88 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import Mustache from "mustache";
|
|
3
|
+
import { api } from "../api";
|
|
4
|
+
import type { OAuthActionResponse } from "../classes/Action";
|
|
5
|
+
import { Connection } from "../classes/Connection";
|
|
6
|
+
import { Initializer } from "../classes/Initializer";
|
|
7
|
+
import { config } from "../config";
|
|
8
|
+
import { checkRateLimit } from "../middleware/rateLimit";
|
|
9
|
+
import {
|
|
10
|
+
appendHeaders,
|
|
11
|
+
buildCorsHeaders,
|
|
12
|
+
getExternalOrigin,
|
|
13
|
+
} from "../util/http";
|
|
14
|
+
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;
|
|
26
|
+
|
|
27
|
+
const namespace = "oauth";
|
|
28
|
+
|
|
29
|
+
declare module "../classes/API" {
|
|
30
|
+
export interface API {
|
|
31
|
+
[namespace]: Awaited<ReturnType<OAuthInitializer["initialize"]>>;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
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
|
+
export class OAuthInitializer extends Initializer {
|
|
58
|
+
constructor() {
|
|
59
|
+
super(namespace);
|
|
60
|
+
this.loadPriority = 175;
|
|
61
|
+
this.startPriority = 175;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
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();
|
|
73
|
+
|
|
74
|
+
async function verifyAccessToken(token: string): Promise<TokenData | null> {
|
|
75
|
+
const raw = await api.redis.redis.get(`oauth:token:${token}`);
|
|
76
|
+
if (!raw) return null;
|
|
77
|
+
return JSON.parse(raw) as TokenData;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function handleRequest(
|
|
81
|
+
req: Request,
|
|
82
|
+
ip?: string,
|
|
83
|
+
): Promise<Response | null> {
|
|
84
|
+
const url = new URL(req.url);
|
|
85
|
+
const path = url.pathname;
|
|
86
|
+
const method = req.method.toUpperCase();
|
|
87
|
+
const origin = getExternalOrigin(req, url);
|
|
88
|
+
const requestOrigin = req.headers.get("origin") ?? undefined;
|
|
89
|
+
const corsHeaders = buildCorsHeaders(requestOrigin, {
|
|
90
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
91
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Handle CORS preflight for OAuth endpoints
|
|
95
|
+
if (
|
|
96
|
+
method === "OPTIONS" &&
|
|
97
|
+
(path.startsWith("/.well-known/oauth") || path.startsWith("/oauth/"))
|
|
98
|
+
) {
|
|
99
|
+
return new Response(null, { status: 204, headers: corsHeaders });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const prmPrefix = "/.well-known/oauth-protected-resource";
|
|
103
|
+
if (path.startsWith(prmPrefix) && method === "GET") {
|
|
104
|
+
const resourcePath = path.slice(prmPrefix.length) || "";
|
|
105
|
+
return appendHeaders(
|
|
106
|
+
handleProtectedResourceMetadata(origin, resourcePath),
|
|
107
|
+
corsHeaders,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
path === "/.well-known/oauth-authorization-server" &&
|
|
112
|
+
method === "GET"
|
|
113
|
+
) {
|
|
114
|
+
return appendHeaders(handleMetadata(origin), corsHeaders);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Rate-limit mutable OAuth endpoints by IP
|
|
118
|
+
if (
|
|
119
|
+
config.rateLimit.enabled &&
|
|
120
|
+
ip &&
|
|
121
|
+
(path === "/oauth/register" ||
|
|
122
|
+
path === "/oauth/authorize" ||
|
|
123
|
+
path === "/oauth/token")
|
|
124
|
+
) {
|
|
125
|
+
// /oauth/register gets a stricter, dedicated rate limit
|
|
126
|
+
const overrides =
|
|
127
|
+
path === "/oauth/register"
|
|
128
|
+
? {
|
|
129
|
+
limit: config.rateLimit.oauthRegisterLimit,
|
|
130
|
+
windowMs: config.rateLimit.oauthRegisterWindowMs,
|
|
131
|
+
keyPrefix: `${config.rateLimit.keyPrefix}:oauth-register`,
|
|
132
|
+
}
|
|
133
|
+
: undefined;
|
|
134
|
+
const info = await checkRateLimit(`ip:${ip}`, false, overrides);
|
|
135
|
+
if (info.retryAfter !== undefined) {
|
|
136
|
+
return new Response(
|
|
137
|
+
JSON.stringify({
|
|
138
|
+
error: "rate_limit_exceeded",
|
|
139
|
+
error_description: `Rate limit exceeded. Try again in ${info.retryAfter} seconds.`,
|
|
140
|
+
}),
|
|
141
|
+
{
|
|
142
|
+
status: 429,
|
|
143
|
+
headers: {
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
"Retry-After": String(info.retryAfter),
|
|
146
|
+
"X-RateLimit-Limit": String(info.limit),
|
|
147
|
+
"X-RateLimit-Remaining": "0",
|
|
148
|
+
"X-RateLimit-Reset": String(info.resetAt),
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (path === "/oauth/register" && method === "POST") {
|
|
156
|
+
return appendHeaders(await handleRegister(req), corsHeaders);
|
|
157
|
+
}
|
|
158
|
+
if (path === "/oauth/authorize" && method === "GET") {
|
|
159
|
+
return handleAuthorizeGet(url);
|
|
160
|
+
}
|
|
161
|
+
if (path === "/oauth/authorize" && method === "POST") {
|
|
162
|
+
return handleAuthorizePost(req);
|
|
163
|
+
}
|
|
164
|
+
if (path === "/oauth/token" && method === "POST") {
|
|
165
|
+
return appendHeaders(await handleToken(req), corsHeaders);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
handleRequest,
|
|
173
|
+
verifyAccessToken,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { logger } from "../api";
|
|
2
|
+
import { Initializer } from "../classes/Initializer";
|
|
3
|
+
import { config } from "../config";
|
|
4
|
+
|
|
5
|
+
const namespace = "process";
|
|
6
|
+
|
|
7
|
+
declare module "../classes/API" {
|
|
8
|
+
export interface API {
|
|
9
|
+
[namespace]: Awaited<ReturnType<Process["initialize"]>>;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class Process extends Initializer {
|
|
14
|
+
constructor() {
|
|
15
|
+
super(namespace);
|
|
16
|
+
this.loadPriority = 2;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async initialize() {
|
|
20
|
+
const name = config.process.name;
|
|
21
|
+
const pid = process.pid;
|
|
22
|
+
logger.info(`Initializing process: ${name}, pid: ${pid}`);
|
|
23
|
+
return { name, pid };
|
|
24
|
+
}
|
|
25
|
+
}
|