Repository navigation
fix(oauth): preserve Claude identity across token rotation (carry #6378) #6518
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
84630c5
fix(oauth): bind Claude imports to bearer-owned account identity
lidge-jun 37fc1fd
fix(oauth): include the profile OAuth beta header
lidge-jun 07f96a6
fix(oauth): resume retry-safe cleanup before account mismatch refusal
lidge-jun 60c449b
fix(oauth): retire obsolete intents after verified credential adoption
lidge-jun 5416d6a
Merge current dev into Lane E identity carry
lidge-jun b4450b8
perf(oauth): avoid the redundant pre-adoption credential read
lidge-jun 90ffac2
Merge latest release integration into Lane E identity carry
lidge-jun fdbc76c
test(oauth): keep identity registration beside OAuth cases
lidge-jun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import { detectClaudeCodeToken, hasClaudeCredentialContinuity } from "./local-token-detect"; | ||
| import { normalizeAnthropicIdentity, resolveAnthropicAccountIdentity, type AnthropicIdentityResolver } from "./anthropic-identity"; | ||
| import { credentialGeneration, type AuthStore } from "./store"; | ||
| import { REFRESH_SKEW_MS } from "./refresh-policy"; | ||
| import type { OAuthCredentials, ProviderAccountSet } from "./types"; | ||
|
|
||
| export type ClaudeCredentialObservation = | ||
| | { kind: "absent" } | ||
| | { kind: "unresolved" | "different"; diskGeneration: string } | ||
| | { kind: "adopt"; credential: OAuthCredentials; diskGeneration: string }; | ||
|
|
||
| export async function newerClaudeCredential( | ||
| stored: OAuthCredentials, now: number, signal?: AbortSignal, | ||
| resolveIdentity: AnthropicIdentityResolver = resolveAnthropicAccountIdentity, | ||
| ): Promise<ClaudeCredentialObservation> { | ||
| if (stored.source !== "local-cli") return { kind: "absent" }; | ||
| const disk = detectClaudeCodeToken(); | ||
| if (!disk || !Number.isFinite(disk.expires) || disk.expires <= now + REFRESH_SKEW_MS | ||
| || credentialGeneration(disk) === credentialGeneration(stored)) return { kind: "absent" }; | ||
| const diskGeneration = credentialGeneration(disk); | ||
| const before = normalizeAnthropicIdentity(stored.anthropicIdentity, stored.access); | ||
| if (hasClaudeCredentialContinuity(stored, disk)) { | ||
| return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: { | ||
| ...stored, ...disk, anthropicIdentity: disk.access === stored.access ? before : undefined, | ||
| } }; | ||
| } | ||
| // Independent observations share a deadline through the caller signal; neither extends the other. | ||
| const [oldProof, newProof] = await Promise.all([ | ||
| before ?? resolveIdentity(stored.access, signal), resolveIdentity(disk.access, signal), | ||
| ]); | ||
| const oldIdentity = normalizeAnthropicIdentity(oldProof, stored.access); | ||
| const newIdentity = normalizeAnthropicIdentity(newProof, disk.access); | ||
| if (!oldIdentity || !newIdentity) return { kind: "unresolved", diskGeneration }; | ||
| if (oldIdentity.accountUuid !== newIdentity.accountUuid) return { kind: "different", diskGeneration }; | ||
| return { kind: "adopt", diskGeneration: credentialGeneration(disk), credential: { | ||
| ...stored, ...disk, accountId: newIdentity.accountUuid, anthropicIdentity: newIdentity, | ||
| } }; | ||
| } | ||
|
|
||
| /** Capture primitives before network awaits; check them again inside the serialized writer. */ | ||
| export function captureAnthropicCredentialOwner(set: ProviderAccountSet, accountId: string) { | ||
| const row = set.accounts.find(account => account.id === accountId)!; | ||
| const loginId = row.loginId; | ||
| const generation = credentialGeneration(row.credential); | ||
| const metadata = identityMetadata(row.credential); | ||
| const needsReauth = row.needsReauth; | ||
| const selection = { id: set.activeAccountId, revision: set.selectionRevision }; | ||
| return (store: AuthStore, diskGeneration?: string, allowPaused = false): boolean => { | ||
| const current = store["anthropic"]; | ||
| const account = current?.accounts.find(candidate => candidate.id === accountId); | ||
| if (!account || (!allowPaused && account.paused) || account.loginId !== loginId | ||
| || account.needsReauth !== needsReauth || credentialGeneration(account.credential) !== generation | ||
| || identityMetadata(account.credential) !== metadata) return false; | ||
| if (diskGeneration !== undefined) { | ||
| if (current?.activeAccountId !== selection.id || current?.selectionRevision !== selection.revision) return false; | ||
| const disk = detectClaudeCodeToken(); | ||
| if (!disk || disk.expires <= Date.now() + REFRESH_SKEW_MS || credentialGeneration(disk) !== diskGeneration) return false; | ||
| } | ||
| return true; | ||
| }; | ||
| } | ||
|
|
||
| function identityMetadata(credential: OAuthCredentials): string { | ||
| return JSON.stringify([credential.source, credential.accountId, credential.email, credential.anthropicIdentity]); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| /** Private account proof from an authenticated Anthropic response, bound to its exact bearer. */ | ||
| import { hasClaudeCredentialContinuity } from "./local-token-detect"; | ||
| import { createHash } from "node:crypto"; | ||
| import { readBoundedResponseBody } from "../lib/bounded-body"; | ||
| import { CLAUDE_CLI_USER_AGENT } from "../providers/claude-cli-identity"; | ||
| import type { OAuthCredentials } from "./types"; | ||
|
|
||
| const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile"; | ||
| const PROFILE_TIMEOUT_MS = 10_000; | ||
| const PROFILE_MAX_BYTES = 65_536; | ||
| export type AnthropicIdentity = NonNullable<OAuthCredentials["anthropicIdentity"]>; | ||
| export type AnthropicIdentityResolver = (access: string, signal?: AbortSignal) => Promise<AnthropicIdentity | undefined>; | ||
|
|
||
| export function bindAnthropicIdentity(access: string, accountUuid: 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") }; | ||
| } | ||
|
|
||
| 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); | ||
| return proof.v === 1 && bound && proof.bearerSha256 === bound.bearerSha256 ? bound : undefined; | ||
| } | ||
|
|
||
| /** 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; | ||
| const deadline = AbortSignal.timeout(PROFILE_TIMEOUT_MS); | ||
| const combined = signal ? AbortSignal.any([signal, deadline]) : deadline; | ||
| try { | ||
| const response = await fetch(PROFILE_URL, { | ||
| method: "GET", redirect: "error", signal: combined, | ||
| headers: { Authorization: `Bearer ${access}`, Accept: "application/json", | ||
| "anthropic-beta": "oauth-2025-04-20", | ||
| "Cache-Control": "no-cache", "User-Agent": CLAUDE_CLI_USER_AGENT }, | ||
|
lidge-jun marked this conversation as resolved.
|
||
| }); | ||
| if (!response.ok || response.redirected) { | ||
| void response.body?.cancel().catch(() => {}); | ||
| return undefined; | ||
| } | ||
| const body = await readBoundedResponseBody(response, { | ||
| signal: combined, maxBytes: PROFILE_MAX_BYTES, fatalUtf8: true, | ||
| totalTimeoutMs: PROFILE_TIMEOUT_MS, inactivityTimeoutMs: PROFILE_TIMEOUT_MS, | ||
| }); | ||
| if (body.truncated || body.oversized || body.timedOut || combined.aborted) return undefined; | ||
| const data: unknown = JSON.parse(body.text); | ||
| if (!data || typeof data !== "object" || !("account" in data)) return undefined; | ||
| const account = data.account; | ||
| return account && typeof account === "object" && "uuid" in account | ||
| ? bindAnthropicIdentity(access, account.uuid) : undefined; | ||
| } catch { | ||
| // Unavailable identity is an explicit unresolved result, never an ownership guess. | ||
| return undefined; | ||
| } | ||
| }; | ||
|
|
||
| /** A changed access bearer cannot inherit the previous bearer's proof. */ | ||
| export function mergeAnthropicIdentity(fresh: OAuthCredentials, previous: OAuthCredentials): 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"); | ||
| } | ||
| 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; | ||
| return hasClaudeCredentialContinuity(stored, imported); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.