fix(codebuddy-intl): probe token in connection test + name OAuth by identity
Two bugs on codebuddy-intl connections: 1. Test Connection always failed with "Provider test not supported": codebuddy-intl was missing from OAUTH_TEST_CONFIG, so testOAuthConnection bailed before probing. Add a real probe against the Keycloak realm's userinfo endpoint (URL derived from the token's iss claim), and wire refreshable so an expired token is rotated via refreshCodebuddyIntlToken. 2. OAuth logins were named "Account N" with no email: mapTokens returned no identity, even though the access token is a Keycloak JWT carrying email/name claims. Extract email + displayName in mapTokens (new shared extractDisplayNameFromAccessToken helper) so fresh logins are named and deduped by identity. Also add a run-once backfill (backfillCodeBuddyIntlIdentity) invoked from GET /api/providers and /api/providers/client to self-heal existing rows (backfill email/displayName, rename the generic "Account N" placeholder). Verified live: the real connection now returns valid:true and the row is renamed to the account email.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: {},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user