corent-mcp 0.3.1 → 0.4.2

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/dist/oauth.js ADDED
@@ -0,0 +1,678 @@
1
+ /**
2
+ * OAuth 2.1 authorization server for the hosted Corent MCP endpoint.
3
+ *
4
+ * Why this exists: claude.ai (and other remote MCP clients) authenticate
5
+ * connectors via OAuth — the user clicks "Connect", approves on our page, and
6
+ * the client holds a token. Without this, users must paste API keys into
7
+ * headers/URLs, which claude.ai custom connectors barely support.
8
+ *
9
+ * Deliberately STATELESS — no database, no session store:
10
+ * - client_id = signed blob embedding the registered redirect_uris
11
+ * - authorization code = AES-256-GCM blob {api key, PKCE challenge, redirect_uri}, 5 min TTL
12
+ * - access/refresh token = AES-256-GCM blob {api key}, 30/180 day TTL
13
+ * The user's Corent API key is the only credential; it rides encrypted inside
14
+ * the tokens. Revoking the key in the Corent dashboard instantly invalidates
15
+ * every token minted from it — that IS the revocation story.
16
+ *
17
+ * The consent page asks the user to paste their Corent API key once; we
18
+ * validate it against the live API before issuing a code. The key never
19
+ * appears in a URL and is never logged.
20
+ *
21
+ * Secret: MCP_TOKEN_SECRET env var. Rotating it invalidates all outstanding
22
+ * tokens (users just re-connect).
23
+ */
24
+ import crypto from "node:crypto";
25
+ import { CLASH_BOLD_WOFF2, LOGO_URL, HERO_POSTER_URL, HERO_VIDEO_URL } from "./brand.js";
26
+ const ACCESS_TTL_S = 30 * 24 * 3600; // 30 days
27
+ const REFRESH_TTL_S = 180 * 24 * 3600; // 180 days
28
+ const CODE_TTL_S = 300; // 5 minutes
29
+ // Token prefixes distinguish our minted tokens from raw co_live_ API keys.
30
+ const P_ACCESS = "coa_";
31
+ const P_REFRESH = "cor_";
32
+ const P_CODE = "coc_";
33
+ const P_CLIENT = "ccl_";
34
+ function getSecretKey() {
35
+ const secret = process.env.MCP_TOKEN_SECRET;
36
+ if (!secret || secret.length < 16)
37
+ return null;
38
+ return crypto.createHash("sha256").update(secret).digest();
39
+ }
40
+ function b64url(buf) {
41
+ return buf.toString("base64url");
42
+ }
43
+ function encrypt(key, payload) {
44
+ const iv = crypto.randomBytes(12);
45
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
46
+ const ct = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
47
+ return b64url(Buffer.concat([iv, cipher.getAuthTag(), ct]));
48
+ }
49
+ function decrypt(key, token) {
50
+ try {
51
+ const raw = Buffer.from(token, "base64url");
52
+ if (raw.length < 29)
53
+ return null;
54
+ const iv = raw.subarray(0, 12);
55
+ const tag = raw.subarray(12, 28);
56
+ const ct = raw.subarray(28);
57
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
58
+ decipher.setAuthTag(tag);
59
+ const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
60
+ return JSON.parse(pt.toString("utf8"));
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function now() {
67
+ return Math.floor(Date.now() / 1000);
68
+ }
69
+ // ---------------------------------------------------------------------------
70
+ // Client registration (RFC 7591, stateless)
71
+ // ---------------------------------------------------------------------------
72
+ function signClientId(key, redirectUris) {
73
+ const blob = b64url(Buffer.from(JSON.stringify({ ru: redirectUris, iat: now() }), "utf8"));
74
+ const mac = b64url(crypto.createHmac("sha256", key).update(blob).digest()).slice(0, 22);
75
+ return `${P_CLIENT}${blob}.${mac}`;
76
+ }
77
+ /** Returns the registered redirect_uris if the client_id is authentic, else null. */
78
+ function verifyClientId(key, clientId) {
79
+ if (!clientId.startsWith(P_CLIENT))
80
+ return null;
81
+ const body = clientId.slice(P_CLIENT.length);
82
+ const dot = body.lastIndexOf(".");
83
+ if (dot < 0)
84
+ return null;
85
+ const blob = body.slice(0, dot);
86
+ const mac = body.slice(dot + 1);
87
+ const expected = b64url(crypto.createHmac("sha256", key).update(blob).digest()).slice(0, 22);
88
+ if (mac.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected)))
89
+ return null;
90
+ try {
91
+ const parsed = JSON.parse(Buffer.from(blob, "base64url").toString("utf8"));
92
+ if (Array.isArray(parsed.ru) && parsed.ru.every((u) => typeof u === "string"))
93
+ return parsed.ru;
94
+ }
95
+ catch {
96
+ /* fall through */
97
+ }
98
+ return null;
99
+ }
100
+ function validRedirectUri(uri) {
101
+ try {
102
+ const u = new URL(uri);
103
+ if (u.protocol === "https:")
104
+ return true;
105
+ // Loopback redirects are allowed for local/dev MCP clients (OAuth 2.1 §8.4.2).
106
+ return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1");
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // Token verification (used by http.ts to resolve Bearer tokens to API keys)
114
+ // ---------------------------------------------------------------------------
115
+ export function isOauthToken(bearer) {
116
+ return bearer.startsWith(P_ACCESS) || bearer.startsWith(P_REFRESH) || bearer.startsWith(P_CODE);
117
+ }
118
+ /** Resolve a coa_ access token to the Corent API key inside it, or null. */
119
+ export function apiKeyFromAccessToken(bearer) {
120
+ const key = getSecretKey();
121
+ if (!key || !bearer.startsWith(P_ACCESS))
122
+ return null;
123
+ const tok = decrypt(key, bearer.slice(P_ACCESS.length));
124
+ if (!tok || tok.t !== "access" || typeof tok.k !== "string")
125
+ return null;
126
+ if (typeof tok.exp !== "number" || tok.exp < now())
127
+ return null;
128
+ return tok.k;
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // HTTP helpers
132
+ // ---------------------------------------------------------------------------
133
+ function json(res, status, body, headers = {}) {
134
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
135
+ res.end(JSON.stringify(body));
136
+ }
137
+ // Outcome-only diagnostics: never log keys, codes, tokens, or query strings.
138
+ function olog(msg) {
139
+ console.error(`[oauth] ${msg}`);
140
+ }
141
+ // Hardening headers for the credential-bearing pages: no framing (clickjack
142
+ // overlay on the login form), no referrer leakage, no MIME sniffing.
143
+ const PAGE_HEADERS = {
144
+ "X-Frame-Options": "DENY",
145
+ "Content-Security-Policy": "frame-ancestors 'none'",
146
+ "X-Content-Type-Options": "nosniff",
147
+ "Referrer-Policy": "no-referrer",
148
+ "Cache-Control": "no-store",
149
+ };
150
+ function html(res, status, body) {
151
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", ...PAGE_HEADERS });
152
+ res.end(body);
153
+ }
154
+ // Per-IP attempt limiter (in-memory, per machine). Purpose: stop password
155
+ // brute-force through /oauth/approve, and keep one abuser from getting this
156
+ // server's egress IP rate-limited at Supabase, which would break sign-in for
157
+ // everyone. Fixed one-minute windows are plenty here.
158
+ const RATE_WINDOW_MS = 60_000;
159
+ const rateBuckets = new Map();
160
+ function rateLimited(ip, scope, max) {
161
+ const nowMs = Date.now();
162
+ if (rateBuckets.size > 10_000) {
163
+ for (const [k, v] of rateBuckets)
164
+ if (v.reset < nowMs)
165
+ rateBuckets.delete(k);
166
+ }
167
+ const bucketKey = `${scope}:${ip}`;
168
+ const b = rateBuckets.get(bucketKey);
169
+ if (!b || b.reset < nowMs) {
170
+ rateBuckets.set(bucketKey, { n: 1, reset: nowMs + RATE_WINDOW_MS });
171
+ return false;
172
+ }
173
+ b.n += 1;
174
+ return b.n > max;
175
+ }
176
+ function clientIp(req) {
177
+ const fly = req.headers["fly-client-ip"];
178
+ if (typeof fly === "string" && fly)
179
+ return fly;
180
+ const fwd = req.headers["x-forwarded-for"];
181
+ if (typeof fwd === "string" && fwd)
182
+ return fwd.split(",")[0].trim();
183
+ return req.socket.remoteAddress ?? "unknown";
184
+ }
185
+ function oauthError(res, status, error, description) {
186
+ olog(`error ${status} ${error}: ${description}`);
187
+ json(res, status, { error, error_description: description });
188
+ }
189
+ function readRawBody(req, limit = 100_000) {
190
+ return new Promise((resolve, reject) => {
191
+ let data = "";
192
+ req.on("data", (chunk) => {
193
+ data += chunk;
194
+ if (data.length > limit)
195
+ reject(new Error("body too large"));
196
+ });
197
+ req.on("end", () => resolve(data));
198
+ req.on("error", reject);
199
+ });
200
+ }
201
+ /** Parse a request body as form-encoded or JSON into a flat string map. */
202
+ async function readParams(req) {
203
+ const raw = await readRawBody(req);
204
+ const type = String(req.headers["content-type"] ?? "");
205
+ const out = {};
206
+ if (type.includes("application/json")) {
207
+ try {
208
+ const parsed = JSON.parse(raw);
209
+ for (const [k, v] of Object.entries(parsed))
210
+ if (typeof v === "string")
211
+ out[k] = v;
212
+ }
213
+ catch {
214
+ /* empty */
215
+ }
216
+ }
217
+ else {
218
+ for (const [k, v] of new URLSearchParams(raw))
219
+ out[k] = v;
220
+ }
221
+ return out;
222
+ }
223
+ function escapeHtml(s) {
224
+ return s
225
+ .replace(/&/g, "&amp;")
226
+ .replace(/</g, "&lt;")
227
+ .replace(/>/g, "&gt;")
228
+ .replace(/"/g, "&quot;")
229
+ .replace(/'/g, "&#39;");
230
+ }
231
+ // ---------------------------------------------------------------------------
232
+ // Consent page
233
+ // ---------------------------------------------------------------------------
234
+ // Mirrors the corent.tech auth pages (frontend/src/components/auth-shell.tsx):
235
+ // dark split-screen, mono eyebrow, Clash Display heading with the blue period,
236
+ // white/opacity form fields, and the angled white submit button. Clash Display
237
+ // Bold is base64-embedded; logo + hero loop hotlink to the live site so the
238
+ // page stays in sync with the brand.
239
+ const SHARED_CSS = `
240
+ :root { color-scheme: dark; }
241
+ @font-face { font-family: "Clash Display"; font-weight: 700; font-style: normal; font-display: swap;
242
+ src: url(data:font/woff2;base64,${CLASH_BOLD_WOFF2}) format("woff2"); }
243
+ * { box-sizing: border-box; margin: 0; }
244
+ body { font-family: "Geist", -apple-system, "Segoe UI", Roboto, sans-serif; background: #0A0A0A; color: #fff;
245
+ min-height: 100vh; display: flex; }
246
+ .col { position: relative; display: flex; flex-direction: column; width: 100%;
247
+ padding: 32px 40px; }
248
+ header { display: flex; align-items: center; justify-content: space-between; }
249
+ header img { height: 22px; width: auto; display: block; }
250
+ .back { font-size: 13px; color: rgba(255,255,255,0.5); text-decoration: none; transition: color .2s; }
251
+ .back:hover { color: #fff; }
252
+ .middle { display: flex; flex: 1; align-items: center; }
253
+ .inner { width: 100%; max-width: 400px; }
254
+ .eyebrow { display: flex; align-items: center; gap: 10px; font-family: "Geist Mono", ui-monospace, monospace;
255
+ font-size: 12px; letter-spacing: 0.18em; text-transform: uppercase; }
256
+ .eyebrow .a { color: rgba(255,255,255,0.7); } .eyebrow .s { color: #8FB5FF; } .eyebrow .b { color: rgba(255,255,255,0.45); }
257
+ h1 { font-family: "Clash Display", "Geist", sans-serif; font-weight: 700; text-transform: uppercase;
258
+ font-size: clamp(2.2rem, 6vw, 52px); line-height: 0.95; letter-spacing: -0.02em; margin-top: 16px; }
259
+ h1 .dot { color: #9CC8FF; }
260
+ .sub { margin-top: 16px; font-size: 15px; line-height: 1.7; font-weight: 300; color: rgba(255,255,255,0.55); }
261
+ .sub strong { color: rgba(255,255,255,0.8); font-weight: 500; }
262
+ label { display: block; font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.7); margin: 28px 0 8px; }
263
+ input[type="password"], input[type="email"] { height: 44px; width: 100%; border-radius: 8px; border: 1px solid rgba(255,255,255,0.12);
264
+ background: rgba(255,255,255,0.06); padding: 0 14px; font-size: 15px; color: #fff;
265
+ font-family: "Geist Mono", ui-monospace, monospace; outline: none; transition: border-color .2s, background .2s; }
266
+ input[type="password"], input[type="email"]::placeholder { color: rgba(255,255,255,0.25); }
267
+ input[type="password"], input[type="email"]:focus { border-color: rgba(255,255,255,0.35); background: rgba(255,255,255,0.08); }
268
+ .hint { margin-top: 10px; font-size: 12.5px; line-height: 1.6; color: rgba(255,255,255,0.45); }
269
+ .hint a { color: #8FB5FF; text-decoration: none; }
270
+ .hint a:hover { text-decoration: underline; }
271
+ .error { margin-top: 16px; padding: 11px 14px; border-radius: 8px; border: 1px solid rgba(217,105,95,0.4);
272
+ background: rgba(217,105,95,0.12); color: #E8A9A2; font-size: 13px; line-height: 1.5; }
273
+ .actions { display: flex; align-items: center; gap: 22px; margin-top: 28px; }
274
+ .connect { display: inline-flex; height: 48px; align-items: center; justify-content: center; border: none;
275
+ background: #fff; color: #000; padding: 0 24px 0 28px; font-family: inherit; font-size: 16px;
276
+ font-weight: 700; font-style: italic; letter-spacing: -0.01em; cursor: pointer;
277
+ clip-path: polygon(1rem 0, 100% 0, 100% 100%, 1rem 100%, 0 50%);
278
+ filter: drop-shadow(0 8px 22px rgba(0,0,0,0.4)); transition: transform .2s; }
279
+ .connect:hover { transform: translateY(-1px); }
280
+ .connect:active { transform: translateY(0) scale(0.98); }
281
+ .cancel { background: none; border: none; padding: 0; font-family: inherit; font-size: 14px;
282
+ color: rgba(255,255,255,0.5); cursor: pointer; transition: color .2s; }
283
+ .cancel:hover { color: #fff; }
284
+ .fine { margin-top: 26px; font-size: 12px; line-height: 1.6; color: rgba(255,255,255,0.35); }
285
+ .panel { display: none; }
286
+ @media (min-width: 1024px) {
287
+ .col { width: 46%; max-width: 640px; flex-shrink: 0; padding: 32px 48px; }
288
+ .panel { display: block; position: relative; flex: 1; overflow: hidden; }
289
+ .panel video { position: absolute; inset: 0; height: 100%; width: 100%; object-fit: cover; object-position: right; }
290
+ .panel .melt { pointer-events: none; position: absolute; inset: 0;
291
+ background: linear-gradient(to right, #0A0A0A 0%, rgba(10,10,10,0.35) 18%, transparent 40%); }
292
+ }`;
293
+ // No third-party font hosts: users type credentials on this page, so their IP
294
+ // must not be disclosed to anyone (Google Fonts on a login page is a GDPR
295
+ // finding). Clash Display is embedded; body text uses the system stack.
296
+ const FONT_LINKS = "";
297
+ const BRAND_PANEL = `<div class="panel" aria-hidden="true">
298
+ <video autoplay muted loop playsinline poster="${HERO_POSTER_URL}"><source src="${HERO_VIDEO_URL}" type="video/mp4"></video>
299
+ <div class="melt"></div>
300
+ </div>`;
301
+ export function loginEnabled(cfg) {
302
+ return Boolean(cfg.supabaseUrl && cfg.supabaseAnonKey);
303
+ }
304
+ function consentPage(params, errorMsg, withLogin = true) {
305
+ const hidden = ["client_id", "redirect_uri", "state", "code_challenge", "code_challenge_method"]
306
+ .filter((k) => params[k])
307
+ .map((k) => `<input type="hidden" name="${k}" value="${escapeHtml(params[k])}">`)
308
+ .join("\n ");
309
+ const err = errorMsg ? `<p class="error">${escapeHtml(errorMsg)}</p>` : "";
310
+ // Two ways in: sign-in (default, key minted server-side) or key-paste
311
+ // (fallback — also the only path for Google-login accounts, which have no
312
+ // password to check). A small inline script swaps which block is active.
313
+ const loginBlock = `
314
+ <div id="mode-login"${withLogin ? "" : " hidden"}>
315
+ <label for="email">Email</label>
316
+ <input type="email" id="email" name="email" placeholder="you@company.com" autocomplete="email">
317
+ <label for="password">Password</label>
318
+ <input type="password" id="password" name="password" autocomplete="current-password">
319
+ <p class="hint">Sign in with your Corent account. A connection key is created for you automatically — you never handle it. <a href="#" id="to-key">Prefer to paste an API key?</a></p>
320
+ </div>`;
321
+ const keyBlock = `
322
+ <div id="mode-key"${withLogin ? " hidden" : ""}>
323
+ <label for="key">Your Corent API key</label>
324
+ <input type="password" id="key" name="corent_api_key" placeholder="co_live_..." autocomplete="off">
325
+ <p class="hint">Find or create a key at <a href="https://corent.tech/dashboard/api-keys" target="_blank" rel="noopener">corent.tech &rarr; Dashboard &rarr; API keys</a>.${withLogin ? ' <a href="#" id="to-login">Sign in instead?</a>' : ""}</p>
326
+ </div>`;
327
+ const toggleScript = withLogin
328
+ ? `<script>
329
+ (function () {
330
+ var l = document.getElementById("mode-login"), k = document.getElementById("mode-key");
331
+ function swap(showKey) {
332
+ l.hidden = showKey; k.hidden = !showKey;
333
+ // Only the visible path's fields should submit values.
334
+ l.querySelectorAll("input").forEach(function (i) { if (showKey) i.value = ""; });
335
+ k.querySelectorAll("input").forEach(function (i) { if (!showKey) i.value = ""; });
336
+ }
337
+ document.getElementById("to-key").addEventListener("click", function (e) { e.preventDefault(); swap(true); });
338
+ var back = document.getElementById("to-login");
339
+ if (back) back.addEventListener("click", function (e) { e.preventDefault(); swap(false); });
340
+ })();
341
+ </script>`
342
+ : "";
343
+ return `<!doctype html>
344
+ <html lang="en">
345
+ <head>
346
+ <meta charset="utf-8">
347
+ <meta name="viewport" content="width=device-width, initial-scale=1">
348
+ <meta name="robots" content="noindex">
349
+ <title>Connect to Corent</title>
350
+ <link rel="icon" type="image/png" href="https://corent.tech/brand/intro-mark.png">
351
+ ${FONT_LINKS}
352
+ <style>${SHARED_CSS}</style>
353
+ </head>
354
+ <body>
355
+ <div class="col">
356
+ <header>
357
+ <a href="https://corent.tech" aria-label="Corent home"><img src="${LOGO_URL}" alt="Corent"></a>
358
+ <a class="back" href="https://corent.tech">&larr; Back to site</a>
359
+ </header>
360
+ <div class="middle">
361
+ <div class="inner">
362
+ <p class="eyebrow"><span class="a">Authorize</span><span class="s">/</span><span class="b">MCP connector</span></p>
363
+ <h1>Connect your account<span class="dot">.</span></h1>
364
+ <p class="sub">An application is asking to use your Corent account. It will be able to <strong>generate images and videos</strong> billed to your balance, and check balance and job status. It cannot change your account settings.</p>
365
+ ${err}
366
+ <form method="POST" action="/oauth/approve">
367
+ ${hidden}${loginBlock}${keyBlock}
368
+ <div class="actions">
369
+ <button type="submit" name="decision" value="approve" class="connect">Connect</button>
370
+ <button type="submit" name="decision" value="deny" class="cancel" formnovalidate>Cancel</button>
371
+ </div>
372
+ </form>
373
+ <p class="fine">New connections can spend at most $10/day until you raise the limit in your dashboard. Disconnecting: revoke the connection&rsquo;s API key in your dashboard at any time.</p>
374
+ </div>
375
+ </div>
376
+ </div>
377
+ ${BRAND_PANEL}
378
+ ${toggleScript}
379
+ </body>
380
+ </html>`;
381
+ }
382
+ function errorPage(message) {
383
+ return `<!doctype html>
384
+ <html lang="en">
385
+ <head>
386
+ <meta charset="utf-8">
387
+ <meta name="viewport" content="width=device-width, initial-scale=1">
388
+ <meta name="robots" content="noindex">
389
+ <title>Corent — can’t continue</title>
390
+ ${FONT_LINKS}
391
+ <style>${SHARED_CSS}
392
+ .center { display: flex; flex: 1; align-items: center; }
393
+ </style>
394
+ </head>
395
+ <body>
396
+ <div class="col">
397
+ <header>
398
+ <a href="https://corent.tech" aria-label="Corent home"><img src="${LOGO_URL}" alt="Corent"></a>
399
+ <a class="back" href="https://corent.tech">&larr; Back to site</a>
400
+ </header>
401
+ <div class="middle">
402
+ <div class="inner">
403
+ <p class="eyebrow"><span class="a">Authorize</span><span class="s">/</span><span class="b">Error</span></p>
404
+ <h1>Can&rsquo;t continue<span class="dot">.</span></h1>
405
+ <p class="sub">${escapeHtml(message)}</p>
406
+ </div>
407
+ </div>
408
+ </div>
409
+ ${BRAND_PANEL}
410
+ </body>
411
+ </html>`;
412
+ }
413
+ /**
414
+ * Sign-in path: verify the user's Corent credentials against Supabase, then
415
+ * mint a fresh scoped API key via the backend using the session JWT. The user
416
+ * never sees or handles the key. Returns the key, or a user-facing error line.
417
+ * Fresh keys get the backend's safe-by-default $10/day cap.
418
+ */
419
+ async function loginAndMintKey(cfg, email, password) {
420
+ try {
421
+ const login = await fetch(`${cfg.supabaseUrl}/auth/v1/token?grant_type=password`, {
422
+ method: "POST",
423
+ headers: { apikey: cfg.supabaseAnonKey, "Content-Type": "application/json" },
424
+ body: JSON.stringify({ email, password }),
425
+ signal: AbortSignal.timeout(10_000),
426
+ });
427
+ if (!login.ok) {
428
+ olog("approve: sign-in rejected by Supabase");
429
+ return {
430
+ error: "That email/password wasn’t accepted. If you signed up with Google, use “paste an API key instead” below.",
431
+ };
432
+ }
433
+ const jwt = (await login.json())?.access_token;
434
+ if (typeof jwt !== "string" || !jwt)
435
+ return { error: "Sign-in failed. Please try again." };
436
+ const mint = await fetch(`${cfg.apiUrl}/v1/account/api-keys`, {
437
+ method: "POST",
438
+ headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
439
+ body: JSON.stringify({ name: `Claude connector (${new Date().toISOString().slice(0, 10)})` }),
440
+ signal: AbortSignal.timeout(10_000),
441
+ });
442
+ if (!mint.ok) {
443
+ olog(`approve: key mint failed (${mint.status})`);
444
+ return { error: "Signed in, but creating the connection key failed. Please try again." };
445
+ }
446
+ const apiKey = (await mint.json())?.api_key;
447
+ if (typeof apiKey !== "string" || !apiKey)
448
+ return { error: "Key creation returned no key. Please try again." };
449
+ olog("approve: signed in, connection key minted");
450
+ return { apiKey };
451
+ }
452
+ catch {
453
+ return { error: "Could not reach the Corent account service. Please try again." };
454
+ }
455
+ }
456
+ /**
457
+ * Handle OAuth-related routes. Returns true if the request was handled.
458
+ * Wire this into the HTTP server BEFORE the /mcp route.
459
+ */
460
+ export async function handleOauth(req, res, url, cfg) {
461
+ const path = url.pathname;
462
+ // --- Discovery metadata (RFC 8414 + RFC 9728). Some clients append the
463
+ // resource path ("/mcp") to the well-known URL, so serve both forms.
464
+ if (req.method === "GET" &&
465
+ (path === "/.well-known/oauth-authorization-server" || path === "/.well-known/oauth-authorization-server/mcp")) {
466
+ json(res, 200, {
467
+ issuer: cfg.publicUrl,
468
+ authorization_endpoint: `${cfg.publicUrl}/authorize`,
469
+ token_endpoint: `${cfg.publicUrl}/token`,
470
+ registration_endpoint: `${cfg.publicUrl}/register`,
471
+ response_types_supported: ["code"],
472
+ grant_types_supported: ["authorization_code", "refresh_token"],
473
+ code_challenge_methods_supported: ["S256"],
474
+ token_endpoint_auth_methods_supported: ["none"],
475
+ scopes_supported: ["corent"],
476
+ });
477
+ return true;
478
+ }
479
+ if (req.method === "GET" &&
480
+ (path === "/.well-known/oauth-protected-resource" || path === "/.well-known/oauth-protected-resource/mcp")) {
481
+ json(res, 200, {
482
+ resource: `${cfg.publicUrl}/mcp`,
483
+ authorization_servers: [cfg.publicUrl],
484
+ bearer_methods_supported: ["header"],
485
+ scopes_supported: ["corent"],
486
+ });
487
+ return true;
488
+ }
489
+ if (!["/register", "/authorize", "/oauth/approve", "/token"].includes(path))
490
+ return false;
491
+ console.error(`[oauth] ${req.method} ${path}`);
492
+ const key = getSecretKey();
493
+ if (!key) {
494
+ // Deployed without MCP_TOKEN_SECRET — OAuth can't mint anything safely.
495
+ json(res, 503, { error: "temporarily_unavailable", error_description: "OAuth is not configured on this server." });
496
+ return true;
497
+ }
498
+ // --- Dynamic client registration ---
499
+ if (path === "/register" && req.method === "POST") {
500
+ let body;
501
+ try {
502
+ body = JSON.parse(await readRawBody(req));
503
+ }
504
+ catch {
505
+ return oauthError(res, 400, "invalid_client_metadata", "Body must be JSON."), true;
506
+ }
507
+ const uris = body?.redirect_uris;
508
+ if (!Array.isArray(uris) || uris.length === 0 || !uris.every((u) => typeof u === "string" && validRedirectUri(u))) {
509
+ return (oauthError(res, 400, "invalid_redirect_uri", "redirect_uris must be https (or http://localhost) URLs."), true);
510
+ }
511
+ olog(`register: client registered for ${uris.map((u) => new URL(u).origin).join(",")}`);
512
+ json(res, 201, {
513
+ client_id: signClientId(key, uris),
514
+ redirect_uris: uris,
515
+ token_endpoint_auth_method: "none",
516
+ grant_types: ["authorization_code", "refresh_token"],
517
+ response_types: ["code"],
518
+ client_id_issued_at: now(),
519
+ client_name: typeof body?.client_name === "string" ? body.client_name : undefined,
520
+ });
521
+ return true;
522
+ }
523
+ // --- Authorization endpoint: render the consent page ---
524
+ if (path === "/authorize" && req.method === "GET") {
525
+ const q = Object.fromEntries(url.searchParams);
526
+ const registered = q.client_id ? verifyClientId(key, q.client_id) : null;
527
+ // Per OAuth 2.1: with an unverifiable client or redirect_uri we must show an
528
+ // error page, never redirect.
529
+ if (!registered) {
530
+ html(res, 400, errorPage("Unknown client. The application must register with this server first."));
531
+ return true;
532
+ }
533
+ if (!q.redirect_uri || !registered.includes(q.redirect_uri)) {
534
+ html(res, 400, errorPage("The redirect address does not match what the application registered."));
535
+ return true;
536
+ }
537
+ const redirectErr = (code, desc) => {
538
+ const to = new URL(q.redirect_uri);
539
+ to.searchParams.set("error", code);
540
+ to.searchParams.set("error_description", desc);
541
+ if (q.state)
542
+ to.searchParams.set("state", q.state);
543
+ res.writeHead(302, { Location: to.toString() });
544
+ res.end();
545
+ };
546
+ if (q.response_type !== "code")
547
+ return redirectErr("unsupported_response_type", "Only 'code' is supported."), true;
548
+ if (!q.code_challenge || q.code_challenge_method !== "S256")
549
+ return redirectErr("invalid_request", "PKCE with S256 is required."), true;
550
+ olog(`authorize: consent page rendered for ${new URL(q.redirect_uri).origin}`);
551
+ html(res, 200, consentPage(q, undefined, loginEnabled(cfg)));
552
+ return true;
553
+ }
554
+ // --- Consent form submission: validate the pasted key, mint a code ---
555
+ if (path === "/oauth/approve" && req.method === "POST") {
556
+ if (rateLimited(clientIp(req), "approve", 10)) {
557
+ olog("approve: rate limited");
558
+ html(res, 429, errorPage("Too many attempts. Wait a minute and try again."));
559
+ return true;
560
+ }
561
+ const p = await readParams(req);
562
+ // Re-validate everything server-side — hidden form fields are caller-controlled.
563
+ const registered = p.client_id ? verifyClientId(key, p.client_id) : null;
564
+ if (!registered || !p.redirect_uri || !registered.includes(p.redirect_uri)) {
565
+ html(res, 400, errorPage("This approval request is invalid or expired. Please retry from the application."));
566
+ return true;
567
+ }
568
+ const redirect = (params) => {
569
+ const to = new URL(p.redirect_uri);
570
+ for (const [k, v] of Object.entries(params))
571
+ to.searchParams.set(k, v);
572
+ if (p.state)
573
+ to.searchParams.set("state", p.state);
574
+ res.writeHead(302, { Location: to.toString() });
575
+ res.end();
576
+ };
577
+ if (p.decision !== "approve") {
578
+ olog("approve: user cancelled");
579
+ return redirect({ error: "access_denied", error_description: "The user cancelled." }), true;
580
+ }
581
+ if (!p.code_challenge) {
582
+ return redirect({ error: "invalid_request", error_description: "PKCE challenge missing." }), true;
583
+ }
584
+ const rerender = (msg) => html(res, 200, consentPage(p, msg, loginEnabled(cfg)));
585
+ let apiKey = (p.corent_api_key ?? "").trim();
586
+ if (apiKey) {
587
+ // Key-paste path: verify against the live API before minting anything.
588
+ let valid = false;
589
+ try {
590
+ const check = await fetch(`${cfg.apiUrl}/v1/account/balance`, {
591
+ headers: { Authorization: `Bearer ${apiKey}` },
592
+ signal: AbortSignal.timeout(10_000),
593
+ });
594
+ valid = check.ok;
595
+ }
596
+ catch {
597
+ valid = false;
598
+ }
599
+ if (!valid) {
600
+ olog("approve: key rejected by API, re-rendering consent");
601
+ return rerender("That API key was not accepted by the Corent API. Check the key and try again."), true;
602
+ }
603
+ olog("approve: key accepted, issuing code");
604
+ }
605
+ else if (loginEnabled(cfg) && (p.email ?? "").trim() && p.password) {
606
+ // Sign-in path: real account login; key is minted behind the scenes.
607
+ const result = await loginAndMintKey(cfg, (p.email ?? "").trim(), p.password);
608
+ if ("error" in result)
609
+ return rerender(result.error), true;
610
+ apiKey = result.apiKey;
611
+ }
612
+ else {
613
+ return rerender(loginEnabled(cfg) ? "Enter your email and password to connect." : "Enter your Corent API key to connect."), true;
614
+ }
615
+ const code = P_CODE +
616
+ encrypt(key, {
617
+ t: "code",
618
+ k: apiKey,
619
+ cc: p.code_challenge,
620
+ ru: p.redirect_uri,
621
+ cid: p.client_id.slice(0, 64),
622
+ exp: now() + CODE_TTL_S,
623
+ });
624
+ olog(`approve: code issued, redirecting to ${new URL(p.redirect_uri).origin}`);
625
+ return redirect({ code }), true;
626
+ }
627
+ // --- Token endpoint ---
628
+ if (path === "/token" && req.method === "POST") {
629
+ if (rateLimited(clientIp(req), "token", 30)) {
630
+ return oauthError(res, 429, "slow_down", "Too many token requests; retry in a minute."), true;
631
+ }
632
+ const p = await readParams(req);
633
+ if (p.grant_type === "authorization_code") {
634
+ if (!p.code?.startsWith(P_CODE))
635
+ return oauthError(res, 400, "invalid_grant", "Malformed code."), true;
636
+ const c = decrypt(key, p.code.slice(P_CODE.length));
637
+ if (!c || c.t !== "code" || typeof c.k !== "string" || c.exp < now())
638
+ return oauthError(res, 400, "invalid_grant", "Code is invalid or expired."), true;
639
+ if (p.redirect_uri && p.redirect_uri !== c.ru)
640
+ return oauthError(res, 400, "invalid_grant", "redirect_uri mismatch."), true;
641
+ if (!p.code_verifier)
642
+ return oauthError(res, 400, "invalid_grant", "PKCE code_verifier required."), true;
643
+ const challenge = b64url(crypto.createHash("sha256").update(p.code_verifier).digest());
644
+ if (challenge !== c.cc)
645
+ return oauthError(res, 400, "invalid_grant", "PKCE verification failed."), true;
646
+ console.error("[oauth] token: authorization_code exchange ok");
647
+ olog("token: authorization_code grant ok — connection established");
648
+ json(res, 200, {
649
+ access_token: P_ACCESS + encrypt(key, { t: "access", k: c.k, exp: now() + ACCESS_TTL_S }),
650
+ refresh_token: P_REFRESH + encrypt(key, { t: "refresh", k: c.k, exp: now() + REFRESH_TTL_S }),
651
+ token_type: "Bearer",
652
+ expires_in: ACCESS_TTL_S,
653
+ scope: "corent",
654
+ });
655
+ return true;
656
+ }
657
+ if (p.grant_type === "refresh_token") {
658
+ if (!p.refresh_token?.startsWith(P_REFRESH))
659
+ return oauthError(res, 400, "invalid_grant", "Malformed refresh token."), true;
660
+ const t = decrypt(key, p.refresh_token.slice(P_REFRESH.length));
661
+ if (!t || t.t !== "refresh" || typeof t.k !== "string" || t.exp < now())
662
+ return oauthError(res, 400, "invalid_grant", "Refresh token is invalid or expired."), true;
663
+ olog("token: refresh grant ok");
664
+ json(res, 200, {
665
+ access_token: P_ACCESS + encrypt(key, { t: "access", k: t.k, exp: now() + ACCESS_TTL_S }),
666
+ refresh_token: P_REFRESH + encrypt(key, { t: "refresh", k: t.k, exp: now() + REFRESH_TTL_S }),
667
+ token_type: "Bearer",
668
+ expires_in: ACCESS_TTL_S,
669
+ scope: "corent",
670
+ });
671
+ return true;
672
+ }
673
+ return oauthError(res, 400, "unsupported_grant_type", "Use authorization_code or refresh_token."), true;
674
+ }
675
+ // Known path, wrong method.
676
+ json(res, 405, { error: "invalid_request", error_description: "Method not allowed." });
677
+ return true;
678
+ }