v3.4.0 593c1c3

service_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close