1package middleware
2
3import (
4 "context"
5 "errors"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9 "time"
10
11 "github.com/bnema/zerowrap"
12 "github.com/stretchr/testify/assert"
13
14 "github.com/bnema/gordon/internal/domain"
15)
16
17func testLogger() zerowrap.Logger {
18 return zerowrap.Default()
19}
20
21func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
22 log := testLogger()
23 tokenCalled := false
24 authSvc := stubAuthService{
25 enabled: true,
26 authType: domain.AuthTypeToken,
27 validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
28 tokenCalled = true
29 return nil, errors.New("not a token")
30 },
31 }
32 internalAuth := InternalRegistryAuth{
33 Username: "internal",
34 Password: "secret",
35 }
36
37 handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
38 w.WriteHeader(http.StatusOK)
39 }))
40
41 req := httptest.NewRequest("GET", "/v2/", nil)
42 req.RemoteAddr = "127.0.0.1:12345"
43 req.SetBasicAuth("internal", "secret")
44 rec := httptest.NewRecorder()
45
46 handler.ServeHTTP(rec, req)
47
48 assert.Equal(t, http.StatusOK, rec.Code)
49 assert.False(t, tokenCalled, "auth service should not be used for internal loopback auth")
50}
51
52func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
53 log := testLogger()
54 tokenCalled := false
55 authSvc := stubAuthService{
56 enabled: true,
57 authType: domain.AuthTypeToken,
58 validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
59 tokenCalled = true
60 return nil, errors.New("invalid token")
61 },
62 }
63 internalAuth := InternalRegistryAuth{
64 Username: "internal",
65 Password: "secret",
66 }
67
68 handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
69 w.WriteHeader(http.StatusOK)
70 }))
71
72 req := httptest.NewRequest("GET", "/v2/", nil)
73 req.RemoteAddr = "127.0.0.1:12345"
74 req.SetBasicAuth("internal", "wrong")
75 rec := httptest.NewRecorder()
76
77 handler.ServeHTTP(rec, req)
78
79 assert.Equal(t, http.StatusUnauthorized, rec.Code)
80 assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer")
81 assert.True(t, tokenCalled, "auth service should be used when internal auth fails")
82}
83
84func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
85 log := testLogger()
86 tokenCalled := false
87 authSvc := stubAuthService{
88 enabled: true,
89 authType: domain.AuthTypeToken,
90 validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
91 tokenCalled = true
92 return nil, errors.New("invalid token")
93 },
94 }
95 internalAuth := InternalRegistryAuth{
96 Username: "internal",
97 Password: "secret",
98 }
99
100 handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
101 w.WriteHeader(http.StatusOK)
102 }))
103
104 req := httptest.NewRequest("GET", "/v2/", nil)
105 req.RemoteAddr = "192.168.1.10:12345"
106 req.SetBasicAuth("internal", "secret")
107 rec := httptest.NewRecorder()
108
109 handler.ServeHTTP(rec, req)
110
111 assert.Equal(t, http.StatusUnauthorized, rec.Code)
112 assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Bearer")
113 assert.True(t, tokenCalled, "auth service should be used for non-localhost requests")
114}
115
116type stubAuthService struct {
117 enabled bool
118 authType domain.AuthType
119 validateToken func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
120}
121
122func (s stubAuthService) GetAuthType() domain.AuthType {
123 return s.authType
124}
125
126func (s stubAuthService) IsEnabled() bool {
127 return s.enabled
128}
129
130func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
131 if s.validateToken != nil {
132 return s.validateToken(ctx, tokenString)
133 }
134 return nil, errors.New("not implemented")
135}
136
137func (s stubAuthService) GenerateToken(context.Context, string, []string, time.Duration) (string, error) {
138 return "", errors.New("not implemented")
139}
140
141func (s stubAuthService) GenerateAccessToken(context.Context, string, []string, time.Duration) (string, error) {
142 return "", errors.New("not implemented")
143}
144
145func (s stubAuthService) RevokeToken(context.Context, string) error {
146 return errors.New("not implemented")
147}
148
149func (s stubAuthService) RevokeAllTokens(context.Context) (int, error) {
150 return 0, errors.New("not implemented")
151}
152
153func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
154 return nil, errors.New("not implemented")
155}
156
157func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) {
158 return nil, errors.New("not implemented")
159}
160
161func (s stubAuthService) ExtendToken(context.Context, string) (string, error) {
162 return "", errors.New("not implemented")
163}
164
165func (s stubAuthService) GetAccessTokenTTL() time.Duration {
166 return 15 * time.Minute
167}
168
169// Tests for checkScopeAccess function
170
171func TestCheckScopeAccess_ActionMapping(t *testing.T) {
172 log := testLogger()
173
174 tests := []struct {
175 name string
176 method string
177 expectedAction string
178 }{
179 {"GET maps to pull", http.MethodGet, "pull"},
180 {"HEAD maps to pull", http.MethodHead, "pull"},
181 {"PUT maps to push", http.MethodPut, "push"},
182 {"POST maps to push", http.MethodPost, "push"},
183 {"PATCH maps to push", http.MethodPatch, "push"},
184 {"DELETE maps to push", http.MethodDelete, "push"},
185 {"OPTIONS defaults to pull", http.MethodOptions, "pull"},
186 }
187
188 for _, tt := range tests {
189 t.Run(tt.name, func(t *testing.T) {
190 claims := &domain.TokenClaims{
191 Scopes: []string{"repository:myrepo:" + tt.expectedAction},
192 }
193
194 req := httptest.NewRequest(tt.method, "/v2/myrepo/manifests/latest", nil)
195 result := checkScopeAccess(req, claims, log)
196
197 assert.True(t, result, "method %s should be allowed with %s scope", tt.method, tt.expectedAction)
198 })
199 }
200}
201
202func TestCheckScopeAccess_RepoNameExtraction(t *testing.T) {
203 log := testLogger()
204
205 tests := []struct {
206 name string
207 path string
208 wantRepo string
209 scopes []string
210 want bool
211 }{
212 {
213 name: "simple repo with manifests",
214 path: "/v2/myrepo/manifests/latest",
215 scopes: []string{"repository:myrepo:pull"},
216 want: true,
217 },
218 {
219 name: "simple repo with blobs",
220 path: "/v2/myrepo/blobs/sha256:abc123",
221 scopes: []string{"repository:myrepo:pull"},
222 want: true,
223 },
224 {
225 name: "nested repo with manifests",
226 path: "/v2/myorg/myapp/manifests/v1.0",
227 scopes: []string{"repository:myorg/myapp:pull"},
228 want: true,
229 },
230 {
231 name: "deeply nested repo",
232 path: "/v2/myorg/team/myapp/manifests/latest",
233 scopes: []string{"repository:myorg/team/myapp:pull"},
234 want: true,
235 },
236 {
237 name: "repo with tags list",
238 path: "/v2/myrepo/tags/list",
239 scopes: []string{"repository:myrepo:pull"},
240 want: true,
241 },
242 {
243 name: "wrong repo name",
244 path: "/v2/myrepo/manifests/latest",
245 scopes: []string{"repository:otherrepo:pull"},
246 want: false,
247 },
248 }
249
250 for _, tt := range tests {
251 t.Run(tt.name, func(t *testing.T) {
252 claims := &domain.TokenClaims{Scopes: tt.scopes}
253 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
254 result := checkScopeAccess(req, claims, log)
255 assert.Equal(t, tt.want, result)
256 })
257 }
258}
259
260func TestCheckScopeAccess_WildcardMatching(t *testing.T) {
261 log := testLogger()
262
263 tests := []struct {
264 name string
265 path string
266 scopes []string
267 want bool
268 }{
269 {
270 name: "wildcard repo matches any",
271 path: "/v2/anyrepo/manifests/latest",
272 scopes: []string{"repository:*:pull"},
273 want: true,
274 },
275 {
276 name: "org wildcard matches repo in org",
277 path: "/v2/myorg/myapp/manifests/latest",
278 scopes: []string{"repository:myorg/*:pull"},
279 want: true,
280 },
281 {
282 name: "org wildcard does not match other org",
283 path: "/v2/otherorg/myapp/manifests/latest",
284 scopes: []string{"repository:myorg/*:pull"},
285 want: false,
286 },
287 {
288 name: "wildcard action grants push",
289 path: "/v2/myrepo/manifests/latest",
290 scopes: []string{"repository:myrepo:*"},
291 want: true,
292 },
293 }
294
295 for _, tt := range tests {
296 t.Run(tt.name, func(t *testing.T) {
297 claims := &domain.TokenClaims{Scopes: tt.scopes}
298 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
299 result := checkScopeAccess(req, claims, log)
300 assert.Equal(t, tt.want, result)
301 })
302 }
303}
304
305func TestCheckScopeAccess_SpecialRoutes(t *testing.T) {
306 log := testLogger()
307
308 tests := []struct {
309 name string
310 path string
311 scopes []string
312 want bool
313 }{
314 {
315 name: "v2 root is allowed",
316 path: "/v2/",
317 scopes: []string{"repository:myrepo:pull"},
318 want: true,
319 },
320 {
321 name: "non-v2 path is allowed",
322 path: "/healthz",
323 scopes: []string{"repository:myrepo:pull"},
324 want: true,
325 },
326 {
327 name: "catalog path is handled",
328 path: "/v2/_catalog",
329 scopes: []string{"repository:myrepo:pull"},
330 want: true,
331 },
332 }
333
334 for _, tt := range tests {
335 t.Run(tt.name, func(t *testing.T) {
336 claims := &domain.TokenClaims{Scopes: tt.scopes}
337 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
338 result := checkScopeAccess(req, claims, log)
339 assert.Equal(t, tt.want, result)
340 })
341 }
342}
343
344func TestRegistryAuthV2_RejectsLongLivedToken(t *testing.T) {
345 log := testLogger()
346 authSvc := stubAuthService{
347 enabled: true,
348 authType: domain.AuthTypeToken,
349 validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
350 return &domain.TokenClaims{
351 Subject: "admin",
352 Scopes: []string{"repository:*:pull,push"},
353 IsEphemeral: false, // long-lived stored token
354 }, nil
355 },
356 }
357 internalAuth := InternalRegistryAuth{
358 Username: "internal",
359 Password: "secret",
360 }
361
362 handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
363 w.WriteHeader(http.StatusOK)
364 }))
365
366 req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
367 req.RemoteAddr = "10.0.0.1:12345"
368 req.Header.Set("Authorization", "Bearer some-long-lived-jwt")
369 rec := httptest.NewRecorder()
370
371 handler.ServeHTTP(rec, req)
372
373 assert.Equal(t, http.StatusUnauthorized, rec.Code)
374 assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
375}
376
377func TestRegistryAuthV2_AcceptsEphemeralToken(t *testing.T) {
378 log := testLogger()
379 authSvc := stubAuthService{
380 enabled: true,
381 authType: domain.AuthTypeToken,
382 validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
383 return &domain.TokenClaims{
384 Subject: "admin",
385 Scopes: []string{"repository:*:pull,push"},
386 IsEphemeral: true, // short-lived ephemeral token
387 }, nil
388 },
389 }
390 internalAuth := InternalRegistryAuth{
391 Username: "internal",
392 Password: "secret",
393 }
394
395 handlerCalled := false
396 handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
397 handlerCalled = true
398 w.WriteHeader(http.StatusOK)
399 }))
400
401 req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
402 req.RemoteAddr = "10.0.0.1:12345"
403 req.Header.Set("Authorization", "Bearer some-ephemeral-jwt")
404 rec := httptest.NewRecorder()
405
406 handler.ServeHTTP(rec, req)
407
408 assert.Equal(t, http.StatusOK, rec.Code)
409 assert.True(t, handlerCalled, "handler should be called for ephemeral tokens")
410}
411
412func TestRegistryAuthV2_RejectsLongLivedTokenAsPassword(t *testing.T) {
413 log := testLogger()
414 authSvc := stubAuthService{
415 enabled: true,
416 authType: domain.AuthTypeToken,
417 validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
418 return &domain.TokenClaims{
419 Subject: "ci-user",
420 Scopes: []string{"repository:*:pull,push"},
421 IsEphemeral: false, // long-lived stored token
422 }, nil
423 },
424 }
425 internalAuth := InternalRegistryAuth{
426 Username: "internal",
427 Password: "secret",
428 }
429
430 handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
431 w.WriteHeader(http.StatusOK)
432 }))
433
434 req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
435 req.RemoteAddr = "10.0.0.1:12345"
436 req.SetBasicAuth("ci-user", "some-long-lived-jwt")
437 rec := httptest.NewRecorder()
438
439 handler.ServeHTTP(rec, req)
440
441 assert.Equal(t, http.StatusUnauthorized, rec.Code)
442 assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
443}
444
445func TestCheckScopeAccess_DenialCases(t *testing.T) {
446 log := testLogger()
447
448 tests := []struct {
449 name string
450 method string
451 path string
452 scopes []string
453 want bool
454 }{
455 {
456 name: "pull scope denies push",
457 method: http.MethodPut,
458 path: "/v2/myrepo/manifests/latest",
459 scopes: []string{"repository:myrepo:pull"},
460 want: false,
461 },
462 {
463 name: "wrong repo denies access",
464 method: http.MethodGet,
465 path: "/v2/myrepo/manifests/latest",
466 scopes: []string{"repository:otherrepo:pull"},
467 want: false,
468 },
469 {
470 name: "empty scopes denies access",
471 method: http.MethodGet,
472 path: "/v2/myrepo/manifests/latest",
473 scopes: []string{},
474 want: false,
475 },
476 {
477 name: "invalid scope format is skipped",
478 method: http.MethodGet,
479 path: "/v2/myrepo/manifests/latest",
480 scopes: []string{"invalid-scope-format"},
481 want: false,
482 },
483 }
484
485 for _, tt := range tests {
486 t.Run(tt.name, func(t *testing.T) {
487 claims := &domain.TokenClaims{Scopes: tt.scopes}
488 req := httptest.NewRequest(tt.method, tt.path, nil)
489 result := checkScopeAccess(req, claims, log)
490 assert.Equal(t, tt.want, result)
491 })
492 }
493}