detached f2fef94

Merge pull request #213 from airtonix/issue-212-oauth-state

fix(auth): prevent duplicate OpenAI OAuth transactions (closes #212)
commit
f2fef94e4d0f93fefeda13562ac61cd352f5a4d2
author
Patric Eckhart · · 2026-09-26 11:21 UTC
committer
GitHub · · 2026-09-26 11:21 UTC
merge of
352bb42 + 8a03f64 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
6 files · +354 -55
M README.md +2 -3
@@ -115,9 +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- - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.
119- - Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers.
120- - OpenAI uses the Codex CLI OAuth flow. 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.
121120 - 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.
122121 - 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.
123122 - 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,9 +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- - Anthropic and OpenAI pin the browser callback to fixed provider-specific ports (`localhost:53692` for Anthropic, `localhost:1455` for OpenAI) because those are the only ports their auth servers will redirect to.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- - 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.
120- - OpenAI uses the Codex CLI OAuth flow. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token.
121 - 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.
122 - 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.
123 - 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 +9 -22
@@ -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 }
@@ -5355,31 +5358,12 @@ func (i *Interactive) startOAuthFlow(provider string) {
53555358 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
53565359 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
53575360 }
5358- // Device-code providers already support headless login and must only
5359- // start one polling flow.
5360- if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
5361- loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
5362- if err != nil {
5363- i.dialog.ShowResult(false, err.Error())
5364- return
5365- }
5366- i.dialog.ShowWaiting(loginURL)
5367- return
5368- }
5369- // Always run the manual/copy-code flow in parallel with the local
5370- // callback server so headless environments (docker, SSH) can paste
5371- // the authorization code directly without first pressing 'p'.
5372- _, err := i.cfg.AuthManager.StartOAuth(provider)
5361+ loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
53735362 if err != nil {
53745363 i.dialog.ShowResult(false, err.Error())
53755364 return
53765365 }
5377- manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)
5378- if mErr == nil {
5379- i.dialog.ShowWaiting(manualURL)
5380- } else {
5381- i.dialog.ShowResult(false, mErr.Error())
5382- }
5366+ i.dialog.ShowWaiting(loginURL)
53835367 }
53845368
53855369 func (i *Interactive) startManualOAuthFlow(provider string) {
@@ -5392,7 +5376,7 @@ func (i *Interactive) startManualOAuthFlow(provider string) {
53925376 i.dialog.ShowResult(false, err.Error())
53935377 return
53945378 }
5395- i.dialog.url = url
5379+ i.dialog.ShowPasteCode(url)
53965380 i.invalidate()
53975381 }
53985382
@@ -5872,6 +5856,9 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
58725856 func (i *Interactive) handleAuthEvent(ev auth.Event) {
58735857 switch ev.Kind {
58745858 case "started":
5859+ if i.dialog.step == loginStepPasteCode && i.dialog.url == ev.URL {
5860+ return
5861+ }
58755862 i.dialog.ShowWaiting(ev.URL)
58765863 case "browser_open":
58775864 // 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 }
@@ -5355,31 +5358,12 @@ func (i *Interactive) startOAuthFlow(provider string) {
5355 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {5358 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
5356 _ = i.cfg.SetKimiCLIFallbackDisabled(false)5359 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
5357 }5360 }
5358- // Device-code providers already support headless login and must only5361+ loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
5359- // start one polling flow.
5360- if provider == "kimi" || provider == "xai" || provider == "github-copilot" {
5361- loginURL, err := i.cfg.AuthManager.StartOAuth(provider)
5362- if err != nil {
5363- i.dialog.ShowResult(false, err.Error())
5364- return
5365- }
5366- i.dialog.ShowWaiting(loginURL)
5367- return
5368- }
5369- // Always run the manual/copy-code flow in parallel with the local
5370- // callback server so headless environments (docker, SSH) can paste
5371- // the authorization code directly without first pressing 'p'.
5372- _, err := i.cfg.AuthManager.StartOAuth(provider)
5373 if err != nil {5362 if err != nil {
5374 i.dialog.ShowResult(false, err.Error())5363 i.dialog.ShowResult(false, err.Error())
5375 return5364 return
5376 }5365 }
5377- manualURL, mErr := i.cfg.AuthManager.StartManualOAuth(provider)5366+ i.dialog.ShowWaiting(loginURL)
5378- if mErr == nil {
5379- i.dialog.ShowWaiting(manualURL)
5380- } else {
5381- i.dialog.ShowResult(false, mErr.Error())
5382- }
5383 }5367 }
5384 5368
5385 func (i *Interactive) startManualOAuthFlow(provider string) {5369 func (i *Interactive) startManualOAuthFlow(provider string) {
@@ -5392,7 +5376,7 @@ func (i *Interactive) startManualOAuthFlow(provider string) {
5392 i.dialog.ShowResult(false, err.Error())5376 i.dialog.ShowResult(false, err.Error())
5393 return5377 return
5394 }5378 }
5395- i.dialog.url = url5379+ i.dialog.ShowPasteCode(url)
5396 i.invalidate()5380 i.invalidate()
5397 }5381 }
5398 5382
@@ -5872,6 +5856,9 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
5872 func (i *Interactive) handleAuthEvent(ev auth.Event) {5856 func (i *Interactive) handleAuthEvent(ev auth.Event) {
5873 switch ev.Kind {5857 switch ev.Kind {
5874 case "started":5858 case "started":
5859+ if i.dialog.step == loginStepPasteCode && i.dialog.url == ev.URL {
5860+ return
5861+ }
5875 i.dialog.ShowWaiting(ev.URL)5862 i.dialog.ShowWaiting(ev.URL)
5876 case "browser_open":5863 case "browser_open":
5877 // no-op5864 // no-op
M packages/agent/modes/login_dialog.go +60 -10
@@ -18,15 +18,16 @@ type loginStep int
1818 // dialog must default to closed so nothing shows up until Open() is
1919 // explicitly called.
2020 const (
21- loginStepClosed loginStep = iota
22- loginStepMethod // pick apikey vs subscription
23- loginStepProvider // pick anthropic vs openai vs kimi
24- loginStepLlamaURL // enter and validate llama.cpp router URL
25- loginStepLlamaKey // enter optional llama.cpp API key
26- loginStepWaiting // browser open, waiting for callback
27- loginStepPasteCode // user pastes the auth code here
28- loginStepInfo // informational setup guidance
29- loginStepDone // success or error, waiting for key to dismiss
21+ loginStepClosed loginStep = iota
22+ loginStepMethod // pick apikey vs subscription
23+ loginStepProvider // pick anthropic vs openai vs kimi
24+ loginStepOAuthMethod // pick browser or copy-code for Anthropic
25+ loginStepLlamaURL // enter and validate llama.cpp router URL
26+ loginStepLlamaKey // enter optional llama.cpp API key
27+ loginStepWaiting // browser open, waiting for callback
28+ loginStepPasteCode // user pastes the auth code here
29+ loginStepInfo // informational setup guidance
30+ loginStepDone // success or error, waiting for key to dismiss
3031 )
3132
3233 const loginProviderPageSize = 8
@@ -79,6 +80,7 @@ func (d *loginDialog) Open(zotHome string) {
7980 d.url = ""
8081 d.cursor = 0
8182 d.providerQuery = ""
83+ d.codeEd = nil
8284 d.llamaEd = nil
8385 d.llamaURL = ""
8486 d.infoTitle = ""
@@ -183,6 +185,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
183185 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
184186 }
185187 lines = append(lines, frameRule(th, width))
188+ case loginStepOAuthMethod:
189+ lines = append(lines, frameHeader(th, "login - subscription - "+providerLabel(d.provider), width))
190+ lines = append(lines, th.FG256(th.Muted, "choose sign-in flow (↑/↓, enter, esc to go back):"))
191+ for idx, label := range []string{"browser callback", "copy code (headless / SSH)"} {
192+ plain := " " + label
193+ if idx == d.cursor {
194+ lines = append(lines, th.PadHighlight(plain, width))
195+ } else {
196+ lines = append(lines, th.FG256(th.Muted, plain))
197+ }
198+ }
199+ lines = append(lines, frameRule(th, width))
186200 case loginStepLlamaURL:
187201 lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width))
188202 lines = append(lines, th.FG256(th.Muted, "server URL:"))
@@ -216,7 +230,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
216230 lines = append(lines, th.FG256(th.Accent, seg))
217231 }
218232 lines = append(lines, "")
219- if d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" {
233+ if d.method == "oauth" && d.provider != "anthropic" && d.provider != "openai-codex" {
220234 lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels"))
221235 lines = append(lines, frameRule(th, width))
222236 break
@@ -447,6 +461,8 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
447461 return d.handleMethodKey(k)
448462 case loginStepProvider:
449463 return d.handleProviderKey(k)
464+ case loginStepOAuthMethod:
465+ return d.handleOAuthMethodKey(k)
450466 case loginStepLlamaURL, loginStepLlamaKey:
451467 return d.handleLlamaKey(k)
452468 case loginStepWaiting:
@@ -543,6 +559,11 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
543559 d.message = ""
544560 return loginDialogAction{}
545561 }
562+ if d.method == "oauth" && d.provider == "anthropic" {
563+ d.step = loginStepOAuthMethod
564+ d.cursor = 0
565+ return loginDialogAction{}
566+ }
546567 d.step = loginStepWaiting
547568 if d.method == "apikey" {
548569 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
@@ -552,6 +573,26 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
552573 return loginDialogAction{}
553574 }
554575
576+func (d *loginDialog) handleOAuthMethodKey(k tui.Key) loginDialogAction {
577+ switch k.Kind {
578+ case tui.KeyUp:
579+ d.cursor = 0
580+ case tui.KeyDown:
581+ d.cursor = 1
582+ case tui.KeyEsc:
583+ d.step = loginStepProvider
584+ d.cursor = 0
585+ case tui.KeyEnter:
586+ if d.cursor == 1 {
587+ d.step = loginStepPasteCode
588+ return loginDialogAction{StartManual: true, Provider: d.provider}
589+ }
590+ d.step = loginStepWaiting
591+ return loginDialogAction{StartOAuth: true, Provider: d.provider}
592+ }
593+ return loginDialogAction{}
594+}
595+
555596 func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction {
556597 if k.Kind == tui.KeyEsc {
557598 d.Close()
@@ -597,6 +638,15 @@ func (d *loginDialog) ShowWaiting(url string) {
597638 d.url = url
598639 }
599640
641+// ShowPasteCode displays the URL for a manual OAuth transaction.
642+func (d *loginDialog) ShowPasteCode(url string) {
643+ if d.step == loginStepClosed {
644+ return
645+ }
646+ d.step = loginStepPasteCode
647+ d.url = url
648+}
649+
600650 // ShowInfo transitions to an informational setup dialog.
601651 // No-op if the user has already dismissed the dialog.
602652 func (d *loginDialog) ShowInfo(title string, lines []string) {
@@ -18,15 +18,16 @@ type loginStep int
18 // dialog must default to closed so nothing shows up until Open() is18 // dialog must default to closed so nothing shows up until Open() is
19 // explicitly called.19 // explicitly called.
20 const (20 const (
21- loginStepClosed loginStep = iota21+ loginStepClosed loginStep = iota
22- loginStepMethod // pick apikey vs subscription22+ loginStepMethod // pick apikey vs subscription
23- loginStepProvider // pick anthropic vs openai vs kimi23+ loginStepProvider // pick anthropic vs openai vs kimi
24- loginStepLlamaURL // enter and validate llama.cpp router URL24+ loginStepOAuthMethod // pick browser or copy-code for Anthropic
25- loginStepLlamaKey // enter optional llama.cpp API key25+ loginStepLlamaURL // enter and validate llama.cpp router URL
26- loginStepWaiting // browser open, waiting for callback26+ loginStepLlamaKey // enter optional llama.cpp API key
27- loginStepPasteCode // user pastes the auth code here27+ loginStepWaiting // browser open, waiting for callback
28- loginStepInfo // informational setup guidance28+ loginStepPasteCode // user pastes the auth code here
29- loginStepDone // success or error, waiting for key to dismiss29+ loginStepInfo // informational setup guidance
30+ loginStepDone // success or error, waiting for key to dismiss
30 )31 )
31 32
32 const loginProviderPageSize = 833 const loginProviderPageSize = 8
@@ -79,6 +80,7 @@ func (d *loginDialog) Open(zotHome string) {
79 d.url = ""80 d.url = ""
80 d.cursor = 081 d.cursor = 0
81 d.providerQuery = ""82 d.providerQuery = ""
83+ d.codeEd = nil
82 d.llamaEd = nil84 d.llamaEd = nil
83 d.llamaURL = ""85 d.llamaURL = ""
84 d.infoTitle = ""86 d.infoTitle = ""
@@ -183,6 +185,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
183 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))185 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
184 }186 }
185 lines = append(lines, frameRule(th, width))187 lines = append(lines, frameRule(th, width))
188+ case loginStepOAuthMethod:
189+ lines = append(lines, frameHeader(th, "login - subscription - "+providerLabel(d.provider), width))
190+ lines = append(lines, th.FG256(th.Muted, "choose sign-in flow (↑/↓, enter, esc to go back):"))
191+ for idx, label := range []string{"browser callback", "copy code (headless / SSH)"} {
192+ plain := " " + label
193+ if idx == d.cursor {
194+ lines = append(lines, th.PadHighlight(plain, width))
195+ } else {
196+ lines = append(lines, th.FG256(th.Muted, plain))
197+ }
198+ }
199+ lines = append(lines, frameRule(th, width))
186 case loginStepLlamaURL:200 case loginStepLlamaURL:
187 lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width))201 lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width))
188 lines = append(lines, th.FG256(th.Muted, "server URL:"))202 lines = append(lines, th.FG256(th.Muted, "server URL:"))
@@ -216,7 +230,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
216 lines = append(lines, th.FG256(th.Accent, seg))230 lines = append(lines, th.FG256(th.Accent, seg))
217 }231 }
218 lines = append(lines, "")232 lines = append(lines, "")
219- if d.provider == "kimi" || d.provider == "xai" || d.provider == "github-copilot" {233+ if d.method == "oauth" && d.provider != "anthropic" && d.provider != "openai-codex" {
220 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"))
221 lines = append(lines, frameRule(th, width))235 lines = append(lines, frameRule(th, width))
222 break236 break
@@ -447,6 +461,8 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
447 return d.handleMethodKey(k)461 return d.handleMethodKey(k)
448 case loginStepProvider:462 case loginStepProvider:
449 return d.handleProviderKey(k)463 return d.handleProviderKey(k)
464+ case loginStepOAuthMethod:
465+ return d.handleOAuthMethodKey(k)
450 case loginStepLlamaURL, loginStepLlamaKey:466 case loginStepLlamaURL, loginStepLlamaKey:
451 return d.handleLlamaKey(k)467 return d.handleLlamaKey(k)
452 case loginStepWaiting:468 case loginStepWaiting:
@@ -543,6 +559,11 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
543 d.message = ""559 d.message = ""
544 return loginDialogAction{}560 return loginDialogAction{}
545 }561 }
562+ if d.method == "oauth" && d.provider == "anthropic" {
563+ d.step = loginStepOAuthMethod
564+ d.cursor = 0
565+ return loginDialogAction{}
566+ }
546 d.step = loginStepWaiting567 d.step = loginStepWaiting
547 if d.method == "apikey" {568 if d.method == "apikey" {
548 return loginDialogAction{StartAPIKey: true, Provider: d.provider}569 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
@@ -552,6 +573,26 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
552 return loginDialogAction{}573 return loginDialogAction{}
553 }574 }
554 575
576+func (d *loginDialog) handleOAuthMethodKey(k tui.Key) loginDialogAction {
577+ switch k.Kind {
578+ case tui.KeyUp:
579+ d.cursor = 0
580+ case tui.KeyDown:
581+ d.cursor = 1
582+ case tui.KeyEsc:
583+ d.step = loginStepProvider
584+ d.cursor = 0
585+ case tui.KeyEnter:
586+ if d.cursor == 1 {
587+ d.step = loginStepPasteCode
588+ return loginDialogAction{StartManual: true, Provider: d.provider}
589+ }
590+ d.step = loginStepWaiting
591+ return loginDialogAction{StartOAuth: true, Provider: d.provider}
592+ }
593+ return loginDialogAction{}
594+}
595+
555 func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction {596 func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction {
556 if k.Kind == tui.KeyEsc {597 if k.Kind == tui.KeyEsc {
557 d.Close()598 d.Close()
@@ -597,6 +638,15 @@ func (d *loginDialog) ShowWaiting(url string) {
597 d.url = url638 d.url = url
598 }639 }
599 640
641+// ShowPasteCode displays the URL for a manual OAuth transaction.
642+func (d *loginDialog) ShowPasteCode(url string) {
643+ if d.step == loginStepClosed {
644+ return
645+ }
646+ d.step = loginStepPasteCode
647+ d.url = url
648+}
649+
600 // ShowInfo transitions to an informational setup dialog.650 // ShowInfo transitions to an informational setup dialog.
601 // No-op if the user has already dismissed the dialog.651 // No-op if the user has already dismissed the dialog.
602 func (d *loginDialog) ShowInfo(title string, lines []string) {652 func (d *loginDialog) ShowInfo(title string, lines []string) {
M packages/agent/modes/login_dialog_test.go +96 -1
@@ -1,9 +1,13 @@
11 package modes
22
33 import (
4+ "context"
5+ "net/url"
6+ "path/filepath"
47 "strings"
58 "testing"
69
10+ "github.com/patriceckhart/zot/packages/provider/auth"
711 "github.com/patriceckhart/zot/packages/tui"
812 )
913
@@ -69,12 +73,103 @@ func TestLoginDialogLlamaCPPValidatesURLAndAcceptsOptionalKey(t *testing.T) {
6973 }
7074 }
7175
76+func TestLoginDialogOpenAIOAuthAcceptsCodeFromSameTransaction(t *testing.T) {
77+ d := newLoginDialog()
78+ d.Open(t.TempDir())
79+ d.method = "oauth"
80+ d.provider = "openai-codex"
81+ d.ShowWaiting("http://localhost:1455/auth/callback")
82+
83+ 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 does not offer pasted code entry")
86+ }
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)
90+ }
91+}
92+
93+func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) {
94+ for _, tc := range []struct {
95+ name string
96+ choice int
97+ manual bool
98+ step loginStep
99+ }{
100+ {name: "browser", choice: 0, step: loginStepWaiting},
101+ {name: "copy code", choice: 1, manual: true, step: loginStepPasteCode},
102+ } {
103+ t.Run(tc.name, func(t *testing.T) {
104+ d := newLoginDialog()
105+ d.Open(t.TempDir())
106+ d.method = "oauth"
107+ d.step = loginStepProvider
108+ for idx, p := range d.providerOptions() {
109+ if p == "anthropic" {
110+ d.cursor = idx
111+ break
112+ }
113+ }
114+ if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.StartOAuth || action.StartManual || d.step != loginStepOAuthMethod {
115+ t.Fatalf("provider selection started OAuth before choosing a flow: action=%+v step=%v", action, d.step)
116+ }
117+ for i := 0; i < tc.choice; i++ {
118+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
119+ }
120+ action := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
121+ if action.StartManual != tc.manual || action.StartOAuth == tc.manual || d.step != tc.step {
122+ t.Fatalf("flow action=%+v step=%v", action, d.step)
123+ }
124+ if tc.manual {
125+ d.ShowPasteCode("https://example.com/oauth/authorize")
126+ } else {
127+ d.ShowWaiting("https://example.com/oauth/authorize")
128+ }
129+ text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))
130+ if !strings.Contains(text, "paste the authorization code") {
131+ t.Fatalf("missing code input for %s flow: %s", tc.name, text)
132+ }
133+ })
134+ }
135+}
136+
137+func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) {
138+ manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json")))
139+ t.Cleanup(manager.Close)
140+ i := NewInteractive(InteractiveConfig{AuthManager: manager})
141+ i.dialog.Open(t.TempDir())
142+ i.dialog.method = "oauth"
143+ i.dialog.provider = "anthropic"
144+ i.dialog.step = loginStepOAuthMethod
145+ i.dialog.HandleKey(tui.Key{Kind: tui.KeyDown})
146+ act := i.dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
147+ if !act.StartManual || act.Provider != "anthropic" {
148+ t.Fatalf("expected manual Anthropic transaction, got %+v", act)
149+ }
150+ i.startManualOAuthFlow(act.Provider)
151+ i.handleAuthEvent(<-manager.Events())
152+ if i.dialog.step != loginStepPasteCode {
153+ t.Fatalf("step = %v, want paste code", i.dialog.step)
154+ }
155+ u, err := url.Parse(i.dialog.url)
156+ if err != nil {
157+ t.Fatal(err)
158+ }
159+ if got := u.Query().Get("redirect_uri"); got != auth.AnthropicManualOAuth.RedirectURI() {
160+ t.Fatalf("redirect URI = %q, want copy-code redirect", got)
161+ }
162+ if err := manager.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" {
163+ t.Fatalf("manual transaction not ready for code entry: %v", err)
164+ }
165+}
166+
72167 func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) {
73168 d := newLoginDialog()
74169 d.Open(t.TempDir())
75170 d.method = "oauth"
76171 d.provider = "anthropic"
77- d.ShowWaiting("https://example.com/oauth/authorize?code_challenge=abc&state=xyz")
172+ d.ShowPasteCode("https://example.com/oauth/authorize?code_challenge=abc&state=xyz")
78173
79174 lines := padDialogFrame(d.Render(tui.Theme{}, 80))
80175 row, _ := d.CursorPos(80)
@@ -1,9 +1,13 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "context"
5+ "net/url"
6+ "path/filepath"
4 "strings"7 "strings"
5 "testing"8 "testing"
6 9
10+ "github.com/patriceckhart/zot/packages/provider/auth"
7 "github.com/patriceckhart/zot/packages/tui"11 "github.com/patriceckhart/zot/packages/tui"
8 )12 )
9 13
@@ -69,12 +73,103 @@ func TestLoginDialogLlamaCPPValidatesURLAndAcceptsOptionalKey(t *testing.T) {
69 }73 }
70 }74 }
71 75
76+func TestLoginDialogOpenAIOAuthAcceptsCodeFromSameTransaction(t *testing.T) {
77+ d := newLoginDialog()
78+ d.Open(t.TempDir())
79+ d.method = "oauth"
80+ d.provider = "openai-codex"
81+ d.ShowWaiting("http://localhost:1455/auth/callback")
82+
83+ 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 does not offer pasted code entry")
86+ }
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)
90+ }
91+}
92+
93+func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) {
94+ for _, tc := range []struct {
95+ name string
96+ choice int
97+ manual bool
98+ step loginStep
99+ }{
100+ {name: "browser", choice: 0, step: loginStepWaiting},
101+ {name: "copy code", choice: 1, manual: true, step: loginStepPasteCode},
102+ } {
103+ t.Run(tc.name, func(t *testing.T) {
104+ d := newLoginDialog()
105+ d.Open(t.TempDir())
106+ d.method = "oauth"
107+ d.step = loginStepProvider
108+ for idx, p := range d.providerOptions() {
109+ if p == "anthropic" {
110+ d.cursor = idx
111+ break
112+ }
113+ }
114+ if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.StartOAuth || action.StartManual || d.step != loginStepOAuthMethod {
115+ t.Fatalf("provider selection started OAuth before choosing a flow: action=%+v step=%v", action, d.step)
116+ }
117+ for i := 0; i < tc.choice; i++ {
118+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
119+ }
120+ action := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
121+ if action.StartManual != tc.manual || action.StartOAuth == tc.manual || d.step != tc.step {
122+ t.Fatalf("flow action=%+v step=%v", action, d.step)
123+ }
124+ if tc.manual {
125+ d.ShowPasteCode("https://example.com/oauth/authorize")
126+ } else {
127+ d.ShowWaiting("https://example.com/oauth/authorize")
128+ }
129+ text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n"))
130+ if !strings.Contains(text, "paste the authorization code") {
131+ t.Fatalf("missing code input for %s flow: %s", tc.name, text)
132+ }
133+ })
134+ }
135+}
136+
137+func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) {
138+ manager := auth.NewManager(auth.NewStore(filepath.Join(t.TempDir(), "auth.json")))
139+ t.Cleanup(manager.Close)
140+ i := NewInteractive(InteractiveConfig{AuthManager: manager})
141+ i.dialog.Open(t.TempDir())
142+ i.dialog.method = "oauth"
143+ i.dialog.provider = "anthropic"
144+ i.dialog.step = loginStepOAuthMethod
145+ i.dialog.HandleKey(tui.Key{Kind: tui.KeyDown})
146+ act := i.dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
147+ if !act.StartManual || act.Provider != "anthropic" {
148+ t.Fatalf("expected manual Anthropic transaction, got %+v", act)
149+ }
150+ i.startManualOAuthFlow(act.Provider)
151+ i.handleAuthEvent(<-manager.Events())
152+ if i.dialog.step != loginStepPasteCode {
153+ t.Fatalf("step = %v, want paste code", i.dialog.step)
154+ }
155+ u, err := url.Parse(i.dialog.url)
156+ if err != nil {
157+ t.Fatal(err)
158+ }
159+ if got := u.Query().Get("redirect_uri"); got != auth.AnthropicManualOAuth.RedirectURI() {
160+ t.Fatalf("redirect URI = %q, want copy-code redirect", got)
161+ }
162+ if err := manager.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" {
163+ t.Fatalf("manual transaction not ready for code entry: %v", err)
164+ }
165+}
166+
72 func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) {167 func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) {
73 d := newLoginDialog()168 d := newLoginDialog()
74 d.Open(t.TempDir())169 d.Open(t.TempDir())
75 d.method = "oauth"170 d.method = "oauth"
76 d.provider = "anthropic"171 d.provider = "anthropic"
77- d.ShowWaiting("https://example.com/oauth/authorize?code_challenge=abc&state=xyz")172+ d.ShowPasteCode("https://example.com/oauth/authorize?code_challenge=abc&state=xyz")
78 173
79 lines := padDialogFrame(d.Render(tui.Theme{}, 80))174 lines := padDialogFrame(d.Render(tui.Theme{}, 80))
80 row, _ := d.CursorPos(80)175 row, _ := d.CursorPos(80)
M packages/provider/auth/auth_test.go +127 -0
@@ -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,6 +102,132 @@ func TestPKCE(t *testing.T) {
101102 }
102103 }
103104
105+func TestStartManualOAuthRejectsOpenAIWithoutCallback(t *testing.T) {
106+ m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json")))
107+ if _, err := m.StartManualOAuth("openai-codex"); err == nil {
108+ t.Fatal("expected manual OpenAI OAuth to be rejected")
109+ } else if !strings.Contains(err.Error(), "manual code login is not supported") {
110+ t.Fatalf("unexpected error: %v", err)
111+ }
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+
104231 func TestAuthorizeURL(t *testing.T) {
105232 p, _ := NewPKCE()
106233 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,6 +102,132 @@ func TestPKCE(t *testing.T) {
101 }102 }
102 }103 }
103 104
105+func TestStartManualOAuthRejectsOpenAIWithoutCallback(t *testing.T) {
106+ m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json")))
107+ if _, err := m.StartManualOAuth("openai-codex"); err == nil {
108+ t.Fatal("expected manual OpenAI OAuth to be rejected")
109+ } else if !strings.Contains(err.Error(), "manual code login is not supported") {
110+ t.Fatalf("unexpected error: %v", err)
111+ }
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+
104 func TestAuthorizeURL(t *testing.T) {231 func TestAuthorizeURL(t *testing.T) {
105 p, _ := NewPKCE()232 p, _ := NewPKCE()
106 u, state, err := AnthropicOAuth.AuthorizeURL(p)233 u, state, err := AnthropicOAuth.AuthorizeURL(p)
M packages/provider/auth/manager.go +60 -19
@@ -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()
@@ -354,8 +381,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
354381 case "anthropic":
355382 op = AnthropicManualOAuth
356383 case "openai", "openai-codex":
357- op = OpenAIOAuth
358- storeProvider = "openai"
384+ return "", fmt.Errorf("openai login requires the browser callback flow; manual code login is not supported")
359385 case "google":
360386 return "", fmt.Errorf("google login is api-key only; use api key login for gemini")
361387 case "deepseek":
@@ -364,6 +390,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
364390 return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google")
365391 }
366392
393+ m.CancelOAuth()
367394 pkce, err := NewPKCE()
368395 if err != nil {
369396 return "", err
@@ -389,23 +416,35 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
389416 // a token and stores it. Accepts either a raw code or a "code#state"
390417 // token shown by providers like Anthropic when code=true is set.
391418 func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
419+ code, pastedState := parseManualCodeInput(strings.TrimSpace(input))
392420 m.mu.Lock()
393- op := m.manualOp
394- storeProvider := m.manualStoreProvider
395- eventProvider := m.manualEventProvider
396- pkce := m.manualPKCE
397- state := m.manualState
398- m.mu.Unlock()
399- if op == nil {
421+ if m.manualOp == nil {
422+ m.mu.Unlock()
400423 return fmt.Errorf("no manual oauth flow in progress")
401424 }
402- code, pastedState := parseManualCodeInput(strings.TrimSpace(input))
403- if pastedState != "" {
404- state = pastedState
425+ if pastedState != "" && pastedState != m.manualState {
426+ m.mu.Unlock()
427+ return fmt.Errorf("oauth state mismatch")
405428 }
406429 if code == "" {
430+ m.mu.Unlock()
407431 return fmt.Errorf("empty code")
408432 }
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()
409448 exCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
410449 defer cancel()
411450 tok, err := op.Exchange(exCtx, code, state, pkce)
@@ -417,13 +456,6 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
417456 m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()})
418457 return err
419458 }
420- m.mu.Lock()
421- m.manualOp = nil
422- m.manualStoreProvider = ""
423- m.manualEventProvider = ""
424- m.manualPKCE = PKCE{}
425- m.manualState = ""
426- m.mu.Unlock()
427459 m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"})
428460 return nil
429461 }
@@ -491,6 +523,15 @@ func (m *Manager) CancelOAuth() {
491523 m.oauthServer.Shutdown()
492524 m.oauthServer = nil
493525 }
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 = ""
494535 }
495536
496537 // ---- 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()
@@ -354,8 +381,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
354 case "anthropic":381 case "anthropic":
355 op = AnthropicManualOAuth382 op = AnthropicManualOAuth
356 case "openai", "openai-codex":383 case "openai", "openai-codex":
357- op = OpenAIOAuth384+ return "", fmt.Errorf("openai login requires the browser callback flow; manual code login is not supported")
358- storeProvider = "openai"
359 case "google":385 case "google":
360 return "", fmt.Errorf("google login is api-key only; use api key login for gemini")386 return "", fmt.Errorf("google login is api-key only; use api key login for gemini")
361 case "deepseek":387 case "deepseek":
@@ -364,6 +390,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
364 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")
365 }391 }
366 392
393+ m.CancelOAuth()
367 pkce, err := NewPKCE()394 pkce, err := NewPKCE()
368 if err != nil {395 if err != nil {
369 return "", err396 return "", err
@@ -389,23 +416,35 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) {
389 // 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"
390 // token shown by providers like Anthropic when code=true is set.417 // token shown by providers like Anthropic when code=true is set.
391 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))
392 m.mu.Lock()420 m.mu.Lock()
393- op := m.manualOp421+ if m.manualOp == nil {
394- storeProvider := m.manualStoreProvider422+ m.mu.Unlock()
395- eventProvider := m.manualEventProvider
396- pkce := m.manualPKCE
397- state := m.manualState
398- m.mu.Unlock()
399- if op == nil {
400 return fmt.Errorf("no manual oauth flow in progress")423 return fmt.Errorf("no manual oauth flow in progress")
401 }424 }
402- code, pastedState := parseManualCodeInput(strings.TrimSpace(input))425+ if pastedState != "" && pastedState != m.manualState {
403- if pastedState != "" {426+ m.mu.Unlock()
404- state = pastedState427+ return fmt.Errorf("oauth state mismatch")
405 }428 }
406 if code == "" {429 if code == "" {
430+ m.mu.Unlock()
407 return fmt.Errorf("empty code")431 return fmt.Errorf("empty code")
408 }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()
409 exCtx, cancel := context.WithTimeout(ctx, 30*time.Second)448 exCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
410 defer cancel()449 defer cancel()
411 tok, err := op.Exchange(exCtx, code, state, pkce)450 tok, err := op.Exchange(exCtx, code, state, pkce)
@@ -417,13 +456,6 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error {
417 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()})
418 return err457 return err
419 }458 }
420- m.mu.Lock()
421- m.manualOp = nil
422- m.manualStoreProvider = ""
423- m.manualEventProvider = ""
424- m.manualPKCE = PKCE{}
425- m.manualState = ""
426- m.mu.Unlock()
427 m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"})459 m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"})
428 return nil460 return nil
429 }461 }
@@ -491,6 +523,15 @@ func (m *Manager) CancelOAuth() {
491 m.oauthServer.Shutdown()523 m.oauthServer.Shutdown()
492 m.oauthServer = nil524 m.oauthServer = nil
493 }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 = ""
494 }535 }
495 536
496 // ---- 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