Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs-site/src/content/docs/reference/cli/providers-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/adapters/anthropic/account-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ const MAX_USER_ID_BYTES = 4096;
type Rec = Record<string, unknown>;
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");
}
Expand Down
7 changes: 7 additions & 0 deletions src/oauth/anthropic-continuity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
} };
Expand Down
53 changes: 44 additions & 9 deletions src/oauth/anthropic-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,43 @@ const PROFILE_MAX_BYTES = 65_536;
export type AnthropicIdentity = NonNullable<OAuthCredentials["anthropicIdentity"]>;
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<AnthropicIdentity>;
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;
Expand All @@ -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<string, unknown> : 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 {
Expand All @@ -63,20 +87,31 @@ export const resolveAnthropicAccountIdentity: AnthropicIdentityResolver = async
}
};

/** A changed access bearer cannot inherit the previous bearer's proof. */
export function mergeAnthropicIdentity(fresh: OAuthCredentials, previous: OAuthCredentials): Pick<OAuthCredentials, "anthropicIdentity"> {
/**
* 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<OAuthCredentials, "anthropicIdentity"> {
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) };
}

/** Explicit imports enrich only a proven slot; labels and paths never establish ownership. */
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);
}
112 changes: 112 additions & 0 deletions src/oauth/anthropic-login-slot.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

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<AnthropicSlotOrganizations> {
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<string, string>();
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);
}
8 changes: 6 additions & 2 deletions src/oauth/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
};
Expand All @@ -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 } } : {};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return { ...credential, ...organization, plan: profile.plan ?? null };
}

export class AnthropicOAuthFlow extends OAuthCallbackFlow {
Expand Down
8 changes: 5 additions & 3 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading