diff --git a/.clanker.example.yaml b/.clanker.example.yaml index 598a005..5bd849c 100644 --- a/.clanker.example.yaml +++ b/.clanker.example.yaml @@ -18,6 +18,7 @@ ai: openai: model: gpt-5 api_key: "" + auth_method: "" # "apiKey" or "oauth" -- oauth uses ChatGPT account login # Optional for OpenAI-compatible local servers such as llama-server. # If the endpoint is localhost, api_key can stay empty. # local_model_inference_url: "http://127.0.0.1:8080/v1" diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 0000000..31db5ee --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,247 @@ +package cmd + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/bgdnvk/clanker/internal/ai" + "github.com/spf13/cobra" +) + +const ( + oauthAuthorizeURL = "https://auth.openai.com/oauth/authorize" + oauthTokenURL = "https://auth.openai.com/oauth/token" + oauthClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + oauthRedirectURI = "http://localhost:1455/auth/callback" + oauthCallbackPort = "1455" +) + +var authCmd = &cobra.Command{ + Use: "auth", + Short: "Manage OpenAI OAuth authentication", + Long: "Login, logout, and check authentication status for OpenAI OAuth (ChatGPT account).", +} + +var authLoginCmd = &cobra.Command{ + Use: "login", + Short: "Login with your OpenAI (ChatGPT) account via OAuth", + RunE: runAuthLogin, +} + +var authLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Remove saved OpenAI OAuth credentials", + RunE: runAuthLogout, +} + +var authStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show current OpenAI OAuth login status", + RunE: runAuthStatus, +} + +func init() { + authCmd.AddCommand(authLoginCmd) + authCmd.AddCommand(authLogoutCmd) + authCmd.AddCommand(authStatusCmd) + rootCmd.AddCommand(authCmd) +} + +func runAuthLogin(_ *cobra.Command, _ []string) error { + // Generate PKCE code verifier (32 random bytes, base64url encoded). + verifierBytes := make([]byte, 32) + if _, err := rand.Read(verifierBytes); err != nil { + return fmt.Errorf("failed to generate code verifier: %w", err) + } + codeVerifier := base64.RawURLEncoding.EncodeToString(verifierBytes) + + // Derive code challenge (SHA-256 of verifier, base64url encoded). + h := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(h[:]) + + // Build the authorization URL. + params := url.Values{ + "client_id": {oauthClientID}, + "redirect_uri": {oauthRedirectURI}, + "response_type": {"code"}, + "scope": {"openid profile email offline_access"}, + "code_challenge": {codeChallenge}, + "code_challenge_method": {"S256"}, + } + authURL := oauthAuthorizeURL + "?" + params.Encode() + + // Channel to receive the authorization code from the callback. + codeCh := make(chan string, 1) + errCh := make(chan error, 1) + + // Start local HTTP server to receive the OAuth callback. + mux := http.NewServeMux() + mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + if code == "" { + errMsg := r.URL.Query().Get("error") + if errMsg == "" { + errMsg = "no authorization code received" + } + http.Error(w, "Authorization failed: "+errMsg, http.StatusBadRequest) + errCh <- fmt.Errorf("authorization failed: %s", errMsg) + return + } + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, `
You can close this window and return to the terminal.
`) + codeCh <- code + }) + + server := &http.Server{ + Addr: ":" + oauthCallbackPort, + Handler: mux, + } + + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- fmt.Errorf("callback server failed: %w", err) + } + }() + + // Open the authorization URL in the default browser. + fmt.Println("Opening browser for OpenAI login...") + fmt.Printf("If the browser does not open, visit this URL:\n%s\n\n", authURL) + openBrowser(authURL) + + // Wait for the callback or an error. + var authCode string + select { + case authCode = <-codeCh: + case err := <-errCh: + server.Close() + return err + case <-time.After(5 * time.Minute): + server.Close() + return fmt.Errorf("login timed out after 5 minutes") + } + + // Shut down the callback server. + server.Close() + + // Exchange authorization code for tokens. + tokens, email, err := exchangeAuthCode(authCode, codeVerifier) + if err != nil { + return err + } + + // Save tokens to disk. + if err := ai.SaveOAuthTokens(tokens); err != nil { + return fmt.Errorf("failed to save tokens: %w", err) + } + + if email != "" { + fmt.Printf("Logged in as %s\n", email) + } else { + fmt.Println("Logged in successfully") + } + return nil +} + +func exchangeAuthCode(code, codeVerifier string) (*ai.OAuthTokens, string, error) { + form := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {oauthClientID}, + "code": {code}, + "code_verifier": {codeVerifier}, + "redirect_uri": {oauthRedirectURI}, + } + + resp, err := http.Post(oauthTokenURL, "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + if err != nil { + return nil, "", fmt.Errorf("token exchange request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("failed to read token response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("token exchange failed (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var raw struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + ExpiresIn int64 `json:"expires_in"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, "", fmt.Errorf("failed to parse token response: %w", err) + } + + email := ai.ExtractEmailFromIDToken(raw.IDToken) + + tokens := &ai.OAuthTokens{ + AccessToken: raw.AccessToken, + RefreshToken: raw.RefreshToken, + ExpiresAt: time.Now().Unix() + raw.ExpiresIn, + Email: email, + } + + return tokens, email, nil +} + +func runAuthLogout(_ *cobra.Command, _ []string) error { + if err := ai.RemoveOAuthTokens(); err != nil { + return fmt.Errorf("failed to remove auth tokens: %w", err) + } + fmt.Println("Logged out") + return nil +} + +func runAuthStatus(_ *cobra.Command, _ []string) error { + tokens, err := ai.LoadOAuthTokens() + if err != nil { + if os.IsNotExist(err) { + fmt.Println("Not logged in") + return nil + } + return fmt.Errorf("failed to read auth tokens: %w", err) + } + + if tokens.Email != "" { + fmt.Printf("Logged in as %s\n", tokens.Email) + } else { + fmt.Println("Logged in (email not available)") + } + + expiresAt := time.Unix(tokens.ExpiresAt, 0) + if time.Now().After(expiresAt) { + fmt.Println("Token expired (will be refreshed on next use)") + } else { + fmt.Printf("Token expires at %s\n", expiresAt.Format(time.RFC3339)) + } + + return nil +} + +// openBrowser opens the given URL in the default browser. +func openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + _ = cmd.Start() +} diff --git a/internal/ai/client.go b/internal/ai/client.go index 3e2c6f1..1d4a881 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -1075,6 +1075,11 @@ func (c *Client) resolveLocalModelInferenceURL(profile *awsclient.AIProfile) str } func (c *Client) askOpenAI(ctx context.Context, prompt string) (string, error) { + // If no API key is configured but OAuth is available, use the Codex Responses API. + if strings.TrimSpace(c.apiKey) == "" && IsOpenAIOAuthActive() { + return c.AskCodex(ctx, prompt) + } + // Get the AI profile configuration (this is the profileLLMCall for OpenAI API access) profileLLMCall, err := c.getAIProfile(c.aiProfile) if err != nil { diff --git a/internal/ai/codex_client.go b/internal/ai/codex_client.go new file mode 100644 index 0000000..286bc7b --- /dev/null +++ b/internal/ai/codex_client.go @@ -0,0 +1,221 @@ +package ai + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +const ( + codexResponsesURL = "https://chatgpt.com/backend-api/codex/responses" +) + +// CodexRequest is the Responses API request format. +type CodexRequest struct { + Model string `json:"model"` + Instructions string `json:"instructions,omitempty"` + Input []Message `json:"input"` + Stream bool `json:"stream"` +} + +// AskCodex sends a non-streaming request to the Codex Responses API +// and returns the full text response. This is used when the OpenAI auth +// method is OAuth rather than API key. +func (c *Client) AskCodex(ctx context.Context, prompt string) (string, error) { + oauthToken, err := GetValidOAuthToken() + if err != nil { + return "", fmt.Errorf("failed to get oauth token: %w", err) + } + + profileLLMCall, err := c.getAIProfile(c.aiProfile) + if err != nil { + return "", fmt.Errorf("failed to get AI profile: %w", err) + } + model := profileLLMCall.Model + if model == "" { + model = "gpt-5.4" + } + + return askCodexWithToken(ctx, oauthToken, model, prompt) +} + +func askCodexWithToken(ctx context.Context, oauthToken, model, prompt string) (string, error) { + codexReq := CodexRequest{ + Model: model, + Input: []Message{{Role: "user", Content: sanitizeASCII(prompt)}}, + Stream: false, + } + + body, err := json.Marshal(codexReq) + if err != nil { + return "", fmt.Errorf("marshal codex request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, codexResponsesURL, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("create codex request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+oauthToken) + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("codex request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read codex response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("codex returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var result struct { + Output []struct { + Type string `json:"type"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"output"` + Error *struct { + Message string `json:"message"` + Code string `json:"code"` + } `json:"error,omitempty"` + } + + if err := json.Unmarshal(respBody, &result); err != nil { + return "", fmt.Errorf("decode codex response: %w", err) + } + + if result.Error != nil { + return "", fmt.Errorf("codex api error (%s): %s", result.Error.Code, result.Error.Message) + } + + var sb strings.Builder + for _, item := range result.Output { + if item.Type != "message" { + continue + } + for _, part := range item.Content { + if part.Type == "output_text" || part.Type == "text" { + sb.WriteString(part.Text) + } + } + } + + return sb.String(), nil +} + +// AskCodexStream sends a streaming request to the Codex Responses API +// and returns text deltas on a channel. +func AskCodexStream(ctx context.Context, oauthToken, model, prompt string) (<-chan string, <-chan error) { + textCh := make(chan string, 64) + errCh := make(chan error, 1) + + go func() { + defer close(textCh) + defer close(errCh) + + codexReq := CodexRequest{ + Model: model, + Input: []Message{{Role: "user", Content: prompt}}, + Stream: true, + } + + body, err := json.Marshal(codexReq) + if err != nil { + errCh <- fmt.Errorf("marshal codex request: %w", err) + return + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, codexResponsesURL, bytes.NewReader(body)) + if err != nil { + errCh <- fmt.Errorf("create codex request: %w", err) + return + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+oauthToken) + httpReq.Header.Set("Accept", "text/event-stream") + + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + errCh <- fmt.Errorf("codex request failed: %w", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + errCh <- fmt.Errorf("codex returned status %d: %s", resp.StatusCode, string(respBody)) + return + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + return + } + + var event struct { + Type string `json:"type"` + Delta string `json:"delta"` + } + if err := json.Unmarshal([]byte(data), &event); err != nil { + continue + } + + switch event.Type { + case "response.output_text.delta": + if event.Delta != "" { + select { + case textCh <- event.Delta: + case <-ctx.Done(): + errCh <- ctx.Err() + return + } + } + case "response.completed", "response.done": + return + } + } + + if err := scanner.Err(); err != nil { + errCh <- fmt.Errorf("reading codex stream: %w", err) + } + }() + + return textCh, errCh +} + +// IsOpenAIOAuthActive returns true if the user has a saved OAuth token +// or an OPENAI_OAUTH_TOKEN environment variable. +func IsOpenAIOAuthActive() bool { + if envToken := strings.TrimSpace(os.Getenv("OPENAI_OAUTH_TOKEN")); envToken != "" { + return true + } + tokens, err := LoadOAuthTokens() + if err != nil { + return false + } + return tokens.AccessToken != "" +} diff --git a/internal/ai/openai_oauth.go b/internal/ai/openai_oauth.go new file mode 100644 index 0000000..27668bb --- /dev/null +++ b/internal/ai/openai_oauth.go @@ -0,0 +1,176 @@ +package ai + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// OAuthTokens holds the persisted OpenAI OAuth token set. +type OAuthTokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt int64 `json:"expires_at"` + Email string `json:"email"` +} + +const ( + openAIOAuthTokenEndpoint = "https://auth.openai.com/oauth/token" + openAIOAuthClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + oauthTokenFileName = "openai-auth.json" +) + +func oauthTokenFilePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + return filepath.Join(home, ".clanker", oauthTokenFileName), nil +} + +// LoadOAuthTokens reads the saved OpenAI OAuth tokens from disk. +func LoadOAuthTokens() (*OAuthTokens, error) { + p, err := oauthTokenFilePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if err != nil { + return nil, err + } + var tokens OAuthTokens + if err := json.Unmarshal(data, &tokens); err != nil { + return nil, fmt.Errorf("failed to parse oauth token file: %w", err) + } + return &tokens, nil +} + +// SaveOAuthTokens writes the OpenAI OAuth tokens to disk. +func SaveOAuthTokens(tokens *OAuthTokens) error { + p, err := oauthTokenFilePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + data, err := json.MarshalIndent(tokens, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, data, 0600) +} + +// RemoveOAuthTokens deletes the saved token file. +func RemoveOAuthTokens() error { + p, err := oauthTokenFilePath() + if err != nil { + return err + } + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// RefreshOAuthToken exchanges a refresh token for a new access token. +func RefreshOAuthToken(refreshToken string) (*OAuthTokens, error) { + form := url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {openAIOAuthClientID}, + "refresh_token": {refreshToken}, + } + + resp, err := http.Post(openAIOAuthTokenEndpoint, "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("token refresh request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read refresh response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token refresh failed (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var raw struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + ExpiresIn int64 `json:"expires_in"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("failed to parse refresh response: %w", err) + } + + tokens := &OAuthTokens{ + AccessToken: raw.AccessToken, + RefreshToken: raw.RefreshToken, + ExpiresAt: time.Now().Unix() + raw.ExpiresIn, + } + + // Preserve email from the id_token if available. + if raw.IDToken != "" { + if email := ExtractEmailFromIDToken(raw.IDToken); email != "" { + tokens.Email = email + } + } + + return tokens, nil +} + +// GetValidOAuthToken loads the stored tokens, refreshes if they expire within +// 5 minutes, saves updated tokens back to disk, and returns the access token. +func GetValidOAuthToken() (string, error) { + tokens, err := LoadOAuthTokens() + if err != nil { + return "", err + } + + const refreshMarginSeconds int64 = 300 // 5 minutes + if time.Now().Unix()+refreshMarginSeconds >= tokens.ExpiresAt { + refreshed, err := RefreshOAuthToken(tokens.RefreshToken) + if err != nil { + return "", fmt.Errorf("failed to refresh oauth token: %w", err) + } + // Keep the original email if the refresh didn't return one. + if refreshed.Email == "" { + refreshed.Email = tokens.Email + } + if err := SaveOAuthTokens(refreshed); err != nil { + return "", fmt.Errorf("failed to save refreshed tokens: %w", err) + } + return refreshed.AccessToken, nil + } + + return tokens.AccessToken, nil +} + +// ExtractEmailFromIDToken decodes the payload of a JWT id_token (without +// verifying the signature) and returns the "email" claim if present. +func ExtractEmailFromIDToken(idToken string) string { + parts := strings.Split(idToken, ".") + if len(parts) < 2 { + return "" + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + var claims struct { + Email string `json:"email"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + return claims.Email +}