detached 8d39436

feat(auth): configurable TTL (default 15m) and admin scope support in /auth/token

- Add access_token_ttl config option (parsed as Go duration, default 15m)
- Thread TTL through app Config -> auth service Config -> handler
- Raise MaxAccessTokenLifetime to 1h to support configurable TTLs
- Extend parseRequestedScopes to accept admin: scoped tokens
- Extend intersectRequestedScopes to intersect admin scopes using domain.ParseAdminScope
- Add tests for admin scope exchange, denial, and wildcard matching
commit
8d3943629534a9637e9e540b26962db63eef1a56
author
brice · · 2026-03-22 06:34 UTC
committer
brice · · 2026-03-22 17:35 UTC
parent
d22746b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +244 -44
M internal/adapters/in/http/auth/handler.go +61 -19
@@ -135,8 +135,9 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
135135 }
136136 }
137137
138- // Generate a short-lived access token (5 minutes) - not stored
139- accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)
138+ // Generate a short-lived access token with configurable TTL - not stored
139+ ttl := h.authSvc.GetAccessTokenTTL()
140+ accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, ttl)
140141 if err != nil {
141142 log.Error().Err(err).Msg("failed to generate access token")
142143 w.Header().Set("Content-Type", "application/json")
@@ -147,7 +148,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
147148
148149 response := dto.TokenResponse{
149150 Token: accessToken,
150- ExpiresIn: 300, // 5 minutes in seconds
151+ ExpiresIn: int(ttl.Seconds()),
151152 IssuedAt: time.Now().UTC().Format(time.RFC3339),
152153 }
153154
@@ -184,26 +185,47 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri
184185
185186 for _, reqScopeStr := range requestedScopes {
186187 reqScope, err := domain.ParseScope(reqScopeStr)
187- if err != nil || reqScope.Type != domain.ScopeTypeRepository {
188+ if err != nil {
188189 continue
189190 }
190191
191- allowedActions := make([]string, 0, len(reqScope.Actions))
192- for _, action := range reqScope.Actions {
193- if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) {
194- allowedActions = append(allowedActions, action)
192+ switch reqScope.Type {
193+ case domain.ScopeTypeRepository:
194+ allowedActions := make([]string, 0, len(reqScope.Actions))
195+ for _, action := range reqScope.Actions {
196+ if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) {
197+ allowedActions = append(allowedActions, action)
198+ }
195199 }
196- }
197200
198- if len(allowedActions) == 0 {
199- continue
200- }
201+ if len(allowedActions) == 0 {
202+ continue
203+ }
204+
205+ effective = append(effective, (&domain.Scope{
206+ Type: reqScope.Type,
207+ Name: reqScope.Name,
208+ Actions: allowedActions,
209+ }).String())
210+
211+ case domain.ScopeTypeAdmin:
212+ allowedActions := make([]string, 0, len(reqScope.Actions))
213+ for _, action := range reqScope.Actions {
214+ if hasGrantedAdminAccess(grantedScopes, reqScope.Name, action) {
215+ allowedActions = append(allowedActions, action)
216+ }
217+ }
201218
202- effective = append(effective, (&domain.Scope{
203- Type: reqScope.Type,
204- Name: reqScope.Name,
205- Actions: allowedActions,
206- }).String())
219+ if len(allowedActions) == 0 {
220+ continue
221+ }
222+
223+ effective = append(effective, (&domain.Scope{
224+ Type: reqScope.Type,
225+ Name: reqScope.Name,
226+ Actions: allowedActions,
227+ }).String())
228+ }
207229 }
208230
209231 log.Debug().
@@ -242,6 +264,26 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b
242264 return false
243265 }
244266
267+func hasGrantedAdminAccess(grantedScopes []string, resource, action string) bool {
268+ for _, grantedScopeStr := range grantedScopes {
269+ scopeStr := strings.TrimSpace(grantedScopeStr)
270+ if scopeStr == domain.ScopeActionAll {
271+ return true
272+ }
273+
274+ adminScope, err := domain.ParseAdminScope(scopeStr)
275+ if err != nil {
276+ continue
277+ }
278+
279+ if adminScope.CanAccess(resource, action) {
280+ return true
281+ }
282+ }
283+
284+ return false
285+}
286+
245287 // parseRequestedScopes extracts and validates scope parameters from the request.
246288 // Per Docker Registry v2 auth spec, scope format is: repository:name:actions
247289 // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
@@ -266,8 +308,8 @@ func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []s
266308 continue
267309 }
268310
269- // Only allow repository scopes
270- if scope.Type != "repository" {
311+ // Allow repository and admin scopes
312+ if scope.Type != domain.ScopeTypeRepository && scope.Type != domain.ScopeTypeAdmin {
271313 log.Debug().
272314 Str("scope_type", scope.Type).
273315 Str("scope", scopeStr).
@@ -135,8 +135,9 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
135 }135 }
136 }136 }
137 137
138- // Generate a short-lived access token (5 minutes) - not stored138+ // Generate a short-lived access token with configurable TTL - not stored
139- accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, 5*time.Minute)139+ ttl := h.authSvc.GetAccessTokenTTL()
140+ accessToken, err := h.authSvc.GenerateAccessToken(ctx, username, requestedScopes, ttl)
140 if err != nil {141 if err != nil {
141 log.Error().Err(err).Msg("failed to generate access token")142 log.Error().Err(err).Msg("failed to generate access token")
142 w.Header().Set("Content-Type", "application/json")143 w.Header().Set("Content-Type", "application/json")
@@ -147,7 +148,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) {
147 148
148 response := dto.TokenResponse{149 response := dto.TokenResponse{
149 Token: accessToken,150 Token: accessToken,
150- ExpiresIn: 300, // 5 minutes in seconds151+ ExpiresIn: int(ttl.Seconds()),
151 IssuedAt: time.Now().UTC().Format(time.RFC3339),152 IssuedAt: time.Now().UTC().Format(time.RFC3339),
152 }153 }
153 154
@@ -184,26 +185,47 @@ func (h *Handler) intersectRequestedScopes(requestedScopes, grantedScopes []stri
184 185
185 for _, reqScopeStr := range requestedScopes {186 for _, reqScopeStr := range requestedScopes {
186 reqScope, err := domain.ParseScope(reqScopeStr)187 reqScope, err := domain.ParseScope(reqScopeStr)
187- if err != nil || reqScope.Type != domain.ScopeTypeRepository {188+ if err != nil {
188 continue189 continue
189 }190 }
190 191
191- allowedActions := make([]string, 0, len(reqScope.Actions))192+ switch reqScope.Type {
192- for _, action := range reqScope.Actions {193+ case domain.ScopeTypeRepository:
193- if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) {194+ allowedActions := make([]string, 0, len(reqScope.Actions))
194- allowedActions = append(allowedActions, action)195+ for _, action := range reqScope.Actions {
196+ if hasGrantedRegistryAccess(grantedScopes, reqScope.Name, action) {
197+ allowedActions = append(allowedActions, action)
198+ }
195 }199 }
196- }
197 200
198- if len(allowedActions) == 0 {201+ if len(allowedActions) == 0 {
199- continue202+ continue
200- }203+ }
204+
205+ effective = append(effective, (&domain.Scope{
206+ Type: reqScope.Type,
207+ Name: reqScope.Name,
208+ Actions: allowedActions,
209+ }).String())
210+
211+ case domain.ScopeTypeAdmin:
212+ allowedActions := make([]string, 0, len(reqScope.Actions))
213+ for _, action := range reqScope.Actions {
214+ if hasGrantedAdminAccess(grantedScopes, reqScope.Name, action) {
215+ allowedActions = append(allowedActions, action)
216+ }
217+ }
201 218
202- effective = append(effective, (&domain.Scope{219+ if len(allowedActions) == 0 {
203- Type: reqScope.Type,220+ continue
204- Name: reqScope.Name,221+ }
205- Actions: allowedActions,222+
206- }).String())223+ effective = append(effective, (&domain.Scope{
224+ Type: reqScope.Type,
225+ Name: reqScope.Name,
226+ Actions: allowedActions,
227+ }).String())
228+ }
207 }229 }
208 230
209 log.Debug().231 log.Debug().
@@ -242,6 +264,26 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b
242 return false264 return false
243 }265 }
244 266
267+func hasGrantedAdminAccess(grantedScopes []string, resource, action string) bool {
268+ for _, grantedScopeStr := range grantedScopes {
269+ scopeStr := strings.TrimSpace(grantedScopeStr)
270+ if scopeStr == domain.ScopeActionAll {
271+ return true
272+ }
273+
274+ adminScope, err := domain.ParseAdminScope(scopeStr)
275+ if err != nil {
276+ continue
277+ }
278+
279+ if adminScope.CanAccess(resource, action) {
280+ return true
281+ }
282+ }
283+
284+ return false
285+}
286+
245 // parseRequestedScopes extracts and validates scope parameters from the request.287 // parseRequestedScopes extracts and validates scope parameters from the request.
246 // Per Docker Registry v2 auth spec, scope format is: repository:name:actions288 // Per Docker Registry v2 auth spec, scope format is: repository:name:actions
247 // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull289 // Example: GET /auth/token?scope=repository:myrepo:push,pull&scope=repository:other:pull
@@ -266,8 +308,8 @@ func (h *Handler) parseRequestedScopes(r *http.Request, log zerowrap.Logger) []s
266 continue308 continue
267 }309 }
268 310
269- // Only allow repository scopes311+ // Allow repository and admin scopes
270- if scope.Type != "repository" {312+ if scope.Type != domain.ScopeTypeRepository && scope.Type != domain.ScopeTypeAdmin {
271 log.Debug().313 log.Debug().
272 Str("scope_type", scope.Type).314 Str("scope_type", scope.Type).
273 Str("scope", scopeStr).315 Str("scope", scopeStr).
M internal/adapters/in/http/auth/handler_test.go +88 -6
@@ -44,7 +44,8 @@ func TestHandler_Token_Success(t *testing.T) {
4444 Subject: "admin",
4545 Scopes: []string{"repository:myrepo:pull"},
4646 }, nil)
47- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute).
47+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
48+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 15*time.Minute).
4849 Return("access-token", nil)
4950
5051 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -61,7 +62,7 @@ func TestHandler_Token_Success(t *testing.T) {
6162 err := json.NewDecoder(rec.Body).Decode(&resp)
6263 assert.NoError(t, err)
6364 assert.Equal(t, "access-token", resp["token"])
64- assert.Equal(t, float64(300), resp["expires_in"])
65+ assert.Equal(t, float64(900), resp["expires_in"])
6566 }
6667
6768 func TestHandler_Token_MissingCredentials(t *testing.T) {
@@ -116,7 +117,8 @@ func TestHandler_Token_InternalAuth(t *testing.T) {
116117 authSvc := mocks.NewMockAuthService(t)
117118
118119 authSvc.EXPECT().IsEnabled().Return(true)
119- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 5*time.Minute).
120+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
121+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 15*time.Minute).
120122 Return("internal-access-token", nil)
121123
122124 internalAuth := InternalAuth{
@@ -148,7 +150,8 @@ func TestHandler_Token_TokenAuth(t *testing.T) {
148150 Subject: "myuser",
149151 Scopes: []string{"repository:*:pull"},
150152 }, nil)
151- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute).
153+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
154+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 15*time.Minute).
152155 Return("short-lived-token", nil)
153156
154157 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -175,7 +178,8 @@ func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) {
175178 Subject: "myuser",
176179 Scopes: []string{"repository:myrepo:pull"},
177180 }, nil)
178- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute).
181+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
182+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute).
179183 Return("scoped-token", nil)
180184
181185 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -223,7 +227,8 @@ func TestHandler_Token_PasswordAuthWithJWT(t *testing.T) {
223227 Subject: "brissou",
224228 Scopes: []string{"*"},
225229 }, nil)
226- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 5*time.Minute).
230+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
231+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 15*time.Minute).
227232 Return("short-lived-token", nil)
228233
229234 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -442,3 +447,80 @@ func TestHandler_isInternalAuth(t *testing.T) {
442447 })
443448 }
444449 }
450+
451+func TestHandler_Token_AdminScopeSuccess(t *testing.T) {
452+ authSvc := mocks.NewMockAuthService(t)
453+
454+ authSvc.EXPECT().IsEnabled().Return(true)
455+ authSvc.EXPECT().ValidateToken(mock.Anything, "admin-jwt-token").Return(&domain.TokenClaims{
456+ Subject: "admin",
457+ Scopes: []string{"admin:routes:read,write"},
458+ }, nil)
459+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
460+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"admin:routes:read"}, 15*time.Minute).
461+ Return("admin-access-token", nil)
462+
463+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
464+
465+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
466+ req.SetBasicAuth("admin", "admin-jwt-token")
467+ rec := httptest.NewRecorder()
468+
469+ handler.ServeHTTP(rec, req)
470+
471+ assert.Equal(t, http.StatusOK, rec.Code)
472+
473+ var resp map[string]any
474+ err := json.NewDecoder(rec.Body).Decode(&resp)
475+ assert.NoError(t, err)
476+ assert.Equal(t, "admin-access-token", resp["token"])
477+}
478+
479+func TestHandler_Token_AdminScopeDeniedWithRegistryOnly(t *testing.T) {
480+ authSvc := mocks.NewMockAuthService(t)
481+
482+ authSvc.EXPECT().IsEnabled().Return(true)
483+ authSvc.EXPECT().ValidateToken(mock.Anything, "registry-jwt-token").Return(&domain.TokenClaims{
484+ Subject: "myuser",
485+ Scopes: []string{"repository:myrepo:pull,push"},
486+ }, nil)
487+
488+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
489+
490+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
491+ req.SetBasicAuth("myuser", "registry-jwt-token")
492+ rec := httptest.NewRecorder()
493+
494+ handler.ServeHTTP(rec, req)
495+
496+ assert.Equal(t, http.StatusForbidden, rec.Code)
497+ assert.Contains(t, rec.Body.String(), "insufficient scope")
498+}
499+
500+func TestHandler_Token_AdminScopeWildcard(t *testing.T) {
501+ authSvc := mocks.NewMockAuthService(t)
502+
503+ authSvc.EXPECT().IsEnabled().Return(true)
504+ authSvc.EXPECT().ValidateToken(mock.Anything, "admin-wildcard-token").Return(&domain.TokenClaims{
505+ Subject: "superadmin",
506+ Scopes: []string{"admin:*:*"},
507+ }, nil)
508+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
509+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "superadmin", []string{"admin:routes:read"}, 15*time.Minute).
510+ Return("wildcard-access-token", nil)
511+
512+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
513+
514+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
515+ req.SetBasicAuth("superadmin", "admin-wildcard-token")
516+ rec := httptest.NewRecorder()
517+
518+ handler.ServeHTTP(rec, req)
519+
520+ assert.Equal(t, http.StatusOK, rec.Code)
521+
522+ var resp map[string]any
523+ err := json.NewDecoder(rec.Body).Decode(&resp)
524+ assert.NoError(t, err)
525+ assert.Equal(t, "wildcard-access-token", resp["token"])
526+}
@@ -44,7 +44,8 @@ func TestHandler_Token_Success(t *testing.T) {
44 Subject: "admin",44 Subject: "admin",
45 Scopes: []string{"repository:myrepo:pull"},45 Scopes: []string{"repository:myrepo:pull"},
46 }, nil)46 }, nil)
47- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute).47+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
48+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 15*time.Minute).
48 Return("access-token", nil)49 Return("access-token", nil)
49 50
50 handler := NewHandler(authSvc, InternalAuth{}, testLogger())51 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -61,7 +62,7 @@ func TestHandler_Token_Success(t *testing.T) {
61 err := json.NewDecoder(rec.Body).Decode(&resp)62 err := json.NewDecoder(rec.Body).Decode(&resp)
62 assert.NoError(t, err)63 assert.NoError(t, err)
63 assert.Equal(t, "access-token", resp["token"])64 assert.Equal(t, "access-token", resp["token"])
64- assert.Equal(t, float64(300), resp["expires_in"])65+ assert.Equal(t, float64(900), resp["expires_in"])
65 }66 }
66 67
67 func TestHandler_Token_MissingCredentials(t *testing.T) {68 func TestHandler_Token_MissingCredentials(t *testing.T) {
@@ -116,7 +117,8 @@ func TestHandler_Token_InternalAuth(t *testing.T) {
116 authSvc := mocks.NewMockAuthService(t)117 authSvc := mocks.NewMockAuthService(t)
117 118
118 authSvc.EXPECT().IsEnabled().Return(true)119 authSvc.EXPECT().IsEnabled().Return(true)
119- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 5*time.Minute).120+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
121+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 15*time.Minute).
120 Return("internal-access-token", nil)122 Return("internal-access-token", nil)
121 123
122 internalAuth := InternalAuth{124 internalAuth := InternalAuth{
@@ -148,7 +150,8 @@ func TestHandler_Token_TokenAuth(t *testing.T) {
148 Subject: "myuser",150 Subject: "myuser",
149 Scopes: []string{"repository:*:pull"},151 Scopes: []string{"repository:*:pull"},
150 }, nil)152 }, nil)
151- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute).153+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
154+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 15*time.Minute).
152 Return("short-lived-token", nil)155 Return("short-lived-token", nil)
153 156
154 handler := NewHandler(authSvc, InternalAuth{}, testLogger())157 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -175,7 +178,8 @@ func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) {
175 Subject: "myuser",178 Subject: "myuser",
176 Scopes: []string{"repository:myrepo:pull"},179 Scopes: []string{"repository:myrepo:pull"},
177 }, nil)180 }, nil)
178- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 5*time.Minute).181+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
182+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute).
179 Return("scoped-token", nil)183 Return("scoped-token", nil)
180 184
181 handler := NewHandler(authSvc, InternalAuth{}, testLogger())185 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -223,7 +227,8 @@ func TestHandler_Token_PasswordAuthWithJWT(t *testing.T) {
223 Subject: "brissou",227 Subject: "brissou",
224 Scopes: []string{"*"},228 Scopes: []string{"*"},
225 }, nil)229 }, nil)
226- authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 5*time.Minute).230+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
231+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 15*time.Minute).
227 Return("short-lived-token", nil)232 Return("short-lived-token", nil)
228 233
229 handler := NewHandler(authSvc, InternalAuth{}, testLogger())234 handler := NewHandler(authSvc, InternalAuth{}, testLogger())
@@ -442,3 +447,80 @@ func TestHandler_isInternalAuth(t *testing.T) {
442 })447 })
443 }448 }
444 }449 }
450+
451+func TestHandler_Token_AdminScopeSuccess(t *testing.T) {
452+ authSvc := mocks.NewMockAuthService(t)
453+
454+ authSvc.EXPECT().IsEnabled().Return(true)
455+ authSvc.EXPECT().ValidateToken(mock.Anything, "admin-jwt-token").Return(&domain.TokenClaims{
456+ Subject: "admin",
457+ Scopes: []string{"admin:routes:read,write"},
458+ }, nil)
459+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
460+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"admin:routes:read"}, 15*time.Minute).
461+ Return("admin-access-token", nil)
462+
463+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
464+
465+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
466+ req.SetBasicAuth("admin", "admin-jwt-token")
467+ rec := httptest.NewRecorder()
468+
469+ handler.ServeHTTP(rec, req)
470+
471+ assert.Equal(t, http.StatusOK, rec.Code)
472+
473+ var resp map[string]any
474+ err := json.NewDecoder(rec.Body).Decode(&resp)
475+ assert.NoError(t, err)
476+ assert.Equal(t, "admin-access-token", resp["token"])
477+}
478+
479+func TestHandler_Token_AdminScopeDeniedWithRegistryOnly(t *testing.T) {
480+ authSvc := mocks.NewMockAuthService(t)
481+
482+ authSvc.EXPECT().IsEnabled().Return(true)
483+ authSvc.EXPECT().ValidateToken(mock.Anything, "registry-jwt-token").Return(&domain.TokenClaims{
484+ Subject: "myuser",
485+ Scopes: []string{"repository:myrepo:pull,push"},
486+ }, nil)
487+
488+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
489+
490+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
491+ req.SetBasicAuth("myuser", "registry-jwt-token")
492+ rec := httptest.NewRecorder()
493+
494+ handler.ServeHTTP(rec, req)
495+
496+ assert.Equal(t, http.StatusForbidden, rec.Code)
497+ assert.Contains(t, rec.Body.String(), "insufficient scope")
498+}
499+
500+func TestHandler_Token_AdminScopeWildcard(t *testing.T) {
501+ authSvc := mocks.NewMockAuthService(t)
502+
503+ authSvc.EXPECT().IsEnabled().Return(true)
504+ authSvc.EXPECT().ValidateToken(mock.Anything, "admin-wildcard-token").Return(&domain.TokenClaims{
505+ Subject: "superadmin",
506+ Scopes: []string{"admin:*:*"},
507+ }, nil)
508+ authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
509+ authSvc.EXPECT().GenerateAccessToken(mock.Anything, "superadmin", []string{"admin:routes:read"}, 15*time.Minute).
510+ Return("wildcard-access-token", nil)
511+
512+ handler := NewHandler(authSvc, InternalAuth{}, testLogger())
513+
514+ req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
515+ req.SetBasicAuth("superadmin", "admin-wildcard-token")
516+ rec := httptest.NewRecorder()
517+
518+ handler.ServeHTTP(rec, req)
519+
520+ assert.Equal(t, http.StatusOK, rec.Code)
521+
522+ var resp map[string]any
523+ err := json.NewDecoder(rec.Body).Decode(&resp)
524+ assert.NoError(t, err)
525+ assert.Equal(t, "wildcard-access-token", resp["token"])
526+}
M internal/adapters/in/http/middleware/auth_test.go +4 -0
@@ -162,6 +162,10 @@ func (s stubAuthService) ExtendToken(context.Context, string) (string, error) {
162162 return "", errors.New("not implemented")
163163 }
164164
165+func (s stubAuthService) GetAccessTokenTTL() time.Duration {
166+ return 15 * time.Minute
167+}
168+
165169 // Tests for checkScopeAccess function
166170
167171 func TestCheckScopeAccess_ActionMapping(t *testing.T) {
@@ -162,6 +162,10 @@ func (s stubAuthService) ExtendToken(context.Context, string) (string, error) {
162 return "", errors.New("not implemented")162 return "", errors.New("not implemented")
163 }163 }
164 164
165+func (s stubAuthService) GetAccessTokenTTL() time.Duration {
166+ return 15 * time.Minute
167+}
168+
165 // Tests for checkScopeAccess function169 // Tests for checkScopeAccess function
166 170
167 func TestCheckScopeAccess_ActionMapping(t *testing.T) {171 func TestCheckScopeAccess_ActionMapping(t *testing.T) {
M internal/app/run.go +18 -4
@@ -130,10 +130,11 @@ type Config struct {
130130 Type string `mapstructure:"type"` // "password" or "token"
131131 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
132132 Username string `mapstructure:"username"`
133- Password string `mapstructure:"password"` // deprecated: use password_hash
134- PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
135- TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
136- TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
133+ Password string `mapstructure:"password"` // deprecated: use password_hash
134+ PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
135+ TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
136+ TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
137+ AccessTokenTTL string `mapstructure:"access_token_ttl"` // e.g., "15m", "30m" (default: 15m)
137138 } `mapstructure:"auth"`
138139
139140 API struct {
@@ -1088,6 +1089,19 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType,
10881089 authConfig.TokenSecret = secret
10891090 authConfig.TokenExpiry = expiry
10901091
1092+ accessTokenTTL := 15 * time.Minute // default
1093+ if cfg.Auth.AccessTokenTTL != "" {
1094+ parsed, err := time.ParseDuration(cfg.Auth.AccessTokenTTL)
1095+ if err != nil {
1096+ return auth.Config{}, fmt.Errorf("invalid auth.access_token_ttl %q: %w", cfg.Auth.AccessTokenTTL, err)
1097+ }
1098+ if parsed <= 0 {
1099+ return auth.Config{}, fmt.Errorf("auth.access_token_ttl must be positive")
1100+ }
1101+ accessTokenTTL = parsed
1102+ }
1103+ authConfig.AccessTokenTTL = accessTokenTTL
1104+
10911105 return authConfig, nil
10921106 }
10931107
@@ -130,10 +130,11 @@ type Config struct {
130 Type string `mapstructure:"type"` // "password" or "token"130 Type string `mapstructure:"type"` // "password" or "token"
131 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"131 SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
132 Username string `mapstructure:"username"`132 Username string `mapstructure:"username"`
133- Password string `mapstructure:"password"` // deprecated: use password_hash133+ Password string `mapstructure:"password"` // deprecated: use password_hash
134- PasswordHash string `mapstructure:"password_hash"` // path in secrets backend134+ PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
135- TokenSecret string `mapstructure:"token_secret"` // path in secrets backend135+ TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
136- TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"136+ TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h", "30d"
137+ AccessTokenTTL string `mapstructure:"access_token_ttl"` // e.g., "15m", "30m" (default: 15m)
137 } `mapstructure:"auth"`138 } `mapstructure:"auth"`
138 139
139 API struct {140 API struct {
@@ -1088,6 +1089,19 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType,
1088 authConfig.TokenSecret = secret1089 authConfig.TokenSecret = secret
1089 authConfig.TokenExpiry = expiry1090 authConfig.TokenExpiry = expiry
1090 1091
1092+ accessTokenTTL := 15 * time.Minute // default
1093+ if cfg.Auth.AccessTokenTTL != "" {
1094+ parsed, err := time.ParseDuration(cfg.Auth.AccessTokenTTL)
1095+ if err != nil {
1096+ return auth.Config{}, fmt.Errorf("invalid auth.access_token_ttl %q: %w", cfg.Auth.AccessTokenTTL, err)
1097+ }
1098+ if parsed <= 0 {
1099+ return auth.Config{}, fmt.Errorf("auth.access_token_ttl must be positive")
1100+ }
1101+ accessTokenTTL = parsed
1102+ }
1103+ authConfig.AccessTokenTTL = accessTokenTTL
1104+
1091 return authConfig, nil1105 return authConfig, nil
1092 }1106 }
1093 1107
M internal/boundaries/in/auth.go +3 -0
@@ -43,4 +43,7 @@ type AuthService interface {
4343 // Returns the same token string if it was already extended within the debounce window (1h).
4444 // Skips extension for ephemeral access tokens (≤5min) and service tokens.
4545 ExtendToken(ctx context.Context, tokenString string) (string, error)
46+
47+ // GetAccessTokenTTL returns the configured ephemeral access token lifetime.
48+ GetAccessTokenTTL() time.Duration
4649 }
@@ -43,4 +43,7 @@ type AuthService interface {
43 // Returns the same token string if it was already extended within the debounce window (1h).43 // Returns the same token string if it was already extended within the debounce window (1h).
44 // Skips extension for ephemeral access tokens (≤5min) and service tokens.44 // Skips extension for ephemeral access tokens (≤5min) and service tokens.
45 ExtendToken(ctx context.Context, tokenString string) (string, error)45 ExtendToken(ctx context.Context, tokenString string) (string, error)
46+
47+ // GetAccessTokenTTL returns the configured ephemeral access token lifetime.
48+ GetAccessTokenTTL() time.Duration
46 }49 }
M internal/boundaries/in/mocks/mock_auth_service.go +44 -0
@@ -261,6 +261,50 @@ func (_c *MockAuthService_GenerateToken_Call) RunAndReturn(run func(ctx context.
261261 return _c
262262 }
263263
264+// GetAccessTokenTTL provides a mock function for the type MockAuthService
265+func (_mock *MockAuthService) GetAccessTokenTTL() time.Duration {
266+ ret := _mock.Called()
267+
268+ if len(ret) == 0 {
269+ panic("no return value specified for GetAccessTokenTTL")
270+ }
271+
272+ var r0 time.Duration
273+ if returnFunc, ok := ret.Get(0).(func() time.Duration); ok {
274+ r0 = returnFunc()
275+ } else {
276+ r0 = ret.Get(0).(time.Duration)
277+ }
278+ return r0
279+}
280+
281+// MockAuthService_GetAccessTokenTTL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAccessTokenTTL'
282+type MockAuthService_GetAccessTokenTTL_Call struct {
283+ *mock.Call
284+}
285+
286+// GetAccessTokenTTL is a helper method to define mock.On call
287+func (_e *MockAuthService_Expecter) GetAccessTokenTTL() *MockAuthService_GetAccessTokenTTL_Call {
288+ return &MockAuthService_GetAccessTokenTTL_Call{Call: _e.mock.On("GetAccessTokenTTL")}
289+}
290+
291+func (_c *MockAuthService_GetAccessTokenTTL_Call) Run(run func()) *MockAuthService_GetAccessTokenTTL_Call {
292+ _c.Call.Run(func(args mock.Arguments) {
293+ run()
294+ })
295+ return _c
296+}
297+
298+func (_c *MockAuthService_GetAccessTokenTTL_Call) Return(duration time.Duration) *MockAuthService_GetAccessTokenTTL_Call {
299+ _c.Call.Return(duration)
300+ return _c
301+}
302+
303+func (_c *MockAuthService_GetAccessTokenTTL_Call) RunAndReturn(run func() time.Duration) *MockAuthService_GetAccessTokenTTL_Call {
304+ _c.Call.Return(run)
305+ return _c
306+}
307+
264308 // GetAuthStatus provides a mock function for the type MockAuthService
265309 func (_mock *MockAuthService) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) {
266310 ret := _mock.Called(ctx)
@@ -261,6 +261,50 @@ func (_c *MockAuthService_GenerateToken_Call) RunAndReturn(run func(ctx context.
261 return _c261 return _c
262 }262 }
263 263
264+// GetAccessTokenTTL provides a mock function for the type MockAuthService
265+func (_mock *MockAuthService) GetAccessTokenTTL() time.Duration {
266+ ret := _mock.Called()
267+
268+ if len(ret) == 0 {
269+ panic("no return value specified for GetAccessTokenTTL")
270+ }
271+
272+ var r0 time.Duration
273+ if returnFunc, ok := ret.Get(0).(func() time.Duration); ok {
274+ r0 = returnFunc()
275+ } else {
276+ r0 = ret.Get(0).(time.Duration)
277+ }
278+ return r0
279+}
280+
281+// MockAuthService_GetAccessTokenTTL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAccessTokenTTL'
282+type MockAuthService_GetAccessTokenTTL_Call struct {
283+ *mock.Call
284+}
285+
286+// GetAccessTokenTTL is a helper method to define mock.On call
287+func (_e *MockAuthService_Expecter) GetAccessTokenTTL() *MockAuthService_GetAccessTokenTTL_Call {
288+ return &MockAuthService_GetAccessTokenTTL_Call{Call: _e.mock.On("GetAccessTokenTTL")}
289+}
290+
291+func (_c *MockAuthService_GetAccessTokenTTL_Call) Run(run func()) *MockAuthService_GetAccessTokenTTL_Call {
292+ _c.Call.Run(func(args mock.Arguments) {
293+ run()
294+ })
295+ return _c
296+}
297+
298+func (_c *MockAuthService_GetAccessTokenTTL_Call) Return(duration time.Duration) *MockAuthService_GetAccessTokenTTL_Call {
299+ _c.Call.Return(duration)
300+ return _c
301+}
302+
303+func (_c *MockAuthService_GetAccessTokenTTL_Call) RunAndReturn(run func() time.Duration) *MockAuthService_GetAccessTokenTTL_Call {
304+ _c.Call.Return(run)
305+ return _c
306+}
307+
264 // GetAuthStatus provides a mock function for the type MockAuthService308 // GetAuthStatus provides a mock function for the type MockAuthService
265 func (_mock *MockAuthService) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) {309 func (_mock *MockAuthService) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) {
266 ret := _mock.Called(ctx)310 ret := _mock.Called(ctx)
M internal/usecase/auth/service.go +17 -6
@@ -18,9 +18,11 @@ import (
1818 const (
1919 // TokenIssuer is the issuer claim for generated tokens.
2020 TokenIssuer = "gordon-registry"
21+ // DefaultAccessTokenTTL is the default lifetime for ephemeral access tokens.
22+ DefaultAccessTokenTTL = 15 * time.Minute
2123 // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens.
2224 // Tokens with this lifetime or less skip store validation.
23- MaxAccessTokenLifetime = 5 * time.Minute
25+ MaxAccessTokenLifetime = 1 * time.Hour
2426 // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.
2527 maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)
2628 // tokenExtensionTTL is the amount of time added to a token's expiry when extended.
@@ -34,11 +36,12 @@ const (
3436
3537 // Config holds the authentication configuration.
3638 type Config struct {
37- Enabled bool
38- AuthType domain.AuthType
39- Username string
40- TokenSecret []byte // signing secret for token auth
41- TokenExpiry time.Duration // default token expiry (0 = never)
39+ Enabled bool
40+ AuthType domain.AuthType
41+ Username string
42+ TokenSecret []byte // signing secret for token auth
43+ TokenExpiry time.Duration // default token expiry (0 = never)
44+ AccessTokenTTL time.Duration // ephemeral token lifetime (default: 15m)
4245 }
4346
4447 // Service implements the AuthService interface.
@@ -67,6 +70,14 @@ func (s *Service) IsEnabled() bool {
6770 return s.config.Enabled
6871 }
6972
73+// GetAccessTokenTTL returns the configured ephemeral access token lifetime.
74+func (s *Service) GetAccessTokenTTL() time.Duration {
75+ if s.config.AccessTokenTTL > 0 {
76+ return s.config.AccessTokenTTL
77+ }
78+ return DefaultAccessTokenTTL
79+}
80+
7081 // ValidateToken validates a JWT token and returns its claims.
7182 func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
7283 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -18,9 +18,11 @@ import (
18 const (18 const (
19 // TokenIssuer is the issuer claim for generated tokens.19 // TokenIssuer is the issuer claim for generated tokens.
20 TokenIssuer = "gordon-registry"20 TokenIssuer = "gordon-registry"
21+ // DefaultAccessTokenTTL is the default lifetime for ephemeral access tokens.
22+ DefaultAccessTokenTTL = 15 * time.Minute
21 // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens.23 // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens.
22 // Tokens with this lifetime or less skip store validation.24 // Tokens with this lifetime or less skip store validation.
23- MaxAccessTokenLifetime = 5 * time.Minute25+ MaxAccessTokenLifetime = 1 * time.Hour
24 // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.26 // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons.
25 maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)27 maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second)
26 // tokenExtensionTTL is the amount of time added to a token's expiry when extended.28 // tokenExtensionTTL is the amount of time added to a token's expiry when extended.
@@ -34,11 +36,12 @@ const (
34 36
35 // Config holds the authentication configuration.37 // Config holds the authentication configuration.
36 type Config struct {38 type Config struct {
37- Enabled bool39+ Enabled bool
38- AuthType domain.AuthType40+ AuthType domain.AuthType
39- Username string41+ Username string
40- TokenSecret []byte // signing secret for token auth42+ TokenSecret []byte // signing secret for token auth
41- TokenExpiry time.Duration // default token expiry (0 = never)43+ TokenExpiry time.Duration // default token expiry (0 = never)
44+ AccessTokenTTL time.Duration // ephemeral token lifetime (default: 15m)
42 }45 }
43 46
44 // Service implements the AuthService interface.47 // Service implements the AuthService interface.
@@ -67,6 +70,14 @@ func (s *Service) IsEnabled() bool {
67 return s.config.Enabled70 return s.config.Enabled
68 }71 }
69 72
73+// GetAccessTokenTTL returns the configured ephemeral access token lifetime.
74+func (s *Service) GetAccessTokenTTL() time.Duration {
75+ if s.config.AccessTokenTTL > 0 {
76+ return s.config.AccessTokenTTL
77+ }
78+ return DefaultAccessTokenTTL
79+}
80+
70 // ValidateToken validates a JWT token and returns its claims.81 // ValidateToken validates a JWT token and returns its claims.
71 func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {82 func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
72 ctx = zerowrap.CtxWithFields(ctx, map[string]any{83 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/usecase/auth/service_test.go +9 -9
@@ -180,13 +180,13 @@ func TestService_GenerateAccessToken_RejectsInvalidExpiry(t *testing.T) {
180180 assert.Contains(t, err.Error(), "expiry must be positive")
181181
182182 // Expiry exceeding max should be rejected
183- _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 10*time.Minute)
183+ _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 2*time.Hour)
184184 assert.Error(t, err)
185185 assert.Contains(t, err.Error(), "exceeds maximum")
186186 }
187187
188188 func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
189- // Tokens with expiry > 5 minutes must exist in store
189+ // Tokens with expiry > MaxAccessTokenLifetime (1h) must exist in store
190190 tokenStore := mocks.NewMockTokenStore(t)
191191
192192 svc := NewService(Config{
@@ -206,7 +206,7 @@ func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
206206 }).
207207 Return(nil)
208208
209- token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, time.Hour)
209+ token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, 2*time.Hour)
210210 assert.NoError(t, err)
211211
212212 // Validation should call GetToken and IsRevoked
@@ -283,7 +283,7 @@ func TestService_ValidateToken_Success(t *testing.T) {
283283 Return(nil)
284284
285285 ctx := testContext()
286- tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
286+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, 2*time.Hour)
287287 require.NoError(t, err)
288288
289289 // Expect GetToken to be called to verify token exists
@@ -341,7 +341,7 @@ func TestService_ValidateToken_Revoked(t *testing.T) {
341341 Return(nil)
342342
343343 ctx := testContext()
344- tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)
344+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, 2*time.Hour)
345345 require.NoError(t, err)
346346
347347 // Expect GetToken to be called to verify token exists
@@ -743,7 +743,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) {
743743
744744 ctx := testContext()
745745
746- // Generate a token with a short remaining life (1h) so ExtendToken produces a different exp
746+ // Generate a token with a short remaining life (2h) so ExtendToken produces a different exp
747747 var capturedToken *domain.Token
748748 tokenStore.EXPECT().
749749 SaveToken(mock.Anything, mock.Anything, mock.Anything).
@@ -752,7 +752,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) {
752752 }).
753753 Return(nil)
754754
755- tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)
755+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, 2*time.Hour)
756756 require.NoError(t, err)
757757
758758 // ExtendToken flow:
@@ -815,7 +815,7 @@ func TestExtendTokenDebounce(t *testing.T) {
815815
816816 ctx := testContext()
817817
818- // Generate a short-lived token (1h) so the first extension produces a different JWT
818+ // Generate a short-lived token (2h) so the first extension produces a different JWT
819819 var capturedToken *domain.Token
820820 tokenStore.EXPECT().
821821 SaveToken(mock.Anything, mock.Anything, mock.Anything).
@@ -824,7 +824,7 @@ func TestExtendTokenDebounce(t *testing.T) {
824824 }).
825825 Return(nil)
826826
827- tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)
827+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, 2*time.Hour)
828828 require.NoError(t, err)
829829
830830 // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry
@@ -180,13 +180,13 @@ func TestService_GenerateAccessToken_RejectsInvalidExpiry(t *testing.T) {
180 assert.Contains(t, err.Error(), "expiry must be positive")180 assert.Contains(t, err.Error(), "expiry must be positive")
181 181
182 // Expiry exceeding max should be rejected182 // Expiry exceeding max should be rejected
183- _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 10*time.Minute)183+ _, err = svc.GenerateAccessToken(ctx, "testuser", []string{"repository:myrepo:pull"}, 2*time.Hour)
184 assert.Error(t, err)184 assert.Error(t, err)
185 assert.Contains(t, err.Error(), "exceeds maximum")185 assert.Contains(t, err.Error(), "exceeds maximum")
186 }186 }
187 187
188 func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {188 func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
189- // Tokens with expiry > 5 minutes must exist in store189+ // Tokens with expiry > MaxAccessTokenLifetime (1h) must exist in store
190 tokenStore := mocks.NewMockTokenStore(t)190 tokenStore := mocks.NewMockTokenStore(t)
191 191
192 svc := NewService(Config{192 svc := NewService(Config{
@@ -206,7 +206,7 @@ func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) {
206 }).206 }).
207 Return(nil)207 Return(nil)
208 208
209- token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, time.Hour)209+ token, err := svc.GenerateToken(ctx, "testuser", []string{"push", "pull"}, 2*time.Hour)
210 assert.NoError(t, err)210 assert.NoError(t, err)
211 211
212 // Validation should call GetToken and IsRevoked212 // Validation should call GetToken and IsRevoked
@@ -283,7 +283,7 @@ func TestService_ValidateToken_Success(t *testing.T) {
283 Return(nil)283 Return(nil)
284 284
285 ctx := testContext()285 ctx := testContext()
286- tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)286+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, 2*time.Hour)
287 require.NoError(t, err)287 require.NoError(t, err)
288 288
289 // Expect GetToken to be called to verify token exists289 // Expect GetToken to be called to verify token exists
@@ -341,7 +341,7 @@ func TestService_ValidateToken_Revoked(t *testing.T) {
341 Return(nil)341 Return(nil)
342 342
343 ctx := testContext()343 ctx := testContext()
344- tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, time.Hour)344+ tokenStr, err := svc.GenerateToken(ctx, "testsubject", []string{"push", "pull"}, 2*time.Hour)
345 require.NoError(t, err)345 require.NoError(t, err)
346 346
347 // Expect GetToken to be called to verify token exists347 // Expect GetToken to be called to verify token exists
@@ -743,7 +743,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) {
743 743
744 ctx := testContext()744 ctx := testContext()
745 745
746- // Generate a token with a short remaining life (1h) so ExtendToken produces a different exp746+ // Generate a token with a short remaining life (2h) so ExtendToken produces a different exp
747 var capturedToken *domain.Token747 var capturedToken *domain.Token
748 tokenStore.EXPECT().748 tokenStore.EXPECT().
749 SaveToken(mock.Anything, mock.Anything, mock.Anything).749 SaveToken(mock.Anything, mock.Anything, mock.Anything).
@@ -752,7 +752,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) {
752 }).752 }).
753 Return(nil)753 Return(nil)
754 754
755- tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)755+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, 2*time.Hour)
756 require.NoError(t, err)756 require.NoError(t, err)
757 757
758 // ExtendToken flow:758 // ExtendToken flow:
@@ -815,7 +815,7 @@ func TestExtendTokenDebounce(t *testing.T) {
815 815
816 ctx := testContext()816 ctx := testContext()
817 817
818- // Generate a short-lived token (1h) so the first extension produces a different JWT818+ // Generate a short-lived token (2h) so the first extension produces a different JWT
819 var capturedToken *domain.Token819 var capturedToken *domain.Token
820 tokenStore.EXPECT().820 tokenStore.EXPECT().
821 SaveToken(mock.Anything, mock.Anything, mock.Anything).821 SaveToken(mock.Anything, mock.Anything, mock.Anything).
@@ -824,7 +824,7 @@ func TestExtendTokenDebounce(t *testing.T) {
824 }).824 }).
825 Return(nil)825 Return(nil)
826 826
827- tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, time.Hour)827+ tokenStr, err := svc.GenerateToken(ctx, "testuser", []string{"admin:*:*"}, 2*time.Hour)
828 require.NoError(t, err)828 require.NoError(t, err)
829 829
830 // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry830 // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry

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