JuliusBrussee/caveman · error

device login refused a redirected token endpoint

Error message

device login refused a redirected token endpoint

What it means

Security check during token polling: if the token endpoint responds with an HTTP 3xx redirect, the CLI refuses to follow it and throws immediately. OAuth token endpoints must be called directly; following a redirect could leak the device_code/credentials to another host.

Solutions

  1. Use the final, canonical https URL of the instance in --instance so no redirect occurs
  2. Fix the reverse proxy/load balancer so the token endpoint is served directly without redirects
  3. Check for trailing-slash or http→https redirect rules affecting the token path

Example fix

// before
--instance=http://auth.example.com   // 301 -> https://auth.example.com
// after
--instance=https://auth.example.com  // no redirect
Defensive patterns

Strategy: validation

Validate before calling

const u = new URL(instance);
if (u.protocol !== "https:") console.warn("http instance URLs may trigger token-endpoint redirects; use the canonical https URL");

Try / catch

try { await login({ instance }) } catch (e) { if (e.message.includes("redirected token endpoint")) console.error("Use the final non-redirecting https URL for --instance"); }

Prevention

When it happens

Trigger: The token-endpoint poll returns a status in 300–399 (e.g. 301/302), typically because the instance URL is http and gets redirected to https, a trailing-slash redirect, a misconfigured reverse proxy, or a load balancer bouncing the path.

Common situations: Using http:// --instance where the server redirects to https; proxy appending/removing a trailing slash; misconfigured load balancer redirecting /oauth/token; pointing at a host that redirects all traffic.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/44f95b74b218ca24. Report an issue: GitHub.

Appendix: source

Thrown at packages/cli/src/index.ts:9723

      tokenStatus = tokResp.status;
      const retryAfter = tokResp.headers.get("retry-after");
      if (retryAfter) {
        const seconds = Number(retryAfter);
        if (Number.isFinite(seconds) && seconds >= 0) retryAfterMs = seconds * 1000;
      }
      tok = tokenStatus >= 300 && tokenStatus < 400 ? {} : await tokResp.json() as Record<string, unknown>;
    } catch (error) {
      // RFC 8628 polling is retryable: a dropped connection or malformed
      // transient response must not consume the approved code or abort login
      // before the bounded device deadline. The next poll can reclaim the
      // server-side lease and replay the same durable bundle.
      if (Date.now() >= deadline) {
        throw new Error(`device login polling failed: ${error instanceof Error ? error.message : String(error)}`);
      }
      await sleep(Math.max(intervalMs, retryAfterMs, 200));
      continue;
    }
    if (tokenStatus >= 300 && tokenStatus < 400) throw new Error("device login refused a redirected token endpoint");
    if (tokenStatus === 429) {
      // rateLimitAuth returns a nested cave error envelope rather than the RFC
      // `error` string. Status is the authoritative retry signal here.
      await sleep(Math.max(intervalMs, retryAfterMs, 200));
      continue;
    }
    const accessToken = typeof tok.access_token === "string" ? tok.access_token : "";
    if (accessToken) {
	  if (instance && (tokenStatus < 200 || tokenStatus >= 300 || tok.credential_kind !== "none" ||
	      ["gateway_api_key", "gateway_key_id", "gateway_url"].some((key) => tok[key] != null) ||
	      typeof tok.refresh_token !== "string" || !tok.refresh_token || typeof tok.project_id !== "string" || !tok.project_id ||
	      typeof tok.delivery_ack_token !== "string" || !tok.delivery_ack_token || typeof tok.scope !== "string" || !tok.scope ||
	      tok.scope.split(/\s+/).some((scope) => scope === "proxy:write" || scope === "sdk:write"))) {
	    throw new Error("private device login requires a keyless project grant with a refresh token and delivery acknowledgement");
	  }
	  const credentials: StoredCredentials = {
	    access_token: accessToken,
	    ...(typeof tok.refresh_token === "string" && tok.refresh_token ? { refresh_token: tok.refresh_token } : {}),

View on GitHub (pinned to 3ee70a1026)