{"repo":"NousResearch/hermes-agent","public_id":"45250","display_label":"Anthropic OAuth token endpoint URL migration issues","summary":"Multiple reports and fixes around the Anthropic OAuth login flow using a stale console.anthropic.com token endpoint that returns HTTP 404, with various proposed fixes updating or adding fallbacks to platform.claude.com.","subsystem":"agent","counts":{"members":14,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":7,"merged_prs":1,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":51723},"resolution":{"state":"partially_resolved","by":"pr-51723"},"freshness":{"state":"verified","last_verified_at":"2026-06-24T06:59:40+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1402197802197802},"fix_edges":[{"issue_number":49821,"pr_number":51723,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":45250,"title":"Anthropic OAuth login (auth add anthropic --type oauth) fails with 404 — login flow uses stale token endpoint","url":null,"state":"closed"},{"kind":"issue","number":47692,"title":"OAuth login (anthropic) hits dead console.anthropic.com token endpoint → HTTP 404; refresh path already moved to platform.claude.com","url":null,"state":"closed"},{"kind":"issue","number":47964,"title":"OAuth: _OAUTH_TOKEN_URL points to console.anthropic.com (404) — should be api.anthropic.com","url":null,"state":"closed"},{"kind":"issue","number":47966,"title":"PKCE redirect_uri mismatch: console.anthropic.com vs platform.claude.com causes invalid tokens","url":null,"state":"closed"},{"kind":"issue","number":49821,"title":"Anthropic OAuth login 404s: token exchange still uses migrated console.anthropic.com endpoint","url":null,"state":"closed"},{"kind":"issue","number":51559,"title":"anthropic OAuth login token exchange hardcodes dead console.anthropic.com URL → HTTP 404, login never completes","url":null,"state":"closed"},{"kind":"issue","number":52630,"title":"Anthropic OAuth login (auth add anthropic --type oauth) 404s: _OAUTH_TOKEN_URL points to console.anthropic.com; refresh path already uses platform.claude.com","url":null,"state":"closed"},{"kind":"pr","number":45254,"title":"fix(auth): iterate token endpoints in Anthropic OAuth login flow","url":null,"state":"closed","close_reason":{"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":45339,"title":"fix(auth): try fallback token endpoints for Anthropic OAuth login (#45250)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45254","number":45254},{"kind":"pr","value":"#45250","number":45250}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":47765,"title":"fix(auth): update OAuth token endpoint to platform.claude.com (#47692)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#45254","number":45254},{"kind":"pr","value":"#45254","number":45254},{"kind":"pr","value":"#46251","number":46251},{"kind":"pr","value":"#45441","number":45441},{"kind":"pr","value":"#47692","number":47692}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":47989,"title":"fix(auth): update Anthropic OAuth token URL (#47964)","url":null,"state":"closed","close_reason":{"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":47992,"title":"fix(auth): correct OAuth redirect URI to platform.claude.com (#47966)","url":null,"state":"closed","close_reason":{"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":50982,"title":"fix: update Anthropic OAuth token endpoint from console.anthropic.com to platform.claude.com","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":51723,"title":"fix(anthropic): use platform.claude.com for OAuth login token exchange","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","hermes_cli/web_server.py","scripts/release.py","tests/agent/test_anthropic_oauth_pkce.py"],"fix_hunks":[{"file":"agent/anthropic_adapter.py","hunks":["@@ -1297,7 +1297,15 @@ def run_oauth_setup_token() -> Optional[str]:\n # Stores credentials in ~/.hermes/.anthropic_oauth.json (our own file).\n \n _OAUTH_CLIENT_ID = \"9d1c250a-e61b-44d9-88ed-5944d1962f5e\"\n-_OAUTH_TOKEN_URL = \"[redacted:secret-assignment]\"\n+# Anthropic migrated the OAuth token endpoint to platform.claude.com;\n+# console.anthropic.com now 404s. Callers should iterate _OAUTH_TOKEN_URLS\n+# (new host first, console fallback). _OAUTH_TOKEN_URL is kept as the primary\n+# for backward compatibility with existing imports and now points at the live host.\n+_OAUTH_TOKEN_URLS = [\n+    \"https://platform.claude.com/v1/oauth/token\",\n+    \"https://console.anthropic.com/v1/oauth/token\",\n+]\n+_OAUTH_TOKEN_URL = [redacted:secret-assignment]\n _OAUTH_REDIRECT_URI = \"https://console.anthropic.com/oauth/code/callback\"\n _OAUTH_SCOPES = \"org:create_api_key user:profile user:inference\"\n _HERMES_OAUTH_FILE = get_hermes_home() / \".anthropic_oauth.json\"","@@ -1395,18 +1403,34 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:\n             \"code_verifier\": verifier,\n         }).encode()\n \n-        req = urllib.request.Request(\n-            _OAUTH_TOKEN_URL,\n-            data=exchange_data,\n-            headers={\n-                \"Content-Type\": \"application/json\",\n-                \"User-Agent\": f\"claude-cli/{_get_claude_code_version()} (external, cli)\",\n-            },\n-            method=\"POST\",\n-        )\n+        # Anthropic migrated the OAuth token endpoint to platform.claude.com;\n+        # console.anthropic.com now 404s. Try the new host first, then fall\n+        # back to console for older deployments (mirrors the refresh path).\n+        result = None\n+        last_error = None\n+        for endpoint in _OAUTH_TOKEN_URLS:\n+            req = urllib.request.Request(\n+                endpoint,\n+                data=exchange_data,\n+                headers={\n+                    \"Content-Type\": \"application/json\",\n+                    \"User-Agent\": f\"claude-cli/{_get_claude_code_version()} (external, cli)\",\n+                },\n+                method=\"POST\",\n+            )\n+            try:\n+                with urllib.request.urlopen(req, timeout=15) as resp:\n+                    result = json.loads(resp.read().decode())\n+                break\n+            except Exception as exc:\n+                last_error = exc\n+                logger.debug(\"Anthropic token exchange failed at %s: %s\", endpoint, exc)\n+                continue\n \n-        with urllib.request.urlopen(req, timeout=15) as resp:\n-            result = json.loads(resp.read().decode())\n+        if result is None:\n+            raise last_error if last_error is not None else ValueError(\n+                \"Anthropic token exchange failed\"\n+            )\n     except Exception as e:\n         print(f\"Token exchange failed: {e}\")\n         return None"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -6123,6 +6123,7 @@ async def disconnect_oauth_provider(\n     from agent.anthropic_adapter import (\n         _OAUTH_CLIENT_ID as _ANTHROPIC_OAUTH_CLIENT_ID,\n         _OAUTH_TOKEN_URL as _ANTHROPIC_OAUTH_TOKEN_URL,\n+        _OAUTH_TOKEN_URLS as _ANTHROPIC_OAUTH_TOKEN_URLS,\n         _OAUTH_REDIRECT_URI as _ANTHROPIC_OAUTH_REDIRECT_URI,\n         _OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES,\n         _generate_pkce as _generate_pkce_pair,","@@ -6311,22 +6312,31 @@ def _submit_anthropic_pkce(\n         \"redirect_uri\": _ANTHROPIC_OAUTH_REDIRECT_URI,\n         \"code_verifier\": sess[\"verifier\"],\n     }).encode()\n-    req = urllib.request.Request(\n-        _ANTHROPIC_OAUTH_TOKEN_URL,\n-        data=exchange_data,\n-        headers={\n-            \"Content-Type\": \"application/json\",\n-            \"User-Agent\": \"hermes-dashboard/1.0\",\n-        },\n-        method=\"POST\",\n-    )\n-    try:\n-        with urllib.request.urlopen(req, timeout=20) as resp:\n-            result = json.loads(resp.read().decode())\n-    except Exception as e:\n+    # Anthropic migrated the OAuth token endpoint to platform.claude.com;\n+    # console.anthropic.com now 404s. Try the new host first, then fall back.\n+    result = None\n+    last_exc = None\n+    for _endpoint in _ANTHROPIC_OAUTH_TOKEN_URLS:\n+        req = urllib.request.Request(\n+            _endpoint,\n+            data=exchange_data,\n+            headers={\n+                \"Content-Type\": \"application/json\",\n+                \"User-Agent\": \"hermes-dashboard/1.0\",\n+            },\n+            method=\"POST\",\n+        )\n+        try:\n+            with urllib.request.urlopen(req, timeout=20) as resp:\n+                result = json.loads(resp.read().decode())\n+            break\n+        except Exception as e:\n+            last_exc = e\n+            continue\n+    if result is None:\n         with _oauth_sessions_lock:\n             sess[\"status\"] = \"error\"\n-            sess[\"error_message\"] = f\"Token exchange failed: {e}\"\n+            sess[\"error_message\"] = f\"Token exchange failed: {last_exc}\"\n         return {\"ok\": False, \"status\": \"error\", \"message\": sess[\"error_message\"]}\n \n     access_token = [redacted:secret-assignment]\"access_token\", \"\")"]},{"file":"scripts/release.py","hunks":["@@ -47,6 +47,7 @@\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"wgu9\",  # PR #51468 salvage (WSL/no-systemd orphan gateway tracking, #51325)\n     \"[redacted:email]\": \"uperLu\",  # PR #50958 salvage (rename plugins/cron → plugins/cron_providers; #50872)\n+    \"[redacted:email]\": \"yusekiotacode\",  # PR #48706 salvage (anthropic OAuth login token endpoint → platform.claude.com; #45250/#49821)\n     \"[redacted:email]\": \"s010mn\",  # PR #29221 salvage (ollama-cloud reasoning_effort xhigh→max)\n     \"[redacted:email]\": \"jeeves-assistant\",  # PR #50771 (computer-use CuaDriver vision capture routing)\n     \"[redacted:email]\": \"ScotterMonk\",  # PR #50145 salvage (cron output truncation: adapter-aware chunking, #50126)"]},{"file":"tests/agent/test_anthropic_oauth_pkce.py","hunks":["@@ -148,6 +148,111 @@ def fake_input(*_a, **_kw):\n     )\n \n \n+def test_login_token_exchange_uses_platform_claude_host(monkeypatch, tmp_path):\n+    \"\"\"The login token exchange must hit ``platform.claude.com`` first.\n+\n+    Anthropic migrated the OAuth token endpoint to ``platform.claude.com``;\n+    ``console.anthropic.com`` now 404s, so a hardcoded console host makes a\n+    fresh login impossible (issue #45250 / #49821). The refresh path already\n+    iterates the new host first — the login path must do the same.\n+    \"\"\"\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+    captured_token: [redacted:secret-assignment] Any] = {}\n+    captured_url: Dict[str, str] = {}\n+    _patch_oauth_flow(\n+        monkeypatch,\n+        callback_code=\"placeholder\",\n+        capture_token_request=[redacted:secret-assignment]\n+        capture_auth_url=captured_url,\n+    )\n+\n+    import builtins\n+\n+    def fake_input(*_a, **_kw):\n+        qs = parse_qs(urlparse(captured_url.get(\"url\", \"\")).query)\n+        state = qs.get(\"state\", [\"\"])[0]\n+        return f\"auth-code#{state}\"\n+\n+    monkeypatch.setattr(builtins, \"input\", fake_input)\n+\n+    from agent.anthropic_adapter import run_hermes_oauth_login_pure\n+\n+    result = run_hermes_oauth_login_pure()\n+\n+    assert result is not None, \"login should succeed against the live host\"\n+    assert captured_token[\"url\"] == \"https://platform.claude.com/v1/oauth/token\", (\n+        \"login token exchange must target platform.claude.com first, not the \"\n+        \"dead console.anthropic.com host (regression of #45250 / #49821)\"\n+    )\n+\n+\n+def test_login_token_exchange_falls_back_to_console_host(monkeypatch, tmp_path):\n+    \"\"\"If ``platform.claude.com`` is unreachable, the login path must fall back\n+    to the legacy ``console.anthropic.com`` host — mirroring the refresh path's\n+    fallback list — rather than failing outright.\n+    \"\"\"\n+    import urllib.request\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+\n+    captured_url: Dict[str, str] = {}\n+    _patch_oauth_flow(\n+        monkeypatch,\n+        callback_code=\"placeholder\",\n+        capture_auth_url=captured_url,\n+    )\n+\n+    attempts: list[str] = []\n+\n+    class _FakeResponse:\n+        def __init__(self, body: bytes) -> None:\n+            self._body = body\n+\n+        def __enter__(self):\n+            return self\n+\n+        def __exit__(self, *_exc):\n+            return False\n+\n+        def read(self):\n+            return self._body\n+\n+    def fake_urlopen(req, *_a, **_kw):\n+        attempts.append(req.full_url)\n+        if req.full_url.startswith(\"https://platform.claude.com\"):\n+            raise RuntimeError(\"HTTP Error 404: Not Found\")\n+        body = json.dumps(\n+            {\n+                \"access_token\": \"sk-ant-test-access\",\n+                \"refresh_token\": \"sk-ant-test-refresh\",\n+                \"expires_in\": 3600,\n+            }\n+        ).encode()\n+        return _FakeResponse(body)\n+\n+    monkeypatch.setattr(urllib.request, \"urlopen\", fake_urlopen)\n+\n+    import builtins\n+\n+    def fake_input(*_a, **_kw):\n+        qs = parse_qs(urlparse(captured_url.get(\"url\", \"\")).query)\n+        state = qs.get(\"state\", [\"\"])[0]\n+        return f\"auth-code#{state}\"\n+\n+    monkeypatch.setattr(builtins, \"input\", fake_input)\n+\n+    from agent.anthropic_adapter import run_hermes_oauth_login_pure\n+\n+    result = run_hermes_oauth_login_pure()\n+\n+    assert result is not None, \"login should succeed via the console fallback\"\n+    assert attempts == [\n+        \"https://platform.claude.com/v1/oauth/token\",\n+        \"https://console.anthropic.com/v1/oauth/token\",\n+    ], \"login must try platform.claude.com first, then fall back to console\"\n+\n+\n def test_callback_state_mismatch_aborts(monkeypatch, tmp_path, caplog):\n     \"\"\"If the state returned in the callback does not match the one we sent\n     in the authorization URL, the flow must abort before exchanging the code.\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}