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"); +});