From 82f78320e8c2a9426be7f40ae0bcedb372e37232 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:39:11 -0300 Subject: [PATCH] fix(oauth): avoid bare-email dedup of Codex OAuth logins (#6706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(oauth): avoid bare-email dedup of Codex OAuth logins When an incoming Codex OAuth connection has no verifiable workspace/account id, do not merge it into an existing row on email match alone — that silently overwrote the other account's token pair. Require a matching chatgptUserId (a stable per-account JWT id) before merging; otherwise insert a distinct connection row. Co-authored-by: lucasjustinudin <34107354+lucasjustinudin@users.noreply.github.com> Inspired-by: https://github.com/decolua/9router/pull/2477 * chore(6706): re-sync onto release tip; CHANGELOG → changelog.d fragment (fragments-first) --------- Co-authored-by: lucasjustinudin <34107354+lucasjustinudin@users.noreply.github.com> --- .../6706-codex-oauth-bare-email-dedup.md | 1 + src/lib/db/providers.ts | 19 ++++++++++++ tests/unit/db-providers-crud.test.ts | 31 +++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 changelog.d/fixes/6706-codex-oauth-bare-email-dedup.md diff --git a/changelog.d/fixes/6706-codex-oauth-bare-email-dedup.md b/changelog.d/fixes/6706-codex-oauth-bare-email-dedup.md new file mode 100644 index 0000000000..4eca2c7828 --- /dev/null +++ b/changelog.d/fixes/6706-codex-oauth-bare-email-dedup.md @@ -0,0 +1 @@ +- **fix(oauth):** stop merging distinct Codex OAuth logins that share an email but lack a verifiable account id, preventing silent token overwrite. (thanks @lucasjustinudin) diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index a59e178cad..d22842b2ed 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -169,6 +169,25 @@ export async function createProviderConnection(data: JsonRecord) { } // For Codex with workspaceId, don't fall back to email-only check // This allows creating new connections for different workspaces + } else if (data.provider === "codex") { + // Codex without a workspaceId — do NOT fall through to the generic + // bare-email dedup below. Codex never sets providerSpecificData.username, + // so that path's disambiguation is a no-op and two distinct Codex logins + // sharing an email (but missing a verifiable workspace/account id) would + // silently collapse into one row, overwriting the first login's token + // pair. Require a matching chatgptUserId (a stable per-account id from + // the JWT) before merging; otherwise treat this as a new connection. + const chatgptUserId = toStringOrNull(providerSpecificData.chatgptUserId); + if (chatgptUserId) { + existing = + (db + .prepare( + "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND json_extract(provider_specific_data, '$.chatgptUserId') = ? AND email = ?" + ) + .get(data.provider, chatgptUserId, data.email) as JsonRecord | undefined) || null; + } + // No chatgptUserId on the incoming row (or no existing match) — leave + // `existing` null so a new connection row is inserted. } else { // For other providers (or Codex without workspaceId), match on email — // disambiguated by providerSpecificData.username when present on both diff --git a/tests/unit/db-providers-crud.test.ts b/tests/unit/db-providers-crud.test.ts index 638af3b50d..38babb66b1 100644 --- a/tests/unit/db-providers-crud.test.ts +++ b/tests/unit/db-providers-crud.test.ts @@ -142,6 +142,37 @@ test("codex workspace uniqueness uses workspaceId alongside email", async () => ]); }); +test("codex logins without a workspaceId are not merged on bare email match", async () => { + const loginA = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "shared@example.com", + accessToken: "token-account-a", + refreshToken: "refresh-account-a", + providerSpecificData: { chatgptUserId: "user-a" }, + }); + const loginB = await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + email: "shared@example.com", + accessToken: "token-account-b", + refreshToken: "refresh-account-b", + providerSpecificData: { chatgptUserId: "user-b" }, + }); + + const rows = await providersDb.getProviderConnections({ provider: "codex" }); + + // Two distinct Codex accounts sharing an email but lacking a verifiable + // workspaceId must NOT collapse into a single row — that would silently + // overwrite the first account's token pair on the second login. + assert.notEqual(loginB.id, loginA.id); + assert.equal(rows.length, 2); + + const rowA = rows.find((row) => row.id === loginA.id); + assert.equal(rowA?.accessToken, "token-account-a"); + assert.equal(rowA?.refreshToken, "refresh-account-a"); +}); + test("updateProviderConnection reorders priorities and returns decrypted payloads", async () => { const first = await providersDb.createProviderConnection({ provider: "openai",