@apiosk/mcp 1.3.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/src/oauth.mjs ADDED
@@ -0,0 +1,1015 @@
1
+ import crypto from "node:crypto";
2
+
3
+ import { OAuthClientMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
4
+ import { createOAuthMetadata, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
5
+ import { authorizationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/authorize.js";
6
+ import { tokenHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/token.js";
7
+ import { clientRegistrationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/register.js";
8
+
9
+ const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
10
+ const AUTHORIZATION_CODE_TTL_SECONDS = 10 * 60;
11
+ const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
12
+ const CLIENT_ID_TTL_SECONDS = 20 * 365 * 24 * 60 * 60;
13
+ const DEFAULT_SCOPE = "mcp:tools";
14
+ const OFFLINE_ACCESS_SCOPE = "offline_access";
15
+ const SUPPORTED_SCOPES = [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE];
16
+ const UUID_LIKE_CLIENT_ID_PATTERN =
17
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
18
+
19
+ function trimString(value) {
20
+ return typeof value === "string" ? value.trim() : "";
21
+ }
22
+
23
+ function normalizeBaseUrl(value, fallback) {
24
+ const input = trimString(value) || fallback;
25
+ return input.replace(/\/+$/, "");
26
+ }
27
+
28
+ function escapeHtml(value) {
29
+ return String(value)
30
+ .replaceAll("&", "&")
31
+ .replaceAll("<", "&lt;")
32
+ .replaceAll(">", "&gt;")
33
+ .replaceAll('"', "&quot;")
34
+ .replaceAll("'", "&#39;");
35
+ }
36
+
37
+ function toBase64Url(value) {
38
+ return Buffer.from(value).toString("base64url");
39
+ }
40
+
41
+ function fromBase64Url(value) {
42
+ return Buffer.from(value, "base64url").toString("utf8");
43
+ }
44
+
45
+ function getIssuedAtSeconds() {
46
+ return Math.floor(Date.now() / 1000);
47
+ }
48
+
49
+ function buildTokenPayload(secret, payload) {
50
+ const encodedPayload = toBase64Url(JSON.stringify(payload));
51
+ const signature = crypto.createHmac("sha256", secret).update(encodedPayload).digest("base64url");
52
+ return `apiosk.${encodedPayload}.${signature}`;
53
+ }
54
+
55
+ function parseSignedToken(secret, token) {
56
+ const parts = String(token || "").split(".");
57
+ if (parts.length !== 3 || parts[0] !== "apiosk") {
58
+ throw new Error("Invalid token format");
59
+ }
60
+
61
+ const encodedPayload = parts[1];
62
+ const providedSignature = parts[2];
63
+ const expectedSignature = crypto.createHmac("sha256", secret).update(encodedPayload).digest("base64url");
64
+
65
+ const providedBuffer = Buffer.from(providedSignature);
66
+ const expectedBuffer = Buffer.from(expectedSignature);
67
+ if (
68
+ providedBuffer.length !== expectedBuffer.length ||
69
+ !crypto.timingSafeEqual(providedBuffer, expectedBuffer)
70
+ ) {
71
+ throw new Error("Invalid token signature");
72
+ }
73
+
74
+ const payload = JSON.parse(fromBase64Url(encodedPayload));
75
+ if (!payload || typeof payload !== "object") {
76
+ throw new Error("Invalid token payload");
77
+ }
78
+
79
+ if (typeof payload.exp !== "number" || payload.exp < getIssuedAtSeconds()) {
80
+ throw new Error("Token has expired");
81
+ }
82
+
83
+ return payload;
84
+ }
85
+
86
+ function resolveEffectiveExpiry(requestedExpiry, upperBound = null) {
87
+ if (!Number.isFinite(requestedExpiry)) {
88
+ return upperBound ?? null;
89
+ }
90
+
91
+ if (Number.isFinite(upperBound)) {
92
+ return Math.min(requestedExpiry, upperBound);
93
+ }
94
+
95
+ return requestedExpiry;
96
+ }
97
+
98
+ function buildIssuedToken(secret, type, payload, ttlSeconds, maxExpiry = null) {
99
+ const issuedAt = getIssuedAtSeconds();
100
+ const exp = resolveEffectiveExpiry(issuedAt + ttlSeconds, maxExpiry);
101
+ if (!Number.isFinite(exp) || exp <= issuedAt) {
102
+ throw new Error("Session has expired. Re-authorize the Apiosk app and retry.");
103
+ }
104
+
105
+ return {
106
+ expiresAt: exp,
107
+ token: buildTokenPayload(secret, {
108
+ ...payload,
109
+ typ: type,
110
+ iat: issuedAt,
111
+ exp,
112
+ }),
113
+ };
114
+ }
115
+
116
+ function buildRedirectUri(baseRedirectUri, params) {
117
+ const redirectUrl = new URL(baseRedirectUri);
118
+
119
+ for (const [key, value] of Object.entries(params)) {
120
+ if (value === undefined || value === null || value === "") continue;
121
+ redirectUrl.searchParams.set(key, String(value));
122
+ }
123
+
124
+ return redirectUrl.toString();
125
+ }
126
+
127
+ function deriveClientSecret(secret, clientId) {
128
+ return crypto.createHmac("sha256", secret).update(`client-secret:${clientId}`).digest("hex");
129
+ }
130
+
131
+ function shouldReplaceClientId(clientId) {
132
+ const value = trimString(clientId);
133
+ return !value || UUID_LIKE_CLIENT_ID_PATTERN.test(value);
134
+ }
135
+
136
+ function sanitizeClientMetadata(client = {}) {
137
+ const entries = Object.entries(client).filter(([key, value]) => {
138
+ if (value === undefined) return false;
139
+ return ![
140
+ "client_id",
141
+ "client_secret",
142
+ "client_id_issued_at",
143
+ "client_secret_expires_at",
144
+ ].includes(key);
145
+ });
146
+ return Object.fromEntries(entries);
147
+ }
148
+
149
+ function restoreSignedClient(secret, clientId) {
150
+ try {
151
+ const payload = parseSignedToken(secret, clientId);
152
+ if (payload.typ !== "client" || !payload.client || typeof payload.client !== "object") {
153
+ return undefined;
154
+ }
155
+
156
+ const restoredClient = {
157
+ ...payload.client,
158
+ client_id: clientId,
159
+ client_id_issued_at: payload.iat,
160
+ };
161
+
162
+ if (
163
+ trimString(restoredClient.token_endpoint_auth_method).toLowerCase() !== "none"
164
+ ) {
165
+ restoredClient.client_secret = deriveClientSecret(secret, clientId);
166
+ if (Number.isFinite(payload.client_secret_expires_at)) {
167
+ restoredClient.client_secret_expires_at = payload.client_secret_expires_at;
168
+ }
169
+ }
170
+
171
+ return restoredClient;
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ }
176
+
177
+ function createAuthorizePage({
178
+ actionPath,
179
+ appName,
180
+ clientName,
181
+ email = "",
182
+ errorMessage = "",
183
+ infoMessage = "",
184
+ oauthParams,
185
+ }) {
186
+ const scope = Array.isArray(oauthParams.scopes) ? oauthParams.scopes.join(" ") : "";
187
+ const resource = oauthParams.resource ? oauthParams.resource.href : "";
188
+
189
+ const hiddenInputs = [
190
+ ["client_id", clientName.client_id],
191
+ ["redirect_uri", oauthParams.redirectUri],
192
+ ["response_type", "code"],
193
+ ["code_challenge", oauthParams.codeChallenge],
194
+ ["code_challenge_method", "S256"],
195
+ ["scope", scope],
196
+ ["state", oauthParams.state || ""],
197
+ ["resource", resource],
198
+ ]
199
+ .map(
200
+ ([name, value]) =>
201
+ `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}" />`
202
+ )
203
+ .join("\n");
204
+
205
+ return `<!doctype html>
206
+ <html lang="en">
207
+ <head>
208
+ <meta charset="utf-8" />
209
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
210
+ <title>Connect ${escapeHtml(appName)}</title>
211
+ <style>
212
+ :root {
213
+ color-scheme: dark;
214
+ --bg: #07111c;
215
+ --panel: rgba(11, 24, 38, 0.94);
216
+ --border: rgba(119, 159, 214, 0.22);
217
+ --text: #edf4ff;
218
+ --muted: #94a8c7;
219
+ --accent: #68b4ff;
220
+ --accent-strong: #4d98ff;
221
+ --danger: #ff9d9d;
222
+ --success: #9ef0ba;
223
+ }
224
+
225
+ * {
226
+ box-sizing: border-box;
227
+ }
228
+
229
+ body {
230
+ margin: 0;
231
+ min-height: 100vh;
232
+ display: grid;
233
+ place-items: center;
234
+ padding: 24px;
235
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
236
+ background:
237
+ radial-gradient(circle at top left, rgba(72, 136, 255, 0.22), transparent 32%),
238
+ radial-gradient(circle at bottom right, rgba(55, 217, 169, 0.18), transparent 28%),
239
+ linear-gradient(180deg, #02070d 0%, var(--bg) 100%);
240
+ color: var(--text);
241
+ }
242
+
243
+ main {
244
+ width: min(100%, 460px);
245
+ border: 1px solid var(--border);
246
+ background: var(--panel);
247
+ border-radius: 20px;
248
+ padding: 28px;
249
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
250
+ }
251
+
252
+ h1 {
253
+ margin: 0 0 8px;
254
+ font-size: 1.8rem;
255
+ line-height: 1.1;
256
+ }
257
+
258
+ p {
259
+ margin: 0;
260
+ color: var(--muted);
261
+ line-height: 1.55;
262
+ }
263
+
264
+ .stack {
265
+ display: grid;
266
+ gap: 16px;
267
+ }
268
+
269
+ .message {
270
+ border-radius: 14px;
271
+ padding: 12px 14px;
272
+ font-size: 0.96rem;
273
+ }
274
+
275
+ .message.error {
276
+ border: 1px solid rgba(255, 157, 157, 0.28);
277
+ background: rgba(106, 26, 26, 0.24);
278
+ color: var(--danger);
279
+ }
280
+
281
+ .message.info {
282
+ border: 1px solid rgba(158, 240, 186, 0.22);
283
+ background: rgba(21, 71, 42, 0.24);
284
+ color: var(--success);
285
+ }
286
+
287
+ label {
288
+ display: grid;
289
+ gap: 6px;
290
+ font-size: 0.92rem;
291
+ color: var(--muted);
292
+ }
293
+
294
+ input {
295
+ width: 100%;
296
+ border: 1px solid rgba(119, 159, 214, 0.18);
297
+ border-radius: 12px;
298
+ background: rgba(2, 10, 18, 0.82);
299
+ color: var(--text);
300
+ padding: 12px 14px;
301
+ font-size: 1rem;
302
+ }
303
+
304
+ button {
305
+ appearance: none;
306
+ border: 0;
307
+ border-radius: 12px;
308
+ padding: 12px 14px;
309
+ font-size: 0.98rem;
310
+ font-weight: 600;
311
+ cursor: pointer;
312
+ }
313
+
314
+ button.primary {
315
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
316
+ color: #04101d;
317
+ }
318
+
319
+ button.secondary {
320
+ background: rgba(255, 255, 255, 0.06);
321
+ color: var(--text);
322
+ }
323
+
324
+ .actions {
325
+ display: grid;
326
+ gap: 10px;
327
+ }
328
+
329
+ .meta {
330
+ font-size: 0.84rem;
331
+ color: var(--muted);
332
+ }
333
+
334
+ code {
335
+ font-family: ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, monospace;
336
+ color: #cbe1ff;
337
+ }
338
+ </style>
339
+ </head>
340
+ <body>
341
+ <main class="stack">
342
+ <header class="stack">
343
+ <div>
344
+ <p>${escapeHtml(clientName.client_name || "Remote MCP client")}</p>
345
+ <h1>Connect ${escapeHtml(appName)}</h1>
346
+ </div>
347
+ <p>Sign in with your Apiosk dashboard account to unlock paid gateway calls, managed wallets, and credit-backed execution from this MCP app.</p>
348
+ </header>
349
+ ${errorMessage ? `<div class="message error">${escapeHtml(errorMessage)}</div>` : ""}
350
+ ${infoMessage ? `<div class="message info">${escapeHtml(infoMessage)}</div>` : ""}
351
+ <form method="post" action="${escapeHtml(actionPath)}" class="stack">
352
+ ${hiddenInputs}
353
+ <label>
354
+ Email
355
+ <input type="email" name="email" autocomplete="username" value="${escapeHtml(email)}" required />
356
+ </label>
357
+ <label>
358
+ Password
359
+ <input type="password" name="password" autocomplete="current-password" required />
360
+ </label>
361
+ <div class="actions">
362
+ <button class="primary" type="submit" name="action" value="sign_in">Sign in and continue</button>
363
+ <button class="secondary" type="submit" name="action" value="sign_up">Create account</button>
364
+ <button class="secondary" type="submit" name="action" value="cancel">Cancel</button>
365
+ </div>
366
+ </form>
367
+ <div class="meta">
368
+ Requested scope: <code>${escapeHtml(scope || DEFAULT_SCOPE)}</code><br />
369
+ Resource: <code>${escapeHtml(resource || "default")}</code>
370
+ </div>
371
+ </main>
372
+ </body>
373
+ </html>`;
374
+ }
375
+
376
+ async function fetchDashboardJson(baseUrl, pathname, payload) {
377
+ const response = await fetch(new URL(pathname, baseUrl), {
378
+ method: "POST",
379
+ headers: {
380
+ accept: "application/json",
381
+ "content-type": "application/json",
382
+ },
383
+ body: JSON.stringify(payload),
384
+ cache: "no-store",
385
+ });
386
+
387
+ const text = await response.text();
388
+ let body = null;
389
+ try {
390
+ body = text ? JSON.parse(text) : null;
391
+ } catch {
392
+ body = text || null;
393
+ }
394
+
395
+ return {
396
+ ok: response.ok,
397
+ status: response.status,
398
+ body,
399
+ };
400
+ }
401
+
402
+ class ApioskOAuthClientsStore {
403
+ constructor(secret) {
404
+ this.secret = secret;
405
+ this.registeredClients = new Map();
406
+ this.metadataClients = new Map();
407
+ }
408
+
409
+ async getClient(clientId) {
410
+ if (this.registeredClients.has(clientId)) {
411
+ return this.registeredClients.get(clientId);
412
+ }
413
+
414
+ if (this.metadataClients.has(clientId)) {
415
+ return this.metadataClients.get(clientId);
416
+ }
417
+
418
+ const restoredClient = restoreSignedClient(this.secret, clientId);
419
+ if (restoredClient) {
420
+ this.registeredClients.set(clientId, restoredClient);
421
+ return restoredClient;
422
+ }
423
+
424
+ if (!URL.canParse(clientId)) {
425
+ return undefined;
426
+ }
427
+
428
+ try {
429
+ const response = await fetch(clientId, {
430
+ headers: {
431
+ accept: "application/json",
432
+ },
433
+ cache: "no-store",
434
+ });
435
+
436
+ if (!response.ok) {
437
+ return undefined;
438
+ }
439
+
440
+ const payload = await response.json();
441
+ const parsed = OAuthClientMetadataSchema.safeParse(payload);
442
+ if (!parsed.success) {
443
+ return undefined;
444
+ }
445
+
446
+ const normalizedClient = {
447
+ ...parsed.data,
448
+ client_id: clientId,
449
+ token_endpoint_auth_method: parsed.data.token_endpoint_auth_method || "none",
450
+ };
451
+ this.metadataClients.set(clientId, normalizedClient);
452
+ return normalizedClient;
453
+ } catch {
454
+ return undefined;
455
+ }
456
+ }
457
+
458
+ async registerClient(client) {
459
+ const normalizedClient = {
460
+ token_endpoint_auth_method: "none",
461
+ ...client,
462
+ };
463
+
464
+ if (shouldReplaceClientId(normalizedClient.client_id)) {
465
+ const signedClientId = buildIssuedToken(
466
+ this.secret,
467
+ "client",
468
+ {
469
+ client: sanitizeClientMetadata(normalizedClient),
470
+ client_secret_expires_at:
471
+ Number.isFinite(normalizedClient.client_secret_expires_at) ?
472
+ normalizedClient.client_secret_expires_at :
473
+ undefined,
474
+ },
475
+ CLIENT_ID_TTL_SECONDS
476
+ ).token;
477
+
478
+ normalizedClient.client_id = signedClientId;
479
+ normalizedClient.client_id_issued_at = getIssuedAtSeconds();
480
+ if (
481
+ trimString(normalizedClient.token_endpoint_auth_method).toLowerCase() !== "none"
482
+ ) {
483
+ normalizedClient.client_secret = deriveClientSecret(this.secret, signedClientId);
484
+ } else {
485
+ delete normalizedClient.client_secret;
486
+ delete normalizedClient.client_secret_expires_at;
487
+ }
488
+ }
489
+
490
+ this.registeredClients.set(normalizedClient.client_id, normalizedClient);
491
+ return normalizedClient;
492
+ }
493
+ }
494
+
495
+ class ApioskHostedOAuthProvider {
496
+ constructor({ env, secret, controlPlaneBaseUrl, issuerUrl, mcpServerUrl, appName, resourceName }) {
497
+ this.env = env;
498
+ this.secret = secret;
499
+ this.controlPlaneBaseUrl = controlPlaneBaseUrl;
500
+ this.issuerUrl = issuerUrl;
501
+ this.mcpServerUrl = mcpServerUrl;
502
+ this.appName = appName;
503
+ this.resourceName = resourceName;
504
+ this.clientsStore = new ApioskOAuthClientsStore(secret);
505
+ }
506
+
507
+ async authorize(client, params, res) {
508
+ const req = res.req;
509
+ const submittedAction = trimString(req?.body?.action);
510
+ const email = trimString(req?.body?.email).toLowerCase();
511
+ const password = trimString(req?.body?.password);
512
+
513
+ if (!req || req.method !== "POST" || !submittedAction) {
514
+ res
515
+ .status(200)
516
+ .setHeader("content-type", "text/html; charset=utf-8")
517
+ .send(
518
+ createAuthorizePage({
519
+ actionPath: new URL("/authorize", this.issuerUrl).pathname,
520
+ appName: this.appName,
521
+ clientName: client,
522
+ oauthParams: params,
523
+ })
524
+ );
525
+ return;
526
+ }
527
+
528
+ if (submittedAction === "cancel") {
529
+ res.redirect(
530
+ 302,
531
+ buildRedirectUri(params.redirectUri, {
532
+ error: "access_denied",
533
+ error_description: "The user cancelled authorization.",
534
+ state: params.state,
535
+ })
536
+ );
537
+ return;
538
+ }
539
+
540
+ if (!email || !password) {
541
+ res
542
+ .status(400)
543
+ .setHeader("content-type", "text/html; charset=utf-8")
544
+ .send(
545
+ createAuthorizePage({
546
+ actionPath: new URL("/authorize", this.issuerUrl).pathname,
547
+ appName: this.appName,
548
+ clientName: client,
549
+ oauthParams: params,
550
+ email,
551
+ errorMessage: "Email and password are required.",
552
+ })
553
+ );
554
+ return;
555
+ }
556
+
557
+ const route = submittedAction === "sign_up" ? "/api/auth/mcp-sign-up" : "/api/auth/mcp-sign-in";
558
+ const authResponse = await fetchDashboardJson(this.controlPlaneBaseUrl, route, {
559
+ email,
560
+ password,
561
+ });
562
+
563
+ const body = authResponse.body && typeof authResponse.body === "object" ? authResponse.body : {};
564
+ const sessionToken = trimString(body.session_token);
565
+ const sessionExpiry = Number(body.expires_at);
566
+ const normalizedSessionExpiry =
567
+ Number.isFinite(sessionExpiry) && sessionExpiry > getIssuedAtSeconds() ? sessionExpiry : null;
568
+
569
+ if (submittedAction === "sign_up" && !sessionToken && body.email_confirmation_required) {
570
+ res
571
+ .status(200)
572
+ .setHeader("content-type", "text/html; charset=utf-8")
573
+ .send(
574
+ createAuthorizePage({
575
+ actionPath: new URL("/authorize", this.issuerUrl).pathname,
576
+ appName: this.appName,
577
+ clientName: client,
578
+ oauthParams: params,
579
+ email,
580
+ infoMessage:
581
+ "Account created. Confirm your email from the Apiosk message we sent, then come back and sign in to finish connecting the app.",
582
+ })
583
+ );
584
+ return;
585
+ }
586
+
587
+ if (!authResponse.ok || !sessionToken) {
588
+ const message =
589
+ trimString(body.message) ||
590
+ trimString(body.error) ||
591
+ "Could not sign in to Apiosk. Check your credentials and try again.";
592
+
593
+ res
594
+ .status(authResponse.ok ? 400 : authResponse.status)
595
+ .setHeader("content-type", "text/html; charset=utf-8")
596
+ .send(
597
+ createAuthorizePage({
598
+ actionPath: new URL("/authorize", this.issuerUrl).pathname,
599
+ appName: this.appName,
600
+ clientName: client,
601
+ oauthParams: params,
602
+ email,
603
+ errorMessage: message,
604
+ })
605
+ );
606
+ return;
607
+ }
608
+
609
+ const authorizationCode = buildIssuedToken(
610
+ this.secret,
611
+ "code",
612
+ {
613
+ clientId: client.client_id,
614
+ redirectUri: params.redirectUri,
615
+ codeChallenge: params.codeChallenge,
616
+ scopes: params.scopes?.length ? params.scopes : [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
617
+ resource: params.resource ? params.resource.href : this.mcpServerUrl.href,
618
+ dashboardSessionToken: sessionToken,
619
+ dashboardSessionExpiresAt: normalizedSessionExpiry || undefined,
620
+ userId: trimString(body.user_id),
621
+ email: trimString(body.email) || email,
622
+ },
623
+ AUTHORIZATION_CODE_TTL_SECONDS,
624
+ normalizedSessionExpiry
625
+ ).token;
626
+
627
+ res.redirect(
628
+ 302,
629
+ buildRedirectUri(params.redirectUri, {
630
+ code: authorizationCode,
631
+ state: params.state,
632
+ })
633
+ );
634
+ }
635
+
636
+ async challengeForAuthorizationCode(client, authorizationCode) {
637
+ const payload = parseSignedToken(this.secret, authorizationCode);
638
+ if (payload.typ !== "code") {
639
+ throw new Error("Invalid authorization code");
640
+ }
641
+ if (payload.clientId !== client.client_id) {
642
+ throw new Error("Authorization code was not issued to this client");
643
+ }
644
+ return payload.codeChallenge;
645
+ }
646
+
647
+ async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
648
+ const payload = parseSignedToken(this.secret, authorizationCode);
649
+ if (payload.typ !== "code") {
650
+ throw new Error("Invalid authorization code");
651
+ }
652
+ if (payload.clientId !== client.client_id) {
653
+ throw new Error("Authorization code was not issued to this client");
654
+ }
655
+ if (redirectUri && payload.redirectUri !== redirectUri) {
656
+ throw new Error("redirect_uri does not match the authorization code");
657
+ }
658
+
659
+ const requestedResource = resource ? resource.href : payload.resource || this.mcpServerUrl.href;
660
+ const maxExpiry = Number.isFinite(payload.dashboardSessionExpiresAt) ? payload.dashboardSessionExpiresAt : null;
661
+ const tokenPayload = {
662
+ clientId: client.client_id,
663
+ scopes:
664
+ Array.isArray(payload.scopes) && payload.scopes.length ?
665
+ payload.scopes :
666
+ [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
667
+ resource: requestedResource,
668
+ dashboardSessionToken: payload.dashboardSessionToken,
669
+ dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
670
+ userId: payload.userId,
671
+ email: payload.email,
672
+ };
673
+
674
+ const accessToken = buildIssuedToken(
675
+ this.secret,
676
+ "access",
677
+ tokenPayload,
678
+ ACCESS_TOKEN_TTL_SECONDS,
679
+ maxExpiry
680
+ );
681
+ const refreshToken = buildIssuedToken(
682
+ this.secret,
683
+ "refresh",
684
+ tokenPayload,
685
+ REFRESH_TOKEN_TTL_SECONDS,
686
+ maxExpiry
687
+ );
688
+
689
+ return {
690
+ access_token: accessToken.token,
691
+ refresh_token: refreshToken.token,
692
+ token_type: "bearer",
693
+ expires_in: Math.max(1, accessToken.expiresAt - getIssuedAtSeconds()),
694
+ scope: tokenPayload.scopes.join(" "),
695
+ };
696
+ }
697
+
698
+ async exchangeRefreshToken(client, refreshToken, scopes, resource) {
699
+ const payload = parseSignedToken(this.secret, refreshToken);
700
+ if (payload.typ !== "refresh") {
701
+ throw new Error("Invalid refresh token");
702
+ }
703
+ if (payload.clientId !== client.client_id) {
704
+ throw new Error("Refresh token was not issued to this client");
705
+ }
706
+
707
+ const grantedScopes =
708
+ Array.isArray(scopes) && scopes.length ?
709
+ scopes.filter((scope) => Array.isArray(payload.scopes) && payload.scopes.includes(scope)) :
710
+ payload.scopes;
711
+ const requestedResource = resource ? resource.href : payload.resource || this.mcpServerUrl.href;
712
+ const maxExpiry = Number.isFinite(payload.dashboardSessionExpiresAt) ? payload.dashboardSessionExpiresAt : null;
713
+ const accessToken = buildIssuedToken(
714
+ this.secret,
715
+ "access",
716
+ {
717
+ clientId: client.client_id,
718
+ scopes: grantedScopes,
719
+ resource: requestedResource,
720
+ dashboardSessionToken: payload.dashboardSessionToken,
721
+ dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
722
+ userId: payload.userId,
723
+ email: payload.email,
724
+ },
725
+ ACCESS_TOKEN_TTL_SECONDS,
726
+ maxExpiry
727
+ );
728
+
729
+ return {
730
+ access_token: accessToken.token,
731
+ refresh_token: refreshToken,
732
+ token_type: "bearer",
733
+ expires_in: Math.max(1, accessToken.expiresAt - getIssuedAtSeconds()),
734
+ scope: Array.isArray(grantedScopes) ? grantedScopes.join(" ") : DEFAULT_SCOPE,
735
+ };
736
+ }
737
+
738
+ async verifyAccessToken(token) {
739
+ // Accept Apiosk connect tokens (aw_live_… / aw_test_…) as bearer-
740
+ // equivalent. This is the headless-agent path: cron / CI / a fresh box
741
+ // can mint a connect token in the buyer portal and call the hosted MCP
742
+ // straight away, no interactive OAuth handshake. The gateway is the
743
+ // authoritative store for connect tokens, so we validate by calling
744
+ // its /v1/me endpoint — one source of truth, no shared secret.
745
+ const trimmed = typeof token === "string" ? token.trim() : "";
746
+ if (/^aw_(live|test)_/i.test(trimmed)) {
747
+ return this.verifyConnectTokenAccess(trimmed);
748
+ }
749
+
750
+ const payload = parseSignedToken(this.secret, token);
751
+ if (payload.typ !== "access") {
752
+ throw new Error("Invalid access token");
753
+ }
754
+
755
+ if (payload.resource && payload.resource !== this.mcpServerUrl.href) {
756
+ throw new Error("Token was issued for a different resource");
757
+ }
758
+
759
+ return {
760
+ token,
761
+ clientId: payload.clientId,
762
+ scopes: Array.isArray(payload.scopes) ? payload.scopes : [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
763
+ expiresAt: payload.exp,
764
+ resource: payload.resource ? new URL(payload.resource) : new URL(this.mcpServerUrl.href),
765
+ extra: {
766
+ dashboardSessionToken: payload.dashboardSessionToken,
767
+ dashboard_session_token: payload.dashboardSessionToken,
768
+ dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
769
+ userId: payload.userId,
770
+ email: payload.email,
771
+ },
772
+ };
773
+ }
774
+
775
+ async verifyConnectTokenAccess(connectToken) {
776
+ const cached = this.connectTokenCache?.get(connectToken);
777
+ const now = Date.now();
778
+ if (cached && cached.expiresAt > now) {
779
+ return cached.auth;
780
+ }
781
+
782
+ const gatewayBase =
783
+ trimString(this.env?.APIOSK_GATEWAY_URL) ||
784
+ trimString(this.env?.APIOSK_GATEWAY_BASE_URL) ||
785
+ "https://gateway.apiosk.com";
786
+ const url = new URL("/v1/me", gatewayBase.replace(/\/+$/, "/")).href;
787
+
788
+ let response;
789
+ try {
790
+ response = await fetch(url, {
791
+ headers: { "X-Apiosk-Connect-Token": connectToken, accept: "application/json" },
792
+ });
793
+ } catch (err) {
794
+ throw new Error(`Gateway unreachable while validating connect token: ${err?.message || err}`);
795
+ }
796
+ if (!response.ok) {
797
+ const body = await response.text().catch(() => "");
798
+ throw new Error(`Gateway rejected connect token (HTTP ${response.status}): ${body.slice(0, 200)}`);
799
+ }
800
+ const data = await response.json().catch(() => ({}));
801
+ const tokenId = trimString(data?.token_id);
802
+ if (!tokenId) {
803
+ throw new Error("Gateway /v1/me returned no token_id");
804
+ }
805
+
806
+ const auth = {
807
+ token: connectToken,
808
+ clientId: tokenId,
809
+ scopes: [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
810
+ // Connect tokens have their own expiry stored gateway-side; we treat
811
+ // each MCP call as needing a fresh check (via cache TTL) rather than
812
+ // mirroring the absolute expiry here.
813
+ expiresAt: Math.floor(now / 1000) + 60,
814
+ resource: new URL(this.mcpServerUrl.href),
815
+ extra: {
816
+ userId: trimString(data?.user_id) || undefined,
817
+ apiosk_connect_token: connectToken,
818
+ apiosk_rails: Array.isArray(data?.rails) ? data.rails : undefined,
819
+ },
820
+ };
821
+
822
+ if (!this.connectTokenCache) {
823
+ this.connectTokenCache = new Map();
824
+ }
825
+ // Cache for 60s. Long enough to absorb a burst of tool calls, short
826
+ // enough that a revocation in the buyer portal takes effect within a
827
+ // minute — same TTL the dashboard uses for similar permission caches.
828
+ this.connectTokenCache.set(connectToken, {
829
+ auth,
830
+ expiresAt: now + 60_000,
831
+ });
832
+ return auth;
833
+ }
834
+ }
835
+
836
+ function resolveOAuthSecret(env = process.env) {
837
+ const rawSecret =
838
+ trimString(env.APIOSK_MCP_OAUTH_SECRET) ||
839
+ trimString(env.APIOSK_MCP_AUTH_SECRET) ||
840
+ trimString(env.MCP_OAUTH_SECRET);
841
+
842
+ if (rawSecret) {
843
+ return rawSecret;
844
+ }
845
+
846
+ if (trimString(env.NODE_ENV).toLowerCase() !== "production") {
847
+ return "apiosk-mcp-dev-secret";
848
+ }
849
+
850
+ throw new Error(
851
+ "Hosted MCP OAuth requires APIOSK_MCP_OAUTH_SECRET (or APIOSK_MCP_AUTH_SECRET) in production."
852
+ );
853
+ }
854
+
855
+ function extractBearerToken(req) {
856
+ // Apiosk's own header alias for headless agents that prefer to keep
857
+ // Authorization free for upstream APIs. Checked first so a request that
858
+ // carries BOTH a Bearer JWT and X-Apiosk-Connect-Token is treated as a
859
+ // connect-token caller (the explicit Apiosk header wins).
860
+ const apioskHeader = trimString(req.headers["x-apiosk-connect-token"]);
861
+ if (apioskHeader) {
862
+ return apioskHeader;
863
+ }
864
+
865
+ const header = trimString(req.headers.authorization);
866
+ if (!header) return null;
867
+
868
+ const match = header.match(/^Bearer\s+(.+)$/i);
869
+ return match?.[1]?.trim() || null;
870
+ }
871
+
872
+ function writeAuthChallenge(res, { status, code, message, resourceMetadataUrl }) {
873
+ const parts = [
874
+ `Bearer error="${code}"`,
875
+ `error_description="${String(message).replaceAll('"', "'")}"`,
876
+ `scope="${DEFAULT_SCOPE}"`,
877
+ ];
878
+
879
+ if (resourceMetadataUrl) {
880
+ parts.push(`resource_metadata="${resourceMetadataUrl}"`);
881
+ }
882
+
883
+ res.setHeader("WWW-Authenticate", parts.join(", "));
884
+ res.status(status).json({
885
+ error: code,
886
+ error_description: message,
887
+ });
888
+ }
889
+
890
+ export function createHostedOAuthSupport({
891
+ env = process.env,
892
+ controlPlaneBaseUrl,
893
+ issuerUrl,
894
+ mcpServerUrl,
895
+ appName = "Apiosk",
896
+ resourceName = "Apiosk MCP",
897
+ } = {}) {
898
+ const secret = resolveOAuthSecret(env);
899
+ const provider = new ApioskHostedOAuthProvider({
900
+ env,
901
+ secret,
902
+ controlPlaneBaseUrl,
903
+ issuerUrl,
904
+ mcpServerUrl,
905
+ appName,
906
+ resourceName,
907
+ });
908
+
909
+ const oauthMetadata = createOAuthMetadata({
910
+ provider,
911
+ issuerUrl,
912
+ scopesSupported: SUPPORTED_SCOPES,
913
+ resourceServerUrl: mcpServerUrl,
914
+ resourceName,
915
+ serviceDocumentationUrl: new URL("https://apiosk.com"),
916
+ });
917
+ oauthMetadata.client_id_metadata_document_supported = true;
918
+
919
+ const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(mcpServerUrl);
920
+
921
+ return {
922
+ provider,
923
+ oauthMetadata,
924
+ resourceMetadataUrl,
925
+ metadataRouter: mcpAuthMetadataRouter({
926
+ oauthMetadata,
927
+ resourceServerUrl: mcpServerUrl,
928
+ scopesSupported: SUPPORTED_SCOPES,
929
+ resourceName,
930
+ serviceDocumentationUrl: new URL("https://apiosk.com"),
931
+ }),
932
+ authorizationRouter: authorizationHandler({ provider }),
933
+ tokenRouter: tokenHandler({ provider }),
934
+ registrationRouter: clientRegistrationHandler({ clientsStore: provider.clientsStore }),
935
+ createMcpAuthMiddleware(runtime) {
936
+ return async (req, res, next) => {
937
+ const bearerToken = extractBearerToken(req);
938
+
939
+ if (bearerToken) {
940
+ try {
941
+ req.auth = await provider.verifyAccessToken(bearerToken);
942
+ } catch (error) {
943
+ writeAuthChallenge(res, {
944
+ status: 401,
945
+ code: "invalid_token",
946
+ message: error instanceof Error ? error.message : "Invalid access token",
947
+ resourceMetadataUrl,
948
+ });
949
+ return;
950
+ }
951
+ }
952
+
953
+ const requestBody = req.body;
954
+ const method = trimString(requestBody?.method);
955
+ if (method !== "tools/call") {
956
+ next();
957
+ return;
958
+ }
959
+
960
+ const toolName = trimString(requestBody?.params?.name);
961
+ const requiresAuth = toolName ? await runtime.isToolProtected(toolName, req.auth) : false;
962
+
963
+ if (!requiresAuth) {
964
+ next();
965
+ return;
966
+ }
967
+
968
+ if (!req.auth) {
969
+ writeAuthChallenge(res, {
970
+ status: 401,
971
+ code: "invalid_token",
972
+ message: "This Apiosk tool requires sign-in before it can run.",
973
+ resourceMetadataUrl,
974
+ });
975
+ return;
976
+ }
977
+
978
+ if (!Array.isArray(req.auth.scopes) || !req.auth.scopes.includes(DEFAULT_SCOPE)) {
979
+ writeAuthChallenge(res, {
980
+ status: 403,
981
+ code: "insufficient_scope",
982
+ message: `This tool requires the ${DEFAULT_SCOPE} scope.`,
983
+ resourceMetadataUrl,
984
+ });
985
+ return;
986
+ }
987
+
988
+ next();
989
+ };
990
+ },
991
+ };
992
+ }
993
+
994
+ export function resolveHostedMcpUrls({ env = process.env, port = 3000 } = {}) {
995
+ const publicBaseUrl = normalizeBaseUrl(
996
+ env.APIOSK_MCP_PUBLIC_BASE_URL ||
997
+ env.APIOSK_MCP_BASE_URL ||
998
+ env.APIOSK_MCP_ORIGIN ||
999
+ env.APIOSK_PUBLIC_MCP_URL,
1000
+ `http://localhost:${port}`
1001
+ );
1002
+
1003
+ const issuerUrl = new URL(
1004
+ normalizeBaseUrl(env.APIOSK_MCP_ISSUER_URL, publicBaseUrl)
1005
+ );
1006
+ const configuredServerUrl = trimString(env.APIOSK_MCP_SERVER_URL);
1007
+ const mcpServerUrl = configuredServerUrl ?
1008
+ new URL(configuredServerUrl) :
1009
+ new URL("/mcp", `${publicBaseUrl}/`);
1010
+
1011
+ return {
1012
+ issuerUrl,
1013
+ mcpServerUrl,
1014
+ };
1015
+ }