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:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
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_ReservedComponentsCannotRedirectRepository(t *testing.T) {
261 log := testLogger()
262 tests := []struct {
263 name string
264 method string
265 path string
266 scopes []string
267 want bool
268 }{
269 {
270 name: "unauthorized repo behind reserved first component",
271 method: http.MethodGet,
272 path: "/v2/manifests/victim/manifests/latest",
273 scopes: []string{"repository:allowed:pull"},
274 want: false,
275 },
276 {
277 name: "authorized prefix cannot reach a nested repository",
278 method: http.MethodGet,
279 path: "/v2/allowed/tags/victim/manifests/latest",
280 scopes: []string{"repository:allowed:pull"},
281 want: false,
282 },
283 {
284 name: "pull token cannot PUT a reserved-looking repository",
285 method: http.MethodPut,
286 path: "/v2/manifests/victim/manifests/latest",
287 scopes: []string{"repository:manifests/victim:pull"},
288 want: false,
289 },
290 {
291 name: "exact nested repository is authorized",
292 method: http.MethodGet,
293 path: "/v2/manifests/victim/manifests/latest",
294 scopes: []string{"repository:manifests/victim:pull"},
295 want: true,
296 },
297 }
298 for _, tt := range tests {
299 t.Run(tt.name, func(t *testing.T) {
300 claims := &domain.TokenClaims{Scopes: tt.scopes}
301 req := httptest.NewRequest(tt.method, tt.path, nil)
302 assert.Equal(t, tt.want, checkScopeAccess(req, claims, log))
303 })
304 }
305}
306
307func TestCheckScopeAccess_WildcardMatching(t *testing.T) {
308 log := testLogger()
309
310 tests := []struct {
311 name string
312 path string
313 scopes []string
314 want bool
315 }{
316 {
317 name: "wildcard repo matches any",
318 path: "/v2/anyrepo/manifests/latest",
319 scopes: []string{"repository:*:pull"},
320 want: true,
321 },
322 {
323 name: "org wildcard matches repo in org",
324 path: "/v2/myorg/myapp/manifests/latest",
325 scopes: []string{"repository:myorg/*:pull"},
326 want: true,
327 },
328 {
329 name: "org wildcard does not match other org",
330 path: "/v2/otherorg/myapp/manifests/latest",
331 scopes: []string{"repository:myorg/*:pull"},
332 want: false,
333 },
334 {
335 name: "wildcard action grants push",
336 path: "/v2/myrepo/manifests/latest",
337 scopes: []string{"repository:myrepo:*"},
338 want: true,
339 },
340 }
341
342 for _, tt := range tests {
343 t.Run(tt.name, func(t *testing.T) {
344 claims := &domain.TokenClaims{Scopes: tt.scopes}
345 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
346 result := checkScopeAccess(req, claims, log)
347 assert.Equal(t, tt.want, result)
348 })
349 }
350}
351
352func TestCheckScopeAccess_SpecialRoutes(t *testing.T) {
353 log := testLogger()
354
355 tests := []struct {
356 name string
357 path string
358 scopes []string
359 want bool
360 }{
361 {
362 name: "v2 root is allowed",
363 path: "/v2/",
364 scopes: []string{"repository:myrepo:pull"},
365 want: true,
366 },
367 {
368 name: "non-v2 path is denied",
369 path: "/healthz",
370 scopes: []string{"repository:myrepo:pull"},
371 want: false,
372 },
373 {
374 name: "catalog path is handled",
375 path: "/v2/_catalog",
376 scopes: []string{"repository:myrepo:pull"},
377 want: true,
378 },
379 }
380
381 for _, tt := range tests {
382 t.Run(tt.name, func(t *testing.T) {
383 claims := &domain.TokenClaims{Scopes: tt.scopes}
384 req := httptest.NewRequest(http.MethodGet, tt.path, nil)
385 result := checkScopeAccess(req, claims, log)
386 assert.Equal(t, tt.want, result)
387 })
388 }
389}
390
391func TestRegistryAuthV2_RejectsLongLivedToken(t *testing.T) {
392 log := testLogger()
393 authSvc := stubAuthService{
394 enabled: true,
395 authType: domain.AuthTypeToken,
396 validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
397 return &domain.TokenClaims{
398 Subject: "admin",
399 Scopes: []string{"repository:*:pull,push"},
400 IsEphemeral: false, // long-lived stored token
401 }, nil
402 },
403 }
404 internalAuth := InternalRegistryAuth{
405 Username: "internal",
406 Password: "secret",
407 }
408
409 handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
410 w.WriteHeader(http.StatusOK)
411 }))
412
413 req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
414 req.RemoteAddr = "10.0.0.1:12345"
415 req.Header.Set("Authorization", "Bearer some-long-lived-jwt")
416 rec := httptest.NewRecorder()
417
418 handler.ServeHTTP(rec, req)
419
420 assert.Equal(t, http.StatusUnauthorized, rec.Code)
421 assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
422}
423
424func TestRegistryAuthV2_AcceptsEphemeralToken(t *testing.T) {
425 log := testLogger()
426 authSvc := stubAuthService{
427 enabled: true,
428 authType: domain.AuthTypeToken,
429 validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
430 return &domain.TokenClaims{
431 Subject: "admin",
432 Scopes: []string{"repository:*:pull,push"},
433 IsEphemeral: true, // short-lived ephemeral token
434 }, nil
435 },
436 }
437 internalAuth := InternalRegistryAuth{
438 Username: "internal",
439 Password: "secret",
440 }
441
442 handlerCalled := false
443 handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
444 handlerCalled = true
445 w.WriteHeader(http.StatusOK)
446 }))
447
448 req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
449 req.RemoteAddr = "10.0.0.1:12345"
450 req.Header.Set("Authorization", "Bearer some-ephemeral-jwt")
451 rec := httptest.NewRecorder()
452
453 handler.ServeHTTP(rec, req)
454
455 assert.Equal(t, http.StatusOK, rec.Code)
456 assert.True(t, handlerCalled, "handler should be called for ephemeral tokens")
457}
458
459func TestRegistryAuthV2_RejectsLongLivedTokenAsPassword(t *testing.T) {
460 log := testLogger()
461 authSvc := stubAuthService{
462 enabled: true,
463 authType: domain.AuthTypeToken,
464 validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) {
465 return &domain.TokenClaims{
466 Subject: "ci-user",
467 Scopes: []string{"repository:*:pull,push"},
468 IsEphemeral: false, // long-lived stored token
469 }, nil
470 },
471 }
472 internalAuth := InternalRegistryAuth{
473 Username: "internal",
474 Password: "secret",
475 }
476
477 handler := RegistryAuthV2(authSvc, internalAuth, nil, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
478 w.WriteHeader(http.StatusOK)
479 }))
480
481 req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil)
482 req.RemoteAddr = "10.0.0.1:12345"
483 req.SetBasicAuth("ci-user", "some-long-lived-jwt")
484 rec := httptest.NewRecorder()
485
486 handler.ServeHTTP(rec, req)
487
488 assert.Equal(t, http.StatusUnauthorized, rec.Code)
489 assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token")
490}
491
492func TestCheckScopeAccess_DenialCases(t *testing.T) {
493 log := testLogger()
494
495 tests := []struct {
496 name string
497 method string
498 path string
499 scopes []string
500 want bool
501 }{
502 {
503 name: "pull scope denies push",
504 method: http.MethodPut,
505 path: "/v2/myrepo/manifests/latest",
506 scopes: []string{"repository:myrepo:pull"},
507 want: false,
508 },
509 {
510 name: "wrong repo denies access",
511 method: http.MethodGet,
512 path: "/v2/myrepo/manifests/latest",
513 scopes: []string{"repository:otherrepo:pull"},
514 want: false,
515 },
516 {
517 name: "empty scopes denies access",
518 method: http.MethodGet,
519 path: "/v2/myrepo/manifests/latest",
520 scopes: []string{},
521 want: false,
522 },
523 {
524 name: "invalid scope format is skipped",
525 method: http.MethodGet,
526 path: "/v2/myrepo/manifests/latest",
527 scopes: []string{"invalid-scope-format"},
528 want: false,
529 },
530 }
531
532 for _, tt := range tests {
533 t.Run(tt.name, func(t *testing.T) {
534 claims := &domain.TokenClaims{Scopes: tt.scopes}
535 req := httptest.NewRequest(tt.method, tt.path, nil)
536 result := checkScopeAccess(req, claims, log)
537 assert.Equal(t, tt.want, result)
538 })
539 }
540}