fix: OAuth token exchange — try JSON then form-encoded, show actual error in browser
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m2s

The callback page now shows the real error message instead of a generic
"Failed to exchange" message. Token exchange tries JSON Content-Type first
(matching Claude Code's pattern) with form-encoded fallback. Full response
body logged for debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 11:48:54 -04:00
parent 8362a50460
commit 999f8f0539

View File

@ -1,6 +1,7 @@
package ai
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
@ -116,8 +117,9 @@ func (o *OAuthManager) StartLogin(openURL func(string) error) (<-chan error, err
// Exchange code for tokens
if err := o.exchangeCode(code, verifier, redirectURI); err != nil {
slog.Error("oauth token exchange failed", "error", err)
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "Failed to exchange authorization code.")
fmt.Fprintf(w, "<html><body><h2>Authentication Failed</h2><pre>%s</pre><p>Check Wraith logs for details.</p></body></html>", err.Error())
done <- fmt.Errorf("exchange code: %w", err)
return
}
@ -158,15 +160,31 @@ func (o *OAuthManager) StartLogin(openURL func(string) error) (<-chan error, err
// exchangeCode exchanges an authorization code for access and refresh tokens.
func (o *OAuthManager) exchangeCode(code, verifier, redirectURI string) error {
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirectURI},
"client_id": {o.clientID},
"code_verifier": {verifier},
// Try JSON format first (what Claude Code appears to use), fall back to form-encoded
payload := map[string]string{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirectURI,
"client_id": o.clientID,
"code_verifier": verifier,
}
resp, err := http.PostForm(o.tokenURL, data)
jsonBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal token request: %w", err)
}
slog.Info("exchanging auth code", "tokenURL", o.tokenURL, "redirectURI", redirectURI)
req, err := http.NewRequest("POST", o.tokenURL, io.NopCloser(
bytes.NewReader(jsonBody),
))
if err != nil {
return fmt.Errorf("create token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("post token request: %w", err)
}
@ -177,8 +195,31 @@ func (o *OAuthManager) exchangeCode(code, verifier, redirectURI string) error {
return fmt.Errorf("read token response: %w", err)
}
slog.Info("token endpoint response", "status", resp.StatusCode, "body", string(body)[:min(len(body), 500)])
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
// If JSON failed, try form-encoded
slog.Info("JSON token exchange failed, trying form-encoded")
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirectURI},
"client_id": {o.clientID},
"code_verifier": {verifier},
}
resp2, err := http.PostForm(o.tokenURL, data)
if err != nil {
return fmt.Errorf("post form token request: %w", err)
}
defer resp2.Body.Close()
body, err = io.ReadAll(resp2.Body)
if err != nil {
return fmt.Errorf("read form token response: %w", err)
}
slog.Info("form-encoded token response", "status", resp2.StatusCode, "body", string(body)[:min(len(body), 500)])
if resp2.StatusCode != http.StatusOK {
return fmt.Errorf("token endpoint returned %d (json) and %d (form): %s", resp.StatusCode, resp2.StatusCode, string(body))
}
}
var tokens tokenResponse
@ -189,6 +230,13 @@ func (o *OAuthManager) exchangeCode(code, verifier, redirectURI string) error {
return o.storeTokens(tokens)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// storeTokens encrypts and persists OAuth tokens in settings.
func (o *OAuthManager) storeTokens(tokens tokenResponse) error {
o.mu.Lock()