Anthropic OAuth token endpoint URL migration issues

NousResearch/hermes-agent · cluster 45250 · agent

Data as of

Nothing open on GitHub. Fix merged in #51723. Check it covers your case.

The short answer

0open issues
0open PRs
14items grouped
Known?
Yes. 14 related items grouped here, 7 issues already closed.
Fix event
A fix was merged to main in PR #51723. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #51723. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #51723 fix(anthropic): use platform.claude.com for OAuth login token exchange

    merged to main

    files: agent/anthropic_adapter.py, hermes_cli/web_server.py, scripts/release.py, tests/agent/test_anthropic_oauth_pkce.py

    Show the diff
    • agent/anthropic_adapter.py
      @@ -1297,7 +1297,15 @@ def run_oauth_setup_token() -> Optional[str]:
       # Stores credentials in ~/.hermes/.anthropic_oauth.json (our own file).
       
       _OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
      -_OAUTH_TOKEN_URL = "[redacted:secret-assignment]"
      +# Anthropic migrated the OAuth token endpoint to platform.claude.com;
      +# console.anthropic.com now 404s. Callers should iterate _OAUTH_TOKEN_URLS
      +# (new host first, console fallback). _OAUTH_TOKEN_URL is kept as the primary
      +# for backward compatibility with existing imports and now points at the live host.
      +_OAUTH_TOKEN_URLS = [
      +    "https://platform.claude.com/v1/oauth/token",
      +    "https://console.anthropic.com/v1/oauth/token",
      +]
      +_OAUTH_TOKEN_URL = [redacted:secret-assignment]
       _OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
       _OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
       _HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
      @@ -1395,18 +1403,34 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
                   "code_verifier": verifier,
               }).encode()
       
      -        req = urllib.request.Request(
      -            _OAUTH_TOKEN_URL,
      -            data=exchange_data,
      -            headers={
      -                "Content-Type": "application/json",
      -                "User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
      -            },
      -            method="POST",
      -        )
      +        # Anthropic migrated the OAuth token endpoint to platform.claude.com;
      +        # console.anthropic.com now 404s. Try the new host first, then fall
      +        # back to console for older deployments (mirrors the refresh path).
      +        result = None
      +        last_error = None
      +        for endpoint in _OAUTH_TOKEN_URLS:
      +            req = urllib.request.Request(
      +                endpoint,
      +                data=exchange_data,
      +                headers={
      +                    "Content-Type": "application/json",
      +                    "User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
      +                },
      +                method="POST",
      +            )
      +            try:
      +                with urllib.request.urlopen(req, timeout=15) as resp:
      +                    result = json.loads(resp.read().decode())
      +                break
      +            except Exception as exc:
      +                last_error = exc
      +                logger.debug("Anthropic token exchange failed at %s: %s", endpoint, exc)
      +                continue
       
      -        with urllib.request.urlopen(req, timeout=15) as resp:
      -            result = json.loads(resp.read().decode())
      +        if result is None:
      +            raise last_error if last_error is not None else ValueError(
      +                "Anthropic token exchange failed"
      +            )
           except Exception as e:
               print(f"Token exchange failed: {e}")
               return None
    • hermes_cli/web_server.py
      @@ -6123,6 +6123,7 @@ async def disconnect_oauth_provider(
           from agent.anthropic_adapter import (
               _OAUTH_CLIENT_ID as _ANTHROPIC_OAUTH_CLIENT_ID,
               _OAUTH_TOKEN_URL as _ANTHROPIC_OAUTH_TOKEN_URL,
      +        _OAUTH_TOKEN_URLS as _ANTHROPIC_OAUTH_TOKEN_URLS,
               _OAUTH_REDIRECT_URI as _ANTHROPIC_OAUTH_REDIRECT_URI,
               _OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES,
               _generate_pkce as _generate_pkce_pair,
      @@ -6311,22 +6312,31 @@ def _submit_anthropic_pkce(
               "redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI,
               "code_verifier": sess["verifier"],
           }).encode()
      -    req = urllib.request.Request(
      -        _ANTHROPIC_OAUTH_TOKEN_URL,
      -        data=exchange_data,
      -        headers={
      -            "Content-Type": "application/json",
      -            "User-Agent": "hermes-dashboard/1.0",
      -        },
      -        method="POST",
      -    )
      -    try:
      -        with urllib.request.urlopen(req, timeout=20) as resp:
      -            result = json.loads(resp.read().decode())
      -    except Exception as e:
      +    # Anthropic migrated the OAuth token endpoint to platform.claude.com;
      +    # console.anthropic.com now 404s. Try the new host first, then fall back.
      +    result = None
      +    last_exc = None
      +    for _endpoint in _ANTHROPIC_OAUTH_TOKEN_URLS:
      +        req = urllib.request.Request(
      +            _endpoint,
      +            data=exchange_data,
      +            headers={
      +                "Content-Type": "application/json",
      +                "User-Agent": "hermes-dashboard/1.0",
      +            },
      +            method="POST",
      +        )
      +        try:
      +            with urllib.request.urlopen(req, timeout=20) as resp:
      +                result = json.loads(resp.read().decode())
      +            break
      +        except Exception as e:
      +            last_exc = e
      +            continue
      +    if result is None:
               with _oauth_sessions_lock:
                   sess["status"] = "error"
      -            sess["error_message"] = f"Token exchange failed: {e}"
      +            sess["error_message"] = f"Token exchange failed: {last_exc}"
               return {"ok": False, "status": "error", "message": sess["error_message"]}
       
           access_token = [redacted:secret-assignment]"access_token", "")
    • scripts/release.py
      @@ -47,6 +47,7 @@
       AUTHOR_MAP = {
           "[redacted:email]": "wgu9",  # PR #51468 salvage (WSL/no-systemd orphan gateway tracking, #51325)
           "[redacted:email]": "uperLu",  # PR #50958 salvage (rename plugins/cron → plugins/cron_providers; #50872)
      +    "[redacted:email]": "yusekiotacode",  # PR #48706 salvage (anthropic OAuth login token endpoint → platform.claude.com; #45250/#49821)
           "[redacted:email]": "s010mn",  # PR #29221 salvage (ollama-cloud reasoning_effort xhigh→max)
           "[redacted:email]": "jeeves-assistant",  # PR #50771 (computer-use CuaDriver vision capture routing)
           "[redacted:email]": "ScotterMonk",  # PR #50145 salvage (cron output truncation: adapter-aware chunking, #50126)
    • tests/agent/test_anthropic_oauth_pkce.py
      @@ -148,6 +148,111 @@ def fake_input(*_a, **_kw):
           )
       
       
      +def test_login_token_exchange_uses_platform_claude_host(monkeypatch, tmp_path):
      +    """The login token exchange must hit ``platform.claude.com`` first.
      +
      +    Anthropic migrated the OAuth token endpoint to ``platform.claude.com``;
      +    ``console.anthropic.com`` now 404s, so a hardcoded console host makes a
      +    fresh login impossible (issue #45250 / #49821). The refresh path already
      +    iterates the new host first — the login path must do the same.
      +    """
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +
      +    captured_token: [redacted:secret-assignment] Any] = {}
      +    captured_url: Dict[str, str] = {}
      +    _patch_oauth_flow(
      +        monkeypatch,
      +        callback_code="placeholder",
      +        capture_token_request=[redacted:secret-assignment]
      +        capture_auth_url=captured_url,
      +    )
      +
      +    import builtins
      +
      +    def fake_input(*_a, **_kw):
      +        qs = parse_qs(urlparse(captured_url.get("url", "")).query)
      +        state = qs.get("state", [""])[0]
      +        return f"auth-code#{state}"
      +
      +    monkeypatch.setattr(builtins, "input", fake_input)
      +
      +    from agent.anthropic_adapter import run_hermes_oauth_login_pure
      +
      +    result = run_hermes_oauth_login_pure()
      +
      +    assert result is not None, "login should succeed against the live host"
      +    assert captured_token["url"] == "https://platform.claude.com/v1/oauth/token", (
      +        "login token exchange must target platform.claude.com first, not the "
      +        "dead console.anthropic.com host (regression of #45250 / #49821)"
      +    )
      +
      +
      +def test_login_token_exchange_falls_back_to_console_host(monkeypatch, tmp_path):
      +    """If ``platform.claude.com`` is unreachable, the login path must fall back
      +    to the legacy ``console.anthropic.com`` host — mirroring the refresh path's
      +    fallback list — rather than failing outright.
      +    """
      +    import urllib.request
      +
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +
      +    captured_url: Dict[str, str] = {}
      +    _patch_oauth_flow(
      +        monkeypatch,
      +        callback_code="placeholder",
      +        capture_auth_url=captured_url,
      +    )
      +
      +    attempts: list[str] = []
      +
      +    class _FakeResponse:
      +        def __init__(self, body: bytes) -> None:
      +            self._body = body
      +
      +        def __enter__(self):
      +            return self
      +
      +        def __exit__(self, *_exc):
      +            return False
      +
      +        def read(self):
      +            return self._body
      +
      +    def fake_urlopen(req, *_a, **_kw):
      +        attempts.append(req.full_url)
      +        if req.full_url.startswith("https://platform.claude.com"):
      +            raise RuntimeError("HTTP Error 404: Not Found")
      +        body = json.dumps(
      +            {
      +                "access_token": "sk-ant-test-access",
      +                "refresh_token": "sk-ant-test-refresh",
      +                "expires_in": 3600,
      +            }
      +        ).encode()
      +        return _FakeResponse(body)
      +
      +    monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
      +
      +    import builtins
      +
      +    def fake_input(*_a, **_kw):
      +        qs = parse_qs(urlparse(captured_url.get("url", "")).query)
      +        state = qs.get("state", [""])[0]
      +        return f"auth-code#{state}"
      +
      +    monkeypatch.setattr(builtins, "input", fake_input)
      +
      +    from agent.anthropic_adapter import run_hermes_oauth_login_pure
      +
      +    result = run_hermes_oauth_login_pure()
      +
      +    assert result is not None, "login should succeed via the console fallback"
      +    assert attempts == [
      +        "https://platform.claude.com/v1/oauth/token",
      +        "https://console.anthropic.com/v1/oauth/token",
      +    ], "login must try platform.claude.com first, then fall back to console"
      +
      +
       def test_callback_state_mismatch_aborts(monkeypatch, tmp_path, caplog):
           """If the state returned in the callback does not match the one we sent
           in the authorization URL, the flow must abort before exchanging the code.

6 PRs closed without merging

From the close comments: won't fix / by design 3 · duplicate 1 · superseded by another PR 1 · other 1

  • #45254 fix(auth): iterate token endpoints in Anthropic OAuth login flow · won't fix / by design
  • #45339 fix(auth): try fallback token endpoints for Anthropic OAuth login (#45250) · superseded by another PR → #45254, #45250
  • #47765 fix(auth): update OAuth token endpoint to platform.claude.com (#47692) · duplicate → #45254, #46251, #45441, #47692
  • #47989 fix(auth): update Anthropic OAuth token URL (#47964) · won't fix / by design
  • #47992 fix(auth): correct OAuth redirect URI to platform.claude.com (#47966) · won't fix / by design
Show 1 more
  • #50982 fix: update Anthropic OAuth token endpoint from console.anthropic.com to platform.claude.com · other
Closed issues · 7
  • Closed#45250Anthropic OAuth login (auth add anthropic --type oauth) fails with 404 — login flow uses stale token endpoint
  • Closed#47692OAuth login (anthropic) hits dead console.anthropic.com token endpoint → HTTP 404; refresh path already moved to platform.claude.com
  • Closed#47964OAuth: _OAUTH_TOKEN_URL points to console.anthropic.com (404) — should be api.anthropic.com
  • Closed#47966PKCE redirect_uri mismatch: console.anthropic.com vs platform.claude.com causes invalid tokens
  • Closed#49821Anthropic OAuth login 404s: token exchange still uses migrated console.anthropic.com endpoint
  • Closed#51559anthropic OAuth login token exchange hardcodes dead console.anthropic.com URL → HTTP 404, login never completes
  • Closed#52630Anthropic OAuth login (auth add anthropic --type oauth) 404s: _OAUTH_TOKEN_URL points to console.anthropic.com; refresh path already uses platform.claude.com
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.14: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#51723
Closed issues
7
Attempts
6 PRs closed without merging
Fix link checked
24 Jun 2026

For your agent

The same facts, typed.