1package auth
2
3import (
4 "context"
5 "errors"
6 "testing"
7 "time"
8
9 "github.com/bnema/zerowrap"
10 "github.com/golang-jwt/jwt/v5"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14 "golang.org/x/crypto/bcrypt"
15
16 "github.com/bnema/gordon/internal/boundaries/out/mocks"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20func testContext() context.Context {
21 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
22}
23
24func TestService_GetAuthType(t *testing.T) {
25 tests := []struct {
26 name string
27 authType domain.AuthType
28 }{
29 {"password type", domain.AuthTypePassword},
30 {"token type", domain.AuthTypeToken},
31 }
32
33 for _, tt := range tests {
34 t.Run(tt.name, func(t *testing.T) {
35 svc := NewService(Config{AuthType: tt.authType}, nil, zerowrap.Default())
36 assert.Equal(t, tt.authType, svc.GetAuthType())
37 })
38 }
39}
40
41func TestService_IsEnabled(t *testing.T) {
42 tests := []struct {
43 name string
44 enabled bool
45 }{
46 {"enabled", true},
47 {"disabled", false},
48 }
49
50 for _, tt := range tests {
51 t.Run(tt.name, func(t *testing.T) {
52 svc := NewService(Config{Enabled: tt.enabled}, nil, zerowrap.Default())
53 assert.Equal(t, tt.enabled, svc.IsEnabled())
54 })
55 }
56}
57
58func TestService_ValidatePassword_Success(t *testing.T) {
59 password := "testpassword123"
60 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
61 require.NoError(t, err)
62
63 svc := NewService(Config{
64 Enabled: true,
65 AuthType: domain.AuthTypePassword,
66 Username: "testuser",
67 PasswordHash: string(hash),
68 }, nil, zerowrap.Default())
69
70 ctx := testContext()
71 assert.True(t, svc.ValidatePassword(ctx, "testuser", password))
72}
73
74func TestService_ValidatePassword_WrongPassword(t *testing.T) {
75 hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost)
76 require.NoError(t, err)
77
78 svc := NewService(Config{
79 Enabled: true,
80 AuthType: domain.AuthTypePassword,
81 Username: "testuser",
82 PasswordHash: string(hash),
83 }, nil, zerowrap.Default())
84
85 ctx := testContext()
86 assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword"))
87}
88
89func TestService_ValidatePassword_WrongUsername(t *testing.T) {
90 password := "testpassword123"
91 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
92 require.NoError(t, err)
93
94 svc := NewService(Config{
95 Enabled: true,
96 AuthType: domain.AuthTypePassword,
97 Username: "testuser",
98 PasswordHash: string(hash),
99 }, nil, zerowrap.Default())
100
101 ctx := testContext()
102 assert.False(t, svc.ValidatePassword(ctx, "wronguser", password))
103}
104
105func TestService_GenerateToken_Success(t *testing.T) {
106 tokenStore := mocks.NewMockTokenStore(t)
107
108 // Capture the token to verify its properties
109 var capturedToken *domain.Token
110 var capturedJWT string
111
112 tokenStore.EXPECT().
113 SaveToken(
114 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
115 mock.MatchedBy(func(token *domain.Token) bool {
116 capturedToken = token
117 return token.Subject == "testsubject" &&
118 len(token.ID) > 0 &&
119 len(token.Scopes) == 2 &&
120 token.Scopes[0] == "push" &&
121 token.Scopes[1] == "pull" &&
122 !token.ExpiresAt.IsZero() &&
123 !token.Revoked
124 }),
125 mock.MatchedBy(func(jwt string) bool {
126 capturedJWT = jwt
127 // JWT should have 3 parts separated by dots
128 parts := 0
129 for _, c := range jwt {
130 if c == '.' {
131 parts++
132 }
133 }
134 return parts == 2 && len(jwt) > 50
135 }),
136 ).
137 Return(nil)
138
139 svc := NewService(Config{
140 Enabled: true,
141 AuthType: domain.AuthTypeToken,
142 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
143 }, tokenStore, zerowrap.Default())
144
145 ctx := testContext()
146 token, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
147
148 assert.NoError(t, err)
149 assert.NotEmpty(t, token)
150 assert.Equal(t, capturedJWT, token)
151 assert.NotNil(t, capturedToken)
152 assert.WithinDuration(t, time.Now().Add(time.Hour), capturedToken.ExpiresAt, 5*time.Second)
153}
154
155func TestService_GenerateToken_NeverExpires(t *testing.T) {
156 tokenStore := mocks.NewMockTokenStore(t)
157
158 tokenStore.EXPECT().
159 SaveToken(
160 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
161 mock.MatchedBy(func(token *domain.Token) bool {
162 return token.Subject == "ci-bot" &&
163 token.ExpiresAt.IsZero() // Never expires
164 }),
165 mock.MatchedBy(func(jwt string) bool {
166 return len(jwt) > 50
167 }),
168 ).
169 Return(nil)
170
171 svc := NewService(Config{
172 Enabled: true,
173 AuthType: domain.AuthTypeToken,
174 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
175 }, tokenStore, zerowrap.Default())
176
177 ctx := testContext()
178 token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0)
179
180 assert.NoError(t, err)
181 assert.NotEmpty(t, token)
182}
183
184func TestService_GenerateAccessToken_NoStorage(t *testing.T) {
185 // Access tokens should NOT be stored - this is the fix for the 401 bug
186 tokenStore := mocks.NewMockTokenStore(t)
187 // No SaveToken expectation - it should NOT be called
188
189 svc := NewService(Config{
190 Enabled: true,
191 AuthType: domain.AuthTypeToken,
192 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
193 }, tokenStore, zerowrap.Default())
194
195 ctx := testContext()
196 token, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:push,pull"}, 5*time.Minute)
197
198 assert.NoError(t, err)
199 assert.NotEmpty(t, token)
200
201 // Verify the token is valid and has correct claims
202 claims, err := svc.ValidateToken(ctx, token)
203 assert.NoError(t, err)
204 assert.Equal(t, "testuser", claims.Subject)
205 assert.Contains(t, claims.Scopes, "repository:myrepo:push,pull")
206 // Access token should have exactly 5 min expiry (allow 2 sec tolerance for test execution)
207 assert.InDelta(t, 300, claims.ExpiresAt-claims.IssuedAt, 2)
208}
209
210func TestService_GenerateAccessToken_RejectsInvalidExpiry(t *testing.T) {
211 tokenStore := mocks.NewMockTokenStore(t)
212
213 svc := NewService(Config{
214 Enabled: true,
215 AuthType: domain.AuthTypeToken,
216 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
217 }, tokenStore, zerowrap.Default())
218
219 ctx := testContext()
220
221 // Zero expiry should be rejected
222 _, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 0)
223 assert.Error(t, err)
224 assert.Contains(t, err.Error(), "expiry must be positive")
225
226 // Negative expiry should be rejected
227 _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, -time.Minute)
228 assert.Error(t, err)
229 assert.Contains(t, err.Error(), "expiry must be positive")
230
231 // Expiry exceeding max should be rejected
232 _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 10*time.Minute)
233 assert.Error(t, err)
234 assert.Contains(t, err.Error(), "exceeds maximum")
235}
236
237func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
238 // Tokens with expiry > 5 minutes must exist in store
239 tokenStore := mocks.NewMockTokenStore(t)
240
241 svc := NewService(Config{
242 Enabled: true,
243 AuthType: domain.AuthTypeToken,
244 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
245 }, tokenStore, zerowrap.Default())
246
247 ctx := testContext()
248
249 // Generate a long-lived token (stored)
250 var capturedToken *domain.Token
251 tokenStore.EXPECT().
252 SaveToken(mock.Anything, mock.Anything, mock.Anything).
253 Run(func(_ context.Context, t *domain.Token, _ string) {
254 capturedToken = t
255 }).
256 Return(nil)
257
258 token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, time.Hour)
259 assert.NoError(t, err)
260
261 // Validation should call GetToken and IsRevoked
262 tokenStore.EXPECT().
263 GetToken(mock.Anything, "testuser").
264 Return(token, capturedToken, nil)
265 tokenStore.EXPECT().
266 IsRevoked(mock.Anything, capturedToken.ID).
267 Return(false, nil)
268
269 claims, err := svc.ValidateToken(ctx, token)
270 assert.NoError(t, err)
271 assert.Equal(t, "testuser", claims.Subject)
272}
273
274func TestService_ValidateToken_NeverExpiringRequiresStore(t *testing.T) {
275 // Tokens with ExpiresAt = 0 (never expires) must exist in store
276 tokenStore := mocks.NewMockTokenStore(t)
277
278 svc := NewService(Config{
279 Enabled: true,
280 AuthType: domain.AuthTypeToken,
281 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
282 }, tokenStore, zerowrap.Default())
283
284 ctx := testContext()
285
286 // Generate a never-expiring token (stored)
287 var capturedToken *domain.Token
288 tokenStore.EXPECT().
289 SaveToken(mock.Anything, mock.Anything, mock.Anything).
290 Run(func(_ context.Context, t *domain.Token, _ string) {
291 capturedToken = t
292 }).
293 Return(nil)
294
295 token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0)
296 assert.NoError(t, err)
297
298 // Validation should call GetToken and IsRevoked (not bypass store)
299 tokenStore.EXPECT().
300 GetToken(mock.Anything, "ci-bot").
301 Return(token, capturedToken, nil)
302 tokenStore.EXPECT().
303 IsRevoked(mock.Anything, capturedToken.ID).
304 Return(false, nil)
305
306 claims, err := svc.ValidateToken(ctx, token)
307 assert.NoError(t, err)
308 assert.Equal(t, "ci-bot", claims.Subject)
309 assert.Equal(t, int64(0), claims.ExpiresAt) // Never expires
310}
311
312func TestService_ValidateToken_Success(t *testing.T) {
313 tokenStore := mocks.NewMockTokenStore(t)
314
315 svc := NewService(Config{
316 Enabled: true,
317 AuthType: domain.AuthTypeToken,
318 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
319 }, tokenStore, zerowrap.Default())
320
321 // Capture the token ID when saving
322 var savedTokenID string
323 tokenStore.EXPECT().
324 SaveToken(
325 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
326 mock.MatchedBy(func(token *domain.Token) bool {
327 savedTokenID = token.ID
328 return token.Subject == "testsubject"
329 }),
330 mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
331 ).
332 Return(nil)
333
334 ctx := testContext()
335 tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
336 require.NoError(t, err)
337
338 // Expect GetToken to be called to verify token exists
339 tokenStore.EXPECT().
340 GetToken(
341 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
342 "testsubject",
343 ).
344 Return(tokenStr, &domain.Token{
345 ID: savedTokenID,
346 Subject: "testsubject",
347 Scopes: []string{"push", "pull"},
348 }, nil)
349
350 // Expect IsRevoked to be called with the same token ID
351 tokenStore.EXPECT().
352 IsRevoked(
353 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
354 mock.MatchedBy(func(tokenID string) bool {
355 return tokenID == savedTokenID
356 }),
357 ).
358 Return(false, nil)
359
360 claims, err := svc.ValidateToken(ctx, tokenStr)
361
362 assert.NoError(t, err)
363 assert.NotNil(t, claims)
364 assert.Equal(t, "testsubject", claims.Subject)
365 assert.Equal(t, savedTokenID, claims.ID)
366 assert.Equal(t, TokenIssuer, claims.Issuer)
367 assert.Contains(t, claims.Scopes, "push")
368 assert.Contains(t, claims.Scopes, "pull")
369}
370
371func TestService_ValidateToken_Revoked(t *testing.T) {
372 tokenStore := mocks.NewMockTokenStore(t)
373
374 svc := NewService(Config{
375 Enabled: true,
376 AuthType: domain.AuthTypeToken,
377 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
378 }, tokenStore, zerowrap.Default())
379
380 var savedTokenID string
381 tokenStore.EXPECT().
382 SaveToken(
383 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
384 mock.MatchedBy(func(token *domain.Token) bool {
385 savedTokenID = token.ID
386 return token.Subject == "testsubject"
387 }),
388 mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
389 ).
390 Return(nil)
391
392 ctx := testContext()
393 tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
394 require.NoError(t, err)
395
396 // Expect GetToken to be called to verify token exists
397 tokenStore.EXPECT().
398 GetToken(
399 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
400 "testsubject",
401 ).
402 Return(tokenStr, &domain.Token{
403 ID: savedTokenID,
404 Subject: "testsubject",
405 Scopes: []string{"push", "pull"},
406 }, nil)
407
408 // Token is revoked
409 tokenStore.EXPECT().
410 IsRevoked(
411 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
412 mock.MatchedBy(func(tokenID string) bool { return tokenID == savedTokenID }),
413 ).
414 Return(true, nil)
415
416 claims, err := svc.ValidateToken(ctx, tokenStr)
417
418 assert.Error(t, err)
419 assert.Nil(t, claims)
420 assert.ErrorIs(t, err, domain.ErrRevokedToken)
421}
422
423func TestService_ValidateToken_InvalidSignature(t *testing.T) {
424 tokenStore := mocks.NewMockTokenStore(t)
425
426 // Service with different secret for validation
427 svc := NewService(Config{
428 Enabled: true,
429 AuthType: domain.AuthTypeToken,
430 TokenSecret: []byte("different-secret-key"),
431 }, tokenStore, zerowrap.Default())
432
433 // Create a token with a different secret
434 otherSvc := NewService(Config{
435 Enabled: true,
436 AuthType: domain.AuthTypeToken,
437 TokenSecret: []byte("original-secret-key"),
438 }, tokenStore, zerowrap.Default())
439
440 tokenStore.EXPECT().
441 SaveToken(
442 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
443 mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
444 mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
445 ).
446 Return(nil)
447
448 ctx := testContext()
449 tokenStr, err := otherSvc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
450 require.NoError(t, err)
451
452 // Try to validate with wrong secret - should fail before checking revocation
453 claims, err := svc.ValidateToken(ctx, tokenStr)
454
455 assert.Error(t, err)
456 assert.Nil(t, claims)
457 assert.ErrorIs(t, err, domain.ErrInvalidToken)
458}
459
460func TestService_ValidateToken_Expired(t *testing.T) {
461 tokenStore := mocks.NewMockTokenStore(t)
462
463 svc := NewService(Config{
464 Enabled: true,
465 AuthType: domain.AuthTypeToken,
466 TokenSecret: []byte("test-secret-key"),
467 }, tokenStore, zerowrap.Default())
468
469 // Generate a token that expires immediately
470 tokenStore.EXPECT().
471 SaveToken(
472 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
473 mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
474 mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
475 ).
476 Return(nil)
477
478 ctx := testContext()
479 // Generate with very short expiry
480 tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Millisecond)
481 require.NoError(t, err)
482
483 // Wait for token to expire
484 time.Sleep(10 * time.Millisecond)
485
486 // JWT library handles expiry during parsing - returns invalid token error
487 // No IsRevoked call is made because parsing fails first
488 claims, err := svc.ValidateToken(ctx, tokenStr)
489
490 assert.Error(t, err)
491 assert.Nil(t, claims)
492 // JWT library returns invalid token for expired tokens during parsing
493 assert.ErrorIs(t, err, domain.ErrInvalidToken)
494}
495
496func TestService_ValidateToken_MalformedToken(t *testing.T) {
497 tokenStore := mocks.NewMockTokenStore(t)
498
499 svc := NewService(Config{
500 Enabled: true,
501 AuthType: domain.AuthTypeToken,
502 TokenSecret: []byte("test-secret-key"),
503 }, tokenStore, zerowrap.Default())
504
505 ctx := testContext()
506
507 // Test with various malformed tokens
508 malformedTokens := []string{
509 "",
510 "not-a-jwt",
511 "only.two.parts.here.extra",
512 "invalid.base64.token",
513 }
514
515 for _, token := range malformedTokens {
516 claims, err := svc.ValidateToken(ctx, token)
517 assert.Error(t, err, "expected error for token: %s", token)
518 assert.Nil(t, claims)
519 assert.ErrorIs(t, err, domain.ErrInvalidToken)
520 }
521}
522
523func TestService_RevokeToken(t *testing.T) {
524 tokenStore := mocks.NewMockTokenStore(t)
525
526 tokenID := "550e8400-e29b-41d4-a716-446655440000"
527
528 tokenStore.EXPECT().
529 Revoke(
530 mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
531 tokenID,
532 ).
533 Return(nil)
534
535 svc := NewService(Config{
536 Enabled: true,
537 AuthType: domain.AuthTypeToken,
538 }, tokenStore, zerowrap.Default())
539
540 ctx := testContext()
541 err := svc.RevokeToken(ctx, tokenID)
542
543 assert.NoError(t, err)
544}
545
546func TestService_RevokeAllTokens(t *testing.T) {
547 t.Run("revokes all active tokens", func(t *testing.T) {
548 tokenStore := mocks.NewMockTokenStore(t)
549
550 tokens := []domain.Token{
551 {ID: "token-1", Subject: "user1", Revoked: false},
552 {ID: "token-2", Subject: "user2", Revoked: false},
553 {ID: "token-3", Subject: "user3", Revoked: true}, // already revoked
554 }
555
556 tokenStore.EXPECT().
557 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
558 Return(tokens, nil)
559
560 // Should only revoke the two non-revoked tokens
561 tokenStore.EXPECT().
562 Revoke(mock.Anything, "token-1").
563 Return(nil)
564 tokenStore.EXPECT().
565 Revoke(mock.Anything, "token-2").
566 Return(nil)
567
568 svc := NewService(Config{
569 Enabled: true,
570 AuthType: domain.AuthTypeToken,
571 }, tokenStore, zerowrap.Default())
572
573 ctx := testContext()
574 count, err := svc.RevokeAllTokens(ctx)
575
576 require.NoError(t, err)
577 assert.Equal(t, 2, count)
578 })
579
580 t.Run("returns zero when no active tokens", func(t *testing.T) {
581 tokenStore := mocks.NewMockTokenStore(t)
582
583 tokens := []domain.Token{
584 {ID: "token-1", Subject: "user1", Revoked: true},
585 }
586
587 tokenStore.EXPECT().
588 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
589 Return(tokens, nil)
590
591 svc := NewService(Config{
592 Enabled: true,
593 AuthType: domain.AuthTypeToken,
594 }, tokenStore, zerowrap.Default())
595
596 ctx := testContext()
597 count, err := svc.RevokeAllTokens(ctx)
598
599 require.NoError(t, err)
600 assert.Equal(t, 0, count)
601 })
602
603 t.Run("returns zero when no tokens exist", func(t *testing.T) {
604 tokenStore := mocks.NewMockTokenStore(t)
605
606 tokenStore.EXPECT().
607 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
608 Return([]domain.Token{}, nil)
609
610 svc := NewService(Config{
611 Enabled: true,
612 AuthType: domain.AuthTypeToken,
613 }, tokenStore, zerowrap.Default())
614
615 ctx := testContext()
616 count, err := svc.RevokeAllTokens(ctx)
617
618 require.NoError(t, err)
619 assert.Equal(t, 0, count)
620 })
621
622 t.Run("returns error when ListTokens fails", func(t *testing.T) {
623 tokenStore := mocks.NewMockTokenStore(t)
624
625 tokenStore.EXPECT().
626 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
627 Return(nil, errors.New("storage unavailable"))
628
629 svc := NewService(Config{
630 Enabled: true,
631 AuthType: domain.AuthTypeToken,
632 }, tokenStore, zerowrap.Default())
633
634 ctx := testContext()
635 count, err := svc.RevokeAllTokens(ctx)
636
637 require.Error(t, err)
638 assert.Contains(t, err.Error(), "failed to list tokens")
639 assert.Equal(t, 0, count)
640 })
641
642 t.Run("continues on partial revoke failure", func(t *testing.T) {
643 tokenStore := mocks.NewMockTokenStore(t)
644
645 tokens := []domain.Token{
646 {ID: "token-1", Subject: "user1", Revoked: false},
647 {ID: "token-2", Subject: "user2", Revoked: false},
648 {ID: "token-3", Subject: "user3", Revoked: false},
649 }
650
651 tokenStore.EXPECT().
652 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
653 Return(tokens, nil)
654
655 // First token succeeds
656 tokenStore.EXPECT().
657 Revoke(mock.Anything, "token-1").
658 Return(nil)
659 // Second token fails
660 tokenStore.EXPECT().
661 Revoke(mock.Anything, "token-2").
662 Return(errors.New("revoke failed"))
663 // Third token succeeds
664 tokenStore.EXPECT().
665 Revoke(mock.Anything, "token-3").
666 Return(nil)
667
668 svc := NewService(Config{
669 Enabled: true,
670 AuthType: domain.AuthTypeToken,
671 }, tokenStore, zerowrap.Default())
672
673 ctx := testContext()
674 count, err := svc.RevokeAllTokens(ctx)
675
676 // Should not return error, but count only successful revokes
677 require.NoError(t, err)
678 assert.Equal(t, 2, count)
679 })
680}
681
682func TestService_ListTokens(t *testing.T) {
683 tokenStore := mocks.NewMockTokenStore(t)
684
685 expectedTokens := []domain.Token{
686 {
687 ID: "token-1",
688 Subject: "user1",
689 Scopes: []string{"push", "pull"},
690 IssuedAt: time.Now().Add(-24 * time.Hour),
691 ExpiresAt: time.Now().Add(24 * time.Hour),
692 Revoked: false,
693 },
694 {
695 ID: "token-2",
696 Subject: "ci-bot",
697 Scopes: []string{"push", "pull"},
698 IssuedAt: time.Now().Add(-48 * time.Hour),
699 ExpiresAt: time.Time{}, // Never expires
700 Revoked: false,
701 },
702 }
703
704 tokenStore.EXPECT().
705 ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
706 Return(expectedTokens, nil)
707
708 svc := NewService(Config{
709 Enabled: true,
710 AuthType: domain.AuthTypeToken,
711 }, tokenStore, zerowrap.Default())
712
713 ctx := testContext()
714 tokens, err := svc.ListTokens(ctx)
715
716 assert.NoError(t, err)
717 assert.Len(t, tokens, 2)
718 assert.Equal(t, "user1", tokens[0].Subject)
719 assert.Equal(t, "ci-bot", tokens[1].Subject)
720 assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire")
721}
722
723func TestService_GeneratePasswordHash(t *testing.T) {
724 svc := NewService(Config{}, nil, zerowrap.Default())
725
726 password := "securePassword123!"
727 hash, err := svc.GeneratePasswordHash(password)
728
729 assert.NoError(t, err)
730 assert.NotEmpty(t, hash)
731 assert.NotEqual(t, password, hash, "hash should not equal plain password")
732
733 // Verify the hash works with bcrypt
734 err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
735 assert.NoError(t, err)
736
737 // Verify wrong password fails
738 err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword"))
739 assert.Error(t, err)
740}
741
742func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) {
743 svc := NewService(Config{}, nil, zerowrap.Default())
744
745 password := "testpassword"
746 hash1, err := svc.GeneratePasswordHash(password)
747 require.NoError(t, err)
748
749 hash2, err := svc.GeneratePasswordHash(password)
750 require.NoError(t, err)
751
752 // Bcrypt should generate different hashes each time due to salt
753 assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes")
754
755 // But both should validate correctly
756 assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)))
757 assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)))
758}
759
760func TestService_GenerateToken_HasNbfClaim(t *testing.T) {
761 // Test that generated tokens include the nbf (not-before) claim
762 // This is a security requirement per SEC-MED-006
763 tokenStore := mocks.NewMockTokenStore(t)
764
765 tokenStore.EXPECT().
766 SaveToken(mock.Anything, mock.Anything, mock.Anything).
767 Return(nil)
768
769 svc := NewService(Config{
770 Enabled: true,
771 AuthType: domain.AuthTypeToken,
772 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
773 }, tokenStore, zerowrap.Default())
774
775 ctx := testContext()
776 tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Hour)
777 require.NoError(t, err)
778
779 // Parse the token to verify nbf claim
780 claims, err := svc.parseTokenClaims(tokenStr)
781 require.NoError(t, err)
782
783 // Verify nbf claim exists and is valid
784 nbf, ok := claims["nbf"].(float64)
785 require.True(t, ok, "nbf claim should exist and be a number")
786
787 iat, ok := claims["iat"].(float64)
788 require.True(t, ok, "iat claim should exist")
789
790 // nbf should equal iat for immediate validity
791 assert.Equal(t, iat, nbf, "nbf should equal iat")
792 assert.InDelta(t, time.Now().Unix(), int64(nbf), 5, "nbf should be current time")
793}
794
795func TestService_GenerateAccessToken_HasNbfClaim(t *testing.T) {
796 // Test that access tokens also include the nbf claim
797 svc := NewService(Config{
798 Enabled: true,
799 AuthType: domain.AuthTypeToken,
800 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
801 }, nil, zerowrap.Default())
802
803 ctx := testContext()
804 tokenStr, err := svc.GenerateAccessToken(ctx, "testsubject", []string{"pull"}, time.Minute)
805 require.NoError(t, err)
806
807 // Parse the token to verify nbf claim
808 claims, err := svc.parseTokenClaims(tokenStr)
809 require.NoError(t, err)
810
811 // Verify nbf claim exists
812 nbf, ok := claims["nbf"].(float64)
813 require.True(t, ok, "nbf claim should exist and be a number")
814
815 iat, ok := claims["iat"].(float64)
816 require.True(t, ok, "iat claim should exist")
817
818 assert.Equal(t, iat, nbf, "nbf should equal iat for access tokens")
819}
820
821func TestExtendTokenSlidesExpiry(t *testing.T) {
822 tokenStore := mocks.NewMockTokenStore(t)
823
824 svc := NewService(Config{
825 Enabled: true,
826 AuthType: domain.AuthTypeToken,
827 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
828 }, tokenStore, zerowrap.Default())
829
830 ctx := testContext()
831
832 // Generate a token with a short remaining life (1h) so ExtendToken produces a different exp
833 var capturedToken *domain.Token
834 tokenStore.EXPECT().
835 SaveToken(mock.Anything, mock.Anything, mock.Anything).
836 Run(func(_ context.Context, t *domain.Token, _ string) {
837 capturedToken = t
838 }).
839 Return(nil)
840
841 tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)
842 require.NoError(t, err)
843
844 // ExtendToken flow:
845 // 1. ValidateToken → GetToken (ensureTokenExists) + IsRevoked
846 tokenStore.EXPECT().
847 GetToken(mock.Anything, "testuser").
848 Return(tokenStr, capturedToken, nil).Once()
849 tokenStore.EXPECT().
850 IsRevoked(mock.Anything, capturedToken.ID).
851 Return(false, nil).Once()
852
853 // 2. GetToken again for debounce check
854 tokenStore.EXPECT().
855 GetToken(mock.Anything, "testuser").
856 Return(tokenStr, capturedToken, nil).Once()
857
858 // 3. UpdateTokenExpiry
859 var updatedToken *domain.Token
860 tokenStore.EXPECT().
861 UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything).
862 Run(func(_ context.Context, t *domain.Token, _ string) {
863 updatedToken = t
864 }).
865 Return(nil)
866
867 // Extend it
868 newTokenStr, err := svc.ExtendToken(ctx, tokenStr)
869 require.NoError(t, err, "ExtendToken should not fail")
870
871 // Since the original token expires in 1h and the new one expires in 24h, they must differ
872 assert.NotEqual(t, tokenStr, newTokenStr, "expected a new token string after extension")
873 assert.NotNil(t, updatedToken)
874 assert.False(t, updatedToken.LastExtendedAt.IsZero(), "LastExtendedAt should be set")
875
876 // New token must be valid — set up mock expectations for ValidateToken
877 tokenStore.EXPECT().
878 GetToken(mock.Anything, "testuser").
879 Return(newTokenStr, updatedToken, nil)
880 tokenStore.EXPECT().
881 IsRevoked(mock.Anything, capturedToken.ID).
882 Return(false, nil)
883
884 claims, err := svc.ValidateToken(ctx, newTokenStr)
885 require.NoError(t, err, "new token must be valid")
886
887 // New token should have ~24h expiry from now
888 expectedExpiry := time.Now().Add(24 * time.Hour)
889 actualExpiry := time.Unix(claims.ExpiresAt, 0)
890 assert.WithinDuration(t, expectedExpiry, actualExpiry, 5*time.Minute, "expiry should be ~24h from now")
891}
892
893func TestExtendTokenDebounce(t *testing.T) {
894 tokenStore := mocks.NewMockTokenStore(t)
895
896 svc := NewService(Config{
897 Enabled: true,
898 AuthType: domain.AuthTypeToken,
899 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
900 }, tokenStore, zerowrap.Default())
901
902 ctx := testContext()
903
904 // Generate a short-lived token (1h) so the first extension produces a different JWT
905 var capturedToken *domain.Token
906 tokenStore.EXPECT().
907 SaveToken(mock.Anything, mock.Anything, mock.Anything).
908 Run(func(_ context.Context, t *domain.Token, _ string) {
909 capturedToken = t
910 }).
911 Return(nil)
912
913 tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)
914 require.NoError(t, err)
915
916 // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry
917 tokenStore.EXPECT().
918 GetToken(mock.Anything, "testuser").
919 Return(tokenStr, capturedToken, nil).Once()
920 tokenStore.EXPECT().
921 IsRevoked(mock.Anything, capturedToken.ID).
922 Return(false, nil).Once()
923 tokenStore.EXPECT().
924 GetToken(mock.Anything, "testuser").
925 Return(tokenStr, capturedToken, nil).Once()
926
927 var extendedToken *domain.Token
928 var extendedJWT string
929 tokenStore.EXPECT().
930 UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything).
931 Run(func(_ context.Context, t *domain.Token, jwt string) {
932 extendedToken = t
933 extendedJWT = jwt
934 }).
935 Return(nil).Once()
936
937 newToken1, err := svc.ExtendToken(ctx, tokenStr)
938 require.NoError(t, err)
939 assert.NotEqual(t, tokenStr, newToken1, "first extend should produce a new token")
940 assert.NotNil(t, extendedToken)
941
942 // Second extend immediately — debounce should kick in (LastExtendedAt just set to now)
943 // Flow: ValidateToken(GetToken+IsRevoked) + GetToken(debounce) → debounced, no UpdateTokenExpiry
944 tokenStore.EXPECT().
945 GetToken(mock.Anything, "testuser").
946 Return(extendedJWT, extendedToken, nil).Once()
947 tokenStore.EXPECT().
948 IsRevoked(mock.Anything, capturedToken.ID).
949 Return(false, nil).Once()
950 tokenStore.EXPECT().
951 GetToken(mock.Anything, "testuser").
952 Return(extendedJWT, extendedToken, nil).Once()
953
954 // No UpdateTokenExpiry should be called — debounced
955 newToken2, err := svc.ExtendToken(ctx, newToken1)
956 require.NoError(t, err)
957
958 assert.Equal(t, newToken1, newToken2, "expected debounce to return same token within 1h window")
959}
960
961func TestExtendTokenSkipsEphemeral(t *testing.T) {
962 svc := NewService(Config{
963 Enabled: true,
964 AuthType: domain.AuthTypeToken,
965 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
966 }, nil, zerowrap.Default())
967
968 ctx := testContext()
969
970 // Generate an ephemeral access token (≤5min)
971 tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute)
972 require.NoError(t, err)
973
974 // ExtendToken should skip ephemeral tokens and return the same string
975 result, err := svc.ExtendToken(ctx, tokenStr)
976 require.NoError(t, err)
977 assert.Equal(t, tokenStr, result, "ephemeral tokens should not be extended")
978}
979
980func newTestAuthService(t *testing.T) (*Service, *mocks.MockTokenStore) {
981 t.Helper()
982 tokenStore := mocks.NewMockTokenStore(t)
983 svc := NewService(Config{
984 Enabled: true,
985 AuthType: domain.AuthTypeToken,
986 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
987 }, tokenStore, zerowrap.Default())
988 return svc, tokenStore
989}
990
991func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) {
992 // A token with iat in the future should NOT be treated as ephemeral
993 // (otherwise it bypasses revocation checks)
994 svc, _ := newTestAuthService(t)
995
996 // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < 5min).
997 // nbf is set to current time (not future) so the JWT library's nbf check does not reject it —
998 // we need to isolate the iat bypass specifically.
999 now := time.Now().UTC()
1000 claims := jwt.MapClaims{
1001 "jti": "test-jti",
1002 "sub": "testuser",
1003 "iss": TokenIssuer,
1004 "iat": now.Add(10 * time.Minute).Unix(), // future iat — the attack vector
1005 "nbf": now.Add(-1 * time.Second).Unix(), // current nbf so nbf check passes
1006 "exp": now.Add(15 * time.Minute).Unix(), // exp - iat = 5min → looks ephemeral
1007 "scopes": []string{"admin:*:*"},
1008 }
1009 token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
1010 tokenStr, err := token.SignedString(svc.config.TokenSecret)
1011 if err != nil {
1012 t.Fatal(err)
1013 }
1014
1015 // Parsing should either reject the token outright (future iat) or not classify it as ephemeral
1016 rawClaims, err := svc.parseTokenClaims(tokenStr)
1017 if err != nil {
1018 // Acceptable: future iat rejected at parse time
1019 t.Logf("token with future iat rejected at parse: %v", err)
1020 return
1021 }
1022 tokenClaims := buildTokenClaims(rawClaims)
1023 if svc.isEphemeralAccessToken(tokenClaims) {
1024 t.Error("token with future iat must NOT be classified as ephemeral — it bypasses revocation")
1025 }
1026}
1027
1028func TestExtendTokenSkipsServiceToken(t *testing.T) {
1029 tokenStore := mocks.NewMockTokenStore(t)
1030
1031 svc := NewService(Config{
1032 Enabled: true,
1033 AuthType: domain.AuthTypeToken,
1034 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
1035 }, tokenStore, zerowrap.Default())
1036
1037 ctx := testContext()
1038
1039 // Generate a service token — service tokens skip extension before store checks
1040 tokenStore.EXPECT().
1041 SaveToken(mock.Anything, mock.Anything, mock.Anything).
1042 Return(nil)
1043
1044 const svcSubject = "gordon-service"
1045 tokenStr, err := svc.GenerateToken(ctx, svcSubject, []string{"pull"}, 24*time.Hour)
1046 require.NoError(t, err)
1047
1048 // ExtendToken should skip service tokens WITHOUT touching the store
1049 result, err := svc.ExtendToken(ctx, tokenStr)
1050 require.NoError(t, err)
1051 assert.Equal(t, tokenStr, result, "service tokens should not be extended")
1052}