Skip to content

Commit 475c4c2

Browse files
authored
Merge pull request #3 from PinataCloud/feat/codex
feat: adds codex oauth
2 parents 96de711 + 3c3480d commit 475c4c2

5 files changed

Lines changed: 308 additions & 6 deletions

File tree

internal/agents/auth.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package agents
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"pinata/internal/utils"
7+
)
8+
9+
// CredentialLogin prompts the user for a credential and stores it as a secret.
10+
func CredentialLogin(prompt, secretName string) error {
11+
key, err := utils.GetInput(prompt, prompt)
12+
if err != nil {
13+
return fmt.Errorf("failed to read credential: %w", err)
14+
}
15+
if key == "" {
16+
return fmt.Errorf("credential cannot be empty")
17+
}
18+
19+
fmt.Printf("Creating secret '%s'...\n", secretName)
20+
err = UpsertSecret(secretName, key)
21+
return err
22+
}
23+
24+
25+
// upsertSecret creates or updates a secret by name
26+
func UpsertSecret(name, value string) error {
27+
var list SecretListResponse
28+
if err := doSecretsJSON(http.MethodGet, "", nil, &list); err != nil {
29+
return fmt.Errorf("failed to list secrets: %w", err)
30+
}
31+
for _, s := range list.Secrets {
32+
if s.Name == name {
33+
return doSecretsJSON(http.MethodPut, "/"+s.ID, UpdateSecretBody{Value: value}, nil)
34+
}
35+
}
36+
37+
return doSecretsJSON(http.MethodPost, "", CreateSecretBody{Name: name, Value: value}, nil)
38+
}

internal/agents/codex_oauth.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package agents
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"crypto/sha256"
7+
"encoding/base64"
8+
"encoding/json"
9+
"fmt"
10+
"net"
11+
"net/http"
12+
"net/url"
13+
"os/exec"
14+
"runtime"
15+
"time"
16+
)
17+
18+
const (
19+
codexClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
20+
codexRedirectURI = "http://localhost:1455/auth/callback"
21+
codexAuthURL = "https://auth.openai.com/oauth/authorize"
22+
codexTokenURL = "https://auth.openai.com/oauth/token"
23+
codexSecretName = "OPENAI_OAUTH_TOKEN"
24+
)
25+
26+
type codexTokenResponse struct {
27+
AccessToken string `json:"access_token"`
28+
RefreshToken string `json:"refresh_token"`
29+
IDToken string `json:"id_token"`
30+
TokenType string `json:"token_type"`
31+
ExpiresIn int `json:"expires_in"`
32+
}
33+
34+
// --- PKCE helpers ---
35+
36+
func generateCodeVerifier() (string, error) {
37+
b := make([]byte, 32)
38+
if _, err := rand.Read(b); err != nil {
39+
return "", err
40+
}
41+
return base64.RawURLEncoding.EncodeToString(b), nil
42+
}
43+
44+
func generateCodeChallenge(verifier string) string {
45+
h := sha256.New()
46+
h.Write([]byte(verifier))
47+
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
48+
}
49+
50+
func generateOAuthState() (string, error) {
51+
b := make([]byte, 16)
52+
if _, err := rand.Read(b); err != nil {
53+
return "", err
54+
}
55+
return base64.RawURLEncoding.EncodeToString(b), nil
56+
}
57+
58+
func buildCodexAuthURL(challenge, state string) string {
59+
params := url.Values{
60+
"response_type": {"code"},
61+
"client_id": {codexClientID},
62+
"redirect_uri": {codexRedirectURI},
63+
"scope": {"openid profile email offline_access"},
64+
"code_challenge": {challenge},
65+
"code_challenge_method": {"S256"},
66+
"state": {state},
67+
"id_token_add_organizations": {"true"},
68+
"codex_cli_simplified_flow": {"true"},
69+
}
70+
return codexAuthURL + "?" + params.Encode()
71+
}
72+
73+
func openBrowser(u string) error {
74+
switch runtime.GOOS {
75+
case "darwin":
76+
return exec.Command("open", u).Start()
77+
case "windows":
78+
return exec.Command("rundll32", "url.dll,FileProtocolHandler", u).Start()
79+
default:
80+
return exec.Command("xdg-open", u).Start()
81+
}
82+
}
83+
84+
// --- Token exchange ---
85+
86+
func exchangeCodexToken(code, verifier string) (*codexTokenResponse, error) {
87+
params := url.Values{
88+
"grant_type": {"authorization_code"},
89+
"code": {code},
90+
"redirect_uri": {codexRedirectURI},
91+
"client_id": {codexClientID},
92+
"code_verifier": {verifier},
93+
}
94+
resp, err := http.PostForm(codexTokenURL, params)
95+
if err != nil {
96+
return nil, fmt.Errorf("token exchange request failed: %w", err)
97+
}
98+
defer resp.Body.Close()
99+
if resp.StatusCode != http.StatusOK {
100+
return nil, fmt.Errorf("token exchange failed with status %d", resp.StatusCode)
101+
}
102+
var tokens codexTokenResponse
103+
if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
104+
return nil, fmt.Errorf("failed to decode token response: %w", err)
105+
}
106+
return &tokens, nil
107+
}
108+
109+
type codexBundle struct {
110+
Access string `json:"access_token"`
111+
Refresh string `json:"refresh_token"`
112+
ExpiresAt string `json:"expires_at"`
113+
}
114+
115+
// --- Public API ---
116+
117+
// CodexOAuthLogin runs the PKCE browser flow, stores the full OAuth bundle in
118+
// the agents API (access token + refresh token + expiry), and caches it locally.
119+
func CodexOAuthLogin() (*CreateSecretResponse, error) {
120+
verifier, err := generateCodeVerifier()
121+
if err != nil {
122+
return nil, fmt.Errorf("failed to generate PKCE verifier: %w", err)
123+
}
124+
challenge := generateCodeChallenge(verifier)
125+
state, err := generateOAuthState()
126+
if err != nil {
127+
return nil, fmt.Errorf("failed to generate state: %w", err)
128+
}
129+
130+
authURL := buildCodexAuthURL(challenge, state)
131+
fmt.Println("Opening browser for OpenAI Codex authentication...")
132+
fmt.Printf("If the browser does not open automatically, visit:\n%s\n\n", authURL)
133+
_ = openBrowser(authURL)
134+
135+
type callbackResult struct {
136+
code string
137+
err error
138+
}
139+
ch := make(chan callbackResult, 1)
140+
141+
mux := http.NewServeMux()
142+
srv := &http.Server{Handler: mux}
143+
144+
mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) {
145+
q := r.URL.Query()
146+
if errParam := q.Get("error"); errParam != "" {
147+
http.Redirect(w, r, "/error?msg="+url.QueryEscape(errParam), http.StatusFound)
148+
ch <- callbackResult{err: fmt.Errorf("oauth error: %s", errParam)}
149+
return
150+
}
151+
if q.Get("state") != state {
152+
http.Redirect(w, r, "/error?msg=state+mismatch", http.StatusFound)
153+
ch <- callbackResult{err: fmt.Errorf("state mismatch: possible CSRF attack")}
154+
return
155+
}
156+
http.Redirect(w, r, "/success", http.StatusFound)
157+
ch <- callbackResult{code: q.Get("code")}
158+
})
159+
160+
mux.HandleFunc("/success", func(w http.ResponseWriter, r *http.Request) {
161+
w.Header().Set("Content-Type", "text/html")
162+
fmt.Fprint(w, `<!DOCTYPE html>
163+
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
164+
<h2>Authentication Successful</h2>
165+
<p>You can close this tab and return to the terminal.</p>
166+
</body></html>`)
167+
})
168+
169+
mux.HandleFunc("/error", func(w http.ResponseWriter, r *http.Request) {
170+
msg := r.URL.Query().Get("msg")
171+
w.Header().Set("Content-Type", "text/html")
172+
fmt.Fprintf(w, `<!DOCTYPE html>
173+
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
174+
<h2>Authentication Failed</h2><p>%s</p>
175+
<p>Please close this tab and try again.</p>
176+
</body></html>`, msg)
177+
})
178+
179+
ln, err := net.Listen("tcp", ":1455")
180+
if err != nil {
181+
return nil, fmt.Errorf("failed to start callback server on port 1455 (is it already in use?): %w", err)
182+
}
183+
go func() { _ = srv.Serve(ln) }()
184+
185+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
186+
defer cancel()
187+
188+
var res callbackResult
189+
select {
190+
case res = <-ch:
191+
case <-ctx.Done():
192+
_ = srv.Shutdown(context.Background())
193+
return nil, fmt.Errorf("authentication timed out after 5 minutes")
194+
}
195+
196+
time.Sleep(500 * time.Millisecond)
197+
_ = srv.Shutdown(context.Background())
198+
199+
if res.err != nil {
200+
return nil, res.err
201+
}
202+
203+
fmt.Println("Exchanging authorization code for tokens...")
204+
tokens, err := exchangeCodexToken(res.code, verifier)
205+
if err != nil {
206+
return nil, err
207+
}
208+
209+
expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second).UTC().Format(time.RFC3339)
210+
211+
bundleJSON, err := json.Marshal(codexBundle{
212+
Access: tokens.AccessToken,
213+
Refresh: tokens.RefreshToken,
214+
ExpiresAt: expiresAt,
215+
})
216+
if err != nil {
217+
return nil, fmt.Errorf("failed to marshal OAuth bundle: %w", err)
218+
}
219+
value := string(bundleJSON)
220+
221+
fmt.Printf("Storing secret '%s'...\n", codexSecretName)
222+
if err := UpsertSecret(codexSecretName, value); err != nil {
223+
return nil, fmt.Errorf("failed to store secret: %w", err)
224+
}
225+
226+
return nil, nil
227+
}

internal/auth/auth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
)
1414

1515
func SaveJWT() error {
16-
jwt, err := utils.GetInput("Enter your Pinata JWT")
16+
jwt, err := utils.GetInput("Enter your Pinata JWT", "Pinata JWT")
1717
if err != nil {
1818
return err
1919
}

internal/utils/utils.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,14 @@ var (
2929
type item string
3030

3131
type inputModel struct {
32+
label string
3233
textInput textinput.Model
3334
err error
3435
}
3536

36-
func initialInputModel() inputModel {
37+
func initialInputModel(label, placeholder string) inputModel {
3738
ti := textinput.New()
38-
ti.Placeholder = "Pinata JWT"
39+
ti.Placeholder = placeholder
3940
ti.Focus()
4041
ti.Width = 35
4142
ti.EchoMode = textinput.EchoPassword
@@ -46,6 +47,7 @@ func initialInputModel() inputModel {
4647
ti.TextStyle = itemStyle
4748

4849
return inputModel{
50+
label: label,
4951
textInput: ti,
5052
err: nil,
5153
}
@@ -79,14 +81,14 @@ func (m inputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7981
func (m inputModel) View() string {
8082
return fmt.Sprintf(
8183
"%s\n\n%s\n\n%s",
82-
"Enter your Pinata JWT",
84+
m.label,
8385
m.textInput.View(),
8486
"(press enter to submit)",
8587
) + "\n"
8688
}
8789

88-
func GetInput(placeholder string) (string, error) {
89-
p := tea.NewProgram(initialInputModel())
90+
func GetInput(label, placeholder string) (string, error) {
91+
p := tea.NewProgram(initialInputModel(label, placeholder))
9092
m, err := p.Run()
9193
if err != nil {
9294
return "", err

main.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2235,6 +2235,41 @@ Examples:
22352235
return err
22362236
},
22372237
},
2238+
{
2239+
Name: "auth",
2240+
Usage: "Authenticate with a provider and store the credential as a secret",
2241+
ArgsUsage: "[provider: anthropic, openai, openrouter]",
2242+
Flags: []cli.Flag{
2243+
&cli.BoolFlag{
2244+
Name: "oauth",
2245+
Usage: "Use OAuth browser flow instead of API key (openai only)",
2246+
},
2247+
&cli.BoolFlag{
2248+
Name: "setup-token",
2249+
Usage: "Store an Anthropic setup token instead of an API key (anthropic only)",
2250+
},
2251+
},
2252+
Action: func(ctx *cli.Context) error {
2253+
provider := ctx.Args().First()
2254+
switch provider {
2255+
case "anthropic":
2256+
if ctx.Bool("setup-token") {
2257+
return agents.CredentialLogin("Anthropic setup token (run 'claude setup-token' to generate one)", "ANTHROPIC_SETUP_TOKEN")
2258+
}
2259+
return agents.CredentialLogin("Anthropic API key", "ANTHROPIC_API_KEY")
2260+
case "openai":
2261+
if ctx.Bool("oauth") {
2262+
_, err := agents.CodexOAuthLogin()
2263+
return err
2264+
}
2265+
return agents.CredentialLogin("OpenAI API key", "OPENAI_API_KEY")
2266+
case "openrouter":
2267+
return agents.CredentialLogin("OpenRouter API key", "OPENROUTER_API_KEY")
2268+
default:
2269+
return fmt.Errorf("unsupported provider: %q\navailable: anthropic, openai, openrouter", provider)
2270+
}
2271+
},
2272+
},
22382273
{
22392274
Name: "feedback",
22402275
Usage: "Submit feedback or feature request",

0 commit comments

Comments
 (0)