1package auth
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "testing"
10 "time"
11
12 "github.com/bnema/zerowrap"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15
16 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
17 "github.com/bnema/gordon/internal/boundaries/in/mocks"
18 "github.com/bnema/gordon/internal/domain"
19)
20
21func testLogger() zerowrap.Logger {
22 return zerowrap.Default()
23}
24
25func TestHandler_Password_AlwaysGone(t *testing.T) {
26 handler := NewHandler(nil, InternalAuth{}, testLogger())
27
28 body := `{"username":"admin","password":"secret"}`
29 req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
30 req.Header.Set("Content-Type", "application/json")
31 rec := httptest.NewRecorder()
32
33 handler.ServeHTTP(rec, req)
34
35 assert.Equal(t, http.StatusGone, rec.Code)
36 assert.Contains(t, rec.Body.String(), "password authentication has been removed")
37}
38
39func TestHandler_Token_Success(t *testing.T) {
40 authSvc := mocks.NewMockAuthService(t)
41
42 authSvc.EXPECT().IsEnabled().Return(true)
43 authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
44 Subject: "admin",
45 Scopes: []string{"repository:myrepo:pull"},
46 }, nil)
47 authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
48 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 15*time.Minute).
49 Return("access-token", nil)
50
51 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
52
53 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
54 req.SetBasicAuth("admin", "existing-jwt-token")
55 rec := httptest.NewRecorder()
56
57 handler.ServeHTTP(rec, req)
58
59 assert.Equal(t, http.StatusOK, rec.Code)
60
61 var resp map[string]any
62 err := json.NewDecoder(rec.Body).Decode(&resp)
63 assert.NoError(t, err)
64 assert.Equal(t, "access-token", resp["token"])
65 assert.Equal(t, float64(900), resp["expires_in"])
66}
67
68func TestHandler_Token_MissingCredentials(t *testing.T) {
69 authSvc := mocks.NewMockAuthService(t)
70
71 authSvc.EXPECT().IsEnabled().Return(true)
72
73 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
74
75 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
76 rec := httptest.NewRecorder()
77
78 handler.ServeHTTP(rec, req)
79
80 assert.Equal(t, http.StatusUnauthorized, rec.Code)
81}
82
83func TestHandler_Token_AuthDisabled(t *testing.T) {
84 authSvc := mocks.NewMockAuthService(t)
85
86 authSvc.EXPECT().IsEnabled().Return(false)
87
88 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
89
90 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
91 rec := httptest.NewRecorder()
92
93 handler.ServeHTTP(rec, req)
94
95 assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
96 assert.Contains(t, rec.Body.String(), "authentication is required")
97}
98
99func TestHandler_Token_InvalidCredentials(t *testing.T) {
100 authSvc := mocks.NewMockAuthService(t)
101
102 authSvc.EXPECT().IsEnabled().Return(true)
103 authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, fmt.Errorf("invalid token"))
104
105 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
106
107 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
108 req.SetBasicAuth("admin", "invalid-token")
109 rec := httptest.NewRecorder()
110
111 handler.ServeHTTP(rec, req)
112
113 assert.Equal(t, http.StatusUnauthorized, rec.Code)
114}
115
116func TestHandler_Token_InternalAuth(t *testing.T) {
117 authSvc := mocks.NewMockAuthService(t)
118
119 authSvc.EXPECT().IsEnabled().Return(true)
120 authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
121 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 15*time.Minute).
122 Return("internal-access-token", nil)
123
124 internalAuth := InternalAuth{
125 Username: "gordon-internal",
126 Password: "internal-secret",
127 }
128 handler := NewHandler(authSvc, internalAuth, testLogger())
129
130 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
131 req.RemoteAddr = "127.0.0.1:12345"
132 req.SetBasicAuth("gordon-internal", "internal-secret")
133 rec := httptest.NewRecorder()
134
135 handler.ServeHTTP(rec, req)
136
137 assert.Equal(t, http.StatusOK, rec.Code)
138
139 var resp map[string]any
140 err := json.NewDecoder(rec.Body).Decode(&resp)
141 assert.NoError(t, err)
142 assert.Equal(t, "internal-access-token", resp["token"])
143}
144
145func TestHandler_Token_TokenAuth(t *testing.T) {
146 authSvc := mocks.NewMockAuthService(t)
147
148 authSvc.EXPECT().IsEnabled().Return(true)
149 authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
150 Subject: "myuser",
151 Scopes: []string{"repository:*:pull"},
152 }, nil)
153 authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
154 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 15*time.Minute).
155 Return("short-lived-token", nil)
156
157 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
158
159 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
160 req.SetBasicAuth("myuser", "existing-jwt-token")
161 rec := httptest.NewRecorder()
162
163 handler.ServeHTTP(rec, req)
164
165 assert.Equal(t, http.StatusOK, rec.Code)
166
167 var resp map[string]any
168 err := json.NewDecoder(rec.Body).Decode(&resp)
169 assert.NoError(t, err)
170 assert.Equal(t, "short-lived-token", resp["token"])
171}
172
173func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) {
174 authSvc := mocks.NewMockAuthService(t)
175
176 authSvc.EXPECT().IsEnabled().Return(true)
177 authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
178 Subject: "myuser",
179 Scopes: []string{"repository:myrepo:pull"},
180 }, nil)
181 authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
182 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute).
183 Return("scoped-token", nil)
184
185 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
186
187 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull,push", nil)
188 req.SetBasicAuth("myuser", "existing-jwt-token")
189 rec := httptest.NewRecorder()
190
191 handler.ServeHTTP(rec, req)
192
193 assert.Equal(t, http.StatusOK, rec.Code)
194 var resp map[string]any
195 err := json.NewDecoder(rec.Body).Decode(&resp)
196 assert.NoError(t, err)
197 assert.Equal(t, "scoped-token", resp["token"])
198}
199
200func TestHandler_Token_TokenAuthScopeDenied(t *testing.T) {
201 authSvc := mocks.NewMockAuthService(t)
202
203 authSvc.EXPECT().IsEnabled().Return(true)
204 authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
205 Subject: "myuser",
206 Scopes: []string{"repository:myrepo:pull"},
207 }, nil)
208
209 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
210
211 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:push", nil)
212 req.SetBasicAuth("myuser", "existing-jwt-token")
213 rec := httptest.NewRecorder()
214
215 handler.ServeHTTP(rec, req)
216
217 assert.Equal(t, http.StatusForbidden, rec.Code)
218 assert.Contains(t, rec.Body.String(), "insufficient scope")
219}
220
221func TestHandler_Token_PasswordAuthWithJWT(t *testing.T) {
222 authSvc := mocks.NewMockAuthService(t)
223
224 // Auth type is password, but the client sends a JWT (from gordon auth login)
225 authSvc.EXPECT().IsEnabled().Return(true)
226 authSvc.EXPECT().ValidateToken(mock.Anything, "jwt-from-login").Return(&domain.TokenClaims{
227 Subject: "brissou",
228 Scopes: []string{"*"},
229 }, nil)
230 authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
231 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 15*time.Minute).
232 Return("short-lived-token", nil)
233
234 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
235
236 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:*:push,pull", nil)
237 req.SetBasicAuth("brissou", "jwt-from-login")
238 rec := httptest.NewRecorder()
239
240 handler.ServeHTTP(rec, req)
241
242 assert.Equal(t, http.StatusOK, rec.Code)
243 var resp map[string]any
244 err := json.NewDecoder(rec.Body).Decode(&resp)
245 assert.NoError(t, err)
246 assert.Equal(t, "short-lived-token", resp["token"])
247}
248
249func TestHandler_Token_MethodNotAllowed(t *testing.T) {
250 handler := NewHandler(nil, InternalAuth{}, testLogger())
251
252 methods := []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
253
254 for _, method := range methods {
255 t.Run(method, func(t *testing.T) {
256 req := httptest.NewRequest(method, "/auth/token", nil)
257 rec := httptest.NewRecorder()
258
259 handler.ServeHTTP(rec, req)
260
261 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
262 })
263 }
264}
265
266func TestHandler_NotFound(t *testing.T) {
267 handler := NewHandler(nil, InternalAuth{}, testLogger())
268
269 paths := []string{"/auth/unknown", "/auth/", "/auth"}
270
271 for _, path := range paths {
272 t.Run(path, func(t *testing.T) {
273 req := httptest.NewRequest(http.MethodGet, path, nil)
274 rec := httptest.NewRecorder()
275
276 handler.ServeHTTP(rec, req)
277
278 assert.Equal(t, http.StatusNotFound, rec.Code)
279 })
280 }
281}
282
283func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) {
284 log := testLogger()
285 h := &Handler{log: log}
286
287 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil)
288
289 scopes := h.parseRequestedScopes(req, log)
290
291 assert.Len(t, scopes, 2)
292 assert.Contains(t, scopes, "repository:repo1:pull")
293 assert.Contains(t, scopes, "repository:repo2:push")
294}
295
296func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) {
297 log := testLogger()
298 h := &Handler{log: log}
299
300 tests := []struct {
301 name string
302 query string
303 wantScopes []string
304 }{
305 {
306 name: "invalid format is skipped",
307 query: "scope=invalid-no-colons&scope=repository:valid:pull",
308 wantScopes: []string{"repository:valid:pull"},
309 },
310 {
311 name: "missing actions is skipped",
312 query: "scope=repository:myrepo&scope=repository:valid:pull",
313 wantScopes: []string{"repository:valid:pull"},
314 },
315 {
316 name: "all invalid returns default",
317 query: "scope=invalid&scope=also-invalid",
318 wantScopes: []string{"repository:*:pull"},
319 },
320 }
321
322 for _, tt := range tests {
323 t.Run(tt.name, func(t *testing.T) {
324 req := httptest.NewRequest(http.MethodGet, "/auth/token?"+tt.query, nil)
325 scopes := h.parseRequestedScopes(req, log)
326
327 assert.Equal(t, tt.wantScopes, scopes)
328 })
329 }
330}
331
332func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) {
333 log := testLogger()
334 h := &Handler{log: log}
335
336 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
337 scopes := h.parseRequestedScopes(req, log)
338
339 assert.Equal(t, []string{"repository:*:pull"}, scopes)
340}
341
342func TestIsLocalhostRequest(t *testing.T) {
343 tests := []struct {
344 name string
345 remoteAddr string
346 want bool
347 }{
348 {"127.0.0.1 is localhost", "127.0.0.1:12345", true},
349 {"127.0.0.2 is localhost", "127.0.0.2:12345", true},
350 {"::1 is localhost", "[::1]:12345", true},
351 {"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
352 {"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
353 {"public IP is not localhost", "8.8.8.8:12345", false},
354 }
355
356 for _, tt := range tests {
357 t.Run(tt.name, func(t *testing.T) {
358 req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
359 req.RemoteAddr = tt.remoteAddr
360
361 result := httphelper.IsLocalhostRequest(req)
362
363 assert.Equal(t, tt.want, result)
364 })
365 }
366}
367
368func TestHandler_isInternalAuth(t *testing.T) {
369 tests := []struct {
370 name string
371 username string
372 password string
373 internalAuth InternalAuth
374 wantResult bool
375 }{
376 {
377 name: "correct credentials",
378 username: "gordon-internal",
379 password: "internal-secret",
380 internalAuth: InternalAuth{
381 Username: "gordon-internal",
382 Password: "internal-secret",
383 },
384 wantResult: true,
385 },
386 {
387 name: "incorrect username",
388 username: "wrong-username",
389 password: "internal-secret",
390 internalAuth: InternalAuth{
391 Username: "gordon-internal",
392 Password: "internal-secret",
393 },
394 wantResult: false,
395 },
396 {
397 name: "incorrect password",
398 username: "gordon-internal",
399 password: "wrong-password",
400 internalAuth: InternalAuth{
401 Username: "gordon-internal",
402 Password: "internal-secret",
403 },
404 wantResult: false,
405 },
406 {
407 name: "empty internal auth credentials",
408 username: "gordon-internal",
409 password: "internal-secret",
410 internalAuth: InternalAuth{
411 Username: "",
412 Password: "",
413 },
414 wantResult: false,
415 },
416 {
417 name: "partially empty internal auth - empty password",
418 username: "gordon-internal",
419 password: "internal-secret",
420 internalAuth: InternalAuth{
421 Username: "gordon-internal",
422 Password: "",
423 },
424 wantResult: false,
425 },
426 {
427 name: "partially empty internal auth - empty username",
428 username: "gordon-internal",
429 password: "internal-secret",
430 internalAuth: InternalAuth{
431 Username: "",
432 Password: "internal-secret",
433 },
434 wantResult: false,
435 },
436 }
437
438 for _, tt := range tests {
439 t.Run(tt.name, func(t *testing.T) {
440 h := &Handler{
441 internalAuth: tt.internalAuth,
442 }
443
444 result := h.isInternalAuth(tt.username, tt.password)
445
446 assert.Equal(t, tt.wantResult, result)
447 })
448 }
449}
450
451func TestHandler_Token_EphemeralTokenRejected(t *testing.T) {
452 authSvc := mocks.NewMockAuthService(t)
453
454 authSvc.EXPECT().IsEnabled().Return(true)
455 authSvc.EXPECT().ValidateToken(mock.Anything, "ephemeral-jwt-token").Return(&domain.TokenClaims{
456 Subject: "myuser",
457 Scopes: []string{"repository:*:pull"},
458 IsEphemeral: true,
459 }, nil)
460
461 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
462
463 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
464 req.SetBasicAuth("myuser", "ephemeral-jwt-token")
465 rec := httptest.NewRecorder()
466
467 handler.ServeHTTP(rec, req)
468
469 assert.Equal(t, http.StatusUnauthorized, rec.Code)
470 assert.Contains(t, rec.Body.String(), "unauthorized")
471}
472
473func TestHandler_Token_LongLivedTokenAccepted(t *testing.T) {
474 authSvc := mocks.NewMockAuthService(t)
475
476 authSvc.EXPECT().IsEnabled().Return(true)
477 authSvc.EXPECT().ValidateToken(mock.Anything, "long-lived-jwt-token").Return(&domain.TokenClaims{
478 Subject: "myuser",
479 Scopes: []string{"repository:*:pull"},
480 IsEphemeral: false,
481 }, nil)
482 authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
483 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute).
484 Return("new-access-token", nil)
485
486 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
487
488 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
489 req.SetBasicAuth("myuser", "long-lived-jwt-token")
490 rec := httptest.NewRecorder()
491
492 handler.ServeHTTP(rec, req)
493
494 assert.Equal(t, http.StatusOK, rec.Code)
495
496 var resp map[string]any
497 err := json.NewDecoder(rec.Body).Decode(&resp)
498 assert.NoError(t, err)
499 assert.Equal(t, "new-access-token", resp["token"])
500}
501
502func TestHandler_Token_AdminScopeSuccess(t *testing.T) {
503 authSvc := mocks.NewMockAuthService(t)
504
505 authSvc.EXPECT().IsEnabled().Return(true)
506 authSvc.EXPECT().ValidateToken(mock.Anything, "admin-jwt-token").Return(&domain.TokenClaims{
507 Subject: "admin",
508 Scopes: []string{"admin:routes:read,write"},
509 }, nil)
510 authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
511 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"admin:routes:read"}, 15*time.Minute).
512 Return("admin-access-token", nil)
513
514 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
515
516 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
517 req.SetBasicAuth("admin", "admin-jwt-token")
518 rec := httptest.NewRecorder()
519
520 handler.ServeHTTP(rec, req)
521
522 assert.Equal(t, http.StatusOK, rec.Code)
523
524 var resp map[string]any
525 err := json.NewDecoder(rec.Body).Decode(&resp)
526 assert.NoError(t, err)
527 assert.Equal(t, "admin-access-token", resp["token"])
528}
529
530func TestHandler_Token_AdminScopeDeniedWithRegistryOnly(t *testing.T) {
531 authSvc := mocks.NewMockAuthService(t)
532
533 authSvc.EXPECT().IsEnabled().Return(true)
534 authSvc.EXPECT().ValidateToken(mock.Anything, "registry-jwt-token").Return(&domain.TokenClaims{
535 Subject: "myuser",
536 Scopes: []string{"repository:myrepo:pull,push"},
537 }, nil)
538
539 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
540
541 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
542 req.SetBasicAuth("myuser", "registry-jwt-token")
543 rec := httptest.NewRecorder()
544
545 handler.ServeHTTP(rec, req)
546
547 assert.Equal(t, http.StatusForbidden, rec.Code)
548 assert.Contains(t, rec.Body.String(), "insufficient scope")
549}
550
551func TestHandler_Token_AdminScopeWildcard(t *testing.T) {
552 authSvc := mocks.NewMockAuthService(t)
553
554 authSvc.EXPECT().IsEnabled().Return(true)
555 authSvc.EXPECT().ValidateToken(mock.Anything, "admin-wildcard-token").Return(&domain.TokenClaims{
556 Subject: "superadmin",
557 Scopes: []string{"admin:*:*"},
558 }, nil)
559 authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
560 authSvc.EXPECT().GenerateAccessToken(mock.Anything, "superadmin", []string{"admin:routes:read"}, 15*time.Minute).
561 Return("wildcard-access-token", nil)
562
563 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
564
565 req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
566 req.SetBasicAuth("superadmin", "admin-wildcard-token")
567 rec := httptest.NewRecorder()
568
569 handler.ServeHTTP(rec, req)
570
571 assert.Equal(t, http.StatusOK, rec.Code)
572
573 var resp map[string]any
574 err := json.NewDecoder(rec.Body).Decode(&resp)
575 assert.NoError(t, err)
576 assert.Equal(t, "wildcard-access-token", resp["token"])
577}