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/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
  }
@@ -97,7 +97,7 @@ function getSuggestion(status, endpoint) {
97
97
  return msg;
98
98
  }
99
99
  case 401:
100
- return "Authentication required. Provide credentials via --header (e.g. --header \"Authorization: Bearer <token>\") or per-request headers parameter.";
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
+ }