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:
MUH. IQRAM BAHRING
2026-09-19 12:14:21 +08:00
parent 1884e3a063
commit 604b4d85d5
8 changed files with 324 additions and 3 deletions
+19 -1
View File
@@ -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);
}
+2 -1
View File
@@ -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";
+4
View File
@@ -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)
+16
View File
@@ -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: {},
}),
};
+38 -1
View File
@@ -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() {