Initial commit
This commit is contained in:
126
src/lib/oauth/constants/oauth.js
Normal file
126
src/lib/oauth/constants/oauth.js
Normal 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
619
src/lib/oauth/providers.js
Normal 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 };
|
||||
}
|
||||
|
||||
239
src/lib/oauth/services/antigravity.js
Normal file
239
src/lib/oauth/services/antigravity.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
136
src/lib/oauth/services/claude.js
Normal file
136
src/lib/oauth/services/claude.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
145
src/lib/oauth/services/codex.js
Normal file
145
src/lib/oauth/services/codex.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
247
src/lib/oauth/services/gemini.js
Normal file
247
src/lib/oauth/services/gemini.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
225
src/lib/oauth/services/github.js
Normal file
225
src/lib/oauth/services/github.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
202
src/lib/oauth/services/iflow.js
Normal file
202
src/lib/oauth/services/iflow.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
src/lib/oauth/services/index.js
Normal file
14
src/lib/oauth/services/index.js
Normal 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";
|
||||
|
||||
157
src/lib/oauth/services/oauth.js
Normal file
157
src/lib/oauth/services/oauth.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
123
src/lib/oauth/services/openai.js
Normal file
123
src/lib/oauth/services/openai.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
170
src/lib/oauth/services/qwen.js
Normal file
170
src/lib/oauth/services/qwen.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
63
src/lib/oauth/utils/banner.js
Normal file
63
src/lib/oauth/utils/banner.js
Normal 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");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
38
src/lib/oauth/utils/pkce.js
Normal file
38
src/lib/oauth/utils/pkce.js
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
116
src/lib/oauth/utils/server.js
Normal file
116
src/lib/oauth/utils/server.js
Normal 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">✓</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
48
src/lib/oauth/utils/ui.js
Normal 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}`));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user