headroomlabs-ai/headroom · error · OAuth2Error

token endpoint returned HTTP {e.code}

Error message

token endpoint returned HTTP {e.code}

What it means

The OAuth2 provider's token request (stdlib urllib POST) received an HTTP error status from the IdP; it is re-raised as OAuth2Error with just the status code. The response body is deliberately drained and NOT surfaced, because IdP error bodies may echo sensitive context (client_id, redirect hints). Common codes: 400 invalid_client/scope, 401 bad credentials, 404 wrong URL path.

Source

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

        if self.auth_style == "basic":
            creds = base64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode()
            headers["Authorization"] = "Basic " + creds
        else:
            form["client_id"] = self.client_id
            form["client_secret"] = self.client_secret
        req = urllib.request.Request(
            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. Check the status: 401/400 → verify client_id/client_secret and scopes against the IdP app registration; 404 → re-check the token_url path and tenant
  2. Re-issue and redeploy the client secret if credentials were rotated or expired
  3. Reproduce outside the app with curl (safely, in a dev tenant) to see the IdP's error body, which the library intentionally hides: `curl -d grant_type=client_credentials -d client_id=... -d client_secret=... -d scope=... $TOKEN_URL`

Example fix

# before
HEADROOM_OAUTH2_CLIENT_SECRET=old-rotated-secret

# after
HEADROOM_OAUTH2_CLIENT_SECRET=newly-issued-secret
# verify shape first:
# curl -s -o /dev/null -w '%{http_code}' -d 'grant_type=client_credentials' \
#   -d 'client_id=$ID' -d 'client_secret=$SECRET' -d 'scope=$SCOPE' $TOKEN_URL  -> want 200
Defensive patterns

Strategy: retry

Validate before calling

# cheap pre-flight: credentials non-empty and scope names confirmed against app registration
assert client_id and client_secret, "empty oauth2 credentials will yield HTTP 400/401"
# optional dev-tenant probe (never log the secret):
# curl -o /dev/null -w '%{http_code}' -d grant_type=client_credentials -d client_id=$ID -d client_secret=$SEC -d scope=$SCOPE $TOKEN_URL == 200

Try / catch

from headroom_oauth2.provider import OAuth2Error

for attempt in range(3):
    try:
        token = provider.get_token()
        break
    except OAuth2Error as e:
        if "HTTP 401" in str(e) or "HTTP 400" in str(e):
            raise RuntimeError(f"credentials/scopes rejected by IdP: {e}") from e  # not retryable
        if attempt == 2:
            raise
        time.sleep(2 ** attempt)  # 5xx / transient only

Prevention

When it happens

Trigger: Minting a token with wrong client_id/client_secret (401), requesting an unsupported scope or audience (400), a token URL with a wrong path (404), or client credentials that were rotated and revoked (400 invalid_client).

Common situations: Secret rotation where the deployed credential was revoked; typo'd scope names like `api://my-app/.default` mangled by templating; copying a token URL from docs but with the wrong tenant or missing `/token` suffix; clock/env drift between environments sharing one app registration.

Related errors


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