Merge pull request #213 from airtonix/issue-212-oauth-state
fix(auth): prevent duplicate OpenAI OAuth transactions (closes #212)
6 files · +354 -55
M README.md +2 -3
| @@ -115,9 +115,8 @@ Run `zot` and type `/login`. Pick one of two methods: | |||
| 115 | 115 | ||
| 116 | 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 | 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. | |
| 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. | |
| 121 | 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 | 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 | 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. | |
| @@ -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) { | |||
| 2298 | 2298 | return false | |
| 2299 | 2299 | } | |
| 2300 | 2300 | act := i.dialog.HandleKey(k) | |
| 2301 | + | if act.Close && i.cfg.AuthManager != nil { | |
| 2302 | + | i.cfg.AuthManager.CancelOAuth() | |
| 2303 | + | } | |
| 2301 | 2304 | if act.StartAPIKey { | |
| 2302 | 2305 | i.startAPIKeyFlow(act.Provider) | |
| 2303 | 2306 | } | |
| @@ -5355,31 +5358,12 @@ func (i *Interactive) startOAuthFlow(provider string) { | |||
| 5355 | 5358 | if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil { | |
| 5356 | 5359 | _ = i.cfg.SetKimiCLIFallbackDisabled(false) | |
| 5357 | 5360 | } | |
| 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) | |
| 5373 | 5362 | if err != nil { | |
| 5374 | 5363 | i.dialog.ShowResult(false, err.Error()) | |
| 5375 | 5364 | return | |
| 5376 | 5365 | } | |
| 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) | |
| 5383 | 5367 | } | |
| 5384 | 5368 | ||
| 5385 | 5369 | func (i *Interactive) startManualOAuthFlow(provider string) { | |
| @@ -5392,7 +5376,7 @@ func (i *Interactive) startManualOAuthFlow(provider string) { | |||
| 5392 | 5376 | i.dialog.ShowResult(false, err.Error()) | |
| 5393 | 5377 | return | |
| 5394 | 5378 | } | |
| 5395 | - | i.dialog.url = url | |
| 5379 | + | i.dialog.ShowPasteCode(url) | |
| 5396 | 5380 | i.invalidate() | |
| 5397 | 5381 | } | |
| 5398 | 5382 | ||
| @@ -5872,6 +5856,9 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string) | |||
| 5872 | 5856 | func (i *Interactive) handleAuthEvent(ev auth.Event) { | |
| 5873 | 5857 | switch ev.Kind { | |
| 5874 | 5858 | case "started": | |
| 5859 | + | if i.dialog.step == loginStepPasteCode && i.dialog.url == ev.URL { | |
| 5860 | + | return | |
| 5861 | + | } | |
| 5875 | 5862 | i.dialog.ShowWaiting(ev.URL) | |
| 5876 | 5863 | case "browser_open": | |
| 5877 | 5864 | // no-op | |
| @@ -2298,6 +2298,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2298 | return false | 2298 | 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 only | 5361 | + | 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 | return | 5364 | 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 | return | 5377 | return | ||
| 5394 | } | 5378 | } | ||
| 5395 | - | i.dialog.url = url | 5379 | + | 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-op | 5864 | // no-op | ||
M packages/agent/modes/login_dialog.go +60 -10
| @@ -18,15 +18,16 @@ type loginStep int | |||
| 18 | 18 | // dialog must default to closed so nothing shows up until Open() is | |
| 19 | 19 | // explicitly called. | |
| 20 | 20 | 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 | |
| 30 | 31 | ) | |
| 31 | 32 | ||
| 32 | 33 | const loginProviderPageSize = 8 | |
| @@ -79,6 +80,7 @@ func (d *loginDialog) Open(zotHome string) { | |||
| 79 | 80 | d.url = "" | |
| 80 | 81 | d.cursor = 0 | |
| 81 | 82 | d.providerQuery = "" | |
| 83 | + | d.codeEd = nil | |
| 82 | 84 | d.llamaEd = nil | |
| 83 | 85 | d.llamaURL = "" | |
| 84 | 86 | d.infoTitle = "" | |
| @@ -183,6 +185,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 183 | 185 | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts)))) | |
| 184 | 186 | } | |
| 185 | 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 | 200 | case loginStepLlamaURL: | |
| 187 | 201 | lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width)) | |
| 188 | 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 | 230 | lines = append(lines, th.FG256(th.Accent, seg)) | |
| 217 | 231 | } | |
| 218 | 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 | 234 | lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels")) | |
| 221 | 235 | lines = append(lines, frameRule(th, width)) | |
| 222 | 236 | break | |
| @@ -447,6 +461,8 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction { | |||
| 447 | 461 | return d.handleMethodKey(k) | |
| 448 | 462 | case loginStepProvider: | |
| 449 | 463 | return d.handleProviderKey(k) | |
| 464 | + | case loginStepOAuthMethod: | |
| 465 | + | return d.handleOAuthMethodKey(k) | |
| 450 | 466 | case loginStepLlamaURL, loginStepLlamaKey: | |
| 451 | 467 | return d.handleLlamaKey(k) | |
| 452 | 468 | case loginStepWaiting: | |
| @@ -543,6 +559,11 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 543 | 559 | d.message = "" | |
| 544 | 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 | 567 | d.step = loginStepWaiting | |
| 547 | 568 | if d.method == "apikey" { | |
| 548 | 569 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | |
| @@ -552,6 +573,26 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 552 | 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 | 596 | func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | |
| 556 | 597 | if k.Kind == tui.KeyEsc { | |
| 557 | 598 | d.Close() | |
| @@ -597,6 +638,15 @@ func (d *loginDialog) ShowWaiting(url string) { | |||
| 597 | 638 | 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 | 650 | // ShowInfo transitions to an informational setup dialog. | |
| 601 | 651 | // No-op if the user has already dismissed the dialog. | |
| 602 | 652 | 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() is | 18 | // 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 = iota | 21 | + | loginStepClosed loginStep = iota |
| 22 | - | loginStepMethod // pick apikey vs subscription | 22 | + | loginStepMethod // pick apikey vs subscription |
| 23 | - | loginStepProvider // pick anthropic vs openai vs kimi | 23 | + | loginStepProvider // pick anthropic vs openai vs kimi |
| 24 | - | loginStepLlamaURL // enter and validate llama.cpp router URL | 24 | + | loginStepOAuthMethod // pick browser or copy-code for Anthropic |
| 25 | - | loginStepLlamaKey // enter optional llama.cpp API key | 25 | + | loginStepLlamaURL // enter and validate llama.cpp router URL |
| 26 | - | loginStepWaiting // browser open, waiting for callback | 26 | + | loginStepLlamaKey // enter optional llama.cpp API key |
| 27 | - | loginStepPasteCode // user pastes the auth code here | 27 | + | loginStepWaiting // browser open, waiting for callback |
| 28 | - | loginStepInfo // informational setup guidance | 28 | + | loginStepPasteCode // user pastes the auth code here |
| 29 | - | loginStepDone // success or error, waiting for key to dismiss | 29 | + | loginStepInfo // informational setup guidance |
| 30 | + | loginStepDone // success or error, waiting for key to dismiss | |||
| 30 | ) | 31 | ) | ||
| 31 | 32 | ||||
| 32 | const loginProviderPageSize = 8 | 33 | const loginProviderPageSize = 8 | ||
| @@ -79,6 +80,7 @@ func (d *loginDialog) Open(zotHome string) { | |||||
| 79 | d.url = "" | 80 | d.url = "" | ||
| 80 | d.cursor = 0 | 81 | d.cursor = 0 | ||
| 81 | d.providerQuery = "" | 82 | d.providerQuery = "" | ||
| 83 | + | d.codeEd = nil | |||
| 82 | d.llamaEd = nil | 84 | 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 | break | 236 | 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 = loginStepWaiting | 567 | 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 = url | 638 | 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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "net/url" | |
| 6 | + | "path/filepath" | |
| 4 | 7 | "strings" | |
| 5 | 8 | "testing" | |
| 6 | 9 | ||
| 10 | + | "github.com/patriceckhart/zot/packages/provider/auth" | |
| 7 | 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 | 167 | func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) { | |
| 73 | 168 | d := newLoginDialog() | |
| 74 | 169 | d.Open(t.TempDir()) | |
| 75 | 170 | d.method = "oauth" | |
| 76 | 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 | 174 | lines := padDialogFrame(d.Render(tui.Theme{}, 80)) | |
| 80 | 175 | row, _ := d.CursorPos(80) | |
| @@ -1,9 +1,13 @@ | |||||
| 1 | package modes | 1 | 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 ( | |||
| 5 | 5 | "io" | |
| 6 | 6 | "net" | |
| 7 | 7 | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 8 | 9 | "net/url" | |
| 9 | 10 | "path/filepath" | |
| 10 | 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 | 231 | func TestAuthorizeURL(t *testing.T) { | |
| 105 | 232 | p, _ := NewPKCE() | |
| 106 | 233 | 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() { | |||
| 75 | 75 | m.oauthCancel() | |
| 76 | 76 | m.oauthCancel = nil | |
| 77 | 77 | } | |
| 78 | + | m.clearManualOAuthLocked() | |
| 78 | 79 | } | |
| 79 | 80 | ||
| 80 | 81 | // ---- API key flow ---- | |
| @@ -166,6 +167,7 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||
| 166 | 167 | if m.oauthCancel != nil { | |
| 167 | 168 | m.oauthCancel() | |
| 168 | 169 | } | |
| 170 | + | m.clearManualOAuthLocked() | |
| 169 | 171 | m.mu.Unlock() | |
| 170 | 172 | ||
| 171 | 173 | pkce, err := NewPKCE() | |
| @@ -187,6 +189,11 @@ func (m *Manager) StartOAuth(provider string) (string, error) { | |||
| 187 | 189 | m.oauthServer = cs | |
| 188 | 190 | m.oauthCtx = ctx | |
| 189 | 191 | m.oauthCancel = cancel | |
| 192 | + | m.manualOp = &op | |
| 193 | + | m.manualStoreProvider = storeProvider | |
| 194 | + | m.manualEventProvider = provider | |
| 195 | + | m.manualPKCE = pkce | |
| 196 | + | m.manualState = state | |
| 190 | 197 | m.mu.Unlock() | |
| 191 | 198 | ||
| 192 | 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 | 210 | res, err := cs.Result(waitCtx) | |
| 204 | 211 | if err != nil { | |
| 205 | 212 | if ctx.Err() == nil { | |
| 213 | + | m.mu.Lock() | |
| 214 | + | if m.oauthCtx == ctx { | |
| 215 | + | m.clearManualOAuthLocked() | |
| 216 | + | } | |
| 217 | + | m.mu.Unlock() | |
| 206 | 218 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: "timeout waiting for callback"}) | |
| 207 | 219 | } | |
| 208 | 220 | return | |
| 209 | 221 | } | |
| 222 | + | if ctx.Err() != nil { | |
| 223 | + | return | |
| 224 | + | } | |
| 210 | 225 | if res.Err != nil { | |
| 226 | + | m.mu.Lock() | |
| 227 | + | if m.oauthCtx == ctx { | |
| 228 | + | m.clearManualOAuthLocked() | |
| 229 | + | } | |
| 230 | + | m.mu.Unlock() | |
| 211 | 231 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: res.Err.Error()}) | |
| 212 | 232 | 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 | 242 | exCtx, exCancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 216 | 243 | defer exCancel() | |
| @@ -354,8 +381,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 354 | 381 | case "anthropic": | |
| 355 | 382 | op = AnthropicManualOAuth | |
| 356 | 383 | 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") | |
| 359 | 385 | case "google": | |
| 360 | 386 | return "", fmt.Errorf("google login is api-key only; use api key login for gemini") | |
| 361 | 387 | case "deepseek": | |
| @@ -364,6 +390,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 364 | 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 | 394 | pkce, err := NewPKCE() | |
| 368 | 395 | if err != nil { | |
| 369 | 396 | return "", err | |
| @@ -389,23 +416,35 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 389 | 416 | // a token and stores it. Accepts either a raw code or a "code#state" | |
| 390 | 417 | // token shown by providers like Anthropic when code=true is set. | |
| 391 | 418 | func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |
| 419 | + | code, pastedState := parseManualCodeInput(strings.TrimSpace(input)) | |
| 392 | 420 | 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() | |
| 400 | 423 | return fmt.Errorf("no manual oauth flow in progress") | |
| 401 | 424 | } | |
| 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") | |
| 405 | 428 | } | |
| 406 | 429 | if code == "" { | |
| 430 | + | m.mu.Unlock() | |
| 407 | 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 | 448 | exCtx, cancel := context.WithTimeout(ctx, 30*time.Second) | |
| 410 | 449 | defer cancel() | |
| 411 | 450 | tok, err := op.Exchange(exCtx, code, state, pkce) | |
| @@ -417,13 +456,6 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |||
| 417 | 456 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | |
| 418 | 457 | 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 | 459 | m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) | |
| 428 | 460 | return nil | |
| 429 | 461 | } | |
| @@ -491,6 +523,15 @@ func (m *Manager) CancelOAuth() { | |||
| 491 | 523 | m.oauthServer.Shutdown() | |
| 492 | 524 | 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 | 537 | // ---- shared ---- | |
| @@ -75,6 +75,7 @@ func (m *Manager) Close() { | |||||
| 75 | m.oauthCancel() | 75 | m.oauthCancel() | ||
| 76 | m.oauthCancel = nil | 76 | 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 = cs | 189 | m.oauthServer = cs | ||
| 188 | m.oauthCtx = ctx | 190 | m.oauthCtx = ctx | ||
| 189 | m.oauthCancel = cancel | 191 | 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 | return | 220 | 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 | return | 232 | 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 = AnthropicManualOAuth | 382 | op = AnthropicManualOAuth | ||
| 356 | case "openai", "openai-codex": | 383 | case "openai", "openai-codex": | ||
| 357 | - | op = OpenAIOAuth | 384 | + | 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 "", err | 396 | 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.manualOp | 421 | + | if m.manualOp == nil { |
| 394 | - | storeProvider := m.manualStoreProvider | 422 | + | 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 = pastedState | 427 | + | 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 err | 457 | 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 nil | 460 | return nil | ||
| 429 | } | 461 | } | ||
| @@ -491,6 +523,15 @@ func (m *Manager) CancelOAuth() { | |||||
| 491 | m.oauthServer.Shutdown() | 523 | m.oauthServer.Shutdown() | ||
| 492 | m.oauthServer = nil | 524 | 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 ---- | ||