|
1 | 1 | /** |
2 | | - * Autodesk Platform Services (APS) 2-legged OAuth and API helpers. |
| 2 | + * Autodesk Platform Services (APS) OAuth helpers. |
| 3 | + * |
| 4 | + * 2‑legged (client credentials) – getApsToken() |
| 5 | + * 3‑legged (authorization code) – performAps3loLogin(), getValid3loToken(), clear3loLogin() |
| 6 | + * |
3 | 7 | * Supports all Data Management API endpoints per datamanagement.yaml. |
4 | 8 | */ |
5 | 9 |
|
| 10 | +/** Escape special characters for safe HTML embedding. */ |
| 11 | +function escapeHtml(s: string): string { |
| 12 | + return s |
| 13 | + .replace(/&/g, "&") |
| 14 | + .replace(/</g, "<") |
| 15 | + .replace(/>/g, ">") |
| 16 | + .replace(/"/g, """) |
| 17 | + .replace(/'/g, "'"); |
| 18 | +} |
| 19 | + |
| 20 | +import { createServer } from "node:http"; |
| 21 | +import type { IncomingMessage, ServerResponse } from "node:http"; |
| 22 | +import { spawn } from "node:child_process"; |
| 23 | +import { homedir } from "node:os"; |
| 24 | +import { join } from "node:path"; |
| 25 | +import { |
| 26 | + existsSync, |
| 27 | + mkdirSync, |
| 28 | + readFileSync, |
| 29 | + writeFileSync, |
| 30 | + unlinkSync, |
| 31 | +} from "node:fs"; |
| 32 | + |
6 | 33 | const APS_TOKEN_URL = "https://developer.api.autodesk.com/authentication/v2/token"; |
| 34 | +const APS_AUTHORIZE_URL = "https://developer.api.autodesk.com/authentication/v2/authorize"; |
7 | 35 | const APS_BASE = "https://developer.api.autodesk.com"; |
8 | 36 |
|
9 | 37 | /** Structured error thrown by APS API calls. Carries status code + body for rich error context. */ |
@@ -161,3 +189,267 @@ export async function apsDmRequest( |
161 | 189 | } |
162 | 190 | return { ok: true, status: res.status, body: text }; |
163 | 191 | } |
| 192 | + |
| 193 | +// ── 3‑legged OAuth (authorization code + PKCE‑optional) ───────── |
| 194 | + |
| 195 | +/** Shape of the 3LO token data we persist to disk. */ |
| 196 | +interface Aps3loTokenData { |
| 197 | + access_token: string; |
| 198 | + refresh_token: string; |
| 199 | + expires_at: number; // epoch ms |
| 200 | + scope: string; |
| 201 | +} |
| 202 | + |
| 203 | +const TOKEN_DIR = join(homedir(), ".aps-mcp"); |
| 204 | +const TOKEN_FILE = join(TOKEN_DIR, "3lo-tokens.json"); |
| 205 | + |
| 206 | +function read3loCache(): Aps3loTokenData | null { |
| 207 | + try { |
| 208 | + if (!existsSync(TOKEN_FILE)) return null; |
| 209 | + return JSON.parse(readFileSync(TOKEN_FILE, "utf8")) as Aps3loTokenData; |
| 210 | + } catch { |
| 211 | + return null; |
| 212 | + } |
| 213 | +} |
| 214 | + |
| 215 | +function write3loCache(data: Aps3loTokenData): void { |
| 216 | + if (!existsSync(TOKEN_DIR)) mkdirSync(TOKEN_DIR, { recursive: true }); |
| 217 | + writeFileSync(TOKEN_FILE, JSON.stringify(data, null, 2)); |
| 218 | +} |
| 219 | + |
| 220 | +function deleteCacheFile(): void { |
| 221 | + try { |
| 222 | + if (existsSync(TOKEN_FILE)) unlinkSync(TOKEN_FILE); |
| 223 | + } catch { |
| 224 | + /* ignore */ |
| 225 | + } |
| 226 | +} |
| 227 | + |
| 228 | +/** Open a URL in the user's default browser (cross‑platform). */ |
| 229 | +function openBrowser(url: string): void { |
| 230 | + let program: string; |
| 231 | + let args: string[]; |
| 232 | + |
| 233 | + if (process.platform === "win32") { |
| 234 | + program = "cmd"; |
| 235 | + args = ["/c", "start", "", url]; |
| 236 | + } else if (process.platform === "darwin") { |
| 237 | + program = "open"; |
| 238 | + args = [url]; |
| 239 | + } else { |
| 240 | + program = "xdg-open"; |
| 241 | + args = [url]; |
| 242 | + } |
| 243 | + |
| 244 | + const child = spawn(program, args, { stdio: "ignore" }); |
| 245 | + child.on("error", () => { /* ignore – best‑effort */ }); |
| 246 | + child.unref(); |
| 247 | +} |
| 248 | + |
| 249 | +/** In‑memory cache so we don't re‑read the file every call. */ |
| 250 | +let cached3lo: Aps3loTokenData | null = null; |
| 251 | + |
| 252 | +/** |
| 253 | + * Perform the interactive 3‑legged OAuth login. |
| 254 | + * |
| 255 | + * 1. Spins up a temporary HTTP server on `callbackPort`. |
| 256 | + * 2. Opens the user's browser to the APS authorize endpoint. |
| 257 | + * 3. Waits for the redirect callback with the authorization code. |
| 258 | + * 4. Exchanges the code for access + refresh tokens. |
| 259 | + * 5. Persists tokens to `~/.aps-mcp/3lo-tokens.json`. |
| 260 | + * |
| 261 | + * Resolves when the login is complete or rejects on timeout / error. |
| 262 | + */ |
| 263 | +export async function performAps3loLogin( |
| 264 | + clientId: string, |
| 265 | + clientSecret: string, |
| 266 | + scope: string, |
| 267 | + callbackPort = 8910, |
| 268 | +): Promise<{ access_token: string; message: string }> { |
| 269 | + const redirectUri = `http://localhost:${callbackPort}/callback`; |
| 270 | + |
| 271 | + return new Promise((resolve, reject) => { |
| 272 | + const server = createServer( |
| 273 | + async (req: IncomingMessage, res: ServerResponse) => { |
| 274 | + const reqUrl = new URL(req.url ?? "/", `http://localhost:${callbackPort}`); |
| 275 | + |
| 276 | + if (reqUrl.pathname !== "/callback") { |
| 277 | + res.writeHead(404); |
| 278 | + res.end("Not found"); |
| 279 | + return; |
| 280 | + } |
| 281 | + |
| 282 | + const error = reqUrl.searchParams.get("error"); |
| 283 | + if (error) { |
| 284 | + const desc = reqUrl.searchParams.get("error_description") ?? error; |
| 285 | + const safeDesc = escapeHtml(desc); |
| 286 | + res.writeHead(400, { "Content-Type": "text/html" }); |
| 287 | + res.end( |
| 288 | + `<html><body><h2>Authorization failed</h2><p>${safeDesc}</p></body></html>`, |
| 289 | + ); |
| 290 | + server.close(); |
| 291 | + reject(new Error(`APS authorization failed: ${desc}`)); |
| 292 | + return; |
| 293 | + } |
| 294 | + |
| 295 | + const code = reqUrl.searchParams.get("code"); |
| 296 | + if (!code) { |
| 297 | + res.writeHead(400, { "Content-Type": "text/html" }); |
| 298 | + res.end( |
| 299 | + "<html><body><h2>Missing authorization code</h2></body></html>", |
| 300 | + ); |
| 301 | + server.close(); |
| 302 | + reject(new Error("No authorization code received in callback.")); |
| 303 | + return; |
| 304 | + } |
| 305 | + |
| 306 | + // Exchange the authorization code for tokens |
| 307 | + try { |
| 308 | + const tokenBody = new URLSearchParams({ |
| 309 | + grant_type: "authorization_code", |
| 310 | + code, |
| 311 | + redirect_uri: redirectUri, |
| 312 | + client_id: clientId, |
| 313 | + client_secret: clientSecret, |
| 314 | + }); |
| 315 | + |
| 316 | + const tokenRes = await fetch(APS_TOKEN_URL, { |
| 317 | + method: "POST", |
| 318 | + headers: { "Content-Type": "application/x-www-form-urlencoded" }, |
| 319 | + body: tokenBody.toString(), |
| 320 | + }); |
| 321 | + |
| 322 | + if (!tokenRes.ok) { |
| 323 | + const text = await tokenRes.text(); |
| 324 | + const safeText = escapeHtml(text); |
| 325 | + res.writeHead(500, { "Content-Type": "text/html" }); |
| 326 | + res.end( |
| 327 | + `<html><body><h2>Token exchange failed</h2><pre>${safeText}</pre></body></html>`, |
| 328 | + ); |
| 329 | + server.close(); |
| 330 | + reject( |
| 331 | + new Error(`Token exchange failed (${tokenRes.status}): ${safeText}`), |
| 332 | + ); |
| 333 | + return; |
| 334 | + } |
| 335 | + |
| 336 | + const data = (await tokenRes.json()) as { |
| 337 | + access_token: string; |
| 338 | + refresh_token: string; |
| 339 | + expires_in: number; |
| 340 | + }; |
| 341 | + |
| 342 | + const cacheData: Aps3loTokenData = { |
| 343 | + access_token: data.access_token, |
| 344 | + refresh_token: data.refresh_token, |
| 345 | + expires_at: Date.now() + (data.expires_in - 60) * 1000, |
| 346 | + scope, |
| 347 | + }; |
| 348 | + write3loCache(cacheData); |
| 349 | + cached3lo = cacheData; |
| 350 | + |
| 351 | + res.writeHead(200, { "Content-Type": "text/html" }); |
| 352 | + res.end( |
| 353 | + "<html><body><h2>Logged in to APS</h2>" + |
| 354 | + "<p>You can close this tab and return to Claude Desktop.</p></body></html>", |
| 355 | + ); |
| 356 | + server.close(); |
| 357 | + |
| 358 | + resolve({ |
| 359 | + access_token: data.access_token, |
| 360 | + message: |
| 361 | + `3-legged login successful. Tokens cached to ${TOKEN_FILE}. ` + |
| 362 | + "The token will auto-refresh when it expires.", |
| 363 | + }); |
| 364 | + } catch (err) { |
| 365 | + res.writeHead(500, { "Content-Type": "text/html" }); |
| 366 | + res.end( |
| 367 | + `<html><body><h2>Error</h2><pre>${String(err)}</pre></body></html>`, |
| 368 | + ); |
| 369 | + server.close(); |
| 370 | + reject(err); |
| 371 | + } |
| 372 | + }, |
| 373 | + ); |
| 374 | + |
| 375 | + server.listen(callbackPort, () => { |
| 376 | + const authUrl = new URL(APS_AUTHORIZE_URL); |
| 377 | + authUrl.searchParams.set("client_id", clientId); |
| 378 | + authUrl.searchParams.set("response_type", "code"); |
| 379 | + authUrl.searchParams.set("redirect_uri", redirectUri); |
| 380 | + authUrl.searchParams.set("scope", scope); |
| 381 | + openBrowser(authUrl.toString()); |
| 382 | + }); |
| 383 | + |
| 384 | + // Give the user 2 minutes to complete login |
| 385 | + setTimeout(() => { |
| 386 | + server.close(); |
| 387 | + reject(new Error("3LO login timed out after 2 minutes. Try again.")); |
| 388 | + }, 120_000); |
| 389 | + }); |
| 390 | +} |
| 391 | + |
| 392 | +/** |
| 393 | + * Return a valid 3LO access token if one exists (from cache or by refreshing). |
| 394 | + * Returns `null` when no 3LO session is active (caller should fall back to 2LO). |
| 395 | + */ |
| 396 | +export async function getValid3loToken( |
| 397 | + clientId: string, |
| 398 | + clientSecret: string, |
| 399 | +): Promise<string | null> { |
| 400 | + if (!cached3lo) cached3lo = read3loCache(); |
| 401 | + if (!cached3lo) return null; |
| 402 | + |
| 403 | + // Still valid? |
| 404 | + if (cached3lo.expires_at > Date.now() + 60_000) { |
| 405 | + return cached3lo.access_token; |
| 406 | + } |
| 407 | + |
| 408 | + // Attempt refresh |
| 409 | + if (cached3lo.refresh_token) { |
| 410 | + try { |
| 411 | + const body = new URLSearchParams({ |
| 412 | + grant_type: "refresh_token", |
| 413 | + refresh_token: cached3lo.refresh_token, |
| 414 | + client_id: clientId, |
| 415 | + client_secret: clientSecret, |
| 416 | + scope: cached3lo.scope, |
| 417 | + }); |
| 418 | + |
| 419 | + const res = await fetch(APS_TOKEN_URL, { |
| 420 | + method: "POST", |
| 421 | + headers: { "Content-Type": "application/x-www-form-urlencoded" }, |
| 422 | + body: body.toString(), |
| 423 | + }); |
| 424 | + |
| 425 | + if (res.ok) { |
| 426 | + const data = (await res.json()) as { |
| 427 | + access_token: string; |
| 428 | + refresh_token: string; |
| 429 | + expires_in: number; |
| 430 | + }; |
| 431 | + cached3lo = { |
| 432 | + access_token: data.access_token, |
| 433 | + refresh_token: data.refresh_token, |
| 434 | + expires_at: Date.now() + (data.expires_in - 60) * 1000, |
| 435 | + scope: cached3lo.scope, |
| 436 | + }; |
| 437 | + write3loCache(cached3lo); |
| 438 | + return cached3lo.access_token; |
| 439 | + } |
| 440 | + } catch { |
| 441 | + // refresh failed – fall through to clear |
| 442 | + } |
| 443 | + } |
| 444 | + |
| 445 | + // Expired and refresh failed |
| 446 | + deleteCacheFile(); |
| 447 | + cached3lo = null; |
| 448 | + return null; |
| 449 | +} |
| 450 | + |
| 451 | +/** Clear any cached 3LO tokens (in‑memory + on disk). */ |
| 452 | +export function clear3loLogin(): void { |
| 453 | + deleteCacheFile(); |
| 454 | + cached3lo = null; |
| 455 | +} |
0 commit comments