From 686585d57668d6ac089ebb59ac6bb059bc318bb3 Mon Sep 17 00:00:00 2001 From: decolua Date: Mon, 2 Feb 2026 10:54:44 +0700 Subject: [PATCH] - Added functionality to check token expiration and key valid --- .../dashboard/providers/[id]/page.js | 5 - src/app/api/providers/[id]/test/route.js | 536 +++++++++++++++--- 2 files changed, 467 insertions(+), 74 deletions(-) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index ba9c56b3..d487f8f9 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -823,11 +823,6 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) { const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" }); const data = await res.json(); setTestResult(data.valid ? "success" : "failed"); - if (data.valid) { - onSave({ testStatus: "active", lastError: null, lastErrorAt: null }); - } else { - onSave({ testStatus: "error", lastError: data.error, lastErrorAt: new Date().toISOString() }); - } } catch { setTestResult("failed"); } finally { diff --git a/src/app/api/providers/[id]/test/route.js b/src/app/api/providers/[id]/test/route.js index 30459c67..c9ef8ed7 100644 --- a/src/app/api/providers/[id]/test/route.js +++ b/src/app/api/providers/[id]/test/route.js @@ -1,5 +1,440 @@ import { NextResponse } from "next/server"; -import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb"; +import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/app/api/sync/cloud/route"; +import { + GEMINI_CONFIG, + ANTIGRAVITY_CONFIG, + CODEX_CONFIG, + KIRO_CONFIG, +} from "@/lib/oauth/constants/oauth"; + +// OAuth provider test endpoints +const OAUTH_TEST_CONFIG = { + claude: { + // Claude doesn't have userinfo, we verify token exists and not expired + checkExpiry: true, + }, + codex: { + url: "https://api.openai.com/v1/models", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, + "gemini-cli": { + url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, + antigravity: { + url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, + github: { + url: "https://api.github.com/user", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + extraHeaders: { "User-Agent": "9Router", "Accept": "application/vnd.github+json" }, + }, + iflow: { + url: "https://iflow.cn/api/oauth/getUserInfo", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + }, + qwen: { + url: "https://portal.qwen.ai/v1/models", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + }, + kiro: { + checkExpiry: true, + refreshable: true, + }, +}; + +/** + * Refresh OAuth token using refresh_token + * @returns {object} { accessToken, expiresIn, refreshToken } or null if failed + */ +async function refreshOAuthToken(connection) { + const provider = connection.provider; + const refreshToken = connection.refreshToken; + + if (!refreshToken) return null; + + try { + // Google-based providers (gemini-cli, antigravity) + if (provider === "gemini-cli" || provider === "antigravity") { + const config = provider === "gemini-cli" ? GEMINI_CONFIG : ANTIGRAVITY_CONFIG; + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); + + if (!response.ok) return null; + + const data = await response.json(); + return { + accessToken: data.access_token, + expiresIn: data.expires_in, + refreshToken: data.refresh_token || refreshToken, + }; + } + + // OpenAI/Codex + if (provider === "codex") { + const response = await fetch(CODEX_CONFIG.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: CODEX_CONFIG.clientId, + refresh_token: refreshToken, + }), + }); + + if (!response.ok) return null; + + const data = await response.json(); + return { + accessToken: data.access_token, + expiresIn: data.expires_in, + refreshToken: data.refresh_token || refreshToken, + }; + } + + // Kiro (AWS SSO or Social auth) + if (provider === "kiro") { + const { clientId, clientSecret, region } = connection; + + // AWS SSO OIDC refresh (Builder ID or IDC) + if (clientId && clientSecret) { + const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`; + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId, + clientSecret, + refreshToken, + grantType: "refresh_token", + }), + }); + + if (!response.ok) { + const errText = await response.text(); + console.log(`Kiro AWS SSO refresh failed: ${response.status} - ${errText}`); + return null; + } + + const data = await response.json(); + return { + accessToken: data.accessToken, + expiresIn: data.expiresIn || 3600, + refreshToken: data.refreshToken || refreshToken, + }; + } + + // Social auth refresh (Google/GitHub) + const response = await fetch(KIRO_CONFIG.socialRefreshUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken }), + }); + + if (!response.ok) { + const errText = await response.text(); + console.log(`Kiro social refresh failed: ${response.status} - ${errText}`); + return null; + } + + const data = await response.json(); + return { + accessToken: data.accessToken, + expiresIn: data.expiresIn || 3600, + refreshToken: data.refreshToken || refreshToken, + }; + } + + return null; + } catch (err) { + console.log(`Error refreshing ${provider} token:`, err.message); + return null; + } +} + +/** + * Check if token is expired or about to expire (within 5 minutes) + */ +function isTokenExpired(connection) { + if (!connection.expiresAt) return false; + const expiresAt = new Date(connection.expiresAt).getTime(); + const buffer = 5 * 60 * 1000; // 5 minutes + return expiresAt <= Date.now() + buffer; +} + +/** + * Sync to cloud if enabled + */ +async function syncToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing to cloud after token refresh:", error); + } +} + +/** + * Test OAuth connection by calling provider API + * Auto-refreshes token if expired + * @returns {{ valid: boolean, error: string|null, refreshed: boolean, newTokens: object|null }} + */ +async function testOAuthConnection(connection) { + const config = OAUTH_TEST_CONFIG[connection.provider]; + + if (!config) { + return { valid: false, error: "Provider test not supported", refreshed: false }; + } + + // Check if token exists + if (!connection.accessToken) { + return { valid: false, error: "No access token", refreshed: false }; + } + + let accessToken = connection.accessToken; + let refreshed = false; + let newTokens = null; + + // Auto-refresh if token is expired and provider supports refresh + const tokenExpired = isTokenExpired(connection); + if (config.refreshable && tokenExpired && connection.refreshToken) { + const tokens = await refreshOAuthToken(connection); + if (tokens) { + accessToken = tokens.accessToken; + refreshed = true; + newTokens = tokens; + } else { + // Refresh failed + return { valid: false, error: "Token expired and refresh failed", refreshed: false }; + } + } + + // For providers that only check expiry (no test endpoint available) + if (config.checkExpiry) { + // If we already refreshed successfully, token is valid + if (refreshed) { + return { valid: true, error: null, refreshed, newTokens }; + } + // Check if token is expired (no refresh available) + if (tokenExpired) { + return { valid: false, error: "Token expired", refreshed: false }; + } + return { valid: true, error: null, refreshed: false, newTokens: null }; + } + + // Call test endpoint + try { + const headers = { + [config.authHeader]: `${config.authPrefix}${accessToken}`, + ...config.extraHeaders, + }; + + const res = await fetch(config.url, { + method: config.method, + headers, + }); + + if (res.ok) { + return { valid: true, error: null, refreshed, newTokens }; + } + + // If 401 and we haven't tried refresh yet, try refresh now + if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) { + const tokens = await refreshOAuthToken(connection); + if (tokens) { + // Retry with new token + const retryRes = await fetch(config.url, { + method: config.method, + headers: { + [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, + ...config.extraHeaders, + }, + }); + + if (retryRes.ok) { + return { valid: true, error: null, refreshed: true, newTokens: tokens }; + } + } + return { valid: false, error: "Token invalid or revoked", refreshed: false }; + } + + if (res.status === 401) { + return { valid: false, error: "Token invalid or revoked", refreshed }; + } + if (res.status === 403) { + return { valid: false, error: "Access denied", refreshed }; + } + + return { valid: false, error: `API returned ${res.status}`, refreshed }; + } catch (err) { + return { valid: false, error: err.message, refreshed }; + } +} + +/** + * Test API key connection + */ +async function testApiKeyConnection(connection) { + try { + switch (connection.provider) { + case "openai": { + const res = await fetch("https://api.openai.com/v1/models", { + headers: { Authorization: `Bearer ${connection.apiKey}` }, + }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + + case "anthropic": { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": connection.apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "claude-3-haiku-20240307", + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }), + }); + const valid = res.status !== 401; + return { valid, error: valid ? null : "Invalid API key" }; + } + + case "gemini": { + const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + + case "openrouter": { + const res = await fetch("https://openrouter.ai/api/v1/auth/key", { + headers: { Authorization: `Bearer ${connection.apiKey}` }, + }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + + case "glm": { + // GLM uses Claude-compatible API at api.z.ai + const res = await fetch("https://api.z.ai/api/anthropic/v1/messages", { + method: "POST", + headers: { + "x-api-key": connection.apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "glm-4.7", + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }), + }); + const valid = res.status !== 401 && res.status !== 403; + return { valid, error: valid ? null : "Invalid API key" }; + } + + case "minimax": { + // MiniMax uses Claude-compatible API + const res = await fetch("https://api.minimax.io/anthropic/v1/messages", { + method: "POST", + headers: { + "x-api-key": connection.apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "minimax-m2", + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }), + }); + const valid = res.status !== 401 && res.status !== 403; + return { valid, error: valid ? null : "Invalid API key" }; + } + + case "kimi": { + // Kimi uses Claude-compatible API + const res = await fetch("https://api.kimi.com/coding/v1/messages", { + method: "POST", + headers: { + "x-api-key": connection.apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "kimi-latest", + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }), + }); + const valid = res.status !== 401 && res.status !== 403; + return { valid, error: valid ? null : "Invalid API key" }; + } + + case "deepseek": { + const res = await fetch("https://api.deepseek.com/models", { + headers: { Authorization: `Bearer ${connection.apiKey}` }, + }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + + case "groq": { + const res = await fetch("https://api.groq.com/openai/v1/models", { + headers: { Authorization: `Bearer ${connection.apiKey}` }, + }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + + case "mistral": { + const res = await fetch("https://api.mistral.ai/v1/models", { + headers: { Authorization: `Bearer ${connection.apiKey}` }, + }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + + case "xai": { + const res = await fetch("https://api.x.ai/v1/models", { + headers: { Authorization: `Bearer ${connection.apiKey}` }, + }); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } + + default: + return { valid: false, error: "Provider test not supported" }; + } + } catch (err) { + return { valid: false, error: err.message }; + } +} // POST /api/providers/[id]/test - Test connection export async function POST(request, { params }) { @@ -11,81 +446,44 @@ export async function POST(request, { params }) { return NextResponse.json({ error: "Connection not found" }, { status: 404 }); } - let isValid = false; - let error = null; + let result; + + if (connection.authType === "apikey") { + result = await testApiKeyConnection(connection); + } else { + result = await testOAuthConnection(connection); + } - try { - if (connection.authType === "apikey") { - // Test API key - switch (connection.provider) { - case "openai": - const openaiRes = await fetch("https://api.openai.com/v1/models", { - headers: { "Authorization": `Bearer ${connection.apiKey}` }, - }); - isValid = openaiRes.ok; - break; - - case "anthropic": - const anthropicRes = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "x-api-key": connection.apiKey, - "anthropic-version": "2023-06-01", - "content-type": "application/json", - }, - body: JSON.stringify({ - model: "claude-3-haiku-20240307", - max_tokens: 1, - messages: [{ role: "user", content: "test" }], - }), - }); - isValid = anthropicRes.status !== 401; - break; - - case "gemini": - const geminiRes = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`); - isValid = geminiRes.ok; - break; - - case "openrouter": - const openrouterRes = await fetch("https://openrouter.ai/api/v1/models", { - headers: { "Authorization": `Bearer ${connection.apiKey}` }, - }); - isValid = openrouterRes.ok; - break; - - default: - error = "Provider test not supported"; - } - } else { - // OAuth - check if token exists and not expired - if (connection.accessToken) { - if (connection.expiresAt) { - const expiresAt = new Date(connection.expiresAt).getTime(); - isValid = expiresAt > Date.now(); - if (!isValid) error = "Token expired"; - } else { - isValid = true; - } - } else { - error = "No access token"; - } + // Build update data + const updateData = { + testStatus: result.valid ? "active" : "error", + lastError: result.valid ? null : result.error, + lastErrorAt: result.valid ? null : new Date().toISOString(), + }; + + // If token was refreshed, update tokens in DB + if (result.refreshed && result.newTokens) { + updateData.accessToken = result.newTokens.accessToken; + if (result.newTokens.refreshToken) { + updateData.refreshToken = result.newTokens.refreshToken; + } + if (result.newTokens.expiresIn) { + updateData.expiresAt = new Date(Date.now() + result.newTokens.expiresIn * 1000).toISOString(); } - } catch (err) { - error = err.message; - isValid = false; } // Update status in db - await updateProviderConnection(id, { - testStatus: isValid ? "active" : "error", - lastError: isValid ? null : error, - lastErrorAt: isValid ? null : new Date().toISOString(), - }); + await updateProviderConnection(id, updateData); + + // Sync to cloud if token was refreshed + if (result.refreshed) { + await syncToCloudIfEnabled(); + } return NextResponse.json({ - valid: isValid, - error: isValid ? null : error, + valid: result.valid, + error: result.error, + refreshed: result.refreshed || false, }); } catch (error) { console.log("Error testing connection:", error);