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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 135 | 135 | } | |
| 136 | 136 | } | |
| 137 | 137 | ||
| 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) | |
| 140 | 141 | if err != nil { | |
| 141 | 142 | log.Error().Err(err).Msg("failed to generate access token") | |
| 142 | 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 | 149 | response := dto.TokenResponse{ | |
| 149 | 150 | Token: accessToken, | |
| 150 | - | ExpiresIn: 300, // 5 minutes in seconds | |
| 151 | + | ExpiresIn: int(ttl.Seconds()), | |
| 151 | 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 | 186 | for _, reqScopeStr := range requestedScopes { | |
| 186 | 187 | reqScope, err := domain.ParseScope(reqScopeStr) | |
| 187 | - | if err != nil || reqScope.Type != domain.ScopeTypeRepository { | |
| 188 | + | if err != nil { | |
| 188 | 189 | continue | |
| 189 | 190 | } | |
| 190 | 191 | ||
| 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 | + | } | |
| 195 | 199 | } | |
| 196 | - | } | |
| 197 | 200 | ||
| 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 | + | } | |
| 201 | 218 | ||
| 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 | + | } | |
| 207 | 229 | } | |
| 208 | 230 | ||
| 209 | 231 | log.Debug(). | |
| @@ -242,6 +264,26 @@ func hasGrantedRegistryAccess(grantedScopes []string, repoName, action string) b | |||
| 242 | 264 | 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 | 287 | // parseRequestedScopes extracts and validates scope parameters from the request. | |
| 246 | 288 | // Per Docker Registry v2 auth spec, scope format is: repository:name:actions | |
| 247 | 289 | // 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 | 308 | continue | |
| 267 | 309 | } | |
| 268 | 310 | ||
| 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 { | |
| 271 | 313 | log.Debug(). | |
| 272 | 314 | Str("scope_type", scope.Type). | |
| 273 | 315 | 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 stored | 138 | + | // 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 seconds | 151 | + | 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 | continue | 189 | 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 | - | continue | 202 | + | 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 false | 264 | 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:actions | 288 | // 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:pull | 289 | // 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 | continue | 308 | continue | ||
| 267 | } | 309 | } | ||
| 268 | 310 | ||||
| 269 | - | // Only allow repository scopes | 311 | + | // 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) { | |||
| 44 | 44 | Subject: "admin", | |
| 45 | 45 | Scopes: []string{"repository:myrepo:pull"}, | |
| 46 | 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 | 49 | Return("access-token", nil) | |
| 49 | 50 | ||
| 50 | 51 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| @@ -61,7 +62,7 @@ func TestHandler_Token_Success(t *testing.T) { | |||
| 61 | 62 | err := json.NewDecoder(rec.Body).Decode(&resp) | |
| 62 | 63 | assert.NoError(t, err) | |
| 63 | 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 | 68 | func TestHandler_Token_MissingCredentials(t *testing.T) { | |
| @@ -116,7 +117,8 @@ func TestHandler_Token_InternalAuth(t *testing.T) { | |||
| 116 | 117 | authSvc := mocks.NewMockAuthService(t) | |
| 117 | 118 | ||
| 118 | 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 | 122 | Return("internal-access-token", nil) | |
| 121 | 123 | ||
| 122 | 124 | internalAuth := InternalAuth{ | |
| @@ -148,7 +150,8 @@ func TestHandler_Token_TokenAuth(t *testing.T) { | |||
| 148 | 150 | Subject: "myuser", | |
| 149 | 151 | Scopes: []string{"repository:*:pull"}, | |
| 150 | 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 | 155 | Return("short-lived-token", nil) | |
| 153 | 156 | ||
| 154 | 157 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| @@ -175,7 +178,8 @@ func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) { | |||
| 175 | 178 | Subject: "myuser", | |
| 176 | 179 | Scopes: []string{"repository:myrepo:pull"}, | |
| 177 | 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 | 183 | Return("scoped-token", nil) | |
| 180 | 184 | ||
| 181 | 185 | handler := NewHandler(authSvc, InternalAuth{}, testLogger()) | |
| @@ -223,7 +227,8 @@ func TestHandler_Token_PasswordAuthWithJWT(t *testing.T) { | |||
| 223 | 227 | Subject: "brissou", | |
| 224 | 228 | Scopes: []string{"*"}, | |
| 225 | 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 | 232 | Return("short-lived-token", nil) | |
| 228 | 233 | ||
| 229 | 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 | + | } | |
| @@ -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) { | |||
| 162 | 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 | 169 | // Tests for checkScopeAccess function | |
| 166 | 170 | ||
| 167 | 171 | 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 function | 169 | // 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 { | |||
| 130 | 130 | Type string `mapstructure:"type"` // "password" or "token" | |
| 131 | 131 | SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe" | |
| 132 | 132 | 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) | |
| 137 | 138 | } `mapstructure:"auth"` | |
| 138 | 139 | ||
| 139 | 140 | API struct { | |
| @@ -1088,6 +1089,19 @@ func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, | |||
| 1088 | 1089 | authConfig.TokenSecret = secret | |
| 1089 | 1090 | 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 | 1105 | return authConfig, nil | |
| 1092 | 1106 | } | |
| 1093 | 1107 | ||
| @@ -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_hash | 133 | + | Password string `mapstructure:"password"` // deprecated: use password_hash |
| 134 | - | PasswordHash string `mapstructure:"password_hash"` // path in secrets backend | 134 | + | PasswordHash string `mapstructure:"password_hash"` // path in secrets backend |
| 135 | - | TokenSecret string `mapstructure:"token_secret"` // path in secrets backend | 135 | + | 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 = secret | 1089 | authConfig.TokenSecret = secret | ||
| 1089 | authConfig.TokenExpiry = expiry | 1090 | 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, nil | 1105 | return authConfig, nil | ||
| 1092 | } | 1106 | } | ||
| 1093 | 1107 | ||||
M internal/boundaries/in/auth.go +3 -0
| @@ -43,4 +43,7 @@ type AuthService interface { | |||
| 43 | 43 | // Returns the same token string if it was already extended within the debounce window (1h). | |
| 44 | 44 | // Skips extension for ephemeral access tokens (≤5min) and service tokens. | |
| 45 | 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 | } | |
| @@ -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. | |||
| 261 | 261 | 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 | 308 | // GetAuthStatus provides a mock function for the type MockAuthService | |
| 265 | 309 | func (_mock *MockAuthService) GetAuthStatus(ctx context.Context) (*domain.AuthStatus, error) { | |
| 266 | 310 | ret := _mock.Called(ctx) | |
| @@ -261,6 +261,50 @@ func (_c *MockAuthService_GenerateToken_Call) RunAndReturn(run func(ctx context. | |||||
| 261 | return _c | 261 | 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 MockAuthService | 308 | // 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 ( | |||
| 18 | 18 | const ( | |
| 19 | 19 | // TokenIssuer is the issuer claim for generated tokens. | |
| 20 | 20 | TokenIssuer = "gordon-registry" | |
| 21 | + | // DefaultAccessTokenTTL is the default lifetime for ephemeral access tokens. | |
| 22 | + | DefaultAccessTokenTTL = 15 * time.Minute | |
| 21 | 23 | // MaxAccessTokenLifetime is the maximum lifetime for ephemeral access tokens. | |
| 22 | 24 | // Tokens with this lifetime or less skip store validation. | |
| 23 | - | MaxAccessTokenLifetime = 5 * time.Minute | |
| 25 | + | MaxAccessTokenLifetime = 1 * time.Hour | |
| 24 | 26 | // maxAccessTokenLifetimeSecs is MaxAccessTokenLifetime in seconds for JWT comparisons. | |
| 25 | 27 | maxAccessTokenLifetimeSecs = int64(MaxAccessTokenLifetime / time.Second) | |
| 26 | 28 | // tokenExtensionTTL is the amount of time added to a token's expiry when extended. | |
| @@ -34,11 +36,12 @@ const ( | |||
| 34 | 36 | ||
| 35 | 37 | // Config holds the authentication configuration. | |
| 36 | 38 | 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) | |
| 42 | 45 | } | |
| 43 | 46 | ||
| 44 | 47 | // Service implements the AuthService interface. | |
| @@ -67,6 +70,14 @@ func (s *Service) IsEnabled() bool { | |||
| 67 | 70 | 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 | 81 | // ValidateToken validates a JWT token and returns its claims. | |
| 71 | 82 | func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) { | |
| 72 | 83 | 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.Minute | 25 | + | 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 bool | 39 | + | Enabled bool |
| 38 | - | AuthType domain.AuthType | 40 | + | AuthType domain.AuthType |
| 39 | - | Username string | 41 | + | Username string |
| 40 | - | TokenSecret []byte // signing secret for token auth | 42 | + | 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.Enabled | 70 | 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) { | |||
| 180 | 180 | assert.Contains(t, err.Error(), "expiry must be positive") | |
| 181 | 181 | ||
| 182 | 182 | // 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 | 184 | assert.Error(t, err) | |
| 185 | 185 | assert.Contains(t, err.Error(), "exceeds maximum") | |
| 186 | 186 | } | |
| 187 | 187 | ||
| 188 | 188 | 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 | |
| 190 | 190 | tokenStore := mocks.NewMockTokenStore(t) | |
| 191 | 191 | ||
| 192 | 192 | svc := NewService(Config{ | |
| @@ -206,7 +206,7 @@ func TestService_ValidateToken_LongLivedRequiresStore(t *testing.T) { | |||
| 206 | 206 | }). | |
| 207 | 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 | 210 | assert.NoError(t, err) | |
| 211 | 211 | ||
| 212 | 212 | // Validation should call GetToken and IsRevoked | |
| @@ -283,7 +283,7 @@ func TestService_ValidateToken_Success(t *testing.T) { | |||
| 283 | 283 | Return(nil) | |
| 284 | 284 | ||
| 285 | 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 | 287 | require.NoError(t, err) | |
| 288 | 288 | ||
| 289 | 289 | // Expect GetToken to be called to verify token exists | |
| @@ -341,7 +341,7 @@ func TestService_ValidateToken_Revoked(t *testing.T) { | |||
| 341 | 341 | Return(nil) | |
| 342 | 342 | ||
| 343 | 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 | 345 | require.NoError(t, err) | |
| 346 | 346 | ||
| 347 | 347 | // Expect GetToken to be called to verify token exists | |
| @@ -743,7 +743,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) { | |||
| 743 | 743 | ||
| 744 | 744 | ctx := testContext() | |
| 745 | 745 | ||
| 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 | |
| 747 | 747 | var capturedToken *domain.Token | |
| 748 | 748 | tokenStore.EXPECT(). | |
| 749 | 749 | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |
| @@ -752,7 +752,7 @@ func TestExtendTokenSlidesExpiry(t *testing.T) { | |||
| 752 | 752 | }). | |
| 753 | 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 | 756 | require.NoError(t, err) | |
| 757 | 757 | ||
| 758 | 758 | // ExtendToken flow: | |
| @@ -815,7 +815,7 @@ func TestExtendTokenDebounce(t *testing.T) { | |||
| 815 | 815 | ||
| 816 | 816 | ctx := testContext() | |
| 817 | 817 | ||
| 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 | |
| 819 | 819 | var capturedToken *domain.Token | |
| 820 | 820 | tokenStore.EXPECT(). | |
| 821 | 821 | SaveToken(mock.Anything, mock.Anything, mock.Anything). | |
| @@ -824,7 +824,7 @@ func TestExtendTokenDebounce(t *testing.T) { | |||
| 824 | 824 | }). | |
| 825 | 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 | 828 | require.NoError(t, err) | |
| 829 | 829 | ||
| 830 | 830 | // 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 rejected | 182 | // 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 store | 189 | + | // 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 IsRevoked | 212 | // 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 exists | 289 | // 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 exists | 347 | // 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 exp | 746 | + | // Generate a token with a short remaining life (2h) so ExtendToken produces a different exp |
| 747 | var capturedToken *domain.Token | 747 | 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 JWT | 818 | + | // Generate a short-lived token (2h) so the first extension produces a different JWT |
| 819 | var capturedToken *domain.Token | 819 | 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) + UpdateTokenExpiry | 830 | // First extend: flow is ValidateToken(GetToken+IsRevoked) + GetToken(debounce) + UpdateTokenExpiry | ||