fix(oauth): avoid bare-email dedup of Codex OAuth logins (#6706)

* 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>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-10 19:39:11 -03:00
committed by GitHub
parent b45d10ceea
commit 82f78320e8
3 changed files with 51 additions and 0 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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",