Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions internal/agents/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package agents

import (
"fmt"
"net/http"
"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 = 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)
}
227 changes: 227 additions & 0 deletions internal/agents/codex_oauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package agents

import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"os/exec"
"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
}

type codexBundle struct {
Access string `json:"access_token"`
Refresh string `json:"refresh_token"`
ExpiresAt string `json:"expires_at"`
}

// --- 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, `<!DOCTYPE html>
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
<h2>Authentication Successful</h2>
<p>You can close this tab and return to the terminal.</p>
</body></html>`)
})

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, `<!DOCTYPE html>
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
<h2>Authentication Failed</h2><p>%s</p>
<p>Please close this tab and try again.</p>
</body></html>`, 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)

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 := UpsertSecret(codexSecretName, value); err != nil {
return nil, fmt.Errorf("failed to store secret: %w", err)
}

return nil, nil
}
2 changes: 1 addition & 1 deletion internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
12 changes: 7 additions & 5 deletions internal/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,6 +47,7 @@ func initialInputModel() inputModel {
ti.TextStyle = itemStyle

return inputModel{
label: label,
textInput: ti,
err: nil,
}
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2235,6 +2235,41 @@ Examples:
return err
},
},
{
Name: "auth",
Usage: "Authenticate with a provider and store the credential as a secret",
ArgsUsage: "[provider: anthropic, openai, openrouter]",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "oauth",
Usage: "Use OAuth browser flow instead of API key (openai only)",
},
&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.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 "openai":
if ctx.Bool("oauth") {
_, err := agents.CodexOAuthLogin()
return err
}
return agents.CredentialLogin("OpenAI API key", "OPENAI_API_KEY")
case "openrouter":
return agents.CredentialLogin("OpenRouter API key", "OPENROUTER_API_KEY")
default:
return fmt.Errorf("unsupported provider: %q\navailable: anthropic, openai, openrouter", provider)
}
},
},
{
Name: "feedback",
Usage: "Submit feedback or feature request",
Expand Down