From 11ad5d1ff0024b9693fa434145f1fe6a67e3212f Mon Sep 17 00:00:00 2001 From: paigexx Date: Mon, 23 Mar 2026 13:41:38 -0500 Subject: [PATCH 1/3] feat: adds codex oauth --- internal/agents/auth.go | 21 +++ internal/agents/codex_oauth.go | 289 +++++++++++++++++++++++++++++++++ internal/auth/auth.go | 2 +- internal/utils/utils.go | 12 +- main.go | 37 +++++ 5 files changed, 355 insertions(+), 6 deletions(-) create mode 100644 internal/agents/auth.go create mode 100644 internal/agents/codex_oauth.go diff --git a/internal/agents/auth.go b/internal/agents/auth.go new file mode 100644 index 0000000..bda330b --- /dev/null +++ b/internal/agents/auth.go @@ -0,0 +1,21 @@ +package agents + +import ( + "fmt" + "pinata/internal/utils" +) + +// CredentialLogin prompts the user for a credential and stores it as a secret. +func CredentialLogin(prompt, secretName string) error { + key, err := utils.GetInput(prompt, prompt) + if err != nil { + return fmt.Errorf("failed to read credential: %w", err) + } + if key == "" { + return fmt.Errorf("credential cannot be empty") + } + + fmt.Printf("Creating secret '%s'...\n", secretName) + _, err = CreateSecret(secretName, key) + return err +} diff --git a/internal/agents/codex_oauth.go b/internal/agents/codex_oauth.go new file mode 100644 index 0000000..55eda68 --- /dev/null +++ b/internal/agents/codex_oauth.go @@ -0,0 +1,289 @@ +package agents + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +const ( + codexClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + codexRedirectURI = "http://localhost:1455/auth/callback" + codexAuthURL = "https://auth.openai.com/oauth/authorize" + codexTokenURL = "https://auth.openai.com/oauth/token" + codexSecretName = "OPENAI_OAUTH_TOKEN" +) + +type codexTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +// --- PKCE helpers --- + +func generateCodeVerifier() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func generateCodeChallenge(verifier string) string { + h := sha256.New() + h.Write([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) +} + +func generateOAuthState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func buildCodexAuthURL(challenge, state string) string { + params := url.Values{ + "response_type": {"code"}, + "client_id": {codexClientID}, + "redirect_uri": {codexRedirectURI}, + "scope": {"openid profile email offline_access"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + "id_token_add_organizations": {"true"}, + "codex_cli_simplified_flow": {"true"}, + } + return codexAuthURL + "?" + params.Encode() +} + +func openBrowser(u string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", u).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", u).Start() + default: + return exec.Command("xdg-open", u).Start() + } +} + +// --- Token exchange --- + +func exchangeCodexToken(code, verifier string) (*codexTokenResponse, error) { + params := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {codexRedirectURI}, + "client_id": {codexClientID}, + "code_verifier": {verifier}, + } + resp, err := http.PostForm(codexTokenURL, params) + if err != nil { + return nil, fmt.Errorf("token exchange request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token exchange failed with status %d", resp.StatusCode) + } + var tokens codexTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil { + return nil, fmt.Errorf("failed to decode token response: %w", err) + } + return &tokens, nil +} + +// --- Secret upsert helpers --- + +// findSecretIDByName returns the ID of a secret with the given name, or "" if not found. +func findSecretIDByName(name string) (string, error) { + var list SecretListResponse + if err := doSecretsJSON(http.MethodGet, "", nil, &list); err != nil { + return "", fmt.Errorf("failed to list secrets: %w", err) + } + for _, s := range list.Secrets { + if s.Name == name { + return s.ID, nil + } + } + return "", nil +} + +// upsertOAuthSecret creates or updates the named secret with the full OAuth bundle +// serialized as a JSON string, e.g. {"access_token":"...","refresh_token":"...","expires_at":"..."}. +func upsertOAuthSecret(name, accessToken, refreshToken, expiresAt string) error { + bundle := codexBundle{ + Access: accessToken, + Refresh: refreshToken, + ExpiresAt: expiresAt, + } + bundleJSON, err := json.Marshal(bundle) + if err != nil { + return fmt.Errorf("failed to marshal OAuth bundle: %w", err) + } + value := string(bundleJSON) + + existingID, err := findSecretIDByName(name) + if err != nil { + return err + } + + if existingID != "" { + body := UpdateSecretBody{Value: value} + return doSecretsJSON(http.MethodPut, "/"+existingID, body, nil) + } + + body := CreateSecretBody{Name: name, Value: value} + var resp CreateSecretResponse + return doSecretsJSON(http.MethodPost, "", body, &resp) +} + +// --- Local bundle (cache only — source of truth is the agents API) --- + +type codexBundle struct { + Access string `json:"access_token"` + Refresh string `json:"refresh_token"` + ExpiresAt string `json:"expires_at"` +} + +func localBundlePath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".pinata-openai-oauth"), nil +} + +func saveLocalBundle(b *codexBundle) { + path, err := localBundlePath() + if err != nil { + return + } + data, _ := json.Marshal(b) + _ = os.WriteFile(path, data, 0600) +} + +// --- Public API --- + +// CodexOAuthLogin runs the PKCE browser flow, stores the full OAuth bundle in +// the agents API (access token + refresh token + expiry), and caches it locally. +func CodexOAuthLogin() (*CreateSecretResponse, error) { + verifier, err := generateCodeVerifier() + if err != nil { + return nil, fmt.Errorf("failed to generate PKCE verifier: %w", err) + } + challenge := generateCodeChallenge(verifier) + state, err := generateOAuthState() + if err != nil { + return nil, fmt.Errorf("failed to generate state: %w", err) + } + + authURL := buildCodexAuthURL(challenge, state) + fmt.Println("Opening browser for OpenAI Codex authentication...") + fmt.Printf("If the browser does not open automatically, visit:\n%s\n\n", authURL) + _ = openBrowser(authURL) + + type callbackResult struct { + code string + err error + } + ch := make(chan callbackResult, 1) + + mux := http.NewServeMux() + srv := &http.Server{Handler: mux} + + mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if errParam := q.Get("error"); errParam != "" { + http.Redirect(w, r, "/error?msg="+url.QueryEscape(errParam), http.StatusFound) + ch <- callbackResult{err: fmt.Errorf("oauth error: %s", errParam)} + return + } + if q.Get("state") != state { + http.Redirect(w, r, "/error?msg=state+mismatch", http.StatusFound) + ch <- callbackResult{err: fmt.Errorf("state mismatch: possible CSRF attack")} + return + } + http.Redirect(w, r, "/success", http.StatusFound) + ch <- callbackResult{code: q.Get("code")} + }) + + mux.HandleFunc("/success", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, ` + +

Authentication Successful

+

You can close this tab and return to the terminal.

+`) + }) + + mux.HandleFunc("/error", func(w http.ResponseWriter, r *http.Request) { + msg := r.URL.Query().Get("msg") + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, ` + +

Authentication Failed

%s

+

Please close this tab and try again.

+`, msg) + }) + + ln, err := net.Listen("tcp", ":1455") + if err != nil { + return nil, fmt.Errorf("failed to start callback server on port 1455 (is it already in use?): %w", err) + } + go func() { _ = srv.Serve(ln) }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + var res callbackResult + select { + case res = <-ch: + case <-ctx.Done(): + _ = srv.Shutdown(context.Background()) + return nil, fmt.Errorf("authentication timed out after 5 minutes") + } + + time.Sleep(500 * time.Millisecond) + _ = srv.Shutdown(context.Background()) + + if res.err != nil { + return nil, res.err + } + + fmt.Println("Exchanging authorization code for tokens...") + tokens, err := exchangeCodexToken(res.code, verifier) + if err != nil { + return nil, err + } + + expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second).UTC().Format(time.RFC3339) + + saveLocalBundle(&codexBundle{ + Access: tokens.AccessToken, + Refresh: tokens.RefreshToken, + ExpiresAt: expiresAt, + }) + + fmt.Printf("Storing secret '%s'...\n", codexSecretName) + if err := upsertOAuthSecret(codexSecretName, tokens.AccessToken, tokens.RefreshToken, expiresAt); err != nil { + return nil, fmt.Errorf("failed to store secret: %w", err) + } + + return nil, nil +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 82b40c6..da0b60e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -13,7 +13,7 @@ import ( ) func SaveJWT() error { - jwt, err := utils.GetInput("Enter your Pinata JWT") + jwt, err := utils.GetInput("Enter your Pinata JWT", "Pinata JWT") if err != nil { return err } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 61b5c07..cdda48b 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -29,13 +29,14 @@ var ( type item string type inputModel struct { + label string textInput textinput.Model err error } -func initialInputModel() inputModel { +func initialInputModel(label, placeholder string) inputModel { ti := textinput.New() - ti.Placeholder = "Pinata JWT" + ti.Placeholder = placeholder ti.Focus() ti.Width = 35 ti.EchoMode = textinput.EchoPassword @@ -46,6 +47,7 @@ func initialInputModel() inputModel { ti.TextStyle = itemStyle return inputModel{ + label: label, textInput: ti, err: nil, } @@ -79,14 +81,14 @@ func (m inputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m inputModel) View() string { return fmt.Sprintf( "%s\n\n%s\n\n%s", - "Enter your Pinata JWT", + m.label, m.textInput.View(), "(press enter to submit)", ) + "\n" } -func GetInput(placeholder string) (string, error) { - p := tea.NewProgram(initialInputModel()) +func GetInput(label, placeholder string) (string, error) { + p := tea.NewProgram(initialInputModel(label, placeholder)) m, err := p.Run() if err != nil { return "", err diff --git a/main.go b/main.go index 30c30e9..a7451bf 100644 --- a/main.go +++ b/main.go @@ -2235,6 +2235,43 @@ Examples: return err }, }, + { + Name: "auth", + Usage: "Authenticate with a provider and store the credential as a secret", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "provider", + Aliases: []string{"p"}, + Usage: "Provider to authenticate with (anthropic, openai, openrouter)", + Required: true, + }, + &cli.StringFlag{ + Name: "type", + Aliases: []string{"t"}, + Usage: "Authentication type (api_key, oauth, setup_token)", + Required: true, + }, + }, + Action: func(ctx *cli.Context) error { + provider := ctx.String("provider") + authType := ctx.String("type") + switch provider + "/" + authType { + case "anthropic/api_key": + return agents.CredentialLogin("Anthropic API key", "ANTHROPIC_API_KEY") + case "anthropic/setup_token": + return agents.CredentialLogin("Anthropic setup token (run 'claude setup-token' to generate one)", "ANTHROPIC_SETUP_TOKEN") + case "openai/api_key": + return agents.CredentialLogin("OpenAI API key", "OPENAI_API_KEY") + case "openai/oauth": + _, err := agents.CodexOAuthLogin() + return err + case "openrouter/api_key": + return agents.CredentialLogin("OpenRouter API key", "OPENROUTER_API_KEY") + default: + return fmt.Errorf("unsupported combination: --provider %s --type %s\navailable:\n --provider anthropic --type api_key\n --provider anthropic --type setup_token\n --provider openai --type api_key\n --provider openai --type oauth\n --provider openrouter --type api_key", provider, authType) + } + }, + }, { Name: "feedback", Usage: "Submit feedback or feature request", From 0db9c586a4662e8518a600c701f072caf30bdc3c Mon Sep 17 00:00:00 2001 From: paigexx Date: Mon, 23 Mar 2026 14:00:20 -0500 Subject: [PATCH 2/3] fix: removes bundle save --- internal/agents/auth.go | 19 ++++++++- internal/agents/codex_oauth.go | 74 +++------------------------------- 2 files changed, 24 insertions(+), 69 deletions(-) diff --git a/internal/agents/auth.go b/internal/agents/auth.go index bda330b..91663e6 100644 --- a/internal/agents/auth.go +++ b/internal/agents/auth.go @@ -2,6 +2,7 @@ package agents import ( "fmt" + "net/http" "pinata/internal/utils" ) @@ -16,6 +17,22 @@ func CredentialLogin(prompt, secretName string) error { } fmt.Printf("Creating secret '%s'...\n", secretName) - _, err = CreateSecret(secretName, key) + err = UpsertSecret(secretName, key) return err } + + +// upsertSecret creates or updates a secret by name +func UpsertSecret(name, value string) error { + var list SecretListResponse + if err := doSecretsJSON(http.MethodGet, "", nil, &list); err != nil { + return fmt.Errorf("failed to list secrets: %w", err) + } + for _, s := range list.Secrets { + if s.Name == name { + return doSecretsJSON(http.MethodPut, "/"+s.ID, UpdateSecretBody{Value: value}, nil) + } + } + + return doSecretsJSON(http.MethodPost, "", CreateSecretBody{Name: name, Value: value}, nil) +} diff --git a/internal/agents/codex_oauth.go b/internal/agents/codex_oauth.go index 55eda68..dd2aae2 100644 --- a/internal/agents/codex_oauth.go +++ b/internal/agents/codex_oauth.go @@ -10,9 +10,7 @@ import ( "net" "net/http" "net/url" - "os" "os/exec" - "path/filepath" "runtime" "time" ) @@ -108,76 +106,12 @@ func exchangeCodexToken(code, verifier string) (*codexTokenResponse, error) { return &tokens, nil } -// --- Secret upsert helpers --- - -// findSecretIDByName returns the ID of a secret with the given name, or "" if not found. -func findSecretIDByName(name string) (string, error) { - var list SecretListResponse - if err := doSecretsJSON(http.MethodGet, "", nil, &list); err != nil { - return "", fmt.Errorf("failed to list secrets: %w", err) - } - for _, s := range list.Secrets { - if s.Name == name { - return s.ID, nil - } - } - return "", nil -} - -// upsertOAuthSecret creates or updates the named secret with the full OAuth bundle -// serialized as a JSON string, e.g. {"access_token":"...","refresh_token":"...","expires_at":"..."}. -func upsertOAuthSecret(name, accessToken, refreshToken, expiresAt string) error { - bundle := codexBundle{ - Access: accessToken, - Refresh: refreshToken, - ExpiresAt: expiresAt, - } - bundleJSON, err := json.Marshal(bundle) - if err != nil { - return fmt.Errorf("failed to marshal OAuth bundle: %w", err) - } - value := string(bundleJSON) - - existingID, err := findSecretIDByName(name) - if err != nil { - return err - } - - if existingID != "" { - body := UpdateSecretBody{Value: value} - return doSecretsJSON(http.MethodPut, "/"+existingID, body, nil) - } - - body := CreateSecretBody{Name: name, Value: value} - var resp CreateSecretResponse - return doSecretsJSON(http.MethodPost, "", body, &resp) -} - -// --- Local bundle (cache only — source of truth is the agents API) --- - type codexBundle struct { Access string `json:"access_token"` Refresh string `json:"refresh_token"` ExpiresAt string `json:"expires_at"` } -func localBundlePath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".pinata-openai-oauth"), nil -} - -func saveLocalBundle(b *codexBundle) { - path, err := localBundlePath() - if err != nil { - return - } - data, _ := json.Marshal(b) - _ = os.WriteFile(path, data, 0600) -} - // --- Public API --- // CodexOAuthLogin runs the PKCE browser flow, stores the full OAuth bundle in @@ -274,14 +208,18 @@ func CodexOAuthLogin() (*CreateSecretResponse, error) { expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second).UTC().Format(time.RFC3339) - saveLocalBundle(&codexBundle{ + bundleJSON, err := json.Marshal(codexBundle{ Access: tokens.AccessToken, Refresh: tokens.RefreshToken, ExpiresAt: expiresAt, }) + if err != nil { + return nil, fmt.Errorf("failed to marshal OAuth bundle: %w", err) + } + value := string(bundleJSON) fmt.Printf("Storing secret '%s'...\n", codexSecretName) - if err := upsertOAuthSecret(codexSecretName, tokens.AccessToken, tokens.RefreshToken, expiresAt); err != nil { + if err := UpsertSecret(codexSecretName, value); err != nil { return nil, fmt.Errorf("failed to store secret: %w", err) } From 3c3480dc82957fb4a0e202e892521b66f00a05c7 Mon Sep 17 00:00:00 2001 From: paigexx Date: Mon, 23 Mar 2026 14:02:37 -0500 Subject: [PATCH 3/3] feat: updates commands --- main.go | 46 ++++++++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/main.go b/main.go index a7451bf..d719b81 100644 --- a/main.go +++ b/main.go @@ -2236,39 +2236,37 @@ Examples: }, }, { - Name: "auth", - Usage: "Authenticate with a provider and store the credential as a secret", + Name: "auth", + Usage: "Authenticate with a provider and store the credential as a secret", + ArgsUsage: "[provider: anthropic, openai, openrouter]", Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "provider", - Aliases: []string{"p"}, - Usage: "Provider to authenticate with (anthropic, openai, openrouter)", - Required: true, + &cli.BoolFlag{ + Name: "oauth", + Usage: "Use OAuth browser flow instead of API key (openai only)", }, - &cli.StringFlag{ - Name: "type", - Aliases: []string{"t"}, - Usage: "Authentication type (api_key, oauth, setup_token)", - Required: true, + &cli.BoolFlag{ + Name: "setup-token", + Usage: "Store an Anthropic setup token instead of an API key (anthropic only)", }, }, Action: func(ctx *cli.Context) error { - provider := ctx.String("provider") - authType := ctx.String("type") - switch provider + "/" + authType { - case "anthropic/api_key": + provider := ctx.Args().First() + switch provider { + case "anthropic": + if ctx.Bool("setup-token") { + return agents.CredentialLogin("Anthropic setup token (run 'claude setup-token' to generate one)", "ANTHROPIC_SETUP_TOKEN") + } return agents.CredentialLogin("Anthropic API key", "ANTHROPIC_API_KEY") - case "anthropic/setup_token": - return agents.CredentialLogin("Anthropic setup token (run 'claude setup-token' to generate one)", "ANTHROPIC_SETUP_TOKEN") - case "openai/api_key": + case "openai": + if ctx.Bool("oauth") { + _, err := agents.CodexOAuthLogin() + return err + } return agents.CredentialLogin("OpenAI API key", "OPENAI_API_KEY") - case "openai/oauth": - _, err := agents.CodexOAuthLogin() - return err - case "openrouter/api_key": + case "openrouter": return agents.CredentialLogin("OpenRouter API key", "OPENROUTER_API_KEY") default: - return fmt.Errorf("unsupported combination: --provider %s --type %s\navailable:\n --provider anthropic --type api_key\n --provider anthropic --type setup_token\n --provider openai --type api_key\n --provider openai --type oauth\n --provider openrouter --type api_key", provider, authType) + return fmt.Errorf("unsupported provider: %q\navailable: anthropic, openai, openrouter", provider) } }, },