diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 75d6a965..246e9983 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -19,6 +19,7 @@ import { KIMCHI_CONFIG, } from "@/lib/oauth/constants/oauth"; import { buildClineHeaders } from "@/shared/utils/clineAuth"; +import { decodeJwtPayload } from "@/lib/oauth/providerHelpers"; // OAuth provider test endpoints const OAUTH_TEST_CONFIG = { @@ -92,6 +93,23 @@ const OAUTH_TEST_CONFIG = { authPrefix: "Bearer ", }, "codebuddy-cn": { tokenExists: true }, + // CodeBuddy Intl access tokens are Keycloak JWTs (iss .../auth/realms/copilot); + // probe the realm's userinfo endpoint so a revoked/expired token is caught. + // Derive the realm URL from the token's `iss` claim, falling back to the + // known copilot realm. 200 = valid, 401 = invalid/revoked. + "codebuddy-intl": { + buildUrl: (token) => { + const iss = decodeJwtPayload(token)?.iss; + const base = typeof iss === "string" && iss.startsWith("https://") + ? iss.replace(/\/$/, "") + : "https://www.codebuddy.ai/auth/realms/copilot"; + return `${base}/protocol/openid-connect/userinfo`; + }, + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, kimchi: { url: KIMCHI_CONFIG.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers", method: "GET", @@ -254,7 +272,7 @@ async function refreshOAuthToken(connection) { return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken }; } - if (provider === "codex" || provider === "grok-cli" || provider === "xai") { + if (provider === "codex" || provider === "grok-cli" || provider === "xai" || provider === "codebuddy-intl") { return await refreshProviderCredentials(provider, connection, console); } diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js index be5342c1..9aff702f 100644 --- a/src/app/api/providers/client/route.js +++ b/src/app/api/providers/client/route.js @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { getProviderConnections } from "@/lib/localDb"; -import { backfillCodexEmails } from "@/lib/oauth/providers"; +import { backfillCodexEmails, backfillCodeBuddyIntlIdentity } from "@/lib/oauth/providers"; import { USAGE_APIKEY_PROVIDERS, USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; const SAFE_FIELDS = [ @@ -77,6 +77,7 @@ function sortConnections(connections, sort) { export async function GET(request) { try { await backfillCodexEmails(); + await backfillCodeBuddyIntlIdentity(); const { searchParams } = new URL(request.url); const provider = searchParams.get("provider") || "all"; diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 5885472b..76b7438a 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -9,6 +9,7 @@ import { import { APIKEY_PROVIDERS } from "@/shared/constants/config"; import { AI_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers"; import { normalizeProviderId, normalizeProviderSpecificData } from "@/lib/providerNormalization"; +import { backfillCodeBuddyIntlIdentity } from "@/lib/oauth/providers"; export const dynamic = "force-dynamic"; @@ -49,6 +50,9 @@ async function normalizeProxyPoolId(proxyPoolId) { // GET /api/providers - List all connections export async function GET() { try { + // Self-heal legacy CodeBuddy Intl OAuth rows that predate identity capture + // (they show as "Account N" with no email). Runs once per process. + await backfillCodeBuddyIntlIdentity(); const connections = await getProviderConnections(); // Build nodeNameMap for compatible providers (id → name) diff --git a/src/lib/oauth/providerHelpers.js b/src/lib/oauth/providerHelpers.js index 0cb46933..c2a7fdd7 100644 --- a/src/lib/oauth/providerHelpers.js +++ b/src/lib/oauth/providerHelpers.js @@ -50,6 +50,21 @@ function extractEmailFromAccessToken(accessToken) { return payload.email || payload.preferred_username || payload.sub || undefined; } +// Human display name from OIDC-style JWT claims. +// Preference: full `name` → given+family → email local-part. +function extractDisplayNameFromAccessToken(accessToken) { + const payload = decodeJwtPayload(accessToken); + if (!payload) return undefined; + const full = typeof payload.name === "string" ? payload.name.trim() : ""; + if (full) return full; + const given = typeof payload.given_name === "string" ? payload.given_name.trim() : ""; + const family = typeof payload.family_name === "string" ? payload.family_name.trim() : ""; + const combined = [given, family].filter(Boolean).join(" ").trim(); + if (combined) return combined; + const email = typeof payload.email === "string" ? payload.email.trim() : ""; + return email ? email.split("@")[0] : undefined; +} + export async function fetchKiroProfileArn(accessToken) { if (!accessToken) return null; try { @@ -87,4 +102,5 @@ export { decodeXaiIdTokenEmail, decodeJwtPayload, extractEmailFromAccessToken, + extractDisplayNameFromAccessToken, }; diff --git a/src/lib/oauth/providers/codebuddy-intl.js b/src/lib/oauth/providers/codebuddy-intl.js index e82e430f..7bb114be 100644 --- a/src/lib/oauth/providers/codebuddy-intl.js +++ b/src/lib/oauth/providers/codebuddy-intl.js @@ -1,4 +1,5 @@ import { CODEBUDDY_INTL_CONFIG } from "../constants/oauth.js"; +import { extractEmailFromAccessToken, extractDisplayNameFromAccessToken } from "../providerHelpers.js"; // CodeBuddy International — mirrors codebuddy-cn flow against the .ai domain. const codebuddyIntl = { @@ -67,6 +68,11 @@ const codebuddyIntl = { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresIn: tokens.expires_in || 86400, + // The CodeBuddy access token is a Keycloak JWT carrying email/name claims; + // surface them so a fresh OAuth login is named by identity (and deduped on + // re-login) instead of falling back to "Account N". + email: extractEmailFromAccessToken(tokens.access_token) || null, + displayName: extractDisplayNameFromAccessToken(tokens.access_token) || null, providerSpecificData: {}, }), }; diff --git a/src/lib/oauth/providers/index.js b/src/lib/oauth/providers/index.js index b4a392f3..a592a15e 100644 --- a/src/lib/oauth/providers/index.js +++ b/src/lib/oauth/providers/index.js @@ -2,7 +2,7 @@ import "open-sse/index.js"; import { generatePKCE } from "../utils/pkce.js"; -import { extractCodexAccountInfo, fetchKiroProfileArn } from "../providerHelpers.js"; +import { extractCodexAccountInfo, fetchKiroProfileArn, extractEmailFromAccessToken, extractDisplayNameFromAccessToken } from "../providerHelpers.js"; import claude from "./claude.js"; import codex from "./codex.js"; @@ -209,6 +209,43 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra // Run-once guard across the process lifetime let codexBackfillDone = false; +let codebuddyIntlBackfillDone = false; + +// Backfill email + displayName for existing CodeBuddy Intl OAuth connections +// created before mapTokens surfaced identity (they show up as "Account N"). +// The access token is a Keycloak JWT carrying email/name claims. +export async function backfillCodeBuddyIntlIdentity() { + if (codebuddyIntlBackfillDone) return; + codebuddyIntlBackfillDone = true; + try { + const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb"); + const connections = await getProviderConnections(); + const targets = connections.filter((c) => { + if (c.provider !== "codebuddy-intl" || c.authType !== "oauth" || !c.accessToken) return false; + // Also re-heal rows whose name is still the generic "Account N" placeholder. + const genericName = typeof c.name === "string" && /^Account \d+$/.test(c.name.trim()); + return !c.email || !c.displayName || genericName; + }); + for (const conn of targets) { + const patch = {}; + const email = conn.email || extractEmailFromAccessToken(conn.accessToken); + const displayName = conn.displayName || extractDisplayNameFromAccessToken(conn.accessToken); + if (!conn.email && email) patch.email = email; + if (!conn.displayName && displayName) patch.displayName = displayName; + // Rename the generic placeholder to the identity (email preferred, matching + // deriveConnectionName's behavior for new logins). + if (/^Account \d+$/.test((conn.name || "").trim()) && (email || displayName)) { + patch.name = email || displayName; + } + if (Object.keys(patch).length) { + await updateProviderConnection(conn.id, patch); + } + } + } catch (err) { + codebuddyIntlBackfillDone = false; + console.log("backfillCodeBuddyIntlIdentity failed:", err?.message || err); + } +} // Backfill email + chatgpt account info for existing codex OAuth connections missing them export async function backfillCodexEmails() { diff --git a/tests/unit/codebuddy-intl-backfill.test.js b/tests/unit/codebuddy-intl-backfill.test.js new file mode 100644 index 00000000..a44b33b2 --- /dev/null +++ b/tests/unit/codebuddy-intl-backfill.test.js @@ -0,0 +1,119 @@ +// Existing CodeBuddy Intl OAuth connections created before mapTokens surfaced +// identity show up as "Account N" with no email. The self-healing backfill must +// fill email + displayName from the access-token JWT so the dashboard shows the +// real identity without forcing a re-login. +// +// backfillCodeBuddyIntlIdentity has a module-level run-once guard, so each test +// re-imports a fresh module instance via vi.resetModules() + dynamic import. +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getProviderConnections: vi.fn(), + updateProviderConnection: vi.fn(), +})); + +vi.mock("@/lib/localDb", () => ({ + getProviderConnections: mocks.getProviderConnections, + updateProviderConnection: mocks.updateProviderConnection, +})); + +// The providers index imports open-sse/index.js for proxy-aware fetch; stub it. +vi.mock("open-sse/index.js", () => ({})); + +function makeJwt(payload) { + const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url"); + return `${b64({ alg: "RS256", typ: "JWT" })}.${b64(payload)}.sig`; +} + +const JWT = makeJwt({ + iss: "https://www.codebuddy.ai/auth/realms/copilot", + email: "aghiyaramadh@gmail.com", + name: "aghiya ramadh", +}); + +async function loadBackfill() { + vi.resetModules(); + const mod = await import("../../src/lib/oauth/providers/index.js"); + return mod.backfillCodeBuddyIntlIdentity; +} + +describe("backfillCodeBuddyIntlIdentity", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.updateProviderConnection.mockResolvedValue({}); + }); + + it("fills email + displayName for a legacy 'Account N' connection", async () => { + mocks.getProviderConnections.mockResolvedValue([ + { + id: "conn-legacy", + provider: "codebuddy-intl", + authType: "oauth", + name: "Account 1", + email: null, + displayName: null, + accessToken: JWT, + }, + ]); + + const backfill = await loadBackfill(); + await backfill(); + + expect(mocks.updateProviderConnection).toHaveBeenCalledTimes(1); + const [id, patch] = mocks.updateProviderConnection.mock.calls[0]; + expect(id).toBe("conn-legacy"); + expect(patch.email).toBe("aghiyaramadh@gmail.com"); + expect(patch.displayName).toBe("aghiya ramadh"); + // Generic placeholder name is replaced by the identity. + expect(patch.name).toBe("aghiyaramadh@gmail.com"); + }); + + it("keeps a user-customized name (does not overwrite non-generic names)", async () => { + mocks.getProviderConnections.mockResolvedValue([ + { + id: "conn-custom", + provider: "codebuddy-intl", + authType: "oauth", + name: "My Work Account", + email: null, + displayName: null, + accessToken: JWT, + }, + ]); + + const backfill = await loadBackfill(); + await backfill(); + + expect(mocks.updateProviderConnection).toHaveBeenCalledTimes(1); + const [, patch] = mocks.updateProviderConnection.mock.calls[0]; + expect(patch.email).toBe("aghiyaramadh@gmail.com"); + expect(patch.name).toBeUndefined(); + }); + + it("leaves connections that already have identity untouched", async () => { + mocks.getProviderConnections.mockResolvedValue([ + { + id: "conn-ok", + provider: "codebuddy-intl", + authType: "oauth", + name: "aghiya ramadh", + email: "aghiyaramadh@gmail.com", + displayName: "aghiya ramadh", + accessToken: JWT, + }, + ]); + + const backfill = await loadBackfill(); + await backfill(); + expect(mocks.updateProviderConnection).not.toHaveBeenCalled(); + }); + it("ignores other providers", async () => { + mocks.getProviderConnections.mockResolvedValue([ + { id: "c1", provider: "codex", authType: "oauth", email: null, accessToken: JWT }, + ]); + + const backfill = await loadBackfill(); + await backfill(); + expect(mocks.updateProviderConnection).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/codebuddy-intl-connection.test.js b/tests/unit/codebuddy-intl-connection.test.js new file mode 100644 index 00000000..ff6445ce --- /dev/null +++ b/tests/unit/codebuddy-intl-connection.test.js @@ -0,0 +1,120 @@ +// CodeBuddy Intl (.ai) OAuth connections: +// 1. The connection test must actually probe the token (was "Provider test not supported" +// because codebuddy-intl was missing from OAUTH_TEST_CONFIG). +// 2. mapTokens must surface email/displayName from the access token JWT so a fresh OAuth +// login is named by identity instead of falling back to "Account N". +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getProviderConnectionById: vi.fn(), + updateProviderConnection: vi.fn(), + resolveConnectionProxyConfig: vi.fn(), +})); + +vi.mock("@/lib/localDb", () => ({ + getProviderConnectionById: mocks.getProviderConnectionById, + updateProviderConnection: mocks.updateProviderConnection, +})); + +vi.mock("@/lib/network/connectionProxy", () => ({ + resolveConnectionProxyConfig: mocks.resolveConnectionProxyConfig, +})); + +import codebuddyIntl from "../../src/lib/oauth/providers/codebuddy-intl.js"; +import { testSingleConnection } from "../../src/app/api/providers/[id]/test/testUtils.js"; + +// Minimal unsigned JWT (header.payload.sig) — mapTokens only decodes the payload. +function makeJwt(payload) { + const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url"); + return `${b64({ alg: "RS256", typ: "JWT" })}.${b64(payload)}.sig`; +} + +const originalFetch = global.fetch; + +describe("codebuddy-intl mapTokens identity", () => { + it("extracts email and display name from the access token JWT", () => { + const token = makeJwt({ + iss: "https://www.codebuddy.ai/auth/realms/copilot", + email: "aghiyaramadh@gmail.com", + name: "aghiya ramadh", + preferred_username: "aghiyaramadh@gmail.com", + }); + + const out = codebuddyIntl.mapTokens({ + access_token: token, + refresh_token: "rt", + expires_in: 3600, + }); + + expect(out.accessToken).toBe(token); + expect(out.refreshToken).toBe("rt"); + expect(out.email).toBe("aghiyaramadh@gmail.com"); + expect(out.displayName).toBe("aghiya ramadh"); + }); + + it("falls back to given/family name when name is absent", () => { + const token = makeJwt({ email: "a@b.com", given_name: "Aghiya", family_name: "Ramadh" }); + const out = codebuddyIntl.mapTokens({ access_token: token, expires_in: 3600 }); + expect(out.email).toBe("a@b.com"); + expect(out.displayName).toBe("Aghiya Ramadh"); + }); + + it("does not throw and leaves identity null for an opaque (non-JWT) token", () => { + const out = codebuddyIntl.mapTokens({ access_token: "opaque-token", expires_in: 3600 }); + expect(out.accessToken).toBe("opaque-token"); + expect(out.email).toBeNull(); + expect(out.displayName).toBeNull(); + }); +}); + +describe("codebuddy-intl connection test", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveConnectionProxyConfig.mockResolvedValue({}); + mocks.updateProviderConnection.mockResolvedValue({}); + mocks.getProviderConnectionById.mockResolvedValue({ + id: "conn-cb-intl", + provider: "codebuddy-intl", + authType: "oauth", + accessToken: makeJwt({ email: "aghiyaramadh@gmail.com", name: "aghiya ramadh" }), + refreshToken: "rt", + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + providerSpecificData: {}, + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("probes the token instead of returning 'Provider test not supported'", async () => { + const calls = []; + global.fetch = vi.fn((url) => { + calls.push(String(url)); + return Promise.resolve( + new Response(JSON.stringify({ email: "aghiyaramadh@gmail.com" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }); + + const result = await testSingleConnection("conn-cb-intl"); + + expect(result.error).not.toBe("Provider test not supported"); + expect(result.valid).toBe(true); + expect(calls.length).toBeGreaterThan(0); + // Must hit a real identity/usage endpoint on the codebuddy.ai domain. + expect(calls.some((u) => u.includes("codebuddy.ai"))).toBe(true); + }); + + it("marks the connection invalid on 401", async () => { + global.fetch = vi.fn(() => + Promise.resolve(new Response("unauthorized", { status: 401 })), + ); + + const result = await testSingleConnection("conn-cb-intl"); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/invalid|revoked/i); + }); +});