detached 8a03f64

Fix pasted OAuth codes using the browser transaction

commit
8a03f64fb8078be957d360ca4be73a92f98baf66
author
patriceckhart · · 2026-09-26 11:08 UTC
parent
34c0119
signature
unsigned
6 files · +198 -30
M README.md +2 -2
@@ -115,8 +115,8 @@ Run `zot` and type `/login`. Pick one of two methods:
115115
116116 - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly.
117117 - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow.
118- - For Anthropic, choose browser callback (`localhost:53692`) or copy code before starting login. Copy code uses Anthropic's hosted redirect and works over SSH or on headless machines without a local callback server. Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
119- - OpenAI uses the Codex CLI OAuth flow with a browser callback on `localhost:1455`. Manual code entry is not supported for its loopback redirect, so the browser must reach zot's callback port. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
118+ - For Anthropic, choose browser callback (`localhost:53692`) or copy code before starting login. Browser callback also accepts a pasted code or full redirect URL from the same authorization link. Copy code uses Anthropic's hosted redirect and works over SSH or on headless machines without a local callback server. Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
119+ - OpenAI uses the Codex CLI OAuth flow with a browser callback on `localhost:1455`. The same authorization link also accepts a pasted code or full redirect URL if the browser cannot reach zot's callback port (for example, over SSH). Copy the final redirect URL from the browser address bar, even if the page fails to load locally. The callback port must still be available on the machine running zot. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
120120 - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers.
121121 - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API.
122122 - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand.
@@ -115,8 +115,8 @@ Run `zot` and type `/login`. Pick one of two methods:
115 115
116 - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly.116 - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly.
117 - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow.117 - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow.
118- - For Anthropic, choose browser callback (`localhost:53692`) or copy code before starting login. Copy code uses Anthropic's hosted redirect and works over SSH or on headless machines without a local callback server. Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.118+ - For Anthropic, choose browser callback (`localhost:53692`) or copy code before starting login. Browser callback also accepts a pasted code or full redirect URL from the same authorization link. Copy code uses Anthropic's hosted redirect and works over SSH or on headless machines without a local callback server. Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
119- - OpenAI uses the Codex CLI OAuth flow with a browser callback on `localhost:1455`. Manual code entry is not supported for its loopback redirect, so the browser must reach zot's callback port. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.119+ - OpenAI uses the Codex CLI OAuth flow with a browser callback on `localhost:1455`. The same authorization link also accepts a pasted code or full redirect URL if the browser cannot reach zot's callback port (for example, over SSH). Copy the final redirect URL from the browser address bar, even if the page fails to load locally. The callback port must still be available on the machine running zot. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
120 - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers.120 - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers.
121 - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API.121 - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API.
122 - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand.122 - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand.
M packages/agent/modes/interactive.go +6 -0
@@ -2298,6 +2298,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
22982298 return false
22992299 }
23002300 act := i.dialog.HandleKey(k)
2301+ if act.Close && i.cfg.AuthManager != nil {
2302+ i.cfg.AuthManager.CancelOAuth()
2303+ }
23012304 if act.StartAPIKey {
23022305 i.startAPIKeyFlow(act.Provider)
23032306 }
@@ -5853,6 +5856,9 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
58535856 func (i *Interactive) handleAuthEvent(ev auth.Event) {
58545857 switch ev.Kind {
58555858 case "started":
5859+ if i.dialog.step == loginStepPasteCode && i.dialog.url == ev.URL {
5860+ return
5861+ }
58565862 i.dialog.ShowWaiting(ev.URL)
58575863 case "browser_open":
58585864 // no-op
@@ -2298,6 +2298,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2298 return false2298 return false
2299 }2299 }
2300 act := i.dialog.HandleKey(k)2300 act := i.dialog.HandleKey(k)
2301+ if act.Close && i.cfg.AuthManager != nil {
2302+ i.cfg.AuthManager.CancelOAuth()
2303+ }
2301 if act.StartAPIKey {2304 if act.StartAPIKey {
2302 i.startAPIKeyFlow(act.Provider)2305 i.startAPIKeyFlow(act.Provider)
2303 }2306 }
@@ -5853,6 +5856,9 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
5853 func (i *Interactive) handleAuthEvent(ev auth.Event) {5856 func (i *Interactive) handleAuthEvent(ev auth.Event) {
5854 switch ev.Kind {5857 switch ev.Kind {
5855 case "started":5858 case "started":
5859+ if i.dialog.step == loginStepPasteCode && i.dialog.url == ev.URL {
5860+ return
5861+ }
5856 i.dialog.ShowWaiting(ev.URL)5862 i.dialog.ShowWaiting(ev.URL)
5857 case "browser_open":5863 case "browser_open":
5858 // no-op5864 // no-op
M packages/agent/modes/login_dialog.go +3 -3
@@ -230,7 +230,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
230230 lines = append(lines, th.FG256(th.Accent, seg))
231231 }
232232 lines = append(lines, "")
233- if d.method == "oauth" {
233+ if d.method == "oauth" && d.provider != "anthropic" && d.provider != "openai-codex" {
234234 lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))
235235 lines = append(lines, frameRule(th, width))
236236 break
@@ -674,7 +674,7 @@ func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction {
674674 d.Close()
675675 return loginDialogAction{Close: true}
676676 }
677- if d.method == "oauth" || d.codeEd == nil {
677+ if d.codeEd == nil {
678678 return loginDialogAction{}
679679 }
680680 if submit := d.codeEd.HandleKey(k); submit {
@@ -717,7 +717,7 @@ func (d *loginDialog) CursorPos(width int) (row, col int) {
717717 }
718718 return baseOffset + eRow, eCol
719719 }
720- if d.codeEd == nil || (d.step == loginStepWaiting && d.method == "oauth") {
720+ if d.codeEd == nil {
721721 return -1, -1
722722 }
723723 if d.step != loginStepPasteCode && d.step != loginStepWaiting {
@@ -230,7 +230,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
230 lines = append(lines, th.FG256(th.Accent, seg))230 lines = append(lines, th.FG256(th.Accent, seg))
231 }231 }
232 lines = append(lines, "")232 lines = append(lines, "")
233- if d.method == "oauth" {233+ if d.method == "oauth" && d.provider != "anthropic" && d.provider != "openai-codex" {
234 lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))234 lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))
235 lines = append(lines, frameRule(th, width))235 lines = append(lines, frameRule(th, width))
236 break236 break
@@ -674,7 +674,7 @@ func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction {
674 d.Close()674 d.Close()
675 return loginDialogAction{Close: true}675 return loginDialogAction{Close: true}
676 }676 }
677- if d.method == "oauth" || d.codeEd == nil {677+ if d.codeEd == nil {
678 return loginDialogAction{}678 return loginDialogAction{}
679 }679 }
680 if submit := d.codeEd.HandleKey(k); submit {680 if submit := d.codeEd.HandleKey(k); submit {
@@ -717,7 +717,7 @@ func (d *loginDialog) CursorPos(width int) (row, col int) {
717 }717 }
718 return baseOffset + eRow, eCol718 return baseOffset + eRow, eCol
719 }719 }
720- if d.codeEd == nil || (d.step == loginStepWaiting && d.method == "oauth") {720+ if d.codeEd == nil {
721 return -1, -1721 return -1, -1
722 }722 }
723 if d.step != loginStepPasteCode && d.step != loginStepWaiting {723 if d.step != loginStepPasteCode && d.step != loginStepWaiting {
M packages/agent/modes/login_dialog_test.go +9 -7
@@ -73,7 +73,7 @@ func TestLoginDialogLlamaCPPValidatesURLAndAcceptsOptionalKey(t *testing.T) {
7373 }
7474 }
7575
76-func TestLoginDialogOpenAIOAuthDoesNotOfferCopyCode(t *testing.T) {
76+func TestLoginDialogOpenAIOAuthAcceptsCodeFromSameTransaction(t *testing.T) {
7777 d := newLoginDialog()
7878 d.Open(t.TempDir())
7979 d.method = "oauth"
@@ -81,11 +81,12 @@ func TestLoginDialogOpenAIOAuthDoesNotOfferCopyCode(t *testing.T) {
8181 d.ShowWaiting("http://localhost:1455/auth/callback")
8282
8383 text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))
84- if strings.Contains(text, "paste the authorization code") {
85- t.Fatal("OpenAI OAuth dialog offers manual code entry")
84+ if !strings.Contains(text, "paste the authorization code") {
85+ t.Fatal("OpenAI OAuth dialog does not offer pasted code entry")
8686 }
87- if !strings.Contains(text, "complete sign-in in the browser") {
88- t.Fatal("OpenAI OAuth dialog does not require browser sign-in")
87+ d.codeEd.SetValue("code#state")
88+ if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SubmitCode != "code#state" {
89+ t.Fatalf("code submission = %+v", action)
8990 }
9091 }
9192
@@ -126,8 +127,8 @@ func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) {
126127 d.ShowWaiting("https://example.com/oauth/authorize")
127128 }
128129 text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))
129- if strings.Contains(text, "paste the authorization code") != tc.manual {
130- t.Fatalf("wrong input for %s flow: %s", tc.name, text)
130+ if !strings.Contains(text, "paste the authorization code") {
131+ t.Fatalf("missing code input for %s flow: %s", tc.name, text)
131132 }
132133 })
133134 }
@@ -147,6 +148,7 @@ func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) {
147148 t.Fatalf("expected manual Anthropic transaction, got %+v", act)
148149 }
149150 i.startManualOAuthFlow(act.Provider)
151+ i.handleAuthEvent(<-manager.Events())
150152 if i.dialog.step != loginStepPasteCode {
151153 t.Fatalf("step = %v, want paste code", i.dialog.step)
152154 }
@@ -73,7 +73,7 @@ func TestLoginDialogLlamaCPPValidatesURLAndAcceptsOptionalKey(t *testing.T) {
73 }73 }
74 }74 }
75 75
76-func TestLoginDialogOpenAIOAuthDoesNotOfferCopyCode(t *testing.T) {76+func TestLoginDialogOpenAIOAuthAcceptsCodeFromSameTransaction(t *testing.T) {
77 d := newLoginDialog()77 d := newLoginDialog()
78 d.Open(t.TempDir())78 d.Open(t.TempDir())
79 d.method = "oauth"79 d.method = "oauth"
@@ -81,11 +81,12 @@ func TestLoginDialogOpenAIOAuthDoesNotOfferCopyCode(t *testing.T) {
81 d.ShowWaiting("http://localhost:1455/auth/callback")81 d.ShowWaiting("http://localhost:1455/auth/callback")
82 82
83 text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))83 text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))
84- if strings.Contains(text, "paste the authorization code") {84+ if !strings.Contains(text, "paste the authorization code") {
85- t.Fatal("OpenAI OAuth dialog offers manual code entry")85+ t.Fatal("OpenAI OAuth dialog does not offer pasted code entry")
86 }86 }
87- if !strings.Contains(text, "complete sign-in in the browser") {87+ d.codeEd.SetValue("code#state")
88- t.Fatal("OpenAI OAuth dialog does not require browser sign-in")88+ if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SubmitCode != "code#state" {
89+ t.Fatalf("code submission = %+v", action)
89 }90 }
90 }91 }
91 92
@@ -126,8 +127,8 @@ func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) {
126 d.ShowWaiting("https://example.com/oauth/authorize")127 d.ShowWaiting("https://example.com/oauth/authorize")
127 }128 }
128 text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))129 text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))
129- if strings.Contains(text, "paste the authorization code") != tc.manual {130+ if !strings.Contains(text, "paste the authorization code") {
130- t.Fatalf("wrong input for %s flow: %s", tc.name, text)131+ t.Fatalf("missing code input for %s flow: %s", tc.name, text)
131 }132 }
132 })133 })
133 }134 }
@@ -147,6 +148,7 @@ func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) {
147 t.Fatalf("expected manual Anthropic transaction, got %+v", act)148 t.Fatalf("expected manual Anthropic transaction, got %+v", act)
148 }149 }
149 i.startManualOAuthFlow(act.Provider)150 i.startManualOAuthFlow(act.Provider)
151+ i.handleAuthEvent(<-manager.Events())
150 if i.dialog.step != loginStepPasteCode {152 if i.dialog.step != loginStepPasteCode {
151 t.Fatalf("step = %v, want paste code", i.dialog.step)153 t.Fatalf("step = %v, want paste code", i.dialog.step)
152 }154 }
M packages/provider/auth/auth_test.go +119 -1
@@ -5,6 +5,7 @@ import (
55 "io"
66 "net"
77 "net/http"
8+ "net/http/httptest"
89 "net/url"
910 "path/filepath"
1011 "strings"
@@ -101,7 +102,7 @@ func TestPKCE(t *testing.T) {
101102 }
102103 }
103104
104-func TestStartManualOAuthRejectsOpenAI(t *testing.T) {
105+func TestStartManualOAuthRejectsOpenAIWithoutCallback(t *testing.T) {
105106 m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json")))
106107 if _, err := m.StartManualOAuth("openai-codex"); err == nil {
107108 t.Fatal("expected manual OpenAI OAuth to be rejected")
@@ -110,6 +111,123 @@ func TestStartManualOAuthRejectsOpenAI(t *testing.T) {
110111 }
111112 }
112113
114+func TestBrowserOAuthSharesTransactionWithPastedCode(t *testing.T) {
115+ for _, provider := range []string{"anthropic", "openai-codex"} {
116+ t.Run(provider, func(t *testing.T) {
117+ m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json")))
118+ m.openBrowser = false
119+ t.Cleanup(m.Close)
120+ loginURL, err := m.StartOAuth(provider)
121+ if err != nil {
122+ if strings.Contains(err.Error(), "bind ") {
123+ t.Skipf("callback port unavailable: %v", err)
124+ }
125+ t.Fatal(err)
126+ }
127+ u, err := url.Parse(loginURL)
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ state := u.Query().Get("state")
132+ if m.manualOp == nil || m.manualState != state || m.manualPKCE.Challenge != u.Query().Get("code_challenge") {
133+ t.Fatal("pasted-code path does not share the browser transaction")
134+ }
135+ if err := m.CompleteManualOAuth(context.Background(), "code#wrong-state"); err == nil || !strings.Contains(err.Error(), "state mismatch") {
136+ t.Fatalf("wrong state accepted: %v", err)
137+ }
138+ if err := m.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" {
139+ t.Fatalf("shared transaction unavailable after bad code: %v", err)
140+ }
141+ m.CancelOAuth()
142+ if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err == nil || !strings.Contains(err.Error(), "no manual oauth flow") {
143+ t.Fatalf("canceled flow still accepts code: %v", err)
144+ }
145+ })
146+ }
147+}
148+
149+func TestPastedOAuthCodeUsesBrowserPKCE(t *testing.T) {
150+ requests := make(chan url.Values, 2)
151+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152+ _ = r.ParseForm()
153+ requests <- r.PostForm
154+ w.Header().Set("content-type", "application/json")
155+ _, _ = w.Write([]byte(`{"access_token":"test-token","refresh_token":"test-refresh","expires_in":3600}`))
156+ }))
157+ defer tokenServer.Close()
158+ old := OpenAIOAuth.TokenURL
159+ OpenAIOAuth.TokenURL = tokenServer.URL
160+ t.Cleanup(func() { OpenAIOAuth.TokenURL = old })
161+
162+ for _, method := range []string{"paste", "callback"} {
163+ t.Run(method, func(t *testing.T) {
164+ store := NewStore(filepath.Join(t.TempDir(), "auth.json"))
165+ m := NewManager(store)
166+ m.openBrowser = false
167+ t.Cleanup(m.Close)
168+ loginURL, err := m.StartOAuth("openai-codex")
169+ if err != nil {
170+ if strings.Contains(err.Error(), "bind ") {
171+ t.Skipf("callback port unavailable: %v", err)
172+ }
173+ t.Fatal(err)
174+ }
175+ u, err := url.Parse(loginURL)
176+ if err != nil {
177+ t.Fatal(err)
178+ }
179+ verifier := m.manualPKCE.Verifier
180+ redirect := u.Query().Get("redirect_uri")
181+ codeURL := redirect + "?code=test-code&state=" + url.QueryEscape(u.Query().Get("state"))
182+ if method == "paste" {
183+ if err := m.CompleteManualOAuth(context.Background(), codeURL); err != nil {
184+ t.Fatal(err)
185+ }
186+ } else {
187+ resp, err := http.Get(codeURL)
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ resp.Body.Close()
192+ if resp.StatusCode != http.StatusOK {
193+ t.Fatalf("callback status = %d", resp.StatusCode)
194+ }
195+ }
196+ select {
197+ case ev := <-m.Events():
198+ if ev.Kind != "started" {
199+ t.Fatalf("event = %+v", ev)
200+ }
201+ case <-time.After(2 * time.Second):
202+ t.Fatal("no started event")
203+ }
204+ select {
205+ case ev := <-m.Events():
206+ if ev.Kind != "success" || ev.Provider != "openai-codex" {
207+ t.Fatalf("event = %+v", ev)
208+ }
209+ case <-time.After(2 * time.Second):
210+ t.Fatal("no success event")
211+ }
212+ select {
213+ case form := <-requests:
214+ if form.Get("code_verifier") != verifier || form.Get("redirect_uri") != redirect || form.Get("code") != "test-code" {
215+ t.Fatalf("exchange did not use browser transaction: %v", form)
216+ }
217+ case <-time.After(2 * time.Second):
218+ t.Fatal("no token exchange")
219+ }
220+ creds, err := store.Load()
221+ if err != nil {
222+ t.Fatal(err)
223+ }
224+ if creds.OpenAI.OAuth == nil || creds.OpenAI.OAuth.AccessToken != "test-token" {
225+ t.Fatalf("stored credential: %v", creds.OpenAI.OAuth)
226+ }
227+ })
228+ }
229+}
230+
113231 func TestAuthorizeURL(t *testing.T) {
114232 p, _ := NewPKCE()
115233 u, state, err := AnthropicOAuth.AuthorizeURL(p)
@@ -5,6 +5,7 @@ import (
5 "io"5 "io"
6 "net"6 "net"
7 "net/http"7 "net/http"
8+ "net/http/httptest"
8 "net/url"9 "net/url"
9 "path/filepath"10 "path/filepath"
10 "strings"11 "strings"
@@ -101,7 +102,7 @@ func TestPKCE(t *testing.T) {
101 }102 }
102 }103 }
103 104
104-func TestStartManualOAuthRejectsOpenAI(t *testing.T) {105+func TestStartManualOAuthRejectsOpenAIWithoutCallback(t *testing.T) {
105 m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json")))106 m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json")))
106 if _, err := m.StartManualOAuth("openai-codex"); err == nil {107 if _, err := m.StartManualOAuth("openai-codex"); err == nil {
107 t.Fatal("expected manual OpenAI OAuth to be rejected")108 t.Fatal("expected manual OpenAI OAuth to be rejected")
@@ -110,6 +111,123 @@ func TestStartManualOAuthRejectsOpenAI(t *testing.T) {
110 }111 }
111 }112 }
112 113
114+func TestBrowserOAuthSharesTransactionWithPastedCode(t *testing.T) {
115+ for _, provider := range []string{"anthropic", "openai-codex"} {
116+ t.Run(provider, func(t *testing.T) {
117+ m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json")))
118+ m.openBrowser = false
119+ t.Cleanup(m.Close)
120+ loginURL, err := m.StartOAuth(provider)
121+ if err != nil {
122+ if strings.Contains(err.Error(), "bind ") {
123+ t.Skipf("callback port unavailable: %v", err)
124+ }
125+ t.Fatal(err)
126+ }
127+ u, err := url.Parse(loginURL)
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ state := u.Query().Get("state")
132+ if m.manualOp == nil || m.manualState != state || m.manualPKCE.Challenge != u.Query().Get("code_challenge") {
133+ t.Fatal("pasted-code path does not share the browser transaction")
134+ }
135+ if err := m.CompleteManualOAuth(context.Background(), "code#wrong-state"); err == nil || !strings.Contains(err.Error(), "state mismatch") {
136+ t.Fatalf("wrong state accepted: %v", err)
137+ }
138+ if err := m.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" {
139+ t.Fatalf("shared transaction unavailable after bad code: %v", err)
140+ }
141+ m.CancelOAuth()
142+ if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err == nil || !strings.Contains(err.Error(), "no manual oauth flow") {
143+ t.Fatalf("canceled flow still accepts code: %v", err)
144+ }
145+ })
146+ }
147+}
148+
149+func TestPastedOAuthCodeUsesBrowserPKCE(t *testing.T) {
150+ requests := make(chan url.Values, 2)
151+ tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152+ _ = r.ParseForm()
153+ requests <- r.PostForm
154+ w.Header().Set("content-type", "application/json")
155+ _, _ = w.Write([]byte(`{"access_token":"test-token","refresh_token":"test-refresh","expires_in":3600}`))
156+ }))
157+ defer tokenServer.Close()
158+ old := OpenAIOAuth.TokenURL
159+ OpenAIOAuth.TokenURL = tokenServer.URL
160+ t.Cleanup(func() { OpenAIOAuth.TokenURL = old })
161+
162+ for _, method := range []string{"paste", "callback"} {
163+ t.Run(method, func(t *testing.T) {
164+ store := NewStore(filepath.Join(t.TempDir(), "auth.json"))
165+ m := NewManager(store)
166+ m.openBrowser = false
167+ t.Cleanup(m.Close)
168+ loginURL, err := m.StartOAuth("openai-codex")
169+ if err != nil {
170+ if strings.Contains(err.Error(), "bind ") {
171+ t.Skipf("callback port unavailable: %v", err)
172+ }
173+ t.Fatal(err)
174+ }
175+ u, err := url.Parse(loginURL)
176+ if err != nil {
177+ t.Fatal(err)
178+ }
179+ verifier := m.manualPKCE.Verifier
180+ redirect := u.Query().Get("redirect_uri")
181+ codeURL := redirect + "?code=test-code&state=" + url.QueryEscape(u.Query().Get("state"))
182+ if method == "paste" {
183+ if err := m.CompleteManualOAuth(context.Background(), codeURL); err != nil {
184+ t.Fatal(err)
185+ }
186+ } else {
187+ resp, err := http.Get(codeURL)
188+ if err != nil {
189+ t.Fatal(err)
190+ }
191+ resp.Body.Close()
192+ if resp.StatusCode != http.StatusOK {
193+ t.Fatalf("callback status = %d", resp.StatusCode)
194+ }
195+ }
196+ select {
197+ case ev := <-m.Events():
198+ if ev.Kind != "started" {
199+ t.Fatalf("event = %+v", ev)
200+ }
201+ case <-time.After(2 * time.Second):
202+ t.Fatal("no started event")
203+ }
204+ select {
205+ case ev := <-m.Events():
206+ if ev.Kind != "success" || ev.Provider != "openai-codex" {
207+ t.Fatalf("event = %+v", ev)
208+ }
209+ case <-time.After(2 * time.Second):
210+ t.Fatal("no success event")
211+ }
212+ select {
213+ case form := <-requests:
214+ if form.Get("code_verifier") != verifier || form.Get("redirect_uri") != redirect || form.Get("code") != "test-code" {
215+ t.Fatalf("exchange did not use browser transaction: %v", form)
216+ }
217+ case <-time.After(2 * time.Second):
218+ t.Fatal("no token exchange")
219+ }
220+ creds, err := store.Load()
221+ if err != nil {
222+ t.Fatal(err)
223+ }
224+ if creds.OpenAI.OAuth == nil || creds.OpenAI.OAuth.AccessToken != "test-token" {
225+ t.Fatalf("stored credential: %v", creds.OpenAI.OAuth)
226+ }
227+ })
228+ }
229+}
230+
113 func TestAuthorizeURL(t *testing.T) {231 func TestAuthorizeURL(t *testing.T) {
114 p, _ := NewPKCE()232 p, _ := NewPKCE()
115 u, state, err := AnthropicOAuth.AuthorizeURL(p)233 u, state, err := AnthropicOAuth.AuthorizeURL(p)
M packages/provider/auth/manager.go +59 -17
@@ -75,6 +75,7 @@ func (m *Manager) Close() {
7575 m.oauthCancel()
7676 m.oauthCancel = nil
7777 }
78+ m.clearManualOAuthLocked()
7879 }
7980
8081 // ---- API key flow ----
@@ -166,6 +167,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
166167 if m.oauthCancel != nil {
167168 m.oauthCancel()
168169 }
170+ m.clearManualOAuthLocked()
169171 m.mu.Unlock()
170172
171173 pkce, err := NewPKCE()
@@ -187,6 +189,11 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
187189 m.oauthServer = cs
188190 m.oauthCtx = ctx
189191 m.oauthCancel = cancel
192+ m.manualOp = &op
193+ m.manualStoreProvider = storeProvider
194+ m.manualEventProvider = provider
195+ m.manualPKCE = pkce
196+ m.manualState = state
190197 m.mu.Unlock()
191198
192199 go m.awaitOAuth(ctx, op, storeProvider, provider, cs, pkce, state)
@@ -203,14 +210,34 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, storeProvide
203210 res, err := cs.Result(waitCtx)
204211 if err != nil {
205212 if ctx.Err() == nil {
213+ m.mu.Lock()
214+ if m.oauthCtx == ctx {
215+ m.clearManualOAuthLocked()
216+ }
217+ m.mu.Unlock()
206218 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: "timeout waiting for callback"})
207219 }
208220 return
209221 }
222+ if ctx.Err() != nil {
223+ return
224+ }
210225 if res.Err != nil {
226+ m.mu.Lock()
227+ if m.oauthCtx == ctx {
228+ m.clearManualOAuthLocked()
229+ }
230+ m.mu.Unlock()
211231 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: res.Err.Error()})
212232 return
213233 }
234+ m.mu.Lock()
235+ if m.oauthCtx != ctx || m.manualOp == nil {
236+ m.mu.Unlock()
237+ return
238+ }
239+ m.clearManualOAuthLocked()
240+ m.mu.Unlock()
214241
215242 exCtx, exCancel := context.WithTimeout(context.Background(), 30*time.Second)
216243 defer exCancel()
@@ -363,6 +390,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
363390 return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google")
364391 }
365392
393+ m.CancelOAuth()
366394 pkce, err := NewPKCE()
367395 if err != nil {
368396 return "", err
@@ -388,23 +416,35 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
388416 // a token and stores it. Accepts either a raw code or a "code#state"
389417 // token shown by providers like Anthropic when code=true is set.
390418 func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
419+ code, pastedState := parseManualCodeInput(strings.TrimSpace(input))
391420 m.mu.Lock()
392- op := m.manualOp
393- storeProvider := m.manualStoreProvider
394- eventProvider := m.manualEventProvider
395- pkce := m.manualPKCE
396- state := m.manualState
397- m.mu.Unlock()
398- if op == nil {
421+ if m.manualOp == nil {
422+ m.mu.Unlock()
399423 return fmt.Errorf("no manual oauth flow in progress")
400424 }
401- code, pastedState := parseManualCodeInput(strings.TrimSpace(input))
402- if pastedState != "" {
403- state = pastedState
425+ if pastedState != "" && pastedState != m.manualState {
426+ m.mu.Unlock()
427+ return fmt.Errorf("oauth state mismatch")
404428 }
405429 if code == "" {
430+ m.mu.Unlock()
406431 return fmt.Errorf("empty code")
407432 }
433+ op := *m.manualOp
434+ storeProvider := m.manualStoreProvider
435+ eventProvider := m.manualEventProvider
436+ pkce := m.manualPKCE
437+ state := m.manualState
438+ m.clearManualOAuthLocked()
439+ if m.oauthServer != nil {
440+ if m.oauthCancel != nil {
441+ m.oauthCancel()
442+ m.oauthCancel = nil
443+ }
444+ m.oauthServer.Shutdown()
445+ m.oauthServer = nil
446+ }
447+ m.mu.Unlock()
408448 exCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
409449 defer cancel()
410450 tok, err := op.Exchange(exCtx, code, state, pkce)
@@ -416,13 +456,6 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
416456 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()})
417457 return err
418458 }
419- m.mu.Lock()
420- m.manualOp = nil
421- m.manualStoreProvider = ""
422- m.manualEventProvider = ""
423- m.manualPKCE = PKCE{}
424- m.manualState = ""
425- m.mu.Unlock()
426459 m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"})
427460 return nil
428461 }
@@ -490,6 +523,15 @@ func (m *Manager) CancelOAuth() {
490523 m.oauthServer.Shutdown()
491524 m.oauthServer = nil
492525 }
526+ m.clearManualOAuthLocked()
527+}
528+
529+func (m *Manager) clearManualOAuthLocked() {
530+ m.manualOp = nil
531+ m.manualStoreProvider = ""
532+ m.manualEventProvider = ""
533+ m.manualPKCE = PKCE{}
534+ m.manualState = ""
493535 }
494536
495537 // ---- shared ----
@@ -75,6 +75,7 @@ func (m *Manager) Close() {
75 m.oauthCancel()75 m.oauthCancel()
76 m.oauthCancel = nil76 m.oauthCancel = nil
77 }77 }
78+ m.clearManualOAuthLocked()
78 }79 }
79 80
80 // ---- API key flow ----81 // ---- API key flow ----
@@ -166,6 +167,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
166 if m.oauthCancel != nil {167 if m.oauthCancel != nil {
167 m.oauthCancel()168 m.oauthCancel()
168 }169 }
170+ m.clearManualOAuthLocked()
169 m.mu.Unlock()171 m.mu.Unlock()
170 172
171 pkce, err := NewPKCE()173 pkce, err := NewPKCE()
@@ -187,6 +189,11 @@ func (m *Manager) StartOAuth(provider string) (string, error) {
187 m.oauthServer = cs189 m.oauthServer = cs
188 m.oauthCtx = ctx190 m.oauthCtx = ctx
189 m.oauthCancel = cancel191 m.oauthCancel = cancel
192+ m.manualOp = &op
193+ m.manualStoreProvider = storeProvider
194+ m.manualEventProvider = provider
195+ m.manualPKCE = pkce
196+ m.manualState = state
190 m.mu.Unlock()197 m.mu.Unlock()
191 198
192 go m.awaitOAuth(ctx, op, storeProvider, provider, cs, pkce, state)199 go m.awaitOAuth(ctx, op, storeProvider, provider, cs, pkce, state)
@@ -203,14 +210,34 @@ func (m *Manager) awaitOAuth(ctx context.Context, op OAuthProvider, storeProvide
203 res, err := cs.Result(waitCtx)210 res, err := cs.Result(waitCtx)
204 if err != nil {211 if err != nil {
205 if ctx.Err() == nil {212 if ctx.Err() == nil {
213+ m.mu.Lock()
214+ if m.oauthCtx == ctx {
215+ m.clearManualOAuthLocked()
216+ }
217+ m.mu.Unlock()
206 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: "timeout waiting for callback"})218 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: "timeout waiting for callback"})
207 }219 }
208 return220 return
209 }221 }
222+ if ctx.Err() != nil {
223+ return
224+ }
210 if res.Err != nil {225 if res.Err != nil {
226+ m.mu.Lock()
227+ if m.oauthCtx == ctx {
228+ m.clearManualOAuthLocked()
229+ }
230+ m.mu.Unlock()
211 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: res.Err.Error()})231 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: res.Err.Error()})
212 return232 return
213 }233 }
234+ m.mu.Lock()
235+ if m.oauthCtx != ctx || m.manualOp == nil {
236+ m.mu.Unlock()
237+ return
238+ }
239+ m.clearManualOAuthLocked()
240+ m.mu.Unlock()
214 241
215 exCtx, exCancel := context.WithTimeout(context.Background(), 30*time.Second)242 exCtx, exCancel := context.WithTimeout(context.Background(), 30*time.Second)
216 defer exCancel()243 defer exCancel()
@@ -363,6 +390,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
363 return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google")390 return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google")
364 }391 }
365 392
393+ m.CancelOAuth()
366 pkce, err := NewPKCE()394 pkce, err := NewPKCE()
367 if err != nil {395 if err != nil {
368 return "", err396 return "", err
@@ -388,23 +416,35 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
388 // a token and stores it. Accepts either a raw code or a "code#state"416 // a token and stores it. Accepts either a raw code or a "code#state"
389 // token shown by providers like Anthropic when code=true is set.417 // token shown by providers like Anthropic when code=true is set.
390 func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {418 func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
419+ code, pastedState := parseManualCodeInput(strings.TrimSpace(input))
391 m.mu.Lock()420 m.mu.Lock()
392- op := m.manualOp421+ if m.manualOp == nil {
393- storeProvider := m.manualStoreProvider422+ m.mu.Unlock()
394- eventProvider := m.manualEventProvider
395- pkce := m.manualPKCE
396- state := m.manualState
397- m.mu.Unlock()
398- if op == nil {
399 return fmt.Errorf("no manual oauth flow in progress")423 return fmt.Errorf("no manual oauth flow in progress")
400 }424 }
401- code, pastedState := parseManualCodeInput(strings.TrimSpace(input))425+ if pastedState != "" && pastedState != m.manualState {
402- if pastedState != "" {426+ m.mu.Unlock()
403- state = pastedState427+ return fmt.Errorf("oauth state mismatch")
404 }428 }
405 if code == "" {429 if code == "" {
430+ m.mu.Unlock()
406 return fmt.Errorf("empty code")431 return fmt.Errorf("empty code")
407 }432 }
433+ op := *m.manualOp
434+ storeProvider := m.manualStoreProvider
435+ eventProvider := m.manualEventProvider
436+ pkce := m.manualPKCE
437+ state := m.manualState
438+ m.clearManualOAuthLocked()
439+ if m.oauthServer != nil {
440+ if m.oauthCancel != nil {
441+ m.oauthCancel()
442+ m.oauthCancel = nil
443+ }
444+ m.oauthServer.Shutdown()
445+ m.oauthServer = nil
446+ }
447+ m.mu.Unlock()
408 exCtx, cancel := context.WithTimeout(ctx, 30*time.Second)448 exCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
409 defer cancel()449 defer cancel()
410 tok, err := op.Exchange(exCtx, code, state, pkce)450 tok, err := op.Exchange(exCtx, code, state, pkce)
@@ -416,13 +456,6 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
416 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()})456 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()})
417 return err457 return err
418 }458 }
419- m.mu.Lock()
420- m.manualOp = nil
421- m.manualStoreProvider = ""
422- m.manualEventProvider = ""
423- m.manualPKCE = PKCE{}
424- m.manualState = ""
425- m.mu.Unlock()
426 m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"})459 m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"})
427 return nil460 return nil
428 }461 }
@@ -490,6 +523,15 @@ func (m *Manager) CancelOAuth() {
490 m.oauthServer.Shutdown()523 m.oauthServer.Shutdown()
491 m.oauthServer = nil524 m.oauthServer = nil
492 }525 }
526+ m.clearManualOAuthLocked()
527+}
528+
529+func (m *Manager) clearManualOAuthLocked() {
530+ m.manualOp = nil
531+ m.manualStoreProvider = ""
532+ m.manualEventProvider = ""
533+ m.manualPKCE = PKCE{}
534+ m.manualState = ""
493 }535 }
494 536
495 // ---- shared ----537 // ---- shared ----

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close