anyapi-mcp-server 1.6.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -195
- package/build/api-client.js +93 -55
- package/build/api-index.js +41 -0
- package/build/config.js +55 -1
- package/build/error-context.js +1 -1
- package/build/index.js +172 -4
- package/build/oauth.js +340 -0
- package/build/response-parser.js +47 -2
- package/package.json +1 -1
package/build/config.js
CHANGED
|
@@ -62,7 +62,17 @@ Required:
|
|
|
62
62
|
Optional:
|
|
63
63
|
--header HTTP header as "Key: Value" (repeatable)
|
|
64
64
|
Supports \${ENV_VAR} interpolation in values
|
|
65
|
-
--log Path to request/response log file (NDJSON format)
|
|
65
|
+
--log Path to request/response log file (NDJSON format)
|
|
66
|
+
|
|
67
|
+
OAuth 2.0 (all optional, but client-id/client-secret/token-url are required together):
|
|
68
|
+
--oauth-client-id OAuth client ID
|
|
69
|
+
--oauth-client-secret OAuth client secret
|
|
70
|
+
--oauth-token-url OAuth token endpoint URL
|
|
71
|
+
--oauth-auth-url OAuth authorization endpoint URL (authorization_code flow)
|
|
72
|
+
--oauth-scopes Comma-separated scopes (e.g. "read,write")
|
|
73
|
+
--oauth-flow "authorization_code" (default) or "client_credentials"
|
|
74
|
+
--oauth-param Extra auth URL param as "key=value" (repeatable)
|
|
75
|
+
All OAuth values support \${ENV_VAR} interpolation`;
|
|
66
76
|
export async function loadConfig() {
|
|
67
77
|
const name = getArg("--name");
|
|
68
78
|
const specUrls = getAllArgs("--spec");
|
|
@@ -84,11 +94,55 @@ export async function loadConfig() {
|
|
|
84
94
|
headers[key] = interpolateEnv(value);
|
|
85
95
|
}
|
|
86
96
|
const logPath = getArg("--log");
|
|
97
|
+
// --- OAuth CLI flags ---
|
|
98
|
+
const oauthClientId = getArg("--oauth-client-id");
|
|
99
|
+
const oauthClientSecret = getArg("--oauth-client-secret");
|
|
100
|
+
const oauthTokenUrl = getArg("--oauth-token-url");
|
|
101
|
+
const oauthAuthUrl = getArg("--oauth-auth-url");
|
|
102
|
+
const oauthScopes = getArg("--oauth-scopes");
|
|
103
|
+
const oauthFlow = getArg("--oauth-flow");
|
|
104
|
+
const oauthParams = getAllArgs("--oauth-param");
|
|
105
|
+
const hasAnyOAuth = oauthClientId || oauthClientSecret || oauthTokenUrl;
|
|
106
|
+
if (hasAnyOAuth && !(oauthClientId && oauthClientSecret && oauthTokenUrl)) {
|
|
107
|
+
console.error("ERROR: --oauth-client-id, --oauth-client-secret, and --oauth-token-url must all be provided together.");
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
let oauth;
|
|
111
|
+
if (oauthClientId && oauthClientSecret && oauthTokenUrl) {
|
|
112
|
+
const extraParams = {};
|
|
113
|
+
for (const raw of oauthParams) {
|
|
114
|
+
const eqIdx = raw.indexOf("=");
|
|
115
|
+
if (eqIdx === -1) {
|
|
116
|
+
console.error(`ERROR: Invalid --oauth-param format "${raw}". Expected "key=value"`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
extraParams[raw.slice(0, eqIdx)] = interpolateEnv(raw.slice(eqIdx + 1));
|
|
120
|
+
}
|
|
121
|
+
const flow = (oauthFlow ? interpolateEnv(oauthFlow) : "authorization_code");
|
|
122
|
+
if (flow !== "authorization_code" && flow !== "client_credentials") {
|
|
123
|
+
console.error(`ERROR: Invalid --oauth-flow "${flow}". Must be "authorization_code" or "client_credentials".`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
oauth = {
|
|
127
|
+
clientId: interpolateEnv(oauthClientId),
|
|
128
|
+
clientSecret: interpolateEnv(oauthClientSecret),
|
|
129
|
+
tokenUrl: interpolateEnv(oauthTokenUrl),
|
|
130
|
+
authUrl: oauthAuthUrl ? interpolateEnv(oauthAuthUrl) : undefined,
|
|
131
|
+
scopes: oauthScopes
|
|
132
|
+
? interpolateEnv(oauthScopes)
|
|
133
|
+
.split(/[,\s]+/)
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
: [],
|
|
136
|
+
flow,
|
|
137
|
+
extraParams,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
87
140
|
return {
|
|
88
141
|
name,
|
|
89
142
|
specs,
|
|
90
143
|
baseUrl: interpolateEnv(baseUrl).replace(/\/+$/, ""),
|
|
91
144
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
92
145
|
logPath: logPath ? resolve(logPath) : undefined,
|
|
146
|
+
oauth,
|
|
93
147
|
};
|
|
94
148
|
}
|
package/build/error-context.js
CHANGED
|
@@ -97,7 +97,7 @@ function getSuggestion(status, endpoint) {
|
|
|
97
97
|
return msg;
|
|
98
98
|
}
|
|
99
99
|
case 401:
|
|
100
|
-
return "Authentication required.
|
|
100
|
+
return "Authentication required. Use the auth tool to authenticate via OAuth, or provide credentials via --header (e.g. --header \"Authorization: Bearer <token>\") or per-request headers parameter.";
|
|
101
101
|
case 403:
|
|
102
102
|
return "Forbidden. Your credentials don't have permission for this operation. Verify your API key or token has the required scopes.";
|
|
103
103
|
case 404: {
|
package/build/index.js
CHANGED
|
@@ -10,10 +10,26 @@ import { generateSuggestions } from "./query-suggestions.js";
|
|
|
10
10
|
import { getOrBuildSchema, executeQuery, schemaToSDL, truncateIfArray, computeShapeHash, } from "./graphql-schema.js";
|
|
11
11
|
import { ApiError, buildErrorContext } from "./error-context.js";
|
|
12
12
|
import { RetryableError } from "./retry.js";
|
|
13
|
+
import { isNonJsonResult } from "./response-parser.js";
|
|
14
|
+
import { startAuth, exchangeCode, awaitCallback, storeTokens, getTokens, isTokenExpired, initTokenStorage, } from "./oauth.js";
|
|
13
15
|
const WRITE_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
|
|
14
16
|
const config = await loadConfig();
|
|
15
17
|
initLogger(config.logPath ?? null);
|
|
16
18
|
const apiIndex = new ApiIndex(config.specs);
|
|
19
|
+
// --- OAuth: merge spec-derived security info and init token storage ---
|
|
20
|
+
if (config.oauth) {
|
|
21
|
+
const schemes = apiIndex.getOAuthSchemes();
|
|
22
|
+
if (schemes.length > 0) {
|
|
23
|
+
const scheme = schemes[0];
|
|
24
|
+
if (!config.oauth.authUrl && scheme.authorizationUrl) {
|
|
25
|
+
config.oauth.authUrl = scheme.authorizationUrl;
|
|
26
|
+
}
|
|
27
|
+
if (config.oauth.scopes.length === 0 && scheme.scopes.length > 0) {
|
|
28
|
+
config.oauth.scopes = scheme.scopes;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
initTokenStorage(config.name);
|
|
32
|
+
}
|
|
17
33
|
function formatToolError(error, method, path) {
|
|
18
34
|
if ((error instanceof ApiError || error instanceof RetryableError) && method && path) {
|
|
19
35
|
const endpoint = apiIndex.getEndpoint(method, path);
|
|
@@ -145,12 +161,25 @@ server.tool("call_api", `Inspect a ${config.name} API endpoint. Makes a real req
|
|
|
145
161
|
"Overrides default --header values."),
|
|
146
162
|
}, async ({ method, path, params, body, headers }) => {
|
|
147
163
|
try {
|
|
148
|
-
const data = await callApi(config, method, path, params, body, headers, "populate");
|
|
164
|
+
const { data, responseHeaders: respHeaders } = await callApi(config, method, path, params, body, headers, "populate");
|
|
165
|
+
// Non-JSON response — skip GraphQL layer, return raw parsed data
|
|
166
|
+
if (isNonJsonResult(data)) {
|
|
167
|
+
return {
|
|
168
|
+
content: [
|
|
169
|
+
{ type: "text", text: JSON.stringify({
|
|
170
|
+
rawResponse: data,
|
|
171
|
+
responseHeaders: respHeaders,
|
|
172
|
+
hint: "This endpoint returned a non-JSON response. The raw parsed content is shown above. " +
|
|
173
|
+
"GraphQL schema inference is not available for non-JSON responses — use the data directly.",
|
|
174
|
+
}, null, 2) },
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
149
178
|
const endpoint = apiIndex.getEndpoint(method, path);
|
|
150
179
|
const bodyHash = WRITE_METHODS.has(method) && body ? computeShapeHash(body) : undefined;
|
|
151
180
|
const { schema, shapeHash } = getOrBuildSchema(data, method, path, endpoint?.requestBodySchema, bodyHash);
|
|
152
181
|
const sdl = schemaToSDL(schema);
|
|
153
|
-
const result = { graphqlSchema: sdl, shapeHash };
|
|
182
|
+
const result = { graphqlSchema: sdl, shapeHash, responseHeaders: respHeaders };
|
|
154
183
|
if (bodyHash)
|
|
155
184
|
result.bodyHash = bodyHash;
|
|
156
185
|
if (endpoint && endpoint.parameters.length > 0) {
|
|
@@ -240,7 +269,20 @@ server.tool("query_api", `Fetch data from a ${config.name} API endpoint, returni
|
|
|
240
269
|
.describe("Client-side slice: items to skip in already-fetched response (default: 0). For API pagination, use params instead."),
|
|
241
270
|
}, async ({ method, path, params, body, query, headers, limit, offset }) => {
|
|
242
271
|
try {
|
|
243
|
-
const rawData = await callApi(config, method, path, params, body, headers, "consume");
|
|
272
|
+
const { data: rawData, responseHeaders: respHeaders } = await callApi(config, method, path, params, body, headers, "consume");
|
|
273
|
+
// Non-JSON response — skip GraphQL layer, return raw parsed data
|
|
274
|
+
if (isNonJsonResult(rawData)) {
|
|
275
|
+
return {
|
|
276
|
+
content: [
|
|
277
|
+
{ type: "text", text: JSON.stringify({
|
|
278
|
+
rawResponse: rawData,
|
|
279
|
+
responseHeaders: respHeaders,
|
|
280
|
+
hint: "This endpoint returned a non-JSON response. GraphQL querying is not available. " +
|
|
281
|
+
"The raw parsed content is shown above.",
|
|
282
|
+
}, null, 2) },
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
244
286
|
const endpoint = apiIndex.getEndpoint(method, path);
|
|
245
287
|
const bodyHash = WRITE_METHODS.has(method) && body ? computeShapeHash(body) : undefined;
|
|
246
288
|
const { schema, shapeHash } = getOrBuildSchema(rawData, method, path, endpoint?.requestBodySchema, bodyHash);
|
|
@@ -248,6 +290,7 @@ server.tool("query_api", `Fetch data from a ${config.name} API endpoint, returni
|
|
|
248
290
|
const queryResult = await executeQuery(schema, data, query);
|
|
249
291
|
if (typeof queryResult === "object" && queryResult !== null) {
|
|
250
292
|
queryResult._shapeHash = shapeHash;
|
|
293
|
+
queryResult._responseHeaders = respHeaders;
|
|
251
294
|
if (bodyHash)
|
|
252
295
|
queryResult._bodyHash = bodyHash;
|
|
253
296
|
if (truncated) {
|
|
@@ -387,7 +430,17 @@ server.tool("batch_query", `Fetch data from multiple ${config.name} API endpoint
|
|
|
387
430
|
}, async ({ requests }) => {
|
|
388
431
|
try {
|
|
389
432
|
const settled = await Promise.allSettled(requests.map(async (req) => {
|
|
390
|
-
const rawData = await callApi(config, req.method, req.path, req.params, req.body, req.headers, "none");
|
|
433
|
+
const { data: rawData, responseHeaders: respHeaders } = await callApi(config, req.method, req.path, req.params, req.body, req.headers, "none");
|
|
434
|
+
// Non-JSON response — skip GraphQL layer
|
|
435
|
+
if (isNonJsonResult(rawData)) {
|
|
436
|
+
return {
|
|
437
|
+
method: req.method,
|
|
438
|
+
path: req.path,
|
|
439
|
+
data: rawData,
|
|
440
|
+
responseHeaders: respHeaders,
|
|
441
|
+
nonJson: true,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
391
444
|
const endpoint = apiIndex.getEndpoint(req.method, req.path);
|
|
392
445
|
const bodyHash = WRITE_METHODS.has(req.method) && req.body
|
|
393
446
|
? computeShapeHash(req.body)
|
|
@@ -398,6 +451,7 @@ server.tool("batch_query", `Fetch data from multiple ${config.name} API endpoint
|
|
|
398
451
|
method: req.method,
|
|
399
452
|
path: req.path,
|
|
400
453
|
data: queryResult,
|
|
454
|
+
responseHeaders: respHeaders,
|
|
401
455
|
shapeHash,
|
|
402
456
|
...(bodyHash ? { bodyHash } : {}),
|
|
403
457
|
};
|
|
@@ -434,6 +488,120 @@ server.tool("batch_query", `Fetch data from multiple ${config.name} API endpoint
|
|
|
434
488
|
};
|
|
435
489
|
}
|
|
436
490
|
});
|
|
491
|
+
// --- Tool 6: auth (only when OAuth is configured) ---
|
|
492
|
+
if (config.oauth) {
|
|
493
|
+
server.tool("auth", `Manage OAuth 2.0 authentication for ${config.name}. ` +
|
|
494
|
+
"Use action 'start' to begin the OAuth flow (returns an authorization URL for " +
|
|
495
|
+
"authorization_code flow, or completes token exchange for client_credentials). " +
|
|
496
|
+
"Use action 'exchange' to complete the flow — the callback is captured automatically " +
|
|
497
|
+
"via a localhost server, or you can provide a 'code' manually. " +
|
|
498
|
+
"Use action 'status' to check the current token status.", {
|
|
499
|
+
action: z
|
|
500
|
+
.enum(["start", "exchange", "status"])
|
|
501
|
+
.describe("'start' begins auth flow, 'exchange' completes code exchange, 'status' shows token info"),
|
|
502
|
+
code: z
|
|
503
|
+
.string()
|
|
504
|
+
.optional()
|
|
505
|
+
.describe("Authorization code from the OAuth provider (optional for 'exchange' — " +
|
|
506
|
+
"if omitted, waits for the localhost callback automatically)"),
|
|
507
|
+
}, async ({ action, code }) => {
|
|
508
|
+
try {
|
|
509
|
+
if (action === "start") {
|
|
510
|
+
const result = await startAuth(config.oauth);
|
|
511
|
+
if ("url" in result) {
|
|
512
|
+
return {
|
|
513
|
+
content: [
|
|
514
|
+
{
|
|
515
|
+
type: "text",
|
|
516
|
+
text: JSON.stringify({
|
|
517
|
+
message: "Open this URL to authorize. A local callback server is listening. " +
|
|
518
|
+
"After you approve, call auth with action 'exchange' to complete authentication.",
|
|
519
|
+
authorizationUrl: result.url,
|
|
520
|
+
flow: config.oauth.flow,
|
|
521
|
+
}, null, 2),
|
|
522
|
+
},
|
|
523
|
+
],
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
// client_credentials: tokens obtained directly
|
|
527
|
+
storeTokens(result.tokens);
|
|
528
|
+
return {
|
|
529
|
+
content: [
|
|
530
|
+
{
|
|
531
|
+
type: "text",
|
|
532
|
+
text: JSON.stringify({
|
|
533
|
+
message: "Authentication successful (client_credentials flow).",
|
|
534
|
+
tokenType: result.tokens.tokenType,
|
|
535
|
+
expiresIn: Math.round((result.tokens.expiresAt - Date.now()) / 1000),
|
|
536
|
+
scope: result.tokens.scope ?? null,
|
|
537
|
+
}, null, 2),
|
|
538
|
+
},
|
|
539
|
+
],
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
if (action === "exchange") {
|
|
543
|
+
const tokens = code
|
|
544
|
+
? await exchangeCode(config.oauth, code)
|
|
545
|
+
: await awaitCallback(config.oauth);
|
|
546
|
+
storeTokens(tokens);
|
|
547
|
+
return {
|
|
548
|
+
content: [
|
|
549
|
+
{
|
|
550
|
+
type: "text",
|
|
551
|
+
text: JSON.stringify({
|
|
552
|
+
message: "Authentication successful.",
|
|
553
|
+
tokenType: tokens.tokenType,
|
|
554
|
+
expiresIn: Math.round((tokens.expiresAt - Date.now()) / 1000),
|
|
555
|
+
hasRefreshToken: !!tokens.refreshToken,
|
|
556
|
+
scope: tokens.scope ?? null,
|
|
557
|
+
}, null, 2),
|
|
558
|
+
},
|
|
559
|
+
],
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
// action === "status"
|
|
563
|
+
const tokens = getTokens();
|
|
564
|
+
if (!tokens) {
|
|
565
|
+
return {
|
|
566
|
+
content: [
|
|
567
|
+
{
|
|
568
|
+
type: "text",
|
|
569
|
+
text: JSON.stringify({
|
|
570
|
+
authenticated: false,
|
|
571
|
+
message: "No tokens stored. Use auth with action 'start' to authenticate.",
|
|
572
|
+
}, null, 2),
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
const expired = isTokenExpired();
|
|
578
|
+
return {
|
|
579
|
+
content: [
|
|
580
|
+
{
|
|
581
|
+
type: "text",
|
|
582
|
+
text: JSON.stringify({
|
|
583
|
+
authenticated: true,
|
|
584
|
+
tokenType: tokens.tokenType,
|
|
585
|
+
expired,
|
|
586
|
+
expiresIn: Math.round((tokens.expiresAt - Date.now()) / 1000),
|
|
587
|
+
hasRefreshToken: !!tokens.refreshToken,
|
|
588
|
+
scope: tokens.scope ?? null,
|
|
589
|
+
}, null, 2),
|
|
590
|
+
},
|
|
591
|
+
],
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
596
|
+
return {
|
|
597
|
+
content: [
|
|
598
|
+
{ type: "text", text: JSON.stringify({ error: message }) },
|
|
599
|
+
],
|
|
600
|
+
isError: true,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
437
605
|
async function main() {
|
|
438
606
|
const transport = new StdioServerTransport();
|
|
439
607
|
await server.connect(transport);
|
package/build/oauth.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
const EXPIRY_BUFFER_MS = 60_000;
|
|
7
|
+
const CALLBACK_PATH = "/callback";
|
|
8
|
+
const CALLBACK_TIMEOUT_MS = 300_000; // 5 minutes
|
|
9
|
+
const TOKEN_DIR = process.platform === "win32"
|
|
10
|
+
? join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "anyapi-mcp", "tokens")
|
|
11
|
+
: join(homedir(), ".cache", "anyapi-mcp", "tokens");
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// PKCE helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
function generateCodeVerifier() {
|
|
16
|
+
return randomBytes(32).toString("base64url");
|
|
17
|
+
}
|
|
18
|
+
function generateCodeChallenge(verifier) {
|
|
19
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
20
|
+
}
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Module state
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
let pendingPkce = null;
|
|
25
|
+
let currentTokens = null;
|
|
26
|
+
let persistName = null;
|
|
27
|
+
let refreshPromise = null;
|
|
28
|
+
// Localhost callback server state
|
|
29
|
+
let callbackServer = null;
|
|
30
|
+
let callbackCodePromise = null;
|
|
31
|
+
let callbackRedirectUri = "";
|
|
32
|
+
let callbackTimeoutHandle = null;
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Token persistence
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
export function initTokenStorage(name) {
|
|
37
|
+
persistName = name;
|
|
38
|
+
const tokenPath = join(TOKEN_DIR, `${name}.json`);
|
|
39
|
+
if (existsSync(tokenPath)) {
|
|
40
|
+
try {
|
|
41
|
+
const raw = readFileSync(tokenPath, "utf-8");
|
|
42
|
+
const loaded = JSON.parse(raw);
|
|
43
|
+
if (loaded.accessToken && typeof loaded.expiresAt === "number") {
|
|
44
|
+
currentTokens = loaded;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Corrupt file — will re-auth
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function persistTokens(tokens) {
|
|
53
|
+
if (!persistName)
|
|
54
|
+
return;
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(TOKEN_DIR, { recursive: true });
|
|
57
|
+
writeFileSync(join(TOKEN_DIR, `${persistName}.json`), JSON.stringify(tokens, null, 2), "utf-8");
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
console.error(`[oauth] Failed to persist tokens: ${err}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Token accessors
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
export function storeTokens(tokens) {
|
|
67
|
+
currentTokens = tokens;
|
|
68
|
+
persistTokens(tokens);
|
|
69
|
+
}
|
|
70
|
+
export function getTokens() {
|
|
71
|
+
return currentTokens;
|
|
72
|
+
}
|
|
73
|
+
export function clearTokens() {
|
|
74
|
+
currentTokens = null;
|
|
75
|
+
}
|
|
76
|
+
export function isTokenExpired() {
|
|
77
|
+
if (!currentTokens)
|
|
78
|
+
return true;
|
|
79
|
+
return Date.now() >= currentTokens.expiresAt - EXPIRY_BUFFER_MS;
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Localhost callback server
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
function cleanupCallbackServer() {
|
|
85
|
+
if (callbackTimeoutHandle) {
|
|
86
|
+
clearTimeout(callbackTimeoutHandle);
|
|
87
|
+
callbackTimeoutHandle = null;
|
|
88
|
+
}
|
|
89
|
+
if (callbackServer) {
|
|
90
|
+
callbackServer.close();
|
|
91
|
+
callbackServer = null;
|
|
92
|
+
}
|
|
93
|
+
callbackCodePromise = null;
|
|
94
|
+
callbackRedirectUri = "";
|
|
95
|
+
}
|
|
96
|
+
function startCallbackServer() {
|
|
97
|
+
return new Promise((resolveSetup, rejectSetup) => {
|
|
98
|
+
let resolveCode;
|
|
99
|
+
let rejectCode;
|
|
100
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
101
|
+
resolveCode = resolve;
|
|
102
|
+
rejectCode = reject;
|
|
103
|
+
});
|
|
104
|
+
// Mark promise as handled to prevent Node.js unhandled rejection warning.
|
|
105
|
+
// The rejection will still propagate through awaitCallback/exchangeCode.
|
|
106
|
+
codePromise.catch(() => { });
|
|
107
|
+
const server = createServer((req, res) => {
|
|
108
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1`);
|
|
109
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
110
|
+
res.writeHead(404);
|
|
111
|
+
res.end("Not found");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const code = url.searchParams.get("code");
|
|
115
|
+
const error = url.searchParams.get("error");
|
|
116
|
+
const errorDesc = url.searchParams.get("error_description");
|
|
117
|
+
if (error) {
|
|
118
|
+
const msg = errorDesc ? `${error}: ${errorDesc}` : error;
|
|
119
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
120
|
+
res.end("<html><body style=\"font-family:system-ui;text-align:center;padding:40px\">" +
|
|
121
|
+
`<h1>Authentication Failed</h1><p>${msg}</p>` +
|
|
122
|
+
"<p style=\"color:#666\">You can close this window.</p></body></html>");
|
|
123
|
+
rejectCode(new Error(`OAuth authorization failed: ${msg}`));
|
|
124
|
+
}
|
|
125
|
+
else if (code) {
|
|
126
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
127
|
+
res.end("<html><body style=\"font-family:system-ui;text-align:center;padding:40px\">" +
|
|
128
|
+
"<h1>Authentication Successful</h1>" +
|
|
129
|
+
"<p>You can close this window and return to Claude.</p></body></html>");
|
|
130
|
+
resolveCode(code);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
134
|
+
res.end("<html><body style=\"font-family:system-ui;text-align:center;padding:40px\">" +
|
|
135
|
+
"<h1>Error</h1><p>No authorization code received.</p></body></html>");
|
|
136
|
+
}
|
|
137
|
+
// Close the HTTP server (no longer needed) but preserve
|
|
138
|
+
// callbackCodePromise and callbackRedirectUri for awaitCallback/exchangeCode.
|
|
139
|
+
if (callbackTimeoutHandle) {
|
|
140
|
+
clearTimeout(callbackTimeoutHandle);
|
|
141
|
+
callbackTimeoutHandle = null;
|
|
142
|
+
}
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
if (callbackServer) {
|
|
145
|
+
callbackServer.close();
|
|
146
|
+
callbackServer = null;
|
|
147
|
+
}
|
|
148
|
+
}, 500);
|
|
149
|
+
});
|
|
150
|
+
server.on("error", rejectSetup);
|
|
151
|
+
server.listen(0, "127.0.0.1", () => {
|
|
152
|
+
const addr = server.address();
|
|
153
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
154
|
+
callbackServer = server;
|
|
155
|
+
// Timeout: reject code promise and clean up after 5 minutes
|
|
156
|
+
callbackTimeoutHandle = setTimeout(() => {
|
|
157
|
+
rejectCode(new Error("OAuth callback timed out after 5 minutes. Please try auth start again."));
|
|
158
|
+
cleanupCallbackServer();
|
|
159
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
160
|
+
resolveSetup({ port, codePromise });
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Token exchange helpers
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
async function fetchTokens(tokenUrl, body) {
|
|
168
|
+
const res = await fetch(tokenUrl, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
171
|
+
body: body.toString(),
|
|
172
|
+
});
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
const errorBody = await res.text();
|
|
175
|
+
throw new Error(`OAuth token request failed (${res.status} ${res.statusText}): ${errorBody}`);
|
|
176
|
+
}
|
|
177
|
+
const data = (await res.json());
|
|
178
|
+
if (typeof data.access_token !== "string") {
|
|
179
|
+
throw new Error("OAuth token response missing access_token");
|
|
180
|
+
}
|
|
181
|
+
const expiresIn = typeof data.expires_in === "number" ? data.expires_in : 3600;
|
|
182
|
+
return {
|
|
183
|
+
accessToken: data.access_token,
|
|
184
|
+
refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : undefined,
|
|
185
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
186
|
+
tokenType: typeof data.token_type === "string" ? data.token_type : "Bearer",
|
|
187
|
+
scope: typeof data.scope === "string" ? data.scope : undefined,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
async function exchangeClientCredentials(config) {
|
|
191
|
+
const body = new URLSearchParams({
|
|
192
|
+
grant_type: "client_credentials",
|
|
193
|
+
client_id: config.clientId,
|
|
194
|
+
client_secret: config.clientSecret,
|
|
195
|
+
});
|
|
196
|
+
if (config.scopes.length > 0) {
|
|
197
|
+
body.set("scope", config.scopes.join(" "));
|
|
198
|
+
}
|
|
199
|
+
return fetchTokens(config.tokenUrl, body);
|
|
200
|
+
}
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Public API — auth flow
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
export async function startAuth(config) {
|
|
205
|
+
if (config.flow === "client_credentials") {
|
|
206
|
+
const tokens = await exchangeClientCredentials(config);
|
|
207
|
+
return { tokens };
|
|
208
|
+
}
|
|
209
|
+
if (!config.authUrl) {
|
|
210
|
+
throw new Error("OAuth authorization URL is required for authorization_code flow. " +
|
|
211
|
+
"Provide --oauth-auth-url or ensure the OpenAPI spec has securitySchemes with an authorizationUrl.");
|
|
212
|
+
}
|
|
213
|
+
// Clean up any previous callback server
|
|
214
|
+
cleanupCallbackServer();
|
|
215
|
+
const verifier = generateCodeVerifier();
|
|
216
|
+
const challenge = generateCodeChallenge(verifier);
|
|
217
|
+
const state = randomBytes(16).toString("hex");
|
|
218
|
+
pendingPkce = { verifier, state };
|
|
219
|
+
// Start localhost callback server on a random available port
|
|
220
|
+
const { port, codePromise } = await startCallbackServer();
|
|
221
|
+
callbackRedirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
|
|
222
|
+
callbackCodePromise = codePromise;
|
|
223
|
+
const params = new URLSearchParams({
|
|
224
|
+
response_type: "code",
|
|
225
|
+
client_id: config.clientId,
|
|
226
|
+
redirect_uri: callbackRedirectUri,
|
|
227
|
+
code_challenge: challenge,
|
|
228
|
+
code_challenge_method: "S256",
|
|
229
|
+
state,
|
|
230
|
+
...config.extraParams,
|
|
231
|
+
});
|
|
232
|
+
if (config.scopes.length > 0) {
|
|
233
|
+
params.set("scope", config.scopes.join(" "));
|
|
234
|
+
}
|
|
235
|
+
const url = `${config.authUrl}?${params.toString()}`;
|
|
236
|
+
return { url };
|
|
237
|
+
}
|
|
238
|
+
export async function exchangeCode(config, code) {
|
|
239
|
+
if (!pendingPkce) {
|
|
240
|
+
throw new Error("No pending authorization flow. Call auth with action 'start' first.");
|
|
241
|
+
}
|
|
242
|
+
const { verifier } = pendingPkce;
|
|
243
|
+
pendingPkce = null;
|
|
244
|
+
const redirectUri = callbackRedirectUri;
|
|
245
|
+
const body = new URLSearchParams({
|
|
246
|
+
grant_type: "authorization_code",
|
|
247
|
+
code,
|
|
248
|
+
client_id: config.clientId,
|
|
249
|
+
client_secret: config.clientSecret,
|
|
250
|
+
code_verifier: verifier,
|
|
251
|
+
redirect_uri: redirectUri,
|
|
252
|
+
});
|
|
253
|
+
cleanupCallbackServer();
|
|
254
|
+
return fetchTokens(config.tokenUrl, body);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Wait for the localhost callback server to receive the authorization code,
|
|
258
|
+
* then exchange it for tokens automatically.
|
|
259
|
+
*/
|
|
260
|
+
export async function awaitCallback(config) {
|
|
261
|
+
if (!callbackCodePromise) {
|
|
262
|
+
throw new Error("No pending authorization flow. Call auth with action 'start' first.");
|
|
263
|
+
}
|
|
264
|
+
if (!pendingPkce) {
|
|
265
|
+
throw new Error("No pending PKCE state. Call auth with action 'start' first.");
|
|
266
|
+
}
|
|
267
|
+
const code = await callbackCodePromise;
|
|
268
|
+
const { verifier } = pendingPkce;
|
|
269
|
+
pendingPkce = null;
|
|
270
|
+
const redirectUri = callbackRedirectUri;
|
|
271
|
+
const body = new URLSearchParams({
|
|
272
|
+
grant_type: "authorization_code",
|
|
273
|
+
code,
|
|
274
|
+
client_id: config.clientId,
|
|
275
|
+
client_secret: config.clientSecret,
|
|
276
|
+
code_verifier: verifier,
|
|
277
|
+
redirect_uri: redirectUri,
|
|
278
|
+
});
|
|
279
|
+
cleanupCallbackServer();
|
|
280
|
+
return fetchTokens(config.tokenUrl, body);
|
|
281
|
+
}
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Token refresh
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
export async function refreshTokens(config) {
|
|
286
|
+
if (!currentTokens?.refreshToken) {
|
|
287
|
+
throw new Error("Cannot refresh: no refresh token available. " +
|
|
288
|
+
"Re-authenticate using the auth tool with action 'start'.");
|
|
289
|
+
}
|
|
290
|
+
const body = new URLSearchParams({
|
|
291
|
+
grant_type: "refresh_token",
|
|
292
|
+
refresh_token: currentTokens.refreshToken,
|
|
293
|
+
client_id: config.clientId,
|
|
294
|
+
client_secret: config.clientSecret,
|
|
295
|
+
});
|
|
296
|
+
const tokens = await fetchTokens(config.tokenUrl, body);
|
|
297
|
+
// Preserve refresh token if server didn't issue a new one
|
|
298
|
+
if (!tokens.refreshToken && currentTokens.refreshToken) {
|
|
299
|
+
tokens.refreshToken = currentTokens.refreshToken;
|
|
300
|
+
}
|
|
301
|
+
storeTokens(tokens);
|
|
302
|
+
return tokens;
|
|
303
|
+
}
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Auth middleware — called by api-client before each request
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
export async function getValidAccessToken(config) {
|
|
308
|
+
if (!config || !currentTokens)
|
|
309
|
+
return undefined;
|
|
310
|
+
if (isTokenExpired()) {
|
|
311
|
+
if (!refreshPromise) {
|
|
312
|
+
refreshPromise = (async () => {
|
|
313
|
+
try {
|
|
314
|
+
if (config.flow === "client_credentials") {
|
|
315
|
+
const tokens = await exchangeClientCredentials(config);
|
|
316
|
+
storeTokens(tokens);
|
|
317
|
+
return tokens;
|
|
318
|
+
}
|
|
319
|
+
return await refreshTokens(config);
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
refreshPromise = null;
|
|
323
|
+
}
|
|
324
|
+
})();
|
|
325
|
+
}
|
|
326
|
+
const tokens = await refreshPromise;
|
|
327
|
+
return tokens.accessToken;
|
|
328
|
+
}
|
|
329
|
+
return currentTokens.accessToken;
|
|
330
|
+
}
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Test helpers — reset module state
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
export function _resetForTests() {
|
|
335
|
+
pendingPkce = null;
|
|
336
|
+
currentTokens = null;
|
|
337
|
+
persistName = null;
|
|
338
|
+
refreshPromise = null;
|
|
339
|
+
cleanupCallbackServer();
|
|
340
|
+
}
|