diff --git a/package-lock.json b/package-lock.json index 0638d76..ee67663 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@hono/zod-validator": "^0.7.2", "@microlabs/otel-cf-workers": "^1.0.0-rc.52", "@modelcontextprotocol/sdk": "^1.20.1", + "@mouryabalabhadra/ts-cloudflare-auth": "git+https://github.com/mouryabalabhadra/ts-cloudflare-auth.git#v0.1.0", "@thoughtspot/rest-api-sdk": "^2.22.0", "agents": "^0.13.1", "hono": "^4.12.21", @@ -1472,7 +1473,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1489,7 +1489,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1506,7 +1505,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1523,7 +1521,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1540,7 +1537,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1557,7 +1553,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1574,7 +1569,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1591,7 +1585,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1608,7 +1601,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1625,7 +1617,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1642,7 +1633,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1659,7 +1649,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1676,7 +1665,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1693,7 +1681,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1710,7 +1697,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1727,7 +1713,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1744,7 +1729,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1761,7 +1745,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1778,7 +1761,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1795,7 +1777,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1812,7 +1793,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1829,7 +1809,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1846,7 +1825,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1863,7 +1841,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1880,7 +1857,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1897,7 +1873,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2563,6 +2538,15 @@ } } }, + "node_modules/@mouryabalabhadra/ts-cloudflare-auth": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/mouryabalabhadra/ts-cloudflare-auth.git#2d0b3fbb71cd369110a1da81ba202a1257fbb8f7", + "license": "MIT", + "peerDependencies": { + "@cloudflare/workers-oauth-provider": "^0.0.5", + "hono": "^4.12.21" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -3387,7 +3371,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3401,7 +3384,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3415,7 +3397,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3429,7 +3410,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3443,7 +3423,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3457,7 +3436,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3471,7 +3449,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3485,7 +3462,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3499,7 +3475,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3513,7 +3488,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3527,7 +3501,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3541,7 +3514,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3555,7 +3527,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3569,7 +3540,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3583,7 +3553,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3597,7 +3566,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3611,7 +3579,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3625,7 +3592,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3639,7 +3605,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3653,7 +3618,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3667,7 +3631,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3681,7 +3644,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3695,7 +3657,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3709,7 +3670,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3723,7 +3683,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5088,7 +5047,7 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -5505,7 +5464,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7743,7 +7701,7 @@ "version": "4.22.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -8023,7 +7981,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8040,7 +7997,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8057,7 +8013,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8074,7 +8029,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8091,7 +8045,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8108,7 +8061,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8125,7 +8077,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8142,7 +8093,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8159,7 +8109,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8176,7 +8125,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8193,7 +8141,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8210,7 +8157,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8227,7 +8173,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8244,7 +8189,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8261,7 +8205,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8278,7 +8221,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8295,7 +8237,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8312,7 +8253,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8329,7 +8269,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8346,7 +8285,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8363,7 +8301,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8380,7 +8317,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8397,7 +8333,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8414,7 +8349,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8431,7 +8365,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8448,7 +8381,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index d88953a..f27e567 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@hono/zod-validator": "^0.7.2", "@microlabs/otel-cf-workers": "^1.0.0-rc.52", "@modelcontextprotocol/sdk": "^1.20.1", + "@mouryabalabhadra/ts-cloudflare-auth": "git+https://github.com/mouryabalabhadra/ts-cloudflare-auth.git#v0.1.0", "@thoughtspot/rest-api-sdk": "^2.22.0", "agents": "^0.13.1", "hono": "^4.12.21", diff --git a/src/bearer.ts b/src/bearer.ts deleted file mode 100644 index 9b9478f..0000000 --- a/src/bearer.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { ThoughtSpotMCP } from "."; -import type honoApp from "./handlers"; -import { - getMetricsRecorderFromExecutionContext, - normalizeRequestedApiVersionForAnalytics, - recordBearerAuthRequestMetric, - resolveRequestedApiVersionMode, -} from "./metrics/runtime/request-metrics"; -import { validateAndSanitizeUrl } from "./oauth-manager/oauth-utils"; -import { PUBLIC_ROUTES, PUBLIC_ROUTE_PREFIXES } from "./routes"; - -type AuthRouteFamily = "bearer" | "token"; - -function getAuthMetricRouteGroup( - pathname: string, - authRouteFamily: AuthRouteFamily, -): "bearer_mcp" | "bearer_sse" | "token_mcp" | "token_sse" { - if (pathname.endsWith(PUBLIC_ROUTES.sse)) { - return authRouteFamily === "bearer" ? "bearer_sse" : "token_sse"; - } - - return authRouteFamily === "bearer" ? "bearer_mcp" : "token_mcp"; -} - -/** - * Handler function for bearer/token authentication endpoints - * @param req - Incoming request - * @param env - Environment bindings - * @param ctx - Execution context - * @param MCPServer - MCP server instance - * @param apiVersionOverride - Optional API version override (ignore value in request) - */ -async function handleTokenAuth( - req: Request, - env: Env, - ctx: ExecutionContext, - MCPServer: typeof ThoughtSpotMCP, - apiVersionOverride?: string, - authRouteFamily: AuthRouteFamily = "token", -): Promise { - const recorder = getMetricsRecorderFromExecutionContext(ctx); - const url = new URL(req.url); - const authMetricRouteGroup = getAuthMetricRouteGroup( - url.pathname, - authRouteFamily, - ); - - try { - const authHeader = req.headers.get("authorization"); - if (!authHeader) { - const response = new Response("Bearer token is required", { - status: 400, - }); - recordBearerAuthRequestMetric( - recorder, - req, - response.status, - authMetricRouteGroup, - ); - return response; - } - - let accessToken = authHeader.split(" ")[1]; - let tsHost: string | null; - - if (accessToken.includes("@")) { - [accessToken, tsHost] = accessToken.split("@"); - } else { - tsHost = req.headers.get("x-ts-host"); - } - - if (!tsHost) { - const response = new Response( - "TS Host is required, either in the authorization header as 'token@ts-host' or as a separate 'x-ts-host' header", - { status: 400 }, - ); - recordBearerAuthRequestMetric( - recorder, - req, - response.status, - authMetricRouteGroup, - ); - return response; - } - - const clientName = - req.headers.get("x-ts-client-name") || "Bearer Token client"; - - // Build props object - const props: any = { - accessToken: accessToken, - instanceUrl: validateAndSanitizeUrl(tsHost), - clientName, - }; - const requestedApiVersion = url.searchParams.get("api-version"); - - // Stamp the effective served surface into props so downstream request/tool metrics - // can distinguish: - // - `api_version=backwards-compatibility-default` => tenants still on the legacy/v1 surface - // - `api_version_mode` => implicit route defaults vs explicit/latest/pinned selectors - // - `api_requested_version` (stored only in Analytics Engine) => the exact selector - // the client sent, which helps debug version-resolution confusion and future-dated pins - // - `api_release_date` (derived later from the registry) => which exact dated release is served - const apiVersion = - apiVersionOverride ?? - requestedApiVersion ?? - (authRouteFamily === "token" ? "latest" : undefined); - const apiVersionMode = apiVersionOverride - ? "implicit_legacy" - : requestedApiVersion - ? resolveRequestedApiVersionMode(requestedApiVersion) - : authRouteFamily === "token" - ? "implicit_latest" - : undefined; - if (requestedApiVersion) { - props.apiRequestedVersion = - normalizeRequestedApiVersionForAnalytics(requestedApiVersion); - } - if (apiVersion) { - props.apiVersion = apiVersion; - } - if (apiVersionMode) { - props.apiVersionMode = apiVersionMode; - } - - (ctx as any).props = props; - - let response: Response; - const pathname = url.pathname; - if (pathname.endsWith(PUBLIC_ROUTES.mcp)) { - response = await MCPServer.serve(PUBLIC_ROUTES.mcp).fetch(req, env, ctx); - } else if (pathname.endsWith(PUBLIC_ROUTES.sse)) { - response = await MCPServer.serveSSE(PUBLIC_ROUTES.sse).fetch( - req, - env, - ctx, - ); - } else { - response = new Response("Not found", { status: 404 }); - } - - recordBearerAuthRequestMetric( - recorder, - req, - response.status, - authMetricRouteGroup, - ); - return response; - } catch (error) { - recordBearerAuthRequestMetric(recorder, req, 500, authMetricRouteGroup); - throw error; - } -} - -export function withBearerHandler( - app: typeof honoApp, - MCPServer: typeof ThoughtSpotMCP, -) { - // These endpoints do NOT support api-version query params (will be removed in future) - // Use /token endpoints instead for new implementations - app.mount(PUBLIC_ROUTE_PREFIXES.bearer, (req, env, ctx) => { - return handleTokenAuth( - req, - env, - ctx, - MCPServer, - "backwards-compatibility-default", - "bearer", - ); - }); - - // NEW: /token endpoints - supports api-version query params - // Recommended for all new implementations - app.mount(PUBLIC_ROUTE_PREFIXES.token, (req, env, ctx) => { - return handleTokenAuth(req, env, ctx, MCPServer, undefined, "token"); - }); - - return app; -} diff --git a/src/handlers.ts b/src/handlers.ts deleted file mode 100644 index 994fe6e..0000000 --- a/src/handlers.ts +++ /dev/null @@ -1,449 +0,0 @@ -import type { OAuthHelpers } from "@cloudflare/workers-oauth-provider"; -import { SpanStatusCode } from "@opentelemetry/api"; -import { Hono } from "hono"; -import { decodeBase64Url, encodeBase64Url } from "hono/utils/encode"; -import { - getStatusClass, - resolveRequestMetricContext, -} from "./metrics/runtime/metric-context"; -import { METRIC_NAMES } from "./metrics/runtime/metric-types"; -import { - getMetricsRecorderFromExecutionContext, - recordStatusMetric, -} from "./metrics/runtime/request-metrics"; -import { WithSpan, getActiveSpan } from "./metrics/tracing/tracing-utils"; -import { - buildSamlRedirectUrl, - parseRedirectApproval, - renderApprovalDialog, -} from "./oauth-manager/oauth-utils"; -import { renderTokenCallback } from "./oauth-manager/token-utils"; -import { PUBLIC_ROUTES } from "./routes"; -import type { Props } from "./utils"; -import { McpServerError } from "./utils"; - -const app = new Hono<{ Bindings: Env & { OAUTH_PROVIDER: OAuthHelpers } }>(); - -function getExecutionContextOrUndefined(context: { - executionCtx: ExecutionContext; -}): ExecutionContext | undefined { - try { - return context.executionCtx; - } catch { - return undefined; - } -} - -function recordAuthFlowMetric( - context: { executionCtx: ExecutionContext; req: { raw: Request } }, - name: - | typeof METRIC_NAMES.oauthAuthorizeRequestsTotal - | typeof METRIC_NAMES.oauthAuthorizeSubmitTotal - | typeof METRIC_NAMES.oauthCallbackTotal - | typeof METRIC_NAMES.oauthStoreTokenTotal, - status: number, -): void { - const executionContext = getExecutionContextOrUndefined(context); - if (!executionContext) { - return; - } - - const requestContext = resolveRequestMetricContext(context.req.raw); - recordStatusMetric( - getMetricsRecorderFromExecutionContext(executionContext), - name, - status, - { - route_group: requestContext.routeGroup, - transport: requestContext.transport, - auth_mode: requestContext.authMode, - api_surface: requestContext.apiSurface, - status_class: getStatusClass(status), - }, - ); -} - -class Handler { - @WithSpan("serve-index") - async serveIndex(env: Env) { - return env.ASSETS.fetch("/index.html"); - } - - @WithSpan("hello-world") - async helloWorld() { - return { message: "Hello, World!" }; - } - - @WithSpan("authorize-get") - async getAuthorize(request: Request, oauthProvider: OAuthHelpers) { - const span = getActiveSpan(); - const oauthReqInfo = await oauthProvider.parseAuthRequest(request); - const { clientId } = oauthReqInfo; - - span?.setAttribute("client_id", clientId || "unknown"); - if (!clientId) { - throw new McpServerError({ message: "Missing client ID" }, 400); - } - if (!oauthReqInfo.codeChallenge) { - throw new McpServerError( - { message: "PKCE is required: missing code challenge" }, - 400, - ); - } - if (oauthReqInfo.codeChallengeMethod !== "S256") { - throw new McpServerError( - { message: "PKCE code challenge method must be S256" }, - 400, - ); - } - const client = await oauthProvider.lookupClient(clientId); - return renderApprovalDialog(request, { - client, - server: { - name: "ThoughtSpot Spotter", - logo: "https://avatars.githubusercontent.com/u/8906680?s=200&v=4", - description: "MCP Server for ThoughtSpot Agent", - }, - state: { oauthReqInfo }, - }); - } - - @WithSpan("authorize-post") - async postAuthorize(request: Request, requestUrl: string) { - const span = getActiveSpan(); - try { - const { state, instanceUrl } = await parseRedirectApproval(request); - - span?.setAttribute("instance_url", instanceUrl || "unknown"); - - if (!state.oauthReqInfo) { - throw new McpServerError( - { message: "Missing OAuth request info" }, - 400, - ); - } - - if (!instanceUrl) { - throw new McpServerError({ message: "Missing instance URL" }, 400); - } - - const origin = new URL(requestUrl).origin; - - // TODO: Remove this once we have a proper way to handle this - // This is a temporary fix to handle the case where the instance URL is a free trial instance URL - // Since, free trial does not support IAMv2, we will assume that the user is logged in. - if ( - instanceUrl.match(/^https:\/\/(?:team|my)\d+\.thoughtspot\.cloud\/?$/) - ) { - const callbackUrl = new URL("/callback", origin); - callbackUrl.searchParams.set("instanceUrl", instanceUrl); - callbackUrl.searchParams.set( - "oauthReqInfo", - encodeBase64Url( - new TextEncoder().encode(JSON.stringify(state.oauthReqInfo)).buffer, - ), - ); - return callbackUrl.toString(); - } - - const redirectUrl = buildSamlRedirectUrl( - instanceUrl, - state.oauthReqInfo, - origin, - ); - return redirectUrl; - } catch (error) { - throw new McpServerError(error, 500); - } - } - - @WithSpan("oauth-callback") - async handleCallback(request: Request, assets: any, requestUrl: string) { - const span = getActiveSpan(); - - const url = new URL(request.url); - const instanceUrl = url.searchParams.get("instanceUrl"); - const encodedOauthReqInfo = url.searchParams - .get("oauthReqInfo") - // Added as a workaround for https://thoughtspot.atlassian.net/browse/SCAL-258056 - ?.replace("/10023.html", ""); - - span?.setAttributes({ - instance_url: instanceUrl || "unknown", - has_oauth_req_info: !!encodedOauthReqInfo, - }); - - if (!instanceUrl) { - throw new McpServerError({ message: "Missing instance URL" }, 400); - } - if (!encodedOauthReqInfo) { - throw new McpServerError({ message: "Missing OAuth request info" }, 400); - } - - let decodedOAuthReqInfo: any; - try { - decodedOAuthReqInfo = JSON.parse( - new TextDecoder().decode(decodeBase64Url(encodedOauthReqInfo)), - ); - } catch (error) { - throw new McpServerError( - { message: "Invalid OAuth request info format", details: error }, - 400, - ); - } - const origin = new URL(requestUrl).origin; - try { - const htmlContent = await renderTokenCallback( - instanceUrl, - decodedOAuthReqInfo, - assets, - origin, - ); - span?.setStatus({ - code: SpanStatusCode.OK, - message: "Token callback rendered successfully", - }); - return htmlContent; - } catch (error) { - throw new McpServerError( - { message: "Error rendering token callback", details: error }, - 500, - ); - } - } - - @WithSpan("store-token") - async storeToken(request: Request, oauthProvider: OAuthHelpers) { - const span = getActiveSpan(); - - let token: any; - let oauthReqInfo: any; - let instanceUrl: string; - - try { - const body = (await request.json()) as any; - token = body.token; - oauthReqInfo = body.oauthReqInfo; - instanceUrl = body.instanceUrl; - } catch (error) { - throw new McpServerError( - { message: "Invalid JSON format", details: error }, - 400, - ); - } - span?.setAttributes({ - instance_url: instanceUrl || "unknown", - has_token: !!token, - has_oauth_req_info: !!oauthReqInfo, - }); - - if (!token || !oauthReqInfo || !instanceUrl) { - throw new McpServerError( - { message: "Missing token or OAuth request info or instanceUrl" }, - 400, - ); - } - - const { clientId } = oauthReqInfo; - span?.setAttribute("client_id", clientId || "unknown"); - - const clientName = await oauthProvider.lookupClient(clientId); - - span?.addEvent("complete-authorization"); - // Complete the authorization with the provided information - const { redirectTo } = await oauthProvider.completeAuthorization({ - request: oauthReqInfo, - userId: "default", // Using a default user ID since username is not required - metadata: { - label: "default", - }, - scope: oauthReqInfo.scope, - props: { - accessToken: token.data.token, - instanceUrl: instanceUrl, - clientName: clientName, - } as Props, - }); - - span?.setStatus({ - code: SpanStatusCode.OK, - message: "Token stored successfully", - }); - - return { redirectTo }; - } -} - -const handler = new Handler(); - -app.get(PUBLIC_ROUTES.root, async (c) => { - const response = await handler.serveIndex(c.env); - return response; -}); - -app.get(PUBLIC_ROUTES.hello, async (c) => { - const result = await handler.helloWorld(); - return c.json(result); -}); - -app.get(PUBLIC_ROUTES.authorize, async (c) => { - try { - const response = await handler.getAuthorize( - c.req.raw, - c.env.OAUTH_PROVIDER, - ); - recordAuthFlowMetric( - c, - METRIC_NAMES.oauthAuthorizeRequestsTotal, - response.status, - ); - return response; - } catch (error) { - const response = c.text(`Internal Server Error ${error}`, 500); - recordAuthFlowMetric( - c, - METRIC_NAMES.oauthAuthorizeRequestsTotal, - response.status, - ); - return response; - } -}); - -app.post(PUBLIC_ROUTES.authorize, async (c) => { - try { - const redirectUrl = await handler.postAuthorize(c.req.raw, c.req.url); - const response = Response.redirect(redirectUrl); - recordAuthFlowMetric( - c, - METRIC_NAMES.oauthAuthorizeSubmitTotal, - response.status, - ); - return response; - } catch (error) { - if ( - error instanceof Error && - error.message.includes("Missing instance URL") - ) { - const response = new Response("Missing instance URL", { status: 400 }); - recordAuthFlowMetric( - c, - METRIC_NAMES.oauthAuthorizeSubmitTotal, - response.status, - ); - return response; - } - const response = new Response(`Internal Server Error ${error}`, { - status: 500, - }); - recordAuthFlowMetric( - c, - METRIC_NAMES.oauthAuthorizeSubmitTotal, - response.status, - ); - return response; - } -}); - -app.get(PUBLIC_ROUTES.callback, async (c) => { - try { - const htmlContent = await handler.handleCallback( - c.req.raw, - c.env.ASSETS, - c.req.url, - ); - const response = new Response(htmlContent, { - headers: { - "Content-Type": "text/html", - }, - }); - recordAuthFlowMetric(c, METRIC_NAMES.oauthCallbackTotal, response.status); - return response; - } catch (error) { - if (error instanceof Error) { - if (error.message.includes("Missing instance URL")) { - const response = c.text(`Missing instance URL ${error}`, 400); - recordAuthFlowMetric( - c, - METRIC_NAMES.oauthCallbackTotal, - response.status, - ); - return response; - } - if (error.message.includes("Missing OAuth request info")) { - const response = c.text(`Missing OAuth request info ${error}`, 400); - recordAuthFlowMetric( - c, - METRIC_NAMES.oauthCallbackTotal, - response.status, - ); - return response; - } - if (error.message.includes("Invalid OAuth request info format")) { - const response = c.text( - `Invalid OAuth request info format ${error}`, - 400, - ); - recordAuthFlowMetric( - c, - METRIC_NAMES.oauthCallbackTotal, - response.status, - ); - return response; - } - } - const response = c.text(`Internal server error ${error}`, 500); - recordAuthFlowMetric(c, METRIC_NAMES.oauthCallbackTotal, response.status); - return response; - } -}); - -app.post(PUBLIC_ROUTES.storeToken, async (c) => { - try { - const result = await handler.storeToken(c.req.raw, c.env.OAUTH_PROVIDER); - const response = new Response(JSON.stringify(result), { - status: 200, - headers: { - "Content-Type": "application/json", - }, - }); - recordAuthFlowMetric(c, METRIC_NAMES.oauthStoreTokenTotal, response.status); - return response; - } catch (error) { - if (error instanceof Error) { - if (error.message.includes("Invalid JSON format")) { - const response = c.text(`Invalid JSON format ${error}`, 400); - recordAuthFlowMetric( - c, - METRIC_NAMES.oauthStoreTokenTotal, - response.status, - ); - return response; - } - if ( - error.message.includes( - "Missing token or OAuth request info or instanceUrl", - ) - ) { - const response = c.text( - `Missing token or OAuth request info or instanceUrl ${error}`, - 400, - ); - recordAuthFlowMetric( - c, - METRIC_NAMES.oauthStoreTokenTotal, - response.status, - ); - return response; - } - } - const response = c.text(`Internal server error ${error}`, 500); - recordAuthFlowMetric(c, METRIC_NAMES.oauthStoreTokenTotal, response.status); - return response; - } -}); - -app.get(PUBLIC_ROUTES.openaiAppsChallenge, (c) => { - return c.text(process.env.OPEN_AI_TOKEN as string); -}); - -export default app; diff --git a/src/index.ts b/src/index.ts index dbec08b..edf04bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,35 @@ -import OAuthProvider from "@cloudflare/workers-oauth-provider"; import { type ResolveConfigFn, type TraceConfig, instrument, } from "@microlabs/otel-cf-workers"; +import { + type AuthHooks, + createOAuthHandler, +} from "@mouryabalabhadra/ts-cloudflare-auth"; import { trace } from "@opentelemetry/api"; -import { withBearerHandler } from "./bearer"; import { instrumentedMCPServer } from "./cloudflare-utils"; -import handler from "./handlers"; -import type { ApiVersionMode } from "./metrics/runtime/metric-types"; import { + getStatusClass, + resolveRequestMetricContext, +} from "./metrics/runtime/metric-context"; +import { + type ApiVersionMode, + METRIC_NAMES, +} from "./metrics/runtime/metric-types"; +import { + getMetricsRecorderFromExecutionContext, normalizeRequestedApiVersionForAnalytics, + recordBearerAuthRequestMetric, recordHttpRequestMetrics, + recordStatusMetric, resolveRequestedApiVersionMode, withRequestMetrics, } from "./metrics/runtime/request-metrics"; -import { PUBLIC_ROUTES } from "./routes"; import { ConversationStorageServerSQLite } from "./servers/conversation-storage-server"; import { MCPServer } from "./servers/mcp-server"; +import type { Props } from "./utils"; export { ConversationStorageServerSQLite }; @@ -36,78 +47,132 @@ const config: ResolveConfigFn = (env: Env, _trigger) => { // Create the instrumented ThoughtSpotMCP for the main export export const ThoughtSpotMCP = instrumentedMCPServer(MCPServer, config); -// Router function to handle query params and inject apiVersion into props -function createMCPRouter( - path: string, - serverClass: typeof ThoughtSpotMCP, - serveMethod: "serve" | "serveSSE", - options?: { binding?: string }, -) { - return { - async fetch( - request: Request, - env: Env, - ctx: ExecutionContext, - ): Promise { - const url = new URL(request.url); - const requestedApiVersion = url.searchParams.get("api-version"); - let apiVersion = requestedApiVersion; - let apiVersionMode: ApiVersionMode; - - // TODO(Rifdhan): this is a temporary backwards compatibility measure. In the future - // we will use latest by default. - if (!apiVersion) { - apiVersion = "backwards-compatibility-default"; - apiVersionMode = "implicit_legacy"; - } else { - apiVersionMode = resolveRequestedApiVersionMode(apiVersion); - } - - // Inject apiVersion into props - const originalProps = (ctx as any).props || {}; - (ctx as any).props = { - ...originalProps, - apiVersion, - apiRequestedVersion: requestedApiVersion - ? normalizeRequestedApiVersionForAnalytics(requestedApiVersion) - : undefined, - apiVersionMode, - }; - - // Route to the appropriate serve method - return serverClass[serveMethod](path, options).fetch(request, env, ctx); - }, - }; -} - -// Create the OAuth provider instance -const oauthProvider = new OAuthProvider({ - apiHandlers: { - [PUBLIC_ROUTES.mcp]: createMCPRouter( - PUBLIC_ROUTES.mcp, - ThoughtSpotMCP, - "serve", - ) as any, - [PUBLIC_ROUTES.sse]: createMCPRouter( - PUBLIC_ROUTES.sse, - ThoughtSpotMCP, - "serveSSE", - ) as any, +const METRIC_NAME_MAP = { + oauth_authorize_requests_total: METRIC_NAMES.oauthAuthorizeRequestsTotal, + oauth_authorize_submit_total: METRIC_NAMES.oauthAuthorizeSubmitTotal, + oauth_callback_total: METRIC_NAMES.oauthCallbackTotal, + oauth_store_token_total: METRIC_NAMES.oauthStoreTokenTotal, +} as const; + +const hooks: AuthHooks = { + onAuthMetric(name, status, ctx, req) { + const requestContext = resolveRequestMetricContext(req); + recordStatusMetric( + getMetricsRecorderFromExecutionContext(ctx), + METRIC_NAME_MAP[name], + status, + { + route_group: requestContext.routeGroup, + transport: requestContext.transport, + auth_mode: requestContext.authMode, + api_surface: requestContext.apiSurface, + status_class: getStatusClass(status), + }, + ); + }, + onBearerMetric(status, ctx, req, group) { + recordBearerAuthRequestMetric( + getMetricsRecorderFromExecutionContext(ctx), + req, + status, + group, + ); + }, + extendProps(req, base): Props { + // Bearer/token flow: stamp api-version metadata from query params. + // /bearer/* path family uses backwards-compat default; /token/* uses requested/latest. + const url = new URL(req.url); + const requestedApiVersion = url.searchParams.get("api-version"); + const isBearerLegacy = url.pathname.includes("/bearer/"); + + const props: Props = { + ...base, + clientName: (base.clientName ?? { + clientId: "Bearer Token client", + clientName: "Bearer Token client", + registrationDate: Date.now(), + }) as Props["clientName"], + }; + + let apiVersion: string | undefined; + let apiVersionMode: ApiVersionMode | undefined; + + if (isBearerLegacy) { + apiVersion = "backwards-compatibility-default"; + apiVersionMode = "implicit_legacy"; + } else if (requestedApiVersion) { + apiVersion = requestedApiVersion; + apiVersionMode = resolveRequestedApiVersionMode(requestedApiVersion); + } else { + apiVersion = "latest"; + apiVersionMode = "implicit_latest"; + } + + if (requestedApiVersion) { + props.apiRequestedVersion = + normalizeRequestedApiVersionForAnalytics(requestedApiVersion); + } + props.apiVersion = apiVersion; + props.apiVersionMode = apiVersionMode; + + return props; + }, +}; + +const oauthFetchHandler = createOAuthHandler({ + serverInfo: { + name: "ThoughtSpot Spotter", + logo: "https://avatars.githubusercontent.com/u/8906680?s=200&v=4", + description: "MCP Server for ThoughtSpot Agent", + }, + mcpServerClass: ThoughtSpotMCP as unknown as Parameters< + typeof createOAuthHandler + >[0]["mcpServerClass"], + // biome-ignore lint/suspicious/noExplicitAny: pkg AuthHooks narrower clientName vs local Props + hooks: hooks as any, + enrichMcpRequestProps(request, _ctx, baseProps): Props { + // OAuth-authenticated /mcp + /sse: derive apiVersion from query params, + // defaulting to legacy for backwards compatibility (matches prior behaviour). + const url = new URL(request.url); + const requestedApiVersion = url.searchParams.get("api-version"); + let apiVersion = requestedApiVersion; + let apiVersionMode: ApiVersionMode; + + if (!apiVersion) { + apiVersion = "backwards-compatibility-default"; + apiVersionMode = "implicit_legacy"; + } else { + apiVersionMode = resolveRequestedApiVersionMode(apiVersion); + } + + return { + ...(baseProps as Props), + apiVersion, + apiRequestedVersion: requestedApiVersion + ? normalizeRequestedApiVersionForAnalytics(requestedApiVersion) + : undefined, + apiVersionMode, + }; + }, + // Extra routes mounted on the default handler app (consumer-specific). + extraRoutes(app) { + app.get("/", async (c) => { + return c.env.ASSETS!.fetch("/index.html"); + }); + app.get("/hello", (c) => c.json({ message: "Hello, World!" })); + app.get("/.well-known/openai-apps-challenge", (c) => { + return c.text(process.env.OPEN_AI_TOKEN as string); + }); }, - defaultHandler: withBearerHandler(handler, ThoughtSpotMCP) as any, // TODO: Remove 'any' - authorizeEndpoint: PUBLIC_ROUTES.authorize, - tokenEndpoint: PUBLIC_ROUTES.oauthToken, - clientRegistrationEndpoint: PUBLIC_ROUTES.register, }); -// Wrap the OAuth provider with a handler that includes tracing +// Wrap with OTel + tracing attributes. const oauthHandler = { async fetch( request: Request, env: Env, ctx: ExecutionContext, ): Promise { - // Add OpenTelemetry tracing attributes const span = trace.getActiveSpan(); if (span) { span.setAttributes({ @@ -117,8 +182,7 @@ const oauthHandler = { request_method: request.method, }); } - - return oauthProvider.fetch(request, env, ctx); + return oauthFetchHandler.fetch!(request as any, env, ctx); }, }; diff --git a/src/oauth-manager/oauth-utils.ts b/src/oauth-manager/oauth-utils.ts deleted file mode 100644 index 1ad220c..0000000 --- a/src/oauth-manager/oauth-utils.ts +++ /dev/null @@ -1,543 +0,0 @@ -import type { - AuthRequest, - ClientInfo, -} from "@cloudflare/workers-oauth-provider"; -import { encodeBase64Url } from "hono/utils/encode"; - -/** - * Configuration for the approval dialog - */ -export interface ApprovalDialogOptions { - /** - * Client information to display in the approval dialog - */ - client: ClientInfo | null; - /** - * Server information to display in the approval dialog - */ - server: { - name: string; - logo?: string; - description?: string; - }; - /** - * Arbitrary state data to pass through the approval flow - * Will be encoded in the form and returned when approval is complete - */ - state: Record; - /** - * Name of the cookie to use for storing approvals - * @default "mcp_approved_clients" - */ - cookieName?: string; - /** - * Secret used to sign cookies for verification - * Can be a string or Uint8Array - * @default Built-in Uint8Array key - */ - cookieSecret?: string | Uint8Array; - /** - * Cookie domain - * @default current domain - */ - cookieDomain?: string; - /** - * Cookie path - * @default "/" - */ - cookiePath?: string; - /** - * Cookie max age in seconds - * @default 30 days - */ - cookieMaxAge?: number; -} - -/** - * Renders an approval dialog for OAuth authorization - * The dialog displays information about the client and server - * and includes a form to submit approval - * - * @param request - The HTTP request - * @param options - Configuration for the approval dialog - * @returns A Response containing the HTML approval dialog - */ -export function renderApprovalDialog( - request: Request, - options: ApprovalDialogOptions, -): Response { - const { server, state, client } = options; - const encodedState = btoa(JSON.stringify(state)); - const serverName = sanitizeHtml(server.name); - const mcpLogoUrl = - "https://raw.githubusercontent.com/thoughtspot/mcp-server/refs/heads/main/static/MCP%20Server%20Logo.svg"; - const thoughtspotLogoUrl = - "https://avatars.githubusercontent.com/u/8906680?s=200&v=4"; - const clientUrl = client?.clientUri; - const tsInstanceUrlMatch = clientUrl?.match(/x-ts-host:(.*)/); - const tsInstanceUrl = tsInstanceUrlMatch ? tsInstanceUrlMatch[1].trim() : ""; - - const htmlContent = ` - - - - - - ${serverName} | Authorization Request - - - -
-
- - - - - - - - - - - - - - -
-
ThoughtSpot Spotter wants access
to your ThoughtSpot instance
-
-
- - - -
-
ThoughtSpot Spotter will be able to:
-
    -
  • Read all ThoughtSpot data and content you have access to
  • -
  • Send data to the client you are connecting to
  • -
-
- - -
-
- - -
-
- -
- - - - `; - return new Response(htmlContent, { - headers: { - "Content-Type": "text/html; charset=utf-8", - }, - }); -} - -/** - * Decodes a base64-encoded state string back into an object - */ -function decodeState(encodedState: string): T { - try { - const decoded = atob(encodedState); - return JSON.parse(decoded) as T; - } catch (e) { - console.error("Error decoding state:", e); - throw new Error("Invalid state format"); - } -} - -/** - * Result of parsing the approval form submission. - */ -export interface ParsedApprovalResult { - /** The original state object passed through the form. */ - state: any; - /** The instance URL extracted from the form. */ - instanceUrl: string; -} - -/** - * Validates and sanitizes a URL to ensure it's a valid ThoughtSpot instance URL - * @param url - The URL to validate and sanitize - * @returns The sanitized URL - * @throws Error if the URL is invalid - */ -export function validateAndSanitizeUrl(url: string): string { - try { - // Remove any whitespace - const trimmedUrl = url.trim(); - - // Add https:// if no protocol is specified - const urlWithProtocol = - trimmedUrl.startsWith("http://") || trimmedUrl.startsWith("https://") - ? trimmedUrl - : `https://${trimmedUrl}`; - - const parsedUrl = new URL(urlWithProtocol); - - if (parsedUrl.protocol !== "https:") { - throw new Error( - `Only HTTPS URLs are allowed, received: ${parsedUrl.protocol}`, - ); - } - - // Remove trailing slashes and normalize the URL - const sanitizedUrl = parsedUrl.origin; - - return sanitizedUrl; - } catch (e) { - if (e instanceof Error) { - throw new Error(`Invalid URL: ${e.message}`); - } - throw new Error("Invalid URL format"); - } -} - -/** - * Parses the form submission from the approval dialog, extracts the state, - * and generates Set-Cookie headers to mark the client as approved. - * - * @param request - The incoming POST Request object containing the form data. - * @returns A promise resolving to an object containing the parsed state and necessary headers. - * @throws If the request method is not POST, form data is invalid, or state is missing. - */ -export async function parseRedirectApproval( - request: Request, -): Promise { - if (request.method !== "POST") { - throw new Error("Invalid request method. Expected POST."); - } - - let state: any; - let clientId: string | undefined; - let instanceUrl: string | undefined; - try { - const formData = await request.formData(); - const encodedState = formData.get("state"); - const rawInstanceUrl = formData.get("instanceUrl") as string; - - if (typeof encodedState !== "string" || !encodedState) { - throw new Error("Missing or invalid 'state' in form data."); - } - - state = decodeState<{ oauthReqInfo?: AuthRequest }>(encodedState); - clientId = state?.oauthReqInfo?.clientId; - - if (!clientId) { - throw new Error("Could not extract clientId from state object."); - } - - if (!rawInstanceUrl) { - throw new Error("Missing instance URL"); - } - - // Validate and sanitize the instance URL - instanceUrl = validateAndSanitizeUrl(rawInstanceUrl); - } catch (e) { - console.error("Error processing form submission:", e); - throw new Error( - `Failed to parse approval form: ${e instanceof Error ? e.message : String(e)}`, - ); - } - - return { state, instanceUrl }; -} - -/** - * Sanitizes HTML content to prevent XSS attacks - * @param unsafe - The unsafe string that might contain HTML - * @returns A safe string with HTML special characters escaped - */ -function sanitizeHtml(unsafe: string): string { - return unsafe - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -/** - * Constructs the SAML login redirect URL for the /authorize POST handler. - * @param instanceUrl The instance URL to use as the base for the redirect. - * @param oauthReqInfo The OAuth request info object to encode in the state. - * @param callbackOrigin The origin to use for the callback URL (e.g., from the incoming request). - * @returns The full redirect URL as a string. - */ -export function buildSamlRedirectUrl( - instanceUrl: string, - oauthReqInfo: any, - callbackOrigin: string, -): string { - // Construct the redirect URL to v1/saml - const redirectUrl = new URL("callosum/v1/saml/login", instanceUrl); - const targetURLPath = new URL("/callback", callbackOrigin); - targetURLPath.searchParams.append("instanceUrl", instanceUrl); - const encodedState = encodeBase64Url( - new TextEncoder().encode(JSON.stringify(oauthReqInfo)).buffer, - ); - targetURLPath.searchParams.append("oauthReqInfo", encodedState); - redirectUrl.searchParams.append("targetURLPath", targetURLPath.href); - return redirectUrl.toString(); -} diff --git a/src/oauth-manager/token-utils.ts b/src/oauth-manager/token-utils.ts deleted file mode 100644 index 77b60e8..0000000 --- a/src/oauth-manager/token-utils.ts +++ /dev/null @@ -1,94 +0,0 @@ -export async function renderTokenCallback( - instanceUrl: string, - oauthReqInfo: string, - assets: any, - origin: string, -) { - // Parse the oauthReqInfo if it's a string - const parsedOAuthReqInfo = - typeof oauthReqInfo === "string" ? JSON.parse(oauthReqInfo) : oauthReqInfo; - const oauthReqInfoJson = JSON.stringify(parsedOAuthReqInfo); - - try { - // Read the HTML template - const htmlResponse = await assets.fetch(`${origin}/oauth-callback.html`); - if (!htmlResponse.ok) { - throw new Error("Failed to load oauth-callback.html"); - } - let htmlContent = await htmlResponse.text(); - - // Read the CSS file - const cssResponse = await assets.fetch(`${origin}/oauth-callback.css`); - if (!cssResponse.ok) { - throw new Error("Failed to load oauth-callback.css"); - } - const css = await cssResponse.text(); - - // Read the JS file - const jsResponse = await assets.fetch(`${origin}/oauth-callback.js`); - if (!jsResponse.ok) { - throw new Error("Failed to load oauth-callback.js"); - } - const js = await jsResponse.text(); - - // Replace the template variable with the actual OAuth request info - htmlContent = htmlContent.replace("{{OAUTH_REQ_INFO}}", oauthReqInfoJson); - - // Inline the CSS - htmlContent = htmlContent.replace( - '', - ``, - ); - - // Inline the JS and add the instance URL as a global variable - htmlContent = htmlContent.replace( - '', - `\n `, - ); - - return htmlContent; - } catch (error) { - console.error("Error loading static files:", error); - // Fallback to a simple error page if static files can't be loaded - return ` - - - - Error - ThoughtSpot Authorization - - - -
-

Authorization Error

-

Failed to load authorization page. Please try again or contact support.

-

Error: ${error instanceof Error ? error.message : "Unknown error"}

-
- - - `; - } -} diff --git a/src/routes.ts b/src/routes.ts index 7952a2f..a08ec36 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,24 +1,16 @@ +import { + PUBLIC_ROUTES as PKG_PUBLIC_ROUTES, + PUBLIC_ROUTE_PREFIXES as PKG_PUBLIC_ROUTE_PREFIXES, +} from "@mouryabalabhadra/ts-cloudflare-auth"; + +// mcp-server-specific public routes layered on top of pkg-provided OAuth routes. export const PUBLIC_ROUTES = { - root: "/", + ...PKG_PUBLIC_ROUTES, hello: "/hello", - authorize: "/authorize", - callback: "/callback", - storeToken: "/store-token", - oauthToken: "/token", - register: "/register", - mcp: "/mcp", - sse: "/sse", - bearerMcp: "/bearer/mcp", - bearerSse: "/bearer/sse", - tokenMcp: "/token/mcp", - tokenSse: "/token/sse", openaiAppsChallenge: "/.well-known/openai-apps-challenge", } as const; -export const PUBLIC_ROUTE_PREFIXES = { - bearer: "/bearer", - token: "/token", -} as const; +export const PUBLIC_ROUTE_PREFIXES = PKG_PUBLIC_ROUTE_PREFIXES; export const EXACT_PUBLIC_ROUTES_REQUIRING_METRICS = [ PUBLIC_ROUTES.root, diff --git a/src/stdio.ts b/src/stdio.ts index 49174a7..f37972f 100755 --- a/src/stdio.ts +++ b/src/stdio.ts @@ -1,9 +1,9 @@ #!/usr/bin/env node import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { validateAndSanitizeUrl } from "@mouryabalabhadra/ts-cloudflare-auth"; import { MCPServer } from "./servers/mcp-server.js"; import type { Props } from "./utils.js"; -import { validateAndSanitizeUrl } from "./oauth-manager/oauth-utils.js"; async function main() { const instanceUrl = process.env.TS_INSTANCE; diff --git a/src/utils.ts b/src/utils.ts index b7c525d..a9430c1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,4 @@ +import { McpServerError as PkgMcpServerError } from "@mouryabalabhadra/ts-cloudflare-auth"; import { type Span, SpanStatusCode } from "@opentelemetry/api"; import type { ApiVersionMode } from "./metrics/runtime/metric-types"; import { getActiveSpan } from "./metrics/tracing/tracing-utils"; @@ -15,83 +16,37 @@ export type Props = { apiRequestedVersion?: string; }; -export class McpServerError extends Error { +/** + * Local McpServerError that wraps the base pkg error with OTel span + * status/attribute side-effects (the pkg error is span-agnostic so it stays + * portable across consumers that don't use OpenTelemetry). + */ +export class McpServerError extends PkgMcpServerError { public readonly span?: Span; - public readonly errorJson: any; - public readonly statusCode: number; - - constructor(errorJson: any, statusCode: number) { - // Extract message from error JSON or use a default message - const message = - typeof errorJson === "string" - ? errorJson - : errorJson?.message || errorJson?.error || "Unknown error occurred"; - - super(message); - this.name = "McpServerError"; + constructor(errorJson: unknown, statusCode: number) { + super(errorJson, statusCode); this.span = getActiveSpan(); - this.errorJson = errorJson; - this.statusCode = statusCode; - // Set span status if span is provided if (this.span) { this.span.setStatus({ code: SpanStatusCode.ERROR, message: this.message, }); - - // Record the exception in the span this.span.recordException(this); - - // Add error details as span attributes if (typeof errorJson === "object" && errorJson !== null) { - // Add relevant error details to span attributes - if (errorJson.code) { - this.span.setAttribute("error.code", errorJson.code); - } - if (errorJson.type) { - this.span.setAttribute("error.type", errorJson.type); - } - if (errorJson.details) { - this.span.setAttribute( - "error.details", - JSON.stringify(errorJson.details), - ); + const obj = errorJson as Record; + if (obj.code) this.span.setAttribute("error.code", String(obj.code)); + if (obj.type) this.span.setAttribute("error.type", String(obj.type)); + if (obj.details) { + this.span.setAttribute("error.details", JSON.stringify(obj.details)); } } - this.span.setAttribute("error.status_code", this.statusCode); } - console.error("Error:", this.message); - - // Ensure proper prototype chain for instanceof checks Object.setPrototypeOf(this, McpServerError.prototype); } - - /** - * Convert the error to a JSON representation - */ - toJSON() { - return { - name: this.name, - message: this.message, - statusCode: this.statusCode, - errorJson: this.errorJson, - stack: this.stack, - }; - } - - /** - * Get a user-friendly error message - */ - getUserMessage(): string { - if (typeof this.errorJson === "object" && this.errorJson?.userMessage) { - return this.errorJson.userMessage; - } - return this.message; - } } /** diff --git a/test/bearer.spec.ts b/test/bearer.spec.ts deleted file mode 100644 index a0af829..0000000 --- a/test/bearer.spec.ts +++ /dev/null @@ -1,774 +0,0 @@ -import { Hono } from "hono"; -import { decodeBase64Url, encodeBase64Url } from "hono/utils/encode"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ThoughtSpotMCP } from "../src"; -import { withBearerHandler } from "../src/bearer"; -import { METRIC_NAMES } from "../src/metrics/runtime/metric-types"; -import { - createRequestMetricsRecorder, - setMetricsRecorderOnExecutionContext, -} from "../src/metrics/runtime/request-metrics"; - -// For correctly-typed Request -const IncomingRequest = Request; - -describe("Bearer Handler", () => { - let app: any; - let mockEnv: any; - let mockCtx: any; - let mockMcpServer: any; - - beforeEach(() => { - // Create a simple Hono app for testing - app = new Hono(); - - // Mock environment - mockEnv = { - ASSETS: { - fetch: vi.fn().mockResolvedValue(new Response("Test")), - }, - OAUTH_PROVIDER: { - parseAuthRequest: vi.fn(), - lookupClient: vi.fn(), - completeAuthorization: vi.fn(), - }, - }; - - // Mock execution context - mockCtx = { - props: {}, - waitUntil: vi.fn(), - }; - - // Mock the MCP server - mockMcpServer = { - serve: vi.fn().mockReturnValue({ - fetch: vi - .fn() - .mockResolvedValue(new Response("MCP Response", { status: 200 })), - }), - serveSSE: vi.fn().mockReturnValue({ - fetch: vi - .fn() - .mockResolvedValue(new Response("SSE Response", { status: 200 })), - }), - }; - - // Mock ThoughtSpotMCP class - vi.mocked(ThoughtSpotMCP).serve = mockMcpServer.serve; - vi.mocked(ThoughtSpotMCP).serveSSE = mockMcpServer.serveSSE; - }); - - describe("withBearerHandler", () => { - it("should mount bearer routes to the app", () => { - const result = withBearerHandler(app, ThoughtSpotMCP); - expect(result).toBe(app); - }); - - it("should handle requests to /bearer/mcp endpoint", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - "x-ts-client-name": "Test Client", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // The request should be handled by the bearer handler - expect(result).toBeDefined(); - }); - - it("should handle requests to /bearer/sse endpoint", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/sse", { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - "x-ts-client-name": "Test Client", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // The request should be handled by the bearer handler - expect(result).toBeDefined(); - }); - - it("should route /bearer/mcp to MCP server and call serve method", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - "x-ts-client-name": "Test Client", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Verify that the MCP server's serve method was called with the correct path - expect(mockMcpServer.serve).toHaveBeenCalledWith("/mcp"); - - // Verify that the serve method returned a fetch function that was called - const mockServeReturn = mockMcpServer.serve(); - expect(mockServeReturn.fetch).toHaveBeenCalledWith( - request, - mockEnv, - mockCtx, - ); - - // Verify the response - expect(result.status).toBe(200); - expect(await result.text()).toBe("MCP Response"); - }); - - it("should route /bearer/sse to MCP server SSE and call serveSSE method", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/sse", { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - "x-ts-client-name": "Test Client", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Verify that the MCP server's serveSSE method was called with the correct path - expect(mockMcpServer.serveSSE).toHaveBeenCalledWith("/sse"); - - // Verify that the serveSSE method returned a fetch function that was called - const mockServeSSEReturn = mockMcpServer.serveSSE(); - expect(mockServeSSEReturn.fetch).toHaveBeenCalledWith( - request, - mockEnv, - mockCtx, - ); - - // Verify the response - expect(result.status).toBe(200); - expect(await result.text()).toBe("SSE Response"); - }); - - it("should set context properties correctly when routing to MCP server", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: - "Bearer my-access-token@https://my-instance.thoughtspot.cloud", - "x-ts-client-name": "Custom Test Client", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Verify that the MCP server's serve method was called - expect(mockMcpServer.serve).toHaveBeenCalledWith("/mcp"); - - // Verify that the serve method returned a fetch function that was called - const mockServeReturn = mockMcpServer.serve(); - expect(mockServeReturn.fetch).toHaveBeenCalledWith( - request, - mockEnv, - mockCtx, - ); - - // Verify that the context properties were set correctly - expect(mockCtx.props).toEqual({ - accessToken: "my-access-token", - instanceUrl: "https://my-instance.thoughtspot.cloud", - clientName: "Custom Test Client", - apiVersion: "backwards-compatibility-default", - apiVersionMode: "implicit_legacy", - }); - - // Verify the response - expect(result.status).toBe(200); - expect(await result.text()).toBe("MCP Response"); - }); - - it("should set default client name when x-ts-client-name is not provided", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: - "Bearer my-access-token@https://my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Verify that the context properties were set correctly with default client name - expect(mockCtx.props).toEqual({ - accessToken: "my-access-token", - instanceUrl: "https://my-instance.thoughtspot.cloud", - clientName: "Bearer Token client", - apiVersion: "backwards-compatibility-default", - apiVersionMode: "implicit_legacy", - }); - - // Verify the response - expect(result.status).toBe(200); - expect(await result.text()).toBe("MCP Response"); - }); - }); - - describe("Authorization Header Parsing", () => { - it("records bearer auth metrics for rejected requests", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - const recorder = createRequestMetricsRecorder(); - setMetricsRecorderOnExecutionContext( - mockCtx as ExecutionContext, - recorder, - ); - - const request = new Request("https://example.com/bearer/mcp"); - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - expect(result.status).toBe(400); - expect(recorder.snapshot()).toContainEqual( - expect.objectContaining({ - kind: "counter", - name: METRIC_NAMES.bearerAuthRequestsTotal, - value: 1, - labels: { - outcome: "client_error", - route_group: "bearer_mcp", - transport: "mcp", - }, - }), - ); - }); - - it("should return 400 when authorization header is missing", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp"); - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - expect(result.status).toBe(400); - expect(await result.text()).toBe("Bearer token is required"); - }); - - it("should parse token and host from authorization header with @ separator", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "Bearer my-token@my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should not return 400 for missing host - expect(result.status).not.toBe(400); - }); - - it("should use x-ts-host header when token doesn't contain @ separator", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "Bearer my-token", - "x-ts-host": "my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should not return 400 for missing host - expect(result.status).not.toBe(400); - }); - - it("should return 400 when neither @ separator nor x-ts-host header is provided", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "Bearer my-token", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - expect(result.status).toBe(400); - expect(await result.text()).toBe( - "TS Host is required, either in the authorization header as 'token@ts-host' or as a separate 'x-ts-host' header", - ); - }); - }); - - describe("Client Name Handling", () => { - it("should use provided x-ts-client-name header", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "Bearer my-token@my-instance.thoughtspot.cloud", - "x-ts-client-name": "Custom Client Name", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should not return 400 for missing client name - expect(result.status).not.toBe(400); - }); - - it("should use default client name when x-ts-client-name is not provided", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "Bearer my-token@my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should not return 400 for missing client name - expect(result.status).not.toBe(400); - }); - }); - - describe("URL Validation", () => { - it("should validate and sanitize the TS host URL", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: - "Bearer my-token@https://my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should not return 400 for invalid URL - expect(result.status).not.toBe(400); - }); - - it("should handle URLs without protocol", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "Bearer my-token@my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should not return 400 for URL without protocol - expect(result.status).not.toBe(400); - }); - }); - - describe("Endpoint Routing", () => { - it("should route /bearer/mcp to MCP server", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "Bearer my-token@my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should be handled by MCP server (not return 404) - expect(result.status).not.toBe(404); - }); - - it("should route /bearer/sse to MCP server SSE", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/sse", { - headers: { - authorization: "Bearer my-token@my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should be handled by MCP server SSE (not return 404) - expect(result.status).not.toBe(404); - }); - - it("should return 404 for unknown endpoints under /bearer", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/unknown", { - headers: { - authorization: "Bearer my-token@my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - expect(result.status).toBe(404); - expect(await result.text()).toBe("Not found"); - }); - }); - - describe("Context Properties", () => { - it("should set accessToken in context props", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "Bearer my-access-token@my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should not return 400 for missing access token - expect(result.status).not.toBe(400); - }); - - it("should set instanceUrl in context props", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: - "Bearer my-token@https://my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should not return 400 for missing instance URL - expect(result.status).not.toBe(400); - }); - - it("should set clientName in context props", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "Bearer my-token@my-instance.thoughtspot.cloud", - "x-ts-client-name": "Test Client", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should not return 400 for missing client name - expect(result.status).not.toBe(400); - }); - }); - - describe("Edge Cases", () => { - it("should handle empty token", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "Bearer @my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should not return 400 for empty token - expect(result.status).not.toBe(400); - }); - - it("should handle malformed authorization header", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: "InvalidFormat my-token@my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should handle malformed header gracefully - expect(result.status).not.toBe(400); - }); - - it("should handle multiple @ symbols in token", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/bearer/mcp", { - headers: { - authorization: - "Bearer my-token@with@multiple@symbols@my-instance.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Should handle multiple @ symbols - expect(result.status).not.toBe(400); - }); - }); - - describe("DEPRECATED: /bearer endpoints - Fixed API Version Override", () => { - it("should use backwards-compatibility-default apiVersion and ignore query param on /bearer/mcp", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request( - "https://example.com/bearer/mcp?api-version=beta", - { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - }, - }, - ); - - await appWithBearer.fetch(request, mockEnv, mockCtx); - - // LEGACY: /bearer endpoints always use backwards-compatibility-default, ignoring any query param - expect(mockCtx.props).toMatchObject({ - accessToken: "test-token", - instanceUrl: "https://test.thoughtspot.cloud", - apiRequestedVersion: "beta", - }); - expect(mockCtx.props.apiVersion).toBe("backwards-compatibility-default"); - expect(mockCtx.props.apiVersionMode).toBe("implicit_legacy"); - }); - - it("should use backwards-compatibility-default apiVersion and ignore query param on /bearer/sse", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request( - "https://example.com/bearer/sse?api-version=beta", - { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - }, - }, - ); - - await appWithBearer.fetch(request, mockEnv, mockCtx); - - // LEGACY: /bearer endpoints always use backwards-compatibility-default, ignoring any query param - expect(mockCtx.props).toMatchObject({ - accessToken: "test-token", - instanceUrl: "https://test.thoughtspot.cloud", - apiRequestedVersion: "beta", - }); - expect(mockCtx.props.apiVersion).toBe("backwards-compatibility-default"); - expect(mockCtx.props.apiVersionMode).toBe("implicit_legacy"); - }); - }); - - describe("NEW: /token endpoints - API Version Query Parameter Support", () => { - it("should inject apiVersion=beta when query param is present on /token/mcp", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request( - "https://example.com/token/mcp?api-version=beta", - { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - }, - }, - ); - - await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Verify that props were set with apiVersion - expect(mockCtx.props).toMatchObject({ - accessToken: "test-token", - instanceUrl: "https://test.thoughtspot.cloud", - apiRequestedVersion: "beta", - apiVersion: "beta", - apiVersionMode: "beta", - }); - }); - - it("should inject apiVersion=beta when query param is present on /token/sse", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request( - "https://example.com/token/sse?api-version=beta", - { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - }, - }, - ); - - await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Verify that props were set with apiVersion - expect(mockCtx.props).toMatchObject({ - accessToken: "test-token", - instanceUrl: "https://test.thoughtspot.cloud", - apiRequestedVersion: "beta", - apiVersion: "beta", - apiVersionMode: "beta", - }); - }); - - it("should default unversioned /token/mcp requests to latest", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/token/mcp", { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - }, - }); - - await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Verify that props reflect the effective served surface - expect(mockCtx.props).toMatchObject({ - accessToken: "test-token", - instanceUrl: "https://test.thoughtspot.cloud", - apiVersion: "latest", - apiVersionMode: "implicit_latest", - }); - }); - - it("should inject apiVersion with date format on /token/mcp", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request( - "https://example.com/token/mcp?api-version=2025-03-01", - { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - }, - }, - ); - - await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Verify that props have apiVersion with date - expect(mockCtx.props).toMatchObject({ - accessToken: "test-token", - instanceUrl: "https://test.thoughtspot.cloud", - apiRequestedVersion: "2025-03-01", - apiVersion: "2025-03-01", - apiVersionMode: "pinned", - }); - }); - - it("should inject apiVersion with any string value on /token/mcp", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request( - "https://example.com/token/mcp?api-version=2024-12-01", - { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - }, - }, - ); - - await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Verify that props have apiVersion - validation happens in the MCP server - expect(mockCtx.props).toMatchObject({ - accessToken: "test-token", - instanceUrl: "https://test.thoughtspot.cloud", - apiRequestedVersion: "2024-12-01", - apiVersion: "2024-12-01", - apiVersionMode: "pinned", - }); - }); - - it("should handle query params with x-ts-host header on /token/mcp", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request( - "https://example.com/token/mcp?api-version=beta", - { - headers: { - authorization: "Bearer test-token", - "x-ts-host": "test.thoughtspot.cloud", - }, - }, - ); - - await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Verify that props were set correctly with both header and query param - expect(mockCtx.props).toMatchObject({ - accessToken: "test-token", - instanceUrl: "https://test.thoughtspot.cloud", - apiRequestedVersion: "beta", - apiVersion: "beta", - apiVersionMode: "beta", - }); - }); - - it("should properly route to serve() with query params on /token/mcp", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request( - "https://example.com/token/mcp?api-version=beta", - { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - }, - }, - ); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Verify that serve was called - expect(mockMcpServer.serve).toHaveBeenCalledWith("/mcp"); - expect(result.status).toBe(200); - }); - - it("should properly route to serveSSE() with query params on /token/sse", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request( - "https://example.com/token/sse?api-version=beta", - { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - }, - }, - ); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - // Verify that serveSSE was called - expect(mockMcpServer.serveSSE).toHaveBeenCalledWith("/sse"); - expect(result.status).toBe(200); - }); - - it("should handle /token/mcp without query params", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/token/mcp", { - headers: { - authorization: "Bearer test-token@test.thoughtspot.cloud", - }, - }); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - expect(result.status).toBe(200); - expect(mockCtx.props.apiVersion).toBe("latest"); - expect(mockCtx.props.apiVersionMode).toBe("implicit_latest"); - }); - - it("should require bearer token on /token/mcp", async () => { - const appWithBearer = withBearerHandler(app, ThoughtSpotMCP); - - const request = new Request("https://example.com/token/mcp"); - - const result = await appWithBearer.fetch(request, mockEnv, mockCtx); - - expect(result.status).toBe(400); - expect(await result.text()).toBe("Bearer token is required"); - }); - }); -}); diff --git a/test/handlers.spec.ts b/test/handlers.spec.ts deleted file mode 100644 index 679f19b..0000000 --- a/test/handlers.spec.ts +++ /dev/null @@ -1,1162 +0,0 @@ -import { - createExecutionContext, - env, - runInDurableObject, -} from "cloudflare:test"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import worker from "../src"; -import app from "../src/handlers"; -import { METRIC_NAMES } from "../src/metrics/runtime/metric-types"; -import { - createRequestMetricsRecorder, - setMetricsRecorderOnExecutionContext, -} from "../src/metrics/runtime/request-metrics"; - -// Type assertion for worker to have fetch method -const typedWorker = worker as { - fetch: (request: Request, env: any, ctx: any) => Promise; -}; -import { decodeBase64Url, encodeBase64Url } from "hono/utils/encode"; - -// For correctly-typed Request -const IncomingRequest = Request; - -describe("Handlers", () => { - let mockEnv: any; - let mockCtx: any; - - beforeEach(() => { - // Mock environment - mockEnv = { - ASSETS: { - fetch: vi.fn().mockResolvedValue(new Response("Test")), - }, - OAUTH_PROVIDER: { - parseAuthRequest: vi.fn(), - lookupClient: vi.fn(), - completeAuthorization: vi.fn(), - }, - }; - - // Mock execution context - mockCtx = createExecutionContext(); - }); - - describe("GET /", () => { - it("should serve index.html from assets", async () => { - const request = new IncomingRequest("https://example.com/"); - const testEnv = { - ...env, - ASSETS: { - fetch: vi.fn().mockImplementation((url) => { - // Handle relative paths by creating a proper URL - const fullUrl = url.startsWith("http") - ? url - : `https://example.com${url}`; - return Promise.resolve( - new Response("Test", { - headers: { "Content-Type": "text/html" }, - }), - ); - }), - connect: vi.fn(), - }, - }; - - const result = await typedWorker.fetch(request, testEnv, mockCtx); - - expect(result.status).toBe(200); - // Consume the response body to prevent storage cleanup issues - await result.text(); - }); - }); - - describe("GET /hello", () => { - it("should return hello world message", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const request = new IncomingRequest("https://example.com/hello"); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(200); - const data = await result.json(); - expect(data).toEqual({ message: "Hello, World!" }); - }); - }); - - describe("GET /authorize", () => { - it("should return 500 for invalid client ID", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const request = new IncomingRequest("https://example.com/authorize"); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(500); - expect(await result.text()).toBe( - "Internal Server Error McpServerError: Missing client ID", - ); - }); - - it("should render approval dialog for valid client ID", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - // Mock the OAUTH_PROVIDER to return valid client info - const mockOAuthProvider = { - parseAuthRequest: vi.fn().mockResolvedValue({ - clientId: "test-client", - codeChallenge: "test-code-challenge", - codeChallengeMethod: "S256", - }), - lookupClient: vi.fn().mockResolvedValue({ - clientId: "test-client", - clientName: "Test Client", - registrationDate: Date.now(), - redirectUris: ["https://example.com/callback"], - tokenEndpointAuthMethod: "client_secret_basic", - }), - }; - - const result = await runInDurableObject(object, async (instance) => { - const request = new IncomingRequest("https://example.com/authorize"); - // Override the env for this test - const testEnv = { ...env, OAUTH_PROVIDER: mockOAuthProvider }; - return typedWorker.fetch(request, testEnv, mockCtx); - }); - - // The response should be HTML content for the approval dialog - expect(result.status).toBe(200); - const contentType = result.headers.get("content-type"); - expect(contentType).toContain("text/html"); - - // Consume the response body to prevent storage cleanup issues - await result.text(); - }); - }); - - describe("POST /authorize", () => { - it("records authorize submit metrics for client errors", async () => { - const recorder = createRequestMetricsRecorder(); - setMetricsRecorderOnExecutionContext( - mockCtx as ExecutionContext, - recorder, - ); - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: { clientId: "test" } })), - ); - - const result = await app.fetch( - new Request("https://example.com/authorize", { - method: "POST", - body: formData, - }), - mockEnv, - mockCtx, - ); - - expect(result.status).toBe(400); - expect(recorder.snapshot()).toContainEqual( - expect.objectContaining({ - kind: "counter", - name: METRIC_NAMES.oauthAuthorizeSubmitTotal, - value: 1, - labels: expect.objectContaining({ - api_surface: "oauth", - auth_mode: "none", - outcome: "client_error", - route_group: "authorize", - status_class: "4xx", - transport: "http", - }), - }), - ); - }); - - it("should return 400 for missing instance URL", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: { clientId: "test" } })), - ); - // Intentionally not adding instanceUrl - - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: formData, - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(400); - expect(await result.text()).toBe("Missing instance URL"); - }); - - it("should return 500 for missing oauthReqInfo in state", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ someOtherData: "test" })), - ); - formData.append("instanceUrl", "https://test.thoughtspot.cloud"); - - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: formData, - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(500); - expect(await result.text()).toBe( - "Internal Server Error McpServerError: Failed to parse approval form: Could not extract clientId from state object.", - ); - }); - - it("should return 500 for null oauthReqInfo in state", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const formData = new FormData(); - formData.append("state", btoa(JSON.stringify({ oauthReqInfo: null }))); - formData.append("instanceUrl", "https://test.thoughtspot.cloud"); - - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: formData, - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(500); - expect(await result.text()).toBe( - "Internal Server Error McpServerError: Failed to parse approval form: Could not extract clientId from state object.", - ); - }); - - it("should return 500 for undefined oauthReqInfo in state", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: undefined })), - ); - formData.append("instanceUrl", "https://test.thoughtspot.cloud"); - - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: formData, - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(500); - expect(await result.text()).toBe( - "Internal Server Error McpServerError: Failed to parse approval form: Could not extract clientId from state object.", - ); - }); - - it("Should redirect to callback for free trial instance URL", async () => { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: { clientId: "test" } })), - ); - formData.append("instanceUrl", "https://team1.thoughtspot.cloud"); - const result = await app.fetch( - new Request("https://example.com/authorize", { - method: "POST", - body: formData, - }), - mockEnv, - ); - expect(result.status).toBe(302); - expect(result.headers.get("location")).toContain( - "https://example.com/callback", - ); - expect(result.headers.get("location")).toContain( - "instanceUrl=https%3A%2F%2Fteam1.thoughtspot.cloud", - ); - }); - - it("should return 400 for empty string instanceUrl", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: { clientId: "test" } })), - ); - formData.append("instanceUrl", ""); - - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: formData, - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(400); - expect(await result.text()).toBe("Missing instance URL"); - }); - - it.skip("should return 500 for whitespace-only instanceUrl", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: { clientId: "test" } })), - ); - formData.append("instanceUrl", " "); - - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: formData, - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(500); - expect(await result.text()).toBe( - "Internal Server Error McpServerError: Failed to parse approval form: Invalid URL: Invalid URL string.", - ); - }); - - it.skip("should return 400 for null instanceUrl", async () => { - // Skipped due to Miniflare/Vitest bug with URL construction - // This test would verify that the handler properly validates instanceUrl - // but the URL constructor behavior in the test environment is inconsistent - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: { clientId: "test" } })), - ); - formData.append("instanceUrl", "null"); - - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: formData, - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(400); - expect(await result.text()).toBe("Missing instance URL"); - }); - - it("should return 500 for malformed form data", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: "invalid form data", - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(500); - // Consume the response body to prevent storage cleanup issues - await result.text(); - }); - - it.skip("should redirect to SAML login with proper parameters", async () => { - // Skipped due to Miniflare/Vitest bug with 302 responses from Durable Objects. - // The handler works correctly in production, as evidenced by the console.log output - // showing the correct redirect URL formation. - // Handler works as expected in production. - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const oauthReqInfo = { - clientId: "test-client", - scope: "read", - redirectUri: "https://example.com/callback", - }; - - const result = await runInDurableObject(object, async (instance) => { - const formData = new FormData(); - formData.append("state", btoa(JSON.stringify({ oauthReqInfo }))); - formData.append("instanceUrl", "https://test.thoughtspot.cloud"); - - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: formData, - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - // Note: Miniflare/Vitest has issues with 302 responses from Durable Objects - // The handler works correctly in production, but the test framework - // doesn't properly handle the redirect response - // We can verify the handler logic by checking that the response is not an error - expect(result.status).not.toBe(400); - expect(result.status).not.toBe(500); - - // The console.log in the handler shows the redirect URL is correctly formed - // This test verifies the handler doesn't throw errors and processes the request - // Consume the response body to prevent storage cleanup issues - await result.text(); - }); - - it.skip("should handle different instance URL formats", async () => { - // Skipped due to Miniflare/Vitest bug with 302 responses from Durable Objects. - // The handler works correctly in production, as evidenced by the console.log output - // showing the correct redirect URL formation for different instance URLs. - // Handler works as expected in production. - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const testCases = [ - "https://test.thoughtspot.cloud", - "https://mycompany.thoughtspot.cloud", - "https://thoughtspot.company.com", - ]; - - for (const instanceUrl of testCases) { - const oauthReqInfo = { - clientId: "test-client", - scope: "read", - }; - - const result = await runInDurableObject(object, async (instance) => { - const formData = new FormData(); - formData.append("state", btoa(JSON.stringify({ oauthReqInfo }))); - formData.append("instanceUrl", instanceUrl); - - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: formData, - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - // Note: Miniflare/Vitest has issues with 302 responses from Durable Objects - // The handler works correctly in production, but the test framework - // doesn't properly handle the redirect response - expect(result.status).not.toBe(400); - expect(result.status).not.toBe(500); - - // The console.log in the handler shows the redirect URL is correctly formed - // This test verifies the handler doesn't throw errors for different URL formats - // Consume the response body to prevent storage cleanup issues - await result.text(); - } - }); - - it.skip("should properly encode complex oauthReqInfo objects", async () => { - // Skipped due to Miniflare/Vitest bug with 302 responses from Durable Objects. - // The handler works correctly in production, as evidenced by the console.log output - // showing the correct encoding of complex oauthReqInfo objects. - // Handler works as expected in production. - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const complexOauthReqInfo = { - clientId: "test-client", - scope: "read write admin", - redirectUri: "https://example.com/callback", - responseType: "code", - state: "random-state-string", - nonce: "random-nonce-string", - }; - - const result = await runInDurableObject(object, async (instance) => { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: complexOauthReqInfo })), - ); - formData.append("instanceUrl", "https://test.thoughtspot.cloud"); - - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: formData, - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - // Note: Miniflare/Vitest has issues with 302 responses from Durable Objects - // The handler works correctly in production, but the test framework - // doesn't properly handle the redirect response - expect(result.status).not.toBe(400); - expect(result.status).not.toBe(500); - - // The console.log in the handler shows the redirect URL is correctly formed - // and the complex oauthReqInfo is properly encoded - // This test verifies the handler can handle complex objects without errors - // Consume the response body to prevent storage cleanup issues - await result.text(); - }); - - it("should handle errors gracefully and return 500", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - // Test with invalid base64 in state - const result = await runInDurableObject(object, async (instance) => { - const formData = new FormData(); - formData.append("state", "invalid-base64-data"); - formData.append("instanceUrl", "https://test.thoughtspot.cloud"); - - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: formData, - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(500); - // Consume the response body to prevent storage cleanup issues - await result.text(); - }); - - describe("Instance URL regex pattern matching", () => { - it("should redirect to callback for team URLs with numbers", async () => { - const testCases = [ - "https://team1.thoughtspot.cloud", - "https://team2.thoughtspot.cloud", - "https://team3.thoughtspot.cloud", - ]; - - for (const instanceUrl of testCases) { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: { clientId: "test" } })), - ); - formData.append("instanceUrl", instanceUrl); - - const result = await app.fetch( - new Request("https://example.com/authorize", { - method: "POST", - body: formData, - }), - mockEnv, - ); - - expect(result.status).toBe(302); - expect(result.headers.get("location")).toContain( - "https://example.com/callback", - ); - expect(result.headers.get("location")).toContain( - `instanceUrl=${encodeURIComponent(instanceUrl)}`, - ); - } - }); - - it("should redirect to callback for my URLs with numbers", async () => { - const testCases = [ - "https://my1.thoughtspot.cloud", - "https://my2.thoughtspot.cloud", - "https://my3.thoughtspot.cloud", - ]; - - for (const instanceUrl of testCases) { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: { clientId: "test" } })), - ); - formData.append("instanceUrl", instanceUrl); - - const result = await app.fetch( - new Request("https://example.com/authorize", { - method: "POST", - body: formData, - }), - mockEnv, - ); - - expect(result.status).toBe(302); - expect(result.headers.get("location")).toContain( - "https://example.com/callback", - ); - expect(result.headers.get("location")).toContain( - `instanceUrl=${encodeURIComponent(instanceUrl)}`, - ); - } - }); - - it("should NOT redirect to callback for URLs that don't match the pattern", async () => { - const testCases = [ - "https://company.thoughtspot.cloud", // no team/my prefix - "https://team.thoughtspot.cloud", // no number after team - "https://my.thoughtspot.cloud", // no number after my - "https://teamabc.thoughtspot.cloud", // non-numeric after team - "https://myabc.thoughtspot.cloud", // non-numeric after my - "https://team1test.thoughtspot.cloud", // extra characters after number - "https://my1test.thoughtspot.cloud", // extra characters after number - "https://test-team1.thoughtspot.cloud", // prefix before team - "https://test-my1.thoughtspot.cloud", // prefix before my - "https://team1.test.cloud", // different domain - "https://my1.test.cloud", // different domain - "https://team123.thoughtspot.com", // wrong TLD - "https://my123.thoughtspot.com", // wrong TLD - "http://team1.thoughtspot.cloud", // http instead of https - "http://my1.thoughtspot.cloud", // http instead of https - ]; - - for (const instanceUrl of testCases) { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: { clientId: "test" } })), - ); - formData.append("instanceUrl", instanceUrl); - - const result = await app.fetch( - new Request("https://example.com/authorize", { - method: "POST", - body: formData, - }), - mockEnv, - ); - - // These should not redirect to callback (should go through SAML redirect) - expect(result.status).not.toBe(400); - if (result.status === 302) { - const location = result.headers.get("location"); - // Should redirect to SAML login, not directly to callback - // SAML redirects contain '/callosum/v1/saml/login' - // Direct callback redirects start with 'https://example.com/callback' - expect(location).not.toMatch(/^https:\/\/example\.com\/callback/); - expect(location).toContain("/callosum/v1/saml/login"); - } - } - }); - - it("should verify the exact regex pattern behavior", () => { - // Test the actual regex pattern used in the code - const regex = /^https:\/\/(?:team|my)\d+\.thoughtspot\.cloud\/?$/; - - // URLs that should match - const matchingUrls = [ - "https://team1.thoughtspot.cloud", - "https://my1.thoughtspot.cloud", - "https://team123.thoughtspot.cloud", - "https://my456.thoughtspot.cloud", - "https://team999999.thoughtspot.cloud", - "https://my999999.thoughtspot.cloud", - "https://team01.thoughtspot.cloud", // leading zeros match \d+ - "https://my01.thoughtspot.cloud", - "https://team001.thoughtspot.cloud", - "https://my001.thoughtspot.cloud", - ]; - - // URLs that should not match - const nonMatchingUrls = [ - "https://company.thoughtspot.cloud", - "https://team.thoughtspot.cloud", - "https://my.thoughtspot.cloud", - "https://teamabc.thoughtspot.cloud", - "https://myabc.thoughtspot.cloud", - "https://team1test.thoughtspot.cloud", - "https://my1test.thoughtspot.cloud", - "https://test-team1.thoughtspot.cloud", - "https://test-my1.thoughtspot.cloud", - "https://team1.test.cloud", - "https://my1.test.cloud", - "https://team123.thoughtspot.com", - "https://my123.thoughtspot.com", - "http://team1.thoughtspot.cloud", - "http://my1.thoughtspot.cloud", - "https://TEAM1.thoughtspot.cloud", // case sensitive - "https://MY1.thoughtspot.cloud", // case sensitive - ]; - - // Test matching URLs - for (const url of matchingUrls) { - expect(regex.test(url)).toBe(true); - } - - // Test non-matching URLs - for (const url of nonMatchingUrls) { - expect(regex.test(url)).toBe(false); - } - }); - }); - }); - - describe("GET /callback", () => { - it("records callback metrics with request context labels", async () => { - const recorder = createRequestMetricsRecorder(); - setMetricsRecorderOnExecutionContext( - mockCtx as ExecutionContext, - recorder, - ); - - const result = await app.fetch( - new Request("https://example.com/callback"), - mockEnv, - mockCtx, - ); - - expect(result.status).toBe(400); - expect(recorder.snapshot()).toContainEqual( - expect.objectContaining({ - kind: "counter", - name: METRIC_NAMES.oauthCallbackTotal, - value: 1, - labels: expect.objectContaining({ - api_surface: "oauth", - auth_mode: "none", - outcome: "client_error", - route_group: "callback", - status_class: "4xx", - transport: "http", - }), - }), - ); - }); - - it("should return 400 for missing instance URL", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const request = new IncomingRequest("https://example.com/callback"); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(400); - expect(await result.text()).toBe( - "Missing instance URL McpServerError: Missing instance URL", - ); - }); - - it("should return 400 for missing OAuth request info", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const url = new URL("https://example.com/callback"); - url.searchParams.append( - "instanceUrl", - "https://test.thoughtspot.cloud", - ); - const request = new IncomingRequest(url.toString()); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(400); - expect(await result.text()).toBe( - "Missing OAuth request info McpServerError: Missing OAuth request info", - ); - }); - - it("should return 400 for invalid OAuth request info format", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const url = new URL("https://example.com/callback"); - url.searchParams.append( - "instanceUrl", - "https://test.thoughtspot.cloud", - ); - url.searchParams.append("oauthReqInfo", "invalid-base64"); - const request = new IncomingRequest(url.toString()); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(400); - expect(await result.text()).toBe( - "Invalid OAuth request info format McpServerError: Invalid OAuth request info format", - ); - }); - - it("should render token callback page for valid parameters", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const oauthReqInfo = { - clientId: "test-client", - scope: "read", - redirectUri: "https://example.com/callback", - }; - const encodedOauthReqInfo = btoa(JSON.stringify(oauthReqInfo)); - - const result = await runInDurableObject(object, async (instance) => { - const url = new URL("https://example.com/callback"); - url.searchParams.append( - "instanceUrl", - "https://test.thoughtspot.cloud", - ); - url.searchParams.append("oauthReqInfo", encodedOauthReqInfo); - const request = new IncomingRequest(url.toString()); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(200); - const contentType = result.headers.get("content-type"); - expect(contentType).toContain("text/html"); - - // Consume the response body to prevent storage cleanup issues - await result.text(); - }); - }); - - describe("POST /store-token", () => { - it("records store-token metrics with request context labels", async () => { - const recorder = createRequestMetricsRecorder(); - setMetricsRecorderOnExecutionContext( - mockCtx as ExecutionContext, - recorder, - ); - - const result = await app.fetch( - new Request("https://example.com/store-token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - oauthReqInfo: { clientId: "test" }, - instanceUrl: "https://test.thoughtspot.cloud", - }), - }), - mockEnv, - mockCtx, - ); - - expect(result.status).toBe(400); - expect(recorder.snapshot()).toContainEqual( - expect.objectContaining({ - kind: "counter", - name: METRIC_NAMES.oauthStoreTokenTotal, - value: 1, - labels: expect.objectContaining({ - api_surface: "oauth", - auth_mode: "none", - outcome: "client_error", - route_group: "store_token", - status_class: "4xx", - transport: "http", - }), - }), - ); - }); - - it("should return 400 for missing token", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const request = new IncomingRequest("https://example.com/store-token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - oauthReqInfo: { clientId: "test" }, - instanceUrl: "https://test.thoughtspot.cloud", - }), - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(400); - expect(await result.text()).toBe( - "Missing token or OAuth request info or instanceUrl McpServerError: Missing token or OAuth request info or instanceUrl", - ); - }); - - it("should return 400 for missing OAuth request info", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const request = new IncomingRequest("https://example.com/store-token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - token: { data: { token: "test-token" } }, - instanceUrl: "https://test.thoughtspot.cloud", - }), - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(400); - expect(await result.text()).toBe( - "Missing token or OAuth request info or instanceUrl McpServerError: Missing token or OAuth request info or instanceUrl", - ); - }); - - it("should return 400 for missing instance URL", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - const result = await runInDurableObject(object, async (instance) => { - const request = new IncomingRequest("https://example.com/store-token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - token: { data: { token: "test-token" } }, - oauthReqInfo: { clientId: "test" }, - }), - }); - return typedWorker.fetch(request, env, mockCtx); - }); - - expect(result.status).toBe(400); - expect(await result.text()).toBe( - "Missing token or OAuth request info or instanceUrl McpServerError: Missing token or OAuth request info or instanceUrl", - ); - }); - - it("should complete authorization and return redirect URL", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - // Mock the OAUTH_PROVIDER - const mockOAuthProvider = { - lookupClient: vi.fn().mockResolvedValue({ - clientId: "test-client", - clientName: "Test Client", - registrationDate: Date.now(), - redirectUris: ["https://example.com/callback"], - tokenEndpointAuthMethod: "client_secret_basic", - }), - completeAuthorization: vi.fn().mockResolvedValue({ - redirectTo: "https://example.com/success", - }), - }; - - const result = await runInDurableObject(object, async (instance) => { - const request = new IncomingRequest("https://example.com/store-token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - token: { data: { token: "test-token" } }, - oauthReqInfo: { - clientId: "test-client", - scope: "read", - }, - instanceUrl: "https://test.thoughtspot.cloud", - }), - }); - const testEnv = { ...env, OAUTH_PROVIDER: mockOAuthProvider }; - return typedWorker.fetch(request, testEnv, mockCtx); - }); - - expect(result.status).toBe(200); - const data = await result.json(); - expect(data).toEqual({ redirectTo: "https://example.com/success" }); - expect(result.headers.get("content-type")).toBe("application/json"); - }); - }); - - describe("Error handling", () => { - it("should handle malformed JSON in store-token", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - // Mock the OAUTH_PROVIDER - const mockOAuthProvider = { - lookupClient: vi.fn().mockResolvedValue({ - clientId: "test-client", - clientName: "Test Client", - registrationDate: Date.now(), - redirectUris: ["https://example.com/callback"], - tokenEndpointAuthMethod: "client_secret_basic", - }), - completeAuthorization: vi.fn().mockResolvedValue({ - redirectTo: "https://example.com/success", - }), - }; - - const result = await runInDurableObject(object, async (instance) => { - const request = new IncomingRequest("https://example.com/store-token", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "invalid json", - }); - const testEnv = { ...env, OAUTH_PROVIDER: mockOAuthProvider }; - return typedWorker.fetch(request, testEnv, mockCtx); - }); - - expect(result.status).toBe(400); - expect(await result.text()).toBe( - "Invalid JSON format McpServerError: Invalid JSON format", - ); - }); - - it("should handle malformed form data in authorize", async () => { - const id = env.MCP_OBJECT.idFromName("test"); - const object = env.MCP_OBJECT.get(id); - - // Mock the OAUTH_PROVIDER - const mockOAuthProvider = { - lookupClient: vi.fn().mockResolvedValue({ - clientId: "test-client", - clientName: "Test Client", - registrationDate: Date.now(), - redirectUris: ["https://example.com/callback"], - tokenEndpointAuthMethod: "client_secret_basic", - }), - completeAuthorization: vi.fn().mockResolvedValue({ - redirectTo: "https://example.com/success", - }), - }; - - const result = await runInDurableObject(object, async (instance) => { - const request = new IncomingRequest("https://example.com/authorize", { - method: "POST", - body: "invalid form data", - }); - const testEnv = { ...env, OAUTH_PROVIDER: mockOAuthProvider }; - return typedWorker.fetch(request, testEnv, mockCtx); - }); - - expect(result.status).toBe(500); - // Consume the response body to prevent storage cleanup issues - await result.text(); - }); - - it("should verify redirect URL construction logic", async () => { - // This test verifies the URL construction logic without relying on the redirect response - const instanceUrl = "https://test.thoughtspot.cloud"; - const oauthReqInfo = { - clientId: "test-client", - scope: "read", - redirectUri: "https://example.com/callback", - }; - - // Test the URL construction logic that the handler uses - const redirectUrl = new URL("callosum/v1/saml/login", instanceUrl); - const targetURLPath = new URL("/callback", "https://example.com"); - targetURLPath.searchParams.append("instanceUrl", instanceUrl); - const encodedState = encodeBase64Url( - new TextEncoder().encode(JSON.stringify(oauthReqInfo)).buffer, - ); - targetURLPath.searchParams.append("oauthReqInfo", encodedState); - redirectUrl.searchParams.append("targetURLPath", targetURLPath.href); - - // Verify the constructed URL has the expected structure - expect(redirectUrl.origin).toBe("https://test.thoughtspot.cloud"); - expect(redirectUrl.pathname).toBe("/callosum/v1/saml/login"); - - const targetURLPathParam = redirectUrl.searchParams.get("targetURLPath"); - expect(targetURLPathParam).toBeTruthy(); - - const targetURL = new URL(targetURLPathParam!); - expect(targetURL.pathname).toBe("/callback"); - expect(targetURL.searchParams.get("instanceUrl")).toBe(instanceUrl); - - const encodedOauthReqInfo = targetURL.searchParams.get("oauthReqInfo"); - expect(encodedOauthReqInfo).toBeTruthy(); - - // Verify the encoding is correct by decoding it - const decodedOauthReqInfo = JSON.parse( - new TextDecoder().decode(decodeBase64Url(encodedOauthReqInfo!)), - ); - expect(decodedOauthReqInfo).toEqual(oauthReqInfo); - }); - - it("should handle complex oauthReqInfo objects in redirect URL construction", async () => { - // Test with complex oauthReqInfo object to verify encoding/decoding - const complexOauthReqInfo = { - clientId: "test-client", - scope: "read write admin", - redirectUri: "https://example.com/callback", - responseType: "code", - state: "random-state-string", - nonce: "random-nonce-string", - }; - - // Test encoding/decoding preserves complex objects - const encodedState = btoa( - JSON.stringify({ oauthReqInfo: complexOauthReqInfo }), - ); - const decodedState = JSON.parse(atob(encodedState)); - expect(decodedState.oauthReqInfo).toEqual(complexOauthReqInfo); - - // Test URL construction with complex object - const instanceUrl = "https://test.thoughtspot.cloud"; - const redirectUrl = new URL("callosum/v1/saml/login", instanceUrl); - const targetURLPath = new URL("/callback", "https://example.com"); - targetURLPath.searchParams.append("instanceUrl", instanceUrl); - const encodedOauthReqInfo = encodeBase64Url( - new TextEncoder().encode(JSON.stringify(complexOauthReqInfo)).buffer, - ); - targetURLPath.searchParams.append("oauthReqInfo", encodedOauthReqInfo); - redirectUrl.searchParams.append("targetURLPath", targetURLPath.href); - - // Verify the complex object is preserved through the URL construction - const targetURLPathParam = redirectUrl.searchParams.get("targetURLPath"); - const targetURL = new URL(targetURLPathParam!); - const encodedParam = targetURL.searchParams.get("oauthReqInfo"); - const decodedOauthReqInfo = JSON.parse( - new TextDecoder().decode(decodeBase64Url(encodedParam!)), - ); - expect(decodedOauthReqInfo).toEqual(complexOauthReqInfo); - }); - - it("should handle different instance URL formats in redirect construction", async () => { - // Test different instance URL formats - const testCases = [ - "https://test.thoughtspot.cloud", - "https://mycompany.thoughtspot.cloud", - "https://thoughtspot.company.com", - ]; - - const oauthReqInfo = { - clientId: "test-client", - scope: "read", - }; - - for (const instanceUrl of testCases) { - const redirectUrl = new URL("callosum/v1/saml/login", instanceUrl); - const targetURLPath = new URL("/callback", "https://example.com"); - targetURLPath.searchParams.append("instanceUrl", instanceUrl); - const encodedState = encodeBase64Url( - new TextEncoder().encode(JSON.stringify(oauthReqInfo)).buffer, - ); - targetURLPath.searchParams.append("oauthReqInfo", encodedState); - redirectUrl.searchParams.append("targetURLPath", targetURLPath.href); - - // Verify each instance URL is properly handled - expect(redirectUrl.origin).toBe(instanceUrl); - expect(redirectUrl.pathname).toBe("/callosum/v1/saml/login"); - - const targetURLPathParam = - redirectUrl.searchParams.get("targetURLPath"); - const targetURL = new URL(targetURLPathParam!); - expect(targetURL.searchParams.get("instanceUrl")).toBe(instanceUrl); - - const encodedOauthReqInfo = targetURL.searchParams.get("oauthReqInfo"); - const decodedOauthReqInfo = JSON.parse( - new TextDecoder().decode(decodeBase64Url(encodedOauthReqInfo!)), - ); - expect(decodedOauthReqInfo).toEqual(oauthReqInfo); - } - }); - }); -}); diff --git a/test/index.header-stripping.spec.ts b/test/index.header-stripping.spec.ts index 0c243f7..e146799 100644 --- a/test/index.header-stripping.spec.ts +++ b/test/index.header-stripping.spec.ts @@ -1,17 +1,22 @@ import { createExecutionContext, env } from "cloudflare:test"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -// Intercept at the OAuthProvider level — this is called after the outer worker -// strips headers, so we can assert on what it actually receives. +// Intercept at the pkg's createOAuthHandler — this is called after the outer +// worker strips headers, so we can assert on what it actually receives. const mockOAuthFetch = vi.fn(); -vi.mock("@cloudflare/workers-oauth-provider", () => ({ - default: class MockOAuthProvider { - fetch(request: Request, env: any, ctx: any) { - return mockOAuthFetch(request, env, ctx); - } - }, -})); +vi.mock("@mouryabalabhadra/ts-cloudflare-auth", async () => { + const actual = await vi.importActual< + typeof import("@mouryabalabhadra/ts-cloudflare-auth") + >("@mouryabalabhadra/ts-cloudflare-auth"); + return { + ...actual, + createOAuthHandler: () => ({ + fetch: (request: Request, env: any, ctx: any) => + mockOAuthFetch(request, env, ctx), + }), + }; +}); describe("Header stripping", () => { beforeEach(() => { diff --git a/test/oauth-manager/oauth-utils.spec.ts b/test/oauth-manager/oauth-utils.spec.ts deleted file mode 100644 index e4931dc..0000000 --- a/test/oauth-manager/oauth-utils.spec.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { decodeBase64Url } from "hono/utils/encode"; -import { describe, expect, it, vi } from "vitest"; -import { - type ApprovalDialogOptions, - buildSamlRedirectUrl, - parseRedirectApproval, - renderApprovalDialog, - validateAndSanitizeUrl, -} from "../../src/oauth-manager/oauth-utils"; - -describe("OAuth Utils", () => { - describe("renderApprovalDialog", () => { - it("should render approval dialog with basic options", () => { - const request = new Request("https://example.com/authorize"); - const options: ApprovalDialogOptions = { - client: { - clientId: "test-client", - clientName: "Test Client", - registrationDate: Date.now(), - redirectUris: ["https://example.com/callback"], - tokenEndpointAuthMethod: "client_secret_basic", - }, - server: { - name: "Test Server", - description: "Test Description", - }, - state: { test: "data" }, - }; - - const response = renderApprovalDialog(request, options); - - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toContain("text/html"); - - return response.text().then((html) => { - expect(html).toContain("Test Server"); - expect(html).toContain("ThoughtSpot Spotter wants access"); - expect(html).toContain("Authorization Request"); - }); - }); - - it("should render approval dialog with custom server logo", () => { - const request = new Request("https://example.com/authorize"); - const options: ApprovalDialogOptions = { - client: { - clientId: "test-client", - clientName: "Test Client", - registrationDate: Date.now(), - redirectUris: ["https://example.com/callback"], - tokenEndpointAuthMethod: "client_secret_basic", - }, - server: { - name: "Test Server", - logo: "https://example.com/logo.png", - }, - state: { test: "data" }, - }; - - const response = renderApprovalDialog(request, options); - - return response.text().then((html) => { - // The actual implementation uses hardcoded logos, not the server.logo - expect(html).toContain( - "https://avatars.githubusercontent.com/u/8906680?s=200&v=4", - ); - }); - }); - - it("should handle null client gracefully", () => { - const request = new Request("https://example.com/authorize"); - const options: ApprovalDialogOptions = { - client: null, - server: { - name: "Test Server", - }, - state: { test: "data" }, - }; - - const response = renderApprovalDialog(request, options); - - expect(response.status).toBe(200); - return response.text().then((html) => { - expect(html).toContain("ThoughtSpot Spotter wants access"); - }); - }); - - it("should sanitize HTML in server name", () => { - const request = new Request("https://example.com/authorize"); - const options: ApprovalDialogOptions = { - client: { - clientId: "test-client", - clientName: "Test Client", - registrationDate: Date.now(), - redirectUris: ["https://example.com/callback"], - tokenEndpointAuthMethod: "client_secret_basic", - }, - server: { - name: "Test Server", - }, - state: { test: "data" }, - }; - - const response = renderApprovalDialog(request, options); - - return response.text().then((html) => { - expect(html).toContain("Test Server"); - expect(html).toContain( - "<script>alert('xss')</script>Test Server", - ); - expect(html).not.toContain(""); - }); - }); - }); - - describe("validateAndSanitizeUrl", () => { - it("should validate and return valid URLs", () => { - const validUrls = [ - "https://example.com", - "https://test.thoughtspot.cloud", - "https://subdomain.example.com", - "https://example.com:8080", - ]; - - for (const url of validUrls) { - expect(() => validateAndSanitizeUrl(url)).not.toThrow(); - expect(validateAndSanitizeUrl(url)).toBe(url); - } - }); - - it("should add https:// to URLs without protocol", () => { - expect(validateAndSanitizeUrl("example.com")).toBe("https://example.com"); - expect(validateAndSanitizeUrl("test.thoughtspot.cloud")).toBe( - "https://test.thoughtspot.cloud", - ); - }); - - it("should normalize URLs by removing paths and query params", () => { - expect(validateAndSanitizeUrl("https://example.com/path")).toBe( - "https://example.com", - ); - expect( - validateAndSanitizeUrl("https://example.com:8080/path?param=value"), - ).toBe("https://example.com:8080"); - }); - - it("should throw error for invalid URLs", () => { - const invalidUrls = [ - "http://", // Missing hostname - "https://", // Missing hostname - "://example.com", // Missing protocol - ]; - - for (const url of invalidUrls) { - expect(() => validateAndSanitizeUrl(url)).toThrow(); - } - }); - - it("should throw error for empty URL", () => { - expect(() => validateAndSanitizeUrl("")).toThrow(); - }); - - it("should throw error for http URLs", () => { - expect(() => validateAndSanitizeUrl("http://example.com")).toThrow( - "Only HTTPS URLs are allowed", - ); - expect(() => - validateAndSanitizeUrl("http://test.thoughtspot.cloud"), - ).toThrow("Only HTTPS URLs are allowed"); - }); - }); - - describe("parseRedirectApproval", () => { - it("should parse valid form data", async () => { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: { clientId: "test" } })), - ); - formData.append("instanceUrl", "https://test.thoughtspot.cloud"); - - const request = new Request("https://example.com/authorize", { - method: "POST", - body: formData, - }); - - const result = await parseRedirectApproval(request); - - expect(result.state).toEqual({ oauthReqInfo: { clientId: "test" } }); - expect(result.instanceUrl).toBe("https://test.thoughtspot.cloud"); - }); - - it("should throw error for missing state", async () => { - const formData = new FormData(); - formData.append("instanceUrl", "https://test.thoughtspot.cloud"); - - const request = new Request("https://example.com/authorize", { - method: "POST", - body: formData, - }); - - await expect(parseRedirectApproval(request)).rejects.toThrow( - "Missing or invalid 'state' in form data", - ); - }); - - it("should throw error for missing instance URL", async () => { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: { clientId: "test" } })), - ); - - const request = new Request("https://example.com/authorize", { - method: "POST", - body: formData, - }); - - await expect(parseRedirectApproval(request)).rejects.toThrow( - "Missing instance URL", - ); - }); - - it("should throw error for invalid JSON in state", async () => { - const formData = new FormData(); - formData.append("state", "invalid-base64"); - formData.append("instanceUrl", "https://test.thoughtspot.cloud"); - - const request = new Request("https://example.com/authorize", { - method: "POST", - body: formData, - }); - - await expect(parseRedirectApproval(request)).rejects.toThrow( - "Invalid state format", - ); - }); - - it("should throw error for invalid instance URL", async () => { - const formData = new FormData(); - formData.append( - "state", - btoa(JSON.stringify({ oauthReqInfo: { clientId: "test" } })), - ); - formData.append("instanceUrl", "http://"); - - const request = new Request("https://example.com/authorize", { - method: "POST", - body: formData, - }); - - await expect(parseRedirectApproval(request)).rejects.toThrow( - "Invalid URL", - ); - }); - - it("should handle complex state objects", async () => { - const complexState = { - oauthReqInfo: { - clientId: "test-client", - scope: "read write", - redirectUri: "https://example.com/callback", - state: "random-state", - }, - additionalData: { - timestamp: Date.now(), - metadata: { source: "test" }, - }, - }; - - const formData = new FormData(); - formData.append("state", btoa(JSON.stringify(complexState))); - formData.append("instanceUrl", "https://test.thoughtspot.cloud"); - - const request = new Request("https://example.com/authorize", { - method: "POST", - body: formData, - }); - - const result = await parseRedirectApproval(request); - - expect(result.state).toEqual(complexState); - expect(result.instanceUrl).toBe("https://test.thoughtspot.cloud"); - }); - - it("should throw error for non-POST requests", async () => { - const request = new Request("https://example.com/authorize", { - method: "GET", - }); - - await expect(parseRedirectApproval(request)).rejects.toThrow( - "Invalid request method. Expected POST", - ); - }); - }); - - describe("buildSamlRedirectUrl", () => { - it("should construct a valid SAML login redirect URL", () => { - const instanceUrl = "https://test.thoughtspot.cloud"; - const oauthReqInfo = { - clientId: "test-client", - scope: "read", - redirectUri: "https://example.com/callback", - }; - const callbackOrigin = "https://example.com"; - - const redirectUrl = buildSamlRedirectUrl( - instanceUrl, - oauthReqInfo, - callbackOrigin, - ); - const url = new URL(redirectUrl); - expect(url.origin).toBe(instanceUrl); - expect(url.pathname).toBe("/callosum/v1/saml/login"); - const targetURLPath = url.searchParams.get("targetURLPath"); - expect(targetURLPath).toBeTruthy(); - const targetURL = new URL(targetURLPath!); - expect(targetURL.origin).toBe(callbackOrigin); - expect(targetURL.pathname).toBe("/callback"); - expect(targetURL.searchParams.get("instanceUrl")).toBe(instanceUrl); - const encodedOauthReqInfo = targetURL.searchParams.get("oauthReqInfo"); - expect(encodedOauthReqInfo).toBeTruthy(); - const decodedOauthReqInfo = JSON.parse( - new TextDecoder().decode(decodeBase64Url(encodedOauthReqInfo!)), - ); - expect(decodedOauthReqInfo).toEqual(oauthReqInfo); - }); - - it("should handle complex oauthReqInfo objects", () => { - const instanceUrl = "https://mycompany.thoughtspot.cloud"; - const oauthReqInfo = { - clientId: "test-client", - scope: "read write", - redirectUri: "https://example.com/callback", - responseType: "code", - state: "random-state", - nonce: "random-nonce", - }; - const callbackOrigin = "https://example.com"; - const redirectUrl = buildSamlRedirectUrl( - instanceUrl, - oauthReqInfo, - callbackOrigin, - ); - const url = new URL(redirectUrl); - const targetURLPath = url.searchParams.get("targetURLPath"); - const targetURL = new URL(targetURLPath!); - const encodedOauthReqInfo = targetURL.searchParams.get("oauthReqInfo"); - const decodedOauthReqInfo = JSON.parse( - new TextDecoder().decode(decodeBase64Url(encodedOauthReqInfo!)), - ); - expect(decodedOauthReqInfo).toEqual(oauthReqInfo); - }); - - it("should work with different callback origins", () => { - const instanceUrl = "https://thoughtspot.company.com"; - const oauthReqInfo = { clientId: "abc", scope: "openid" }; - const callbackOrigin = "https://another.com"; - const redirectUrl = buildSamlRedirectUrl( - instanceUrl, - oauthReqInfo, - callbackOrigin, - ); - const url = new URL(redirectUrl); - const targetURLPath = url.searchParams.get("targetURLPath"); - const targetURL = new URL(targetURLPath!); - expect(targetURL.origin).toBe(callbackOrigin); - }); - - it("should encode oauthReqInfo as base64url", () => { - const instanceUrl = "https://test.thoughtspot.cloud"; - const oauthReqInfo = { foo: "bar", n: 123 }; - const callbackOrigin = "https://example.com"; - const redirectUrl = buildSamlRedirectUrl( - instanceUrl, - oauthReqInfo, - callbackOrigin, - ); - const url = new URL(redirectUrl); - const targetURLPath = url.searchParams.get("targetURLPath"); - const targetURL = new URL(targetURLPath!); - const encoded = targetURL.searchParams.get("oauthReqInfo"); - expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/); // base64url format - }); - }); -}); diff --git a/test/oauth-manager/token-utils.integration.spec.ts b/test/oauth-manager/token-utils.integration.spec.ts deleted file mode 100644 index 3c72dc8..0000000 --- a/test/oauth-manager/token-utils.integration.spec.ts +++ /dev/null @@ -1,654 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderTokenCallback } from "../../src/oauth-manager/token-utils"; - -// Import the actual static file content directly -// This is the actual content from static/oauth-callback.html -const actualHtml = ` - - - ThoughtSpot Authorization - - - -
- -

Authorization in Progress

-
-

Establishing secure connection...

- -
- - - - - -`; - -// This is the actual content from static/oauth-callback.css -const actualCss = `body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; - display: flex; - justify-content: center; - align-items: center; - height: 100vh; - margin: 0; - background-color: #f8f9fa; - color: #2c3e50; -} -.container { - text-align: center; - padding: 3rem; - background: white; - border-radius: 12px; - box-shadow: 0 4px 6px rgba(0,0,0,0.1); - max-width: 480px; - width: 90%; -} -.logo { - width: 48px; - height: 48px; - margin-bottom: 1.5rem; -} -h2 { - font-size: 1.5rem; - font-weight: 600; - margin: 0 0 1rem 0; - color: #1a1a1a; -} -.spinner { - border: 3px solid #e9ecef; - border-top: 3px solid #0066cc; - border-radius: 50%; - width: 36px; - height: 36px; - animation: spin 1s linear infinite; - margin: 1.5rem auto; -} -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} -#status { - font-size: 0.95rem; - color: #495057; - margin: 1rem 0; - line-height: 1.5; -} -.footer { - margin-top: 2rem; - font-size: 0.85rem; - color: #6c757d; -} - -.warning-banner { - display: flex; - align-items: center; - justify-content: space-between; - background: #FFF8E1; - border: 1px solid #FFE082; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(16, 30, 54, 0.04); - padding: 12px 16px; - margin-bottom: 1rem; -} - -.warning-icon { - flex-shrink: 0; - margin-right: 10px; - align-self: flex-start; - margin-top: 2px; -} - -.warning-text { - flex: 1; - margin: 0; - font-size: 14px; - line-height: 1.4; - color: #333; - text-align: left; - max-width: 100%; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -} - -.warning-close { - background: none; - border: none; - font-size: 16px; - line-height: 1; - color: #999; - opacity: 0.7; - cursor: pointer; - padding: 0; - align-self: flex-start; - margin-top: 2px; - margin-left: 5px; -} -.warning-close:hover { - opacity: 1; -}`; - -// This is the actual content from static/oauth-callback.js -const actualJs = `// Immediately invoke the async function -(async function() { - const oauthReqInfo = JSON.parse(document.getElementById('oauth-req-info').textContent); - - // Ensure manual section is hidden initially - const manualSection = document.getElementById('manual-token-section'); - const container = document.querySelector('.container'); - manualSection.style.display = 'none'; - - try { - const tokenUrl = new URL('callosum/v1/v2/auth/token/fetch?validity_time_in_sec=2592000', window.INSTANCE_URL); - console.log('Fetching token from:', tokenUrl.toString()); - - document.getElementById('status').textContent = 'Retrieving authentication token...'; - - const response = await fetch(tokenUrl.toString(), { - method: 'GET', - credentials: 'include' - }); - - if (!response.ok) { - if (response.status === 401) { - // 401 likely due to 3rd party cookies being blocked - manualSection.style.display = 'flex'; - document.getElementById('status').textContent = ''; - container.style.display = 'none'; - - // Set up event handlers after showing the section - document.getElementById('manual-token-url-link').onclick = function(e) { - e.preventDefault(); - window.open(tokenUrl.toString(), '_blank'); - }; - document.getElementById('manual-back-btn').onclick = function() { - window.history.back(); - }; - document.querySelector('.warning-close').onclick = function() { - document.querySelector('.warning-banner').style.display = 'none'; - }; - document.getElementById('submit-manual-token').onclick = async function() { - const tokenText = document.getElementById('manual-token-input').value; - let tokenData; - try { - // If the text starts with "data", wrap it in curly braces to make it valid JSON - const jsonText = tokenText.trim().startsWith('"data"') ? '{' + tokenText + '}' : tokenText; - const parsed = JSON.parse(jsonText); - - // Handle different token formats - if (typeof parsed === 'string') { - // Case 1: tokenText is a quoted string - tokenData = { data: { token: parsed } }; - } else if (parsed.data && parsed.data.token) { - // Case 2: { data: { token: ... } } - tokenData = { data: { token: parsed.data.token } }; - } else if (parsed.token) { - // Case 3: { token: ... } - tokenData = { data: { token: parsed.token } }; - } else { - throw new Error('Unrecognized token format.'); - } - } catch (e) { - // If JSON parsing fails, try to extract token from the string - const tokenMatch = tokenText.match(/"token"\\s*:\\s*"([^"]+)"/); - if (tokenMatch) { - console.log('Token match:', tokenMatch[1]); - tokenData = { data: { token: tokenMatch[1] } }; - } else if (typeof tokenText === 'string' && tokenText.trim().length > 0) { - // Case 4: raw token string - console.log('Token text:', tokenText); - tokenData = { data: { token: tokenText.trim() } }; - } else { - document.getElementById('status').textContent = 'Invalid token format. Please paste the correct token.'; - document.getElementById('status').style.color = '#dc3545'; - return; - } - } - document.getElementById('status').textContent = 'Submitting token...'; - document.getElementById('status').style.color = '#495057'; - try { - const storeResponse = await fetch('/store-token', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - token: tokenData, - oauthReqInfo: oauthReqInfo, - instanceUrl: window.INSTANCE_URL - }) - }); - const responseData = await storeResponse.json(); - if (!storeResponse.ok) { - const errorText = await storeResponse.text(); - throw new Error('Failed to store token (Status: ' + storeResponse.status + '): ' + errorText); - } - window.location.href = responseData.redirectTo; - } catch (err) { - document.getElementById('status').textContent = err.message; - document.getElementById('status').style.color = '#dc3545'; - } - }; - return; - } else { - const errorText = await response.text(); - throw new Error('Authentication failed (Status: ' + response.status + '): ' + errorText); - } - } - - const data = await response.json(); - document.getElementById('status').textContent = 'Authentication successful. Securing your session...'; - - // Send the token to the server - const storeResponse = await fetch('/store-token', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - token: data, - oauthReqInfo: oauthReqInfo, - instanceUrl: window.INSTANCE_URL - }) - }); - const responseData = await storeResponse.json(); - - if (!storeResponse.ok) { - const errorText = await storeResponse.text(); - throw new Error('Failed to store token (Status: ' + storeResponse.status + '): ' + errorText); - } - - console.log('Redirecting to:', responseData.redirectTo); - window.location.href = responseData.redirectTo; - - } catch (error) { - console.error('Error:', error); - document.getElementById('status').textContent = error.message; - document.querySelector('h2').textContent = 'Authorization Failed'; - document.querySelector('.spinner').style.display = 'none'; - } -})();`; - -describe("Token Utils Integration Tests", () => { - const mockInstanceUrl = "https://test-instance.thoughtspot.com"; - const mockOrigin = "https://example.com"; - const mockOAuthReqInfo = { - clientId: "test-client-id", - redirectUri: "https://example.com/callback", - state: "test-state", - }; - - let mockAssets: any; - - beforeEach(() => { - // Reset mocks before each test - vi.clearAllMocks(); - - // Create a fresh mock assets object for each test - mockAssets = { - fetch: vi.fn(), - }; - }); - - describe("HTML Rendering", () => { - it("should render complete HTML with inlined CSS and JS", async () => { - // Mock successful asset fetches with actual static files - mockAssets.fetch - .mockResolvedValueOnce(new Response(actualHtml)) - .mockResolvedValueOnce(new Response(actualCss)) - .mockResolvedValueOnce(new Response(actualJs)); - - const result = await renderTokenCallback( - mockInstanceUrl, - JSON.stringify(mockOAuthReqInfo), - mockAssets, - mockOrigin, - ); - - // Verify all assets were fetched - expect(mockAssets.fetch).toHaveBeenCalledWith( - `${mockOrigin}/oauth-callback.html`, - ); - expect(mockAssets.fetch).toHaveBeenCalledWith( - `${mockOrigin}/oauth-callback.css`, - ); - expect(mockAssets.fetch).toHaveBeenCalledWith( - `${mockOrigin}/oauth-callback.js`, - ); - - // Verify HTML structure from actual file - expect(result).toContain(""); - expect(result).toContain("ThoughtSpot Authorization"); - expect(result).toContain("

Authorization in Progress

"); - expect(result).toContain("ThoughtSpot MCP Server"); - - // Verify OAuth request info is properly embedded - expect(result).toContain(JSON.stringify(mockOAuthReqInfo)); - - // Verify CSS is inlined (check for actual CSS content) - expect(result).toContain("