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