1package auth
2
3import (
4 "context"
5 "net"
6 "net/http"
7 "net/url"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12)
13
14func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) {
15 SetExtraAPIKeyProviders([]string{"my-company"})
16 t.Cleanup(func() { SetExtraAPIKeyProviders(nil) })
17
18 if err := ProbeAPIKey(context.Background(), "my-company", "test-key"); err != nil {
19 t.Fatalf("ProbeAPIKey custom provider: %v", err)
20 }
21}
22
23func TestStoreRoundTrip(t *testing.T) {
24 dir := t.TempDir()
25 s := NewStore(filepath.Join(dir, "auth.json"))
26
27 if err := s.SetAPIKey("anthropic", "sk-ant-xyz"); err != nil {
28 t.Fatal(err)
29 }
30 c, err := s.Load()
31 if err != nil {
32 t.Fatal(err)
33 }
34 if c.Anthropic.APIKey != "sk-ant-xyz" {
35 t.Fatalf("got %+v", c)
36 }
37 if !c.Has("anthropic") || c.Method("anthropic") != "apikey" {
38 t.Fatalf("method mismatch: %q", c.Method("anthropic"))
39 }
40
41 tok := OAuthToken{AccessToken: "tok", RefreshToken: "ref", Expiry: time.Now().Add(1 * time.Hour)}
42 if err := s.SetOAuth("openai", tok); err != nil {
43 t.Fatal(err)
44 }
45 c, _ = s.Load()
46 if c.OpenAI.OAuth == nil || c.OpenAI.OAuth.AccessToken != "tok" {
47 t.Fatalf("got %+v", c.OpenAI)
48 }
49 if c.Method("openai") != "oauth" {
50 t.Fatalf("method=%q", c.Method("openai"))
51 }
52
53 if err := s.Clear("anthropic"); err != nil {
54 t.Fatal(err)
55 }
56 c, _ = s.Load()
57 if c.Has("anthropic") {
58 t.Fatalf("expected cleared, got %+v", c.Anthropic)
59 }
60}
61
62func TestPKCE(t *testing.T) {
63 p, err := NewPKCE()
64 if err != nil {
65 t.Fatal(err)
66 }
67 if len(p.Verifier) < 40 || len(p.Challenge) < 40 {
68 t.Fatalf("short pkce: %+v", p)
69 }
70 if p.Verifier == p.Challenge {
71 t.Fatal("verifier == challenge")
72 }
73}
74
75func TestAuthorizeURL(t *testing.T) {
76 p, _ := NewPKCE()
77 u, state, err := AnthropicOAuth.AuthorizeURL(p)
78 if err != nil {
79 t.Fatal(err)
80 }
81 parsed, err := url.Parse(u)
82 if err != nil {
83 t.Fatal(err)
84 }
85 q := parsed.Query()
86 if q.Get("client_id") != AnthropicOAuth.ClientID {
87 t.Fatal("missing client_id")
88 }
89 if q.Get("code_challenge_method") != "S256" {
90 t.Fatal("wrong challenge method")
91 }
92 // anthropic uses state == verifier
93 if q.Get("state") != p.Verifier {
94 t.Fatalf("state mismatch: %q vs verifier %q", q.Get("state"), p.Verifier)
95 }
96 if state != p.Verifier {
97 t.Fatalf("returned state != verifier")
98 }
99 if q.Get("redirect_uri") != AnthropicOAuth.RedirectURI() {
100 t.Fatalf("redirect_uri=%q", q.Get("redirect_uri"))
101 }
102 if q.Get("code") != "true" {
103 t.Fatalf("missing code=true extra arg")
104 }
105}
106
107func TestOpenAIAuthorizeURL(t *testing.T) {
108 p, _ := NewPKCE()
109 u, state, err := OpenAIOAuth.AuthorizeURL(p)
110 if err != nil {
111 t.Fatal(err)
112 }
113 parsed, _ := url.Parse(u)
114 q := parsed.Query()
115 if q.Get("redirect_uri") != "http://localhost:1455/auth/callback" {
116 t.Fatalf("redirect_uri=%q", q.Get("redirect_uri"))
117 }
118 if state == p.Verifier {
119 t.Fatal("openai state should be random, not verifier")
120 }
121 if q.Get("originator") != "zot" {
122 t.Fatalf("originator=%q", q.Get("originator"))
123 }
124}
125
126func TestServerIndexAndAPIKey(t *testing.T) {
127 s, err := NewServer()
128 if err != nil {
129 t.Fatal(err)
130 }
131 defer s.Shutdown(context.Background())
132
133 // Swap the probe out for a fake that always succeeds.
134 s.probeFn = func(ctx context.Context, provider, key string) error { return nil }
135
136 resp, err := http.Get(s.URL() + "/")
137 if err != nil {
138 t.Fatal(err)
139 }
140 defer resp.Body.Close()
141 if resp.StatusCode != 200 {
142 t.Fatalf("index status %d", resp.StatusCode)
143 }
144
145 resp, err = http.Get(s.URL() + "/apikey?provider=anthropic")
146 if err != nil {
147 t.Fatal(err)
148 }
149 resp.Body.Close()
150 if resp.StatusCode != 200 {
151 t.Fatalf("apikey form status %d", resp.StatusCode)
152 }
153
154 // POST a fake key and expect a success result on the channel.
155 _, err = http.PostForm(s.URL()+"/apikey", url.Values{
156 "provider": {"anthropic"},
157 "api_key": {"sk-ant-test"},
158 })
159 if err != nil {
160 t.Fatal(err)
161 }
162 select {
163 case r := <-s.Result():
164 if r.Err != nil {
165 t.Fatalf("unexpected err: %v", r.Err)
166 }
167 if r.Provider != "anthropic" || r.APIKey != "sk-ant-test" || r.Method != "apikey" {
168 t.Fatalf("bad result: %+v", r)
169 }
170 case <-time.After(2 * time.Second):
171 t.Fatal("no result")
172 }
173}
174
175func TestServerCallback(t *testing.T) {
176 s, err := NewServer()
177 if err != nil {
178 t.Fatal(err)
179 }
180 defer s.Shutdown(context.Background())
181
182 u := s.URL() + "/callback?code=abc123&state=anthropic:xyz"
183 resp, err := http.Get(u)
184 if err != nil {
185 t.Fatal(err)
186 }
187 resp.Body.Close()
188
189 select {
190 case r := <-s.Result():
191 if r.Provider != "anthropic" || r.Code != "abc123" || r.State != "anthropic:xyz" || r.Method != "oauth" {
192 t.Fatalf("bad result: %+v", r)
193 }
194 case <-time.After(2 * time.Second):
195 t.Fatal("no result")
196 }
197}
198
199func TestCallbackServerRoundTrip(t *testing.T) {
200 // Use a provider config with an ephemeral port so tests stay hermetic.
201 op := OAuthProvider{
202 Name: "anthropic",
203 RedirectHost: "127.0.0.1",
204 RedirectPort: 0, // let kernel pick
205 RedirectPath: "/callback",
206 }
207 // We need a real fixed port for CallbackServer.NewCallbackServer; find one.
208 l, err := net.Listen("tcp", "127.0.0.1:0")
209 if err != nil {
210 t.Fatal(err)
211 }
212 port := l.Addr().(*net.TCPAddr).Port
213 l.Close()
214 op.RedirectPort = port
215
216 cs, err := NewCallbackServer(op, "thestate")
217 if err != nil {
218 t.Fatal(err)
219 }
220 defer cs.Shutdown()
221
222 go func() {
223 time.Sleep(50 * time.Millisecond)
224 _, _ = http.Get(cs.URL() + "?code=abc&state=thestate")
225 }()
226 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
227 defer cancel()
228 r, err := cs.Result(ctx)
229 if err != nil {
230 t.Fatal(err)
231 }
232 if r.Code != "abc" || r.State != "thestate" {
233 t.Fatalf("bad result: %+v", r)
234 }
235}
236
237func TestCallbackServerStateMismatch(t *testing.T) {
238 l, _ := net.Listen("tcp", "127.0.0.1:0")
239 port := l.Addr().(*net.TCPAddr).Port
240 l.Close()
241 op := OAuthProvider{Name: "anthropic", RedirectHost: "127.0.0.1", RedirectPort: port, RedirectPath: "/callback"}
242 cs, err := NewCallbackServer(op, "expected")
243 if err != nil {
244 t.Fatal(err)
245 }
246 defer cs.Shutdown()
247
248 go func() {
249 time.Sleep(50 * time.Millisecond)
250 _, _ = http.Get(cs.URL() + "?code=abc&state=wrong")
251 }()
252 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
253 defer cancel()
254 r, err := cs.Result(ctx)
255 if err != nil {
256 t.Fatal(err)
257 }
258 if r.Err == nil || !strings.Contains(r.Err.Error(), "state") {
259 t.Fatalf("want state error, got %+v", r)
260 }
261}
262
263func TestServerCallbackError(t *testing.T) {
264 s, err := NewServer()
265 if err != nil {
266 t.Fatal(err)
267 }
268 defer s.Shutdown(context.Background())
269
270 _, _ = http.Get(s.URL() + "/callback?error=access_denied&state=openai:xyz")
271 select {
272 case r := <-s.Result():
273 if r.Err == nil || r.Provider != "openai" {
274 t.Fatalf("bad result: %+v", r)
275 }
276 if !strings.Contains(r.Err.Error(), "access_denied") {
277 t.Fatalf("missing reason: %v", r.Err)
278 }
279 case <-time.After(2 * time.Second):
280 t.Fatal("no result")
281 }
282}