import { randomBytes } from "node:crypto"; import { createServer, type Server } from "node:http"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface, } from "./oauth-compat.js"; import { registerOAuthProvider, unregisterOAuthProvider, } from "./oauth-compat.js"; import { fetchWithTimeout } from "./async-utils.js"; import { isRecord, normalizeNonEmptyString, throwFixedAbortErrorIfAborted, } from "./auth-error-utils.js"; import { buildClineClientHeaders } from "./cline-compat.js"; import { OAuthRefreshFailureError, type OAuthRefreshFailureDetails, } from "./types-oauth.js"; const CLINE_PROVIDER_ID = "cline"; const CLINE_PROVIDER_NAME = "Cline"; const CLINE_API_BASE_URL = "https://api.cline.bot"; const CLINE_AUTHORIZE_ENDPOINT = "/api/v1/auth/authorize"; const CLINE_TOKEN_ENDPOINT = "/api/v1/auth/token"; const CLINE_REFRESH_ENDPOINT = "/api/v1/auth/refresh"; const CLINE_CALLBACK_PATH = "/auth"; const CALLBACK_HOST = "127.0.0.1"; const CALLBACK_PORT_RANGE_START = 48_801; const CALLBACK_PORT_RANGE_END = 48_811; const MANUAL_CALLBACK_PROMPT = "Paste the Cline callback URL or authorization code:"; const DEFAULT_REQUEST_TIMEOUT_MS = 15_000; const CLINE_OAUTH_CANCELLED_MESSAGE = "Cline OAuth login was cancelled."; const CLINE_OAUTH_STATE_BYTES = 32; const AUTH_SUCCESS_HTML = ` Cline Authentication Complete

Authentication successful

You can return to Pi now.

`; const AUTH_ERROR_HTML = ` Cline Authentication Error

Authentication callback error

Please return to Pi and paste the full callback URL manually.

`; type ClineAuthApiUser = { subject?: string | null; email?: string; name?: string; clineUserId?: string | null; accounts?: string[] | null; }; type ClineAuthResponseData = { accessToken?: string; refreshToken?: string; tokenType?: string; expiresAt?: string; userInfo?: ClineAuthApiUser; }; type ClineAuthResponse = { success?: boolean; data?: ClineAuthResponseData; error?: string; error_description?: string; redirect_url?: string; }; type ParsedAuthorizationInput = { code?: string; provider?: string; state?: string; stateCount: number; requiresStateValidation: boolean; }; export interface LocalCallbackServerHandle { callbackUrl: string; waitForCallback(): Promise; cancelWait(): void; close(): Promise; } export interface ClineOAuthProviderDependencies { fetchImplementation: typeof fetch; startLocalCallbackServer: () => Promise; requestTimeoutMs: number; now: () => number; } function createRefreshFailureDetails( status: number | undefined, errorCode: string | undefined, reason: string, permanent: boolean, ): OAuthRefreshFailureDetails { return { providerId: CLINE_PROVIDER_ID, status, errorCode, reason, permanent, source: "extension", }; } function parseAuthorizationParams(params: URLSearchParams, requiresStateValidation: boolean): ParsedAuthorizationInput { const states = params.getAll("state"); return { code: normalizeNonEmptyString(params.get("refreshToken") ?? undefined) ?? normalizeNonEmptyString(params.get("idToken") ?? undefined) ?? normalizeNonEmptyString(params.get("code") ?? undefined), provider: normalizeNonEmptyString(params.get("provider") ?? undefined), state: states.length === 1 ? normalizeNonEmptyString(states[0] ?? undefined) : undefined, stateCount: states.length, requiresStateValidation, }; } function parseAuthorizationInput(input: string): ParsedAuthorizationInput { const value = input.trim(); if (!value) { return { stateCount: 0, requiresStateValidation: false }; } try { const url = new URL(value); return parseAuthorizationParams(url.searchParams, true); } catch { // Ignore URL parsing errors and fall back to plain-text parsing. } if (value.includes("code=") || value.includes("refreshToken=") || value.includes("idToken=")) { return parseAuthorizationParams(new URLSearchParams(value), true); } return { code: value, stateCount: 0, requiresStateValidation: false, }; } function createOAuthState(): string { return randomBytes(CLINE_OAUTH_STATE_BYTES).toString("base64url"); } function validateAuthorizationState(parsedInput: ParsedAuthorizationInput, expectedState: string): void { if (!parsedInput.requiresStateValidation) { return; } if (parsedInput.stateCount !== 1 || parsedInput.state !== expectedState) { throw new Error("Cline OAuth callback state did not match."); } } function parseJsonRecord(text: string): Record | null { if (!text.trim()) { return null; } try { const parsed = JSON.parse(text) as unknown; return isRecord(parsed) ? parsed : null; } catch { return null; } } function resolveExpirationTimestamp(expiresAtIso: string | undefined, fallbackToken: string): number { const parsedTimestamp = typeof expiresAtIso === "string" ? Date.parse(expiresAtIso) : Number.NaN; if (Number.isFinite(parsedTimestamp) && parsedTimestamp > 0) { return parsedTimestamp; } const tokenParts = fallbackToken.split("."); if (tokenParts.length === 3 && tokenParts[1]) { try { const payload = JSON.parse( Buffer.from(tokenParts[1], "base64").toString("utf-8"), ) as { exp?: unknown }; if (typeof payload.exp === "number" && Number.isFinite(payload.exp)) { return payload.exp * 1000; } } catch { // Fall through to default expiration. } } return Date.now() + 60 * 60_000; } function createStoredCredentials( responseData: ClineAuthResponseData, now: () => number, ): OAuthCredentials { const accessToken = normalizeNonEmptyString(responseData.accessToken); const refreshToken = normalizeNonEmptyString(responseData.refreshToken); if (!accessToken || !refreshToken) { throw new Error("Cline token response did not include both accessToken and refreshToken."); } const userInfo = responseData.userInfo ?? {}; const accountId = normalizeNonEmptyString(userInfo.clineUserId) ?? normalizeNonEmptyString(userInfo.subject); const email = normalizeNonEmptyString(userInfo.email); const displayName = normalizeNonEmptyString(userInfo.name); return { access: accessToken, refresh: refreshToken, expires: resolveExpirationTimestamp(responseData.expiresAt, accessToken), accountId, provider: CLINE_PROVIDER_ID, startedAt: now(), userInfo: { id: accountId, email, displayName, subject: normalizeNonEmptyString(userInfo.subject), accounts: Array.isArray(userInfo.accounts) ? userInfo.accounts.filter((value): value is string => typeof value === "string") : [], }, }; } function createRequestHeaders(): HeadersInit { return buildClineClientHeaders({ includeJsonContentType: true }); } async function readResponsePayload(response: Response): Promise<{ text: string; json: Record | null; }> { const text = await response.text().catch(() => ""); return { text, json: parseJsonRecord(text), }; } async function requestAuthorizeRedirectUrl( fetchImplementation: typeof fetch, callbackUrl: string, state: string, requestTimeoutMs: number, ): Promise { const url = new URL(CLINE_AUTHORIZE_ENDPOINT, CLINE_API_BASE_URL); url.searchParams.set("client_type", "extension"); url.searchParams.set("callback_url", callbackUrl); url.searchParams.set("redirect_uri", callbackUrl); url.searchParams.set("state", state); const response = await fetchWithTimeout( url, { method: "GET", headers: createRequestHeaders(), redirect: "manual", }, { fetchImplementation, timeoutMs: requestTimeoutMs }, ); if (response.status >= 300 && response.status < 400) { const redirectUrl = normalizeNonEmptyString(response.headers.get("Location") ?? undefined); if (redirectUrl) { return redirectUrl; } } const payload = (await response.json().catch(() => null)) as ClineAuthResponse | null; const redirectUrl = normalizeNonEmptyString(payload?.redirect_url); if (redirectUrl) { return redirectUrl; } throw new Error("Cline authorization did not return a redirect URL."); } async function exchangeAuthorizationCode( fetchImplementation: typeof fetch, code: string, callbackUrl: string, provider: string | undefined, now: () => number, requestTimeoutMs: number, ): Promise { const body: Record = { grant_type: "authorization_code", code, client_type: "extension", redirect_uri: callbackUrl, }; if (provider) { body.provider = provider; } const response = await fetchWithTimeout( new URL(CLINE_TOKEN_ENDPOINT, CLINE_API_BASE_URL), { method: "POST", headers: createRequestHeaders(), body: JSON.stringify(body), }, { fetchImplementation, timeoutMs: requestTimeoutMs }, ); const payload = (await response.json().catch(() => null)) as ClineAuthResponse | null; if (!response.ok || !payload?.success || !payload.data) { const message = normalizeNonEmptyString(payload?.error_description) ?? normalizeNonEmptyString(payload?.error) ?? "Failed to exchange the Cline authorization code for OAuth credentials."; throw new Error(message); } return createStoredCredentials(payload.data, now); } async function refreshStoredCredentials( fetchImplementation: typeof fetch, credentials: OAuthCredentials, now: () => number, requestTimeoutMs: number, ): Promise { const refreshToken = normalizeNonEmptyString(credentials.refresh); if (!refreshToken) { throw new OAuthRefreshFailureError( "Cline OAuth credentials are missing a refresh token.", createRefreshFailureDetails(undefined, undefined, "missing_refresh_token", true), ); } let response: Response; try { response = await fetchWithTimeout( new URL(CLINE_REFRESH_ENDPOINT, CLINE_API_BASE_URL), { method: "POST", headers: createRequestHeaders(), body: JSON.stringify({ refreshToken, grantType: "refresh_token", }), }, { fetchImplementation, timeoutMs: requestTimeoutMs }, ); } catch (error) { throw new OAuthRefreshFailureError( "Cline token refresh request failed.", createRefreshFailureDetails(undefined, undefined, "request_failed", false), { cause: error }, ); } const payload = (await response.json().catch(() => null)) as ClineAuthResponse | null; if (!response.ok || !payload?.success || !payload.data) { const status = response.status; const errorCode = normalizeNonEmptyString(payload?.error); const permanent = status === 400 || status === 401; throw new OAuthRefreshFailureError( normalizeNonEmptyString(payload?.error_description) ?? normalizeNonEmptyString(payload?.error) ?? "Cline token refresh failed.", createRefreshFailureDetails( status, errorCode, permanent ? "token_rejected" : "http_error", permanent, ), ); } const refreshed = createStoredCredentials(payload.data, now); return { ...credentials, ...refreshed, accountId: normalizeNonEmptyString(refreshed.accountId) ?? normalizeNonEmptyString(credentials.accountId), }; } function fallbackCallbackUrl(): string { return `http://${CALLBACK_HOST}:${CALLBACK_PORT_RANGE_START}${CLINE_CALLBACK_PATH}`; } async function requestManualAuthorizationInput(callbacks: OAuthLoginCallbacks): Promise { if (callbacks.onManualCodeInput) { return callbacks.onManualCodeInput(); } return callbacks.onPrompt({ message: MANUAL_CALLBACK_PROMPT, placeholder: fallbackCallbackUrl(), }); } async function resolveAuthorizationInput( callbacks: OAuthLoginCallbacks, server: LocalCallbackServerHandle, ): Promise { const serverResultPromise = server.waitForCallback().then((value) => value ? { source: "server" as const, value } : null, ); const manualResultPromise = requestManualAuthorizationInput(callbacks).then((value) => ({ source: "manual" as const, value, })); const firstResult = await Promise.race([serverResultPromise, manualResultPromise]); if (firstResult) { if (firstResult.source === "manual") { server.cancelWait(); } return firstResult.value; } return requestManualAuthorizationInput(callbacks); } async function closeServerSafely(server: LocalCallbackServerHandle | null): Promise { if (!server) { return; } try { await server.close(); } catch { // Ignore callback server shutdown errors. } } async function listenOnPort(server: Server, port: number): Promise { await new Promise((resolve, reject) => { const onError = (error: Error & { code?: string }) => { server.off("error", onError); reject(error); }; server.once("error", onError); server.listen(port, CALLBACK_HOST, () => { server.off("error", onError); resolve(); }); }); } async function startDefaultLocalCallbackServer(): Promise { let settled = false; let settleWait: ((value: string | null) => void) | null = null; const callbackPromise = new Promise((resolve) => { settleWait = (value) => { if (settled) { return; } settled = true; resolve(value); }; }); const server = createServer((request, response) => { const requestUrl = new URL(request.url ?? "/", `http://${CALLBACK_HOST}`); if (requestUrl.pathname !== CLINE_CALLBACK_PATH) { response.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); response.end(AUTH_ERROR_HTML); return; } const fullUrl = `http://${CALLBACK_HOST}:${boundPort}${request.url ?? ""}`; response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); response.end(AUTH_SUCCESS_HTML); settleWait?.(fullUrl); }); let boundPort = CALLBACK_PORT_RANGE_START; let lastError: Error | null = null; for (let port = CALLBACK_PORT_RANGE_START; port <= CALLBACK_PORT_RANGE_END; port += 1) { try { await listenOnPort(server, port); boundPort = port; lastError = null; break; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if ((lastError as Error & { code?: string }).code !== "EADDRINUSE") { throw lastError; } } } if (lastError) { throw lastError; } return { callbackUrl: `http://${CALLBACK_HOST}:${boundPort}${CLINE_CALLBACK_PATH}`, waitForCallback: async () => callbackPromise, cancelWait: () => { settleWait?.(null); }, close: async () => { await new Promise((resolve) => { server.close(() => { resolve(); }); }); }, }; } export function createClineOAuthProvider( dependencies: Partial = {}, ): OAuthProviderInterface { const resolvedDependencies: ClineOAuthProviderDependencies = { fetchImplementation: dependencies.fetchImplementation ?? fetch, startLocalCallbackServer: dependencies.startLocalCallbackServer ?? startDefaultLocalCallbackServer, requestTimeoutMs: typeof dependencies.requestTimeoutMs === "number" && Number.isFinite(dependencies.requestTimeoutMs) && dependencies.requestTimeoutMs > 0 ? Math.floor(dependencies.requestTimeoutMs) : DEFAULT_REQUEST_TIMEOUT_MS, now: dependencies.now ?? (() => Date.now()), }; return { id: CLINE_PROVIDER_ID, name: CLINE_PROVIDER_NAME, usesCallbackServer: true, async login(callbacks: OAuthLoginCallbacks): Promise { throwFixedAbortErrorIfAborted(callbacks.signal, CLINE_OAUTH_CANCELLED_MESSAGE); let expectedState: string | undefined = createOAuthState(); let callbackServer: LocalCallbackServerHandle | null = null; let callbackUrl = fallbackCallbackUrl(); try { callbackServer = await resolvedDependencies.startLocalCallbackServer(); callbackUrl = callbackServer.callbackUrl; } catch { callbackServer = { callbackUrl, waitForCallback: async () => null, cancelWait: () => {}, close: async () => {}, }; } try { callbacks.onProgress?.("Requesting Cline authorization URL..."); const stateForRequest = expectedState; if (!stateForRequest) { throw new Error("Cline OAuth state was already used."); } const authUrl = await requestAuthorizeRedirectUrl( resolvedDependencies.fetchImplementation, callbackUrl, stateForRequest, resolvedDependencies.requestTimeoutMs, ); throwFixedAbortErrorIfAborted(callbacks.signal, CLINE_OAUTH_CANCELLED_MESSAGE); callbacks.onAuth({ url: authUrl, instructions: "Complete the browser sign-in. If Pi does not capture the callback automatically, paste the final callback URL or authorization code here.", }); callbacks.onProgress?.("Waiting for Cline authentication callback..."); const rawInput = await resolveAuthorizationInput(callbacks, callbackServer); throwFixedAbortErrorIfAborted(callbacks.signal, CLINE_OAUTH_CANCELLED_MESSAGE); const parsedInput = parseAuthorizationInput(rawInput); const stateForValidation = expectedState; expectedState = undefined; if (!stateForValidation) { throw new Error("Cline OAuth state was already used."); } validateAuthorizationState(parsedInput, stateForValidation); const code = normalizeNonEmptyString(parsedInput.code); if (!code) { throw new Error("Cline OAuth login requires an authorization code or callback URL."); } callbacks.onProgress?.("Exchanging Cline authorization code..."); return await exchangeAuthorizationCode( resolvedDependencies.fetchImplementation, code, callbackUrl, parsedInput.provider, resolvedDependencies.now, resolvedDependencies.requestTimeoutMs, ); } finally { expectedState = undefined; await closeServerSafely(callbackServer); } }, async refreshToken(credentials: OAuthCredentials): Promise { return refreshStoredCredentials( resolvedDependencies.fetchImplementation, credentials, resolvedDependencies.now, resolvedDependencies.requestTimeoutMs, ); }, getApiKey(credentials: OAuthCredentials): string { const accessToken = normalizeNonEmptyString(credentials.access); if (!accessToken) { throw new Error("Cline OAuth credentials are missing an access token."); } return `workos:${accessToken}`; }, }; } export const clineOAuthProvider = createClineOAuthProvider(); export function registerClineOAuthProvider(): void { registerOAuthProvider(clineOAuthProvider); } export function unregisterClineOAuthProvider(): void { unregisterOAuthProvider(CLINE_PROVIDER_ID); }