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