refactor(oauth): remove dead legacy OAuth service classes (#5838)

The src/lib/oauth/services/ service-class hierarchy is superseded — the live OAuth
flow runs through src/lib/oauth/providers.ts + providers/. The old per-provider
'class *Service extends OAuthService' implementations and their barrel had zero
production or test references. Removed oauth/openai/github/claude/codex/antigravity/
qwen/qoder + the index barrel (-1559 LOC). Kept kiro.ts, cursor.ts, codexImport.ts
(routes import them directly by path, never via the deleted barrel).

Proven safe by typecheck:core staying green (a live reference would fail the build)
+ a filesystem guard test pinning the removal. Salvage of closed PR #5039.
gaps v3.8.42 - T10 (5.7).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-02 00:25:19 -03:00
committed by GitHub
parent f6100d67c7
commit 02e73c5440
12 changed files with 65 additions and 1561 deletions

View File

@@ -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

View File

@@ -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.
*/

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<any> {
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;
}
}
}

View File

@@ -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";

View File

@@ -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<string, string> = {}
) {
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,
};
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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`
);
}
});