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
This commit is contained in:
Davide Baraldo
2026-09-01 05:51:38 +02:00
committed by GitHub
parent 05490304bf
commit e7a65d28db
5 changed files with 216 additions and 11 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string, any> | null | undefined,
incomingProviderData: Record<string, any> | 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<Record<string, any>>,
@@ -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;
});
}

View File

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