mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
committed by
GitHub
parent
f6100d67c7
commit
02e73c5440
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
62
tests/unit/oauth-legacy-services-removed.test.ts
Normal file
62
tests/unit/oauth-legacy-services-removed.test.ts
Normal 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`
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user