diff --git a/internal/ai/oauth.go b/internal/ai/oauth.go index 0d4f930..c1e0418 100644 --- a/internal/ai/oauth.go +++ b/internal/ai/oauth.go @@ -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, "
%s
Check Wraith logs for details.
", 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()