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 assert.Equal(t, "stored", claims.TokenType)
806}
807
808func TestExtendTokenDebounce(t *testing.T) {
809 tokenStore := mocks.NewMockTokenStore(t)
810
811 svc := NewService(Config{
812 Enabled: true,
813 AuthType: domain.AuthTypeToken,
814 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
815 }, tokenStore, zerowrap.Default())
816
817 ctx := testContext()
818
819 // Generate a short-lived token (MaxAccessTokenLifetime+1m) so the first extension produces a different JWT
820 var capturedToken *domain.Token
821 tokenStore.EXPECT().
822 SaveToken(mock.Anything, mock.Anything, mock.Anything).
823 Run(func(_ context.Context, t *domain.Token, _ string) {
824 capturedToken = t
825 }).
826 Return(nil)
827
828 tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, MaxAccessTokenLifetime+time.Minute)
829 require.NoError(t, err)
830
831 // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry
832 tokenStore.EXPECT().
833 GetToken(mock.Anything, "testuser").
834 Return(tokenStr, capturedToken, nil).Once()
835 tokenStore.EXPECT().
836 IsRevoked(mock.Anything, capturedToken.ID).
837 Return(false, nil).Once()
838 tokenStore.EXPECT().
839 GetToken(mock.Anything, "testuser").
840 Return(tokenStr, capturedToken, nil).Once()
841
842 var extendedToken *domain.Token
843 var extendedJWT string
844 tokenStore.EXPECT().
845 UpdateTokenExpiry(mock.Anything, mock.Anything, mock.Anything).
846 Run(func(_ context.Context, t *domain.Token, jwt string) {
847 extendedToken = t
848 extendedJWT = jwt
849 }).
850 Return(nil).Once()
851
852 newToken1, err := svc.ExtendToken(ctx, tokenStr)
853 require.NoError(t, err)
854 assert.NotEqual(t, tokenStr, newToken1, "first extend should produce a new token")
855 assert.NotNil(t, extendedToken)
856
857 // Second extend immediately — debounce should kick in (LastExtendedAt just set to now)
858 // Flow: ValidateToken(GetToken+IsRevoked) + GetToken(debounce) → debounced, no UpdateTokenExpiry
859 tokenStore.EXPECT().
860 GetToken(mock.Anything, "testuser").
861 Return(extendedJWT, extendedToken, nil).Once()
862 tokenStore.EXPECT().
863 IsRevoked(mock.Anything, capturedToken.ID).
864 Return(false, nil).Once()
865 tokenStore.EXPECT().
866 GetToken(mock.Anything, "testuser").
867 Return(extendedJWT, extendedToken, nil).Once()
868
869 // No UpdateTokenExpiry should be called — debounced
870 newToken2, err := svc.ExtendToken(ctx, newToken1)
871 require.NoError(t, err)
872
873 assert.Equal(t, newToken1, newToken2, "expected debounce to return same token within 1h window")
874}
875
876func TestExtendTokenSkipsEphemeral(t *testing.T) {
877 svc := NewService(Config{
878 Enabled: true,
879 AuthType: domain.AuthTypeToken,
880 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
881 }, nil, zerowrap.Default())
882
883 ctx := testContext()
884
885 // Generate an ephemeral access token (≤MaxAccessTokenLifetime)
886 tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"pull"}, 5*time.Minute)
887 require.NoError(t, err)
888
889 // ExtendToken should skip ephemeral tokens and return the same string
890 result, err := svc.ExtendToken(ctx, tokenStr)
891 require.NoError(t, err)
892 assert.Equal(t, tokenStr, result, "ephemeral tokens should not be extended")
893}
894
895func newTestAuthService(t *testing.T) (*Service, *mocks.MockTokenStore) {
896 t.Helper()
897 tokenStore := mocks.NewMockTokenStore(t)
898 svc := NewService(Config{
899 Enabled: true,
900 AuthType: domain.AuthTypeToken,
901 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
902 }, tokenStore, zerowrap.Default())
903 return svc, tokenStore
904}
905
906func TestIsEphemeralAccessTokenRejectsFutureIat(t *testing.T) {
907 // A token with iat in the future should NOT be treated as ephemeral
908 // (otherwise it bypasses revocation checks)
909 svc, _ := newTestAuthService(t)
910
911 // Craft a token with iat = now+10min, exp = now+15min (age appears as -10min → negative → < MaxAccessTokenLifetime).
912 // nbf is set to current time (not future) so the JWT library's nbf check does not reject it —
913 // we need to isolate the iat bypass specifically.
914 now := time.Now().UTC()
915 claims := jwt.MapClaims{
916 "jti": "test-jti",
917 "sub": "testuser",
918 "iss": TokenIssuer,
919 "iat": now.Add(10 * time.Minute).Unix(), // future iat — the attack vector
920 "nbf": now.Add(-1 * time.Second).Unix(), // current nbf so nbf check passes
921 "exp": now.Add(15 * time.Minute).Unix(), // exp - iat = 5min → looks ephemeral
922 "scopes": []string{"admin:*:*"},
923 }
924 token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
925 tokenStr, err := token.SignedString(svc.config.TokenSecret)
926 if err != nil {
927 t.Fatal(err)
928 }
929
930 // Parsing should either reject the token outright (future iat) or not classify it as ephemeral
931 rawClaims, err := svc.parseTokenClaims(tokenStr)
932 if err != nil {
933 // Acceptable: future iat rejected at parse time
934 t.Logf("token with future iat rejected at parse: %v", err)
935 return
936 }
937 tokenClaims := buildTokenClaims(rawClaims)
938 if svc.isEphemeralAccessToken(tokenClaims) {
939 t.Error("token with future iat must NOT be classified as ephemeral — it bypasses revocation")
940 }
941}
942
943func TestIsEphemeralAccessTokenRequiresExplicitTokenType(t *testing.T) {
944 svc, _ := newTestAuthService(t)
945 now := time.Now().UTC()
946
947 claims := &domain.TokenClaims{
948 IssuedAt: now.Unix(),
949 ExpiresAt: now.Add(5 * time.Minute).Unix(),
950 }
951
952 assert.False(t, svc.isEphemeralAccessToken(claims))
953 claims.TokenType = "access"
954 assert.True(t, svc.isEphemeralAccessToken(claims))
955}
956
957func TestExtendTokenSkipsServiceToken(t *testing.T) {
958 tokenStore := mocks.NewMockTokenStore(t)
959
960 svc := NewService(Config{
961 Enabled: true,
962 AuthType: domain.AuthTypeToken,
963 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
964 }, tokenStore, zerowrap.Default())
965
966 ctx := testContext()
967
968 // Generate a service token — service tokens skip extension before store checks
969 tokenStore.EXPECT().
970 SaveToken(mock.Anything, mock.Anything, mock.Anything).
971 Return(nil)
972
973 const svcSubject = "gordon-service"
974 tokenStr, err := svc.GenerateToken(ctx, svcSubject, []string{"pull"}, 24*time.Hour)
975 require.NoError(t, err)
976
977 // ExtendToken should skip service tokens WITHOUT touching the store
978 result, err := svc.ExtendToken(ctx, tokenStr)
979 require.NoError(t, err)
980 assert.Equal(t, tokenStr, result, "service tokens should not be extended")
981}
982
983func TestValidateToken_IsEphemeral_EphemeralToken(t *testing.T) {
984 // Ephemeral access tokens (generated by GenerateAccessToken) should have IsEphemeral = true
985 svc := NewService(Config{
986 Enabled: true,
987 AuthType: domain.AuthTypeToken,
988 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
989 }, nil, zerowrap.Default())
990
991 ctx := testContext()
992
993 tokenStr, err := svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 5*time.Minute)
994 require.NoError(t, err)
995
996 claims, err := svc.ValidateToken(ctx, tokenStr)
997 require.NoError(t, err)
998 assert.True(t, claims.IsEphemeral, "ephemeral access token should have IsEphemeral = true")
999}
1000
1001func TestValidateToken_IsEphemeral_StoredToken(t *testing.T) {
1002 // Long-lived stored tokens should have IsEphemeral = false
1003 tokenStore := mocks.NewMockTokenStore(t)
1004
1005 svc := NewService(Config{
1006 Enabled: true,
1007 AuthType: domain.AuthTypeToken,
1008 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
1009 }, tokenStore, zerowrap.Default())
1010
1011 ctx := testContext()
1012
1013 var capturedToken *domain.Token
1014 tokenStore.EXPECT().
1015 SaveToken(mock.Anything, mock.Anything, mock.Anything).
1016 Run(func(_ context.Context, t *domain.Token, _ string) {
1017 capturedToken = t
1018 }).
1019 Return(nil)
1020
1021 tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, MaxAccessTokenLifetime+time.Minute)
1022 require.NoError(t, err)
1023
1024 tokenStore.EXPECT().
1025 GetToken(mock.Anything, "testuser").
1026 Return(tokenStr, capturedToken, nil)
1027 tokenStore.EXPECT().
1028 IsRevoked(mock.Anything, capturedToken.ID).
1029 Return(false, nil)
1030
1031 claims, err := svc.ValidateToken(ctx, tokenStr)
1032 require.NoError(t, err)
1033 assert.False(t, claims.IsEphemeral, "stored token should have IsEphemeral = false")
1034}
1035
1036func TestValidateToken_IsEphemeral_NeverExpiringToken(t *testing.T) {
1037 // Never-expiring tokens must not be ephemeral
1038 tokenStore := mocks.NewMockTokenStore(t)
1039
1040 svc := NewService(Config{
1041 Enabled: true,
1042 AuthType: domain.AuthTypeToken,
1043 TokenSecret: []byte("test-secret-key-for-jwt-signing"),
1044 }, tokenStore, zerowrap.Default())
1045
1046 ctx := testContext()
1047
1048 var capturedToken *domain.Token
1049 tokenStore.EXPECT().
1050 SaveToken(mock.Anything, mock.Anything, mock.Anything).
1051 Run(func(_ context.Context, t *domain.Token, _ string) {
1052 capturedToken = t
1053 }).
1054 Return(nil)
1055
1056 tokenStr, err := svc.GenerateToken(ctx, "ci-bot", []string{"push"}, 0)
1057 require.NoError(t, err)
1058
1059 tokenStore.EXPECT().
1060 GetToken(mock.Anything, "ci-bot").
1061 Return(tokenStr, capturedToken, nil)
1062 tokenStore.EXPECT().
1063 IsRevoked(mock.Anything, capturedToken.ID).
1064 Return(false, nil)
1065
1066 claims, err := svc.ValidateToken(ctx, tokenStr)
1067 require.NoError(t, err)
1068 assert.False(t, claims.IsEphemeral, "never-expiring token should have IsEphemeral = false")
1069}