decolua/9router · error

Token exchange failed: ${error}

Error message

Token exchange failed: ${error}

What it means

Thrown by OAuthService.exchangeCode() when the POST to the provider's token endpoint returns a non-2xx response. The response body text (which usually contains the OAuth error JSON such as `invalid_grant`, `invalid_client`, or `redirect_uri_mismatch`) is embedded verbatim in the message. It means the authorization code could not be swapped for tokens.

Source

Thrown at src/lib/oauth/services/oauth.js:116

            grant_type: "authorization_code",
            client_id: this.config.clientId,
            code: code,
            redirect_uri: redirectUri,
            code_verifier: codeVerifier,
          });

    const response = await fetch(this.config.tokenUrl, {
      method: "POST",
      headers: {
        "Content-Type": contentType,
        Accept: "application/json",
      },
      body: body,
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Token exchange failed: ${error}`);
    }

    return await response.json();
  }

  /**
   * Complete OAuth flow
   */
  async authenticate(providerName, buildAuthUrlFn) {
    // Generate PKCE
    const { codeVerifier, codeChallenge, state } = generatePKCE();

    // Start local server and get redirect URI
    const { redirectUri, waitForCallback } = await this.startAuthFlow(null, providerName);

    // Build authorization URL
    const authUrl = buildAuthUrlFn(redirectUri, state, codeChallenge);

View on GitHub (pinned to 90b52e06ff)

Solutions

  1. Read the embedded body text — it names the exact OAuth error (invalid_grant, invalid_client, redirect_uri_mismatch) and fix that specific cause.
  2. Restart the whole auth flow to get a fresh, unused authorization code; codes are single-use and short-lived.
  3. Ensure the redirect_uri passed to exchangeCode is byte-identical to the one used in the authorize URL (same port).
  4. Verify clientId and PKCE code_verifier match the challenge sent in buildAuthUrl; if regenerating, regenerate both together.
  5. Try the alternate contentType (application/json vs application/x-www-form-urlencoded) if the provider's token endpoint rejects the default.

Example fix

// before (opaque text blob)
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
// after (surface status + parsed OAuth error)
const text = await response.text();
let detail = text;
try { detail = JSON.parse(text).error_description || JSON.parse(text).error || text; } catch {}
throw new Error(`Token exchange failed (HTTP ${response.status}): ${detail}`);
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-flight sanity checks before calling exchangeCode:
if (!code || !codeVerifier || !redirectUri) throw new Error("Missing code/verifier/redirectUri before exchange");
if (!tokenUrl.startsWith("https://")) throw new Error("Token URL must be https");

Type guard

function isTokenResponse(json) {
  return json != null && typeof json === "object" && typeof json.access_token === "string";
}

Try / catch

try {
  const tokens = await service.exchangeCode(code, redirectUri, codeVerifier);
} catch (err) {
  if (err.message.startsWith("Token exchange failed:")) {
    if (err.message.includes("invalid_grant")) { /* code expired/used — rerun auth */ }
    else if (err.message.includes("redirect_uri")) { /* align redirect_uri with authorize call */ }
    else throw err;
  } else throw err;
}

Prevention

When it happens

Trigger: exchangeCode(code, redirectUri, codeVerifier, contentType) posts grant_type=authorization_code to config.tokenUrl and the upstream replies !response.ok — e.g. the code was already redeemed or expired, code_verifier doesn't match the challenge, redirect_uri differs from the authorize request, clientId is wrong, or the Content-Type the provider expects (form vs JSON) was mismatched.

Common situations: Re-running the flow and reusing the old one-time code; PKCE verifier/challenge mismatch across restarted CLI runs; redirect_uri changed because the local callback server got a different port; provider requires HTTP Basic client auth instead of body credentials; proxy/firewall returning an HTML error page.

Related errors


AI-assisted analysis of decolua/9router@90b52e06ff (2026-08-30). Data as JSON: /api/errors/fb1a4e52dac5aa4a. Report an issue: GitHub.