From 4d6ef866e78f9b3279e3db0a874116731dacb430 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Sun, 12 Jul 2026 15:15:56 -0700 Subject: [PATCH] fix(anthropic): repair Claude subscription login (moved endpoints + Cloudflare UA) The interactive 'clawcodex login -> anthropic -> subscription' flow failed at token exchange with: 'Claude OAuth request failed (403): error code: 1010'. Root causes, both surfaced only by a real login (the request path was tested via an imported token; the login endpoints never were): 1. STALE TOKEN ENDPOINT. The token exchange migrated off console.anthropic.com to platform.claude.com (upstream typescript/src/constants/oauth.ts PROD_OAUTH_CONFIG). The old console endpoint now 404s behind Cloudflare. Updated TOKEN_URL + the manual REDIRECT_URI to platform.claude.com, and SCOPES to the full ALL_OAUTH_SCOPES set the real CLI requests (adds user:sessions:claude_code, user:mcp_servers, user:file_upload). 2. MISSING USER-AGENT = Cloudflare 1010. urllib defaults to 'Python-urllib/x.y', which Cloudflare bot-blocks with 'error code: 1010' before the request reaches OAuth. The real CLI uses axios (its own UA); any genuine UA passes. _post_json now sends the same claude-cli UA the inference path uses. Also corrected AUTHORIZE_URL to the Claude.ai *subscriber* entrypoint claude.com/cai/oauth/authorize (307 -> claude.ai/oauth/authorize, the consent page a Pro/Max user signs in against). A default 'claude login' uses loginWithClaudeAi=!useConsole=true (cli/handlers/auth.ts:133-135); platform.claude.com/oauth/authorize is the --console/API-account path a pure subscriber may not complete. Verified live against the real endpoints: authorize URL 307s to claude.ai consent; complete_login() reaches OAuth (fake code -> invalid_grant); bare urllib -> 1010, with UA -> the app. CLIENT_ID unchanged. Regression tests pin the platform.claude.com token endpoint, the subscriber authorize base, and the load-bearing User-Agent. Co-Authored-By: Claude Fable 5 --- src/auth/anthropic_subscription.py | 52 ++++++++++++++++++++++++---- tests/test_anthropic_subscription.py | 41 ++++++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/auth/anthropic_subscription.py b/src/auth/anthropic_subscription.py index 9d79a0bc..9ba64b39 100644 --- a/src/auth/anthropic_subscription.py +++ b/src/auth/anthropic_subscription.py @@ -23,11 +23,39 @@ from typing import Any CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" -AUTHORIZE_URL = "https://claude.ai/oauth/authorize" -TOKEN_URL = "https://console.anthropic.com/v1/oauth/token" -REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback" -SCOPES = "org:create_api_key user:profile user:inference" +# Endpoints track the current upstream Claude Code config +# (typescript/src/constants/oauth.ts PROD_OAUTH_CONFIG). The login flow +# migrated off ``console.anthropic.com`` to ``platform.claude.com``; the old +# console token endpoint now sits behind Cloudflare and 404s, so a login +# against it fails with a 403 "error code: 1010" (Cloudflare bot block) before +# it even reaches OAuth. +# +# AUTHORIZE_URL is the Claude.ai *subscriber* entrypoint (CLAUDE_AI_AUTHORIZE_URL +# — a default ``claude login`` sets ``loginWithClaudeAi = !useConsole``, i.e. +# true, per cli/handlers/auth.ts:133-135; the console entrypoint is only for +# ``--console`` / API-account logins). ``claude.com/cai/*`` is an attribution +# wrapper that 307-redirects to ``claude.ai/oauth/authorize`` — the exact +# consent page a Pro/Max subscriber signs in against. +AUTHORIZE_URL = "https://claude.com/cai/oauth/authorize" +TOKEN_URL = "https://platform.claude.com/v1/oauth/token" +# The copy/paste ("manual") redirect target — MANUAL_REDIRECT_URL upstream. +# Same host for both authorize bases (client.ts uses MANUAL_REDIRECT_URL +# regardless of the authorize base), and it must match the token exchange's +# redirect_uri. +REDIRECT_URI = "https://platform.claude.com/oauth/code/callback" +# ALL_OAUTH_SCOPES (console ∪ claude.ai), constants/oauth.ts:56-58. The prior +# 3-scope subset omitted the claude.ai subscriber scopes the real CLI requests. +SCOPES = ( + "org:create_api_key user:profile user:inference " + "user:sessions:claude_code user:mcp_servers user:file_upload" +) OAUTH_BETAS = ("oauth-2025-04-20", "interleaved-thinking-2025-05-14") +# The token endpoint is Cloudflare-fronted and rejects the default +# ``Python-urllib/x.y`` client signature with a 403 "error code: 1010" bot +# block. The real CLI reaches it via axios (which sends its own UA); any +# genuine UA gets through, so we send the same ``claude-cli`` identity the +# inference requests use (verified: bare urllib → 1010; this UA → the app). +OAUTH_USER_AGENT = "claude-cli/2.1.2 (external, cli)" _refresh_lock = threading.Lock() @@ -93,7 +121,14 @@ def _post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]: request = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), - headers={"Content-Type": "application/json"}, + # The User-Agent is load-bearing: without it urllib sends + # ``Python-urllib/x.y`` and Cloudflare bot-blocks the token endpoint + # with a 403 "error code: 1010" before the request reaches OAuth. + headers={ + "Content-Type": "application/json", + "User-Agent": OAUTH_USER_AGENT, + "Accept": "application/json", + }, method="POST", ) try: @@ -166,6 +201,11 @@ def get_valid_credentials() -> SubscriptionCredentials | None: credentials = load_credentials() if credentials is None or not credentials.needs_refresh: return credentials + # No ``scope`` sent: RFC 6749 §6 — omitting it returns the originally + # granted scopes (which include user:inference from the full-scope + # login), so there's nothing to re-request. Upstream sends a narrower + # set only to allow scope expansion for pre-expansion imported tokens, + # which is never clawcodex's case. result = _post_json(TOKEN_URL, { "grant_type": "refresh_token", "refresh_token": credentials.refresh_token, @@ -182,5 +222,5 @@ def subscription_headers(existing: dict[str, str] | None = None) -> dict[str, st headers = dict(existing or {}) present = [part.strip() for part in headers.get("anthropic-beta", "").split(",") if part.strip()] headers["anthropic-beta"] = ",".join(dict.fromkeys([*OAUTH_BETAS, *present])) - headers["user-agent"] = "claude-cli/2.1.2 (external, cli)" + headers["user-agent"] = OAUTH_USER_AGENT return headers diff --git a/tests/test_anthropic_subscription.py b/tests/test_anthropic_subscription.py index 52ce0220..a903e48e 100644 --- a/tests/test_anthropic_subscription.py +++ b/tests/test_anthropic_subscription.py @@ -45,6 +45,47 @@ def test_complete_login_accepts_claude_copy_paste_code(tmp_path: Path, monkeypat assert payload["code_verifier"] == "verifier" +def test_oauth_endpoints_are_current_platform_domain() -> None: + # The login flow migrated to platform.claude.com; the old + # console.anthropic.com token endpoint is Cloudflare-blocked/404 and made + # login fail with "error code: 1010". Guard against a regression to the + # stale hosts. (Mirrors typescript/src/constants/oauth.ts PROD_OAUTH_CONFIG.) + # Subscriber authorize entrypoint (claude.com/cai → 307 → claude.ai + # consent); console platform.claude.com/oauth/authorize is the --console + # path and must NOT be used for a subscription login. + assert auth.AUTHORIZE_URL == "https://claude.com/cai/oauth/authorize" + assert auth.TOKEN_URL == "https://platform.claude.com/v1/oauth/token" + assert auth.REDIRECT_URI == "https://platform.claude.com/oauth/code/callback" + assert "console.anthropic.com" not in auth.TOKEN_URL + assert "console.anthropic.com" not in auth.REDIRECT_URI + # The old code pointed the token exchange at console.anthropic.com — the + # host that 1010'd. Ensure neither login host regressed to it. + assert "console.anthropic.com" not in auth.AUTHORIZE_URL + # The full subscriber scope set (ALL_OAUTH_SCOPES), not the old 3-scope subset. + for scope in ("user:inference", "user:sessions:claude_code", "user:mcp_servers"): + assert scope in auth.SCOPES + + +def test_post_json_sends_user_agent_to_evade_cloudflare(monkeypatch) -> None: + # Without a real User-Agent, urllib sends Python-urllib/x.y and Cloudflare + # 1010-blocks the token endpoint. The request MUST carry a genuine UA. + captured = {} + + class _Resp: + def __enter__(self): return self + def __exit__(self, *a): return False + def read(self): return b'{"access_token":"a","refresh_token":"r","expires_in":3600}' + + def _fake_urlopen(request, timeout=30): + captured["ua"] = request.get_header("User-agent") + return _Resp() + + monkeypatch.setattr(auth.urllib.request, "urlopen", _fake_urlopen) + auth._post_json(auth.TOKEN_URL, {"grant_type": "refresh_token"}) + assert captured["ua"] == auth.OAUTH_USER_AGENT + assert captured["ua"] and "urllib" not in captured["ua"].lower() + + def test_provider_uses_bearer_oauth_and_adapts_tools(monkeypatch) -> None: monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) with patch.object(auth, "get_valid_credentials", return_value=_credentials()), \