From e7a65d28db4331847b6f1ea67b3cf569c6d0f261 Mon Sep 17 00:00:00 2001 From: Davide Baraldo Date: Tue, 1 Sep 2026 05:51:38 +0200 Subject: [PATCH] fix(oauth): keep Claude personal and Team organizations apart (#12222) * fix(oauth): keep Claude personal and Team organizations apart One Anthropic identity reaches its personal workspace and every Team organization it belongs to with the same email AND the same accountUUID, each with its own tokens, plan and rate limits. The OAuth dedup matched on email alone for every provider except Codex, so authenticating the second organization overwrote the first connection instead of adding one: only the most recent organization stayed usable. organizationUUID is the field that separates them (cliUserID cannot be used, it changes on every login). Disambiguate on organizationUUID, mirroring how Codex uses workspaceId/chatgptUserId (#7737): - findExistingOAuthConnectionMatch routes claude through a new isSameClaudeAccount helper, so a login only merges into an existing row when the organization agrees; - isMatchingOauthIdentity gains organizationUUID as a third optional disambiguator, compared strictly two-sided; - createProviderConnection passes the incoming organizationUUID, closing the same hole on the create path. Rows stored before Claude returned organizationUUID keep the bare-email match, so re-authenticating an existing connection still updates it in place instead of forking a duplicate. No behaviour change for other providers. * docs(oauth): changelog fragment for #12222 --- .../fixes/12222-claude-org-oauth-dedup.md | 1 + src/lib/db/providers.ts | 11 +- src/lib/db/webSessionDedup.ts | 40 ++++- src/lib/oauth/connectionPersistence.ts | 34 ++++- ...ction-persistence-claude-org-dedup.test.ts | 141 ++++++++++++++++++ 5 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 changelog.d/fixes/12222-claude-org-oauth-dedup.md create mode 100644 tests/unit/oauth-connection-persistence-claude-org-dedup.test.ts diff --git a/changelog.d/fixes/12222-claude-org-oauth-dedup.md b/changelog.d/fixes/12222-claude-org-oauth-dedup.md new file mode 100644 index 0000000000..210f289d64 --- /dev/null +++ b/changelog.d/fixes/12222-claude-org-oauth-dedup.md @@ -0,0 +1 @@ +- **fix(oauth):** Keep a Claude personal workspace and a Team organization as separate connections — they share the same email and `accountUUID`, so the email-only OAuth dedup let the second login overwrite the first account's tokens; `organizationUUID` now disambiguates them, the way `workspaceId` does for Codex ([#12222](https://github.com/diegosouzapw/OmniRoute/pull/12222)) diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 96fce83fa7..04c4de9b6f 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -514,6 +514,10 @@ export async function createProviderConnection(data: JsonRecord) { // (legacy rows created before this disambiguation existed). const incomingUsername = toStringOrNull(providerSpecificData.username); const incomingProfileArn = toStringOrNull(providerSpecificData.profileArn); + // Claude: one identity reaches its personal workspace and every Team + // organization with the same email and the same accountUUID, so + // organizationUUID is what separates the accounts. + const incomingOrganizationUuid = toStringOrNull(providerSpecificData.organizationUUID); const emailMatches = db .prepare( "SELECT * FROM provider_connections WHERE provider = ? AND auth_type = 'oauth' AND email = ?" @@ -521,7 +525,12 @@ export async function createProviderConnection(data: JsonRecord) { .all(data.provider, data.email) as JsonRecord[]; existing = emailMatches.find((row) => - isMatchingOauthIdentity(row, incomingUsername, incomingProfileArn) + isMatchingOauthIdentity( + row, + incomingUsername, + incomingProfileArn, + incomingOrganizationUuid + ) ) || null; } } else if (data.authType === "apikey") { diff --git a/src/lib/db/webSessionDedup.ts b/src/lib/db/webSessionDedup.ts index 7983c2686f..e1514489a9 100644 --- a/src/lib/db/webSessionDedup.ts +++ b/src/lib/db/webSessionDedup.ts @@ -72,26 +72,50 @@ function fieldMatch(incoming: string | null, existing: string | null): boolean | return undefined; } +/** + * Strictly two-sided disambiguator match: decides only when BOTH sides carry + * the field. Unlike `fieldMatch`, a value present on one side alone stays + * undecided, so rows stored before the field existed are never forked into a + * duplicate on the next login. + */ +function bothSidesFieldMatch( + incoming: string | null, + existing: string | null +): boolean | undefined { + if (incoming && existing) return incoming === existing; + return undefined; +} + /** * Decide whether `row` (an existing `provider_connections` record) is the * same OAuth identity as an incoming connection carrying `incomingUsername` - * and `incomingProfileArn` (#10815). + * and `incomingProfileArn` (#10815), plus `incomingOrganizationUuid` for + * Claude. * - * Two independent disambiguators, either of which can prove "different - * account": `providerSpecificData.username` (generic username/IdP fallback) and + * Three independent disambiguators, any of which can prove "different + * account": `providerSpecificData.username` (generic username/IdP fallback), * `providerSpecificData.profileArn` (Kiro/AWS profile dedup — Kiro never - * sets `username`). A field only rules a match IN/OUT when both the - * incoming and existing record carry it; when neither carries either field - * the legacy bare-email match still applies unchanged. + * sets `username`) and `providerSpecificData.organizationUUID` (Claude, where + * one identity reaches its personal workspace and any Team organization under + * the same email and the same accountUUID). A field only rules a match IN/OUT + * when both the incoming and existing record carry it; when neither carries + * any of them the legacy bare-email match still applies unchanged. */ export function isMatchingOauthIdentity( row: { provider_specific_data?: unknown }, incomingUsername: string | null, - incomingProfileArn: string | null + incomingProfileArn: string | null, + incomingOrganizationUuid: string | null = null ): boolean { const existingPsd = parseProviderSpecificData(row.provider_specific_data); const usernameMatch = fieldMatch(incomingUsername, nonEmptyString(existingPsd?.username)); const profileArnMatch = fieldMatch(incomingProfileArn, nonEmptyString(existingPsd?.profileArn)); - if (usernameMatch === false || profileArnMatch === false) return false; + const organizationMatch = bothSidesFieldMatch( + incomingOrganizationUuid, + nonEmptyString(existingPsd?.organizationUUID) + ); + if (usernameMatch === false || profileArnMatch === false || organizationMatch === false) { + return false; + } return true; } diff --git a/src/lib/oauth/connectionPersistence.ts b/src/lib/oauth/connectionPersistence.ts index 125b0fbebf..5dc15cfe49 100644 --- a/src/lib/oauth/connectionPersistence.ts +++ b/src/lib/oauth/connectionPersistence.ts @@ -56,14 +56,41 @@ function isSameCodexAccount( return Boolean(incomingUserId) && safeEqual(existingUserId, incomingUserId); } +/** + * Does this existing Claude connection represent the SAME account as the + * incoming login? One Anthropic identity can belong to several organizations + * at once — the personal workspace (`organizationType` `claude_max`/ + * `claude_pro`) plus any Team/Enterprise organization (`claude_team`) — and + * they all authenticate with the same email and the same `accountUUID`, each + * with its own tokens, plan and rate limits. `organizationUUID` is the only + * field that separates them, so an email-only match silently overwrote + * whichever organization had been connected first. Require `organizationUUID` + * to agree whenever BOTH sides carry it; when either side lacks it (rows + * stored before Claude started returning it) the legacy bare-email match + * still applies, so re-authenticating an existing connection keeps updating + * it in place instead of forking a duplicate. + */ +function isSameClaudeAccount( + existingProviderData: Record | null | undefined, + incomingProviderData: Record | null | undefined +): boolean { + const incomingOrganization = incomingProviderData?.organizationUUID; + const existingOrganization = existingProviderData?.organizationUUID; + if (incomingOrganization && existingOrganization) { + return safeEqual(existingOrganization, incomingOrganization); + } + return true; +} + /** * Find the existing OAuth connection (if any) that an incoming token payload * should be merged into, shared by every OAuth-completion call site * (persistOAuthConnection, and the exchange/poll/poll-callback branches in * `src/app/api/oauth/[provider]/[action]/route.ts`). Matches by explicit * connectionId first, then by same email + auth type — with Codex requiring - * workspaceId/chatgptUserId agreement (#7737) to avoid silently overwriting - * a different Codex account that merely shares an email. + * workspaceId/chatgptUserId agreement (#7737) and Claude requiring + * organizationUUID agreement, to avoid silently overwriting a different + * account that merely shares an email. */ export function findExistingOAuthConnectionMatch( existing: Array>, @@ -81,6 +108,9 @@ export function findExistingOAuthConnectionMatch( if (provider === "codex") { return isSameCodexAccount(c.providerSpecificData, tokenData.providerSpecificData); } + if (provider === "claude") { + return isSameClaudeAccount(c.providerSpecificData, tokenData.providerSpecificData); + } return true; }); } diff --git a/tests/unit/oauth-connection-persistence-claude-org-dedup.test.ts b/tests/unit/oauth-connection-persistence-claude-org-dedup.test.ts new file mode 100644 index 0000000000..3326b2f84b --- /dev/null +++ b/tests/unit/oauth-connection-persistence-claude-org-dedup.test.ts @@ -0,0 +1,141 @@ +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 TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-claude-org-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { persistOAuthConnection } = await import("../../src/lib/oauth/connectionPersistence.ts"); + +async function resetStorage() { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + break; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException)?.code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("persistOAuthConnection must not merge a Claude personal workspace and a Team organization that share an email and accountUUID", async () => { + const personal = await persistOAuthConnection("claude", { + email: "shared@example.com", + accessToken: "token-personal", + refreshToken: "refresh-personal", + expiresIn: 3600, + providerSpecificData: { + accountUUID: "account-shared", + organizationUUID: "org-personal", + organizationType: "claude_max", + }, + }); + + const team = await persistOAuthConnection("claude", { + email: "shared@example.com", + accessToken: "token-team", + refreshToken: "refresh-team", + expiresIn: 3600, + providerSpecificData: { + accountUUID: "account-shared", + organizationUUID: "org-team", + organizationType: "claude_team", + }, + }); + + const rows = await providersDb.getProviderConnections({ provider: "claude" }); + + assert.notEqual( + team.id, + personal.id, + "connecting the Team organization must create a distinct connection, not reuse the personal workspace row" + ); + assert.equal(rows.length, 2, "both Claude organizations must persist as separate connections"); + + const personalRow = rows.find((row: { id: string }) => row.id === personal.id); + assert.equal( + personalRow?.accessToken, + "token-personal", + "the personal workspace tokens must survive the Team login unmodified" + ); +}); + +test("persistOAuthConnection still merges a re-login for the SAME Claude organizationUUID", async () => { + const first = await persistOAuthConnection("claude", { + email: "solo@example.com", + accessToken: "token-first", + refreshToken: "refresh-first", + expiresIn: 3600, + providerSpecificData: { accountUUID: "account-solo", organizationUUID: "org-solo" }, + }); + + const second = await persistOAuthConnection("claude", { + email: "solo@example.com", + accessToken: "token-second", + refreshToken: "refresh-second", + expiresIn: 3600, + providerSpecificData: { accountUUID: "account-solo", organizationUUID: "org-solo" }, + }); + + assert.equal( + second.id, + first.id, + "re-authenticating the same Claude organization must update the same row" + ); + + const rows = await providersDb.getProviderConnections({ provider: "claude" }); + assert.equal( + rows.length, + 1, + "no duplicate connection should be created for the same organization" + ); + assert.equal(rows[0]?.accessToken, "token-second", "the row must reflect the latest tokens"); +}); + +test("persistOAuthConnection keeps updating a legacy Claude row stored without organizationUUID", async () => { + const legacy = await persistOAuthConnection("claude", { + email: "legacy@example.com", + accessToken: "token-legacy", + refreshToken: "refresh-legacy", + expiresIn: 3600, + providerSpecificData: { accountUUID: "account-legacy" }, + }); + + const relogin = await persistOAuthConnection("claude", { + email: "legacy@example.com", + accessToken: "token-relogin", + refreshToken: "refresh-relogin", + expiresIn: 3600, + providerSpecificData: { accountUUID: "account-legacy", organizationUUID: "org-discovered" }, + }); + + assert.equal( + relogin.id, + legacy.id, + "a row stored before organizationUUID existed must be updated in place, not forked" + ); + + const rows = await providersDb.getProviderConnections({ provider: "claude" }); + assert.equal(rows.length, 1, "the legacy row must not be duplicated by the next login"); +});