Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(providers): adding a second web-cookie provider account under an already-used connection name no longer overwrites the first account's session (#15070)
4 changes: 2 additions & 2 deletions src/lib/db/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
webSessionCredentialKey,
parseProviderSpecificData,
isMatchingOauthIdentity,
isWebCookieProviderId,
} from "./webSessionDedup";
import { LOCAL_PROVIDERS } from "@/shared/constants/providers";
import { pickCodexConnectionForUser } from "@/lib/oauth/utils/codexConnectionSelection";
Expand Down Expand Up @@ -597,8 +598,7 @@ export async function createProviderConnection(data: JsonRecord) {
) || null;
}
} else if (data.authType === "apikey") {
// Name-based upsert (existing behavior): same provider + same name → update.
if (data.name) {
if (data.name && !isWebCookieProviderId(data.provider)) {
existing =
(db
.prepare(
Expand Down
18 changes: 18 additions & 0 deletions src/lib/db/webSessionDedup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@
* cookie-dedup wiring there stays thin (#3368 PR6). No DB access here.
*/

import { WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers";

/**
* #15070 — web-cookie providers store a session credential under the apikey
* auth type when added from the dashboard. `name` is a user-editable label that
* two different accounts legitimately share, so the apikey name-based upsert
* must NOT apply to them: a second account under the same name would silently
* overwrite the first account's session. The credential-value dedup (#3023)
* still collapses re-adding the very same session. Mirrors #15159 / B-01 for
* the cookie auth type.
*/
export function isWebCookieProviderId(provider: unknown): boolean {
return (
typeof provider === "string" &&
Object.prototype.hasOwnProperty.call(WEB_COOKIE_PROVIDERS, provider.toLowerCase())
);
}

/**
* Reduce a `provider_specific_data` record to a single comparable credential
* value. Cookie/token credentials are mirrored across a provider's storage
Expand Down
78 changes: 78 additions & 0 deletions tests/unit/db-provider-apikey-name-overwrite-15070.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-t15070-"));
process.env.DATA_DIR = DIR;
const core = await import("../../src/lib/db/core.ts");
const db = await import("../../src/lib/db/providers.ts");

test.beforeEach(() => core.resetDbInstance());
test.after(() => {
core.resetDbInstance();
fs.rmSync(DIR, { recursive: true, force: true });
});

async function apikeyRows(provider: string) {
const rows = (await db.getProviderConnections({ provider })) as Array<Record<string, unknown>>;
return rows.filter((r) => r.authType === "apikey");
}

test("web-cookie provider, apikey path: same name + different session keeps both accounts", async () => {
await db.createProviderConnection({
provider: "claude-web",
authType: "apikey",
name: "work",
apiKey: "session=A_SESSION",
isActive: true,
});
await db.createProviderConnection({
provider: "claude-web",
authType: "apikey",
name: "work",
apiKey: "session=B_SESSION",
isActive: true,
});
const keys = (await apikeyRows("claude-web")).map((r) => r.apiKey).sort();
assert.deepEqual(keys, ["session=A_SESSION", "session=B_SESSION"]);
});

test("web-cookie provider, apikey path: same session re-added still dedups in place", async () => {
await db.createProviderConnection({
provider: "grok-web",
authType: "apikey",
name: "work",
apiKey: "session=A_SESSION",
isActive: true,
});
await db.createProviderConnection({
provider: "grok-web",
authType: "apikey",
name: "renamed",
apiKey: "session=A_SESSION",
isActive: true,
});
assert.equal((await apikeyRows("grok-web")).length, 1);
});

test("ordinary apikey provider keeps the name-based upsert", async () => {
await db.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "main",
apiKey: "sk-old",
isActive: true,
});
await db.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "main",
apiKey: "sk-new",
isActive: true,
});
const rows = await apikeyRows("openai");
assert.equal(rows.length, 1);
assert.equal(rows[0].apiKey, "sk-new");
});
Loading