v2.31.0-dev16 8d0a092

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}
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}

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