# NousResearch/hermes-agent · Anthropic OAuth token endpoint URL migration issues

## Open now

- 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.

## TL;DR

- 14 member(s): 0 open issue(s), 7 closed issue(s), 0 open PR(s), 1 merged PR(s), 6 PR(s) closed without merging.
- A fix was merged to main in PR #51723. Verify it addresses your case.
- **Recommended:** read PR #51723 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-51723`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Closed / duplicate issues

- #45250: Anthropic OAuth login (auth add anthropic --type oauth) fails with 404 — login flow uses stale token endpoint
- #47692: OAuth login (anthropic) hits dead console.anthropic.com token endpoint → HTTP 404; refresh path already moved to platform.claude.com
- #47964: OAuth: _OAUTH_TOKEN_URL points to console.anthropic.com (404) — should be api.anthropic.com
- #47966: PKCE redirect_uri mismatch: console.anthropic.com vs platform.claude.com causes invalid tokens
- #49821: Anthropic OAuth login 404s: token exchange still uses migrated console.anthropic.com endpoint
- #51559: anthropic OAuth login token exchange hardcodes dead console.anthropic.com URL → HTTP 404, login never completes
- #52630: Anthropic OAuth login (auth add anthropic --type oauth) 404s: _OAUTH_TOKEN_URL points to console.anthropic.com; refresh path already uses platform.claude.com

## Prior & competing PRs

- **#45339: fix(auth): try fallback token endpoints for Anthropic OAuth login (#45250)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#45254`, `#45250`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#50982: fix: update Anthropic OAuth token endpoint from console.anthropic.com to platform.claude.com**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#51723: fix(anthropic): use platform.claude.com for OAuth login token exchange**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/anthropic_adapter.py`, `hermes_cli/web_server.py`, `scripts/release.py`, `tests/agent/test_anthropic_oauth_pkce.py`
  - fix hunks:
  - `agent/anthropic_adapter.py`

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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.
    ```
- **#45254: fix(auth): iterate token endpoints in Anthropic OAuth login flow**. closed without merge
  - maintainer direction: category `wontfix_bydesign`; implication `wontfix`
- **#47765: fix(auth): update OAuth token endpoint to platform.claude.com (#47692)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#45254`, `#45254`, `#46251`, `#45441`, `#47692`
- **#47989: fix(auth): update Anthropic OAuth token URL (#47964)**. closed without merge
  - maintainer direction: category `wontfix_bydesign`; implication `wontfix`
- **#47992: fix(auth): correct OAuth redirect URI to platform.claude.com (#47966)**. closed without merge
  - maintainer direction: category `wontfix_bydesign`; implication `wontfix`

## Fix status

- Merged to main in PR #51723 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-24`.
- Subsystem: `agent`
