diff --git a/CHANGELOG.md b/CHANGELOG.md index ab0b39623c..cd3b2002f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,8 @@ ### šŸ“ Maintenance +- **oauth (dead-code removal):** delete the superseded legacy OAuth **service-class** hierarchy under `src/lib/oauth/services/`. The live OAuth flow runs through `src/lib/oauth/providers.ts` + `src/lib/oauth/providers/` (wired into the generic `oauth/[provider]/[action]` route); the old per-provider `class *Service extends OAuthService` implementations plus their barrel had **zero** production or test references. Removed `oauth.ts` (base class), `openai.ts`, `github.ts`, `claude.ts`, `codex.ts`, `antigravity.ts`, `qwen.ts`, `qoder.ts`, and the `index.ts` barrel (āˆ’1559 LOC). Kept the three still-live files that routes import **directly** by path: `kiro.ts` (Kiro import/exchange routes), `cursor.ts` (Cursor import route), and `codexImport.ts` (utility fns for the Codex bulk-import route). Proven safe by `typecheck:core` staying green (any live reference would fail the build) + a filesystem guard `tests/unit/oauth-legacy-services-removed.test.ts` pinning the removal against re-introduction. Salvage of the closed PR [#5039](https://github.com/diegosouzapw/OmniRoute/pull/5039). gaps v3.8.42 — T10 (5.7). + --- ## [3.8.42] — 2026-06-30 diff --git a/open-sse/services/antigravityProjectBootstrap.ts b/open-sse/services/antigravityProjectBootstrap.ts index d9d4cdb58c..bd9fb91617 100644 --- a/open-sse/services/antigravityProjectBootstrap.ts +++ b/open-sse/services/antigravityProjectBootstrap.ts @@ -10,8 +10,7 @@ * attempt. Results are memoized per-token for the process lifetime to * avoid redundant round-trips. * - * Based on AntigravityService.loadCodeAssist() in - * src/lib/oauth/services/antigravity.ts and the CLIProxyAPI reference + * Based on the Antigravity loadCodeAssist flow and the CLIProxyAPI reference * implementation in internal/runtime/executor/antigravity_executor.go. */ diff --git a/src/lib/oauth/services/antigravity.ts b/src/lib/oauth/services/antigravity.ts deleted file mode 100644 index d3a0ad4362..0000000000 --- a/src/lib/oauth/services/antigravity.ts +++ /dev/null @@ -1,347 +0,0 @@ -import crypto from "crypto"; -import open from "open"; -import { ANTIGRAVITY_CONFIG } from "../constants/oauth"; -import { - antigravityNativeOAuthUserAgent, - getAntigravityHeaders, - getAntigravityLoadCodeAssistMetadata, -} from "@omniroute/open-sse/services/antigravityHeaders.ts"; -import { extractCodeAssistOnboardTierId } from "@omniroute/open-sse/services/codeAssistSubscription.ts"; -import { getServerCredentials } from "../config/index"; -import { startLocalServer } from "../utils/server"; -import { spinner as createSpinner } from "../utils/ui"; - -/** - * Antigravity OAuth Service - * Uses standard OAuth2 Authorization Code flow (similar to Gemini) - */ -export class AntigravityService { - config: any; - - constructor() { - this.config = ANTIGRAVITY_CONFIG; - } - - /** - * Build Antigravity authorization URL - */ - buildAuthUrl(redirectUri: string, state: string) { - const params = new URLSearchParams({ - client_id: this.config.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: this.config.scopes.join(" "), - state: state, - access_type: "offline", - prompt: "consent", - }); - - return `${this.config.authorizeUrl}?${params.toString()}`; - } - - /** - * Exchange authorization code for tokens - */ - async exchangeCode(code: string, redirectUri: string) { - const response = await fetch(this.config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - "User-Agent": antigravityNativeOAuthUserAgent(), - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - code: code, - redirect_uri: redirectUri, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - } - - /** - * Get user info from Google - */ - async getUserInfo(accessToken: string) { - const response = await fetch(`${this.config.userInfoUrl}?alt=json`, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - }, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Failed to get user info: ${error}`); - } - - return await response.json(); - } - - /** - * Get common headers for Antigravity API calls - */ - getApiHeaders(accessToken: string) { - return getAntigravityHeaders("loadCodeAssist", accessToken); - } - - /** - * Get metadata object for API calls - */ - getMetadata() { - return getAntigravityLoadCodeAssistMetadata(); - } - - private getEndpointList(key: string, fallbackKey: string) { - const endpoints = this.config[key]; - if (Array.isArray(endpoints) && endpoints.length > 0) return endpoints; - const fallback = this.config[fallbackKey]; - return typeof fallback === "string" && fallback ? [fallback] : []; - } - - private async fetchFirstOk(endpoints: string[], init: RequestInit, label: string) { - let lastError: unknown = null; - for (const endpoint of endpoints) { - try { - const response = await fetch(endpoint, init); - if (response.ok) return response; - lastError = new Error(`${response.status} ${await response.text()}`); - } catch (error) { - lastError = error; - } - } - - const message = - lastError instanceof Error ? lastError.message : String(lastError || "no endpoints"); - throw new Error(`Failed to ${label}: ${message}`); - } - - /** - * Fetch Project ID and Tier from loadCodeAssist API - */ - async loadCodeAssist(accessToken: string) { - const response = await this.fetchFirstOk( - this.getEndpointList("loadCodeAssistEndpoints", "loadCodeAssistEndpoint"), - { - method: "POST", - headers: this.getApiHeaders(accessToken), - body: JSON.stringify({ metadata: this.getMetadata() }), - }, - "load code assist" - ); - - const data = await response.json(); - - // Extract project ID - let projectId = data.cloudaicompanionProject; - if (typeof projectId === "object" && projectId !== null && projectId.id) { - projectId = projectId.id; - } - - const tierId = extractCodeAssistOnboardTierId(data); - - return { projectId, tierId, raw: data }; - } - - /** - * Onboard user to enable Gemini Code Assist for the project - */ - async onboardUser(accessToken: string, tierId: string) { - const response = await this.fetchFirstOk( - this.getEndpointList("onboardUserEndpoints", "onboardUserEndpoint"), - { - method: "POST", - headers: this.getApiHeaders(accessToken), - body: JSON.stringify({ - tier_id: tierId, - metadata: this.getMetadata(), - }), - }, - "onboard user" - ); - - return await response.json(); - } - - /** - * Complete onboarding flow with retry - */ - async completeOnboarding( - accessToken: string, - projectId: string, - tierId: string, - maxRetries = 10 - ) { - for (let i = 0; i < maxRetries; i++) { - const result = await this.onboardUser(accessToken, tierId); - - if (result.done === true) { - // Extract final project ID from response - let finalProjectId = projectId; - if (result.response?.cloudaicompanionProject) { - const respProject = result.response.cloudaicompanionProject; - if (typeof respProject === "string") { - finalProjectId = respProject.trim(); - } else if (respProject.id) { - finalProjectId = respProject.id.trim(); - } - } - return { success: true, projectId: finalProjectId }; - } - - // Wait 5 seconds before retry - await new Promise((resolve) => setTimeout(resolve, 5000)); - } - - throw new Error("Onboarding timeout - please try again"); - } - - /** - * Fetch Project ID from loadCodeAssist API (legacy method for compatibility) - */ - async fetchProjectId(accessToken: string) { - const { projectId } = await this.loadCodeAssist(accessToken); - if (!projectId) { - throw new Error("No cloudaicompanionProject found in response"); - } - return projectId; - } - - /** - * Save Antigravity tokens to server - */ - async saveTokens(tokens: any, userInfo: any, projectId: string) { - const { server, token, userId } = getServerCredentials(); - - const response = await fetch(`${server}/api/cli/providers/antigravity`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "X-User-Id": userId, - }, - body: JSON.stringify({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - email: userInfo.email, - projectId: projectId, // Send projectId to server - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to save tokens"); - } - - return await response.json(); - } - - /** - * Complete Antigravity OAuth flow - */ - async connect() { - const spinner = createSpinner("Starting Antigravity OAuth...").start(); - - try { - spinner.text = "Starting local server..."; - - // Start local server for callback - let callbackParams: any = null; - const { port, close } = await startLocalServer((params) => { - callbackParams = params; - }); - - const redirectUri = `http://localhost:${port}/callback`; - spinner.succeed(`Local server started on port ${port}`); - - // Generate state - const state = crypto.randomBytes(32).toString("base64url"); - - // Build authorization URL - const authUrl = this.buildAuthUrl(redirectUri, state); - - console.log("\nOpening browser for Antigravity authentication..."); - console.log(`If browser doesn't open, visit:\n${authUrl}\n`); - - // Open browser - await open(authUrl); - - // Wait for callback - spinner.start("Waiting for Antigravity authorization..."); - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Authentication timeout (5 minutes)")); - }, 300000); - - const checkInterval = setInterval(() => { - if (callbackParams) { - clearInterval(checkInterval); - clearTimeout(timeout); - resolve(undefined); - } - }, 100); - }); - - close(); - - if (callbackParams.error) { - throw new Error(callbackParams.error_description || callbackParams.error); - } - - if (!callbackParams.code) { - throw new Error("No authorization code received"); - } - - spinner.start("Exchanging code for tokens..."); - - // Exchange code for tokens - const tokens = await this.exchangeCode(callbackParams.code, redirectUri); - - spinner.text = "Fetching user info..."; - - // Get user info - const userInfo = await this.getUserInfo(tokens.access_token); - - spinner.text = "Loading Code Assist configuration..."; - - // Load Code Assist to get project ID and tier - const { projectId, tierId } = await this.loadCodeAssist(tokens.access_token); - - if (!projectId) { - throw new Error( - "No Google Cloud Project found. Please ensure you have a GCP project with Gemini Code Assist enabled." - ); - } - - spinner.text = "Onboarding to Gemini Code Assist..."; - - // Complete onboarding to enable Gemini Code Assist - const onboardResult = await this.completeOnboarding(tokens.access_token, projectId, tierId); - const finalProjectId = onboardResult.projectId || projectId; - - spinner.text = "Saving tokens to server..."; - - // Save tokens to server - await this.saveTokens(tokens, userInfo, finalProjectId); - - spinner.succeed( - `Antigravity connected successfully! (${userInfo.email}, Project: ${finalProjectId})` - ); - return true; - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); - throw error; - } - } -} diff --git a/src/lib/oauth/services/claude.ts b/src/lib/oauth/services/claude.ts deleted file mode 100644 index 03aaafa4a7..0000000000 --- a/src/lib/oauth/services/claude.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { OAuthService } from "./oauth"; -import { CLAUDE_CONFIG } from "../constants/oauth"; -import { getServerCredentials } from "../config/index"; -import { spinner as createSpinner } from "../utils/ui"; - -/** - * Claude OAuth Service - */ -export class ClaudeService extends OAuthService { - constructor() { - super(CLAUDE_CONFIG); - } - - /** - * Build Claude authorization URL - */ - buildClaudeAuthUrl(redirectUri, state, codeChallenge) { - const scopeStr = CLAUDE_CONFIG.scopes.join(" "); - const params = new URLSearchParams({ - code: "true", - client_id: CLAUDE_CONFIG.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: scopeStr, - code_challenge: codeChallenge, - code_challenge_method: CLAUDE_CONFIG.codeChallengeMethod, - state: state, - }); - - return `${CLAUDE_CONFIG.authorizeUrl}?${params.toString()}`; - } - - /** - * Exchange Claude authorization code (with special handling) - */ - async exchangeClaudeCode(code, redirectUri, codeVerifier, state) { - // Parse code - may contain state after # - let authCode = code; - let codeState = ""; - if (authCode.includes("#")) { - const parts = authCode.split("#"); - authCode = parts[0]; - codeState = parts[1] || ""; - } - - // Claude uses JSON format (not form-urlencoded) - const tokenPayload = { - code: authCode, - state: codeState || state, - grant_type: "authorization_code", - client_id: CLAUDE_CONFIG.clientId, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }; - - const response = await fetch(CLAUDE_CONFIG.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify(tokenPayload), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - } - - /** - * Save Claude tokens to server - */ - async saveTokens(tokens) { - const { server, token, userId } = getServerCredentials(); - - // Server will auto-generate displayName based on existing account count - const response = await fetch(`${server}/api/cli/providers/claude`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "X-User-Id": userId, - }, - body: JSON.stringify({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to save tokens"); - } - - return await response.json(); - } - - /** - * Complete Claude OAuth flow - */ - async connect() { - const spinner = createSpinner("Starting Claude OAuth...").start(); - - try { - spinner.text = "Starting local server..."; - - // Authenticate and get authorization code - const { code, state, codeVerifier, redirectUri } = await this.authenticate( - "Claude", - this.buildClaudeAuthUrl.bind(this) - ); - - spinner.start("Exchanging code for tokens..."); - - // Exchange code for tokens - const tokens = await this.exchangeClaudeCode(code, redirectUri, codeVerifier, state); - - spinner.text = "Saving tokens to server..."; - - // Save tokens to server - await this.saveTokens(tokens); - - spinner.succeed("Claude connected successfully!"); - return true; - } catch (error) { - spinner.fail(`Failed: ${error.message}`); - throw error; - } - } -} diff --git a/src/lib/oauth/services/codex.ts b/src/lib/oauth/services/codex.ts deleted file mode 100644 index 44d4249a64..0000000000 --- a/src/lib/oauth/services/codex.ts +++ /dev/null @@ -1,149 +0,0 @@ -import open from "open"; -import { OAuthService } from "./oauth"; -import { CODEX_CONFIG } from "../constants/oauth"; -import { getServerCredentials } from "../config/index"; -import { startLocalServer } from "../utils/server"; -import { generatePKCE } from "../utils/pkce"; -import { spinner as createSpinner } from "../utils/ui"; - -/** - * Codex (OpenAI) OAuth Service - */ -export class CodexService extends OAuthService { - constructor() { - super(CODEX_CONFIG); - } - - /** - * Build Codex authorization URL - */ - buildCodexAuthUrl(redirectUri, state, codeChallenge) { - // Build URL manually to ensure space encoding as %20 instead of + - const params = { - response_type: "code", - client_id: CODEX_CONFIG.clientId, - redirect_uri: redirectUri, - scope: CODEX_CONFIG.scope, - code_challenge: codeChallenge, - code_challenge_method: CODEX_CONFIG.codeChallengeMethod, - ...CODEX_CONFIG.extraParams, - state: state, - }; - - const queryString = Object.entries(params) - .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) - .join("&"); - - return `${CODEX_CONFIG.authorizeUrl}?${queryString}`; - } - - /** - * Save Codex tokens to server - */ - async saveTokens(tokens) { - const { server, token, userId } = getServerCredentials(); - - const response = await fetch(`${server}/api/cli/providers/codex`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "X-User-Id": userId, - }, - body: JSON.stringify({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - idToken: tokens.id_token, - expiresIn: tokens.expires_in, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to save tokens"); - } - - return await response.json(); - } - - /** - * Complete Codex OAuth flow - */ - async connect() { - const spinner = createSpinner("Starting Codex OAuth...").start(); - - try { - spinner.text = "Starting local server..."; - - // Start local server for callback (use fixed port 1455 like real Codex CLI) - const fixedPort = 1455; - let callbackParams = null; - const { port, close } = await startLocalServer((params) => { - callbackParams = params; - }, fixedPort); - - const redirectUri = `http://localhost:${port}/auth/callback`; - spinner.succeed(`Local server started on port ${port}`); - - // Generate PKCE - const { codeVerifier, codeChallenge, state } = generatePKCE(); - - // Build authorization URL - const authUrl = this.buildCodexAuthUrl(redirectUri, state, codeChallenge); - - console.log("\nOpening browser for OpenAI authentication..."); - console.log(`If browser doesn't open, visit:\n${authUrl}\n`); - - // Open browser - await open(authUrl); - - // Wait for callback - spinner.start("Waiting for OpenAI authorization..."); - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Authentication timeout (5 minutes)")); - }, 300000); - - const checkInterval = setInterval(() => { - if (callbackParams) { - clearInterval(checkInterval); - clearTimeout(timeout); - resolve(void 0); - } - }, 100); - }); - - close(); - - if (callbackParams.error) { - throw new Error(callbackParams.error_description || callbackParams.error); - } - - if (!callbackParams.code) { - throw new Error("No authorization code received"); - } - - spinner.start("Exchanging code for tokens..."); - - // Exchange code for tokens (Codex uses form-urlencoded) - const tokens = await this.exchangeCode( - callbackParams.code, - redirectUri, - codeVerifier, - "application/x-www-form-urlencoded" - ); - - spinner.text = "Saving tokens to server..."; - - // Save tokens to server - await this.saveTokens(tokens); - - spinner.succeed("Codex connected successfully!"); - return true; - } catch (error) { - spinner.fail(`Failed: ${error.message}`); - throw error; - } - } -} diff --git a/src/lib/oauth/services/github.ts b/src/lib/oauth/services/github.ts deleted file mode 100644 index dd41a16c99..0000000000 --- a/src/lib/oauth/services/github.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { OAuthService } from "./oauth"; -import { GITHUB_CONFIG } from "../constants/oauth"; -import { spinner as createSpinner } from "../utils/ui"; - -/** - * GitHub Copilot OAuth Service - * Uses Device Code Flow for authentication - */ -export class GitHubService extends OAuthService { - constructor() { - super(GITHUB_CONFIG); - } - - /** - * Get device code for GitHub authentication - */ - async getDeviceCode() { - const response = await fetch(`${GITHUB_CONFIG.deviceCodeUrl}`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: GITHUB_CONFIG.clientId, - scope: GITHUB_CONFIG.scopes, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Failed to get device code: ${error}`); - } - - return await response.json(); - } - - /** - * Poll for access token using device code - */ - async pollAccessToken(deviceCode, verificationUri, userCode, interval = 5000) { - const spinner = createSpinner("Waiting for GitHub authentication...").start(); - - // Show user code and verification URL - console.log(`\nPlease visit: ${verificationUri}`); - console.log(`Enter code: ${userCode}\n`); - - // Open browser automatically - try { - const open = (await import("open")).default; - await open(verificationUri); - } catch (error) { - console.log("Could not open browser automatically. Please visit the URL above manually."); - } - - // Poll for access token - while (true) { - await new Promise((resolve) => setTimeout(resolve, interval)); - - const response = await fetch(`${GITHUB_CONFIG.tokenUrl}`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: GITHUB_CONFIG.clientId, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - - const data = await response.json(); - - if (data.access_token) { - spinner.succeed("GitHub authentication successful!"); - return { - access_token: data.access_token, - token_type: data.token_type, - scope: data.scope, - }; - } else if (data.error === "authorization_pending") { - // Continue polling - continue; - } else if (data.error === "slow_down") { - // Increase polling interval - interval += 5000; - continue; - } else if (data.error === "expired_token") { - spinner.fail("Device code expired. Please try again."); - throw new Error("Device code expired"); - } else if (data.error === "access_denied") { - spinner.fail("Access denied by user."); - throw new Error("Access denied"); - } else { - spinner.fail("Failed to get access token."); - throw new Error(data.error_description || data.error); - } - } - } - - /** - * Get Copilot token using GitHub access token - */ - async getCopilotToken(accessToken) { - const response = await fetch(`${GITHUB_CONFIG.copilotTokenUrl}`, { - headers: { - Authorization: `Bearer ${accessToken}`, // GitHub API typically uses Bearer - Accept: "application/json", - "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, - "User-Agent": GITHUB_CONFIG.userAgent, - }, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Failed to get Copilot token: ${error}`); - } - - return await response.json(); - } - - /** - * Get user info using GitHub access token - */ - async getUserInfo(accessToken) { - const response = await fetch(`${GITHUB_CONFIG.userInfoUrl}`, { - headers: { - Authorization: `Bearer ${accessToken}`, // GitHub API typically uses Bearer - Accept: "application/json", - "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, - "User-Agent": GITHUB_CONFIG.userAgent, - }, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Failed to get user info: ${error}`); - } - - return await response.json(); - } - - /** - * Complete GitHub Copilot authentication flow - */ - async authenticate(): Promise { - try { - // Get device code - const deviceResponse = await this.getDeviceCode(); - - // Poll for access token - const tokenResponse = await this.pollAccessToken( - deviceResponse.device_code, - deviceResponse.verification_uri, - deviceResponse.user_code - ); - - // Get Copilot token - const copilotToken = await this.getCopilotToken(tokenResponse.access_token); - - // Get user info - const userInfo = await this.getUserInfo(tokenResponse.access_token); - - console.log(`\nāœ… Successfully authenticated as ${userInfo.login}`); - - return { - accessToken: tokenResponse.access_token, - copilotToken: copilotToken.token, - refreshToken: null, // GitHub device flow doesn't return refresh token - expiresIn: copilotToken.expires_at, - userInfo: { - id: userInfo.id, - login: userInfo.login, - name: userInfo.name, - email: userInfo.email, - }, - copilotTokenInfo: copilotToken, - }; - } catch (error) { - throw new Error(`GitHub authentication failed: ${error.message}`); - } - } - - /** - * Connect to server with GitHub credentials - */ - async connect() { - try { - // Authenticate with GitHub - const authResult = await this.authenticate(); - - // Send credentials to server - const { server, token, userId } = await import("../config/index").then((m) => - m.getServerCredentials() - ); - const spinner = (await import("../utils/ui")).spinner("Connecting to server...").start(); - - const response = await fetch(`${server}/api/cli/providers/github`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "X-User-Id": userId, - }, - body: JSON.stringify({ - accessToken: authResult.accessToken, - copilotToken: authResult.copilotToken, - userInfo: authResult.userInfo, - copilotTokenInfo: authResult.copilotTokenInfo, - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || "Failed to connect to server"); - } - - spinner.succeed("GitHub Copilot connected successfully!"); - console.log(`\nConnected as: ${authResult.userInfo.login}`); - } catch (error) { - const { error: showError } = await import("../utils/ui"); - showError(`GitHub connection failed: ${error.message}`); - throw error; - } - } -} diff --git a/src/lib/oauth/services/index.ts b/src/lib/oauth/services/index.ts deleted file mode 100644 index 6e0271e396..0000000000 --- a/src/lib/oauth/services/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Export all services - */ - -export { OAuthService } from "./oauth"; -export { ClaudeService } from "./claude"; -export { CodexService } from "./codex"; -export { QwenService } from "./qwen"; -export { QoderService } from "./qoder"; -export { AntigravityService } from "./antigravity"; -export { OpenAIService } from "./openai"; -export { GitHubService } from "./github"; -export { KiroService } from "./kiro"; -export { CursorService } from "./cursor"; diff --git a/src/lib/oauth/services/oauth.ts b/src/lib/oauth/services/oauth.ts deleted file mode 100644 index b53864ae0e..0000000000 --- a/src/lib/oauth/services/oauth.ts +++ /dev/null @@ -1,171 +0,0 @@ -import open from "open"; -import { startLocalServer } from "../utils/server"; -import { generatePKCE } from "../utils/pkce"; -import { spinner as createSpinner } from "../utils/ui"; -import { OAUTH_TIMEOUT } from "../constants/oauth"; - -/** - * Generic OAuth Authorization Code Flow with PKCE - */ -export class OAuthService { - config: any; - - constructor(config: any) { - this.config = config; - } - - /** - * Build authorization URL - */ - buildAuthUrl( - redirectUri: string, - state: string, - codeChallenge: string, - extraParams: Record = {} - ) { - const params = new URLSearchParams({ - client_id: this.config.clientId, - response_type: "code", - redirect_uri: redirectUri, - state: state, - code_challenge: codeChallenge, - code_challenge_method: this.config.codeChallengeMethod, - ...extraParams, - }); - - return `${this.config.authorizeUrl}?${params.toString()}`; - } - - /** - * Start local server and wait for callback - */ - async startAuthFlow(authUrl: string | null, providerName: string) { - const spinner = createSpinner("Starting local server...").start(); - - // Start local server for callback - let callbackParams: any = null; - const { port, close } = await startLocalServer((params) => { - callbackParams = params; - }); - - const redirectUri = `http://localhost:${port}/callback`; - spinner.succeed(`Local server started on port ${port}`); - - return { - redirectUri, - port, - close, - waitForCallback: async () => { - spinner.start(`Waiting for ${providerName} authorization...`); - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Authentication timeout (5 minutes)")); - }, OAUTH_TIMEOUT); - - const checkInterval = setInterval(() => { - if (callbackParams) { - clearInterval(checkInterval); - clearTimeout(timeout); - resolve(undefined); - } - }, 100); - }); - - spinner.stop(); - close(); - - if (callbackParams.error) { - throw new Error(callbackParams.error_description || callbackParams.error); - } - - if (!callbackParams.code) { - throw new Error("No authorization code received"); - } - - return callbackParams; - }, - }; - } - - /** - * Exchange authorization code for tokens - */ - async exchangeCode( - code: string, - redirectUri: string, - codeVerifier: string, - contentType = "application/x-www-form-urlencoded" - ) { - const body = - contentType === "application/json" - ? JSON.stringify({ - grant_type: "authorization_code", - client_id: this.config.clientId, - code: code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }) - : new URLSearchParams({ - grant_type: "authorization_code", - client_id: this.config.clientId, - code: code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }); - - const response = await fetch(this.config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": contentType, - Accept: "application/json", - }, - body: body, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - } - - /** - * Complete OAuth flow - */ - async authenticate( - providerName: string, - buildAuthUrlFn: (redirectUri: string, state: string, codeChallenge: string) => string - ) { - // Generate PKCE - const { codeVerifier, codeChallenge, state } = generatePKCE(); - - // Start local server and get redirect URI - const { redirectUri, waitForCallback } = await this.startAuthFlow(null, providerName); - - // Build authorization URL - const authUrl = buildAuthUrlFn(redirectUri, state, codeChallenge); - - console.log(`\nOpening browser for ${providerName} authentication...`); - console.log(`If browser doesn't open, visit:\n${authUrl}\n`); - - // Open browser - await open(authUrl); - - // Wait for callback - const callbackParams = await waitForCallback(); - - // Validate state - if (callbackParams.state !== state) { - throw new Error("Invalid state parameter"); - } - - return { - code: callbackParams.code, - state: callbackParams.state, - codeVerifier, - redirectUri, - }; - } -} diff --git a/src/lib/oauth/services/openai.ts b/src/lib/oauth/services/openai.ts deleted file mode 100644 index 080b2c5b4e..0000000000 --- a/src/lib/oauth/services/openai.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { OAuthService } from "./oauth"; -import { OPENAI_CONFIG } from "../constants/oauth"; -import { getServerCredentials } from "../config/index"; -import { spinner as createSpinner } from "../utils/ui"; - -/** - * OpenAI OAuth Service (Native) - * Uses Authorization Code Flow with PKCE (similar to Codex) - */ -export class OpenAIService extends OAuthService { - constructor() { - super(OPENAI_CONFIG); - } - - /** - * Build OpenAI authorization URL - */ - buildOpenAIAuthUrl(redirectUri, state, codeChallenge) { - const params = new URLSearchParams({ - client_id: OPENAI_CONFIG.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: OPENAI_CONFIG.scope, - state: state, - code_challenge: codeChallenge, - code_challenge_method: OPENAI_CONFIG.codeChallengeMethod, - ...OPENAI_CONFIG.extraParams, - }); - - return `${OPENAI_CONFIG.authorizeUrl}?${params.toString()}`; - } - - /** - * Exchange OpenAI authorization code for tokens - */ - async exchangeOpenAICode(code, redirectUri, codeVerifier) { - const response = await fetch(OPENAI_CONFIG.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: OPENAI_CONFIG.clientId, - code: code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - } - - /** - * Save OpenAI tokens to server - */ - async saveTokens(tokens) { - const { server, token, userId } = getServerCredentials(); - - const response = await fetch(`${server}/api/cli/providers/openai`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "X-User-Id": userId, - }, - body: JSON.stringify({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - idToken: tokens.id_token, - scope: tokens.scope, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to save tokens"); - } - - return await response.json(); - } - - /** - * Complete OpenAI OAuth flow - */ - async connect() { - const spinner = createSpinner("Starting OpenAI OAuth...").start(); - - try { - spinner.text = "Starting local server..."; - - // Authenticate and get authorization code - const { code, codeVerifier, redirectUri } = await this.authenticate( - "OpenAI", - this.buildOpenAIAuthUrl.bind(this) - ); - - spinner.start("Exchanging code for tokens..."); - - // Exchange code for tokens - const tokens = await this.exchangeOpenAICode(code, redirectUri, codeVerifier); - - spinner.text = "Saving tokens to server..."; - - // Save tokens to server - await this.saveTokens(tokens); - - spinner.succeed("OpenAI connected successfully!"); - return true; - } catch (error) { - spinner.fail(`Failed: ${error.message}`); - throw error; - } - } -} diff --git a/src/lib/oauth/services/qoder.ts b/src/lib/oauth/services/qoder.ts deleted file mode 100644 index 2916f47908..0000000000 --- a/src/lib/oauth/services/qoder.ts +++ /dev/null @@ -1,221 +0,0 @@ -import crypto from "crypto"; -import open from "open"; -import { QODER_CONFIG } from "../constants/oauth"; -import { getServerCredentials } from "../config/index"; -import { startLocalServer } from "../utils/server"; -import { spinner as createSpinner } from "../utils/ui"; - -/** - * Qoder OAuth Service - * Uses Authorization Code flow with Basic Auth - */ -export class QoderService { - config: any; - - constructor() { - this.config = QODER_CONFIG; - } - - /** - * Build Qoder authorization URL - */ - buildAuthUrl(redirectUri: string, state: string) { - if (!this.config?.enabled || !this.config?.authorizeUrl) { - throw new Error( - "Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token." - ); - } - - const params = new URLSearchParams({ - loginMethod: this.config.extraParams.loginMethod, - type: this.config.extraParams.type, - redirect: redirectUri, - state: state, - client_id: this.config.clientId, - }); - - return `${this.config.authorizeUrl}?${params.toString()}`; - } - - /** - * Exchange authorization code for tokens - */ - async exchangeCode(code: string, redirectUri: string) { - if (!this.config?.enabled || !this.config?.tokenUrl) { - throw new Error( - "Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token." - ); - } - - // Create Basic Auth header - const basicAuth = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString( - "base64" - ); - - const response = await fetch(this.config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Authorization: `Basic ${basicAuth}`, - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code: code, - redirect_uri: redirectUri, - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - } - - /** - * Get user info from Qoder - */ - async getUserInfo(accessToken: string) { - if (!this.config?.enabled || !this.config?.userInfoUrl) { - throw new Error( - "Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token." - ); - } - - const response = await fetch( - `${this.config.userInfoUrl}?accessToken=${encodeURIComponent(accessToken)}`, - { - headers: { - Accept: "application/json", - }, - } - ); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Failed to get user info: ${error}`); - } - - const result = await response.json(); - - if (!result.success) { - throw new Error("Failed to get user info"); - } - - return result.data; - } - - /** - * Save Qoder tokens to server - */ - async saveTokens(tokens: any, userInfo: any) { - const { server, token, userId } = getServerCredentials(); - - const response = await fetch(`${server}/api/cli/providers/qoder`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "X-User-Id": userId, - }, - body: JSON.stringify({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - apiKey: userInfo.apiKey, - email: userInfo.email || userInfo.phone, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to save tokens"); - } - - return await response.json(); - } - - /** - * Complete Qoder OAuth flow - */ - async connect() { - const spinner = createSpinner("Starting Qoder OAuth...").start(); - - try { - spinner.text = "Starting local server..."; - - // Start local server for callback - let callbackParams: any = null; - const { port, close } = await startLocalServer((params) => { - callbackParams = params; - }); - - const redirectUri = `http://localhost:${port}/callback`; - spinner.succeed(`Local server started on port ${port}`); - - // Generate state - const state = crypto.randomBytes(32).toString("base64url"); - - // Build authorization URL - const authUrl = this.buildAuthUrl(redirectUri, state); - - console.log("\nOpening browser for Qoder authentication..."); - console.log(`If browser doesn't open, visit:\n${authUrl}\n`); - - // Open browser - await open(authUrl); - - // Wait for callback - spinner.start("Waiting for Qoder authorization..."); - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Authentication timeout (5 minutes)")); - }, 300000); - - const checkInterval = setInterval(() => { - if (callbackParams) { - clearInterval(checkInterval); - clearTimeout(timeout); - resolve(undefined); - } - }, 100); - }); - - close(); - - if (callbackParams.error) { - throw new Error(callbackParams.error_description || callbackParams.error); - } - - if (!callbackParams.code) { - throw new Error("No authorization code received"); - } - - spinner.start("Exchanging code for tokens..."); - - // Exchange code for tokens - const tokens = await this.exchangeCode(callbackParams.code, redirectUri); - - spinner.text = "Fetching user info..."; - - // Get user info (includes API key) - const userInfo = await this.getUserInfo(tokens.access_token); - - spinner.text = "Saving tokens to server..."; - - // Save tokens to server - await this.saveTokens(tokens, userInfo); - - spinner.succeed(`Qoder connected successfully! (${userInfo.email || userInfo.phone})`); - return true; - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); - throw error; - } - } -} diff --git a/src/lib/oauth/services/qwen.ts b/src/lib/oauth/services/qwen.ts deleted file mode 100644 index 699952dcf8..0000000000 --- a/src/lib/oauth/services/qwen.ts +++ /dev/null @@ -1,173 +0,0 @@ -import open from "open"; -import { randomUUID } from "node:crypto"; -import { QWEN_CONFIG } from "../constants/oauth"; -import { getServerCredentials } from "../config/index"; -import { generatePKCE } from "../utils/pkce"; -import { spinner as createSpinner } from "../utils/ui"; - -/** - * Qwen OAuth Service - * Uses Device Code Flow with PKCE - */ -export class QwenService { - config: any; - - constructor() { - this.config = QWEN_CONFIG; - } - - /** - * Request device code - */ - async requestDeviceCode(codeChallenge: string) { - const response = await fetch(this.config.deviceCodeUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - "x-request-id": randomUUID(), - }, - body: new URLSearchParams({ - client_id: this.config.clientId, - scope: this.config.scope, - code_challenge: codeChallenge, - code_challenge_method: this.config.codeChallengeMethod, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Device code request failed: ${error}`); - } - - return await response.json(); - } - - /** - * Poll for token - */ - async pollForToken(deviceCode: string, codeVerifier: string, interval = 5) { - const maxAttempts = 60; // 5 minutes - const pollInterval = interval * 1000; - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - await new Promise((r) => setTimeout(r, pollInterval)); - - const response = await fetch(this.config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - client_id: this.config.clientId, - device_code: deviceCode, - code_verifier: codeVerifier, - }), - }); - - if (response.ok) { - return await response.json(); - } - - const error = await response.json(); - - if (error.error === "authorization_pending") { - continue; - } else if (error.error === "slow_down") { - await new Promise((r) => setTimeout(r, 5000)); - continue; - } else if (error.error === "expired_token") { - throw new Error("Device code expired"); - } else if (error.error === "access_denied") { - throw new Error("Access denied"); - } else { - throw new Error(error.error_description || error.error); - } - } - - throw new Error("Authorization timeout"); - } - - /** - * Save Qwen tokens to server - */ - async saveTokens(tokens: any) { - const { server, token, userId } = getServerCredentials(); - - const response = await fetch(`${server}/api/cli/providers/qwen`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "X-User-Id": userId, - }, - body: JSON.stringify({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - resourceUrl: tokens.resource_url, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to save tokens"); - } - - return await response.json(); - } - - /** - * Complete Qwen OAuth flow - */ - async connect() { - const spinner = createSpinner("Starting Qwen OAuth...").start(); - - try { - spinner.text = "Generating PKCE..."; - - // Generate PKCE - const { codeVerifier, codeChallenge } = generatePKCE(); - - spinner.text = "Requesting device code..."; - - // Request device code - const deviceData = await this.requestDeviceCode(codeChallenge); - - spinner.stop(); - - console.log("\nšŸ“‹ Please visit the following URL and enter the code:\n"); - console.log(` ${deviceData.verification_uri}\n`); - console.log(` Code: ${deviceData.user_code}\n`); - - // Open browser - if (deviceData.verification_uri_complete) { - await open(deviceData.verification_uri_complete); - } else { - await open(deviceData.verification_uri); - } - - spinner.start("Waiting for authorization..."); - - // Poll for token - const tokens = await this.pollForToken( - deviceData.device_code, - codeVerifier, - deviceData.interval || 5 - ); - - spinner.text = "Saving tokens to server..."; - - // Save tokens to server - await this.saveTokens(tokens); - - spinner.succeed("Qwen connected successfully!"); - return true; - } catch (error: any) { - spinner.fail(`Failed: ${error.message}`); - throw error; - } - } -} diff --git a/tests/unit/oauth-legacy-services-removed.test.ts b/tests/unit/oauth-legacy-services-removed.test.ts new file mode 100644 index 0000000000..1b044368de --- /dev/null +++ b/tests/unit/oauth-legacy-services-removed.test.ts @@ -0,0 +1,62 @@ +/** + * Guard for the legacy OAuth service-class removal (salvage of the closed PR #5039, + * "Remove legacy OAuth service classes"; gaps v3.8.42 — T10 / Onda 5 item 5.7). + * + * The `src/lib/oauth/services/` folder is a superseded surface: the live OAuth flow runs + * through `src/lib/oauth/providers.ts` + `src/lib/oauth/providers/` (wired into the generic + * `src/app/api/oauth/[provider]/[action]/route.ts`). The old per-provider service-class + * hierarchy (`class *Service extends OAuthService`) plus its barrel had ZERO production or + * test references and were removed. Only three files survive because routes still import them + * directly by path (never via the deleted barrel): + * - `kiro.ts` → src/app/api/oauth/kiro/{import,auto-import,social-exchange}/route.ts + * - `cursor.ts` → src/app/api/oauth/cursor/import/route.ts + * - `codexImport.ts` (utility fns, not a service class) → src/app/api/oauth/codex/import/route.ts + * + * This guard pins the removal so the dead classes are not re-introduced, and asserts the + * live files remain. Real safety net is that typecheck/build/tests stay green: had any deleted + * class been referenced, `typecheck:core` would fail. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SERVICES = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../src/lib/oauth/services" +); + +const REMOVED_DEAD = [ + "oauth", // base class OAuthService + "openai", // OpenAIService extends OAuthService + "github", // GitHubService extends OAuthService + "claude", // ClaudeService extends OAuthService + "codex", // CodexService extends OAuthService + "antigravity", // AntigravityService + "qwen", // QwenService + "qoder", // QoderService + "index", // the barrel re-exporting all of the above +]; + +const KEPT_LIVE = ["kiro", "cursor", "codexImport"]; + +test("legacy OAuth service-class files stay removed (dead code — PR #5039)", () => { + for (const f of REMOVED_DEAD) { + assert.equal( + fs.existsSync(path.join(SERVICES, `${f}.ts`)), + false, + `src/lib/oauth/services/${f}.ts is dead legacy code (0 refs) and must not be re-added` + ); + } +}); + +test("live OAuth service files remain (imported directly by routes)", () => { + for (const f of KEPT_LIVE) { + assert.equal( + fs.existsSync(path.join(SERVICES, `${f}.ts`)), + true, + `src/lib/oauth/services/${f}.ts is still imported by routes and must remain` + ); + } +});