sipeed/picoclaw · error

parsing token response: %w

Error message

parsing token response: %w

What it means

parseTokenResponse (pkg/auth/oauth.go:579) failed to json.Unmarshal the token-endpoint body into {access_token, refresh_token, expires_in, id_token}. This is hit by RefreshAccessToken and ExchangeCodeForTokens whenever the body is not strict JSON or a field has the wrong JSON type — notably expires_in as a string ("3600") fails unmarshal into int.

Source

Thrown at pkg/auth/oauth.go:579

	if err != nil {
		return nil, fmt.Errorf("reading token exchange response: %w", err)
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("token exchange failed: %s", string(body))
	}

	return parseTokenResponse(body, provider)
}

func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
	var tokenResp struct {
		AccessToken  string `json:"access_token"`
		RefreshToken string `json:"refresh_token"`
		ExpiresIn    int    `json:"expires_in"`
		IDToken      string `json:"id_token"`
	}
	if err := json.Unmarshal(body, &tokenResp); err != nil {
		return nil, fmt.Errorf("parsing token response: %w", err)
	}

	if tokenResp.AccessToken == "" {
		return nil, fmt.Errorf("no access token in response")
	}

	var expiresAt time.Time
	if tokenResp.ExpiresIn > 0 {
		expiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
	}

	cred := &AuthCredential{
		AccessToken:  tokenResp.AccessToken,
		RefreshToken: tokenResp.RefreshToken,
		ExpiresAt:    expiresAt,
		Provider:     provider,
		AuthMethod:   "oauth",
	}

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Capture the raw body and run it through a JSON validator / jq to find the offending field
  2. If expires_in arrives as a string, relax the struct: use json.Number or a custom flexible int type like parseFlexibleInt
  3. If the body is HTML, fix the network path (wrong tokenURL, intercepting proxy) — this is a transport problem masquerading as a parse problem
  4. Align mock/test fixtures with the provider's real schema

Example fix

// before
ExpiresIn int `json:"expires_in"`

// after (accept numeric strings)
ExpiresIn json.Number `json:"expires_in"`
// then: expiresIn, _ := tokenResp.ExpiresIn.Int64()
Defensive patterns

Strategy: type-guard

Validate before calling

// Probe the payload shape before relying on the library parse
var probe map[string]json.RawMessage
if err := json.Unmarshal(body, &probe); err != nil {
	return fmt.Errorf("token endpoint returned non-JSON (len %d)", len(body))
}
if _, ok := probe["access_token"]; !ok {
	return fmt.Errorf("token response missing access_token key")
}

Type guard

func isTokenParseError(err error) bool {
	return err != nil && strings.Contains(err.Error(), "parsing token response")
}

Try / catch

cred, err := auth.ExchangeCodeForTokens(cfg, code, verifier, redirectURI)
if err != nil && isTokenParseError(err) {
	// likely type mismatch (e.g. string expires_in) or HTML body; capture raw body for diagnosis
	return fmt.Errorf("token payload not parseable: %w", err)
}

Prevention

When it happens

Trigger: Token endpoint returns 200 with: HTML/plain-text (proxy error page), a JSON error object, expires_in as a string, or a number formatted as float where an int is required — any of these fail Unmarshal and surface as 'parsing token response'.

Common situations: Provider returning string-typed expires_in (common in nonstandard OIDC implementations); Cloudflare/proxy 200-wrapped HTML; mock servers returning loosely typed fixtures; success payload nesting tokens one level deeper than expected.

Related errors


AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15). Data as JSON: /api/errors/ac14abd0dbe7fdd9. Report an issue: GitHub.