Fix pasted OAuth codes using the browser transaction
- commit
- 8a03f64fb8078be957d360ca4be73a92f98baf66
- author
- patriceckhart · · 2026-09-26 11:08 UTC
- parent
- 34c0119
- signature
- unsigned
6 files · +198 -30
M README.md +2 -2
| @@ -115,8 +115,8 @@ Run `zot` and type `/login`. Pick one of two methods: | |||
| 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 | - | - For Anthropic, choose browser callback (`localhost:53692`) or copy code before starting login. Copy code uses Anthropic's hosted redirect and works over SSH or on headless machines without a local callback server. Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers. | |
| 119 | - | - OpenAI uses the Codex CLI OAuth flow with a browser callback on `localhost:1455`. Manual code entry is not supported for its loopback redirect, so the browser must reach zot's callback port. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token. | |
| 118 | + | - For Anthropic, choose browser callback (`localhost:53692`) or copy code before starting login. Browser callback also accepts a pasted code or full redirect URL from the same authorization link. Copy code uses Anthropic's hosted redirect and works over SSH or on headless machines without a local callback server. Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers. | |
| 119 | + | - OpenAI uses the Codex CLI OAuth flow with a browser callback on `localhost:1455`. The same authorization link also accepts a pasted code or full redirect URL if the browser cannot reach zot's callback port (for example, over SSH). Copy the final redirect URL from the browser address bar, even if the page fails to load locally. The callback port must still be available on the machine running zot. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token. | |
| 120 | 120 | - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers. | |
| 121 | 121 | - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API. | |
| 122 | 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,8 +115,8 @@ Run `zot` and type `/login`. Pick one of two methods: | |||||
| 115 | 115 | ||||
| 116 | - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly. | 116 | - **API key**: a small local web server starts on `127.0.0.1:<free-port>`, your browser opens a form, you pick a provider from the full API-key provider list, paste the key, and zot saves it to `auth.json` if accepted. Providers with a lightweight model-list endpoint are probed before saving; provider backends that need extra project/account env vars are saved directly. | ||
| 117 | - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow. | 117 | - **Subscription**: use your Claude Pro/Max, ChatGPT Plus/Pro, Kimi Code, SuperGrok/X Premium, or GitHub Copilot subscription. DeepSeek and Google Gemini do **not** have a subscription login path. For those, use the API-key flow. | ||
| 118 | - | - For Anthropic, choose browser callback (`localhost:53692`) or copy code before starting login. Copy code uses Anthropic's hosted redirect and works over SSH or on headless machines without a local callback server. Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers. | 118 | + | - For Anthropic, choose browser callback (`localhost:53692`) or copy code before starting login. Browser callback also accepts a pasted code or full redirect URL from the same authorization link. Copy code uses Anthropic's hosted redirect and works over SSH or on headless machines without a local callback server. Anthropic uses the Claude Code OAuth flow. Messages go to `api.anthropic.com` with a bearer token and the Claude Code identity headers. |
| 119 | - | - OpenAI uses the Codex CLI OAuth flow with a browser callback on `localhost:1455`. Manual code entry is not supported for its loopback redirect, so the browser must reach zot's callback port. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token. | 119 | + | - OpenAI uses the Codex CLI OAuth flow with a browser callback on `localhost:1455`. The same authorization link also accepts a pasted code or full redirect URL if the browser cannot reach zot's callback port (for example, over SSH). Copy the final redirect URL from the browser address bar, even if the page fails to load locally. The callback port must still be available on the machine running zot. Messages go to `chatgpt.com/backend-api/codex/responses` with the `chatgpt-account-id` extracted from the returned id_token. |
| 120 | - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers. | 120 | - Kimi uses the Kimi Code device-code OAuth flow. zot opens the verification URL, polls until you approve it in the browser, then sends messages to `api.kimi.com/coding/v1` with the Kimi Code identity headers. | ||
| 121 | - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API. | 121 | - xAI uses a device-code OAuth flow. zot opens a prefilled authorization URL, polls for approval, and uses the resulting token with the xAI API. | ||
| 122 | - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand. | 122 | - GitHub Copilot uses GitHub's device-code login flow. zot stores the GitHub access token and exchanges it for short-lived Copilot inference tokens on demand. | ||
M packages/agent/modes/interactive.go +6 -0
| @@ -2298,6 +2298,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 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 | } | |
| @@ -5853,6 +5856,9 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string) | |||
| 5853 | 5856 | func (i *Interactive) handleAuthEvent(ev auth.Event) { | |
| 5854 | 5857 | switch ev.Kind { | |
| 5855 | 5858 | case "started": | |
| 5859 | + | if i.dialog.step == loginStepPasteCode && i.dialog.url == ev.URL { | |
| 5860 | + | return | |
| 5861 | + | } | |
| 5856 | 5862 | i.dialog.ShowWaiting(ev.URL) | |
| 5857 | 5863 | case "browser_open": | |
| 5858 | 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 | } | ||
| @@ -5853,6 +5856,9 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string) | |||||
| 5853 | func (i *Interactive) handleAuthEvent(ev auth.Event) { | 5856 | func (i *Interactive) handleAuthEvent(ev auth.Event) { | ||
| 5854 | switch ev.Kind { | 5857 | switch ev.Kind { | ||
| 5855 | case "started": | 5858 | case "started": | ||
| 5859 | + | if i.dialog.step == loginStepPasteCode && i.dialog.url == ev.URL { | |||
| 5860 | + | return | |||
| 5861 | + | } | |||
| 5856 | i.dialog.ShowWaiting(ev.URL) | 5862 | i.dialog.ShowWaiting(ev.URL) | ||
| 5857 | case "browser_open": | 5863 | case "browser_open": | ||
| 5858 | // no-op | 5864 | // no-op | ||
M packages/agent/modes/login_dialog.go +3 -3
| @@ -230,7 +230,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 230 | 230 | lines = append(lines, th.FG256(th.Accent, seg)) | |
| 231 | 231 | } | |
| 232 | 232 | lines = append(lines, "") | |
| 233 | - | if d.method == "oauth" { | |
| 233 | + | if d.method == "oauth" && d.provider != "anthropic" && d.provider != "openai-codex" { | |
| 234 | 234 | lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels")) | |
| 235 | 235 | lines = append(lines, frameRule(th, width)) | |
| 236 | 236 | break | |
| @@ -674,7 +674,7 @@ func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction { | |||
| 674 | 674 | d.Close() | |
| 675 | 675 | return loginDialogAction{Close: true} | |
| 676 | 676 | } | |
| 677 | - | if d.method == "oauth" || d.codeEd == nil { | |
| 677 | + | if d.codeEd == nil { | |
| 678 | 678 | return loginDialogAction{} | |
| 679 | 679 | } | |
| 680 | 680 | if submit := d.codeEd.HandleKey(k); submit { | |
| @@ -717,7 +717,7 @@ func (d *loginDialog) CursorPos(width int) (row, col int) { | |||
| 717 | 717 | } | |
| 718 | 718 | return baseOffset + eRow, eCol | |
| 719 | 719 | } | |
| 720 | - | if d.codeEd == nil || (d.step == loginStepWaiting && d.method == "oauth") { | |
| 720 | + | if d.codeEd == nil { | |
| 721 | 721 | return -1, -1 | |
| 722 | 722 | } | |
| 723 | 723 | if d.step != loginStepPasteCode && d.step != loginStepWaiting { | |
| @@ -230,7 +230,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 230 | lines = append(lines, th.FG256(th.Accent, seg)) | 230 | lines = append(lines, th.FG256(th.Accent, seg)) | ||
| 231 | } | 231 | } | ||
| 232 | lines = append(lines, "") | 232 | lines = append(lines, "") | ||
| 233 | - | if d.method == "oauth" { | 233 | + | if d.method == "oauth" && d.provider != "anthropic" && d.provider != "openai-codex" { |
| 234 | lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels")) | 234 | lines = append(lines, th.FG256(th.Muted, "complete sign-in in the browser - esc cancels")) | ||
| 235 | lines = append(lines, frameRule(th, width)) | 235 | lines = append(lines, frameRule(th, width)) | ||
| 236 | break | 236 | break | ||
| @@ -674,7 +674,7 @@ func (d *loginDialog) handleWaitingKey(k tui.Key) loginDialogAction { | |||||
| 674 | d.Close() | 674 | d.Close() | ||
| 675 | return loginDialogAction{Close: true} | 675 | return loginDialogAction{Close: true} | ||
| 676 | } | 676 | } | ||
| 677 | - | if d.method == "oauth" || d.codeEd == nil { | 677 | + | if d.codeEd == nil { |
| 678 | return loginDialogAction{} | 678 | return loginDialogAction{} | ||
| 679 | } | 679 | } | ||
| 680 | if submit := d.codeEd.HandleKey(k); submit { | 680 | if submit := d.codeEd.HandleKey(k); submit { | ||
| @@ -717,7 +717,7 @@ func (d *loginDialog) CursorPos(width int) (row, col int) { | |||||
| 717 | } | 717 | } | ||
| 718 | return baseOffset + eRow, eCol | 718 | return baseOffset + eRow, eCol | ||
| 719 | } | 719 | } | ||
| 720 | - | if d.codeEd == nil || (d.step == loginStepWaiting && d.method == "oauth") { | 720 | + | if d.codeEd == nil { |
| 721 | return -1, -1 | 721 | return -1, -1 | ||
| 722 | } | 722 | } | ||
| 723 | if d.step != loginStepPasteCode && d.step != loginStepWaiting { | 723 | if d.step != loginStepPasteCode && d.step != loginStepWaiting { | ||
M packages/agent/modes/login_dialog_test.go +9 -7
| @@ -73,7 +73,7 @@ func TestLoginDialogLlamaCPPValidatesURLAndAcceptsOptionalKey(t *testing.T) { | |||
| 73 | 73 | } | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | - | func TestLoginDialogOpenAIOAuthDoesNotOfferCopyCode(t *testing.T) { | |
| 76 | + | func TestLoginDialogOpenAIOAuthAcceptsCodeFromSameTransaction(t *testing.T) { | |
| 77 | 77 | d := newLoginDialog() | |
| 78 | 78 | d.Open(t.TempDir()) | |
| 79 | 79 | d.method = "oauth" | |
| @@ -81,11 +81,12 @@ func TestLoginDialogOpenAIOAuthDoesNotOfferCopyCode(t *testing.T) { | |||
| 81 | 81 | d.ShowWaiting("http://localhost:1455/auth/callback") | |
| 82 | 82 | ||
| 83 | 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 offers manual code entry") | |
| 84 | + | if !strings.Contains(text, "paste the authorization code") { | |
| 85 | + | t.Fatal("OpenAI OAuth dialog does not offer pasted code entry") | |
| 86 | 86 | } | |
| 87 | - | if !strings.Contains(text, "complete sign-in in the browser") { | |
| 88 | - | t.Fatal("OpenAI OAuth dialog does not require browser sign-in") | |
| 87 | + | d.codeEd.SetValue("code#state") | |
| 88 | + | if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SubmitCode != "code#state" { | |
| 89 | + | t.Fatalf("code submission = %+v", action) | |
| 89 | 90 | } | |
| 90 | 91 | } | |
| 91 | 92 | ||
| @@ -126,8 +127,8 @@ func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) { | |||
| 126 | 127 | d.ShowWaiting("https://example.com/oauth/authorize") | |
| 127 | 128 | } | |
| 128 | 129 | text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n")) | |
| 129 | - | if strings.Contains(text, "paste the authorization code") != tc.manual { | |
| 130 | - | t.Fatalf("wrong input for %s flow: %s", tc.name, text) | |
| 130 | + | if !strings.Contains(text, "paste the authorization code") { | |
| 131 | + | t.Fatalf("missing code input for %s flow: %s", tc.name, text) | |
| 131 | 132 | } | |
| 132 | 133 | }) | |
| 133 | 134 | } | |
| @@ -147,6 +148,7 @@ func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) { | |||
| 147 | 148 | t.Fatalf("expected manual Anthropic transaction, got %+v", act) | |
| 148 | 149 | } | |
| 149 | 150 | i.startManualOAuthFlow(act.Provider) | |
| 151 | + | i.handleAuthEvent(<-manager.Events()) | |
| 150 | 152 | if i.dialog.step != loginStepPasteCode { | |
| 151 | 153 | t.Fatalf("step = %v, want paste code", i.dialog.step) | |
| 152 | 154 | } | |
| @@ -73,7 +73,7 @@ func TestLoginDialogLlamaCPPValidatesURLAndAcceptsOptionalKey(t *testing.T) { | |||||
| 73 | } | 73 | } | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | - | func TestLoginDialogOpenAIOAuthDoesNotOfferCopyCode(t *testing.T) { | 76 | + | func TestLoginDialogOpenAIOAuthAcceptsCodeFromSameTransaction(t *testing.T) { |
| 77 | d := newLoginDialog() | 77 | d := newLoginDialog() | ||
| 78 | d.Open(t.TempDir()) | 78 | d.Open(t.TempDir()) | ||
| 79 | d.method = "oauth" | 79 | d.method = "oauth" | ||
| @@ -81,11 +81,12 @@ func TestLoginDialogOpenAIOAuthDoesNotOfferCopyCode(t *testing.T) { | |||||
| 81 | d.ShowWaiting("http://localhost:1455/auth/callback") | 81 | d.ShowWaiting("http://localhost:1455/auth/callback") | ||
| 82 | 82 | ||||
| 83 | text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n")) | 83 | text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n")) | ||
| 84 | - | if strings.Contains(text, "paste the authorization code") { | 84 | + | if !strings.Contains(text, "paste the authorization code") { |
| 85 | - | t.Fatal("OpenAI OAuth dialog offers manual code entry") | 85 | + | t.Fatal("OpenAI OAuth dialog does not offer pasted code entry") |
| 86 | } | 86 | } | ||
| 87 | - | if !strings.Contains(text, "complete sign-in in the browser") { | 87 | + | d.codeEd.SetValue("code#state") |
| 88 | - | t.Fatal("OpenAI OAuth dialog does not require browser sign-in") | 88 | + | if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SubmitCode != "code#state" { |
| 89 | + | t.Fatalf("code submission = %+v", action) | |||
| 89 | } | 90 | } | ||
| 90 | } | 91 | } | ||
| 91 | 92 | ||||
| @@ -126,8 +127,8 @@ func TestLoginDialogAnthropicOffersSeparateOAuthFlows(t *testing.T) { | |||||
| 126 | d.ShowWaiting("https://example.com/oauth/authorize") | 127 | d.ShowWaiting("https://example.com/oauth/authorize") | ||
| 127 | } | 128 | } | ||
| 128 | text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n")) | 129 | text := stripANSIBytes(strings.Join(d.Render(tui.Theme{}, 80), "\n")) | ||
| 129 | - | if strings.Contains(text, "paste the authorization code") != tc.manual { | 130 | + | if !strings.Contains(text, "paste the authorization code") { |
| 130 | - | t.Fatalf("wrong input for %s flow: %s", tc.name, text) | 131 | + | t.Fatalf("missing code input for %s flow: %s", tc.name, text) |
| 131 | } | 132 | } | ||
| 132 | }) | 133 | }) | ||
| 133 | } | 134 | } | ||
| @@ -147,6 +148,7 @@ func TestAnthropicManualLoginUsesCopyCodeTransaction(t *testing.T) { | |||||
| 147 | t.Fatalf("expected manual Anthropic transaction, got %+v", act) | 148 | t.Fatalf("expected manual Anthropic transaction, got %+v", act) | ||
| 148 | } | 149 | } | ||
| 149 | i.startManualOAuthFlow(act.Provider) | 150 | i.startManualOAuthFlow(act.Provider) | ||
| 151 | + | i.handleAuthEvent(<-manager.Events()) | |||
| 150 | if i.dialog.step != loginStepPasteCode { | 152 | if i.dialog.step != loginStepPasteCode { | ||
| 151 | t.Fatalf("step = %v, want paste code", i.dialog.step) | 153 | t.Fatalf("step = %v, want paste code", i.dialog.step) | ||
| 152 | } | 154 | } | ||
M packages/provider/auth/auth_test.go +119 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 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,7 +102,7 @@ func TestPKCE(t *testing.T) { | |||
| 101 | 102 | } | |
| 102 | 103 | } | |
| 103 | 104 | ||
| 104 | - | func TestStartManualOAuthRejectsOpenAI(t *testing.T) { | |
| 105 | + | func TestStartManualOAuthRejectsOpenAIWithoutCallback(t *testing.T) { | |
| 105 | 106 | m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json"))) | |
| 106 | 107 | if _, err := m.StartManualOAuth("openai-codex"); err == nil { | |
| 107 | 108 | t.Fatal("expected manual OpenAI OAuth to be rejected") | |
| @@ -110,6 +111,123 @@ func TestStartManualOAuthRejectsOpenAI(t *testing.T) { | |||
| 110 | 111 | } | |
| 111 | 112 | } | |
| 112 | 113 | ||
| 114 | + | func TestBrowserOAuthSharesTransactionWithPastedCode(t *testing.T) { | |
| 115 | + | for _, provider := range []string{"anthropic", "openai-codex"} { | |
| 116 | + | t.Run(provider, func(t *testing.T) { | |
| 117 | + | m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json"))) | |
| 118 | + | m.openBrowser = false | |
| 119 | + | t.Cleanup(m.Close) | |
| 120 | + | loginURL, err := m.StartOAuth(provider) | |
| 121 | + | if err != nil { | |
| 122 | + | if strings.Contains(err.Error(), "bind ") { | |
| 123 | + | t.Skipf("callback port unavailable: %v", err) | |
| 124 | + | } | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | u, err := url.Parse(loginURL) | |
| 128 | + | if err != nil { | |
| 129 | + | t.Fatal(err) | |
| 130 | + | } | |
| 131 | + | state := u.Query().Get("state") | |
| 132 | + | if m.manualOp == nil || m.manualState != state || m.manualPKCE.Challenge != u.Query().Get("code_challenge") { | |
| 133 | + | t.Fatal("pasted-code path does not share the browser transaction") | |
| 134 | + | } | |
| 135 | + | if err := m.CompleteManualOAuth(context.Background(), "code#wrong-state"); err == nil || !strings.Contains(err.Error(), "state mismatch") { | |
| 136 | + | t.Fatalf("wrong state accepted: %v", err) | |
| 137 | + | } | |
| 138 | + | if err := m.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" { | |
| 139 | + | t.Fatalf("shared transaction unavailable after bad code: %v", err) | |
| 140 | + | } | |
| 141 | + | m.CancelOAuth() | |
| 142 | + | if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err == nil || !strings.Contains(err.Error(), "no manual oauth flow") { | |
| 143 | + | t.Fatalf("canceled flow still accepts code: %v", err) | |
| 144 | + | } | |
| 145 | + | }) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestPastedOAuthCodeUsesBrowserPKCE(t *testing.T) { | |
| 150 | + | requests := make(chan url.Values, 2) | |
| 151 | + | tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 152 | + | _ = r.ParseForm() | |
| 153 | + | requests <- r.PostForm | |
| 154 | + | w.Header().Set("content-type", "application/json") | |
| 155 | + | _, _ = w.Write([]byte(`{"access_token":"test-token","refresh_token":"test-refresh","expires_in":3600}`)) | |
| 156 | + | })) | |
| 157 | + | defer tokenServer.Close() | |
| 158 | + | old := OpenAIOAuth.TokenURL | |
| 159 | + | OpenAIOAuth.TokenURL = tokenServer.URL | |
| 160 | + | t.Cleanup(func() { OpenAIOAuth.TokenURL = old }) | |
| 161 | + | ||
| 162 | + | for _, method := range []string{"paste", "callback"} { | |
| 163 | + | t.Run(method, func(t *testing.T) { | |
| 164 | + | store := NewStore(filepath.Join(t.TempDir(), "auth.json")) | |
| 165 | + | m := NewManager(store) | |
| 166 | + | m.openBrowser = false | |
| 167 | + | t.Cleanup(m.Close) | |
| 168 | + | loginURL, err := m.StartOAuth("openai-codex") | |
| 169 | + | if err != nil { | |
| 170 | + | if strings.Contains(err.Error(), "bind ") { | |
| 171 | + | t.Skipf("callback port unavailable: %v", err) | |
| 172 | + | } | |
| 173 | + | t.Fatal(err) | |
| 174 | + | } | |
| 175 | + | u, err := url.Parse(loginURL) | |
| 176 | + | if err != nil { | |
| 177 | + | t.Fatal(err) | |
| 178 | + | } | |
| 179 | + | verifier := m.manualPKCE.Verifier | |
| 180 | + | redirect := u.Query().Get("redirect_uri") | |
| 181 | + | codeURL := redirect + "?code=test-code&state=" + url.QueryEscape(u.Query().Get("state")) | |
| 182 | + | if method == "paste" { | |
| 183 | + | if err := m.CompleteManualOAuth(context.Background(), codeURL); err != nil { | |
| 184 | + | t.Fatal(err) | |
| 185 | + | } | |
| 186 | + | } else { | |
| 187 | + | resp, err := http.Get(codeURL) | |
| 188 | + | if err != nil { | |
| 189 | + | t.Fatal(err) | |
| 190 | + | } | |
| 191 | + | resp.Body.Close() | |
| 192 | + | if resp.StatusCode != http.StatusOK { | |
| 193 | + | t.Fatalf("callback status = %d", resp.StatusCode) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | select { | |
| 197 | + | case ev := <-m.Events(): | |
| 198 | + | if ev.Kind != "started" { | |
| 199 | + | t.Fatalf("event = %+v", ev) | |
| 200 | + | } | |
| 201 | + | case <-time.After(2 * time.Second): | |
| 202 | + | t.Fatal("no started event") | |
| 203 | + | } | |
| 204 | + | select { | |
| 205 | + | case ev := <-m.Events(): | |
| 206 | + | if ev.Kind != "success" || ev.Provider != "openai-codex" { | |
| 207 | + | t.Fatalf("event = %+v", ev) | |
| 208 | + | } | |
| 209 | + | case <-time.After(2 * time.Second): | |
| 210 | + | t.Fatal("no success event") | |
| 211 | + | } | |
| 212 | + | select { | |
| 213 | + | case form := <-requests: | |
| 214 | + | if form.Get("code_verifier") != verifier || form.Get("redirect_uri") != redirect || form.Get("code") != "test-code" { | |
| 215 | + | t.Fatalf("exchange did not use browser transaction: %v", form) | |
| 216 | + | } | |
| 217 | + | case <-time.After(2 * time.Second): | |
| 218 | + | t.Fatal("no token exchange") | |
| 219 | + | } | |
| 220 | + | creds, err := store.Load() | |
| 221 | + | if err != nil { | |
| 222 | + | t.Fatal(err) | |
| 223 | + | } | |
| 224 | + | if creds.OpenAI.OAuth == nil || creds.OpenAI.OAuth.AccessToken != "test-token" { | |
| 225 | + | t.Fatalf("stored credential: %v", creds.OpenAI.OAuth) | |
| 226 | + | } | |
| 227 | + | }) | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 113 | 231 | func TestAuthorizeURL(t *testing.T) { | |
| 114 | 232 | p, _ := NewPKCE() | |
| 115 | 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,7 +102,7 @@ func TestPKCE(t *testing.T) { | |||||
| 101 | } | 102 | } | ||
| 102 | } | 103 | } | ||
| 103 | 104 | ||||
| 104 | - | func TestStartManualOAuthRejectsOpenAI(t *testing.T) { | 105 | + | func TestStartManualOAuthRejectsOpenAIWithoutCallback(t *testing.T) { |
| 105 | m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json"))) | 106 | m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json"))) | ||
| 106 | if _, err := m.StartManualOAuth("openai-codex"); err == nil { | 107 | if _, err := m.StartManualOAuth("openai-codex"); err == nil { | ||
| 107 | t.Fatal("expected manual OpenAI OAuth to be rejected") | 108 | t.Fatal("expected manual OpenAI OAuth to be rejected") | ||
| @@ -110,6 +111,123 @@ func TestStartManualOAuthRejectsOpenAI(t *testing.T) { | |||||
| 110 | } | 111 | } | ||
| 111 | } | 112 | } | ||
| 112 | 113 | ||||
| 114 | + | func TestBrowserOAuthSharesTransactionWithPastedCode(t *testing.T) { | |||
| 115 | + | for _, provider := range []string{"anthropic", "openai-codex"} { | |||
| 116 | + | t.Run(provider, func(t *testing.T) { | |||
| 117 | + | m := NewManager(NewStore(filepath.Join(t.TempDir(), "auth.json"))) | |||
| 118 | + | m.openBrowser = false | |||
| 119 | + | t.Cleanup(m.Close) | |||
| 120 | + | loginURL, err := m.StartOAuth(provider) | |||
| 121 | + | if err != nil { | |||
| 122 | + | if strings.Contains(err.Error(), "bind ") { | |||
| 123 | + | t.Skipf("callback port unavailable: %v", err) | |||
| 124 | + | } | |||
| 125 | + | t.Fatal(err) | |||
| 126 | + | } | |||
| 127 | + | u, err := url.Parse(loginURL) | |||
| 128 | + | if err != nil { | |||
| 129 | + | t.Fatal(err) | |||
| 130 | + | } | |||
| 131 | + | state := u.Query().Get("state") | |||
| 132 | + | if m.manualOp == nil || m.manualState != state || m.manualPKCE.Challenge != u.Query().Get("code_challenge") { | |||
| 133 | + | t.Fatal("pasted-code path does not share the browser transaction") | |||
| 134 | + | } | |||
| 135 | + | if err := m.CompleteManualOAuth(context.Background(), "code#wrong-state"); err == nil || !strings.Contains(err.Error(), "state mismatch") { | |||
| 136 | + | t.Fatalf("wrong state accepted: %v", err) | |||
| 137 | + | } | |||
| 138 | + | if err := m.CompleteManualOAuth(context.Background(), ""); err == nil || err.Error() != "empty code" { | |||
| 139 | + | t.Fatalf("shared transaction unavailable after bad code: %v", err) | |||
| 140 | + | } | |||
| 141 | + | m.CancelOAuth() | |||
| 142 | + | if err := m.CompleteManualOAuth(context.Background(), "code#"+state); err == nil || !strings.Contains(err.Error(), "no manual oauth flow") { | |||
| 143 | + | t.Fatalf("canceled flow still accepts code: %v", err) | |||
| 144 | + | } | |||
| 145 | + | }) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestPastedOAuthCodeUsesBrowserPKCE(t *testing.T) { | |||
| 150 | + | requests := make(chan url.Values, 2) | |||
| 151 | + | tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 152 | + | _ = r.ParseForm() | |||
| 153 | + | requests <- r.PostForm | |||
| 154 | + | w.Header().Set("content-type", "application/json") | |||
| 155 | + | _, _ = w.Write([]byte(`{"access_token":"test-token","refresh_token":"test-refresh","expires_in":3600}`)) | |||
| 156 | + | })) | |||
| 157 | + | defer tokenServer.Close() | |||
| 158 | + | old := OpenAIOAuth.TokenURL | |||
| 159 | + | OpenAIOAuth.TokenURL = tokenServer.URL | |||
| 160 | + | t.Cleanup(func() { OpenAIOAuth.TokenURL = old }) | |||
| 161 | + | ||||
| 162 | + | for _, method := range []string{"paste", "callback"} { | |||
| 163 | + | t.Run(method, func(t *testing.T) { | |||
| 164 | + | store := NewStore(filepath.Join(t.TempDir(), "auth.json")) | |||
| 165 | + | m := NewManager(store) | |||
| 166 | + | m.openBrowser = false | |||
| 167 | + | t.Cleanup(m.Close) | |||
| 168 | + | loginURL, err := m.StartOAuth("openai-codex") | |||
| 169 | + | if err != nil { | |||
| 170 | + | if strings.Contains(err.Error(), "bind ") { | |||
| 171 | + | t.Skipf("callback port unavailable: %v", err) | |||
| 172 | + | } | |||
| 173 | + | t.Fatal(err) | |||
| 174 | + | } | |||
| 175 | + | u, err := url.Parse(loginURL) | |||
| 176 | + | if err != nil { | |||
| 177 | + | t.Fatal(err) | |||
| 178 | + | } | |||
| 179 | + | verifier := m.manualPKCE.Verifier | |||
| 180 | + | redirect := u.Query().Get("redirect_uri") | |||
| 181 | + | codeURL := redirect + "?code=test-code&state=" + url.QueryEscape(u.Query().Get("state")) | |||
| 182 | + | if method == "paste" { | |||
| 183 | + | if err := m.CompleteManualOAuth(context.Background(), codeURL); err != nil { | |||
| 184 | + | t.Fatal(err) | |||
| 185 | + | } | |||
| 186 | + | } else { | |||
| 187 | + | resp, err := http.Get(codeURL) | |||
| 188 | + | if err != nil { | |||
| 189 | + | t.Fatal(err) | |||
| 190 | + | } | |||
| 191 | + | resp.Body.Close() | |||
| 192 | + | if resp.StatusCode != http.StatusOK { | |||
| 193 | + | t.Fatalf("callback status = %d", resp.StatusCode) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | select { | |||
| 197 | + | case ev := <-m.Events(): | |||
| 198 | + | if ev.Kind != "started" { | |||
| 199 | + | t.Fatalf("event = %+v", ev) | |||
| 200 | + | } | |||
| 201 | + | case <-time.After(2 * time.Second): | |||
| 202 | + | t.Fatal("no started event") | |||
| 203 | + | } | |||
| 204 | + | select { | |||
| 205 | + | case ev := <-m.Events(): | |||
| 206 | + | if ev.Kind != "success" || ev.Provider != "openai-codex" { | |||
| 207 | + | t.Fatalf("event = %+v", ev) | |||
| 208 | + | } | |||
| 209 | + | case <-time.After(2 * time.Second): | |||
| 210 | + | t.Fatal("no success event") | |||
| 211 | + | } | |||
| 212 | + | select { | |||
| 213 | + | case form := <-requests: | |||
| 214 | + | if form.Get("code_verifier") != verifier || form.Get("redirect_uri") != redirect || form.Get("code") != "test-code" { | |||
| 215 | + | t.Fatalf("exchange did not use browser transaction: %v", form) | |||
| 216 | + | } | |||
| 217 | + | case <-time.After(2 * time.Second): | |||
| 218 | + | t.Fatal("no token exchange") | |||
| 219 | + | } | |||
| 220 | + | creds, err := store.Load() | |||
| 221 | + | if err != nil { | |||
| 222 | + | t.Fatal(err) | |||
| 223 | + | } | |||
| 224 | + | if creds.OpenAI.OAuth == nil || creds.OpenAI.OAuth.AccessToken != "test-token" { | |||
| 225 | + | t.Fatalf("stored credential: %v", creds.OpenAI.OAuth) | |||
| 226 | + | } | |||
| 227 | + | }) | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | ||||
| 113 | func TestAuthorizeURL(t *testing.T) { | 231 | func TestAuthorizeURL(t *testing.T) { | ||
| 114 | p, _ := NewPKCE() | 232 | p, _ := NewPKCE() | ||
| 115 | u, state, err := AnthropicOAuth.AuthorizeURL(p) | 233 | u, state, err := AnthropicOAuth.AuthorizeURL(p) | ||
M packages/provider/auth/manager.go +59 -17
| @@ -75,6 +75,7 @@ func (m *Manager) Close() { | |||
| 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() | |
| @@ -363,6 +390,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 363 | 390 | return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google") | |
| 364 | 391 | } | |
| 365 | 392 | ||
| 393 | + | m.CancelOAuth() | |
| 366 | 394 | pkce, err := NewPKCE() | |
| 367 | 395 | if err != nil { | |
| 368 | 396 | return "", err | |
| @@ -388,23 +416,35 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||
| 388 | 416 | // a token and stores it. Accepts either a raw code or a "code#state" | |
| 389 | 417 | // token shown by providers like Anthropic when code=true is set. | |
| 390 | 418 | func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |
| 419 | + | code, pastedState := parseManualCodeInput(strings.TrimSpace(input)) | |
| 391 | 420 | m.mu.Lock() | |
| 392 | - | op := m.manualOp | |
| 393 | - | storeProvider := m.manualStoreProvider | |
| 394 | - | eventProvider := m.manualEventProvider | |
| 395 | - | pkce := m.manualPKCE | |
| 396 | - | state := m.manualState | |
| 397 | - | m.mu.Unlock() | |
| 398 | - | if op == nil { | |
| 421 | + | if m.manualOp == nil { | |
| 422 | + | m.mu.Unlock() | |
| 399 | 423 | return fmt.Errorf("no manual oauth flow in progress") | |
| 400 | 424 | } | |
| 401 | - | code, pastedState := parseManualCodeInput(strings.TrimSpace(input)) | |
| 402 | - | if pastedState != "" { | |
| 403 | - | state = pastedState | |
| 425 | + | if pastedState != "" && pastedState != m.manualState { | |
| 426 | + | m.mu.Unlock() | |
| 427 | + | return fmt.Errorf("oauth state mismatch") | |
| 404 | 428 | } | |
| 405 | 429 | if code == "" { | |
| 430 | + | m.mu.Unlock() | |
| 406 | 431 | return fmt.Errorf("empty code") | |
| 407 | 432 | } | |
| 433 | + | op := *m.manualOp | |
| 434 | + | storeProvider := m.manualStoreProvider | |
| 435 | + | eventProvider := m.manualEventProvider | |
| 436 | + | pkce := m.manualPKCE | |
| 437 | + | state := m.manualState | |
| 438 | + | m.clearManualOAuthLocked() | |
| 439 | + | if m.oauthServer != nil { | |
| 440 | + | if m.oauthCancel != nil { | |
| 441 | + | m.oauthCancel() | |
| 442 | + | m.oauthCancel = nil | |
| 443 | + | } | |
| 444 | + | m.oauthServer.Shutdown() | |
| 445 | + | m.oauthServer = nil | |
| 446 | + | } | |
| 447 | + | m.mu.Unlock() | |
| 408 | 448 | exCtx, cancel := context.WithTimeout(ctx, 30*time.Second) | |
| 409 | 449 | defer cancel() | |
| 410 | 450 | tok, err := op.Exchange(exCtx, code, state, pkce) | |
| @@ -416,13 +456,6 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |||
| 416 | 456 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | |
| 417 | 457 | return err | |
| 418 | 458 | } | |
| 419 | - | m.mu.Lock() | |
| 420 | - | m.manualOp = nil | |
| 421 | - | m.manualStoreProvider = "" | |
| 422 | - | m.manualEventProvider = "" | |
| 423 | - | m.manualPKCE = PKCE{} | |
| 424 | - | m.manualState = "" | |
| 425 | - | m.mu.Unlock() | |
| 426 | 459 | m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) | |
| 427 | 460 | return nil | |
| 428 | 461 | } | |
| @@ -490,6 +523,15 @@ func (m *Manager) CancelOAuth() { | |||
| 490 | 523 | m.oauthServer.Shutdown() | |
| 491 | 524 | m.oauthServer = nil | |
| 492 | 525 | } | |
| 526 | + | m.clearManualOAuthLocked() | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func (m *Manager) clearManualOAuthLocked() { | |
| 530 | + | m.manualOp = nil | |
| 531 | + | m.manualStoreProvider = "" | |
| 532 | + | m.manualEventProvider = "" | |
| 533 | + | m.manualPKCE = PKCE{} | |
| 534 | + | m.manualState = "" | |
| 493 | 535 | } | |
| 494 | 536 | ||
| 495 | 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() | ||
| @@ -363,6 +390,7 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||||
| 363 | return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google") | 390 | return "", fmt.Errorf("provider must be anthropic, openai, openai-codex, kimi, xai, github-copilot, deepseek, or google") | ||
| 364 | } | 391 | } | ||
| 365 | 392 | ||||
| 393 | + | m.CancelOAuth() | |||
| 366 | pkce, err := NewPKCE() | 394 | pkce, err := NewPKCE() | ||
| 367 | if err != nil { | 395 | if err != nil { | ||
| 368 | return "", err | 396 | return "", err | ||
| @@ -388,23 +416,35 @@ func (m *Manager) StartManualOAuth(provider string) (string, error) { | |||||
| 388 | // a token and stores it. Accepts either a raw code or a "code#state" | 416 | // a token and stores it. Accepts either a raw code or a "code#state" | ||
| 389 | // token shown by providers like Anthropic when code=true is set. | 417 | // token shown by providers like Anthropic when code=true is set. | ||
| 390 | func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | 418 | func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | ||
| 419 | + | code, pastedState := parseManualCodeInput(strings.TrimSpace(input)) | |||
| 391 | m.mu.Lock() | 420 | m.mu.Lock() | ||
| 392 | - | op := m.manualOp | 421 | + | if m.manualOp == nil { |
| 393 | - | storeProvider := m.manualStoreProvider | 422 | + | m.mu.Unlock() |
| 394 | - | eventProvider := m.manualEventProvider | |||
| 395 | - | pkce := m.manualPKCE | |||
| 396 | - | state := m.manualState | |||
| 397 | - | m.mu.Unlock() | |||
| 398 | - | if op == nil { | |||
| 399 | return fmt.Errorf("no manual oauth flow in progress") | 423 | return fmt.Errorf("no manual oauth flow in progress") | ||
| 400 | } | 424 | } | ||
| 401 | - | code, pastedState := parseManualCodeInput(strings.TrimSpace(input)) | 425 | + | if pastedState != "" && pastedState != m.manualState { |
| 402 | - | if pastedState != "" { | 426 | + | m.mu.Unlock() |
| 403 | - | state = pastedState | 427 | + | return fmt.Errorf("oauth state mismatch") |
| 404 | } | 428 | } | ||
| 405 | if code == "" { | 429 | if code == "" { | ||
| 430 | + | m.mu.Unlock() | |||
| 406 | return fmt.Errorf("empty code") | 431 | return fmt.Errorf("empty code") | ||
| 407 | } | 432 | } | ||
| 433 | + | op := *m.manualOp | |||
| 434 | + | storeProvider := m.manualStoreProvider | |||
| 435 | + | eventProvider := m.manualEventProvider | |||
| 436 | + | pkce := m.manualPKCE | |||
| 437 | + | state := m.manualState | |||
| 438 | + | m.clearManualOAuthLocked() | |||
| 439 | + | if m.oauthServer != nil { | |||
| 440 | + | if m.oauthCancel != nil { | |||
| 441 | + | m.oauthCancel() | |||
| 442 | + | m.oauthCancel = nil | |||
| 443 | + | } | |||
| 444 | + | m.oauthServer.Shutdown() | |||
| 445 | + | m.oauthServer = nil | |||
| 446 | + | } | |||
| 447 | + | m.mu.Unlock() | |||
| 408 | exCtx, cancel := context.WithTimeout(ctx, 30*time.Second) | 448 | exCtx, cancel := context.WithTimeout(ctx, 30*time.Second) | ||
| 409 | defer cancel() | 449 | defer cancel() | ||
| 410 | tok, err := op.Exchange(exCtx, code, state, pkce) | 450 | tok, err := op.Exchange(exCtx, code, state, pkce) | ||
| @@ -416,13 +456,6 @@ func (m *Manager) CompleteManualOAuth(ctx context.Context, input string) error { | |||||
| 416 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | 456 | m.emit(Event{Kind: "error", Provider: eventProvider, Method: "oauth", Message: err.Error()}) | ||
| 417 | return err | 457 | return err | ||
| 418 | } | 458 | } | ||
| 419 | - | m.mu.Lock() | |||
| 420 | - | m.manualOp = nil | |||
| 421 | - | m.manualStoreProvider = "" | |||
| 422 | - | m.manualEventProvider = "" | |||
| 423 | - | m.manualPKCE = PKCE{} | |||
| 424 | - | m.manualState = "" | |||
| 425 | - | m.mu.Unlock() | |||
| 426 | m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) | 459 | m.emit(Event{Kind: "success", Provider: eventProvider, Method: "oauth"}) | ||
| 427 | return nil | 460 | return nil | ||
| 428 | } | 461 | } | ||
| @@ -490,6 +523,15 @@ func (m *Manager) CancelOAuth() { | |||||
| 490 | m.oauthServer.Shutdown() | 523 | m.oauthServer.Shutdown() | ||
| 491 | m.oauthServer = nil | 524 | m.oauthServer = nil | ||
| 492 | } | 525 | } | ||
| 526 | + | m.clearManualOAuthLocked() | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func (m *Manager) clearManualOAuthLocked() { | |||
| 530 | + | m.manualOp = nil | |||
| 531 | + | m.manualStoreProvider = "" | |||
| 532 | + | m.manualEventProvider = "" | |||
| 533 | + | m.manualPKCE = PKCE{} | |||
| 534 | + | m.manualState = "" | |||
| 493 | } | 535 | } | ||
| 494 | 536 | ||||
| 495 | // ---- shared ---- | 537 | // ---- shared ---- | ||