detached ccd5bf8

feat(usecase/auth): implement authentication service with password and token support

Add auth service implementation:
- Password validation with bcrypt
- JWT token generation and validation (HS256)
- Token revocation checking
- Support for never-expiring tokens (expiry=0)
- Comprehensive test coverage
commit
ccd5bf87ca843e853c194851f85719478d35846e
author
brice · · 2026-01-12 18:55 UTC
committer
brice · · 2026-01-12 18:21 UTC
parent
e858e17
signature
unsigned
2 files · +743 -0
A internal/usecase/auth/service.go +275 -0
@@ -0,0 +1,275 @@
1+// Package auth implements the authentication use case for the registry.
2+package auth
3+
4+import (
5+ "context"
6+ "crypto/subtle"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/golang-jwt/jwt/v5"
11+ "github.com/google/uuid"
12+ "golang.org/x/crypto/bcrypt"
13+
14+ "gordon/internal/boundaries/out"
15+ "gordon/internal/domain"
16+)
17+
18+const (
19+ // TokenIssuer is the issuer claim for generated tokens.
20+ TokenIssuer = "gordon-registry"
21+ // DefaultBcryptCost is the default cost for bcrypt hashing.
22+ DefaultBcryptCost = 12
23+)
24+
25+// Config holds the authentication configuration.
26+type Config struct {
27+ Enabled bool
28+ AuthType domain.AuthType
29+ Username string
30+ PasswordHash string // bcrypt hash for password auth
31+ TokenSecret []byte // signing secret for token auth
32+ TokenExpiry time.Duration // default token expiry (0 = never)
33+}
34+
35+// Service implements the AuthService interface.
36+type Service struct {
37+ config Config
38+ tokenStore out.TokenStore
39+ log zerowrap.Logger
40+}
41+
42+// NewService creates a new auth service.
43+func NewService(config Config, tokenStore out.TokenStore, log zerowrap.Logger) *Service {
44+ return &Service{
45+ config: config,
46+ tokenStore: tokenStore,
47+ log: log,
48+ }
49+}
50+
51+// GetAuthType returns the configured authentication type.
52+func (s *Service) GetAuthType() domain.AuthType {
53+ return s.config.AuthType
54+}
55+
56+// IsEnabled returns whether authentication is enabled.
57+func (s *Service) IsEnabled() bool {
58+ return s.config.Enabled
59+}
60+
61+// ValidatePassword checks if the username and password are valid.
62+func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool {
63+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
64+ zerowrap.FieldLayer: "usecase",
65+ zerowrap.FieldUseCase: "ValidatePassword",
66+ "username": username,
67+ })
68+ log := zerowrap.FromCtx(ctx)
69+
70+ // Constant-time username comparison
71+ usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1
72+
73+ // Bcrypt comparison (already constant-time)
74+ err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password))
75+ passwordMatch := err == nil
76+
77+ if !usernameMatch || !passwordMatch {
78+ log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed")
79+ return false
80+ }
81+
82+ log.Debug().Msg("password validation successful")
83+ return true
84+}
85+
86+// ValidateToken validates a JWT token and returns its claims.
87+func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
88+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
89+ zerowrap.FieldLayer: "usecase",
90+ zerowrap.FieldUseCase: "ValidateToken",
91+ })
92+ log := zerowrap.FromCtx(ctx)
93+
94+ // Parse the token
95+ token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
96+ // Validate signing method
97+ if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
98+ return nil, domain.ErrInvalidToken
99+ }
100+ return s.config.TokenSecret, nil
101+ })
102+ if err != nil {
103+ log.Debug().Err(err).Msg("failed to parse token")
104+ return nil, domain.ErrInvalidToken
105+ }
106+
107+ if !token.Valid {
108+ log.Debug().Msg("token is invalid")
109+ return nil, domain.ErrInvalidToken
110+ }
111+
112+ // Extract claims
113+ claims, ok := token.Claims.(jwt.MapClaims)
114+ if !ok {
115+ log.Debug().Msg("failed to extract claims")
116+ return nil, domain.ErrInvalidToken
117+ }
118+
119+ // Build TokenClaims from JWT claims
120+ tokenClaims := &domain.TokenClaims{
121+ Issuer: getStringClaim(claims, "iss"),
122+ }
123+
124+ // Get standard claims
125+ if jti, ok := claims["jti"].(string); ok {
126+ tokenClaims.ID = jti
127+ }
128+ if sub, ok := claims["sub"].(string); ok {
129+ tokenClaims.Subject = sub
130+ }
131+ if iat, ok := claims["iat"].(float64); ok {
132+ tokenClaims.IssuedAt = int64(iat)
133+ }
134+ if exp, ok := claims["exp"].(float64); ok {
135+ tokenClaims.ExpiresAt = int64(exp)
136+ }
137+ if scopes, ok := claims["scopes"].([]any); ok {
138+ for _, scope := range scopes {
139+ if s, ok := scope.(string); ok {
140+ tokenClaims.Scopes = append(tokenClaims.Scopes, s)
141+ }
142+ }
143+ }
144+
145+ // Check if token is revoked
146+ revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID)
147+ if err != nil {
148+ log.Warn().Err(err).Msg("failed to check token revocation")
149+ // Continue anyway - revocation check is best effort
150+ }
151+ if revoked {
152+ log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked")
153+ return nil, domain.ErrRevokedToken
154+ }
155+
156+ // Check expiry (if set)
157+ if tokenClaims.ExpiresAt > 0 {
158+ if time.Now().Unix() > tokenClaims.ExpiresAt {
159+ log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired")
160+ return nil, domain.ErrExpiredToken
161+ }
162+ }
163+
164+ log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful")
165+ return tokenClaims, nil
166+}
167+
168+// GenerateToken creates a new JWT token for the given subject.
169+func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
170+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
171+ zerowrap.FieldLayer: "usecase",
172+ zerowrap.FieldUseCase: "GenerateToken",
173+ "subject": subject,
174+ })
175+ log := zerowrap.FromCtx(ctx)
176+
177+ tokenID := uuid.New().String()
178+ now := time.Now()
179+
180+ // Build token
181+ token := &domain.Token{
182+ ID: tokenID,
183+ Subject: subject,
184+ Scopes: scopes,
185+ IssuedAt: now,
186+ Revoked: false,
187+ }
188+
189+ // Build JWT claims
190+ claims := jwt.MapClaims{
191+ "jti": tokenID,
192+ "sub": subject,
193+ "iss": TokenIssuer,
194+ "iat": now.Unix(),
195+ "scopes": scopes,
196+ }
197+
198+ // Set expiry if specified
199+ if expiry > 0 {
200+ expiresAt := now.Add(expiry)
201+ token.ExpiresAt = expiresAt
202+ claims["exp"] = expiresAt.Unix()
203+ }
204+ // If expiry is 0, token never expires (no exp claim)
205+
206+ // Sign token
207+ jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
208+ tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
209+ if err != nil {
210+ return "", log.WrapErr(err, "failed to sign token")
211+ }
212+
213+ // Store token
214+ if err := s.tokenStore.SaveToken(ctx, token, tokenString); err != nil {
215+ return "", log.WrapErr(err, "failed to save token")
216+ }
217+
218+ log.Info().
219+ Str("token_id", tokenID).
220+ Str("subject", subject).
221+ Time("expires_at", token.ExpiresAt).
222+ Msg("token generated")
223+
224+ return tokenString, nil
225+}
226+
227+// RevokeToken revokes a token by its ID.
228+func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
229+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
230+ zerowrap.FieldLayer: "usecase",
231+ zerowrap.FieldUseCase: "RevokeToken",
232+ "token_id": tokenID,
233+ })
234+ log := zerowrap.FromCtx(ctx)
235+
236+ if err := s.tokenStore.Revoke(ctx, tokenID); err != nil {
237+ return log.WrapErr(err, "failed to revoke token")
238+ }
239+
240+ log.Info().Msg("token revoked")
241+ return nil
242+}
243+
244+// ListTokens returns all stored tokens.
245+func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
246+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
247+ zerowrap.FieldLayer: "usecase",
248+ zerowrap.FieldUseCase: "ListTokens",
249+ })
250+ log := zerowrap.FromCtx(ctx)
251+
252+ tokens, err := s.tokenStore.ListTokens(ctx)
253+ if err != nil {
254+ return nil, log.WrapErr(err, "failed to list tokens")
255+ }
256+
257+ return tokens, nil
258+}
259+
260+// GeneratePasswordHash generates a bcrypt hash for a password.
261+func (s *Service) GeneratePasswordHash(password string) (string, error) {
262+ hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
263+ if err != nil {
264+ return "", err
265+ }
266+ return string(hash), nil
267+}
268+
269+// getStringClaim safely extracts a string claim from JWT claims.
270+func getStringClaim(claims jwt.MapClaims, key string) string {
271+ if val, ok := claims[key].(string); ok {
272+ return val
273+ }
274+ return ""
275+}
@@ -0,0 +1,275 @@
1+// Package auth implements the authentication use case for the registry.
2+package auth
3+
4+import (
5+ "context"
6+ "crypto/subtle"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/golang-jwt/jwt/v5"
11+ "github.com/google/uuid"
12+ "golang.org/x/crypto/bcrypt"
13+
14+ "gordon/internal/boundaries/out"
15+ "gordon/internal/domain"
16+)
17+
18+const (
19+ // TokenIssuer is the issuer claim for generated tokens.
20+ TokenIssuer = "gordon-registry"
21+ // DefaultBcryptCost is the default cost for bcrypt hashing.
22+ DefaultBcryptCost = 12
23+)
24+
25+// Config holds the authentication configuration.
26+type Config struct {
27+ Enabled bool
28+ AuthType domain.AuthType
29+ Username string
30+ PasswordHash string // bcrypt hash for password auth
31+ TokenSecret []byte // signing secret for token auth
32+ TokenExpiry time.Duration // default token expiry (0 = never)
33+}
34+
35+// Service implements the AuthService interface.
36+type Service struct {
37+ config Config
38+ tokenStore out.TokenStore
39+ log zerowrap.Logger
40+}
41+
42+// NewService creates a new auth service.
43+func NewService(config Config, tokenStore out.TokenStore, log zerowrap.Logger) *Service {
44+ return &Service{
45+ config: config,
46+ tokenStore: tokenStore,
47+ log: log,
48+ }
49+}
50+
51+// GetAuthType returns the configured authentication type.
52+func (s *Service) GetAuthType() domain.AuthType {
53+ return s.config.AuthType
54+}
55+
56+// IsEnabled returns whether authentication is enabled.
57+func (s *Service) IsEnabled() bool {
58+ return s.config.Enabled
59+}
60+
61+// ValidatePassword checks if the username and password are valid.
62+func (s *Service) ValidatePassword(ctx context.Context, username, password string) bool {
63+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
64+ zerowrap.FieldLayer: "usecase",
65+ zerowrap.FieldUseCase: "ValidatePassword",
66+ "username": username,
67+ })
68+ log := zerowrap.FromCtx(ctx)
69+
70+ // Constant-time username comparison
71+ usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(s.config.Username)) == 1
72+
73+ // Bcrypt comparison (already constant-time)
74+ err := bcrypt.CompareHashAndPassword([]byte(s.config.PasswordHash), []byte(password))
75+ passwordMatch := err == nil
76+
77+ if !usernameMatch || !passwordMatch {
78+ log.Debug().Bool("username_match", usernameMatch).Msg("password validation failed")
79+ return false
80+ }
81+
82+ log.Debug().Msg("password validation successful")
83+ return true
84+}
85+
86+// ValidateToken validates a JWT token and returns its claims.
87+func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
88+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
89+ zerowrap.FieldLayer: "usecase",
90+ zerowrap.FieldUseCase: "ValidateToken",
91+ })
92+ log := zerowrap.FromCtx(ctx)
93+
94+ // Parse the token
95+ token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
96+ // Validate signing method
97+ if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
98+ return nil, domain.ErrInvalidToken
99+ }
100+ return s.config.TokenSecret, nil
101+ })
102+ if err != nil {
103+ log.Debug().Err(err).Msg("failed to parse token")
104+ return nil, domain.ErrInvalidToken
105+ }
106+
107+ if !token.Valid {
108+ log.Debug().Msg("token is invalid")
109+ return nil, domain.ErrInvalidToken
110+ }
111+
112+ // Extract claims
113+ claims, ok := token.Claims.(jwt.MapClaims)
114+ if !ok {
115+ log.Debug().Msg("failed to extract claims")
116+ return nil, domain.ErrInvalidToken
117+ }
118+
119+ // Build TokenClaims from JWT claims
120+ tokenClaims := &domain.TokenClaims{
121+ Issuer: getStringClaim(claims, "iss"),
122+ }
123+
124+ // Get standard claims
125+ if jti, ok := claims["jti"].(string); ok {
126+ tokenClaims.ID = jti
127+ }
128+ if sub, ok := claims["sub"].(string); ok {
129+ tokenClaims.Subject = sub
130+ }
131+ if iat, ok := claims["iat"].(float64); ok {
132+ tokenClaims.IssuedAt = int64(iat)
133+ }
134+ if exp, ok := claims["exp"].(float64); ok {
135+ tokenClaims.ExpiresAt = int64(exp)
136+ }
137+ if scopes, ok := claims["scopes"].([]any); ok {
138+ for _, scope := range scopes {
139+ if s, ok := scope.(string); ok {
140+ tokenClaims.Scopes = append(tokenClaims.Scopes, s)
141+ }
142+ }
143+ }
144+
145+ // Check if token is revoked
146+ revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID)
147+ if err != nil {
148+ log.Warn().Err(err).Msg("failed to check token revocation")
149+ // Continue anyway - revocation check is best effort
150+ }
151+ if revoked {
152+ log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked")
153+ return nil, domain.ErrRevokedToken
154+ }
155+
156+ // Check expiry (if set)
157+ if tokenClaims.ExpiresAt > 0 {
158+ if time.Now().Unix() > tokenClaims.ExpiresAt {
159+ log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired")
160+ return nil, domain.ErrExpiredToken
161+ }
162+ }
163+
164+ log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful")
165+ return tokenClaims, nil
166+}
167+
168+// GenerateToken creates a new JWT token for the given subject.
169+func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []string, expiry time.Duration) (string, error) {
170+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
171+ zerowrap.FieldLayer: "usecase",
172+ zerowrap.FieldUseCase: "GenerateToken",
173+ "subject": subject,
174+ })
175+ log := zerowrap.FromCtx(ctx)
176+
177+ tokenID := uuid.New().String()
178+ now := time.Now()
179+
180+ // Build token
181+ token := &domain.Token{
182+ ID: tokenID,
183+ Subject: subject,
184+ Scopes: scopes,
185+ IssuedAt: now,
186+ Revoked: false,
187+ }
188+
189+ // Build JWT claims
190+ claims := jwt.MapClaims{
191+ "jti": tokenID,
192+ "sub": subject,
193+ "iss": TokenIssuer,
194+ "iat": now.Unix(),
195+ "scopes": scopes,
196+ }
197+
198+ // Set expiry if specified
199+ if expiry > 0 {
200+ expiresAt := now.Add(expiry)
201+ token.ExpiresAt = expiresAt
202+ claims["exp"] = expiresAt.Unix()
203+ }
204+ // If expiry is 0, token never expires (no exp claim)
205+
206+ // Sign token
207+ jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
208+ tokenString, err := jwtToken.SignedString(s.config.TokenSecret)
209+ if err != nil {
210+ return "", log.WrapErr(err, "failed to sign token")
211+ }
212+
213+ // Store token
214+ if err := s.tokenStore.SaveToken(ctx, token, tokenString); err != nil {
215+ return "", log.WrapErr(err, "failed to save token")
216+ }
217+
218+ log.Info().
219+ Str("token_id", tokenID).
220+ Str("subject", subject).
221+ Time("expires_at", token.ExpiresAt).
222+ Msg("token generated")
223+
224+ return tokenString, nil
225+}
226+
227+// RevokeToken revokes a token by its ID.
228+func (s *Service) RevokeToken(ctx context.Context, tokenID string) error {
229+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
230+ zerowrap.FieldLayer: "usecase",
231+ zerowrap.FieldUseCase: "RevokeToken",
232+ "token_id": tokenID,
233+ })
234+ log := zerowrap.FromCtx(ctx)
235+
236+ if err := s.tokenStore.Revoke(ctx, tokenID); err != nil {
237+ return log.WrapErr(err, "failed to revoke token")
238+ }
239+
240+ log.Info().Msg("token revoked")
241+ return nil
242+}
243+
244+// ListTokens returns all stored tokens.
245+func (s *Service) ListTokens(ctx context.Context) ([]domain.Token, error) {
246+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
247+ zerowrap.FieldLayer: "usecase",
248+ zerowrap.FieldUseCase: "ListTokens",
249+ })
250+ log := zerowrap.FromCtx(ctx)
251+
252+ tokens, err := s.tokenStore.ListTokens(ctx)
253+ if err != nil {
254+ return nil, log.WrapErr(err, "failed to list tokens")
255+ }
256+
257+ return tokens, nil
258+}
259+
260+// GeneratePasswordHash generates a bcrypt hash for a password.
261+func (s *Service) GeneratePasswordHash(password string) (string, error) {
262+ hash, err := bcrypt.GenerateFromPassword([]byte(password), DefaultBcryptCost)
263+ if err != nil {
264+ return "", err
265+ }
266+ return string(hash), nil
267+}
268+
269+// getStringClaim safely extracts a string claim from JWT claims.
270+func getStringClaim(claims jwt.MapClaims, key string) string {
271+ if val, ok := claims[key].(string); ok {
272+ return val
273+ }
274+ return ""
275+}
A internal/usecase/auth/service_test.go +468 -0
@@ -0,0 +1,468 @@
1+package auth
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+ "golang.org/x/crypto/bcrypt"
13+
14+ "gordon/internal/boundaries/out/mocks"
15+ "gordon/internal/domain"
16+)
17+
18+func testContext() context.Context {
19+ return zerowrap.WithCtx(context.Background(), zerowrap.Default())
20+}
21+
22+func TestService_GetAuthType(t *testing.T) {
23+ tests := []struct {
24+ name string
25+ authType domain.AuthType
26+ }{
27+ {"password type", domain.AuthTypePassword},
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+
39+func 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+
56+func TestService_ValidatePassword_Success(t *testing.T) {
57+ password := "testpassword123"
58+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
59+ require.NoError(t, err)
60+
61+ svc := NewService(Config{
62+ Enabled: true,
63+ AuthType: domain.AuthTypePassword,
64+ Username: "testuser",
65+ PasswordHash: string(hash),
66+ }, nil, zerowrap.Default())
67+
68+ ctx := testContext()
69+ assert.True(t, svc.ValidatePassword(ctx, "testuser", password))
70+}
71+
72+func TestService_ValidatePassword_WrongPassword(t *testing.T) {
73+ hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost)
74+ require.NoError(t, err)
75+
76+ svc := NewService(Config{
77+ Enabled: true,
78+ AuthType: domain.AuthTypePassword,
79+ Username: "testuser",
80+ PasswordHash: string(hash),
81+ }, nil, zerowrap.Default())
82+
83+ ctx := testContext()
84+ assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword"))
85+}
86+
87+func TestService_ValidatePassword_WrongUsername(t *testing.T) {
88+ password := "testpassword123"
89+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
90+ require.NoError(t, err)
91+
92+ svc := NewService(Config{
93+ Enabled: true,
94+ AuthType: domain.AuthTypePassword,
95+ Username: "testuser",
96+ PasswordHash: string(hash),
97+ }, nil, zerowrap.Default())
98+
99+ ctx := testContext()
100+ assert.False(t, svc.ValidatePassword(ctx, "wronguser", password))
101+}
102+
103+func TestService_GenerateToken_Success(t *testing.T) {
104+ tokenStore := mocks.NewMockTokenStore(t)
105+
106+ // Capture the token to verify its properties
107+ var capturedToken *domain.Token
108+ var capturedJWT string
109+
110+ tokenStore.EXPECT().
111+ SaveToken(
112+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
113+ mock.MatchedBy(func(token *domain.Token) bool {
114+ capturedToken = token
115+ return token.Subject == "testsubject" &&
116+ len(token.ID) > 0 &&
117+ len(token.Scopes) == 2 &&
118+ token.Scopes[0] == "push" &&
119+ token.Scopes[1] == "pull" &&
120+ !token.ExpiresAt.IsZero() &&
121+ !token.Revoked
122+ }),
123+ mock.MatchedBy(func(jwt string) bool {
124+ capturedJWT = jwt
125+ // JWT should have 3 parts separated by dots
126+ parts := 0
127+ for _, c := range jwt {
128+ if c == '.' {
129+ parts++
130+ }
131+ }
132+ return parts == 2 && len(jwt) > 50
133+ }),
134+ ).
135+ Return(nil)
136+
137+ svc := NewService(Config{
138+ Enabled: true,
139+ AuthType: domain.AuthTypeToken,
140+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
141+ }, tokenStore, zerowrap.Default())
142+
143+ ctx := testContext()
144+ token, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
145+
146+ assert.NoError(t, err)
147+ assert.NotEmpty(t, token)
148+ assert.Equal(t, capturedJWT, token)
149+ assert.NotNil(t, capturedToken)
150+ assert.WithinDuration(t, time.Now().Add(time.Hour), capturedToken.ExpiresAt, 5*time.Second)
151+}
152+
153+func TestService_GenerateToken_NeverExpires(t *testing.T) {
154+ tokenStore := mocks.NewMockTokenStore(t)
155+
156+ tokenStore.EXPECT().
157+ SaveToken(
158+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
159+ mock.MatchedBy(func(token *domain.Token) bool {
160+ return token.Subject == "ci-bot" &&
161+ token.ExpiresAt.IsZero() // Never expires
162+ }),
163+ mock.MatchedBy(func(jwt string) bool {
164+ return len(jwt) > 50
165+ }),
166+ ).
167+ Return(nil)
168+
169+ svc := NewService(Config{
170+ Enabled: true,
171+ AuthType: domain.AuthTypeToken,
172+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
173+ }, tokenStore, zerowrap.Default())
174+
175+ ctx := testContext()
176+ token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0)
177+
178+ assert.NoError(t, err)
179+ assert.NotEmpty(t, token)
180+}
181+
182+func TestService_ValidateToken_Success(t *testing.T) {
183+ tokenStore := mocks.NewMockTokenStore(t)
184+
185+ svc := NewService(Config{
186+ Enabled: true,
187+ AuthType: domain.AuthTypeToken,
188+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
189+ }, tokenStore, zerowrap.Default())
190+
191+ // Capture the token ID when saving
192+ var savedTokenID string
193+ tokenStore.EXPECT().
194+ SaveToken(
195+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
196+ mock.MatchedBy(func(token *domain.Token) bool {
197+ savedTokenID = token.ID
198+ return token.Subject == "testsubject"
199+ }),
200+ mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
201+ ).
202+ Return(nil)
203+
204+ ctx := testContext()
205+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
206+ require.NoError(t, err)
207+
208+ // Expect IsRevoked to be called with the same token ID
209+ tokenStore.EXPECT().
210+ IsRevoked(
211+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
212+ mock.MatchedBy(func(tokenID string) bool {
213+ return tokenID == savedTokenID
214+ }),
215+ ).
216+ Return(false, nil)
217+
218+ claims, err := svc.ValidateToken(ctx, tokenStr)
219+
220+ assert.NoError(t, err)
221+ assert.NotNil(t, claims)
222+ assert.Equal(t, "testsubject", claims.Subject)
223+ assert.Equal(t, savedTokenID, claims.ID)
224+ assert.Equal(t, TokenIssuer, claims.Issuer)
225+ assert.Contains(t, claims.Scopes, "push")
226+ assert.Contains(t, claims.Scopes, "pull")
227+}
228+
229+func TestService_ValidateToken_Revoked(t *testing.T) {
230+ tokenStore := mocks.NewMockTokenStore(t)
231+
232+ svc := NewService(Config{
233+ Enabled: true,
234+ AuthType: domain.AuthTypeToken,
235+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
236+ }, tokenStore, zerowrap.Default())
237+
238+ var savedTokenID string
239+ tokenStore.EXPECT().
240+ SaveToken(
241+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
242+ mock.MatchedBy(func(token *domain.Token) bool {
243+ savedTokenID = token.ID
244+ return token.Subject == "testsubject"
245+ }),
246+ mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
247+ ).
248+ Return(nil)
249+
250+ ctx := testContext()
251+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
252+ require.NoError(t, err)
253+
254+ // Token is revoked
255+ tokenStore.EXPECT().
256+ IsRevoked(
257+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
258+ mock.MatchedBy(func(tokenID string) bool { return tokenID == savedTokenID }),
259+ ).
260+ Return(true, nil)
261+
262+ claims, err := svc.ValidateToken(ctx, tokenStr)
263+
264+ assert.Error(t, err)
265+ assert.Nil(t, claims)
266+ assert.ErrorIs(t, err, domain.ErrRevokedToken)
267+}
268+
269+func TestService_ValidateToken_InvalidSignature(t *testing.T) {
270+ tokenStore := mocks.NewMockTokenStore(t)
271+
272+ // Service with different secret for validation
273+ svc := NewService(Config{
274+ Enabled: true,
275+ AuthType: domain.AuthTypeToken,
276+ TokenSecret: []byte("different-secret-key"),
277+ }, tokenStore, zerowrap.Default())
278+
279+ // Create a token with a different secret
280+ otherSvc := NewService(Config{
281+ Enabled: true,
282+ AuthType: domain.AuthTypeToken,
283+ TokenSecret: []byte("original-secret-key"),
284+ }, tokenStore, zerowrap.Default())
285+
286+ tokenStore.EXPECT().
287+ SaveToken(
288+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
289+ mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
290+ mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
291+ ).
292+ Return(nil)
293+
294+ ctx := testContext()
295+ tokenStr, err := otherSvc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
296+ require.NoError(t, err)
297+
298+ // Try to validate with wrong secret - should fail before checking revocation
299+ claims, err := svc.ValidateToken(ctx, tokenStr)
300+
301+ assert.Error(t, err)
302+ assert.Nil(t, claims)
303+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
304+}
305+
306+func TestService_ValidateToken_Expired(t *testing.T) {
307+ tokenStore := mocks.NewMockTokenStore(t)
308+
309+ svc := NewService(Config{
310+ Enabled: true,
311+ AuthType: domain.AuthTypeToken,
312+ TokenSecret: []byte("test-secret-key"),
313+ }, tokenStore, zerowrap.Default())
314+
315+ // Generate a token that expires immediately
316+ tokenStore.EXPECT().
317+ SaveToken(
318+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
319+ mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
320+ mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
321+ ).
322+ Return(nil)
323+
324+ ctx := testContext()
325+ // Generate with very short expiry
326+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Millisecond)
327+ require.NoError(t, err)
328+
329+ // Wait for token to expire
330+ time.Sleep(10 * time.Millisecond)
331+
332+ // JWT library handles expiry during parsing - returns invalid token error
333+ // No IsRevoked call is made because parsing fails first
334+ claims, err := svc.ValidateToken(ctx, tokenStr)
335+
336+ assert.Error(t, err)
337+ assert.Nil(t, claims)
338+ // JWT library returns invalid token for expired tokens during parsing
339+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
340+}
341+
342+func TestService_ValidateToken_MalformedToken(t *testing.T) {
343+ tokenStore := mocks.NewMockTokenStore(t)
344+
345+ svc := NewService(Config{
346+ Enabled: true,
347+ AuthType: domain.AuthTypeToken,
348+ TokenSecret: []byte("test-secret-key"),
349+ }, tokenStore, zerowrap.Default())
350+
351+ ctx := testContext()
352+
353+ // Test with various malformed tokens
354+ malformedTokens := []string{
355+ "",
356+ "not-a-jwt",
357+ "only.two.parts.here.extra",
358+ "invalid.base64.token",
359+ }
360+
361+ for _, token := range malformedTokens {
362+ claims, err := svc.ValidateToken(ctx, token)
363+ assert.Error(t, err, "expected error for token: %s", token)
364+ assert.Nil(t, claims)
365+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
366+ }
367+}
368+
369+func TestService_RevokeToken(t *testing.T) {
370+ tokenStore := mocks.NewMockTokenStore(t)
371+
372+ tokenID := "550e8400-e29b-41d4-a716-446655440000"
373+
374+ tokenStore.EXPECT().
375+ Revoke(
376+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
377+ tokenID,
378+ ).
379+ Return(nil)
380+
381+ svc := NewService(Config{
382+ Enabled: true,
383+ AuthType: domain.AuthTypeToken,
384+ }, tokenStore, zerowrap.Default())
385+
386+ ctx := testContext()
387+ err := svc.RevokeToken(ctx, tokenID)
388+
389+ assert.NoError(t, err)
390+}
391+
392+func TestService_ListTokens(t *testing.T) {
393+ tokenStore := mocks.NewMockTokenStore(t)
394+
395+ expectedTokens := []domain.Token{
396+ {
397+ ID: "token-1",
398+ Subject: "user1",
399+ Scopes: []string{"push", "pull"},
400+ IssuedAt: time.Now().Add(-24 * time.Hour),
401+ ExpiresAt: time.Now().Add(24 * time.Hour),
402+ Revoked: false,
403+ },
404+ {
405+ ID: "token-2",
406+ Subject: "ci-bot",
407+ Scopes: []string{"push", "pull"},
408+ IssuedAt: time.Now().Add(-48 * time.Hour),
409+ ExpiresAt: time.Time{}, // Never expires
410+ Revoked: false,
411+ },
412+ }
413+
414+ tokenStore.EXPECT().
415+ ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
416+ Return(expectedTokens, nil)
417+
418+ svc := NewService(Config{
419+ Enabled: true,
420+ AuthType: domain.AuthTypeToken,
421+ }, tokenStore, zerowrap.Default())
422+
423+ ctx := testContext()
424+ tokens, err := svc.ListTokens(ctx)
425+
426+ assert.NoError(t, err)
427+ assert.Len(t, tokens, 2)
428+ assert.Equal(t, "user1", tokens[0].Subject)
429+ assert.Equal(t, "ci-bot", tokens[1].Subject)
430+ assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire")
431+}
432+
433+func TestService_GeneratePasswordHash(t *testing.T) {
434+ svc := NewService(Config{}, nil, zerowrap.Default())
435+
436+ password := "securePassword123!"
437+ hash, err := svc.GeneratePasswordHash(password)
438+
439+ assert.NoError(t, err)
440+ assert.NotEmpty(t, hash)
441+ assert.NotEqual(t, password, hash, "hash should not equal plain password")
442+
443+ // Verify the hash works with bcrypt
444+ err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
445+ assert.NoError(t, err)
446+
447+ // Verify wrong password fails
448+ err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword"))
449+ assert.Error(t, err)
450+}
451+
452+func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) {
453+ svc := NewService(Config{}, nil, zerowrap.Default())
454+
455+ password := "testpassword"
456+ hash1, err := svc.GeneratePasswordHash(password)
457+ require.NoError(t, err)
458+
459+ hash2, err := svc.GeneratePasswordHash(password)
460+ require.NoError(t, err)
461+
462+ // Bcrypt should generate different hashes each time due to salt
463+ assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes")
464+
465+ // But both should validate correctly
466+ assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)))
467+ assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)))
468+}
@@ -0,0 +1,468 @@
1+package auth
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+ "golang.org/x/crypto/bcrypt"
13+
14+ "gordon/internal/boundaries/out/mocks"
15+ "gordon/internal/domain"
16+)
17+
18+func testContext() context.Context {
19+ return zerowrap.WithCtx(context.Background(), zerowrap.Default())
20+}
21+
22+func TestService_GetAuthType(t *testing.T) {
23+ tests := []struct {
24+ name string
25+ authType domain.AuthType
26+ }{
27+ {"password type", domain.AuthTypePassword},
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+
39+func 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+
56+func TestService_ValidatePassword_Success(t *testing.T) {
57+ password := "testpassword123"
58+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
59+ require.NoError(t, err)
60+
61+ svc := NewService(Config{
62+ Enabled: true,
63+ AuthType: domain.AuthTypePassword,
64+ Username: "testuser",
65+ PasswordHash: string(hash),
66+ }, nil, zerowrap.Default())
67+
68+ ctx := testContext()
69+ assert.True(t, svc.ValidatePassword(ctx, "testuser", password))
70+}
71+
72+func TestService_ValidatePassword_WrongPassword(t *testing.T) {
73+ hash, err := bcrypt.GenerateFromPassword([]byte("correctpassword"), bcrypt.DefaultCost)
74+ require.NoError(t, err)
75+
76+ svc := NewService(Config{
77+ Enabled: true,
78+ AuthType: domain.AuthTypePassword,
79+ Username: "testuser",
80+ PasswordHash: string(hash),
81+ }, nil, zerowrap.Default())
82+
83+ ctx := testContext()
84+ assert.False(t, svc.ValidatePassword(ctx, "testuser", "wrongpassword"))
85+}
86+
87+func TestService_ValidatePassword_WrongUsername(t *testing.T) {
88+ password := "testpassword123"
89+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
90+ require.NoError(t, err)
91+
92+ svc := NewService(Config{
93+ Enabled: true,
94+ AuthType: domain.AuthTypePassword,
95+ Username: "testuser",
96+ PasswordHash: string(hash),
97+ }, nil, zerowrap.Default())
98+
99+ ctx := testContext()
100+ assert.False(t, svc.ValidatePassword(ctx, "wronguser", password))
101+}
102+
103+func TestService_GenerateToken_Success(t *testing.T) {
104+ tokenStore := mocks.NewMockTokenStore(t)
105+
106+ // Capture the token to verify its properties
107+ var capturedToken *domain.Token
108+ var capturedJWT string
109+
110+ tokenStore.EXPECT().
111+ SaveToken(
112+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
113+ mock.MatchedBy(func(token *domain.Token) bool {
114+ capturedToken = token
115+ return token.Subject == "testsubject" &&
116+ len(token.ID) > 0 &&
117+ len(token.Scopes) == 2 &&
118+ token.Scopes[0] == "push" &&
119+ token.Scopes[1] == "pull" &&
120+ !token.ExpiresAt.IsZero() &&
121+ !token.Revoked
122+ }),
123+ mock.MatchedBy(func(jwt string) bool {
124+ capturedJWT = jwt
125+ // JWT should have 3 parts separated by dots
126+ parts := 0
127+ for _, c := range jwt {
128+ if c == '.' {
129+ parts++
130+ }
131+ }
132+ return parts == 2 && len(jwt) > 50
133+ }),
134+ ).
135+ Return(nil)
136+
137+ svc := NewService(Config{
138+ Enabled: true,
139+ AuthType: domain.AuthTypeToken,
140+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
141+ }, tokenStore, zerowrap.Default())
142+
143+ ctx := testContext()
144+ token, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
145+
146+ assert.NoError(t, err)
147+ assert.NotEmpty(t, token)
148+ assert.Equal(t, capturedJWT, token)
149+ assert.NotNil(t, capturedToken)
150+ assert.WithinDuration(t, time.Now().Add(time.Hour), capturedToken.ExpiresAt, 5*time.Second)
151+}
152+
153+func TestService_GenerateToken_NeverExpires(t *testing.T) {
154+ tokenStore := mocks.NewMockTokenStore(t)
155+
156+ tokenStore.EXPECT().
157+ SaveToken(
158+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
159+ mock.MatchedBy(func(token *domain.Token) bool {
160+ return token.Subject == "ci-bot" &&
161+ token.ExpiresAt.IsZero() // Never expires
162+ }),
163+ mock.MatchedBy(func(jwt string) bool {
164+ return len(jwt) > 50
165+ }),
166+ ).
167+ Return(nil)
168+
169+ svc := NewService(Config{
170+ Enabled: true,
171+ AuthType: domain.AuthTypeToken,
172+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
173+ }, tokenStore, zerowrap.Default())
174+
175+ ctx := testContext()
176+ token, err := svc.GenerateToken(ctx, "ci-bot", []string{"push", "pull"}, 0)
177+
178+ assert.NoError(t, err)
179+ assert.NotEmpty(t, token)
180+}
181+
182+func TestService_ValidateToken_Success(t *testing.T) {
183+ tokenStore := mocks.NewMockTokenStore(t)
184+
185+ svc := NewService(Config{
186+ Enabled: true,
187+ AuthType: domain.AuthTypeToken,
188+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
189+ }, tokenStore, zerowrap.Default())
190+
191+ // Capture the token ID when saving
192+ var savedTokenID string
193+ tokenStore.EXPECT().
194+ SaveToken(
195+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
196+ mock.MatchedBy(func(token *domain.Token) bool {
197+ savedTokenID = token.ID
198+ return token.Subject == "testsubject"
199+ }),
200+ mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
201+ ).
202+ Return(nil)
203+
204+ ctx := testContext()
205+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
206+ require.NoError(t, err)
207+
208+ // Expect IsRevoked to be called with the same token ID
209+ tokenStore.EXPECT().
210+ IsRevoked(
211+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
212+ mock.MatchedBy(func(tokenID string) bool {
213+ return tokenID == savedTokenID
214+ }),
215+ ).
216+ Return(false, nil)
217+
218+ claims, err := svc.ValidateToken(ctx, tokenStr)
219+
220+ assert.NoError(t, err)
221+ assert.NotNil(t, claims)
222+ assert.Equal(t, "testsubject", claims.Subject)
223+ assert.Equal(t, savedTokenID, claims.ID)
224+ assert.Equal(t, TokenIssuer, claims.Issuer)
225+ assert.Contains(t, claims.Scopes, "push")
226+ assert.Contains(t, claims.Scopes, "pull")
227+}
228+
229+func TestService_ValidateToken_Revoked(t *testing.T) {
230+ tokenStore := mocks.NewMockTokenStore(t)
231+
232+ svc := NewService(Config{
233+ Enabled: true,
234+ AuthType: domain.AuthTypeToken,
235+ TokenSecret: []byte("test-secret-key-for-jwt-signing"),
236+ }, tokenStore, zerowrap.Default())
237+
238+ var savedTokenID string
239+ tokenStore.EXPECT().
240+ SaveToken(
241+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
242+ mock.MatchedBy(func(token *domain.Token) bool {
243+ savedTokenID = token.ID
244+ return token.Subject == "testsubject"
245+ }),
246+ mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
247+ ).
248+ Return(nil)
249+
250+ ctx := testContext()
251+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
252+ require.NoError(t, err)
253+
254+ // Token is revoked
255+ tokenStore.EXPECT().
256+ IsRevoked(
257+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
258+ mock.MatchedBy(func(tokenID string) bool { return tokenID == savedTokenID }),
259+ ).
260+ Return(true, nil)
261+
262+ claims, err := svc.ValidateToken(ctx, tokenStr)
263+
264+ assert.Error(t, err)
265+ assert.Nil(t, claims)
266+ assert.ErrorIs(t, err, domain.ErrRevokedToken)
267+}
268+
269+func TestService_ValidateToken_InvalidSignature(t *testing.T) {
270+ tokenStore := mocks.NewMockTokenStore(t)
271+
272+ // Service with different secret for validation
273+ svc := NewService(Config{
274+ Enabled: true,
275+ AuthType: domain.AuthTypeToken,
276+ TokenSecret: []byte("different-secret-key"),
277+ }, tokenStore, zerowrap.Default())
278+
279+ // Create a token with a different secret
280+ otherSvc := NewService(Config{
281+ Enabled: true,
282+ AuthType: domain.AuthTypeToken,
283+ TokenSecret: []byte("original-secret-key"),
284+ }, tokenStore, zerowrap.Default())
285+
286+ tokenStore.EXPECT().
287+ SaveToken(
288+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
289+ mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
290+ mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
291+ ).
292+ Return(nil)
293+
294+ ctx := testContext()
295+ tokenStr, err := otherSvc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
296+ require.NoError(t, err)
297+
298+ // Try to validate with wrong secret - should fail before checking revocation
299+ claims, err := svc.ValidateToken(ctx, tokenStr)
300+
301+ assert.Error(t, err)
302+ assert.Nil(t, claims)
303+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
304+}
305+
306+func TestService_ValidateToken_Expired(t *testing.T) {
307+ tokenStore := mocks.NewMockTokenStore(t)
308+
309+ svc := NewService(Config{
310+ Enabled: true,
311+ AuthType: domain.AuthTypeToken,
312+ TokenSecret: []byte("test-secret-key"),
313+ }, tokenStore, zerowrap.Default())
314+
315+ // Generate a token that expires immediately
316+ tokenStore.EXPECT().
317+ SaveToken(
318+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
319+ mock.MatchedBy(func(token *domain.Token) bool { return token.Subject == "testsubject" }),
320+ mock.MatchedBy(func(jwt string) bool { return len(jwt) > 50 }),
321+ ).
322+ Return(nil)
323+
324+ ctx := testContext()
325+ // Generate with very short expiry
326+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push"}, time.Millisecond)
327+ require.NoError(t, err)
328+
329+ // Wait for token to expire
330+ time.Sleep(10 * time.Millisecond)
331+
332+ // JWT library handles expiry during parsing - returns invalid token error
333+ // No IsRevoked call is made because parsing fails first
334+ claims, err := svc.ValidateToken(ctx, tokenStr)
335+
336+ assert.Error(t, err)
337+ assert.Nil(t, claims)
338+ // JWT library returns invalid token for expired tokens during parsing
339+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
340+}
341+
342+func TestService_ValidateToken_MalformedToken(t *testing.T) {
343+ tokenStore := mocks.NewMockTokenStore(t)
344+
345+ svc := NewService(Config{
346+ Enabled: true,
347+ AuthType: domain.AuthTypeToken,
348+ TokenSecret: []byte("test-secret-key"),
349+ }, tokenStore, zerowrap.Default())
350+
351+ ctx := testContext()
352+
353+ // Test with various malformed tokens
354+ malformedTokens := []string{
355+ "",
356+ "not-a-jwt",
357+ "only.two.parts.here.extra",
358+ "invalid.base64.token",
359+ }
360+
361+ for _, token := range malformedTokens {
362+ claims, err := svc.ValidateToken(ctx, token)
363+ assert.Error(t, err, "expected error for token: %s", token)
364+ assert.Nil(t, claims)
365+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
366+ }
367+}
368+
369+func TestService_RevokeToken(t *testing.T) {
370+ tokenStore := mocks.NewMockTokenStore(t)
371+
372+ tokenID := "550e8400-e29b-41d4-a716-446655440000"
373+
374+ tokenStore.EXPECT().
375+ Revoke(
376+ mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil }),
377+ tokenID,
378+ ).
379+ Return(nil)
380+
381+ svc := NewService(Config{
382+ Enabled: true,
383+ AuthType: domain.AuthTypeToken,
384+ }, tokenStore, zerowrap.Default())
385+
386+ ctx := testContext()
387+ err := svc.RevokeToken(ctx, tokenID)
388+
389+ assert.NoError(t, err)
390+}
391+
392+func TestService_ListTokens(t *testing.T) {
393+ tokenStore := mocks.NewMockTokenStore(t)
394+
395+ expectedTokens := []domain.Token{
396+ {
397+ ID: "token-1",
398+ Subject: "user1",
399+ Scopes: []string{"push", "pull"},
400+ IssuedAt: time.Now().Add(-24 * time.Hour),
401+ ExpiresAt: time.Now().Add(24 * time.Hour),
402+ Revoked: false,
403+ },
404+ {
405+ ID: "token-2",
406+ Subject: "ci-bot",
407+ Scopes: []string{"push", "pull"},
408+ IssuedAt: time.Now().Add(-48 * time.Hour),
409+ ExpiresAt: time.Time{}, // Never expires
410+ Revoked: false,
411+ },
412+ }
413+
414+ tokenStore.EXPECT().
415+ ListTokens(mock.MatchedBy(func(ctx context.Context) bool { return ctx != nil })).
416+ Return(expectedTokens, nil)
417+
418+ svc := NewService(Config{
419+ Enabled: true,
420+ AuthType: domain.AuthTypeToken,
421+ }, tokenStore, zerowrap.Default())
422+
423+ ctx := testContext()
424+ tokens, err := svc.ListTokens(ctx)
425+
426+ assert.NoError(t, err)
427+ assert.Len(t, tokens, 2)
428+ assert.Equal(t, "user1", tokens[0].Subject)
429+ assert.Equal(t, "ci-bot", tokens[1].Subject)
430+ assert.True(t, tokens[1].ExpiresAt.IsZero(), "CI bot token should never expire")
431+}
432+
433+func TestService_GeneratePasswordHash(t *testing.T) {
434+ svc := NewService(Config{}, nil, zerowrap.Default())
435+
436+ password := "securePassword123!"
437+ hash, err := svc.GeneratePasswordHash(password)
438+
439+ assert.NoError(t, err)
440+ assert.NotEmpty(t, hash)
441+ assert.NotEqual(t, password, hash, "hash should not equal plain password")
442+
443+ // Verify the hash works with bcrypt
444+ err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
445+ assert.NoError(t, err)
446+
447+ // Verify wrong password fails
448+ err = bcrypt.CompareHashAndPassword([]byte(hash), []byte("wrongpassword"))
449+ assert.Error(t, err)
450+}
451+
452+func TestService_GeneratePasswordHash_DifferentHashesForSamePassword(t *testing.T) {
453+ svc := NewService(Config{}, nil, zerowrap.Default())
454+
455+ password := "testpassword"
456+ hash1, err := svc.GeneratePasswordHash(password)
457+ require.NoError(t, err)
458+
459+ hash2, err := svc.GeneratePasswordHash(password)
460+ require.NoError(t, err)
461+
462+ // Bcrypt should generate different hashes each time due to salt
463+ assert.NotEqual(t, hash1, hash2, "bcrypt should generate unique hashes")
464+
465+ // But both should validate correctly
466+ assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash1), []byte(password)))
467+ assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(hash2), []byte(password)))
468+}

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