From 56ed2d1442e0ba40fa7f6be2b626fa1d72377925 Mon Sep 17 00:00:00 2001 From: nash Date: Sun, 5 Apr 2026 13:48:09 +0000 Subject: [PATCH 1/2] feat: add OpenAI OAuth login and Codex Responses API client Add `clanker auth login/logout/status` commands for browser-based OpenAI OAuth PKCE flow. Tokens are stored at ~/.clanker/openai-auth.json. Add Codex Responses API client (internal/ai/codex_client.go) that sends requests to chatgpt.com/backend-api/codex/responses using OAuth tokens. When the OpenAI provider is selected but no API key is configured, the CLI checks for OAuth tokens and routes through the Codex client instead of the standard api.openai.com endpoint. Also support OPENAI_OAUTH_TOKEN env var for backend-forwarded tokens. --- .clanker.example.yaml | 1 + cmd/auth.go | 247 ++++++++++++++++++++++++++++++++++++ internal/ai/client.go | 5 + internal/ai/codex_client.go | 221 ++++++++++++++++++++++++++++++++ internal/ai/openai_oauth.go | 176 +++++++++++++++++++++++++ 5 files changed, 650 insertions(+) create mode 100644 cmd/auth.go create mode 100644 internal/ai/codex_client.go create mode 100644 internal/ai/openai_oauth.go 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..4967d10 --- /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, `

Login successful!

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 +} From f910b93a756d37128338182af4596e08ead10516 Mon Sep 17 00:00:00 2001 From: nash Date: Sun, 5 Apr 2026 13:52:16 +0000 Subject: [PATCH 2/2] fix: correct gofmt alignment in auth.go url.Values --- cmd/auth.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index 4967d10..31db5ee 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -73,10 +73,10 @@ func runAuthLogin(_ *cobra.Command, _ []string) error { // 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}, + "redirect_uri": {oauthRedirectURI}, + "response_type": {"code"}, + "scope": {"openid profile email offline_access"}, + "code_challenge": {codeChallenge}, "code_challenge_method": {"S256"}, } authURL := oauthAuthorizeURL + "?" + params.Encode()