@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.
- package/README.md +84 -537
- package/assets/brand/apiosk-a-20260921.png +0 -0
- package/assets/brand/apple-touch-icon.png +0 -0
- package/assets/brand/favicon-dark.ico +0 -0
- package/assets/brand/favicon-light.ico +0 -0
- package/assets/brand/favicon.ico +0 -0
- package/assets/brand/icon-192.png +0 -0
- package/assets/brand/icon-512.png +0 -0
- package/assets/brand/icon-maskable-512.png +0 -0
- package/assets/brand/inter-OFL.txt +93 -0
- package/assets/brand/inter-latin-400-normal.woff2 +0 -0
- package/assets/brand/inter-latin-500-normal.woff2 +0 -0
- package/assets/brand/inter-latin-600-normal.woff2 +0 -0
- package/assets/brand/mark-20260905-transparent.svg +1 -0
- package/assets/brand/mark-20260918.svg +1 -0
- package/assets/brand/mark-black-20260918.svg +1 -0
- package/assets/brand/mark-dark-20260905-transparent.png +0 -0
- package/assets/brand/mark-dark-20260918.png +0 -0
- package/assets/brand/mark-dark-96.png +0 -0
- package/assets/brand/mark-light-20260905-transparent.png +0 -0
- package/assets/brand/mark-light-20260918.png +0 -0
- package/assets/brand/mark-light-96.png +0 -0
- package/assets/brand/wordmark-black-320.png +0 -0
- package/assets/brand/wordmark-white-320.png +0 -0
- package/docs/branding.md +11 -0
- package/docs/marketplace-submission-2026-04-08.md +4 -0
- package/docs/marketplace-submission-2026-08-20.md +157 -0
- package/docs/openai-plugin-submission-2026-09-05.md +143 -0
- package/docs/sepa-rail.md +13 -13
- package/dxt.json +26 -22
- package/index.mjs +3 -1
- package/logo-optimized-light.png +0 -0
- package/package.json +15 -13
- package/plugin/apiosk/.codex-plugin/plugin.json +45 -0
- package/plugin/apiosk/.mcp.json +8 -0
- package/plugin/apiosk/assets/icon-dark.png +0 -0
- package/plugin/apiosk/assets/icon.png +0 -0
- package/plugin/apiosk/assets/icon.svg +1 -0
- package/plugin/apiosk/assets/logo.png +0 -0
- package/plugin/apiosk/skills/apiosk/SKILL.md +33 -0
- package/plugin/apiosk/skills/apiosk/agents/openai.yaml +12 -0
- package/server.json +98 -9
- package/server.mjs +258 -71
- package/src/approval-feedback.mjs +21 -0
- package/src/brand-routes.mjs +28 -0
- package/src/create-server.mjs +168 -9
- package/src/display-money.mjs +20 -0
- package/src/display-text.mjs +67 -0
- package/src/gateway-client.mjs +42 -0
- package/src/gateway-v2-ask.mjs +50 -0
- package/src/gateway-v2-card-account.mjs +23 -0
- package/src/gateway-v2-card-actions.mjs +67 -0
- package/src/gateway-v2-card-answer-text.mjs +130 -0
- package/src/gateway-v2-card-answer.mjs +68 -0
- package/src/gateway-v2-card-blocks.mjs +166 -0
- package/src/gateway-v2-card-body.mjs +259 -0
- package/src/gateway-v2-card-budget.mjs +21 -0
- package/src/gateway-v2-card-cbs.mjs +56 -0
- package/src/gateway-v2-card-choices.mjs +4 -0
- package/src/gateway-v2-card-clarification.mjs +25 -0
- package/src/gateway-v2-card-compact.mjs +34 -0
- package/src/gateway-v2-card-events.mjs +41 -0
- package/src/gateway-v2-card-presentation.mjs +125 -0
- package/src/gateway-v2-card-research.mjs +63 -0
- package/src/gateway-v2-card-result.mjs +32 -0
- package/src/gateway-v2-card-search.mjs +39 -0
- package/src/gateway-v2-card-sources.mjs +32 -0
- package/src/gateway-v2-card-style.mjs +76 -0
- package/src/gateway-v2-card-verdict.mjs +66 -0
- package/src/gateway-v2-card.mjs +91 -0
- package/src/gateway-v2-contracts.json +66 -0
- package/src/gateway-v2-instructions.md +113 -0
- package/src/gateway-v2-recovery.mjs +18 -0
- package/src/gateway-v2-report-links.mjs +14 -0
- package/src/gateway-v2-workflows.mjs +17 -0
- package/src/gateway-v2.mjs +179 -0
- package/src/oauth.mjs +603 -364
- package/src/observability.mjs +210 -0
- package/src/result-presentation.mjs +9 -0
- package/src/runtime.mjs +24 -3091
- package/src/settlement-disclosure.mjs +26 -0
- package/src/source-groups.mjs +91 -0
- package/src/source-value-format.mjs +25 -0
- package/src/tool-result.mjs +20 -0
- package/src/ui-bridge.mjs +185 -0
- package/src/well-known-routes.mjs +132 -0
- package/src/funding-options.mjs +0 -255
- package/src/gateway-management.mjs +0 -107
- package/src/listing-metadata.mjs +0 -287
- package/src/local-config.mjs +0 -256
- package/src/payment-guidance.mjs +0 -307
- 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
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
<
|
|
207
|
-
|
|
208
|
-
<
|
|
209
|
-
<
|
|
210
|
-
|
|
211
|
-
|
|
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({
|
|
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
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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(
|
|
516
|
-
.
|
|
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
|
-
|
|
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:
|
|
533
|
-
error_description:
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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(
|
|
572
|
-
.
|
|
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
|
-
|
|
588
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
701
|
+
maxExpiry
|
|
625
702
|
).token;
|
|
626
703
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
|
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
|
|
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="${
|
|
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
|
-
|
|
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
|
-
|
|
1142
|
+
sseResourceMetadataUrl,
|
|
1143
|
+
resourceUrls,
|
|
1144
|
+
metadataRouter: buildResourceMetadataRouter({
|
|
926
1145
|
oauthMetadata,
|
|
927
|
-
|
|
1146
|
+
resourceUrls,
|
|
928
1147
|
scopesSupported: SUPPORTED_SCOPES,
|
|
929
1148
|
resourceName,
|
|
930
|
-
serviceDocumentationUrl
|
|
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
|
}
|