UNPKG

@civic/auth-mcp

Version:

Civic Auth integration for MCP servers

1 lines 79.9 kB
{"version":3,"sources":["../src/index.ts","../src/constants.ts","../src/legacy/LegacyOAuthRouter.ts","../src/resolveUrl.ts","../src/legacy/constants.ts","../src/legacy/OAuthProxyHandler.ts","../src/legacy/StateStore.ts","../src/McpServerAuth.ts","../src/types.ts","../src/client/CLIClient.ts","../src/client/providers/persistence/InMemoryTokenPersistence.ts","../src/client/providers/CivicAuthProvider.ts","../src/client/providers/CLIAuthProvider.ts","../src/client/providers/TokenAuthProvider.ts","../src/client/transport/RestartableStreamableHTTPClientTransport.ts"],"sourcesContent":["import type { Request, RequestHandler } from \"express\";\nimport { Router } from \"express\";\nimport { DEFAULT_MCP_ROUTE } from \"./constants\";\nimport { LegacyOAuthRouter } from \"./legacy/LegacyOAuthRouter.js\";\nimport { McpServerAuth } from \"./McpServerAuth.js\";\nimport { resolveBaseUrl } from \"./resolveUrl.js\";\nimport type { CivicAuthOptions, ExtendedAuthInfo, OIDCWellKnownConfiguration } from \"./types.js\";\nimport { AuthenticationError } from \"./types.js\";\n\nexport * from \"./client/index.js\";\nexport * from \"./constants.js\";\nexport { InMemoryStateStore } from \"./legacy/StateStore.js\";\nexport type { OAuthState, StateStore } from \"./legacy/types.js\";\nexport { McpServerAuth } from \"./McpServerAuth.js\";\nexport type { UrlResolutionOptions } from \"./resolveUrl.js\";\nexport { resolveBaseUrl } from \"./resolveUrl.js\";\nexport * from \"./types.js\";\n\n/**\n * Express middleware that configures an MCP server to use Civic Auth\n * as its authorization server.\n *\n * This middleware:\n * 1. Exposes /.well-known/oauth-protected-resource metadata\n * 2. Validates bearer tokens using Civic's JWKS\n * 3. Attaches user info to the request\n * 4. (Legacy) Optionally exposes OAuth server endpoints for backward compatibility\n *\n * @param options Configuration options\n * @returns Express middleware\n */\nexport async function auth<TAuthInfo extends ExtendedAuthInfo>(\n options: CivicAuthOptions<TAuthInfo, Request> = {}\n): Promise<RequestHandler> {\n console.log(`Civic Auth MCP middleware initialized with options: ${JSON.stringify(options)}`);\n\n // Default to enabling legacy OAuth for backward compatibility\n const enableLegacyOAuth = options.enableLegacyOAuth ?? true;\n\n // Initialize the core auth functionality\n const mcpServerAuth = await McpServerAuth.init<TAuthInfo, Request>(options);\n\n const mcpRoute = options.mcpRoute ?? DEFAULT_MCP_ROUTE;\n\n // Get OIDC config for legacy mode\n // @ts-expect-error - Accessing protected property for legacy compatibility\n const oidcConfig = mcpServerAuth.oidcConfig as OIDCWellKnownConfiguration;\n\n // Create router\n const router = Router();\n\n const wellKnownPath = \"/.well-known/oauth-protected-resource\";\n\n // Expose OAuth Protected Resource Metadata\n // This tells MCP clients where to authenticate\n router.use(wellKnownPath, (req, res) => {\n // Derive resource URL from the request: strip the well-known suffix to get\n // the mount path, then append mcpRoute.\n // e.g. originalUrl \"/hub/.well-known/...\" → mount \"/hub\" → resource \"/hub/mcp\"\n const mountPath = req.originalUrl.slice(0, req.originalUrl.indexOf(wellKnownPath));\n const resourceUrl = `${resolveBaseUrl(req, options)}${mountPath}${mcpRoute}`;\n const metadata = mcpServerAuth.getProtectedResourceMetadata(resourceUrl);\n res.json(metadata);\n });\n\n // Legacy OAuth endpoints\n if (enableLegacyOAuth) {\n const legacyOAuthRouter = new LegacyOAuthRouter(options, oidcConfig);\n router.use(legacyOAuthRouter.createRouter());\n }\n\n // Token validation middleware - only apply to mcpRoute\n const tokenValidationMiddleware: RequestHandler = async (req, res, next) => {\n // Skip auth for metadata endpoints\n if (req.path === \"/.well-known/oauth-protected-resource\") {\n return next();\n }\n\n // Skip auth for legacy OAuth endpoints\n if (enableLegacyOAuth && LegacyOAuthRouter.getOAuthPaths().includes(req.path)) {\n return next();\n }\n\n // Only protect routes that start with mcpRoute\n if (!req.path.startsWith(mcpRoute)) {\n return next();\n }\n\n // Handle request authentication\n try {\n const authInfo = await mcpServerAuth.handleRequest(req);\n\n // Attach to request for downstream use\n // Express allows extending the Request interface through declaration merging\n // @ts-expect-error - Adding auth property to request\n req.auth = authInfo;\n\n next();\n } catch (error) {\n if (error instanceof AuthenticationError) {\n // authentication errors e.g. jwt verification errors (expired, invalid signature, etc.) should return 401\n // Per RFC9728 Section 3, the well-known URI is constructed by inserting\n // /.well-known/oauth-protected-resource between host and resource path\n const baseUrl = resolveBaseUrl(req, options);\n const resourcePath = `${req.baseUrl}${mcpRoute}`;\n const metadataUrl = `${baseUrl}/.well-known/oauth-protected-resource${resourcePath}`;\n\n res.setHeader(\"WWW-Authenticate\", `Bearer resource_metadata=\"${metadataUrl}\"`);\n res.status(401).json({\n error: \"authentication_error\",\n error_description: error.message,\n });\n return;\n }\n\n // Unknown error\n res.status(500).json({\n error: \"internal_error\",\n error_description: \"An unexpected error occurred\",\n });\n return;\n }\n };\n\n router.use(tokenValidationMiddleware);\n\n return router;\n}\n","export const DEFAULT_WELLKNOWN_URL = \"https://auth.civic.com/oauth/.well-known/openid-configuration\";\n\n/**\n * Default scope for OAuth authentication\n */\nexport const DEFAULT_SCOPES = [\"openid\", \"profile\", \"email\", \"offline_access\"];\n\n/**\n * Default callback port for CLI authentication flow\n */\nexport const DEFAULT_CALLBACK_PORT = 8080;\n\n// Default mcpRoute to '/mcp' if not specified\nexport const DEFAULT_MCP_ROUTE = \"/mcp\";\n\n// This client ID is used when a client is not provided.\n// It is registered on Civic Auth as a rate-limited public \"sandbox\" account.\n// Note, this option is used only if the auth server is Civic\nexport const PUBLIC_CIVIC_CLIENT_ID = \"12220cf4-1a9a-4964-8eb7-7c6d7d049f34\";\n","import type { Request, Response } from \"express\";\nimport { Router } from \"express\";\nimport { resolveBaseUrl } from \"../resolveUrl.js\";\nimport type { CivicAuthOptions, ExtendedAuthInfo, OIDCWellKnownConfiguration } from \"../types.js\";\nimport {\n LEGACY_GRANT_TYPES,\n LEGACY_OAUTH_PATHS,\n LEGACY_RESPONSE_TYPES,\n LEGACY_TOKEN_AUTH_METHODS,\n} from \"./constants.js\";\nimport { OAuthProxyHandler } from \"./OAuthProxyHandler.js\";\n\n/**\n * Creates a router with legacy OAuth endpoints for backward compatibility\n */\nexport class LegacyOAuthRouter<TAuthInfo extends ExtendedAuthInfo> {\n private oauthHandler: OAuthProxyHandler<TAuthInfo, Request>;\n private oidcConfig: OIDCWellKnownConfiguration;\n private options: CivicAuthOptions<TAuthInfo, Request>;\n\n constructor(options: CivicAuthOptions<TAuthInfo, Request>, oidcConfig: OIDCWellKnownConfiguration) {\n this.options = options;\n this.oidcConfig = oidcConfig;\n this.oauthHandler = new OAuthProxyHandler(options, oidcConfig);\n }\n\n /**\n * Create and configure the legacy OAuth router\n */\n createRouter(): Router {\n const router = Router();\n\n // OAuth Authorization Server Metadata (legacy)\n router.get(LEGACY_OAUTH_PATHS.WELL_KNOWN, (req: Request, res: Response) => {\n const baseUrl = resolveBaseUrl(req, this.options);\n const metadata = {\n issuer: baseUrl,\n authorization_endpoint: `${baseUrl}${LEGACY_OAUTH_PATHS.AUTHORIZE}`,\n token_endpoint: `${baseUrl}${LEGACY_OAUTH_PATHS.TOKEN}`,\n registration_endpoint: this.oidcConfig.registration_endpoint\n ? `${baseUrl}${LEGACY_OAUTH_PATHS.REGISTER}`\n : undefined,\n scopes_supported: this.options.scopesSupported || this.oidcConfig.scopes_supported || [],\n response_types_supported: LEGACY_RESPONSE_TYPES,\n grant_types_supported: LEGACY_GRANT_TYPES,\n token_endpoint_auth_methods_supported: LEGACY_TOKEN_AUTH_METHODS,\n code_challenge_methods_supported: [\"S256\", \"plain\"],\n };\n res.json(metadata);\n });\n\n // Authorization endpoint\n router.get(LEGACY_OAUTH_PATHS.AUTHORIZE, async (req: Request, res: Response) => {\n await this.oauthHandler.handleAuthorize(req, res);\n });\n\n // OAuth callback\n router.get(\"/oauth/callback\", async (req: Request, res: Response) => {\n await this.oauthHandler.handleCallback(req, res);\n });\n\n // Token endpoint\n router.post(LEGACY_OAUTH_PATHS.TOKEN, async (req: Request, res: Response) => {\n await this.oauthHandler.handleToken(req, res);\n });\n\n // Registration endpoint\n if (this.oidcConfig.registration_endpoint) {\n router.post(LEGACY_OAUTH_PATHS.REGISTER, async (req: Request, res: Response) => {\n await this.oauthHandler.handleRegistration(req, res);\n });\n }\n\n return router;\n }\n\n /**\n * Get the list of legacy OAuth paths for authentication bypass\n */\n static getOAuthPaths(): string[] {\n return [\n LEGACY_OAUTH_PATHS.WELL_KNOWN,\n LEGACY_OAUTH_PATHS.AUTHORIZE,\n LEGACY_OAUTH_PATHS.TOKEN,\n LEGACY_OAUTH_PATHS.REGISTER,\n \"/oauth/callback\",\n ];\n }\n}\n","import type { IncomingMessage } from \"node:http\";\n\nexport interface UrlResolutionOptions {\n /** Header to read the protocol from. Default: none (uses forceHttps or req.protocol) */\n protocolHeader?: string;\n /** Header to read the host from. Default: \"host\" (standard Host header) */\n hostHeader?: string;\n /** Force HTTPS regardless of headers. Default: false */\n forceHttps?: boolean;\n}\n\n/** Resolves protocol and host from request, respecting configured headers */\nexport function resolveBaseUrl(req: IncomingMessage, options: UrlResolutionOptions = {}): string {\n let protocol: string;\n if (options.forceHttps) {\n protocol = \"https\";\n } else if (options.protocolHeader) {\n const headerValue = req.headers?.[options.protocolHeader.toLowerCase()];\n protocol =\n (typeof headerValue === \"string\" ? headerValue : undefined) ??\n (\"protocol\" in req ? (req as unknown as { protocol: string }).protocol : \"http\");\n } else {\n protocol = \"protocol\" in req ? (req as unknown as { protocol: string }).protocol : \"http\";\n }\n\n let host: string | undefined;\n if (options.hostHeader) {\n const headerValue = req.headers?.[options.hostHeader.toLowerCase()];\n host = typeof headerValue === \"string\" ? headerValue : undefined;\n }\n if (!host) {\n host = req.headers?.host ?? \"localhost\";\n }\n\n return `${protocol}://${host}`;\n}\n","/**\n * Default paths for legacy OAuth endpoints\n */\nexport const LEGACY_OAUTH_PATHS = {\n WELL_KNOWN: \"/.well-known/oauth-authorization-server\",\n AUTHORIZE: \"/authorize\",\n TOKEN: \"/token\",\n REGISTER: \"/register\",\n} as const;\n\n/**\n * OAuth error codes\n */\nexport const OAUTH_ERRORS = {\n INVALID_REQUEST: \"invalid_request\",\n UNAUTHORIZED_CLIENT: \"unauthorized_client\",\n ACCESS_DENIED: \"access_denied\",\n UNSUPPORTED_RESPONSE_TYPE: \"unsupported_response_type\",\n INVALID_SCOPE: \"invalid_scope\",\n SERVER_ERROR: \"server_error\",\n TEMPORARILY_UNAVAILABLE: \"temporarily_unavailable\",\n INVALID_CLIENT: \"invalid_client\",\n INVALID_GRANT: \"invalid_grant\",\n UNSUPPORTED_GRANT_TYPE: \"unsupported_grant_type\",\n} as const;\n\n/**\n * State expiration time in milliseconds (10 minutes)\n */\nexport const STATE_EXPIRATION_MS = 10 * 60 * 1000;\n\n/**\n * Supported grant types for legacy mode\n */\nexport const LEGACY_GRANT_TYPES = [\"authorization_code\", \"refresh_token\"] as const;\n\n/**\n * Supported response types for legacy mode\n */\nexport const LEGACY_RESPONSE_TYPES = [\"code\"] as const;\n\n/**\n * Token endpoint auth methods supported\n */\nexport const LEGACY_TOKEN_AUTH_METHODS = [\"client_secret_post\", \"client_secret_basic\", \"none\"] as const;\n","import { randomBytes } from \"node:crypto\";\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { resolveBaseUrl } from \"../resolveUrl.js\";\nimport type { ExtendedAuthInfo, OIDCWellKnownConfiguration } from \"../types.js\";\nimport { OAUTH_ERRORS } from \"./constants.js\";\nimport { InMemoryStateStore } from \"./StateStore.js\";\nimport type {\n AuthorizationRequest,\n LegacyOAuthOptions,\n OAuthErrorResponse,\n OAuthState,\n StateStore,\n TokenRequest,\n} from \"./types.js\";\n\n// Handling clients that do not request scopes\nconst DEFAULT_SCOPES = \"openid email profile\";\n\n// Additional scopes that MCP clients may request (e.g. Gemini CLI).\n// These are preserved during DCR if the client explicitly requests them.\nconst ALLOWED_ADDITIONAL_SCOPES = [\"mcp:tools\"];\n\n/**\n * Handles OAuth endpoint proxying for legacy mode\n */\nexport class OAuthProxyHandler<TAuthInfo extends ExtendedAuthInfo, TRequest extends IncomingMessage = IncomingMessage> {\n private stateStore: StateStore;\n private options: LegacyOAuthOptions<TAuthInfo, TRequest>;\n private oidcConfig: OIDCWellKnownConfiguration;\n\n constructor(options: LegacyOAuthOptions<TAuthInfo, TRequest>, oidcConfig: OIDCWellKnownConfiguration) {\n this.options = options;\n this.oidcConfig = oidcConfig;\n this.stateStore = options.stateStore || new InMemoryStateStore();\n }\n\n /**\n * Handle authorization endpoint requests\n */\n async handleAuthorize(req: TRequest, res: ServerResponse): Promise<void> {\n try {\n if (!req.url) {\n throw new Error(\"Request URL is missing\");\n }\n const url = new URL(req.url, `http://${req.headers.host}`);\n const params = url.searchParams;\n\n // Extract authorization request parameters\n const authRequest: AuthorizationRequest = {\n response_type: params.get(\"response_type\") || \"\",\n client_id: params.get(\"client_id\") || \"\",\n redirect_uri: params.get(\"redirect_uri\") || \"\",\n state: params.get(\"state\") || undefined,\n scope: params.get(\"scope\") || DEFAULT_SCOPES, // Do not permit missing scopes.\n code_challenge: params.get(\"code_challenge\") || undefined,\n code_challenge_method: params.get(\"code_challenge_method\") || undefined,\n };\n\n // Validate required parameters\n if (!authRequest.response_type || !authRequest.client_id || !authRequest.redirect_uri) {\n return this.sendErrorRedirect(res, authRequest.redirect_uri, {\n error: OAUTH_ERRORS.INVALID_REQUEST,\n error_description: \"Missing required parameters\",\n state: authRequest.state,\n });\n }\n\n // Only support authorization code flow\n if (authRequest.response_type !== \"code\") {\n return this.sendErrorRedirect(res, authRequest.redirect_uri, {\n error: OAUTH_ERRORS.UNSUPPORTED_RESPONSE_TYPE,\n error_description: \"Only 'code' response type is supported\",\n state: authRequest.state,\n });\n }\n\n // Generate state for tracking this authorization\n const internalState = this.generateState();\n\n // Store the original request details\n const stateData: OAuthState = {\n redirectUri: authRequest.redirect_uri,\n clientState: authRequest.state,\n codeChallenge: authRequest.code_challenge,\n codeChallengeMethod: authRequest.code_challenge_method,\n createdAt: Date.now(),\n scope: authRequest.scope,\n clientId: authRequest.client_id,\n };\n\n await this.stateStore.set(internalState, stateData);\n\n // Build redirect to actual auth server\n const authUrl = new URL(this.oidcConfig.authorization_endpoint);\n authUrl.searchParams.set(\"response_type\", \"code\");\n authUrl.searchParams.set(\"client_id\", this.options.clientId || authRequest.client_id);\n authUrl.searchParams.set(\"redirect_uri\", this.getMcpCallbackUrl(req));\n authUrl.searchParams.set(\"state\", internalState);\n\n if (authRequest.scope) {\n authUrl.searchParams.set(\"scope\", authRequest.scope);\n }\n\n // Forward PKCE parameters if provided\n if (authRequest.code_challenge) {\n authUrl.searchParams.set(\"code_challenge\", authRequest.code_challenge);\n if (authRequest.code_challenge_method) {\n authUrl.searchParams.set(\"code_challenge_method\", authRequest.code_challenge_method);\n }\n }\n\n // Redirect to auth server\n res.writeHead(302, { Location: authUrl.toString() });\n res.end();\n } catch (error) {\n console.error(\"Error handling authorize request:\", error);\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: OAUTH_ERRORS.SERVER_ERROR }));\n }\n }\n\n /**\n * Handle OAuth callback from auth server\n */\n async handleCallback(req: TRequest, res: ServerResponse): Promise<void> {\n try {\n if (!req.url) {\n throw new Error(\"Request URL is missing\");\n }\n const url = new URL(req.url, `http://${req.headers.host}`);\n const params = url.searchParams;\n\n const code = params.get(\"code\");\n const state = params.get(\"state\");\n const error = params.get(\"error\");\n\n if (!state) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: OAUTH_ERRORS.INVALID_REQUEST }));\n return;\n }\n\n // Retrieve stored state\n const stateData = await this.stateStore.get(state);\n if (!stateData) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: OAUTH_ERRORS.INVALID_REQUEST, error_description: \"Invalid state\" }));\n return;\n }\n\n // Clean up state\n await this.stateStore.delete(state);\n\n // If there was an error from auth server, forward it\n if (error) {\n return this.sendErrorRedirect(res, stateData.redirectUri, {\n error: error,\n error_description: params.get(\"error_description\") || undefined,\n error_uri: params.get(\"error_uri\") || undefined,\n state: stateData.clientState,\n });\n }\n\n if (!code) {\n return this.sendErrorRedirect(res, stateData.redirectUri, {\n error: OAUTH_ERRORS.INVALID_REQUEST,\n error_description: \"Missing authorization code\",\n state: stateData.clientState,\n });\n }\n\n // Redirect back to original client with the code\n const redirectUrl = new URL(stateData.redirectUri);\n redirectUrl.searchParams.set(\"code\", code);\n if (stateData.clientState) {\n redirectUrl.searchParams.set(\"state\", stateData.clientState);\n }\n\n res.writeHead(302, { Location: redirectUrl.toString() });\n res.end();\n } catch (error) {\n console.error(\"Error handling callback:\", error);\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: OAUTH_ERRORS.SERVER_ERROR }));\n }\n }\n\n /**\n * Handle token endpoint requests\n */\n async handleToken(req: TRequest, res: ServerResponse): Promise<void> {\n try {\n let tokenRequest: TokenRequest;\n\n // Check if Express has already parsed the body\n if (\"body\" in req && req.body) {\n // Express has parsed the body (likely as JSON)\n tokenRequest = req.body as TokenRequest;\n } else {\n // Parse as form-encoded\n const body = await this.parseRequestBody(req);\n tokenRequest = {\n grant_type: body.get(\"grant_type\") || \"\",\n code: body.get(\"code\") || undefined,\n redirect_uri: body.get(\"redirect_uri\") || undefined,\n client_id: body.get(\"client_id\") || undefined,\n client_secret: body.get(\"client_secret\") || undefined,\n code_verifier: body.get(\"code_verifier\") || undefined,\n refresh_token: body.get(\"refresh_token\") || undefined,\n scope: body.get(\"scope\") || undefined,\n };\n }\n\n // Validate grant type\n if (!tokenRequest.grant_type) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: OAUTH_ERRORS.INVALID_REQUEST }));\n return;\n }\n\n // Forward the token request to the actual auth server\n const tokenResponse = await fetch(this.oidcConfig.token_endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n },\n body: new URLSearchParams({\n grant_type: tokenRequest.grant_type,\n ...(tokenRequest.code && { code: tokenRequest.code }),\n ...(tokenRequest.redirect_uri && { redirect_uri: this.getMcpCallbackUrl(req) }),\n ...(tokenRequest.client_id && { client_id: this.options.clientId || tokenRequest.client_id }),\n ...(tokenRequest.client_secret && { client_secret: tokenRequest.client_secret }),\n ...(tokenRequest.code_verifier && { code_verifier: tokenRequest.code_verifier }),\n ...(tokenRequest.refresh_token && { refresh_token: tokenRequest.refresh_token }),\n ...(tokenRequest.scope && { scope: tokenRequest.scope }),\n }).toString(),\n });\n\n const contentType = tokenResponse.headers.get(\"content-type\") || \"\";\n const responseBody = await tokenResponse.text();\n\n // Forward the response\n res.writeHead(tokenResponse.status, {\n \"Content-Type\": contentType,\n \"Cache-Control\": \"no-store\",\n Pragma: \"no-cache\",\n });\n res.end(responseBody);\n } catch (error) {\n console.error(\"Error handling token request:\", error);\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: OAUTH_ERRORS.SERVER_ERROR }));\n }\n }\n\n /**\n * Handle registration endpoint requests\n */\n async handleRegistration(req: TRequest, res: ServerResponse): Promise<void> {\n try {\n if (!this.oidcConfig.registration_endpoint) {\n res.writeHead(404, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: \"Registration not supported\" }));\n return;\n }\n\n let bodyObj: { scope?: string };\n\n // Check if Express has already parsed the body\n if (\"body\" in req && req.body) {\n // Express has already parsed the body\n bodyObj = req.body as { scope?: string };\n } else {\n // Need to read the raw body\n const contentType = req.headers[\"content-type\"] || \"\";\n\n if (contentType.includes(\"application/json\")) {\n // For JSON requests, read the raw body\n const rawBody = await this.readRawBody(req);\n bodyObj = JSON.parse(rawBody);\n } else {\n // For form-encoded, parse and reconstruct\n const parsed = await this.parseRequestBody(req);\n bodyObj = Object.fromEntries(parsed);\n }\n }\n\n // Build the registration scope: start with defaults, then preserve any\n // allowed additional scopes the client explicitly requested.\n const requestedScopes = (bodyObj.scope || \"\").split(/\\s+/).filter(Boolean);\n const additionalScopes = requestedScopes.filter((s) => ALLOWED_ADDITIONAL_SCOPES.includes(s));\n const finalScope = [DEFAULT_SCOPES, ...additionalScopes].join(\" \");\n console.log(`Replacing requested scopes \"${bodyObj.scope}\" with \"${finalScope}\"`);\n bodyObj.scope = finalScope;\n\n // Forward the registration request to the actual auth server\n const registrationResponse = await fetch(this.oidcConfig.registration_endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(bodyObj),\n });\n\n const responseContentType = registrationResponse.headers.get(\"content-type\") || \"\";\n const responseBody = await registrationResponse.text();\n\n // Forward the response\n res.writeHead(registrationResponse.status, {\n \"Content-Type\": responseContentType,\n });\n res.end(responseBody);\n } catch (error) {\n console.error(\"Error handling registration request:\", error);\n res.writeHead(500, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify({ error: OAUTH_ERRORS.SERVER_ERROR }));\n }\n }\n\n /**\n * Get the callback URL for the MCP server\n */\n private getMcpCallbackUrl(req: TRequest): string {\n const baseUrl = resolveBaseUrl(req, this.options);\n return `${baseUrl}/oauth/callback`;\n }\n\n /**\n * Generate a cryptographically secure state parameter\n */\n private generateState(): string {\n return randomBytes(32).toString(\"base64url\");\n }\n\n /**\n * Send an error redirect response\n */\n private sendErrorRedirect(res: ServerResponse, redirectUri: string, error: OAuthErrorResponse): void {\n if (!redirectUri) {\n res.writeHead(400, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify(error));\n return;\n }\n\n const url = new URL(redirectUri);\n url.searchParams.set(\"error\", error.error);\n if (error.error_description) {\n url.searchParams.set(\"error_description\", error.error_description);\n }\n if (error.error_uri) {\n url.searchParams.set(\"error_uri\", error.error_uri);\n }\n if (error.state) {\n url.searchParams.set(\"state\", error.state);\n }\n\n res.writeHead(302, { Location: url.toString() });\n res.end();\n }\n\n /**\n * Parse request body from incoming request\n */\n private async parseRequestBody(req: TRequest): Promise<URLSearchParams> {\n return new Promise((resolve, reject) => {\n let body = \"\";\n req.on(\"data\", (chunk) => {\n body += chunk.toString();\n });\n req.on(\"end\", () => {\n try {\n resolve(new URLSearchParams(body));\n } catch (error) {\n reject(error);\n }\n });\n req.on(\"error\", reject);\n });\n }\n\n /**\n * Read raw body from request\n */\n private async readRawBody(req: TRequest): Promise<string> {\n return new Promise((resolve, reject) => {\n let body = \"\";\n req.on(\"data\", (chunk) => {\n body += chunk.toString();\n });\n req.on(\"end\", () => {\n resolve(body);\n });\n req.on(\"error\", reject);\n });\n }\n}\n","import { STATE_EXPIRATION_MS } from \"./constants.js\";\nimport type { OAuthState, StateStore } from \"./types.js\";\n\n/**\n * In-memory implementation of OAuth state store\n */\nexport class InMemoryStateStore implements StateStore {\n private states: Map<string, OAuthState> = new Map();\n\n async set(key: string, state: OAuthState): Promise<void> {\n this.states.set(key, state);\n }\n\n async get(key: string): Promise<OAuthState | null> {\n const state = this.states.get(key);\n if (!state) return null;\n\n // Check if state has expired\n if (Date.now() - state.createdAt > STATE_EXPIRATION_MS) {\n this.states.delete(key);\n return null;\n }\n\n return state;\n }\n\n async delete(key: string): Promise<void> {\n this.states.delete(key);\n }\n\n async cleanup(): Promise<void> {\n const now = Date.now();\n for (const [key, state] of this.states.entries()) {\n if (now - state.createdAt > STATE_EXPIRATION_MS) {\n this.states.delete(key);\n }\n }\n }\n}\n","import type { IncomingMessage } from \"node:http\";\nimport { createLocalJWKSet, createRemoteJWKSet, type JWTPayload, jwtVerify } from \"jose\";\nimport { DEFAULT_SCOPES, DEFAULT_WELLKNOWN_URL, PUBLIC_CIVIC_CLIENT_ID } from \"./constants.js\";\nimport {\n type AccessTokenPayload,\n AuthenticationError,\n type CivicAuthOptions,\n type ExtendedAuthInfo,\n JWTVerificationError,\n type OIDCWellKnownConfiguration,\n} from \"./types.js\";\n\n/**\n * Return the client ID that must be in the jwt (in either the tid or client_id field).\n * If a client id is explicitly specified by the config then use that.\n * If the auth server is civic, then we allow the public client id if none is specified.\n * Otherwise, return undefined, which means the jwt will accept any access token from the specified issuer\n * @param options\n */\nconst getExpectedClientId = <TAuthInfo extends ExtendedAuthInfo, TRequest extends IncomingMessage = IncomingMessage>(\n options: CivicAuthOptions<TAuthInfo, TRequest>\n): string | undefined => {\n if (options.clientId) {\n return options.clientId;\n }\n\n // If wellKnownUrl is not provided (undefined) or is the default, we're using Civic\n if (!options.wellKnownUrl || options.wellKnownUrl === DEFAULT_WELLKNOWN_URL) {\n return PUBLIC_CIVIC_CLIENT_ID;\n }\n\n return undefined;\n};\n\n/**\n * Get the auth server URL based on the options provided.\n * This adds tenant-specific information via the path if using Civic Auth and dynamic client registration is enabled.\n */\nconst getAuthServer = <TAuthInfo extends ExtendedAuthInfo, TRequest extends IncomingMessage = IncomingMessage>(\n options: CivicAuthOptions<TAuthInfo, TRequest>\n): string => {\n // if the wellknown url is explicitly set to something other than Civic, just use that\n if (options.wellKnownUrl && options.wellKnownUrl !== DEFAULT_WELLKNOWN_URL) return options.wellKnownUrl;\n\n // If dynamic client registration is enabled, adapt the URL with client ID in the path\n if (options.allowDynamicClientRegistration) {\n const clientId = getExpectedClientId(options) ?? PUBLIC_CIVIC_CLIENT_ID;\n return DEFAULT_WELLKNOWN_URL.replace(\"/oauth/\", `/oauth/${clientId}/`);\n }\n\n // Default behavior: use the URL as-is without modification\n return DEFAULT_WELLKNOWN_URL;\n};\n\n/**\n * Verify that the client_id or tid in the token matches the expected client ID.\n * Throws an error if it does not match.\n *\n * In a DCR environment we would expect the actual client id to be the dynamically created one,\n * but in that case the \"tid\" should refer to the tenant ID, which is the same as the \"base\"\n * client ID passed in the options.\n *\n * @param payload The JWT payload containing client_id or tid\n * @param expectedClientId The expected client ID to match against\n */\nconst verifyClientId = (payload: AccessTokenPayload, expectedClientId: string | undefined) => {\n if (!expectedClientId) {\n throw new AuthenticationError(\"Client ID verification is enabled but no expected client ID was provided\");\n }\n\n // Check if either the client_id or tid matches the expected client ID\n // At least one of them must match\n const clientIdMatches = payload.client_id === expectedClientId;\n const tidMatches = payload.tid === expectedClientId;\n\n if (!clientIdMatches && !tidMatches) {\n throw new AuthenticationError(`Invalid client_id or tid in token. Expected: ${expectedClientId}`);\n }\n};\n\n/**\n * Core authentication functionality that can be used with any framework\n */\nexport class McpServerAuth<TAuthInfo extends ExtendedAuthInfo, TRequest extends IncomingMessage = IncomingMessage> {\n protected oidcConfig: OIDCWellKnownConfiguration;\n protected jwks: ReturnType<typeof createRemoteJWKSet> | ReturnType<typeof createLocalJWKSet>;\n protected options: CivicAuthOptions<TAuthInfo, TRequest>;\n\n protected constructor(oidcConfig: OIDCWellKnownConfiguration, options: CivicAuthOptions<TAuthInfo, TRequest>) {\n this.oidcConfig = oidcConfig;\n this.options = options;\n\n // Use local JWKS if provided, otherwise fetch from remote\n if (options.jwks) {\n this.jwks = createLocalJWKSet(options.jwks);\n } else {\n this.jwks = createRemoteJWKSet(new URL(oidcConfig.jwks_uri));\n }\n }\n\n /**\n * Initialize the auth core by fetching OIDC configuration\n */\n static async init<TAuthInfo extends ExtendedAuthInfo, TRequest extends IncomingMessage = IncomingMessage>(\n options: CivicAuthOptions<TAuthInfo, TRequest> = {}\n ): Promise<McpServerAuth<TAuthInfo, TRequest>> {\n const wellKnownUrl = getAuthServer(options);\n console.log(`Fetching Civic Auth OIDC configuration from ${wellKnownUrl}`);\n\n const response = await fetch(wellKnownUrl);\n if (!response.ok) {\n throw new Error(`Failed to fetch Civic Auth configuration: ${response.statusText}`);\n }\n\n const oidcConfig = (await response.json()) as OIDCWellKnownConfiguration;\n return new McpServerAuth(oidcConfig, options);\n }\n\n /**\n * Get the OAuth Protected Resource metadata\n * @param resourceUrl The resource URL of the protected resource (e.g., https://my-server.com/mcp)\n */\n getProtectedResourceMetadata(resourceUrl: string) {\n return {\n resource: resourceUrl,\n authorization_servers: [this.oidcConfig.issuer],\n scopes_supported: this.options.scopesSupported || DEFAULT_SCOPES,\n bearer_methods_supported: [\"header\"],\n };\n }\n\n /**\n * Create auth info from a token (or null) and request\n * @param token The JWT token (can be null)\n * @param payload The JWT payload if token was already verified\n * @param request Optional request object to pass to onLogin callback\n * @returns ExtendedAuthInfo if successful, null otherwise\n */\n private async createAuthInfo(\n token: string | null,\n payload: JWTPayload | null,\n request?: TRequest\n ): Promise<TAuthInfo | null> {\n const inputAuthInfo: ExtendedAuthInfo | null =\n token && payload\n ? {\n token,\n clientId: (payload.client_id as string) || (payload.aud as string),\n tenantId: payload.tid as string | undefined,\n scopes: payload.scope ? (payload.scope as string).split(\" \") : [],\n expiresAt: payload.exp,\n extra: {\n ...payload,\n },\n }\n : null;\n\n if (!this.options.onLogin) return inputAuthInfo as TAuthInfo;\n\n // Call onLogin if provided - it can create or enrich auth info\n // If authInfo is null, onLogin might create it from request headers\n return this.options.onLogin(inputAuthInfo, request);\n }\n\n /**\n * Extract and verify bearer token from authorization header\n * @param authHeader The Authorization header value\n * @returns Object with token and payload if valid, throws if invalid token, returns null values if no token\n */\n private async extractBearerToken(authHeader: string | undefined): Promise<{\n token: string | null;\n payload: AccessTokenPayload | null;\n }> {\n if (!authHeader?.startsWith(\"Bearer \")) {\n return { token: null, payload: null };\n }\n\n const token = authHeader.substring(7);\n\n try {\n // Verify the token - this will throw if invalid\n const { payload } = await jwtVerify<AccessTokenPayload>(token, this.jwks, {\n issuer: this.oidcConfig.issuer,\n });\n\n if (!(this.options.disableClientIdVerification ?? false)) {\n verifyClientId(payload, getExpectedClientId(this.options));\n }\n\n return { token, payload };\n } catch (error) {\n // Wrap jose errors in our custom error class, so that we can catch them and return 401\n throw new JWTVerificationError(\n error instanceof Error ? error.message : \"JWT verification failed\",\n error instanceof Error ? error : undefined\n );\n }\n }\n\n /**\n * Handle a request by extracting and verifying the bearer token\n * @param request The request object\n * @returns ExtendedAuthInfo if valid\n * @throws Error if authentication fails\n */\n async handleRequest(request: TRequest): Promise<TAuthInfo> {\n const { token, payload } = await this.extractBearerToken(request.headers.authorization);\n\n // Try to create auth info (even with null token/payload, onLogin might handle it)\n const authInfo = await this.createAuthInfo(token, payload, request);\n\n if (!authInfo) throw new AuthenticationError(\"Authentication failed\");\n\n return authInfo;\n }\n}\n","import type { IncomingMessage } from \"node:http\";\nimport type { AuthInfo } from \"@modelcontextprotocol/sdk/server/auth/types.js\";\nimport type { JWTPayload } from \"jose\";\nimport type { StateStore } from \"./legacy\";\n\nexport interface CivicAuthOptions<\n TAuthInfo extends ExtendedAuthInfo,\n TRequest extends IncomingMessage = IncomingMessage,\n> {\n /**\n * The URL to the auth server's well-known OIDC configuration\n * Defaults to https://auth.civic.com/oauth/.well-known/openid-configuration\n */\n wellKnownUrl?: string;\n\n /**\n * OAuth scopes to support\n * Defaults to ['openid', 'profile', 'email']\n */\n scopesSupported?: string[];\n\n /**\n * Header name to read the protocol from (e.g. \"X-Forwarded-Proto\").\n * Resolution order: forceHttps > protocolHeader > req.protocol.\n */\n protocolHeader?: string;\n\n /**\n * Header name to read the host from (e.g. \"X-Forwarded-Host\").\n * Defaults to the standard \"host\" header.\n */\n hostHeader?: string;\n\n /**\n * Base path for auth endpoints\n * Defaults to '/'\n */\n basePath?: string;\n\n /**\n * The MCP route to protect with authentication\n * Defaults to '/mcp'\n */\n mcpRoute?: string;\n\n /**\n * Optional callback to enrich the auth info with custom data\n * Called after successful token verification\n * @param authInfo The verified auth info from the token. Null if no token was provided.\n * @param request Optional request object that may contain headers or other data\n * @returns Enriched auth info with custom data\n */\n onLogin?: (authInfo: ExtendedAuthInfo | null, request?: TRequest) => Promise<TAuthInfo | null>;\n\n /**\n * Optional OAuth client ID / Tenant ID.\n * When set, the access token must include *either* a \"client_id\" field or \"tid\" field that matches it.\n */\n clientId?: string;\n\n /**\n * Whether to allow dynamic client registration by adding client ID as subdomain.\n * When true, the client ID will be added as a subdomain to the auth server URL.\n * When false (default), the auth server URL will be used as-is without subdomain prefixing.\n * Defaults to false.\n */\n allowDynamicClientRegistration?: boolean;\n\n /**\n * Enable legacy OAuth mode where MCP server acts as an OAuth server.\n * When true, the server will expose OAuth endpoints that proxy to the underlying auth server.\n * Defaults to true for backward compatibility.\n * @deprecated This mode is deprecated. Clients should authenticate directly with the auth server.\n */\n enableLegacyOAuth?: boolean;\n\n /**\n * Custom state store for managing OAuth flow state between redirects in legacy mode.\n * Only used when enableLegacyOAuth is true.\n * Defaults to in-memory store.\n */\n stateStore?: StateStore;\n\n /**\n * Optional JSON Web Key Set for local JWT verification.\n * When provided, these keys will be used instead of fetching from the OIDC jwks_uri.\n * Useful for testing or air-gapped environments.\n */\n jwks?: {\n keys: Array<{\n kty: string;\n kid?: string;\n use?: string;\n alg?: string;\n [key: string]: unknown;\n }>;\n };\n\n /**\n * Whether to disable client ID verification.\n * When true, the client_id or tid verification will be skipped.\n * Defaults to false (verification enabled).\n */\n disableClientIdVerification?: boolean;\n\n /**\n * If true, forces all metadata URLs to use https even if the incoming request is http.\n * This is useful when sitting behind a proxy that terminates SSL.\n * Defaults to false.\n */\n forceHttps?: boolean;\n}\n\nexport interface OIDCWellKnownConfiguration {\n issuer: string;\n authorization_endpoint: string;\n token_endpoint: string;\n jwks_uri: string;\n scopes_supported?: string[];\n response_types_supported?: string[];\n grant_types_supported?: string[];\n token_endpoint_auth_methods_supported?: string[];\n introspection_endpoint?: string;\n revocation_endpoint?: string;\n registration_endpoint?: string;\n}\n\nexport interface ExtendedAuthInfo extends AuthInfo {\n /**\n * The tenant ID from the tid claim, if present\n */\n tenantId?: string;\n extra?: {\n sub?: string;\n email?: string;\n name?: string;\n picture?: string;\n [key: string]: unknown;\n };\n}\n\n/**\n * Custom error class for all authentication errors\n */\nexport class AuthenticationError extends Error {}\n\n/**\n * Custom error class for JWT verification failures\n */\nexport class JWTVerificationError extends AuthenticationError {\n constructor(\n message: string,\n public originalError?: Error\n ) {\n super(message);\n this.name = \"JWTVerificationError\";\n }\n}\n\nexport type AccessTokenPayload = JWTPayload & {\n client_id: string | undefined;\n tid: string | undefined;\n};\n","import { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport type { RestartableStreamableHTTPClientTransport } from \"./transport/index.js\";\n\n/**\n * MCP Client with built-in CLI authentication support\n * Handles the OAuth flow automatically and retries connection after auth\n */\nexport class CLIClient extends Client {\n /**\n * Connect to MCP server with automatic authentication handling\n * If the first connection fails due to auth, it will wait for the OAuth flow\n * to complete and then retry the connection\n */\n async connect(transport: RestartableStreamableHTTPClientTransport): Promise<void> {\n try {\n await super.connect(transport);\n } catch (error: unknown) {\n // Check if this is an authorization error\n if (error instanceof Error) {\n // This error.message is ONLY returned if auth() in @modelcontextprotocol/sdk/client/auth.js\n // returns \"REDIRECT\", therefore we waitForAuthorizationCode() and connect again.\n if (error.message === \"Unauthorized\") {\n console.log(\"Authorization required, waiting for user to complete OAuth flow...\");\n const authProvider = transport.authProvider;\n\n // Wait for the OAuth flow to complete\n await authProvider.waitForAuthorizationCode();\n console.log(\"Authorization completed.\");\n\n // Retry the connection - the auth provider now has tokens\n return await super.connect(transport);\n }\n }\n\n // Re-throw any other errors\n throw error;\n }\n }\n}\n","import type { OAuthTokens } from \"@modelcontextprotocol/sdk/shared/auth.js\";\nimport type { TokenPersistence } from \"./TokenPersistence.js\";\n\n/**\n * In-memory token persistence strategy\n * Tokens are stored in memory and lost when the process exits\n */\nexport class InMemoryTokenPersistence implements TokenPersistence {\n private tokens: OAuthTokens | undefined;\n\n saveTokens(tokens: OAuthTokens): void {\n this.tokens = tokens;\n }\n\n loadTokens(): OAuthTokens | undefined {\n return this.tokens;\n }\n\n clearTokens(): void {\n this.tokens = undefined;\n }\n}\n","import type { OAuthClientProvider } from \"@modelcontextprotocol/sdk/client/auth.js\";\nimport type {\n OAuthClientInformation,\n OAuthClientMetadata,\n OAuthTokens,\n} from \"@modelcontextprotocol/sdk/shared/auth.js\";\nimport { InMemoryTokenPersistence, type TokenPersistence } from \"./persistence/index.js\";\n\nexport interface CivicAuthProviderOptions {\n /**\n * Client secret for OAuth flows that don't support PKCE.\n * Optional - only needed for auth servers that require client authentication.\n */\n clientSecret?: string;\n\n /**\n * Token persistence strategy to use for storing/retrieving tokens.\n * Defaults to in-memory persistence if not provided.\n */\n tokenPersistence?: TokenPersistence;\n}\n\n/**\n * Abstract base class for Civic auth providers\n */\nexport abstract class CivicAuthProvider implements OAuthClientProvider {\n protected clientSecret?: string;\n protected tokenPersistence: TokenPersistence;\n\n constructor(options: CivicAuthProviderOptions) {\n this.clientSecret = options.clientSecret;\n this.tokenPersistence = options.tokenPersistence ?? new InMemoryTokenPersistence();\n }\n\n abstract clientInformation(): OAuthClientInformation | Promise<OAuthClientInformation | undefined> | undefined;\n\n abstract get clientMetadata(): OAuthClientMetadata;\n\n abstract codeVerifier(): string | Promise<string>;\n\n abstract get redirectUrl(): string | URL;\n\n abstract saveCodeVerifier(codeVerifier: string): void;\n\n saveTokens(tokens: OAuthTokens): void | Promise<void> {\n return this.tokenPersistence.saveTokens(tokens);\n }\n\n /**\n * Returns the stored tokens\n */\n tokens(): OAuthTokens | undefined | Promise<OAuthTokens | undefined> {\n return this.tokenPersistence.loadTokens();\n }\n\n /**\n * Clears the stored tokens\n */\n clearTokens(): void | Promise<void> {\n return this.tokenPersistence.clearTokens();\n }\n\n abstract redirectToAuthorization(authorizationUrl: URL): void | Promise<void>;\n}\n","import { execFile } from \"node:child_process\";\nimport crypto from \"node:crypto\";\nimport http from \"node:http\";\nimport type { AddressInfo } from \"node:net\";\nimport url from \"node:url\";\nimport { promisify } from \"node:util\";\nimport type { SSEClientTransport } from \"@modelcontextprotocol/sdk/client/sse.js\";\nimport type { StreamableHTTPClientTransport } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport type { OAuthClientInformation, OAuthClientMetadata } from \"@modelcontextprotocol/sdk/shared/auth.js\";\nimport escapeHtml from \"escape-html\";\nimport { DEFAULT_CALLBACK_PORT, DEFAULT_SCOPES } from \"../../constants.js\";\nimport { CivicAuthProvider, type CivicAuthProviderOptions } from \"./CivicAuthProvider.js\";\n\nexport interface CLIAuthProviderOptions extends CivicAuthProviderOptions {\n clientId: string;\n scope?: string;\n callbackPort?: number;\n enablePortFallback?: boolean;\n successHtml?: string;\n errorHtml?: string;\n authTimeoutMs?: number;\n}\n\n/**\n * CLI Auth Provider for MCP\n * Opens authorization URL in default browser and stores tokens in memory\n */\nexport class CLIAuthProvider extends CivicAuthProvider {\n private storedCodeVerifier: string | undefined;\n private clientId: string;\n private scope: string;\n private callbackPort: number;\n private enablePortFallback: boolean;\n private authTimeoutMs: number;\n private successHtml: string;\n private errorHtml: string;\n private callbackServer: http.Server | undefined;\n private authorizationCodePromise: Promise<string> | undefined;\n private authorizationCodeResolve: ((code: string) => void) | undefined;\n private authorizationCodeReject: ((error: Error) => void) | undefined;\n private transport: SSEClientTransport | StreamableHTTPClientTransport | undefined;\n private serverTimeout: NodeJS.Timeout | undefined;\n\n constructor(options: CLIAuthProviderOptions) {\n super(options);\n this.clientId = options.clientId;\n this.scope = options.scope ?? DEFAULT_SCOPES.join(\" \");\n this.callbackPort = options.callbackPort ?? DEFAULT_CALLBACK_PORT;\n this.enablePortFallback = options.enablePortFallback ?? true;\n this.authTimeoutMs = options.authTimeoutMs ?? 5 * 60 * 1000; // 5 minutes default\n this.successHtml =\n options.successHtml ??\n '<html lang=\"en\"><body><h1>Authorization Successful</h1><p>You can now close this window.</p></body></html>';\n this.errorHtml =\n options.errorHtml ?? '<html lang=\"en\"><body><h1>Authorization Failed</h1><p>{{error}}</p></body></html>';\n }\n\n clientInformation(): OAuthClientInformation | Promise<OAuthClientInformation | undefined> | undefined {\n const info: OAuthClientInformation = {\n client_id: this.clientId,\n };\n\n // Include client_secret if provided (for non-PKCE auth servers)\n if (this.clientSecret) {\n info.client_secret = this.clientSecret;\n }\n\n return info;\n }\n\n get clientMetadata(): OAuthClientMetadata {\n return {\n redirect_uris: [this.getCallbackUrl(this.callbackPort)],\n client_name: this.clientId,\n scope: this.scope,\n };\n }\n\n codeVerifier(): string | Promise<string> {\n // Generate and return the stored code verifier\n if (!this.storedCodeVerifier) {\n this.storedCodeVerifier = crypto.randomBytes(32).toString(\"base64url\");\n }\n return this.storedCodeVerifier;\n }\n\n async redirectToAuthorization(authorizationUrl: URL): Promise<void> {\n // Check if authorization flow is already in progress\n if (this.callbackServer) {\n throw new Error(\"Authorization flow already in progress. Please wait for it to complete.\");\n }\n\n console.log(`Opening authorization URL in browser: ${authorizationUrl.href}`);\n\n // Start the callback server before opening the browser\n const actualPort = await this.startCallbackServer();\n\n // Modify the auth URL to use updated redirect URI if port changed\n let urlToOpen = authorizationUrl.href;\n if (actualPort) {\n // update the callback URL\n this.callbackPort = actualPort;\n const authUrlObj = new URL(authorizationUrl);\n authUrlObj.searchParams.se