@apiosk/mcp 1.3.1 → 2.0.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.
Files changed (92) hide show
  1. package/README.md +84 -537
  2. package/assets/brand/apiosk-a-20260921.png +0 -0
  3. package/assets/brand/apple-touch-icon.png +0 -0
  4. package/assets/brand/favicon-dark.ico +0 -0
  5. package/assets/brand/favicon-light.ico +0 -0
  6. package/assets/brand/favicon.ico +0 -0
  7. package/assets/brand/icon-192.png +0 -0
  8. package/assets/brand/icon-512.png +0 -0
  9. package/assets/brand/icon-maskable-512.png +0 -0
  10. package/assets/brand/inter-OFL.txt +93 -0
  11. package/assets/brand/inter-latin-400-normal.woff2 +0 -0
  12. package/assets/brand/inter-latin-500-normal.woff2 +0 -0
  13. package/assets/brand/inter-latin-600-normal.woff2 +0 -0
  14. package/assets/brand/mark-20260905-transparent.svg +1 -0
  15. package/assets/brand/mark-20260918.svg +1 -0
  16. package/assets/brand/mark-black-20260918.svg +1 -0
  17. package/assets/brand/mark-dark-20260905-transparent.png +0 -0
  18. package/assets/brand/mark-dark-20260918.png +0 -0
  19. package/assets/brand/mark-dark-96.png +0 -0
  20. package/assets/brand/mark-light-20260905-transparent.png +0 -0
  21. package/assets/brand/mark-light-20260918.png +0 -0
  22. package/assets/brand/mark-light-96.png +0 -0
  23. package/assets/brand/wordmark-black-320.png +0 -0
  24. package/assets/brand/wordmark-white-320.png +0 -0
  25. package/docs/branding.md +11 -0
  26. package/docs/marketplace-submission-2026-04-08.md +4 -0
  27. package/docs/marketplace-submission-2026-08-20.md +157 -0
  28. package/docs/openai-plugin-submission-2026-09-05.md +143 -0
  29. package/docs/sepa-rail.md +13 -13
  30. package/dxt.json +26 -22
  31. package/index.mjs +3 -1
  32. package/logo-optimized-light.png +0 -0
  33. package/package.json +15 -13
  34. package/plugin/apiosk/.codex-plugin/plugin.json +45 -0
  35. package/plugin/apiosk/.mcp.json +8 -0
  36. package/plugin/apiosk/assets/icon-dark.png +0 -0
  37. package/plugin/apiosk/assets/icon.png +0 -0
  38. package/plugin/apiosk/assets/icon.svg +1 -0
  39. package/plugin/apiosk/assets/logo.png +0 -0
  40. package/plugin/apiosk/skills/apiosk/SKILL.md +33 -0
  41. package/plugin/apiosk/skills/apiosk/agents/openai.yaml +12 -0
  42. package/server.json +98 -9
  43. package/server.mjs +258 -71
  44. package/src/approval-feedback.mjs +21 -0
  45. package/src/brand-routes.mjs +28 -0
  46. package/src/create-server.mjs +168 -9
  47. package/src/display-money.mjs +20 -0
  48. package/src/display-text.mjs +67 -0
  49. package/src/gateway-client.mjs +42 -0
  50. package/src/gateway-v2-ask.mjs +50 -0
  51. package/src/gateway-v2-card-account.mjs +23 -0
  52. package/src/gateway-v2-card-actions.mjs +67 -0
  53. package/src/gateway-v2-card-answer-text.mjs +130 -0
  54. package/src/gateway-v2-card-answer.mjs +68 -0
  55. package/src/gateway-v2-card-blocks.mjs +166 -0
  56. package/src/gateway-v2-card-body.mjs +259 -0
  57. package/src/gateway-v2-card-budget.mjs +21 -0
  58. package/src/gateway-v2-card-cbs.mjs +56 -0
  59. package/src/gateway-v2-card-choices.mjs +4 -0
  60. package/src/gateway-v2-card-clarification.mjs +25 -0
  61. package/src/gateway-v2-card-compact.mjs +34 -0
  62. package/src/gateway-v2-card-events.mjs +41 -0
  63. package/src/gateway-v2-card-presentation.mjs +125 -0
  64. package/src/gateway-v2-card-research.mjs +63 -0
  65. package/src/gateway-v2-card-result.mjs +32 -0
  66. package/src/gateway-v2-card-search.mjs +39 -0
  67. package/src/gateway-v2-card-sources.mjs +32 -0
  68. package/src/gateway-v2-card-style.mjs +76 -0
  69. package/src/gateway-v2-card-verdict.mjs +66 -0
  70. package/src/gateway-v2-card.mjs +91 -0
  71. package/src/gateway-v2-contracts.json +66 -0
  72. package/src/gateway-v2-instructions.md +113 -0
  73. package/src/gateway-v2-recovery.mjs +18 -0
  74. package/src/gateway-v2-report-links.mjs +14 -0
  75. package/src/gateway-v2-workflows.mjs +17 -0
  76. package/src/gateway-v2.mjs +179 -0
  77. package/src/oauth.mjs +603 -364
  78. package/src/observability.mjs +210 -0
  79. package/src/result-presentation.mjs +9 -0
  80. package/src/runtime.mjs +24 -3091
  81. package/src/settlement-disclosure.mjs +26 -0
  82. package/src/source-groups.mjs +91 -0
  83. package/src/source-value-format.mjs +25 -0
  84. package/src/tool-result.mjs +20 -0
  85. package/src/ui-bridge.mjs +185 -0
  86. package/src/well-known-routes.mjs +132 -0
  87. package/src/funding-options.mjs +0 -255
  88. package/src/gateway-management.mjs +0 -107
  89. package/src/listing-metadata.mjs +0 -287
  90. package/src/local-config.mjs +0 -256
  91. package/src/payment-guidance.mjs +0 -307
  92. package/src/wallet-store.mjs +0 -476
package/src/oauth.mjs CHANGED
@@ -1,10 +1,14 @@
1
1
  import crypto from "node:crypto";
2
2
 
3
+ import express from "express";
4
+
3
5
  import { OAuthClientMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
4
- import { createOAuthMetadata, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
6
+ import { createOAuthMetadata, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
5
7
  import { authorizationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/authorize.js";
6
8
  import { tokenHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/token.js";
9
+ import { InvalidGrantError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
7
10
  import { clientRegistrationHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/register.js";
11
+ import { metadataHandler } from "@modelcontextprotocol/sdk/server/auth/handlers/metadata.js";
8
12
 
9
13
  const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
10
14
  const AUTHORIZATION_CODE_TTL_SECONDS = 10 * 60;
@@ -13,9 +17,48 @@ const CLIENT_ID_TTL_SECONDS = 20 * 365 * 24 * 60 * 60;
13
17
  const DEFAULT_SCOPE = "mcp:tools";
14
18
  const OFFLINE_ACCESS_SCOPE = "offline_access";
15
19
  const SUPPORTED_SCOPES = [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE];
20
+ // Every transport surface an MCP client may connect to and treat as the
21
+ // OAuth "resource". Streamable HTTP clients target /mcp; the legacy HTTP+SSE
22
+ // transport (ChatGPT's connector) opens /sse and posts to /messages. We
23
+ // publish protected-resource metadata for each, plus the origin root, so a
24
+ // client's discovery probe succeeds no matter which surface it connected to.
25
+ const TRANSPORT_RESOURCE_PATHS = ["/mcp", "/sse", "/messages"];
16
26
  const UUID_LIKE_CLIENT_ID_PATTERN =
17
27
  /^[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
28
 
29
+ // The client_id this server authorizes as. One client for Claude, ChatGPT and
30
+ // Cursor alike — they arrive with an optional app_name shown beside it, which
31
+ // the agent gateway passes to the approval screen as `name`.
32
+ //
33
+ // It matches `^[a-z][a-z0-9-]{0,63}$`, which is what `startAuthorization` in
34
+ // the agent gateway's oauth.ts requires of a client_id.
35
+ const PORTAL_CLIENT_ID = "apiosk-mcp";
36
+ /**
37
+ * The agent gateway. ONE approval screen, and this is how this server reaches
38
+ * it.
39
+ *
40
+ * It used to send the browser to `https://buy.apiosk.com/connect` and redeem
41
+ * the resulting code at `https://gateway.apiosk.com/v1/connect/oauth/token`.
42
+ * That was a second approval screen, in a second frontend, backed by a second
43
+ * token system — beside the one the pasted-invitation flow already used, which
44
+ * approves at `app.apiosk.com/connect`.
45
+ *
46
+ * Now this server starts an ordinary authorization request at the agent
47
+ * gateway, which validates it and redirects to that same screen. The person
48
+ * approving a connection from Claude sees the page they see approving one from
49
+ * a pasted prompt, and sets the limits in the same place.
50
+ */
51
+ const DEFAULT_GATEWAY_URL = "https://api.apiosk.com/functions/v1/agent-gateway";
52
+ // Same window as the authorization code it feeds into — the round trip to the
53
+ // portal and back happens in one browser session, not across a coffee break.
54
+ const PORTAL_HANDOFF_TTL_SECONDS = AUTHORIZATION_CODE_TTL_SECONDS;
55
+ /**
56
+ * How close to expiry an upstream access token is renewed rather than passed
57
+ * on. Comfortably longer than this server's own access token life, so a token
58
+ * minted here never carries an upstream credential that dies before it does.
59
+ */
60
+ const UPSTREAM_RENEWAL_MARGIN_SECONDS = 2 * ACCESS_TOKEN_TTL_SECONDS;
61
+
19
62
  function trimString(value) {
20
63
  return typeof value === "string" ? value.trim() : "";
21
64
  }
@@ -95,11 +138,26 @@ function resolveEffectiveExpiry(requestedExpiry, upperBound = null) {
95
138
  return requestedExpiry;
96
139
  }
97
140
 
141
+ // OAuth clients need invalid_grant to prompt reconnection. Plain Errors are
142
+ // converted by the SDK into HTTP 500 and retried as service outages.
143
+ function parseOAuthGrant(secret, token, type, client) {
144
+ let payload;
145
+ try {
146
+ payload = parseSignedToken(secret, token);
147
+ } catch {
148
+ throw new InvalidGrantError("Invalid or expired grant. Reconnect your Apiosk account.");
149
+ }
150
+ if (payload.typ !== type || payload.clientId !== client.client_id) {
151
+ throw new InvalidGrantError("Invalid or expired grant. Reconnect your Apiosk account.");
152
+ }
153
+ return payload;
154
+ }
155
+
98
156
  function buildIssuedToken(secret, type, payload, ttlSeconds, maxExpiry = null) {
99
157
  const issuedAt = getIssuedAtSeconds();
100
158
  const exp = resolveEffectiveExpiry(issuedAt + ttlSeconds, maxExpiry);
101
159
  if (!Number.isFinite(exp) || exp <= issuedAt) {
102
- throw new Error("Session has expired. Re-authorize the Apiosk app and retry.");
160
+ throw new InvalidGrantError("Session has expired. Re-authorize the Apiosk app and retry.");
103
161
  }
104
162
 
105
163
  return {
@@ -124,6 +182,19 @@ function buildRedirectUri(baseRedirectUri, params) {
124
182
  return redirectUrl.toString();
125
183
  }
126
184
 
185
+ function responseMessage(body, fallback) {
186
+ if (body && typeof body === "object") {
187
+ return trimString(body.message) || trimString(body.error) || fallback;
188
+ }
189
+ return trimString(body) || fallback;
190
+ }
191
+
192
+ function statusError(message, status = 500) {
193
+ const error = new Error(message);
194
+ error.status = status;
195
+ return error;
196
+ }
197
+
127
198
  function deriveClientSecret(secret, clientId) {
128
199
  return crypto.createHmac("sha256", secret).update(`client-secret:${clientId}`).digest("hex");
129
200
  }
@@ -174,216 +245,44 @@ function restoreSignedClient(secret, clientId) {
174
245
  }
175
246
  }
176
247
 
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
- });
248
+ // The interstitial shown after the round trip to buy.apiosk.com finishes,
249
+ // right before bouncing back to the MCP client. The client only completes the
250
+ // connection once its own callback runs, so this still carries the
251
+ // authorization code (auto-continue + manual link) rather than replacing it.
252
+ function createConnectionCompletePage({ appName, clientName, redirectTarget }) {
253
+ const clientLabel = clientName.client_name || "the app";
254
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8" />
255
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
256
+ <meta name="color-scheme" content="only light" />
257
+ <meta http-equiv="refresh" content="2;url=${escapeHtml(redirectTarget)}" />
258
+ <title>Connected · ${escapeHtml(appName)}</title>
259
+ <style>
260
+ @font-face{font-family:Inter;src:url("/brand/inter-latin-500-normal.woff2") format("woff2");font-weight:500;font-display:swap}@font-face{font-family:Inter;src:url("/brand/inter-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
261
+ :root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light dark;--background:#f8f8fb;--foreground:#1f2028;--card:#fff;--border:#e7e3ef;--muted:#676371;--primary:#6349db;--primary-fg:#fff;--glow:rgb(99 73 219/.07);--success:#057857;--shadow:0 22px 54px -30px rgba(48,28,100,.3);color:var(--foreground);background:var(--background);font-weight:500;letter-spacing:-.011em}
262
+ @media (prefers-color-scheme:dark){:root{--background:#0d0f13;--foreground:#ecebf2;--card:#15171d;--border:#262a34;--muted:#a5a2b0;--primary:#c3a0ff;--primary-fg:#25153c;--glow:rgb(195 160 255/.12);--success:#6ee7b7;--shadow:0 24px 52px -26px rgba(0,0,0,.72)}}
263
+ *{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(820px 440px at 88% -10%,var(--glow),transparent 62%),var(--background);-webkit-font-smoothing:antialiased}
264
+ .page{min-height:100vh;display:grid;place-items:center;padding:28px 18px}
265
+ main{width:min(430px,100%);background:color-mix(in srgb,var(--card) 97%,transparent);border:1px solid var(--border);border-radius:20px;padding:28px;box-shadow:var(--shadow);text-align:center}
266
+ .brand{display:block;width:92px;height:30px;margin:0 auto 20px}.brand img{display:block;width:92px;height:30px;object-fit:contain}
267
+ .check{width:38px;height:38px;margin:0 auto 15px;border-radius:50%;background:color-mix(in srgb,var(--success) 13%,transparent);display:grid;place-items:center}
268
+ .check svg{width:19px;height:19px;stroke:var(--success)}
269
+ h1{margin:0 0 8px;font-size:24px;font-weight:600;letter-spacing:-.03em}p{color:var(--muted);font-size:14px;line-height:1.55;margin:0 0 20px}
270
+ .steps{display:flex;gap:6px;margin:0 0 20px}.step{height:4px;flex:1;border-radius:99px;background:var(--success)}
271
+ a.continue{display:block;border-radius:11px;padding:12px 15px;font-weight:600;letter-spacing:-.016em;background:var(--primary);color:var(--primary-fg);text-decoration:none;box-shadow:0 10px 22px -16px var(--primary)}
272
+ .note{font-size:12px;color:var(--muted);margin:14px 0 0}
273
+ :root{color-scheme:only light;--background:#f8f8fb;--foreground:#1f2028;--card:#fff;--border:#e5e7eb;--muted:#676371;--primary:#6349db;--primary-fg:#fff;--success:#057857}body{background:var(--background)}main{background:#fff;border-radius:12px;box-shadow:none}.steps{display:none}a.continue{box-shadow:none}
274
+ </style></head><body><div class="page"><main><picture class="brand"><img src="/brand/wordmark-black-320.png" alt="Apiosk" width="320" height="103"></picture>
275
+ <div class="check"><svg viewBox="0 0 24 24" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg></div>
276
+ <h1>You're connected</h1>
277
+ <p>Your Apiosk account is connected. Finishing securely in ${escapeHtml(clientLabel)}.</p>
278
+ <div class="steps" aria-label="Authorization complete"><span class="step"></span><span class="step"></span><span class="step"></span></div>
279
+ <a class="continue" id="continue" href="${escapeHtml(redirectTarget)}">Continue to ${escapeHtml(clientLabel)}</a>
280
+ <p class="note">Returning you to ${escapeHtml(clientLabel)} automatically…</p>
281
+ </main></div><script>setTimeout(()=>{window.location.replace(${JSON.stringify(redirectTarget).replaceAll("<", "\\u003c")})},1200);</script></body></html>`;
282
+ }
386
283
 
284
+ async function fetchJsonWithBody(url, options) {
285
+ const response = await fetch(url, options);
387
286
  const text = await response.text();
388
287
  let body = null;
389
288
  try {
@@ -399,6 +298,100 @@ async function fetchDashboardJson(baseUrl, pathname, payload) {
399
298
  };
400
299
  }
401
300
 
301
+ function resolveGatewayBaseUrl(env = process.env) {
302
+ return normalizeBaseUrl(
303
+ env?.APIOSK_GATEWAY_URL || env?.APIOSK_GATEWAY_BASE_URL,
304
+ DEFAULT_GATEWAY_URL
305
+ );
306
+ }
307
+
308
+ /**
309
+ * Where the browser is sent to approve: the agent gateway's own authorize
310
+ * endpoint, which validates the request and redirects on to
311
+ * `app.apiosk.com/connect`.
312
+ *
313
+ * IT IS THE GATEWAY BASE AND NOTHING ELSE. `APIOSK_BUYER_PORTAL_URL` used to
314
+ * name a frontend here and is deliberately no longer read: a deploy that still
315
+ * sets it to `buy.apiosk.com` would otherwise be sent to
316
+ * `buy.apiosk.com/v1/oauth/authorize`, which is not a page, and a wrong host
317
+ * that answers 404 is worse than one that was never consulted. A deploy that
318
+ * needs to move this moves `APIOSK_GATEWAY_URL`, which moves the token
319
+ * endpoint with it - and those two must agree or the code will not redeem.
320
+ */
321
+ function resolveAuthorizeBaseUrl(env = process.env) {
322
+ return resolveGatewayBaseUrl(env);
323
+ }
324
+
325
+ /**
326
+ * One request to the agent gateway's token endpoint, and the reading of its
327
+ * answer.
328
+ *
329
+ * The endpoint takes a form or JSON (`readForm` in the gateway's oauth.ts
330
+ * accepts both), and answers the same shape for both grants this server uses.
331
+ * No wallet key, no on-chain transaction and no x402 payload is ever
332
+ * constructed here — settlement belongs to the gateway.
333
+ *
334
+ * THE REFRESH TOKEN IS THE PART THAT IS NEW AND THE PART THAT MATTERS. The
335
+ * agent gateway's access tokens live 24 hours; this server's own live one.
336
+ * Kept only the access token, a connection would work for a day and then ask
337
+ * the person to approve something they had already approved. The refresh token
338
+ * rotates on every use, so what is held is at most a day old.
339
+ */
340
+ async function requestUpstreamToken(env, grant, failureMessage) {
341
+ const url = `${resolveGatewayBaseUrl(env)}/v1/oauth/token`;
342
+ const response = await fetchJsonWithBody(url, {
343
+ method: "POST",
344
+ headers: { accept: "application/json", "content-type": "application/json" },
345
+ body: JSON.stringify({ ...grant, client_id: PORTAL_CLIENT_ID }),
346
+ });
347
+
348
+ if (!response.ok) {
349
+ throw statusError(
350
+ responseMessage(response.body, failureMessage),
351
+ response.status >= 400 ? response.status : 502
352
+ );
353
+ }
354
+
355
+ const body = response.body && typeof response.body === "object" ? response.body : {};
356
+ const connectToken = trimString(body.access_token);
357
+ if (!connectToken) {
358
+ throw statusError("The gateway did not return an access token.", 502);
359
+ }
360
+
361
+ return {
362
+ connectToken,
363
+ refreshToken: trimString(body.refresh_token) || null,
364
+ expiresInSeconds: Number.isFinite(Number(body.expires_in)) ? Number(body.expires_in) : null,
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Redeem the one-time code the approval screen produced, server side.
370
+ *
371
+ * Injectable so tests can bypass the network call.
372
+ */
373
+ async function defaultExchangePortalCode(env, { code, codeVerifier, redirectUri }) {
374
+ return await requestUpstreamToken(
375
+ env,
376
+ {
377
+ grant_type: "authorization_code",
378
+ code,
379
+ code_verifier: codeVerifier,
380
+ redirect_uri: redirectUri,
381
+ },
382
+ "Could not exchange the approval code for an access token."
383
+ );
384
+ }
385
+
386
+ /** Trade a rotating upstream refresh token for a fresh pair. */
387
+ async function defaultRefreshPortalToken(env, { refreshToken }) {
388
+ return await requestUpstreamToken(
389
+ env,
390
+ { grant_type: "refresh_token", refresh_token: refreshToken },
391
+ "Could not refresh the Apiosk connection."
392
+ );
393
+ }
394
+
402
395
  class ApioskOAuthClientsStore {
403
396
  constructor(secret) {
404
397
  this.secret = secret;
@@ -493,118 +486,203 @@ class ApioskOAuthClientsStore {
493
486
  }
494
487
 
495
488
  class ApioskHostedOAuthProvider {
496
- constructor({ env, secret, controlPlaneBaseUrl, issuerUrl, mcpServerUrl, appName, resourceName }) {
489
+ constructor({
490
+ env,
491
+ secret,
492
+ issuerUrl,
493
+ mcpServerUrl,
494
+ appName,
495
+ resourceName,
496
+ exchangePortalCode,
497
+ refreshPortalToken,
498
+ }) {
497
499
  this.env = env;
498
500
  this.secret = secret;
499
- this.controlPlaneBaseUrl = controlPlaneBaseUrl;
500
501
  this.issuerUrl = issuerUrl;
501
502
  this.mcpServerUrl = mcpServerUrl;
502
503
  this.appName = appName;
503
504
  this.resourceName = resourceName;
505
+ // Injectable so tests can bypass the network call to the gateway.
506
+ this.exchangePortalCode = exchangePortalCode || defaultExchangePortalCode;
507
+ this.refreshPortalToken = refreshPortalToken || defaultRefreshPortalToken;
504
508
  this.clientsStore = new ApioskOAuthClientsStore(secret);
509
+ this.callbackUrl = new URL("/authorize/callback", this.issuerUrl).href;
510
+ // Audiences we honour on an access token. A client that connected via
511
+ // /sse (ChatGPT) requests resource=<origin>/sse; one via /mcp requests
512
+ // <origin>/mcp. The origin root is accepted for clients that omit the
513
+ // path. All map to the same underlying Apiosk MCP server.
514
+ this.allowedResources = new Set(
515
+ [
516
+ ...TRANSPORT_RESOURCE_PATHS.map((path) => new URL(path, this.mcpServerUrl).href),
517
+ new URL("/", this.mcpServerUrl).href,
518
+ this.mcpServerUrl.href,
519
+ ].map((href) => href.replace(/\/+$/, "") || href)
520
+ );
521
+ }
522
+
523
+ isAllowedResource(resourceHref) {
524
+ const normalized = String(resourceHref || "").replace(/\/+$/, "");
525
+ return this.allowedResources.has(normalized) || this.allowedResources.has(resourceHref);
505
526
  }
506
527
 
528
+ /**
529
+ * Start the handoff to the agent gateway, which approves at
530
+ * `app.apiosk.com/connect`.
531
+ *
532
+ * Identity, funding and spending limits all live there — this server never
533
+ * renders a sign-in page and never sees a wallet key. It starts an OAuth 2.0
534
+ * authorization code request with PKCE of its own and stashes the ORIGINAL
535
+ * request (from Claude, ChatGPT, ...) in a signed `state` so
536
+ * `completePortalCallback` can pick the flow back up when the browser comes
537
+ * back.
538
+ *
539
+ * `name` rather than `app_name`: that is what `startAuthorization` reads and
540
+ * carries to the approval screen as the suggested connection name, which the
541
+ * person can edit there.
542
+ */
507
543
  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);
544
+ const verifier = crypto.randomBytes(48).toString("base64url");
545
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
546
+ // Display the runtime that owns the validated callback, rather than the
547
+ // shared Apiosk transport client. Client-supplied names are not identity.
548
+ const callback = new URL(params.redirectUri);
549
+ const provider = callback.protocol === "https:" ? ({
550
+ "claude.ai": "anthropic",
551
+ "chatgpt.com": "openai",
552
+ "chat.openai.com": "openai",
553
+ })[callback.hostname] : undefined;
554
+
555
+ const handoffState = buildIssuedToken(
556
+ this.secret,
557
+ "portal_handoff",
558
+ {
559
+ verifier,
560
+ clientId: client.client_id,
561
+ redirectUri: params.redirectUri,
562
+ codeChallenge: params.codeChallenge,
563
+ state: params.state || null,
564
+ scopes: params.scopes?.length ? params.scopes : [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
565
+ resource: params.resource ? params.resource.href : this.mcpServerUrl.href,
566
+ },
567
+ PORTAL_HANDOFF_TTL_SECONDS
568
+ ).token;
512
569
 
513
- if (!req || req.method !== "POST" || !submittedAction) {
570
+ // Appended, not resolved as a root-relative path: the agent gateway lives
571
+ // UNDER a path (`/functions/v1/agent-gateway`), and `new URL("/v1/...",
572
+ // base)` would throw that path away and address the project root.
573
+ const portalUrl = buildRedirectUri(
574
+ `${resolveAuthorizeBaseUrl(this.env)}/v1/oauth/authorize`,
575
+ {
576
+ client_id: PORTAL_CLIENT_ID,
577
+ redirect_uri: this.callbackUrl,
578
+ response_type: "code",
579
+ code_challenge: challenge,
580
+ // Required, and refused rather than assumed: `startAuthorization`
581
+ // answers `plain` with an error instead of downgrading.
582
+ code_challenge_method: "S256",
583
+ state: handoffState,
584
+ name: trimString(client.client_name) || undefined,
585
+ provider,
586
+ }
587
+ );
588
+
589
+ res.redirect(302, portalUrl);
590
+ }
591
+
592
+ /**
593
+ * GET /authorize/callback — buy.apiosk.com sends the browser back here once
594
+ * the buyer signs in, funds a wallet, sets limits and consents. Exchanges
595
+ * the one-time code for a connect token server side (RFC 6749 §4.1.3), so
596
+ * the token never enters this browser's address bar or its history.
597
+ */
598
+ async completePortalCallback(req, res) {
599
+ const query = req.query || {};
600
+ const stateToken = trimString(query.state);
601
+
602
+ let handoff;
603
+ try {
604
+ handoff = parseSignedToken(this.secret, stateToken);
605
+ if (handoff.typ !== "portal_handoff") {
606
+ throw new Error("Invalid state");
607
+ }
608
+ } catch {
514
609
  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
- );
610
+ .status(400)
611
+ .type("text/plain")
612
+ .send("This sign-in link is invalid or has expired. Start again from your MCP client.");
525
613
  return;
526
614
  }
527
615
 
528
- if (submittedAction === "cancel") {
616
+ const client = await this.clientsStore.getClient(handoff.clientId);
617
+ if (!client) {
618
+ res.status(400).type("text/plain").send("Unknown client. Start again from your MCP client.");
619
+ return;
620
+ }
621
+
622
+ const params = {
623
+ redirectUri: handoff.redirectUri,
624
+ codeChallenge: handoff.codeChallenge,
625
+ state: handoff.state,
626
+ scopes: handoff.scopes,
627
+ resource: handoff.resource ? new URL(handoff.resource) : new URL(this.mcpServerUrl.href),
628
+ };
629
+
630
+ const errorCode = trimString(query.error);
631
+ if (errorCode) {
529
632
  res.redirect(
530
633
  302,
531
634
  buildRedirectUri(params.redirectUri, {
532
- error: "access_denied",
533
- error_description: "The user cancelled authorization.",
635
+ error: errorCode,
636
+ error_description:
637
+ trimString(query.error_description) || "The buyer did not complete the connection.",
534
638
  state: params.state,
535
639
  })
536
640
  );
537
641
  return;
538
642
  }
539
643
 
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
- );
644
+ const code = trimString(query.code);
645
+ if (!code) {
646
+ res.status(400).type("text/plain").send("The portal did not return a code.");
554
647
  return;
555
648
  }
556
649
 
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) {
650
+ let exchange;
651
+ try {
652
+ exchange = await this.exchangePortalCode(this.env, {
653
+ code,
654
+ codeVerifier: handoff.verifier,
655
+ redirectUri: this.callbackUrl,
656
+ });
657
+ } catch (error) {
570
658
  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
- );
659
+ .status(error?.status && error.status >= 400 ? error.status : 502)
660
+ .type("text/plain")
661
+ .send(error instanceof Error ? error.message : "Could not finish connecting to Apiosk.");
584
662
  return;
585
663
  }
586
664
 
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.";
665
+ await this.finishAuthorization(res, client, params, exchange);
666
+ }
592
667
 
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
- }
668
+ async finishAuthorization(res, client, params, exchange) {
669
+ const connectToken = trimString(exchange.connectToken);
670
+ const upstreamRefresh = trimString(exchange.refreshToken);
671
+ const upstreamExpiresAt = Number.isFinite(exchange.expiresInSeconds)
672
+ ? getIssuedAtSeconds() + exchange.expiresInSeconds
673
+ : null;
674
+
675
+ /**
676
+ * The cap, and when there is not one.
677
+ *
678
+ * This server's tokens may never outlive what they carry, so an upstream
679
+ * access token with no way to renew it caps everything issued from it.
680
+ * HOLDING A REFRESH TOKEN REMOVES THE CAP: the upstream access token
681
+ * expiring is then a thing this server fixes on the next refresh rather
682
+ * than a thing the person fixes by approving again. Without this the whole
683
+ * connection died every 24 hours, which is the agent gateway's access TTL.
684
+ */
685
+ const maxExpiry = upstreamRefresh ? null : upstreamExpiresAt;
608
686
 
609
687
  const authorizationCode = buildIssuedToken(
610
688
  this.secret,
@@ -615,49 +693,44 @@ class ApioskHostedOAuthProvider {
615
693
  codeChallenge: params.codeChallenge,
616
694
  scopes: params.scopes?.length ? params.scopes : [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
617
695
  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,
696
+ apioskConnectToken: connectToken || undefined,
697
+ apioskConnectTokenExpiresAt: upstreamExpiresAt || undefined,
698
+ apioskRefreshToken: upstreamRefresh || undefined,
622
699
  },
623
700
  AUTHORIZATION_CODE_TTL_SECONDS,
624
- normalizedSessionExpiry
701
+ maxExpiry
625
702
  ).token;
626
703
 
627
- res.redirect(
628
- 302,
629
- buildRedirectUri(params.redirectUri, {
630
- code: authorizationCode,
631
- state: params.state,
632
- })
633
- );
704
+ const redirectTarget = buildRedirectUri(params.redirectUri, {
705
+ code: authorizationCode,
706
+ state: params.state,
707
+ });
708
+
709
+ // Complete the host's OAuth session using a real HTTP redirect. An HTML
710
+ // timer leaves mobile authentication sessions waiting for JavaScript and
711
+ // can race the meta refresh into a second use of a one-time code.
712
+ res.setHeader("Cache-Control", "no-store");
713
+ res.setHeader("Referrer-Policy", "no-referrer");
714
+ res.redirect(302, redirectTarget);
634
715
  }
635
716
 
636
717
  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
- }
718
+ const payload = parseOAuthGrant(this.secret, authorizationCode, "code", client);
644
719
  return payload.codeChallenge;
645
720
  }
646
721
 
647
722
  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
- }
723
+ const payload = parseOAuthGrant(this.secret, authorizationCode, "code", client);
655
724
  if (redirectUri && payload.redirectUri !== redirectUri) {
656
- throw new Error("redirect_uri does not match the authorization code");
725
+ throw new InvalidGrantError("redirect_uri does not match the authorization code");
657
726
  }
658
727
 
659
728
  const requestedResource = resource ? resource.href : payload.resource || this.mcpServerUrl.href;
660
- const maxExpiry = Number.isFinite(payload.dashboardSessionExpiresAt) ? payload.dashboardSessionExpiresAt : null;
729
+ // Uncapped when a refresh token came with it. See `finishAuthorization`.
730
+ const maxExpiry =
731
+ !payload.apioskRefreshToken && Number.isFinite(payload.apioskConnectTokenExpiresAt)
732
+ ? payload.apioskConnectTokenExpiresAt
733
+ : null;
661
734
  const tokenPayload = {
662
735
  clientId: client.client_id,
663
736
  scopes:
@@ -665,10 +738,9 @@ class ApioskHostedOAuthProvider {
665
738
  payload.scopes :
666
739
  [DEFAULT_SCOPE, OFFLINE_ACCESS_SCOPE],
667
740
  resource: requestedResource,
668
- dashboardSessionToken: payload.dashboardSessionToken,
669
- dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
670
- userId: payload.userId,
671
- email: payload.email,
741
+ apioskConnectToken: payload.apioskConnectToken,
742
+ apioskConnectTokenExpiresAt: payload.apioskConnectTokenExpiresAt,
743
+ apioskRefreshToken: payload.apioskRefreshToken,
672
744
  };
673
745
 
674
746
  const accessToken = buildIssuedToken(
@@ -696,39 +768,87 @@ class ApioskHostedOAuthProvider {
696
768
  }
697
769
 
698
770
  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
- }
771
+ const payload = parseOAuthGrant(this.secret, refreshToken, "refresh", client);
706
772
 
707
773
  const grantedScopes =
708
774
  Array.isArray(scopes) && scopes.length ?
709
775
  scopes.filter((scope) => Array.isArray(payload.scopes) && payload.scopes.includes(scope)) :
710
776
  payload.scopes;
711
777
  const requestedResource = resource ? resource.href : payload.resource || this.mcpServerUrl.href;
712
- const maxExpiry = Number.isFinite(payload.dashboardSessionExpiresAt) ? payload.dashboardSessionExpiresAt : null;
778
+
779
+ /**
780
+ * Renew the upstream token when it is spent, before minting one that
781
+ * carries it.
782
+ *
783
+ * THIS IS WHERE A CONNECTION SURVIVES ITS SECOND DAY. The agent gateway's
784
+ * access tokens live 24 hours and this server's live one, so a client that
785
+ * refreshes hourly reaches a point where every token it is handed carries
786
+ * an upstream credential that is already dead — and every tool call fails
787
+ * with a 401 the person can do nothing about except approve again.
788
+ *
789
+ * Renewed a little BEFORE expiry, not at it: a token that dies between
790
+ * being minted here and being used by the next tool call is the same
791
+ * failure arriving less often, which is worse to diagnose than one that
792
+ * arrives reliably.
793
+ *
794
+ * A refusal upstream is not fatal here. The old token may still have life
795
+ * in it, and if it does not, the tool call is where that is reported - with
796
+ * the gateway's own words, to a caller that is asking for something rather
797
+ * than to one that is only renewing.
798
+ */
799
+ let upstreamToken = payload.apioskConnectToken;
800
+ let upstreamRefresh = payload.apioskRefreshToken;
801
+ let upstreamExpiresAt = payload.apioskConnectTokenExpiresAt;
802
+ const spent =
803
+ Number.isFinite(upstreamExpiresAt) &&
804
+ upstreamExpiresAt - getIssuedAtSeconds() < UPSTREAM_RENEWAL_MARGIN_SECONDS;
805
+
806
+ if (upstreamRefresh && spent) {
807
+ try {
808
+ const renewed = await this.refreshPortalToken(this.env, { refreshToken: upstreamRefresh });
809
+ upstreamToken = trimString(renewed.connectToken) || upstreamToken;
810
+ // Rotating: the one just spent is dead, so keeping it would guarantee
811
+ // the next renewal fails.
812
+ upstreamRefresh = trimString(renewed.refreshToken) || null;
813
+ upstreamExpiresAt = Number.isFinite(renewed.expiresInSeconds)
814
+ ? getIssuedAtSeconds() + renewed.expiresInSeconds
815
+ : null;
816
+ } catch {
817
+ // Left as it was. See above.
818
+ }
819
+ }
820
+
821
+ const tokenPayload = {
822
+ clientId: client.client_id,
823
+ scopes: grantedScopes,
824
+ resource: requestedResource,
825
+ apioskConnectToken: upstreamToken,
826
+ apioskConnectTokenExpiresAt: upstreamExpiresAt,
827
+ apioskRefreshToken: upstreamRefresh,
828
+ };
829
+ // Uncapped when a refresh token remains. See `finishAuthorization`.
830
+ const maxExpiry =
831
+ !upstreamRefresh && Number.isFinite(upstreamExpiresAt) ? upstreamExpiresAt : null;
713
832
  const accessToken = buildIssuedToken(
714
833
  this.secret,
715
834
  "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
- },
835
+ tokenPayload,
725
836
  ACCESS_TOKEN_TTL_SECONDS,
726
837
  maxExpiry
727
838
  );
728
839
 
729
840
  return {
730
841
  access_token: accessToken.token,
731
- refresh_token: refreshToken,
842
+ // Reissued rather than returned, because the upstream token it carries
843
+ // may have just changed. Handing back the one that came in would hand
844
+ // back a rotated-away upstream refresh token with it.
845
+ refresh_token: buildIssuedToken(
846
+ this.secret,
847
+ "refresh",
848
+ tokenPayload,
849
+ REFRESH_TOKEN_TTL_SECONDS,
850
+ maxExpiry
851
+ ).token,
732
852
  token_type: "bearer",
733
853
  expires_in: Math.max(1, accessToken.expiresAt - getIssuedAtSeconds()),
734
854
  scope: Array.isArray(grantedScopes) ? grantedScopes.join(" ") : DEFAULT_SCOPE,
@@ -741,7 +861,7 @@ class ApioskHostedOAuthProvider {
741
861
  // can mint a connect token in the buyer portal and call the hosted MCP
742
862
  // straight away, no interactive OAuth handshake. The gateway is the
743
863
  // authoritative store for connect tokens, so we validate by calling
744
- // its /v1/me endpoint — one source of truth, no shared secret.
864
+ // its /v1/me endpoint, one source of truth, no shared secret.
745
865
  const trimmed = typeof token === "string" ? token.trim() : "";
746
866
  if (/^aw_(live|test)_/i.test(trimmed)) {
747
867
  return this.verifyConnectTokenAccess(trimmed);
@@ -752,7 +872,7 @@ class ApioskHostedOAuthProvider {
752
872
  throw new Error("Invalid access token");
753
873
  }
754
874
 
755
- if (payload.resource && payload.resource !== this.mcpServerUrl.href) {
875
+ if (payload.resource && !this.isAllowedResource(payload.resource)) {
756
876
  throw new Error("Token was issued for a different resource");
757
877
  }
758
878
 
@@ -763,11 +883,11 @@ class ApioskHostedOAuthProvider {
763
883
  expiresAt: payload.exp,
764
884
  resource: payload.resource ? new URL(payload.resource) : new URL(this.mcpServerUrl.href),
765
885
  extra: {
766
- dashboardSessionToken: payload.dashboardSessionToken,
767
- dashboard_session_token: payload.dashboardSessionToken,
768
- dashboardSessionExpiresAt: payload.dashboardSessionExpiresAt,
769
- userId: payload.userId,
770
- email: payload.email,
886
+ // Connect token minted by the buyer portal's OAuth handoff. The
887
+ // runtime threads this to the gateway as X-Apiosk-Connect-Token so
888
+ // paid calls settle from the buyer's own wallet (runtime getClient
889
+ // reads extra.apiosk_connect_token).
890
+ apiosk_connect_token: payload.apioskConnectToken,
771
891
  },
772
892
  };
773
893
  }
@@ -779,11 +899,7 @@ class ApioskHostedOAuthProvider {
779
899
  return cached.auth;
780
900
  }
781
901
 
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;
902
+ const url = new URL("/v1/me", `${resolveGatewayBaseUrl(this.env)}/`).href;
787
903
 
788
904
  let response;
789
905
  try {
@@ -824,7 +940,7 @@ class ApioskHostedOAuthProvider {
824
940
  }
825
941
  // Cache for 60s. Long enough to absorb a burst of tool calls, short
826
942
  // enough that a revocation in the buyer portal takes effect within a
827
- // minute — same TTL the dashboard uses for similar permission caches.
943
+ // minute, same TTL the dashboard uses for similar permission caches.
828
944
  this.connectTokenCache.set(connectToken, {
829
945
  auth,
830
946
  expiresAt: now + 60_000,
@@ -870,9 +986,17 @@ function extractBearerToken(req) {
870
986
  }
871
987
 
872
988
  function writeAuthChallenge(res, { status, code, message, resourceMetadataUrl }) {
989
+ // HTTP header values must be ASCII: strip quotes and replace any
990
+ // non-printable/non-ASCII characters so an upstream error message (which
991
+ // may contain arrows, em-dashes, etc.) can never crash setHeader.
992
+ const headerSafeMessage = String(message)
993
+ .replaceAll('"', "'")
994
+ .replace(/[^\x20-\x7e]/g, " ")
995
+ .replace(/\s+/g, " ")
996
+ .trim();
873
997
  const parts = [
874
998
  `Bearer error="${code}"`,
875
- `error_description="${String(message).replaceAll('"', "'")}"`,
999
+ `error_description="${headerSafeMessage}"`,
876
1000
  `scope="${DEFAULT_SCOPE}"`,
877
1001
  ];
878
1002
 
@@ -887,23 +1011,82 @@ function writeAuthChallenge(res, { status, code, message, resourceMetadataUrl })
887
1011
  });
888
1012
  }
889
1013
 
1014
+ function protectedResourceMetadataPath(resourceUrl) {
1015
+ const rsPath = new URL(resourceUrl.href).pathname;
1016
+ return `/.well-known/oauth-protected-resource${rsPath === "/" ? "" : rsPath}`;
1017
+ }
1018
+
1019
+ // One router that serves protected-resource metadata (RFC 9728) for the
1020
+ // origin root AND every transport surface (/mcp, /sse, /messages), so an MCP
1021
+ // client's discovery probe resolves regardless of which URL it connected to.
1022
+ // Longer paths are registered first because express `use()` matches by prefix
1023
+ // and the root path would otherwise shadow the transport-specific documents.
1024
+ function buildResourceMetadataRouter({
1025
+ oauthMetadata,
1026
+ resourceUrls,
1027
+ scopesSupported,
1028
+ resourceName,
1029
+ serviceDocumentationUrl,
1030
+ }) {
1031
+ checkResourceRouterIssuer(oauthMetadata.issuer);
1032
+ const router = express.Router();
1033
+
1034
+ const sorted = [...resourceUrls].sort(
1035
+ (a, b) => new URL(b.href).pathname.length - new URL(a.href).pathname.length
1036
+ );
1037
+
1038
+ for (const resourceUrl of sorted) {
1039
+ const document = {
1040
+ resource: resourceUrl.href,
1041
+ authorization_servers: [oauthMetadata.issuer],
1042
+ scopes_supported: scopesSupported,
1043
+ resource_name: resourceName,
1044
+ resource_documentation: serviceDocumentationUrl?.href,
1045
+ };
1046
+ router.use(protectedResourceMetadataPath(resourceUrl), metadataHandler(document));
1047
+ }
1048
+
1049
+ // RFC 8414 authorization-server metadata, so clients that only speak the
1050
+ // AS-metadata discovery path still find the issuer.
1051
+ router.use("/.well-known/oauth-authorization-server", metadataHandler(oauthMetadata));
1052
+
1053
+ return router;
1054
+ }
1055
+
1056
+ function checkResourceRouterIssuer(issuer) {
1057
+ const issuerUrl = new URL(issuer);
1058
+ const allowInsecure =
1059
+ process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === "true" ||
1060
+ process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === "1";
1061
+ if (
1062
+ issuerUrl.protocol !== "https:" &&
1063
+ issuerUrl.hostname !== "localhost" &&
1064
+ issuerUrl.hostname !== "127.0.0.1" &&
1065
+ !allowInsecure
1066
+ ) {
1067
+ throw new Error("Issuer URL must be HTTPS");
1068
+ }
1069
+ }
1070
+
890
1071
  export function createHostedOAuthSupport({
891
1072
  env = process.env,
892
- controlPlaneBaseUrl,
893
1073
  issuerUrl,
894
1074
  mcpServerUrl,
895
1075
  appName = "Apiosk",
896
1076
  resourceName = "Apiosk MCP",
1077
+ exchangePortalCode,
1078
+ refreshPortalToken,
897
1079
  } = {}) {
898
1080
  const secret = resolveOAuthSecret(env);
899
1081
  const provider = new ApioskHostedOAuthProvider({
900
1082
  env,
901
1083
  secret,
902
- controlPlaneBaseUrl,
903
1084
  issuerUrl,
904
1085
  mcpServerUrl,
905
1086
  appName,
906
1087
  resourceName,
1088
+ exchangePortalCode,
1089
+ refreshPortalToken,
907
1090
  });
908
1091
 
909
1092
  const oauthMetadata = createOAuthMetadata({
@@ -914,26 +1097,69 @@ export function createHostedOAuthSupport({
914
1097
  resourceName,
915
1098
  serviceDocumentationUrl: new URL("https://apiosk.com"),
916
1099
  });
917
- oauthMetadata.client_id_metadata_document_supported = true;
1100
+ // Claude prefers CIMD when advertised. Its metadata origin currently
1101
+ // challenges Fly egress with HTTP 403, which makes a healthy MCP endpoint
1102
+ // fail sign-in as invalid_client. DCR is local, durable through signed client
1103
+ // IDs, and supported by both hosts. Only advertise CIMD after deployment-
1104
+ // side reachability has been verified.
1105
+ oauthMetadata.client_id_metadata_document_supported = env.APIOSK_MCP_CIMD_ENABLED === "1";
1106
+
1107
+ const serviceDocumentationUrl = new URL("https://apiosk.com");
1108
+
1109
+ // Every transport surface published as its own OAuth resource, plus the
1110
+ // Streamable HTTP /mcp URL and the origin root.
1111
+ const resourceUrls = [
1112
+ ...TRANSPORT_RESOURCE_PATHS.map((path) => new URL(path, mcpServerUrl)),
1113
+ mcpServerUrl,
1114
+ new URL("/", mcpServerUrl),
1115
+ ];
918
1116
 
919
1117
  const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(mcpServerUrl);
1118
+ // PRM URL for the legacy HTTP+SSE transport, so a client that connected via
1119
+ // /sse (and posts to /messages) is handed metadata whose `resource` matches
1120
+ // the surface it is actually talking to.
1121
+ const sseResourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(
1122
+ new URL("/sse", mcpServerUrl)
1123
+ );
1124
+
1125
+ // The tool-call challenge rides in on the transport the client chose. Point
1126
+ // it at that transport's protected-resource metadata so the `resource` the
1127
+ // client discovers matches the URL it connected to (RFC 9728 / RFC 8707).
1128
+ function resolveResourceMetadataUrl(req) {
1129
+ const pathname = String(req?.path || req?.originalUrl || "")
1130
+ .split("?")[0]
1131
+ .replace(/\/+$/, "");
1132
+ if (pathname === "/messages" || pathname === "/sse") {
1133
+ return sseResourceMetadataUrl;
1134
+ }
1135
+ return resourceMetadataUrl;
1136
+ }
920
1137
 
921
1138
  return {
922
1139
  provider,
923
1140
  oauthMetadata,
924
1141
  resourceMetadataUrl,
925
- metadataRouter: mcpAuthMetadataRouter({
1142
+ sseResourceMetadataUrl,
1143
+ resourceUrls,
1144
+ metadataRouter: buildResourceMetadataRouter({
926
1145
  oauthMetadata,
927
- resourceServerUrl: mcpServerUrl,
1146
+ resourceUrls,
928
1147
  scopesSupported: SUPPORTED_SCOPES,
929
1148
  resourceName,
930
- serviceDocumentationUrl: new URL("https://apiosk.com"),
1149
+ serviceDocumentationUrl,
931
1150
  }),
932
1151
  authorizationRouter: authorizationHandler({ provider }),
933
1152
  tokenRouter: tokenHandler({ provider }),
934
1153
  registrationRouter: clientRegistrationHandler({ clientsStore: provider.clientsStore }),
1154
+ // GET /authorize/callback — buy.apiosk.com's return leg. Not part of the
1155
+ // SDK's authorizationHandler (that only ever starts a flow); this ends
1156
+ // one that started on this server and continued at the portal.
1157
+ async handlePortalCallback(req, res) {
1158
+ await provider.completePortalCallback(req, res);
1159
+ },
935
1160
  createMcpAuthMiddleware(runtime) {
936
1161
  return async (req, res, next) => {
1162
+ const challengeResourceMetadataUrl = resolveResourceMetadataUrl(req);
937
1163
  const bearerToken = extractBearerToken(req);
938
1164
 
939
1165
  if (bearerToken) {
@@ -944,7 +1170,7 @@ export function createHostedOAuthSupport({
944
1170
  status: 401,
945
1171
  code: "invalid_token",
946
1172
  message: error instanceof Error ? error.message : "Invalid access token",
947
- resourceMetadataUrl,
1173
+ resourceMetadataUrl: challengeResourceMetadataUrl,
948
1174
  });
949
1175
  return;
950
1176
  }
@@ -952,6 +1178,19 @@ export function createHostedOAuthSupport({
952
1178
 
953
1179
  const requestBody = req.body;
954
1180
  const method = trimString(requestBody?.method);
1181
+ // Every tool uses the connected account. Challenge during the
1182
+ // initial handshake too, so hosts do not install it as an anonymous
1183
+ // connector and discover the sign-in requirement only on first use.
1184
+ if (method && !req.auth) {
1185
+ writeAuthChallenge(res, {
1186
+ status: 401,
1187
+ code: "invalid_token",
1188
+ message: "Connect your Apiosk account once to use this connector.",
1189
+ resourceMetadataUrl: challengeResourceMetadataUrl,
1190
+ });
1191
+ return;
1192
+ }
1193
+
955
1194
  if (method !== "tools/call") {
956
1195
  next();
957
1196
  return;
@@ -970,7 +1209,7 @@ export function createHostedOAuthSupport({
970
1209
  status: 401,
971
1210
  code: "invalid_token",
972
1211
  message: "This Apiosk tool requires sign-in before it can run.",
973
- resourceMetadataUrl,
1212
+ resourceMetadataUrl: challengeResourceMetadataUrl,
974
1213
  });
975
1214
  return;
976
1215
  }
@@ -980,7 +1219,7 @@ export function createHostedOAuthSupport({
980
1219
  status: 403,
981
1220
  code: "insufficient_scope",
982
1221
  message: `This tool requires the ${DEFAULT_SCOPE} scope.`,
983
- resourceMetadataUrl,
1222
+ resourceMetadataUrl: challengeResourceMetadataUrl,
984
1223
  });
985
1224
  return;
986
1225
  }