headroomlabs-ai/headroom · error · OAuth2Error

token endpoint response had no access_token

Error message

token endpoint response had no access_token

What it means

The OAuth2 client credentials provider in headroom-oauth2 successfully reached the identity provider's token endpoint and got an HTTP 200 with a JSON body, but the parsed payload contained no usable 'access_token' field (missing, null, or empty string). The provider only treats HTTP errors, connection failures, and malformed JSON as separate failures, so this error specifically means the IdP answered 'OK' but did not hand out a token. This almost always indicates a configuration mismatch (wrong token URL, wrong grant parameters) rather than a network problem.

Source

Thrown at plugins/headroom-oauth2/src/headroom_oauth2/provider.py:141

            self.token_url,
            data=urllib.parse.urlencode(form).encode(),
            headers=headers,
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=self.timeout) as resp:
                payload = json.load(resp)
        except HTTPError as e:
            with suppress(Exception):
                e.read()  # drain; do NOT surface the IdP body (may echo sensitive context)
            raise OAuth2Error(f"token endpoint returned HTTP {e.code}") from None
        except (URLError, OSError) as e:
            raise OAuth2Error(f"token endpoint unreachable: {e}") from None
        except json.JSONDecodeError:
            raise OAuth2Error("token endpoint returned non-JSON") from None
        token = payload.get("access_token")
        if not token:
            raise OAuth2Error("token endpoint response had no access_token")
        raw = payload.get("expires_in")
        try:
            ttl = int(float(raw))  # tolerate "3600", "3600.0", 3600, or a JSON float
        except (TypeError, ValueError):
            ttl = 300
        ttl = max(1, ttl)  # 0/negative would cause a stale token or per-request minting
        log.info("oauth2: minted token (ttl=%ss, scopes=%s)", ttl, self.scopes or "-")
        return token, ttl

View on GitHub (pinned to 322425c43b)

Solutions

  1. Manually reproduce the token request with curl (POST to the configured token URL with grant_type=client_credentials, client_id, client_secret) and inspect the JSON body — it will usually show an error field or a login page instead of a token
  2. Verify the token URL in the provider config is the IdP's actual token endpoint (e.g. https://idp.example.com/oauth2/token, not the issuer base or /authorize)
  3. Confirm the client is allowed to use the client_credentials grant and that the requested scopes are registered for that client
  4. Check for an intermediary (API gateway, service mesh sidecar, captive proxy) that returns 200 with its own JSON on failure

Example fix

# before: token_url pointed at the issuer base
provider = OAuth2Provider(
    token_url="https://idp.example.com/oauth2/",  # 200 JSON, no access_token
    ...
)

# after: point at the real token endpoint
provider = OAuth2Provider(
    token_url="https://idp.example.com/oauth2/token",
    ...
)
Defensive patterns

Strategy: try-catch

Validate before calling

import urllib.request, json, urllib.parse

def token_endpoint_smoke_check(token_url: str, client_id: str, client_secret: str) -> bool:
    """Preflight: confirm the token endpoint mints an access_token before wiring the provider."""
    data = urllib.parse.urlencode({
        "grant_type": "client_credentials",
        "client_id": client_id,
        "client_secret": client_secret,
    }).encode()
    req = urllib.request.Request(token_url, data=data, headers={"Accept": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            return bool(json.load(resp).get("access_token"))
    except Exception:
        return False

Type guard

from headroom_oauth2.errors import OAuth2Error

def is_oauth2_config_error(exc: BaseException) -> bool:
    """True when the provider rejected the token response shape (config-level, not transient)."""
    return isinstance(exc, OAuth2Error) and "no access_token" in str(exc)

Try / catch

from headroom_oauth2.errors import OAuth2Error

try:
    token, ttl = provider.fetch_token()
except OAuth2Error as e:
    if "no access_token" in str(e):
        # IdP answered 200 but issued no token: configuration problem, do not retry blindly
        raise RuntimeError(f"IdP token endpoint misconfigured: {e}") from None
    raise

Prevention

When it happens

Trigger: Calling the token fetch on the provider (any path that ends up requesting a token via client credentials) where: the token_url points at the IdP's base URL or an introspection/userinfo endpoint instead of the token endpoint; the IdP returns 200 with an error payload (some proxies and API gateways do this); the client is not registered for the client_credentials grant so the IdP returns a body without access_token; a required scope or assertion parameter is missing and the IdP responds non-standardly.

Common situations: Auth server URL copy-pasted without the /token path; wrong environment's token endpoint (dev vs prod IdP); IdP or gateway rewrites error responses to 200 JSON; client secret valid for a different client_id; service accounts not enabled for machine-to-machine grants.

Related errors


AI-assisted analysis of headroomlabs-ai/headroom@322425c43b (2026-08-15). Data as JSON: /api/errors/ccad7d1b1b4408b6. Report an issue: GitHub.