{"record":{"id":"1e3e05b5145683bd","repo":"sipeed/picoclaw","slug":"token-exchange-failed-s","errorCode":null,"errorMessage":"token exchange failed: %s","messagePattern":"token exchange failed: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"pkg/auth/oauth.go","lineNumber":565,"sourceCode":"\n\t// Determine provider name from config\n\tprovider := \"openai\"\n\tif cfg.TokenURL != \"\" && strings.Contains(cfg.TokenURL, \"googleapis.com\") {\n\t\tprovider = \"google-antigravity\"\n\t}\n\n\tresp, err := http.PostForm(tokenURL, data)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"exchanging code for tokens: %w\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading token exchange response: %w\", err)\n\t}\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"token exchange failed: %s\", string(body))\n\t}\n\n\treturn parseTokenResponse(body, provider)\n}\n\nfunc parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {\n\tvar tokenResp struct {\n\t\tAccessToken  string `json:\"access_token\"`\n\t\tRefreshToken string `json:\"refresh_token\"`\n\t\tExpiresIn    int    `json:\"expires_in\"`\n\t\tIDToken      string `json:\"id_token\"`\n\t}\n\tif err := json.Unmarshal(body, &tokenResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing token response: %w\", err)\n\t}\n\n\tif tokenResp.AccessToken == \"\" {\n\t\treturn nil, fmt.Errorf(\"no access token in response\")","sourceCodeStart":547,"sourceCodeEnd":583,"githubUrl":"https://github.com/sipeed/picoclaw/blob/49183d7e8daed0dba89ddbb6fcb60089401d9680/pkg/auth/oauth.go#L547-L583","documentation":"ExchangeCodeForTokens (pkg/auth/oauth.go:565) got a non-200 from the token endpoint and embeds the raw body (status code not included). Standard OAuth errors appear here: invalid_grant (bad/expired/reused code, wrong redirect_uri or code_verifier), invalid_client (bad client_id/secret).","triggerScenarios":"POST grant_type=authorization_code returns 400 invalid_grant when the code expired (~1-10 min), was already exchanged (e.g. after a mid-body read retry), redirect_uri differs from the authorize request, or the PKCE code_verifier is wrong; 401 invalid_client on credential mismatch; 429/5xx otherwise.","commonSituations":"User sits on the callback too long before exchange; retrying after a lost response reuses a consumed code; redirectURI not matching the one built into the authorize URL (pollDeviceCode uses {Issuer}/deviceauth/callback); client secret required but unset; TokenURL pointing at the wrong provider.","solutions":["Parse the error field in the embedded body: invalid_grant → restart login; invalid_client → fix client_id/secret; mismatch params → align redirect_uri/code_verifier","If a network glitch interrupted a previous exchange, assume the code is consumed and restart the flow rather than retrying blindly","Ensure redirectURI passed to ExchangeCodeForTokens exactly matches the one used in the authorize request","Verify cfg.ClientSecret is set if the provider requires a confidential client; confirm tokenURL is the right endpoint","Include resp.StatusCode when logging to speed up triage"],"exampleFix":"// before\nif resp.StatusCode != http.StatusOK {\n\treturn nil, fmt.Errorf(\"token exchange failed: %s\", string(body))\n}\n\n// after\nif resp.StatusCode != http.StatusOK {\n\treturn nil, fmt.Errorf(\"token exchange failed: status %d: %s\", resp.StatusCode, string(body))\n}","handlingStrategy":"try-catch","validationCode":"// Verify exchange parameters mirror the authorize request\nif code == \"\" || codeVerifier == \"\" || redirectURI == \"\" {\n\treturn fmt.Errorf(\"code, verifier, and redirect URI are all required\")\n}\nif cfg.ClientSecret == \"\" && providerRequiresSecret(cfg) {\n\treturn fmt.Errorf(\"client_secret required by this provider\")\n}","typeGuard":"func isTokenExchangeRejected(err error) bool {\n\treturn err != nil && strings.Contains(err.Error(), \"token exchange failed\")\n}","tryCatchPattern":"cred, err := auth.ExchangeCodeForTokens(cfg, code, verifier, redirectURI)\nif err != nil && isTokenExchangeRejected(err) {\n\t// body embeds the OAuth error: invalid_grant means restart login\n\tif strings.Contains(err.Error(), \"invalid_grant\") {\n\t\treturn reloginFlow(cfg)\n\t}\n\treturn err\n}","preventionTips":["Pass the exact same redirect_uri used at authorization","Never reuse an authorization code after an ambiguous failure","Exchange the code immediately after receiving the callback","Keep PKCE verifier/code_challenge paired from the same session"],"tags":["oauth","authorization-code","pkce","http-status","invalid-grant","go"],"backgroundTag":null,"analyzedSha":"49183d7e8daed0dba89ddbb6fcb60089401d9680","analyzedAt":"2026-08-15T21:55:41.315Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}