Skip to content

Commit 1257cbb

Browse files
committed
fix(provider): stop meta credentials crossing endpoints
1 parent 60b0108 commit 1257cbb

19 files changed

Lines changed: 566 additions & 36 deletions

‎packages/first-class-providers/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {
22
FIRST_CLASS_PROVIDERS,
3+
META_API_KEY_PROVIDER_ID,
34
OPENAI_API_BASE_URL,
45
OPENAI_API_MAX_COMPLETION_TOKENS_MODELS,
56
firstClassPathAsProvider,

‎packages/first-class-providers/src/providers.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ describe("FIRST_CLASS_PROVIDERS", () => {
112112

113113
const api = meta?.paths?.find((p) => p.id === "api");
114114
expect(api?.auth).toBe("api-key");
115-
expect(api?.providerId).toBe("meta");
115+
expect(api?.providerId).toBe("meta-api");
116116
expect(api?.baseURL).toBe("https://api.meta.ai/v1");
117117
expect(api?.defaultModel).toBe("muse-spark-1.3");
118118
expect((api?.models ?? []).length).toBeGreaterThan(0);

‎packages/first-class-providers/src/providers.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import {
1616
import { META_BASE_URL, META_DEFAULT_MODELS } from "@corbits/meta-provider";
1717
import type { FirstClassProviderDef } from "./types.js";
1818

19+
/** Settings id for a pasted Meta API key. OAuth profiles stay under `meta/<slug>`. */
20+
export const META_API_KEY_PROVIDER_ID = "meta-api";
21+
1922
const OPENAI_API_MODELS = [
2023
"gpt-6-astra",
2124
"gpt-5.4",
@@ -109,7 +112,7 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [
109112
models: META_DEFAULT_MODELS,
110113
defaultModel: "muse-spark-1.3",
111114
authHint: "Paste your Meta API key (LLM|...)",
112-
providerId: "meta",
115+
providerId: META_API_KEY_PROVIDER_ID,
113116
},
114117
],
115118
},

‎src/auth/meta/login.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { startMetaLogin } from "./login.js";
66
import { loadMetaProfile } from "../../config/oauth-stores.js";
7+
import { REJECTED_META_VERIFICATION_URI } from "./verification-uri.js";
78

89
const AUTH_URL = "https://auth.meta.com/oidc/device/authorization/";
910
const TOKEN_URL = "https://auth.meta.com/oidc/device/token/";
@@ -91,6 +92,38 @@ describe("startMetaLogin (device flow)", () => {
9192
});
9293
});
9394

95+
test("does not forward an http or non-Meta verification URI", async () => {
96+
const fetchImpl = (async (input: RequestInfo | URL) => {
97+
const url = String(input);
98+
if (url === AUTH_URL) {
99+
return jsonResponse({
100+
device_code: "dc-1",
101+
user_code: "ABCD-1234",
102+
verification_uri_complete: "http://evil.example/device?code=ABCD",
103+
interval: 1,
104+
expires_in: 1800,
105+
});
106+
}
107+
throw new Error(`unexpected fetch ${url}`);
108+
}) as unknown as typeof fetch;
109+
110+
let notified = false;
111+
const controller = new AbortController();
112+
const handle = startMetaLogin({
113+
profile: "work",
114+
signal: controller.signal,
115+
fetchImpl,
116+
sleep: noopSleep,
117+
notify: () => {
118+
notified = true;
119+
},
120+
});
121+
await expect(handle.completed).rejects.toThrow(
122+
REJECTED_META_VERIFICATION_URI,
123+
);
124+
expect(notified).toBe(false);
125+
});
126+
94127
test("abort rejects with the cancelled-login message", async () => {
95128
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
96129
const url = String(input);

‎src/auth/meta/login.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { metaDeviceLogin, type MetaOAuthTokens } from "@corbits/meta-provider";
22

33
import type { AuthProfile } from "../store.js";
44
import { saveMetaProfile } from "../../config/oauth-stores.js";
5+
import {
6+
isPinnedMetaVerificationUri,
7+
REJECTED_META_VERIFICATION_URI,
8+
} from "./verification-uri.js";
59

610
export type MetaLoginHandle = {
711
authorizeUrl: string;
@@ -44,13 +48,15 @@ export function startMetaLogin(opts: StartMetaLoginOptions): MetaLoginHandle {
4448
...(opts.sleep !== undefined ? { sleep: opts.sleep } : {}),
4549
...(opts.now !== undefined ? { now: opts.now } : {}),
4650
notify: (event) => {
47-
if (event.type === "device_code") {
48-
opts.notify?.({
49-
type: "device_code",
50-
verificationUri: event.verificationUri,
51-
userCode: event.userCode,
52-
});
51+
if (event.type !== "device_code") return;
52+
if (!isPinnedMetaVerificationUri(event.verificationUri)) {
53+
throw new Error(REJECTED_META_VERIFICATION_URI);
5354
}
55+
opts.notify?.({
56+
type: "device_code",
57+
verificationUri: event.verificationUri,
58+
userCode: event.userCode,
59+
});
5460
},
5561
});
5662
const profile: AuthProfile<MetaOAuthTokens> = {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { isPinnedMetaVerificationUri } from "./verification-uri.js";
4+
5+
describe("isPinnedMetaVerificationUri", () => {
6+
test("accepts https on Meta's auth host", () => {
7+
expect(
8+
isPinnedMetaVerificationUri(
9+
"https://auth.meta.com/device?code=ABCD-1234",
10+
),
11+
).toBe(true);
12+
});
13+
14+
test("rejects http and non-Meta hosts", () => {
15+
expect(
16+
isPinnedMetaVerificationUri("http://auth.meta.com/device?code=ABCD"),
17+
).toBe(false);
18+
expect(
19+
isPinnedMetaVerificationUri("https://evil.example/device?code=ABCD"),
20+
).toBe(false);
21+
expect(
22+
isPinnedMetaVerificationUri("https://auth.meta.com.evil.example/device"),
23+
).toBe(false);
24+
expect(isPinnedMetaVerificationUri("not a url")).toBe(false);
25+
});
26+
});

‎src/auth/meta/verification-uri.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { META_AUTH_HOST } from "@corbits/meta-provider";
2+
3+
const META_AUTH_HOSTNAME = new URL(META_AUTH_HOST).hostname;
4+
5+
export const REJECTED_META_VERIFICATION_URI = "rejected verification url";
6+
7+
/** https on Meta's auth host only. The device-flow package allows any http(s) URL. */
8+
export function isPinnedMetaVerificationUri(uri: string): boolean {
9+
try {
10+
const url = new URL(uri);
11+
return url.protocol === "https:" && url.hostname === META_AUTH_HOSTNAME;
12+
} catch {
13+
return false;
14+
}
15+
}

‎src/auth/remove-provider.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import {
77
listCodexProfiles,
88
loadCodexProfile,
99
saveCodexProfile,
10+
listMetaProfiles,
11+
loadMetaProfile,
12+
saveMetaProfile,
1013
} from "../config/oauth-stores.js";
1114
import {
1215
listXaiProfiles,
@@ -128,4 +131,24 @@ describe("oauthStoreForProvider", () => {
128131
expect(await codex?.removeProfile(codex.profile, home)).toEqual([]);
129132
});
130133
});
134+
135+
test("removing an OAuth-backed meta row deletes that auth-store profile", async () => {
136+
await withHome(async (home) => {
137+
const tokens = { access: "LLM|a", refresh: "id", expiresAt: 10_000_000 };
138+
await saveMetaProfile({ name: "default", createdAt: 0, tokens }, home);
139+
await saveMetaProfile({ name: "work", createdAt: 0, tokens }, home);
140+
const target = oauthStoreForProvider({
141+
name: "meta/default",
142+
metaProfile: "default",
143+
});
144+
expect(await target?.removeProfile(target.profile, home)).toEqual([
145+
"default",
146+
]);
147+
expect(await loadMetaProfile("default", home)).toBeUndefined();
148+
expect(
149+
(await listMetaProfiles(home)).map((profile) => profile.name),
150+
).toEqual(["work"]);
151+
expect(oauthStoreForProvider({ name: "meta-api/default" })).toBeNull();
152+
});
153+
});
131154
});

‎src/config/index.ts‎

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
metaProfilesToCatalogEntries,
5555
metaProvidersAsSettings,
5656
} from "./meta-providers.js";
57+
import { separateCollidingMetaApiKeys } from "./meta-api-key.js";
5758
import { fetchBifrostModels } from "./bifrost.js";
5859
import { customReasoningSettings } from "./providers.js";
5960

@@ -108,6 +109,7 @@ import {
108109
resolveLocalSettingsPath,
109110
normalizeOpenAICompatibleBaseURL,
110111
resolveProvider,
112+
saveGlobalSettings,
111113
hasMcpTransport,
112114
isExaMCPPreset,
113115
type MCPServerSettingsEntry,
@@ -459,6 +461,7 @@ export function buildMetaSource(fields: {
459461
export function buildMetaApiKeySource(fields: {
460462
id: string;
461463
apiKey?: string;
464+
baseURL?: string;
462465
model: string;
463466
sessionId: string;
464467
reasoningEffort?: ReasoningEffort;
@@ -467,10 +470,14 @@ export function buildMetaApiKeySource(fields: {
467470
if (fields.reasoningEffort !== undefined)
468471
providerOptions[META_REASONING_EFFORT_OPTION] = fields.reasoningEffort;
469472
registerSourceSecret(fields.id, fields.apiKey);
473+
const configured = fields.baseURL?.trim() ?? "";
470474
return {
471475
id: fields.id,
472476
provider: META_PROVIDER,
473-
baseURL: META_BASE_URL,
477+
baseURL:
478+
configured.length > 0
479+
? normalizeOpenAICompatibleBaseURL(configured)
480+
: META_BASE_URL,
474481
credentialId: fields.id,
475482
model: fields.model,
476483
defaults: { maxTokens: SOURCE_MAX_TOKENS, providerOptions },
@@ -1104,7 +1111,7 @@ export async function loadConfig(
11041111
...xaiProvidersAsSettings(xaiProfiles),
11051112
...metaProvidersAsSettings(metaProfiles),
11061113
};
1107-
const settings =
1114+
let settings =
11081115
configPath !== undefined
11091116
? await loadSettingsRecoveringClobberedOAuthSelection(
11101117
configPath,
@@ -1122,6 +1129,25 @@ export async function loadConfig(
11221129
projectedOAuthProviders,
11231130
{ persist: true },
11241131
);
1132+
const separatedMeta = separateCollidingMetaApiKeys(
1133+
settings,
1134+
metaProfiles.map((profile) => profile.name),
1135+
);
1136+
if (
1137+
separatedMeta.changed &&
1138+
separatedMeta.settings !== null &&
1139+
configPath === undefined &&
1140+
useOAuthProfiles
1141+
) {
1142+
try {
1143+
await saveGlobalSettings(effectiveSettingsPath, separatedMeta.settings);
1144+
} catch {
1145+
process.stderr.write(
1146+
`settings: failed to persist Meta API-key separation for ${effectiveSettingsPath}; continuing with in-memory settings.\n`,
1147+
);
1148+
}
1149+
}
1150+
settings = separatedMeta.settings;
11251151

11261152
// True when the value came from settings, not this invocation's
11271153
// --dangerously-skip-permissions flag (Config.skipPermissionsFromSettings).
@@ -1388,30 +1414,39 @@ export function mergeOAuthCatalog(
13881414
xaiProfiles: readonly XaiProfile[],
13891415
metaProfiles: readonly MetaProfile[],
13901416
): ProviderCatalogEntry[] {
1417+
const separated = separateCollidingMetaApiKeys(
1418+
settings,
1419+
metaProfiles.map((profile) => profile.name),
1420+
).settings;
13911421
const codexEntries = codexProfilesToCatalogEntries(codexProfiles);
13921422
const xaiEntries = xaiProfilesToCatalogEntries(xaiProfiles);
13931423
const metaEntries = metaProfilesToCatalogEntries(metaProfiles);
1424+
const bareMeta = separated?.providers["meta"];
1425+
const bareMetaHasApiKey =
1426+
typeof bareMeta?.apiKey === "string" && bareMeta.apiKey.length > 0;
13941427
// Legacy bare `codex`/`xai` row (the original single-instance connect key)
13951428
// next to live credential-backed `<kind>/<profile>` entries reads as a
13961429
// second provider. Drop it once that family has a live profile; when
13971430
// nothing is connected the bare row is the only ChatGPT/Grok access and
13981431
// stays. A bare row at a different endpoint is a distinct provider (see
1399-
// sameEndpoint), so it stays too.
1432+
// sameEndpoint), so it stays too. A bare `meta` row with its own API key
1433+
// is not a legacy placeholder and stays beside OAuth profiles.
14001434
const dropBare = new Set([
14011435
...(codexEntries.length > 0 &&
1402-
sameEndpoint(settings?.providers["codex"]?.baseURL, CODEX_BASE_URL)
1436+
sameEndpoint(separated?.providers["codex"]?.baseURL, CODEX_BASE_URL)
14031437
? ["codex"]
14041438
: []),
14051439
...(xaiEntries.length > 0 &&
1406-
sameEndpoint(settings?.providers["xai"]?.baseURL, XAI_BASE_URL)
1440+
sameEndpoint(separated?.providers["xai"]?.baseURL, XAI_BASE_URL)
14071441
? ["xai"]
14081442
: []),
14091443
...(metaEntries.length > 0 &&
1410-
sameEndpoint(settings?.providers["meta"]?.baseURL, META_BASE_URL)
1444+
!bareMetaHasApiKey &&
1445+
sameEndpoint(bareMeta?.baseURL, META_BASE_URL)
14111446
? ["meta"]
14121447
: []),
14131448
]);
1414-
const settingsRows = buildProviderCatalog(settings, resolved);
1449+
const settingsRows = buildProviderCatalog(separated, resolved);
14151450
// A hand-named codex/<slug> or xai/<slug> API-key row is explicit config,
14161451
// not an OAuth placeholder (see isHandNamedProviderEntry): keep it and skip
14171452
// the colliding live profile projection. buildProviderCatalog
@@ -1420,7 +1455,7 @@ export function mergeOAuthCatalog(
14201455
// profile marker — treating it as hand-named would eject the real marked
14211456
// entry for a stale token snapshot.
14221457
const handNamed = new Set(
1423-
Object.entries(settings?.providers ?? {})
1458+
Object.entries(separated?.providers ?? {})
14241459
.filter(
14251460
([name, provider]) =>
14261461
(isCodexProviderName(name) ||

‎src/config/inference-sources.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,31 @@ describe("source credential provenance", () => {
229229
});
230230
});
231231

232+
test("a meta row with a non-Meta base URL keeps that URL and its key", () => {
233+
const source = buildInferenceSourceForRef(
234+
{ provider: "meta", model: "muse-spark-1.3" },
235+
{
236+
sessionId: "sess-meta-gateway",
237+
catalog: [
238+
{
239+
name: "meta",
240+
baseURL: "https://gateway.corp.example/v1",
241+
apiKey: "sk-corp",
242+
models: ["muse-spark-1.3"],
243+
},
244+
],
245+
},
246+
undefined,
247+
);
248+
249+
if (source === null) throw new Error("expected Meta API-key source");
250+
expect(source.baseURL).toBe("https://gateway.corp.example/v1");
251+
expect(source.baseURL).not.toBe("https://api.meta.ai/v1");
252+
expect(
253+
readSourceCredentialRecord(source.credentialId).material.secret,
254+
).toBe("sk-corp");
255+
});
256+
232257
test("namespaced API-key rows are not inferred as OAuth", () => {
233258
const source = buildInferenceSourceForRef(
234259
{ provider: "codex/shadow", model: "relay-model" },

0 commit comments

Comments
 (0)