diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 81b00029396..c84c80280fa 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -316,6 +316,26 @@ with the list of providers that do. For Codex accounts use the device-code login The paste window is 10 minutes, and each login accepts one code: if the exchange fails, start a new login. A successful login becomes the provider's active account, exactly like a browser login. +A Claude account that belongs to several organizations can be added once per organization: run +`ocx login anthropic` again and pick the other organization on Claude's consent screen. Each +organization has its own subscription and limits, so it becomes its own pool account instead of +replacing the first. Logging in to an organization that already has an account refreshes that +account. + +All of this depends on organization evidence, which Claude reports at login and on token refresh; +it is recorded only when that report succeeds. A login gets its own account only when both +organizations are known and differ. When the login's organization is known, an existing account +with no recorded organization (for example, one saved before this release) is checked through its +own token, if that token is still current. An account of the login's organization is preferred. +Otherwise, if the existing account's organization stays unknown (expired token, failed check, or a +login whose own organization was not reported), the login may replace it, as earlier releases did. +Reauthenticating an account is refused when its known organization differs from the login's, or +when another account already holds the login's organization; add the other organization as a new +login instead. An unknown organization on either side never counts as a conflict, but the +second rule still applies: another account holding the login's known organization refuses it. Before adding another +organization, make sure the existing account is signed in and its token is current, for example +by sending it one request. + OAuth reauthentication preserves operator settings such as model selections, pricing overrides, and account failover preferences. Login-owned transport/authentication fields and registry-owned catalog metadata are refreshed. A live-discovery provider keeps its selected default model; a diff --git a/src/adapters/anthropic/account-metadata.ts b/src/adapters/anthropic/account-metadata.ts index a1474f34c91..728958af9ac 100644 --- a/src/adapters/anthropic/account-metadata.ts +++ b/src/adapters/anthropic/account-metadata.ts @@ -5,7 +5,10 @@ const MAX_USER_ID_BYTES = 4096; type Rec = Record; const isRec = (value: unknown): value is Rec => value !== null && typeof value === "object" && !Array.isArray(value); -/** Deterministic per-account 64-hex device_id so pooled accounts never share one client device_id. */ +/** + * Deterministic per-account 64-hex device_id so pooled accounts never share one client device_id. + * Organization slots of one Claude account share it: they are one person on one device. + */ export function deriveAnthropicAccountDeviceId(accountUuid: string): string { return createHash("sha256").update(`ocx-claude-code-device-v1:${accountUuid.trim().toLowerCase()}`, "utf8").digest("hex"); } diff --git a/src/oauth/anthropic-continuity.ts b/src/oauth/anthropic-continuity.ts index 2f51db09877..60f7296a9af 100644 --- a/src/oauth/anthropic-continuity.ts +++ b/src/oauth/anthropic-continuity.ts @@ -42,6 +42,13 @@ export async function newerClaudeCredentialForInstance( const newIdentity = normalizeAnthropicIdentity(newProof, disk.access); if (!oldIdentity || !newIdentity) return { kind: "unresolved", diskGeneration }; if (oldIdentity.accountUuid !== newIdentity.accountUuid) return { kind: "different", diskGeneration }; + // The same person signed in to another organization holds a different grant and quota. A stored + // proof saved before organizations were recorded asks its own still-usable bearer once. + const oldOrganization = oldIdentity.organizationUuid ?? (newIdentity.organizationUuid && stored.expires > now + ? normalizeAnthropicIdentity(await resolveIdentity(stored.access, signal), stored.access)?.organizationUuid : undefined); + if (oldOrganization && newIdentity.organizationUuid && oldOrganization !== newIdentity.organizationUuid) { + return { kind: "different", diskGeneration }; + } return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: { ...stored, ...disk, accountId: newIdentity.accountUuid, anthropicIdentity: newIdentity, plan: newProof?.plan ?? null, } }; diff --git a/src/oauth/anthropic-identity.ts b/src/oauth/anthropic-identity.ts index 44c355c76f0..d9c2c44327e 100644 --- a/src/oauth/anthropic-identity.ts +++ b/src/oauth/anthropic-identity.ts @@ -12,19 +12,43 @@ const PROFILE_MAX_BYTES = 65_536; export type AnthropicIdentity = NonNullable; export type AnthropicIdentityResolver = (access: string, signal?: AbortSignal) => Promise<(AnthropicIdentity & { plan?: string }) | undefined>; -export function bindAnthropicIdentity(access: string, accountUuid: unknown): AnthropicIdentity | undefined { +const ORGANIZATION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** A well-formed organization UUID in canonical lowercase, or undefined. */ +function organizationUuidOf(value: unknown): string | undefined { + return typeof value === "string" && ORGANIZATION_UUID_RE.test(value) ? value.toLowerCase() : undefined; +} + +export function bindAnthropicIdentity(access: string, accountUuid: unknown, organizationUuid?: unknown): AnthropicIdentity | undefined { if (!access || typeof accountUuid !== "string" || !accountUuid.length || accountUuid.length > 128 || accountUuid !== accountUuid.trim() || /[\x00-\x1f\x7f]/.test(accountUuid)) return undefined; - return { v: 1, accountUuid, bearerSha256: createHash("sha256").update(access).digest("hex") }; + const organization = organizationUuidOf(organizationUuid); + return { v: 1, accountUuid, bearerSha256: createHash("sha256").update(access).digest("hex"), + ...(organization ? { organizationUuid: organization } : {}) }; } export function normalizeAnthropicIdentity(value: unknown, access: string): AnthropicIdentity | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const proof = value as Partial; - const bound = bindAnthropicIdentity(access, proof.accountUuid); + const bound = bindAnthropicIdentity(access, proof.accountUuid, proof.organizationUuid); return proof.v === 1 && bound && proof.bearerSha256 === bound.bearerSha256 ? bound : undefined; } +/** + * The organization a credential's grant is scoped to, when its own bearer proved it. One Claude + * account can belong to several organizations, and each OAuth grant serves exactly one of them. + */ +export function anthropicOrganization(credential: OAuthCredentials): string | undefined { + return normalizeAnthropicIdentity(credential.anthropicIdentity, credential.access)?.organizationUuid; +} + +/** True only when both credentials prove different organizations; an unknown side never conflicts. */ +export function anthropicOrganizationsConflict(a: OAuthCredentials, b: OAuthCredentials): boolean { + const left = anthropicOrganization(a); + const right = anthropicOrganization(b); + return left !== undefined && right !== undefined && left !== right; +} + /** Fixed destination, no redirects, bounded body and deadline; failures never include response data. */ export const resolveAnthropicAccountIdentity: AnthropicIdentityResolver = async (access, signal) => { if (!access || signal?.aborted) return undefined; @@ -49,12 +73,12 @@ export const resolveAnthropicAccountIdentity: AnthropicIdentityResolver = async const data: unknown = JSON.parse(body.text); if (!data || typeof data !== "object" || !("account" in data)) return undefined; const account = data.account; - const identity = account && typeof account === "object" && "uuid" in account - ? bindAnthropicIdentity(access, account.uuid) : undefined; - if (!identity) return undefined; const organization = "organization" in data ? data.organization : undefined; const org = organization && typeof organization === "object" ? organization as Record : undefined; + const identity = account && typeof account === "object" && "uuid" in account + ? bindAnthropicIdentity(access, account.uuid, org?.uuid) : undefined; + if (!identity) return undefined; const plan = normalizeAnthropicPlan(org?.rate_limit_tier) ?? normalizeAnthropicPlan(org?.organization_type); return { ...identity, ...(plan ? { plan } : {}) }; } catch { @@ -63,13 +87,24 @@ export const resolveAnthropicAccountIdentity: AnthropicIdentityResolver = async } }; -/** A changed access bearer cannot inherit the previous bearer's proof. */ -export function mergeAnthropicIdentity(fresh: OAuthCredentials, previous: OAuthCredentials): Pick { +/** + * A changed access bearer cannot inherit the previous bearer's proof. `sameGrant` marks a + * provider refresh of this row's own refresh token: the grant's organization cannot change + * there, so a freshly proven same account keeps the stored organization when the refresh + * response and its profile read did not repeat it. The organization is a scope, not identity: + * a refresh that reports another one keeps the fresh answer instead of discarding a rotated grant. + */ +export function mergeAnthropicIdentity( + fresh: OAuthCredentials, previous: OAuthCredentials, opts: { sameGrant?: boolean } = {}, +): Pick { const before = normalizeAnthropicIdentity(previous.anthropicIdentity, previous.access); const after = normalizeAnthropicIdentity(fresh.anthropicIdentity, fresh.access); if (before && after && before.accountUuid !== after.accountUuid) { throw new Error("Anthropic credential identity changed; explicit account login required"); } + if (opts.sameGrant && before?.organizationUuid && after && !after.organizationUuid) { + return { anthropicIdentity: { ...after, organizationUuid: before.organizationUuid } }; + } return { anthropicIdentity: after ?? (fresh.access === previous.access ? before : undefined) }; } @@ -77,6 +112,6 @@ export function mergeAnthropicIdentity(fresh: OAuthCredentials, previous: OAuthC export function matchesAnthropicImport(stored: OAuthCredentials, imported: OAuthCredentials): boolean { const before = normalizeAnthropicIdentity(stored.anthropicIdentity, stored.access); const after = normalizeAnthropicIdentity(imported.anthropicIdentity, imported.access); - if (before && after) return before.accountUuid === after.accountUuid; + if (before && after) return before.accountUuid === after.accountUuid && !anthropicOrganizationsConflict(stored, imported); return hasClaudeCredentialContinuity(stored, imported); } diff --git a/src/oauth/anthropic-login-slot.ts b/src/oauth/anthropic-login-slot.ts new file mode 100644 index 00000000000..fc282843875 --- /dev/null +++ b/src/oauth/anthropic-login-slot.ts @@ -0,0 +1,112 @@ +/** + * Which stored Anthropic row a login, a Claude Code import or a reauth may write. + * + * One Claude account (one `account.uuid`) can belong to several organizations, and every OAuth + * grant is scoped to exactly one of them, with its own subscription and usage limits. A login + * therefore replaces the row of the same account AND organization; a proven different + * organization gets its own row instead of overwriting the first one. + * + * The organization only scopes a slot. It never proves identity: matching still starts from + * the same account identity the store always used. When the incoming organization is unknown, + * the login keeps the historical same-account replacement, so a row stored before + * organizations were recorded is never duplicated by re-login. + */ +import { createHash } from "node:crypto"; +import { anthropicOrganization, matchesAnthropicImport, normalizeAnthropicIdentity, + resolveAnthropicAccountIdentity, type AnthropicIdentityResolver } from "./anthropic-identity"; +import type { OAuthCredentials, ProviderAccount } from "./types"; + +/** Interactive login waits on these reads; a slow profile falls back to the historical match. */ +const LOGIN_OBSERVATION_TIMEOUT_MS = 5_000; + +/** Organizations observed for stored rows, keyed by the SHA-256 of the bearer that proved them. */ +export type AnthropicSlotOrganizations = ReadonlyMap; + +function bearerSha256(access: string): string { + return createHash("sha256").update(access).digest("hex"); +} + +function loginIdentity(credential: OAuthCredentials): string | undefined { + return credential.accountId ?? credential.email; +} + +/** + * Before the store lock: read the organization of each same-account row that has none recorded + * (rows saved before organizations were tracked), so the login can tell its org from theirs. + * Only an unexpired bearer is asked, each answer is bound to that exact bearer, and nothing is + * written here; a row whose bearer changes before the write simply stays unknown. + */ +export async function observeAnthropicSlotOrganizations( + accounts: readonly ProviderAccount[], + incoming: OAuthCredentials, + resolve: AnthropicIdentityResolver = resolveAnthropicAccountIdentity, + now: () => number = Date.now, +): Promise { + const identity = loginIdentity(incoming); + if (!identity || !anthropicOrganization(incoming)) return new Map(); + const pending = accounts.filter(row => loginIdentity(row.credential) === identity + && anthropicOrganization(row.credential) === undefined + && row.credential.access && row.credential.expires > now()); + if (pending.length === 0) return new Map(); + const signal = AbortSignal.timeout(LOGIN_OBSERVATION_TIMEOUT_MS); + const proofs = await Promise.all(pending.map(row => resolve(row.credential.access, signal))); + const observed = new Map(); + pending.forEach((row, index) => { + const proof = normalizeAnthropicIdentity(proofs[index], row.credential.access); + if (proof?.organizationUuid && proof.accountUuid === row.credential.accountId) { + observed.set(proof.bearerSha256, proof.organizationUuid); + } + }); + return observed; +} + +/** + * One rule for every writer. A known incoming organization takes its own row, else a row whose + * organization is still unknown; a row proven to serve another organization is never chosen. + * An unknown incoming organization prefers an unknown row, then the active one, so it never + * displaces a proven slot while a better candidate exists. + */ +function pickSlot( + candidates: readonly ProviderAccount[], incoming: OAuthCredentials, + observed: AnthropicSlotOrganizations, activeAccountId?: string, +): ProviderAccount | undefined { + const organizationOf = (row: ProviderAccount) => anthropicOrganization(row.credential) + ?? observed.get(bearerSha256(row.credential.access)); + const organization = anthropicOrganization(incoming); + if (organization) { + return candidates.find(row => organizationOf(row) === organization) + ?? candidates.find(row => organizationOf(row) === undefined); + } + return candidates.find(row => organizationOf(row) === undefined) + ?? candidates.find(row => row.id === activeAccountId) ?? candidates[0]; +} + +/** Inside the store lock: the row a browser login replaces, or undefined for a new row. */ +export function findAnthropicLoginSlot( + accounts: readonly ProviderAccount[], incoming: OAuthCredentials, + observed: AnthropicSlotOrganizations = new Map(), activeAccountId?: string, +): ProviderAccount | undefined { + const identity = loginIdentity(incoming); + return pickSlot(accounts.filter(row => loginIdentity(row.credential) === identity), incoming, observed, activeAccountId); +} + +/** Inside the store lock: the row a Claude Code credential import enriches, or undefined for a new row. */ +export function findAnthropicImportSlot( + accounts: readonly ProviderAccount[], incoming: OAuthCredentials, + observed: AnthropicSlotOrganizations = new Map(), activeAccountId?: string, +): ProviderAccount | undefined { + return pickSlot(accounts.filter(row => matchesAnthropicImport(row.credential, incoming)), incoming, observed, activeAccountId); +} + +/** + * A reauth must not turn a row into a second copy of an organization another row of the same + * account already proves; that would leave two rows on one quota and drop the first grant. + */ +export function anthropicOrganizationHeldElsewhere( + accounts: readonly ProviderAccount[], accountId: string, incoming: OAuthCredentials, +): boolean { + const organization = anthropicOrganization(incoming); + const identity = loginIdentity(incoming); + return organization !== undefined && accounts.some(row => row.id !== accountId + && loginIdentity(row.credential) === identity && anthropicOrganization(row.credential) === organization); +} diff --git a/src/oauth/anthropic.ts b/src/oauth/anthropic.ts index e9f3440cc71..c16bcd6284a 100644 --- a/src/oauth/anthropic.ts +++ b/src/oauth/anthropic.ts @@ -48,6 +48,7 @@ interface AnthropicTokenResponse { refresh_token: string; expires_in: number; account?: { uuid?: string; email_address?: string }; + organization?: { uuid?: string }; } export class AnthropicTokenError extends Error { @@ -155,7 +156,7 @@ function credsFrom(data: AnthropicTokenResponse, refreshFallback?: string): OAut refresh: data.refresh_token || refreshFallback || "", access: data.access_token, expires, - anthropicIdentity: bindAnthropicIdentity(data.access_token, accountUuid), + anthropicIdentity: bindAnthropicIdentity(data.access_token, accountUuid, data.organization?.uuid), accountId: typeof accountUuid === "string" && accountUuid.length > 0 ? accountUuid : undefined, email: typeof email === "string" && email.length > 0 ? email : undefined, }; @@ -167,7 +168,10 @@ async function withAccountProfile(credential: OAuthCredentials, signal?: AbortSi // Token-response identity remains authoritative here; enrichment changes display metadata only. const identity = normalizeAnthropicIdentity(credential.anthropicIdentity, credential.access); if (identity && identity.accountUuid !== profile.accountUuid) return credential; - return { ...credential, plan: profile.plan ?? null }; + // The same bearer's profile names the grant's organization when the token response did not. + const organization = identity && !identity.organizationUuid && profile.organizationUuid + ? { anthropicIdentity: { ...identity, organizationUuid: profile.organizationUuid } } : {}; + return { ...credential, ...organization, plan: profile.plan ?? null }; } export class AnthropicOAuthFlow extends OAuthCallbackFlow { diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 7e67e9a9651..7d1c7a5cf79 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -99,7 +99,8 @@ import { REFRESH_SKEW_MS } from "./refresh-policy"; import { newerClaudeCredentialForInstance, captureAnthropicCredentialOwnerForInstance, claudeCredentialGenerationCurrent } from "./anthropic-continuity"; import { anthropicSubscriptionLabel } from "../providers/anthropic-subscription"; import { normalizeOAuthPlan, mergeOAuthPlan } from "./plan"; -import { mergeAnthropicIdentity, type AnthropicIdentityResolver } from "./anthropic-identity"; +import { anthropicOrganizationsConflict, mergeAnthropicIdentity, type AnthropicIdentityResolver } from "./anthropic-identity"; +import { anthropicOrganizationHeldElsewhere } from "./anthropic-login-slot"; import { AnthropicCrossInstanceDuplicateError, AnthropicLocalCliImportError, assertAnthropicCredentialSource } from "./store-anthropic-instance"; export interface OAuthAccessSnapshot { workbuddy?: import("./types").WorkBuddyOAuthMetadata; @@ -786,7 +787,7 @@ function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OA function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials { return { ...fresh, - ...mergeAnthropicIdentity(fresh, previous), + ...mergeAnthropicIdentity(fresh, previous, { sameGrant: true }), plan: mergeOAuthPlan(fresh, previous), // Shared: a refresh function returns "local-cli" only when the credential it hands back // still is the local CLI's (Devin re-reading the CLI file, Meta Muse echoing its durable @@ -1668,7 +1669,8 @@ export async function runLogin( ? kiroReauthIdentityMatches(existing, cred, getAccountSet("kiro")?.accounts .filter(account => account.id !== opts.reauthAccountId).map(account => account.credential)) : existing.accountId && cred.accountId - ? existing.accountId === cred.accountId + ? existing.accountId === cred.accountId && !anthropicOrganizationsConflict(existing, cred) + && !anthropicOrganizationHeldElsewhere(getAccountSet(provider)?.accounts ?? [], opts.reauthAccountId, cred) : existing.email && cred.email ? existing.email.trim().toLowerCase() === cred.email.trim().toLowerCase() : false; diff --git a/src/oauth/plan.ts b/src/oauth/plan.ts index 917ece100f6..03bee26a643 100644 --- a/src/oauth/plan.ts +++ b/src/oauth/plan.ts @@ -12,6 +12,10 @@ export function normalizeOAuthPlan(value: unknown): string | undefined { * (Anthropic UUID, then accountId, then case-insensitive email); undefined when none overlap. */ function sameAccount(a: OAuthCredentials, b: OAuthCredentials): boolean | undefined { + // One Claude account in two organizations is two subscriptions; neither inherits the other's tier. + const orgA = boundOrganization(a); + const orgB = boundOrganization(b); + if (orgA !== undefined && orgB !== undefined && orgA !== orgB) return false; const pairs: Array<[string | undefined, string | undefined]> = [ [a.anthropicIdentity?.accountUuid, b.anthropicIdentity?.accountUuid], [a.accountId, b.accountId], @@ -21,6 +25,14 @@ function sameAccount(a: OAuthCredentials, b: OAuthCredentials): boolean | undefi return undefined; } +/** + * The proof's organization. This module is also bundled into the browser GUI, so it cannot hash + * the bearer; callers pass store-normalized credentials, whose proofs are already bearer-bound. + */ +function boundOrganization(credential: OAuthCredentials): string | undefined { + return credential.anthropicIdentity?.organizationUuid?.toLowerCase(); +} + /** An observed unknown clears old metadata; an account replacement cannot inherit a plan. */ export function mergeOAuthPlan(fresh: OAuthCredentials, previous: OAuthCredentials): string | null | undefined { if (fresh.plan !== undefined) return normalizeOAuthPlan(fresh.plan) ?? null; diff --git a/src/oauth/store-anthropic-instance.ts b/src/oauth/store-anthropic-instance.ts index dde516a5e45..ba13589fb53 100644 --- a/src/oauth/store-anthropic-instance.ts +++ b/src/oauth/store-anthropic-instance.ts @@ -87,7 +87,9 @@ export function assertNoCrossAnthropicRegistration(store: AuthStore, instance: A const refresh = tokenFingerprint(row.credential.refresh); const existingIdentity = normalizeAnthropicIdentity(row.credential.anthropicIdentity, row.credential.access); if ((access !== undefined && tokens.has(access)) || (refresh !== undefined && tokens.has(refresh)) - || (identity && existingIdentity && identity.accountUuid === existingIdentity.accountUuid)) { + || (identity && existingIdentity && identity.accountUuid === existingIdentity.accountUuid + && !(identity.organizationUuid && existingIdentity.organizationUuid + && identity.organizationUuid !== existingIdentity.organizationUuid))) { throw new AnthropicCrossInstanceDuplicateError(); } } diff --git a/src/oauth/store.ts b/src/oauth/store.ts index cd5e709d6fb..3e1caf88800 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -23,7 +23,8 @@ import { publishCredentialSync } from "../local-credentials/generation"; import { notifyAuthStoreLoaded } from "../lib/decision-quota-sink"; import { accountPauseDetailsSchema } from "../config/schema/account-auto-pause"; import { normalizeOAuthPlan, mergeOAuthPlan } from "./plan"; -import { normalizeAnthropicIdentity, matchesAnthropicImport, mergeAnthropicIdentity } from "./anthropic-identity"; +import { normalizeAnthropicIdentity, mergeAnthropicIdentity } from "./anthropic-identity"; +import { findAnthropicImportSlot, findAnthropicLoginSlot, observeAnthropicSlotOrganizations } from "./anthropic-login-slot"; import { normalizeWorkBuddyMetadata } from "../providers/workbuddy"; import { findKiroAccountForLogin, withRetainedKiroUser } from "./kiro-account-identity"; import { isAnthropicInstanceId, type AnthropicInstanceId } from "../providers/anthropic-instance-id"; @@ -961,6 +962,9 @@ export async function saveCredentialWithReceipt( if (isAnthropicInstanceId(provider)) assertAnthropicCredentialSource(provider, cred); const safe = normalizeCredential(cred); if (!safe) return null; + // Network reads stay outside the store lock; the answers are bound to bearers rechecked inside. + const anthropicOrganizations = isAnthropicInstanceId(provider) + ? await observeAnthropicSlotOrganizations(getAccountSet(provider)?.accounts ?? [], safe) : undefined; // A login that clears an automatic pause publishes it once the write commits (#8398). let pauseCleared = false; const receipt = await mutateStore(store => { @@ -982,7 +986,7 @@ export async function saveCredentialWithReceipt( store[provider] = { activeAccountId: id, accounts: [{ id, credential: safe, addedAt: Date.now() }] }; accountId = id; } else if (provider === "anthropic" && safe.source === "local-cli") { - const existing = set.accounts.find(a => matchesAnthropicImport(a.credential, safe)); + const existing = findAnthropicImportSlot(set.accounts, safe, anthropicOrganizations, set.activeAccountId); if (existing) { existing.credential = { ...safe, ...mergeAnthropicIdentity(safe, existing.credential), plan: mergeOAuthPlan(safe, existing.credential), @@ -1004,7 +1008,9 @@ export async function saveCredentialWithReceipt( // same-session whoami email (#6641); the helper never guesses between identified users. const existing = provider === "kiro" ? findKiroAccountForLogin(set.accounts, safe) - : set.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity); + : isAnthropicInstanceId(provider) + ? findAnthropicLoginSlot(set.accounts, safe, anthropicOrganizations, set.activeAccountId) + : set.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity); if (existing) { const incoming = provider === "kiro" ? withRetainedKiroUser(safe, existing.credential) : safe; // Same identity: a login whose tier read failed keeps the stored label (plan absent), diff --git a/src/oauth/types.ts b/src/oauth/types.ts index ec5a9a4ae38..40c9310f836 100644 --- a/src/oauth/types.ts +++ b/src/oauth/types.ts @@ -73,8 +73,11 @@ export type OAuthCredentials = { source?: OAuthCredentialSource; /** Validated upstream subscription label; null is an observed unknown, absent is not read. */ plan?: string | null; - /** Private authenticated account proof; never projected into account summaries. */ - anthropicIdentity?: { v: 1; accountUuid: string; bearerSha256: string }; + /** + * Private authenticated account proof; never projected into account summaries. The optional + * organization is the grant's scope, observed from the same bearer; it never proves identity. + */ + anthropicIdentity?: { v: 1; accountUuid: string; bearerSha256: string; organizationUuid?: string }; /** Google Antigravity (Cloud Code Assist) discovered project id; injected into the CCA envelope. */ projectId?: string; /** diff --git a/structure/providers/anthropic-account-pool.md b/structure/providers/anthropic-account-pool.md index 327581571fd..ddd96aa8beb 100644 --- a/structure/providers/anthropic-account-pool.md +++ b/structure/providers/anthropic-account-pool.md @@ -322,6 +322,12 @@ Its private versioned proof binds the UUID to SHA-256 of the access bearer. Stor in `src/oauth/store.ts` drops malformed or stale proofs; account summaries omit the entire field. Profile observations reject redirects and use a ten-second deadline and 64 KiB body limit. Organization, email, generic account ID, disk location and active selection do not establish proof. +An account can join several organizations; each grant serves one. The proof may carry the bearer-observed +`organizationUuid` (UUID, lowercased), a slot scope, never identity. `src/oauth/anthropic-login-slot.ts` lets login and +CLI import write the same-organization row, else an unknown one; a proven other organization gets a new row. Unknown +rows are asked through their own unexpired bearer before the lock, never rewritten. Reauth refuses an organization +another row proves; continuity, cross-pool checks and plan labels treat proven organizations as different accounts. +Own-grant refresh keeps the stored organization unless fresh evidence names one; it never throws over it. `src/oauth/anthropic-continuity.ts` permits usable changed local-CLI generations with a shared nonempty token. A fully rotated pair instead requires matching authenticated account UUIDs. diff --git a/tests/oauth/oauth-anthropic-identity.test.ts b/tests/oauth/oauth-anthropic-identity.test.ts index 8d8395c09f4..80ddd545122 100644 --- a/tests/oauth/oauth-anthropic-identity.test.ts +++ b/tests/oauth/oauth-anthropic-identity.test.ts @@ -13,6 +13,15 @@ import { join } from "node:path"; import { getValidAccessToken, OAuthTokenRefreshStaleError } from "../../src/oauth"; import { credentialGeneration, getAccountCredential, getAccountSet, readOAuthRefreshIntent, saveCredential, writeOAuthRefreshIntent } from "../../src/oauth/store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { OAuthReauthIdentityMismatchError, runLogin } from "../../src/oauth"; +import { mergeAnthropicIdentity } from "../../src/oauth/anthropic-identity"; +import { newerClaudeCredential } from "../../src/oauth/anthropic-continuity"; +import { findAnthropicLoginSlot } from "../../src/oauth/anthropic-login-slot"; +import { mergeOAuthPlan } from "../../src/oauth/plan"; +import { AnthropicCrossInstanceDuplicateError, assertNoCrossAnthropicRegistration } from "../../src/oauth/store-anthropic-instance"; +import type { AuthStore } from "../../src/oauth/store"; +import type { OAuthController } from "../../src/oauth/types"; +import type { OcxConfig } from "../../src/config"; const originalEnv = { HOME: process.env.HOME, OPENCODEX_HOME: process.env.OPENCODEX_HOME, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR }; const originalFetch = globalThis.fetch; @@ -545,3 +554,214 @@ test("adoption reads the CLI generation only at observation and inside persisten expect(reads).toEqual([false, true]); } finally { detect.mockRestore(); } }); + +// One Claude account can belong to several organizations; each grant serves one of them. +const ORG_A = "0a0a0a0a-0000-4000-8000-00000000000a"; +const ORG_B = "0b0b0b0b-0000-4000-8000-00000000000b"; +function orgCredential(access: string, organizationUuid?: string, accountUuid = "synthetic-account-a"): OAuthCredentials { + return { access, refresh: `${access}-refresh`, expires: Date.now() + 3600_000, source: "oauth", + accountId: accountUuid, email: "shared@example.test", + anthropicIdentity: { ...proof(access, accountUuid), ...(organizationUuid ? { organizationUuid } : {}) } }; +} +/** Profile answers per bearer; a bearer without an entry is unavailable. Returns the bearers asked. */ +function orgProfiles(organizationByBearer: Record, accountUuid = "synthetic-account-a"): string[] { + const asked: string[] = []; + globalThis.fetch = (async (_url, init) => { + const bearer = new Headers(init?.headers).get("authorization")?.replace(/^Bearer /, "") ?? ""; + asked.push(bearer); + const organization = organizationByBearer[bearer]; + return organization === undefined ? new Response(null, { status: 503 }) + : Response.json({ account: { uuid: accountUuid }, organization: { uuid: organization } }); + }) as typeof fetch; + return asked; +} +const anthropicRows = () => getAccountSet("anthropic")!.accounts; + +test("profile binds the grant's organization in canonical form; a malformed organization stays unknown", async () => { + orgProfiles({ "synthetic-access": ORG_A.toUpperCase(), "synthetic-malformed": "not-an-organization" }); + expect(await resolveAnthropicAccountIdentity("synthetic-access")).toEqual({ ...proof("synthetic-access"), organizationUuid: ORG_A }); + expect(await resolveAnthropicAccountIdentity("synthetic-malformed")).toEqual(proof("synthetic-malformed")); +}); + +test("token response organization is bound to the returned bearer", async () => { + globalThis.fetch = (async (_url, init) => init?.method === "POST" + ? Response.json({ access_token: "synthetic-fresh", refresh_token: "synthetic-fresh-refresh", expires_in: 3600, + account: { uuid: "synthetic-account-a" }, organization: { uuid: ORG_B } }) + : new Response(null, { status: 503 })) as typeof fetch; + expect((await refreshAnthropicToken("synthetic-old-refresh")).anthropicIdentity) + .toEqual({ ...proof("synthetic-fresh"), organizationUuid: ORG_B }); +}); + +test("login of the same account in another organization adds a slot; same-organization login replaces its own", async () => { + orgProfiles({}); + await saveCredential("anthropic", orgCredential("synthetic-org-a", ORG_A)); + const first = anthropicRows()[0]!.id; + await saveCredential("anthropic", orgCredential("synthetic-org-b", ORG_B)); + expect(anthropicRows()).toHaveLength(2); + const second = anthropicRows().find(row => row.id !== first)!.id; + expect(getAccountSet("anthropic")!.activeAccountId).toBe(second); + expect(getAccountCredential("anthropic", first)!.access).toBe("synthetic-org-a"); + await saveCredential("anthropic", orgCredential("synthetic-org-b-2", ORG_B)); + expect(anthropicRows()).toHaveLength(2); + expect(getAccountCredential("anthropic", second)!.access).toBe("synthetic-org-b-2"); + expect(getAccountCredential("anthropic", first)!.access).toBe("synthetic-org-a"); +}); + +test("a row saved without an organization is observed by its own bearer before a login picks its slot", async () => { + await saveCredential("anthropic", orgCredential("synthetic-legacy")); + const legacy = anthropicRows()[0]!; + const asked = orgProfiles({ "synthetic-legacy": ORG_A }); + await saveCredential("anthropic", orgCredential("synthetic-org-b", ORG_B)); + expect(asked).toEqual(["synthetic-legacy"]); + expect(anthropicRows()).toHaveLength(2); + // Observation decides the slot only; the legacy row itself is not rewritten. + expect(getAccountCredential("anthropic", legacy.id)).toEqual(legacy.credential); + await saveCredential("anthropic", orgCredential("synthetic-org-a", ORG_A)); + expect(anthropicRows()).toHaveLength(2); + expect(getAccountCredential("anthropic", legacy.id)!.anthropicIdentity) + .toEqual({ ...proof("synthetic-org-a"), organizationUuid: ORG_A }); +}); + +test("an unobservable or unproven organization keeps the historical same-account replacement", async () => { + await saveCredential("anthropic", orgCredential("synthetic-legacy")); + orgProfiles({}); + await saveCredential("anthropic", orgCredential("synthetic-org-b", ORG_B)); + expect(anthropicRows()).toHaveLength(1); + expect(anthropicRows()[0]!.credential.access).toBe("synthetic-org-b"); + await saveCredential("anthropic", orgCredential("synthetic-unproven")); + expect(anthropicRows()).toHaveLength(1); + expect(anthropicRows()[0]!.credential.access).toBe("synthetic-unproven"); +}); + +test("reauth of an organization slot refuses a grant for another organization", async () => { + orgProfiles({}); + await saveCredential("anthropic", orgCredential("synthetic-org-a", ORG_A)); + const slot = anthropicRows()[0]!.id; + const original = OAUTH_PROVIDERS.anthropic!.login; + const config = (): OcxConfig => ({ port: 10100, defaultProvider: "openai", openaiProviderTierVersion: 2, providers: {} }); + const reauthWith = (credential: OAuthCredentials) => { + OAUTH_PROVIDERS.anthropic!.login = async () => credential; + return runLogin("anthropic", {} as OAuthController, { reauthAccountId: slot }, { loadConfig: config, saveConfig: () => {} }); + }; + try { + await expect(reauthWith(orgCredential("synthetic-org-b", ORG_B))).rejects.toBeInstanceOf(OAuthReauthIdentityMismatchError); + expect(getAccountCredential("anthropic", slot)!.access).toBe("synthetic-org-a"); + await reauthWith(orgCredential("synthetic-org-a-2", ORG_A)); + expect(getAccountCredential("anthropic", slot)!.access).toBe("synthetic-org-a-2"); + } finally { OAUTH_PROVIDERS.anthropic!.login = original; } +}); + +test("own-grant refresh keeps the stored organization and never discards a rotated grant over it", () => { + const stored = orgCredential("synthetic-old", ORG_A); + const fresh = orgCredential("synthetic-new"); + expect(mergeAnthropicIdentity(fresh, stored, { sameGrant: true }).anthropicIdentity) + .toEqual({ ...proof("synthetic-new"), organizationUuid: ORG_A }); + expect(mergeAnthropicIdentity(fresh, stored).anthropicIdentity).toEqual(proof("synthetic-new")); + // The organization is a scope, not identity: fresh evidence wins instead of throwing after rotation. + expect(mergeAnthropicIdentity(orgCredential("synthetic-new", ORG_B), stored, { sameGrant: true }).anthropicIdentity) + .toEqual({ ...proof("synthetic-new"), organizationUuid: ORG_B }); +}); + +test("Claude Code signed in to another organization is a different account, not a rotation", async () => { + const stored: OAuthCredentials = { ...orgCredential("synthetic-old", ORG_A), source: "local-cli" }; + disk(); + const resolveTo = (organizationUuid: string) => async (access: string) => ({ ...proof(access), organizationUuid }); + expect((await newerClaudeCredential(stored, Date.now(), undefined, resolveTo(ORG_B))).kind).toBe("different"); + expect((await newerClaudeCredential(stored, Date.now(), undefined, resolveTo(ORG_A))).kind).toBe("adopt"); +}); + +test("the other pool may hold the same account only for a proven different organization", () => { + const store = (credential: OAuthCredentials): AuthStore => ({ anthropic: { activeAccountId: "a", accounts: [{ id: "a", credential }] } }); + const incoming = orgCredential("synthetic-pool-2", ORG_B); + expect(() => assertNoCrossAnthropicRegistration(store(orgCredential("synthetic-pool-1", ORG_A)), "anthropic2", incoming)).not.toThrow(); + expect(() => assertNoCrossAnthropicRegistration(store(orgCredential("synthetic-pool-1", ORG_B)), "anthropic2", incoming)) + .toThrow(AnthropicCrossInstanceDuplicateError); + expect(() => assertNoCrossAnthropicRegistration(store(orgCredential("synthetic-pool-1")), "anthropic2", incoming)) + .toThrow(AnthropicCrossInstanceDuplicateError); +}); + +test("a subscription label never crosses organizations of one account", () => { + const previous = { ...orgCredential("synthetic-old", ORG_A), plan: "max_5x" }; + expect(mergeOAuthPlan(orgCredential("synthetic-new", ORG_B), previous)).toBeUndefined(); + expect(mergeOAuthPlan(orgCredential("synthetic-new", ORG_A), previous)).toBe("max_5x"); +}); + +test("profile read fills the organization a token response left out", async () => { + globalThis.fetch = (async (_url, init) => init?.method === "POST" + ? Response.json({ access_token: "synthetic-fresh", refresh_token: "synthetic-fresh-refresh", expires_in: 3600, + account: { uuid: "synthetic-account-a" } }) + : Response.json({ account: { uuid: "synthetic-account-a" }, organization: { uuid: ORG_A } })) as typeof fetch; + expect((await refreshAnthropicToken("synthetic-old-refresh")).anthropicIdentity) + .toEqual({ ...proof("synthetic-fresh"), organizationUuid: ORG_A }); +}); + +test("an expired organization-less row is not asked and keeps the historical replacement", async () => { + await saveCredential("anthropic", { ...orgCredential("synthetic-legacy"), expires: Date.now() - 1 }); + const asked = orgProfiles({ "synthetic-legacy": ORG_A }); + await saveCredential("anthropic", orgCredential("synthetic-org-b", ORG_B)); + expect(asked).toEqual([]); + expect(anthropicRows()).toHaveLength(1); + expect(anthropicRows()[0]!.credential.access).toBe("synthetic-org-b"); +}); + +test("an observation applies only while the row still holds the bearer that answered", () => { + const row = { id: "legacy", credential: orgCredential("synthetic-refreshed") }; + const sha = (access: string) => createHash("sha256").update(access).digest("hex"); + const incoming = orgCredential("synthetic-org-b", ORG_B); + expect(findAnthropicLoginSlot([row], incoming, new Map([[sha("synthetic-before-refresh"), ORG_A]]))).toBe(row); + expect(findAnthropicLoginSlot([row], incoming, new Map([[sha("synthetic-refreshed"), ORG_A]]))).toBeUndefined(); +}); + +test("a login with no proven organization never displaces a proven slot ahead of the active one", async () => { + orgProfiles({}); + await saveCredential("anthropic", orgCredential("synthetic-org-a", ORG_A)); + const a = anthropicRows()[0]!.id; + await saveCredential("anthropic", orgCredential("synthetic-org-b", ORG_B)); + const b = getAccountSet("anthropic")!.activeAccountId; + await saveCredential("anthropic", orgCredential("synthetic-unproven")); + expect(anthropicRows()).toHaveLength(2); + expect(getAccountCredential("anthropic", b)!.access).toBe("synthetic-unproven"); + expect(getAccountCredential("anthropic", a)!.access).toBe("synthetic-org-a"); +}); + +/** A legacy row (really org A) plus a proven org B row, the shape a first second-org login leaves. */ +async function legacyAndOrgB() { + await saveCredential("anthropic", orgCredential("synthetic-legacy")); + const legacy = anthropicRows()[0]!; + orgProfiles({ "synthetic-legacy": ORG_A }); + await saveCredential("anthropic", orgCredential("synthetic-org-b", ORG_B)); + const orgB = anthropicRows().find(row => row.id !== legacy.id)!.id; + return { legacy, orgB }; +} + +test("Claude Code import for an organization lands in that organization's row, not a legacy one", async () => { + const { legacy, orgB } = await legacyAndOrgB(); + await saveCredential("anthropic", { ...orgCredential("synthetic-cli-b", ORG_B), source: "local-cli" }); + expect(anthropicRows()).toHaveLength(2); + expect(getAccountCredential("anthropic", legacy.id)).toEqual(legacy.credential); + expect(getAccountCredential("anthropic", orgB)!.access).toBe("synthetic-cli-b"); +}); + +test("reauth cannot turn a legacy row into a second copy of an organization another row holds", async () => { + const { legacy } = await legacyAndOrgB(); + const original = OAUTH_PROVIDERS.anthropic!.login; + const config = (): OcxConfig => ({ port: 10100, defaultProvider: "openai", openaiProviderTierVersion: 2, providers: {} }); + try { + OAUTH_PROVIDERS.anthropic!.login = async () => orgCredential("synthetic-org-b-again", ORG_B); + await expect(runLogin("anthropic", {} as OAuthController, { reauthAccountId: legacy.id }, { loadConfig: config, saveConfig: () => {} })) + .rejects.toBeInstanceOf(OAuthReauthIdentityMismatchError); + expect(getAccountCredential("anthropic", legacy.id)).toEqual(legacy.credential); + } finally { OAUTH_PROVIDERS.anthropic!.login = original; } +}); + +test("a stored proof without an organization asks its own bearer before adopting another organization", async () => { + const stored: OAuthCredentials = { ...orgCredential("synthetic-old"), source: "local-cli" }; + disk(); + const asked: string[] = []; + const resolve = async (access: string) => { + asked.push(access); + return { ...proof(access), organizationUuid: access === "synthetic-other" ? ORG_B : ORG_A }; + }; + expect((await newerClaudeCredential(stored, Date.now(), undefined, resolve)).kind).toBe("different"); + expect(asked.sort()).toEqual(["synthetic-old", "synthetic-other"]); +});