1package auth
2
3import (
4 "context"
5 "io"
6 "net"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14)
15
16func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) {
17 SetExtraAPIKeyProviders([]string{"my-company"})
18 t.Cleanup(func() { SetExtraAPIKeyProviders(nil) })
19
20 if err := ProbeAPIKey(context.Background(), "my-company", "test-key"); err != nil {
21 t.Fatalf("ProbeAPIKey custom provider: %v", err)
22 }
23}
24
25func TestProbeGondolaAPIKeyUsesAuthenticatedBalanceEndpoint(t *testing.T) {
26 client := &http.Client{Transport: authRoundTripFunc(func(req *http.Request) (*http.Response, error) {
27 if req.URL.String() != "https://api.gondola-ai.com/v1/balance" {
28 t.Fatalf("probe URL = %q", req.URL.String())
29 }
30 if got := req.Header.Get("authorization"); got != "Bearer gnd_test" {
31 t.Fatalf("authorization = %q", got)
32 }
33 return &http.Response{
34 StatusCode: http.StatusUnauthorized,
35 Body: io.NopCloser(strings.NewReader(`{"error":"invalid key"}`)),
36 Header: make(http.Header),
37 Request: req,
38 }, nil
39 })}
40
41 err := probeAPIKey(context.Background(), "gondola", "gnd_test", client)
42 if err == nil || !strings.Contains(err.Error(), "rejected the key") {
43 t.Fatalf("probeAPIKey error = %v", err)
44 }
45}
46
47type authRoundTripFunc func(*http.Request) (*http.Response, error)
48
49func (f authRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
50 return f(req)
51}
52
53func TestStoreRoundTrip(t *testing.T) {
54 dir := t.TempDir()
55 s := NewStore(filepath.Join(dir, "auth.json"))
56
57 if err := s.SetAPIKey("anthropic", "sk-ant-xyz"); err != nil {
58 t.Fatal(err)
59 }
60 c, err := s.Load()
61 if err != nil {
62 t.Fatal(err)
63 }
64 if c.Anthropic.APIKey != "sk-ant-xyz" {
65 t.Fatalf("got %+v", c)
66 }
67 if !c.Has("anthropic") || c.Method("anthropic") != "apikey" {
68 t.Fatalf("method mismatch: %q", c.Method("anthropic"))
69 }
70
71 tok := OAuthToken{AccessToken: "tok", RefreshToken: "ref", Expiry: time.Now().Add(1 * time.Hour)}
72 if err := s.SetOAuth("openai", tok); err != nil {
73 t.Fatal(err)
74 }
75 c, _ = s.Load()
76 if c.OpenAI.OAuth == nil || c.OpenAI.OAuth.AccessToken != "tok" {
77 t.Fatalf("got %+v", c.OpenAI)
78 }
79 if c.Method("openai") != "oauth" {
80 t.Fatalf("method=%q", c.Method("openai"))
81 }
82
83 if err := s.Clear("anthropic"); err != nil {
84 t.Fatal(err)
85 }
86 c, _ = s.Load()
87 if c.Has("anthropic") {
88 t.Fatalf("expected cleared, got %+v", c.Anthropic)
89 }
90}
91
92func TestPKCE(t *testing.T) {
93 p, err := NewPKCE()
94 if err != nil {
95 t.Fatal(err)
96 }
97 if len(p.Verifier) < 40 || len(p.Challenge) < 40 {
98 t.Fatalf("short pkce: %+v", p)
99 }
100 if p.Verifier == p.Challenge {
101 t.Fatal("verifier == challenge")
102 }
103}
104
105func 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
114func 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
149func 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
231func TestAuthorizeURL(t *testing.T) {
232 p, _ := NewPKCE()
233 u, state, err := AnthropicOAuth.AuthorizeURL(p)
234 if err != nil {
235 t.Fatal(err)
236 }
237 parsed, err := url.Parse(u)
238 if err != nil {
239 t.Fatal(err)
240 }
241 q := parsed.Query()
242 if q.Get("client_id") != AnthropicOAuth.ClientID {
243 t.Fatal("missing client_id")
244 }
245 if q.Get("code_challenge_method") != "S256" {
246 t.Fatal("wrong challenge method")
247 }
248 // anthropic uses state == verifier
249 if q.Get("state") != p.Verifier {
250 t.Fatalf("state mismatch: %q vs verifier %q", q.Get("state"), p.Verifier)
251 }
252 if state != p.Verifier {
253 t.Fatalf("returned state != verifier")
254 }
255 if q.Get("redirect_uri") != AnthropicOAuth.RedirectURI() {
256 t.Fatalf("redirect_uri=%q", q.Get("redirect_uri"))
257 }
258 if q.Get("code") != "true" {
259 t.Fatalf("missing code=true extra arg")
260 }
261}
262
263func TestOpenAIAuthorizeURL(t *testing.T) {
264 p, _ := NewPKCE()
265 u, state, err := OpenAIOAuth.AuthorizeURL(p)
266 if err != nil {
267 t.Fatal(err)
268 }
269 parsed, _ := url.Parse(u)
270 q := parsed.Query()
271 if q.Get("redirect_uri") != "http://localhost:1455/auth/callback" {
272 t.Fatalf("redirect_uri=%q", q.Get("redirect_uri"))
273 }
274 if state == p.Verifier {
275 t.Fatal("openai state should be random, not verifier")
276 }
277 if q.Get("originator") != "zot" {
278 t.Fatalf("originator=%q", q.Get("originator"))
279 }
280}
281
282func TestServerIndexAndAPIKey(t *testing.T) {
283 s, err := NewServer()
284 if err != nil {
285 t.Fatal(err)
286 }
287 defer s.Shutdown(context.Background())
288
289 // Swap the probe out for a fake that always succeeds.
290 s.probeFn = func(ctx context.Context, provider, key string) error { return nil }
291
292 resp, err := http.Get(s.URL() + "/")
293 if err != nil {
294 t.Fatal(err)
295 }
296 defer resp.Body.Close()
297 if resp.StatusCode != 200 {
298 t.Fatalf("index status %d", resp.StatusCode)
299 }
300
301 resp, err = http.Get(s.URL() + "/apikey?provider=anthropic")
302 if err != nil {
303 t.Fatal(err)
304 }
305 resp.Body.Close()
306 if resp.StatusCode != 200 {
307 t.Fatalf("apikey form status %d", resp.StatusCode)
308 }
309
310 // POST a fake key and expect a success result on the channel.
311 _, err = http.PostForm(s.URL()+"/apikey", url.Values{
312 "provider": {"anthropic"},
313 "api_key": {"sk-ant-test"},
314 })
315 if err != nil {
316 t.Fatal(err)
317 }
318 select {
319 case r := <-s.Result():
320 if r.Err != nil {
321 t.Fatalf("unexpected err: %v", r.Err)
322 }
323 if r.Provider != "anthropic" || r.APIKey != "sk-ant-test" || r.Method != "apikey" {
324 t.Fatalf("bad result: %+v", r)
325 }
326 case <-time.After(2 * time.Second):
327 t.Fatal("no result")
328 }
329}
330
331func TestServerCallback(t *testing.T) {
332 s, err := NewServer()
333 if err != nil {
334 t.Fatal(err)
335 }
336 defer s.Shutdown(context.Background())
337
338 u := s.URL() + "/callback?code=abc123&state=anthropic:xyz"
339 resp, err := http.Get(u)
340 if err != nil {
341 t.Fatal(err)
342 }
343 resp.Body.Close()
344
345 select {
346 case r := <-s.Result():
347 if r.Provider != "anthropic" || r.Code != "abc123" || r.State != "anthropic:xyz" || r.Method != "oauth" {
348 t.Fatalf("bad result: %+v", r)
349 }
350 case <-time.After(2 * time.Second):
351 t.Fatal("no result")
352 }
353}
354
355func TestCallbackServerRoundTrip(t *testing.T) {
356 // Use a provider config with an ephemeral port so tests stay hermetic.
357 op := OAuthProvider{
358 Name: "anthropic",
359 RedirectHost: "127.0.0.1",
360 RedirectPort: 0, // let kernel pick
361 RedirectPath: "/callback",
362 }
363 // We need a real fixed port for CallbackServer.NewCallbackServer; find one.
364 l, err := net.Listen("tcp", "127.0.0.1:0")
365 if err != nil {
366 t.Fatal(err)
367 }
368 port := l.Addr().(*net.TCPAddr).Port
369 l.Close()
370 op.RedirectPort = port
371
372 cs, err := NewCallbackServer(op, "thestate")
373 if err != nil {
374 t.Fatal(err)
375 }
376 defer cs.Shutdown()
377
378 go func() {
379 time.Sleep(50 * time.Millisecond)
380 _, _ = http.Get(cs.URL() + "?code=abc&state=thestate")
381 }()
382 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
383 defer cancel()
384 r, err := cs.Result(ctx)
385 if err != nil {
386 t.Fatal(err)
387 }
388 if r.Code != "abc" || r.State != "thestate" {
389 t.Fatalf("bad result: %+v", r)
390 }
391}
392
393func TestCallbackServerStateMismatch(t *testing.T) {
394 l, _ := net.Listen("tcp", "127.0.0.1:0")
395 port := l.Addr().(*net.TCPAddr).Port
396 l.Close()
397 op := OAuthProvider{Name: "anthropic", RedirectHost: "127.0.0.1", RedirectPort: port, RedirectPath: "/callback"}
398 cs, err := NewCallbackServer(op, "expected")
399 if err != nil {
400 t.Fatal(err)
401 }
402 defer cs.Shutdown()
403
404 go func() {
405 time.Sleep(50 * time.Millisecond)
406 _, _ = http.Get(cs.URL() + "?code=abc&state=wrong")
407 }()
408 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
409 defer cancel()
410 r, err := cs.Result(ctx)
411 if err != nil {
412 t.Fatal(err)
413 }
414 if r.Err == nil || !strings.Contains(r.Err.Error(), "state") {
415 t.Fatalf("want state error, got %+v", r)
416 }
417}
418
419func TestServerCallbackError(t *testing.T) {
420 s, err := NewServer()
421 if err != nil {
422 t.Fatal(err)
423 }
424 defer s.Shutdown(context.Background())
425
426 _, _ = http.Get(s.URL() + "/callback?error=access_denied&state=openai:xyz")
427 select {
428 case r := <-s.Result():
429 if r.Err == nil || r.Provider != "openai" {
430 t.Fatalf("bad result: %+v", r)
431 }
432 if !strings.Contains(r.Err.Error(), "access_denied") {
433 t.Fatalf("missing reason: %v", r.Err)
434 }
435 case <-time.After(2 * time.Second):
436 t.Fatal("no result")
437 }
438}