Initial commit

This commit is contained in:
decolua
2026-01-05 09:58:59 +07:00
commit 3857598de4
159 changed files with 14537 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
/**
* OAuth Configuration Constants
*/
// Claude OAuth Configuration (Authorization Code Flow with PKCE)
export const CLAUDE_CONFIG = {
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
authorizeUrl: "https://claude.ai/oauth/authorize",
tokenUrl: "https://console.anthropic.com/v1/oauth/token",
scopes: ["org:create_api_key", "user:profile", "user:inference"],
codeChallengeMethod: "S256",
};
// Codex (OpenAI) OAuth Configuration (Authorization Code Flow with PKCE)
export const CODEX_CONFIG = {
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
authorizeUrl: "https://auth.openai.com/oauth/authorize",
tokenUrl: "https://auth.openai.com/oauth/token",
scope: "openid profile email offline_access",
codeChallengeMethod: "S256",
// Additional OpenAI-specific params
extraParams: {
id_token_add_organizations: "true",
codex_cli_simplified_flow: "true",
originator: "codex_cli_rs",
},
};
// Gemini (Google) OAuth Configuration (Standard OAuth2)
export const GEMINI_CONFIG = {
clientId: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
clientSecret: "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",
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo",
scopes: [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
],
};
// Qwen OAuth Configuration (Device Code Flow with PKCE)
export const QWEN_CONFIG = {
clientId: "f0304373b74a44d2b584a3fb70ca9e56",
deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code",
tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token",
scope: "openid profile email model.completion",
codeChallengeMethod: "S256",
};
// iFlow OAuth Configuration (Authorization Code)
export const IFLOW_CONFIG = {
clientId: "10009311001",
clientSecret: "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW",
authorizeUrl: "https://iflow.cn/oauth",
tokenUrl: "https://iflow.cn/oauth/token",
userInfoUrl: "https://iflow.cn/api/oauth/getUserInfo",
extraParams: {
loginMethod: "phone",
type: "phone",
},
};
// Antigravity OAuth Configuration (Standard OAuth2 with Google)
export const ANTIGRAVITY_CONFIG = {
clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
clientSecret: "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",
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo",
scopes: [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
],
// Antigravity specific
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
loadCodeAssistUserAgent: "google-api-nodejs-client/9.15.1",
loadCodeAssistApiClient: "google-cloud-sdk vscode_cloudshelleditor/0.1",
loadCodeAssistClientMetadata: `{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}`,
};
// OpenAI OAuth Configuration (Authorization Code Flow with PKCE)
export const OPENAI_CONFIG = {
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
authorizeUrl: "https://auth.openai.com/oauth/authorize",
tokenUrl: "https://auth.openai.com/oauth/token",
scope: "openid profile email offline_access",
codeChallengeMethod: "S256",
extraParams: {
id_token_add_organizations: "true",
originator: "openai_native",
},
};
// GitHub Copilot OAuth Configuration (Device Code Flow)
export const GITHUB_CONFIG = {
clientId: "Iv1.b507a08c87ecfe98",
deviceCodeUrl: "https://github.com/login/device/code",
tokenUrl: "https://github.com/login/oauth/access_token",
userInfoUrl: "https://api.github.com/user",
scopes: "read:user",
apiVersion: "2022-11-28", // Updated to supported version
copilotTokenUrl: "https://api.github.com/copilot_internal/v2/token",
userAgent: "GitHubCopilotChat/0.26.7",
editorVersion: "vscode/1.85.0",
editorPluginVersion: "copilot-chat/0.26.7",
};
// OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000;
// Provider list
export const PROVIDERS = {
CLAUDE: "claude",
CODEX: "codex",
GEMINI: "gemini-cli",
QWEN: "qwen",
IFLOW: "iflow",
ANTIGRAVITY: "antigravity",
OPENAI: "openai",
GITHUB: "github",
};

619
src/lib/oauth/providers.js Normal file
View File

@@ -0,0 +1,619 @@
/**
* OAuth Provider Configurations and Handlers
* Centralized DRY approach for all OAuth providers
*/
import { generatePKCE, generateState } from "./utils/pkce";
import {
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CONFIG,
QWEN_CONFIG,
IFLOW_CONFIG,
ANTIGRAVITY_CONFIG,
GITHUB_CONFIG,
} from "./constants/oauth";
// Provider configurations
const PROVIDERS = {
claude: {
config: CLAUDE_CONFIG,
flowType: "authorization_code_pkce",
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
const params = new URLSearchParams({
code: "true",
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
state: state,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, 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] || "";
}
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
code: authCode,
state: codeState || state,
grant_type: "authorization_code",
client_id: config.clientId,
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();
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
}),
},
codex: {
config: CODEX_CONFIG,
flowType: "authorization_code_pkce",
fixedPort: 1455,
callbackPath: "/auth/callback",
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
const params = {
response_type: "code",
client_id: config.clientId,
redirect_uri: redirectUri,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
...config.extraParams,
state: state,
};
const queryString = Object.entries(params)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join("&");
return `${config.authorizeUrl}?${queryString}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: 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();
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
expiresIn: tokens.expires_in,
}),
},
"gemini-cli": {
config: GEMINI_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
state: state,
access_type: "offline",
prompt: "consent",
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
client_secret: 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();
},
postExchange: async (tokens) => {
// Fetch user info
const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
// Fetch project ID
let projectId = "";
try {
const projectRes = await fetch(
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" },
}),
}
);
if (projectRes.ok) {
const data = await projectRes.json();
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
}
} catch (e) {
console.log("Failed to fetch project ID:", e);
}
return { userInfo, projectId };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
email: extra?.userInfo?.email,
projectId: extra?.projectId,
}),
},
antigravity: {
config: ANTIGRAVITY_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
state: state,
access_type: "offline",
prompt: "consent",
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
client_secret: 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();
},
postExchange: async (tokens) => {
// Fetch user info
const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
// Fetch project ID from loadCodeAssist
let projectId = "";
try {
const projectRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
"User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent,
"X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient,
"Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata,
},
body: JSON.stringify({
metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" },
}),
});
if (projectRes.ok) {
const data = await projectRes.json();
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
}
} catch (e) {
console.log("Failed to fetch project ID:", e);
}
return { userInfo, projectId };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
email: extra?.userInfo?.email,
projectId: extra?.projectId,
}),
},
iflow: {
config: IFLOW_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
loginMethod: config.extraParams.loginMethod,
type: config.extraParams.type,
redirect: redirectUri,
state: state,
client_id: config.clientId,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
// Create Basic Auth header
const basicAuth = Buffer.from(
`${config.clientId}:${config.clientSecret}`
).toString("base64");
const response = await fetch(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: config.clientId,
client_secret: config.clientSecret,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
// Fetch user info
const userInfoRes = await fetch(
`${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
{
headers: {
Accept: "application/json",
},
}
);
const result = userInfoRes.ok ? await userInfoRes.json() : {};
const userInfo = result.success ? result.data : {};
return { userInfo };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
apiKey: extra?.userInfo?.apiKey,
email: extra?.userInfo?.email || extra?.userInfo?.phone,
displayName: extra?.userInfo?.nickname || extra?.userInfo?.name,
}),
},
qwen: {
config: QWEN_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config, codeChallenge) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode, codeVerifier) => {
const response = await fetch(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: config.clientId,
device_code: deviceCode,
code_verifier: codeVerifier,
}),
});
return {
ok: response.ok,
data: await response.json(),
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: { resourceUrl: tokens.resource_url },
}),
},
github: {
config: GITHUB_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scopes,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
// Handle response properly - if not ok, try to get error as text first
let data;
try {
data = await response.json();
} catch (e) {
// If response is not JSON, get as text
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
return {
ok: response.ok,
data: data,
};
},
postExchange: async (tokens) => {
// Get Copilot token using GitHub access token
const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
"User-Agent": GITHUB_CONFIG.userAgent,
},
});
const copilotToken = copilotRes.ok ? await copilotRes.json() : {};
// Get user info from GitHub
const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
"User-Agent": GITHUB_CONFIG.userAgent,
},
});
const userInfo = userRes.ok ? await userRes.json() : {};
return { copilotToken, userInfo };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: {
copilotToken: extra?.copilotToken?.token,
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
githubUserId: extra?.userInfo?.id,
githubLogin: extra?.userInfo?.login,
githubName: extra?.userInfo?.name,
githubEmail: extra?.userInfo?.email,
},
}),
},
};
/**
* Get provider handler
*/
export function getProvider(name) {
const provider = PROVIDERS[name];
if (!provider) {
throw new Error(`Unknown provider: ${name}`);
}
return provider;
}
/**
* Get all provider names
*/
export function getProviderNames() {
return Object.keys(PROVIDERS);
}
/**
* Generate auth data for a provider
*/
export function generateAuthData(providerName, redirectUri) {
const provider = getProvider(providerName);
const { codeVerifier, codeChallenge, state } = generatePKCE();
let authUrl;
if (provider.flowType === "device_code") {
// Device code flow doesn't have auth URL upfront
authUrl = null;
} else if (provider.flowType === "authorization_code_pkce") {
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge);
} else {
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state);
}
return {
authUrl,
state,
codeVerifier,
codeChallenge,
redirectUri,
flowType: provider.flowType,
fixedPort: provider.fixedPort,
callbackPath: provider.callbackPath || "/callback",
};
}
/**
* Exchange code for tokens
*/
export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state) {
const provider = getProvider(providerName);
const tokens = await provider.exchangeToken(provider.config, code, redirectUri, codeVerifier, state);
let extra = null;
if (provider.postExchange) {
extra = await provider.postExchange(tokens);
}
return provider.mapTokens(tokens, extra);
}
/**
* Request device code (for device_code flow)
*/
export async function requestDeviceCode(providerName, codeChallenge) {
const provider = getProvider(providerName);
if (provider.flowType !== "device_code") {
throw new Error(`Provider ${providerName} does not support device code flow`);
}
return await provider.requestDeviceCode(provider.config, codeChallenge);
}
/**
* Poll for token (for device_code flow)
*/
export async function pollForToken(providerName, deviceCode, codeVerifier) {
const provider = getProvider(providerName);
if (provider.flowType !== "device_code") {
throw new Error(`Provider ${providerName} does not support device code flow`);
}
const result = await provider.pollToken(provider.config, deviceCode, codeVerifier);
if (result.ok) {
// For device code flows, success is only when we have an access token
if (result.data.access_token) {
// Call postExchange to get additional data (copilotToken, userInfo, etc.)
let extra = null;
if (provider.postExchange) {
extra = await provider.postExchange(result.data);
}
return { success: true, tokens: provider.mapTokens(result.data, extra) };
} else {
// Check if it's still pending authorization
if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') {
// This is not a failure, just still waiting
return {
success: false,
error: result.data.error,
errorDescription: result.data.error_description || result.data.message,
pending: result.data.error === 'authorization_pending'
};
} else {
// Actual error
return {
success: false,
error: result.data.error || 'no_access_token',
errorDescription: result.data.error_description || result.data.message || 'No access token received'
};
}
}
}
return { success: false, error: result.data.error, errorDescription: result.data.error_description };
}

View File

@@ -0,0 +1,239 @@
import crypto from "crypto";
import open from "open";
import { ANTIGRAVITY_CONFIG } from "../constants/oauth.js";
import { getServerCredentials } from "../config/index.js";
import { startLocalServer } from "../utils/server.js";
import { spinner as createSpinner } from "../utils/ui.js";
/**
* Antigravity OAuth Service
* Uses standard OAuth2 Authorization Code flow (similar to Gemini)
*/
export class AntigravityService {
constructor() {
this.config = ANTIGRAVITY_CONFIG;
}
/**
* Build Antigravity authorization URL
*/
buildAuthUrl(redirectUri, state) {
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, redirectUri) {
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: "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) {
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();
}
/**
* Fetch Project ID from loadCodeAssist API
*/
async fetchProjectId(accessToken) {
const loadReqBody = {
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
};
const response = await fetch(this.config.loadCodeAssistEndpoint, {
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": this.config.loadCodeAssistUserAgent,
"X-Goog-Api-Client": this.config.loadCodeAssistApiClient,
"Client-Metadata": this.config.loadCodeAssistClientMetadata,
},
body: JSON.stringify(loadReqBody),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch project ID: ${errorText}`);
}
const loadResp = await response.json();
let projectId = loadResp.cloudaicompanionProject;
if (typeof projectId === 'object' && projectId !== null && projectId.id) {
projectId = projectId.id;
}
if (!projectId) {
throw new Error("No cloudaicompanionProject found in response");
}
return projectId;
}
/**
* Save Antigravity tokens to server
*/
async saveTokens(tokens, userInfo, projectId) {
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 = 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();
}
}, 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 = "Fetching Google Cloud Project ID...";
// Fetch Project ID
const projectId = await this.fetchProjectId(tokens.access_token);
spinner.text = "Saving tokens to server...";
// Save tokens to server
await this.saveTokens(tokens, userInfo, projectId);
spinner.succeed(`Antigravity connected successfully! (${userInfo.email}, Project: ${projectId})`);
return true;
} catch (error) {
spinner.fail(`Failed: ${error.message}`);
throw error;
}
}
}

View File

@@ -0,0 +1,136 @@
import { OAuthService } from "./oauth.js";
import { CLAUDE_CONFIG } from "../constants/oauth.js";
import { getServerCredentials } from "../config/index.js";
import { spinner as createSpinner } from "../utils/ui.js";
/**
* 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

@@ -0,0 +1,145 @@
import open from "open";
import { OAuthService } from "./oauth.js";
import { CODEX_CONFIG } from "../constants/oauth.js";
import { getServerCredentials } from "../config/index.js";
import { startLocalServer } from "../utils/server.js";
import { generatePKCE } from "../utils/pkce.js";
import { spinner as createSpinner } from "../utils/ui.js";
/**
* 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();
}
}, 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

@@ -0,0 +1,247 @@
import crypto from "crypto";
import open from "open";
import { GEMINI_CONFIG } from "../constants/oauth.js";
import { getServerCredentials } from "../config/index.js";
import { startLocalServer } from "../utils/server.js";
import { spinner as createSpinner } from "../utils/ui.js";
/**
* Gemini CLI (Google Cloud Code Assist) OAuth Service
* Uses standard OAuth2 Authorization Code flow (no PKCE)
*/
export class GeminiCLIService {
constructor() {
this.config = GEMINI_CONFIG;
}
/**
* Build Gemini CLI authorization URL
*/
buildAuthUrl(redirectUri, state) {
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, redirectUri) {
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: "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();
}
/**
* Fetch project ID from Google Cloud Code Assist
*/
async fetchProjectId(accessToken) {
const response = await fetch(
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
{
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI"
})
},
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI"
}
})
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to fetch project ID: ${error}`);
}
const data = await response.json();
// Extract project ID
let projectId = "";
if (typeof data.cloudaicompanionProject === "string") {
projectId = data.cloudaicompanionProject.trim();
} else if (data.cloudaicompanionProject?.id) {
projectId = data.cloudaicompanionProject.id.trim();
}
if (!projectId) {
throw new Error("No project ID found in response");
}
return projectId;
}
/**
* Get user info from Google
*/
async getUserInfo(accessToken) {
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();
}
/**
* Save Gemini CLI tokens to server
*/
async saveTokens(tokens, userInfo, projectId) {
const { server, token, userId } = getServerCredentials();
const response = await fetch(`${server}/api/cli/providers/gemini-cli`, {
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,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to save tokens");
}
return await response.json();
}
/**
* Complete Gemini OAuth flow
*/
async connect() {
const spinner = createSpinner("Starting Gemini OAuth...").start();
try {
spinner.text = "Starting local server...";
// Start local server for callback
let callbackParams = 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 Google authentication...");
console.log(`If browser doesn't open, visit:\n${authUrl}\n`);
// Open browser
await open(authUrl);
// Wait for callback
spinner.start("Waiting for Google 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();
}
}, 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 = "Fetching project ID...";
// Fetch project ID
const projectId = await this.fetchProjectId(tokens.access_token);
spinner.text = "Saving tokens to server...";
// Save tokens to server
await this.saveTokens(tokens, userInfo, projectId);
spinner.succeed(`Gemini CLI connected successfully! (${userInfo.email}, Project: ${projectId})`);
return true;
} catch (error) {
spinner.fail(`Failed: ${error.message}`);
throw error;
}
}
}

View File

@@ -0,0 +1,225 @@
import { OAuthService } from "./oauth.js";
import { GITHUB_CONFIG } from "../constants/oauth.js";
import { spinner as createSpinner } from "../utils/ui.js";
/**
* 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() {
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.js").then(m => m.getServerCredentials());
const spinner = (await import("../utils/ui.js")).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.js");
showError(`GitHub connection failed: ${error.message}`);
throw error;
}
}
}

View File

@@ -0,0 +1,202 @@
import crypto from "crypto";
import open from "open";
import { IFLOW_CONFIG } from "../constants/oauth.js";
import { getServerCredentials } from "../config/index.js";
import { startLocalServer } from "../utils/server.js";
import { spinner as createSpinner } from "../utils/ui.js";
/**
* iFlow OAuth Service
* Uses Authorization Code flow with Basic Auth
*/
export class IFlowService {
constructor() {
this.config = IFLOW_CONFIG;
}
/**
* Build iFlow authorization URL
*/
buildAuthUrl(redirectUri, state) {
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, redirectUri) {
// 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 iFlow
*/
async getUserInfo(accessToken) {
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 iFlow tokens to server
*/
async saveTokens(tokens, userInfo) {
const { server, token, userId } = getServerCredentials();
const response = await fetch(`${server}/api/cli/providers/iflow`, {
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 iFlow OAuth flow
*/
async connect() {
const spinner = createSpinner("Starting iFlow OAuth...").start();
try {
spinner.text = "Starting local server...";
// Start local server for callback
let callbackParams = 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 iFlow authentication...");
console.log(`If browser doesn't open, visit:\n${authUrl}\n`);
// Open browser
await open(authUrl);
// Wait for callback
spinner.start("Waiting for iFlow 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();
}
}, 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(`iFlow connected successfully! (${userInfo.email || userInfo.phone})`);
return true;
} catch (error) {
spinner.fail(`Failed: ${error.message}`);
throw error;
}
}
}

View File

@@ -0,0 +1,14 @@
/**
* Export all services
*/
export { OAuthService } from "./oauth.js";
export { ClaudeService } from "./claude.js";
export { CodexService } from "./codex.js";
export { GeminiCLIService } from "./gemini.js";
export { QwenService } from "./qwen.js";
export { IFlowService } from "./iflow.js";
export { AntigravityService } from "./antigravity.js";
export { OpenAIService } from "./openai.js";
export { GitHubService } from "./github.js";

View File

@@ -0,0 +1,157 @@
import open from "open";
import { startLocalServer } from "../utils/server.js";
import { generatePKCE } from "../utils/pkce.js";
import { spinner as createSpinner } from "../utils/ui.js";
import { OAUTH_TIMEOUT } from "../constants/oauth.js";
/**
* Generic OAuth Authorization Code Flow with PKCE
*/
export class OAuthService {
constructor(config) {
this.config = config;
}
/**
* Build authorization URL
*/
buildAuthUrl(redirectUri, state, codeChallenge, extraParams = {}) {
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, providerName) {
const spinner = createSpinner("Starting local server...").start();
// Start local server for callback
let callbackParams = 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();
}
}, 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, redirectUri, codeVerifier, 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, buildAuthUrlFn) {
// 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

@@ -0,0 +1,123 @@
import { OAuthService } from "./oauth.js";
import { OPENAI_CONFIG } from "../constants/oauth.js";
import { getServerCredentials } from "../config/index.js";
import { spinner as createSpinner } from "../utils/ui.js";
/**
* 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

@@ -0,0 +1,170 @@
import open from "open";
import { QWEN_CONFIG } from "../constants/oauth.js";
import { getServerCredentials } from "../config/index.js";
import { generatePKCE } from "../utils/pkce.js";
import { spinner as createSpinner } from "../utils/ui.js";
/**
* Qwen OAuth Service
* Uses Device Code Flow with PKCE
*/
export class QwenService {
constructor() {
this.config = QWEN_CONFIG;
}
/**
* Request device code
*/
async requestDeviceCode(codeChallenge) {
const response = await fetch(this.config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
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, codeVerifier, 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) {
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) {
spinner.fail(`Failed: ${error.message}`);
throw error;
}
}
}

View File

@@ -0,0 +1,63 @@
import figlet from "figlet";
import gradient from "gradient-string";
import chalkAnimation from "chalk-animation";
/**
* Display banner
*/
export function showBanner() {
const banner = figlet.textSync("LLM Proxy", {
font: "ANSI Shadow",
horizontalLayout: "default",
verticalLayout: "default",
});
console.log("\n" + gradient.pastel.multiline(banner));
console.log(gradient.cristal(" 🚀 OAuth CLI for AI Providers\n"));
}
/**
* Display simple banner (no animation)
*/
export function showSimpleBanner() {
const banner = figlet.textSync("EP CLI", {
font: "Standard",
horizontalLayout: "default",
});
console.log(gradient.pastel.multiline(banner));
console.log(gradient.cristal(" OAuth CLI for AI Providers\n"));
}
/**
* Display success animation
*/
export async function showSuccess(message) {
return new Promise((resolve) => {
const animation = chalkAnimation.rainbow(`\n✨ ${message}\n`);
setTimeout(() => {
animation.stop();
resolve();
}, 1000);
});
}
/**
* Display loading animation
*/
export function showLoading(text) {
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let i = 0;
const interval = setInterval(() => {
process.stdout.write(`\r${frames[i]} ${text}`);
i = (i + 1) % frames.length;
}, 80);
return {
stop: () => {
clearInterval(interval);
process.stdout.write("\r");
},
};
}

View File

@@ -0,0 +1,38 @@
import crypto from "crypto";
/**
* Generate PKCE code verifier (43-128 characters)
*/
export function generateCodeVerifier() {
return crypto.randomBytes(32).toString("base64url");
}
/**
* Generate PKCE code challenge from verifier (S256 method)
*/
export function generateCodeChallenge(verifier) {
return crypto.createHash("sha256").update(verifier).digest("base64url");
}
/**
* Generate random state for CSRF protection
*/
export function generateState() {
return crypto.randomBytes(32).toString("base64url");
}
/**
* Generate complete PKCE pair
*/
export function generatePKCE() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
return {
codeVerifier,
codeChallenge,
state,
};
}

View File

@@ -0,0 +1,116 @@
import http from "http";
import { URL } from "url";
/**
* Start a local HTTP server to receive OAuth callback
* @param {Function} onCallback - Called with query params when callback received
* @param {number} fixedPort - Optional fixed port number (default: random)
* @returns {Promise<{server: http.Server, port: number, close: Function}>}
*/
export function startLocalServer(onCallback, fixedPort = null) {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://localhost`);
if (url.pathname === "/callback" || url.pathname === "/auth/callback") {
const params = Object.fromEntries(url.searchParams);
// Send success response to browser with auto-close attempt
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Authentication Successful</title>
<style>
body { font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
.container { text-align: center; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.success { color: #22c55e; font-size: 3rem; }
h1 { margin: 1rem 0; }
p { color: #666; }
#countdown { font-weight: bold; }
</style>
</head>
<body>
<div class="container">
<div class="success">&#10003;</div>
<h1>Authentication Successful</h1>
<p id="message">Closing in <span id="countdown">3</span> seconds...</p>
</div>
<script>
let count = 3;
const countdown = document.getElementById("countdown");
const message = document.getElementById("message");
const timer = setInterval(() => {
count--;
countdown.textContent = count;
if (count <= 0) {
clearInterval(timer);
window.close();
setTimeout(() => {
message.textContent = "Please close this tab manually.";
}, 500);
}
}, 1000);
</script>
</body>
</html>`);
// Call callback with params
onCallback(params);
} else {
res.writeHead(404);
res.end("Not found");
}
});
// Listen on fixed port or find available port
const portToUse = fixedPort || 0;
server.listen(portToUse, "127.0.0.1", () => {
const { port } = server.address();
resolve({
server,
port,
close: () => server.close(),
});
});
server.on("error", (err) => {
if (err.code === "EADDRINUSE" && fixedPort) {
reject(new Error(`Port ${fixedPort} is already in use. Please close other applications using this port.`));
} else {
reject(err);
}
});
});
}
/**
* Wait for callback with timeout
* @param {number} timeoutMs - Timeout in milliseconds
* @returns {Promise<Object>} - Callback params
*/
export function waitForCallback(timeoutMs = 300000) {
return new Promise((resolve, reject) => {
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
reject(new Error("Authentication timeout"));
}
}, timeoutMs);
const onCallback = (params) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
resolve(params);
}
};
// Return the callback function
resolve.__onCallback = onCallback;
});
}

48
src/lib/oauth/utils/ui.js Normal file
View File

@@ -0,0 +1,48 @@
import chalk from "chalk";
import ora from "ora";
/**
* UI Helper Functions
*/
export function success(message) {
console.log(chalk.green(`\n✓ ${message}\n`));
}
export function error(message) {
console.log(chalk.red(`\n✗ ${message}\n`));
}
export function info(message) {
console.log(chalk.blue(`\n${message}\n`));
}
export function warn(message) {
console.log(chalk.yellow(`\n⚠ ${message}\n`));
}
export function gray(message) {
console.log(chalk.gray(message));
}
export function spinner(text) {
return ora(text);
}
export function printSection(title) {
console.log(chalk.blue(`\n${title}\n`));
}
export function printKeyValue(key, value, isSuccess = false) {
const color = isSuccess ? chalk.green : chalk.gray;
console.log(color(` ${key}: ${value}`));
}
export function printList(items, isSuccess = false) {
const symbol = isSuccess ? "✓" : "✗";
const color = isSuccess ? chalk.green : chalk.gray;
items.forEach((item) => {
console.log(color(` ${symbol} ${item}`));
});
}