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