diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index 16b85e33..bcc0eab5 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -139,6 +139,48 @@ export async function POST(request, { params }) { if (action === "exchange") { const { code, redirectUri, codeVerifier, state, meta } = body; + // Detect if "code" is actually a raw JWT access token (starts with eyJ) + if (code && code.startsWith("eyJ") && code.includes(".")) { + const { extractCodexAccountInfo } = await import("@/lib/oauth/providers"); + const info = extractCodexAccountInfo(code); + + // Also decode JWT directly for ChatGPT website tokens which use + // top-level account_id/plan_type instead of nested openai auth claims + let directPayload = {}; + try { + const b64 = code.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - b64.length % 4) % 4); + directPayload = JSON.parse(Buffer.from(padded, "base64").toString("utf8")); + } catch {} + + const accountId = info.chatgptAccountId || directPayload.account_id; + const planType = info.chatgptPlanType || directPayload.plan_type; + const email = info.email || directPayload.email; + + const providerSpecificData = { authMethod: "access_token" }; + if (accountId) providerSpecificData.chatgptAccountId = accountId; + if (planType) providerSpecificData.chatgptPlanType = planType; + + const connection = await createProviderConnection({ + provider, + authType: "access_token", + accessToken: code, + email: email || null, + providerSpecificData, + testStatus: "active", + }); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + } + }); + } + // Cline uses authorization_code without PKCE const noPkceExchangeProviders = ["cline"]; if (!code || !redirectUri || (!codeVerifier && !noPkceExchangeProviders.includes(provider))) { diff --git a/src/app/api/oauth/codex/import-token/route.js b/src/app/api/oauth/codex/import-token/route.js new file mode 100644 index 00000000..73e80d52 --- /dev/null +++ b/src/app/api/oauth/codex/import-token/route.js @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; +import { createProviderConnection } from "@/models"; +import { extractCodexAccountInfo } from "@/lib/oauth/providers"; + +/** + * POST /api/oauth/codex/import-token + * Import a ChatGPT access token (created from chatgpt.com settings) + * as a provider connection, bypassing OAuth refresh flow. + * + * Body: { accessToken: string, name?: string } + */ +export async function POST(request) { + try { + const { accessToken, name } = await request.json(); + + if (!accessToken || typeof accessToken !== "string") { + return NextResponse.json( + { error: "Access token is required" }, + { status: 400 } + ); + } + + const token = accessToken.trim(); + + // Extract account info from the JWT (email, workspace, plan) + let email = null; + let providerSpecificData = { authMethod: "access_token" }; + + // Try decoding as JWT to extract email + workspace + try { + const parts = token.split("."); + if (parts.length === 3) { + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const missingPadding = (4 - (base64.length % 4)) % 4; + const padded = base64 + "=".repeat(missingPadding); + const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf8")); + + // Extract from OpenAI JWT structure + const auth = payload["https://api.openai.com/auth"] || {}; + const profile = payload["https://api.openai.com/profile"] || {}; + email = profile.email || payload.email || payload.preferred_username || null; + + if (auth.chatgpt_account_id) { + providerSpecificData.chatgptAccountId = auth.chatgpt_account_id; + } + if (auth.chatgpt_plan_type) { + providerSpecificData.chatgptPlanType = auth.chatgpt_plan_type; + } + + // Store expiry info from JWT if available + if (payload.exp) { + providerSpecificData.jwtExp = payload.exp; + } + } + } catch { + // Not a JWT or malformed — still allow import as raw token + } + + // Also try extractCodexAccountInfo via id_token-style extraction + // (the access token itself may contain the same claims) + if (!email) { + const info = extractCodexAccountInfo(token); + if (info.email) email = info.email; + if (info.chatgptAccountId) providerSpecificData.chatgptAccountId = info.chatgptAccountId; + if (info.chatgptPlanType) providerSpecificData.chatgptPlanType = info.chatgptPlanType; + } + + const connectionName = name || email || "ChatGPT Access Token"; + + // Save to database as access_token authType (no refresh token) + const connection = await createProviderConnection({ + provider: "codex", + authType: "access_token", + accessToken: token, + name: connectionName, + email: email, + providerSpecificData, + testStatus: "active", + }); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + name: connection.name, + workspace: providerSpecificData.chatgptAccountId || null, + plan: providerSpecificData.chatgptPlanType || null, + }, + }); + } catch (error) { + console.log("Codex access token import error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js index ae39b5e8..34467d89 100644 --- a/src/lib/db/repos/connectionsRepo.js +++ b/src/lib/db/repos/connectionsRepo.js @@ -98,10 +98,18 @@ export async function createProviderConnection(data) { let existing = null; if (data.authType === "oauth" && data.email) { - existing = all.find(c => c.authType === "oauth" && c.email === data.email); + const incomingWs = data.providerSpecificData?.chatgptAccountId; + existing = all.find(c => { + if (c.authType !== "oauth" || c.email !== data.email) return false; + // If both sides have a workspace ID, they must match for dedup + const existingWs = c.providerSpecificData?.chatgptAccountId; + if (incomingWs && existingWs) return incomingWs === existingWs; + return true; // fallback: email-only match for non-workspace providers + }); } else if (data.authType === "apikey" && data.name) { existing = all.find(c => c.authType === "apikey" && c.name === data.name); } + // access_token: never dedup — user manages duplicates manually if (existing) { const merged = { ...existing, ...data, updatedAt: now }; @@ -111,7 +119,7 @@ export async function createProviderConnection(data) { } let connectionName = data.name || null; - if (!connectionName && data.authType === "oauth") { + if (!connectionName && (data.authType === "oauth" || data.authType === "access_token")) { connectionName = data.email || `Account ${all.length + 1}`; } let connectionPriority = data.priority; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 88adc4f4..7ba73cfe 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -53,15 +53,15 @@ function extractEmailFromAccessToken(accessToken) { return payload.email || payload.preferred_username || payload.sub || undefined; } -// Extract codex account info from id_token +// Extract codex account info from id_token or access token export function extractCodexAccountInfo(idToken) { const payload = decodeJwtPayload(idToken); if (!payload) return {}; const chatgpt = payload["https://api.openai.com/auth"] || {}; return { email: payload.email, - chatgptAccountId: chatgpt.chatgpt_account_id, - chatgptPlanType: chatgpt.chatgpt_plan_type, + chatgptAccountId: chatgpt.chatgpt_account_id || payload.account_id, + chatgptPlanType: chatgpt.chatgpt_plan_type || payload.plan_type, }; } diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index d6bc3e85..87dcd64c 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -383,7 +383,16 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const handleManualSubmit = async () => { try { setError(null); - const url = new URL(callbackUrl); + + const input = callbackUrl.trim(); + + // Detect raw JWT access token (starts with eyJ) — skip URL parsing + if (input.startsWith("eyJ") && input.includes(".")) { + await exchangeTokens(input, null); + return; + } + + const url = new URL(input); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const errorParam = url.searchParams.get("error");