{"record":{"id":"b908b555b8087361","repo":"headroomlabs-ai/headroom","slug":"token-endpoint-returned-non-json","errorCode":null,"errorMessage":"token endpoint returned non-JSON","messagePattern":"token endpoint returned non-JSON","errorType":"exception","errorClass":"OAuth2Error","httpStatus":null,"severity":"error","filePath":"plugins/headroom-oauth2/src/headroom_oauth2/provider.py","lineNumber":138,"sourceCode":"            form[\"client_id\"] = self.client_id\n            form[\"client_secret\"] = self.client_secret\n        req = urllib.request.Request(\n            self.token_url,\n            data=urllib.parse.urlencode(form).encode(),\n            headers=headers,\n            method=\"POST\",\n        )\n        try:\n            with urllib.request.urlopen(req, timeout=self.timeout) as resp:\n                payload = json.load(resp)\n        except HTTPError as e:\n            with suppress(Exception):\n                e.read()  # drain; do NOT surface the IdP body (may echo sensitive context)\n            raise OAuth2Error(f\"token endpoint returned HTTP {e.code}\") from None\n        except (URLError, OSError) as e:\n            raise OAuth2Error(f\"token endpoint unreachable: {e}\") from None\n        except json.JSONDecodeError:\n            raise OAuth2Error(\"token endpoint returned non-JSON\") from None\n        token = payload.get(\"access_token\")\n        if not token:\n            raise OAuth2Error(\"token endpoint response had no access_token\")\n        raw = payload.get(\"expires_in\")\n        try:\n            ttl = int(float(raw))  # tolerate \"3600\", \"3600.0\", 3600, or a JSON float\n        except (TypeError, ValueError):\n            ttl = 300\n        ttl = max(1, ttl)  # 0/negative would cause a stale token or per-request minting\n        log.info(\"oauth2: minted token (ttl=%ss, scopes=%s)\", ttl, self.scopes or \"-\")\n        return token, ttl\n","sourceCodeStart":120,"sourceCodeEnd":150,"githubUrl":"https://github.com/headroomlabs-ai/headroom/blob/322425c43bffde1ed0b64fecf3cf5951565dd82b/plugins/headroom-oauth2/src/headroom_oauth2/provider.py#L120-L150","documentation":"The token endpoint returned a 2xx response whose body is not valid JSON, so `json.load(resp)` raised JSONDecodeError, re-wrapped as OAuth2Error. The URL is reachable and returns HTTP 200, but the payload is something else — an HTML login/consent page, a plaintext WAF/block page, or a misrited gateway response.","triggerScenarios":"token_url pointing at a page that returns 200 HTML (e.g. the IdP's login page because the `/token` path is wrong or the endpoint expects browser flows), a captive portal/WAF intercepting with a 200 block page, or a gateway rewriting the response.","commonSituations":"Token URL copy-paste errors landing on the IdP UI base path instead of the token endpoint; service mesh sidecars injecting an HTML error page with 200; proxies that replace responses; OAuth endpoints that require a trailing path segment (e.g. `/oauth2/v2.0/token` truncated to `/oauth2`).","solutions":["Confirm the exact token endpoint path from your IdP docs (Azure: `.../oauth2/v2.0/token`; Keycloak: `.../protocol/openid-connect/token`; Auth0: `.../oauth/token`) and fix token_url","Curl the URL and inspect content type: `curl -si -d 'grant_type=client_credentials' $TOKEN_URL | head -20` — if you see HTML, you are not hitting the token endpoint","If a WAF/sidecar/mesh is rewriting responses, add an exclusion for the token endpoint or route the provider around it"],"exampleFix":"# before\nHEADROOM_OAUTH2_TOKEN_URL=https://login.microsoftonline.com/<tenant>/oauth2   # HTML discovery page, 200\n\n# after\nHEADROOM_OAUTH2_TOKEN_URL=https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token","handlingStrategy":"validation","validationCode":"import json, urllib.request\n\ndef token_endpoint_speaks_json(url: str) -> bool:\n    try:\n        req = urllib.request.Request(url, data=b\"grant_type=client_credentials\", method=\"POST\")\n        with urllib.request.urlopen(req, timeout=5) as r:\n            ct = r.headers.get(\"Content-Type\", \"\")\n            body = r.read(64)\n        return \"json\" in ct or body.lstrip()[:1] == b\"{\"\n    except Exception:\n        return False\n\nif not token_endpoint_speaks_json(url):\n    raise RuntimeError(f\"{url} does not return JSON — check the token path\")","typeGuard":"def looks_like_token_url(url: str) -> bool:\n    return url.rstrip('/').endswith((\"/token\", \"oauth/token\", \"openid-connect/token\"))","tryCatchPattern":"from headroom_oauth2.provider import OAuth2Error\n\ntry:\n    token = provider.get_token()\nexcept OAuth2Error as e:\n    if \"non-JSON\" in str(e):\n        raise RuntimeError(\"token_url points at a non-API page; verify the IdP token path\") from e\n    raise","preventionTips":["Copy token endpoints from the IdP's machine-to-machine docs, not the browser address bar","Health-check the endpoint at deploy time with a probe that asserts a JSON content type","Watch for WAF/mesh sidecars returning 200 HTML pages; exclude the token path from rewriting"],"tags":["oauth2","api-contract","json","identity-provider"],"backgroundTag":null,"analyzedSha":"322425c43bffde1ed0b64fecf3cf5951565dd82b","analyzedAt":"2026-08-15T01:03:05.481Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}