fix(security): reject long-lived tokens on admin and registry endpoints
- commit
- 589c4c390a811418c3217db1d0f6e434d5f2914b
- author
- brice · · 2026-03-22 19:44 UTC
- parent
- b9504fc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +258 -25
M internal/adapters/in/http/admin/middleware.go +11 -0
| @@ -86,6 +86,17 @@ func AuthMiddleware( | |||
| 86 | 86 | return | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | + | // SECURITY: Reject long-lived (stored) tokens — they must be exchanged | |
| 90 | + | // for ephemeral access tokens via /auth/token first. | |
| 91 | + | if !claims.IsEphemeral { | |
| 92 | + | log.Warn(). | |
| 93 | + | Str("subject", claims.Subject). | |
| 94 | + | Str("path", r.URL.Path). | |
| 95 | + | Msg("long-lived token rejected on admin endpoint") | |
| 96 | + | sendUnauthorized(w, "long-lived tokens must be exchanged via /auth/token") | |
| 97 | + | return | |
| 98 | + | } | |
| 99 | + | ||
| 89 | 100 | // Check if token has admin scopes | |
| 90 | 101 | hasAdminScope := false | |
| 91 | 102 | for _, scope := range claims.Scopes { | |
| @@ -86,6 +86,17 @@ func AuthMiddleware( | |||||
| 86 | return | 86 | return | ||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | + | // SECURITY: Reject long-lived (stored) tokens — they must be exchanged | |||
| 90 | + | // for ephemeral access tokens via /auth/token first. | |||
| 91 | + | if !claims.IsEphemeral { | |||
| 92 | + | log.Warn(). | |||
| 93 | + | Str("subject", claims.Subject). | |||
| 94 | + | Str("path", r.URL.Path). | |||
| 95 | + | Msg("long-lived token rejected on admin endpoint") | |||
| 96 | + | sendUnauthorized(w, "long-lived tokens must be exchanged via /auth/token") | |||
| 97 | + | return | |||
| 98 | + | } | |||
| 99 | + | ||||
| 89 | // Check if token has admin scopes | 100 | // Check if token has admin scopes | ||
| 90 | hasAdminScope := false | 101 | hasAdminScope := false | ||
| 91 | for _, scope := range claims.Scopes { | 102 | for _, scope := range claims.Scopes { | ||
M internal/adapters/in/http/admin/middleware_test.go +76 -12
| @@ -85,8 +85,9 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||
| 85 | 85 | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | |
| 86 | 86 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 87 | 87 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |
| 88 | - | Subject: "admin", | |
| 89 | - | Scopes: []string{"admin:*:*"}, | |
| 88 | + | Subject: "admin", | |
| 89 | + | Scopes: []string{"admin:*:*"}, | |
| 90 | + | IsEphemeral: true, | |
| 90 | 91 | }, nil) | |
| 91 | 92 | // ExtendToken is called after successful validation; non-fatal if it fails | |
| 92 | 93 | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | |
| @@ -114,8 +115,9 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |||
| 114 | 115 | // No rate limiters, auth still required | |
| 115 | 116 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 116 | 117 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |
| 117 | - | Subject: "admin", | |
| 118 | - | Scopes: []string{"admin:*:*"}, | |
| 118 | + | Subject: "admin", | |
| 119 | + | Scopes: []string{"admin:*:*"}, | |
| 120 | + | IsEphemeral: true, | |
| 119 | 121 | }, nil) | |
| 120 | 122 | // ExtendToken is called after successful validation; non-fatal if it fails | |
| 121 | 123 | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | |
| @@ -216,6 +218,64 @@ func TestAuthMiddleware_InvalidToken(t *testing.T) { | |||
| 216 | 218 | assert.Contains(t, rec.Body.String(), "invalid token") | |
| 217 | 219 | } | |
| 218 | 220 | ||
| 221 | + | func TestAuthMiddleware_RejectsLongLivedToken(t *testing.T) { | |
| 222 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 223 | + | ||
| 224 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 225 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "long-lived-token").Return(&domain.TokenClaims{ | |
| 226 | + | Subject: "admin", | |
| 227 | + | Scopes: []string{"admin:*:*"}, | |
| 228 | + | IsEphemeral: false, // long-lived stored token | |
| 229 | + | }, nil) | |
| 230 | + | ||
| 231 | + | handlerCalled := false | |
| 232 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 233 | + | handlerCalled = true | |
| 234 | + | w.WriteHeader(http.StatusOK) | |
| 235 | + | }) | |
| 236 | + | ||
| 237 | + | mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger()) | |
| 238 | + | wrappedHandler := mw(handler) | |
| 239 | + | ||
| 240 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 241 | + | req.Header.Set("Authorization", "Bearer long-lived-token") | |
| 242 | + | rec := httptest.NewRecorder() | |
| 243 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 244 | + | ||
| 245 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 246 | + | assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token") | |
| 247 | + | assert.False(t, handlerCalled, "handler should not be called for long-lived tokens") | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func TestAuthMiddleware_AcceptsEphemeralToken(t *testing.T) { | |
| 251 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 252 | + | ||
| 253 | + | authSvc.EXPECT().IsEnabled().Return(true) | |
| 254 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "ephemeral-token").Return(&domain.TokenClaims{ | |
| 255 | + | Subject: "admin", | |
| 256 | + | Scopes: []string{"admin:*:*"}, | |
| 257 | + | IsEphemeral: true, // short-lived ephemeral token | |
| 258 | + | }, nil) | |
| 259 | + | authSvc.EXPECT().ExtendToken(mock.Anything, "ephemeral-token").Return("ephemeral-token", nil) | |
| 260 | + | ||
| 261 | + | handlerCalled := false | |
| 262 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 263 | + | handlerCalled = true | |
| 264 | + | w.WriteHeader(http.StatusOK) | |
| 265 | + | }) | |
| 266 | + | ||
| 267 | + | mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger()) | |
| 268 | + | wrappedHandler := mw(handler) | |
| 269 | + | ||
| 270 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |
| 271 | + | req.Header.Set("Authorization", "Bearer ephemeral-token") | |
| 272 | + | rec := httptest.NewRecorder() | |
| 273 | + | wrappedHandler.ServeHTTP(rec, req) | |
| 274 | + | ||
| 275 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 276 | + | assert.True(t, handlerCalled, "handler should be called for ephemeral tokens") | |
| 277 | + | } | |
| 278 | + | ||
| 219 | 279 | func TestAuthMiddleware_MissingAdminScope(t *testing.T) { | |
| 220 | 280 | authSvc := inmocks.NewMockAuthService(t) | |
| 221 | 281 | globalLimiter := outmocks.NewMockRateLimiter(t) | |
| @@ -225,8 +285,9 @@ func TestAuthMiddleware_MissingAdminScope(t *testing.T) { | |||
| 225 | 285 | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |
| 226 | 286 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 227 | 287 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-token").Return(&domain.TokenClaims{ | |
| 228 | - | Subject: "user1", | |
| 229 | - | Scopes: []string{"push", "pull"}, // No admin scopes | |
| 288 | + | Subject: "user1", | |
| 289 | + | Scopes: []string{"push", "pull"}, // No admin scopes | |
| 290 | + | IsEphemeral: true, | |
| 230 | 291 | }, nil) | |
| 231 | 292 | ||
| 232 | 293 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| @@ -255,8 +316,9 @@ func TestAuthMiddleware_Success(t *testing.T) { | |||
| 255 | 316 | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | |
| 256 | 317 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 257 | 318 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |
| 258 | - | Subject: "admin", | |
| 259 | - | Scopes: []string{"admin:*:*"}, | |
| 319 | + | Subject: "admin", | |
| 320 | + | Scopes: []string{"admin:*:*"}, | |
| 321 | + | IsEphemeral: true, | |
| 260 | 322 | }, nil) | |
| 261 | 323 | // ExtendToken is called after successful validation; non-fatal if it fails | |
| 262 | 324 | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | |
| @@ -376,8 +438,9 @@ func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) { | |||
| 376 | 438 | ||
| 377 | 439 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 378 | 440 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | |
| 379 | - | Subject: "admin", | |
| 380 | - | Scopes: []string{"admin:*:*"}, | |
| 441 | + | Subject: "admin", | |
| 442 | + | Scopes: []string{"admin:*:*"}, | |
| 443 | + | IsEphemeral: true, | |
| 381 | 444 | }, nil) | |
| 382 | 445 | // ExtendToken fails — response must still be 200 (non-fatal) | |
| 383 | 446 | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("", errors.New("store unavailable")) | |
| @@ -405,8 +468,9 @@ func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) { | |||
| 405 | 468 | ||
| 406 | 469 | authSvc.EXPECT().IsEnabled().Return(true) | |
| 407 | 470 | authSvc.EXPECT().ValidateToken(mock.Anything, "old-token").Return(&domain.TokenClaims{ | |
| 408 | - | Subject: "admin", | |
| 409 | - | Scopes: []string{"admin:*:*"}, | |
| 471 | + | Subject: "admin", | |
| 472 | + | Scopes: []string{"admin:*:*"}, | |
| 473 | + | IsEphemeral: true, | |
| 410 | 474 | }, nil) | |
| 411 | 475 | // ExtendToken returns a rotated token — must appear in response header | |
| 412 | 476 | authSvc.EXPECT().ExtendToken(mock.Anything, "old-token").Return("rotated-token", nil) | |
| @@ -85,8 +85,9 @@ func TestAuthMiddleware_TrustedProxy(t *testing.T) { | |||||
| 85 | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | 85 | ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true) | ||
| 86 | authSvc.EXPECT().IsEnabled().Return(true) | 86 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 87 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | 87 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | ||
| 88 | - | Subject: "admin", | 88 | + | Subject: "admin", |
| 89 | - | Scopes: []string{"admin:*:*"}, | 89 | + | Scopes: []string{"admin:*:*"}, |
| 90 | + | IsEphemeral: true, | |||
| 90 | }, nil) | 91 | }, nil) | ||
| 91 | // ExtendToken is called after successful validation; non-fatal if it fails | 92 | // ExtendToken is called after successful validation; non-fatal if it fails | ||
| 92 | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | 93 | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | ||
| @@ -114,8 +115,9 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) { | |||||
| 114 | // No rate limiters, auth still required | 115 | // No rate limiters, auth still required | ||
| 115 | authSvc.EXPECT().IsEnabled().Return(true) | 116 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 116 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | 117 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | ||
| 117 | - | Subject: "admin", | 118 | + | Subject: "admin", |
| 118 | - | Scopes: []string{"admin:*:*"}, | 119 | + | Scopes: []string{"admin:*:*"}, |
| 120 | + | IsEphemeral: true, | |||
| 119 | }, nil) | 121 | }, nil) | ||
| 120 | // ExtendToken is called after successful validation; non-fatal if it fails | 122 | // ExtendToken is called after successful validation; non-fatal if it fails | ||
| 121 | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | 123 | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | ||
| @@ -216,6 +218,64 @@ func TestAuthMiddleware_InvalidToken(t *testing.T) { | |||||
| 216 | assert.Contains(t, rec.Body.String(), "invalid token") | 218 | assert.Contains(t, rec.Body.String(), "invalid token") | ||
| 217 | } | 219 | } | ||
| 218 | 220 | ||||
| 221 | + | func TestAuthMiddleware_RejectsLongLivedToken(t *testing.T) { | |||
| 222 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 223 | + | ||||
| 224 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 225 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "long-lived-token").Return(&domain.TokenClaims{ | |||
| 226 | + | Subject: "admin", | |||
| 227 | + | Scopes: []string{"admin:*:*"}, | |||
| 228 | + | IsEphemeral: false, // long-lived stored token | |||
| 229 | + | }, nil) | |||
| 230 | + | ||||
| 231 | + | handlerCalled := false | |||
| 232 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 233 | + | handlerCalled = true | |||
| 234 | + | w.WriteHeader(http.StatusOK) | |||
| 235 | + | }) | |||
| 236 | + | ||||
| 237 | + | mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger()) | |||
| 238 | + | wrappedHandler := mw(handler) | |||
| 239 | + | ||||
| 240 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 241 | + | req.Header.Set("Authorization", "Bearer long-lived-token") | |||
| 242 | + | rec := httptest.NewRecorder() | |||
| 243 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 244 | + | ||||
| 245 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 246 | + | assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token") | |||
| 247 | + | assert.False(t, handlerCalled, "handler should not be called for long-lived tokens") | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func TestAuthMiddleware_AcceptsEphemeralToken(t *testing.T) { | |||
| 251 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 252 | + | ||||
| 253 | + | authSvc.EXPECT().IsEnabled().Return(true) | |||
| 254 | + | authSvc.EXPECT().ValidateToken(mock.Anything, "ephemeral-token").Return(&domain.TokenClaims{ | |||
| 255 | + | Subject: "admin", | |||
| 256 | + | Scopes: []string{"admin:*:*"}, | |||
| 257 | + | IsEphemeral: true, // short-lived ephemeral token | |||
| 258 | + | }, nil) | |||
| 259 | + | authSvc.EXPECT().ExtendToken(mock.Anything, "ephemeral-token").Return("ephemeral-token", nil) | |||
| 260 | + | ||||
| 261 | + | handlerCalled := false | |||
| 262 | + | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 263 | + | handlerCalled = true | |||
| 264 | + | w.WriteHeader(http.StatusOK) | |||
| 265 | + | }) | |||
| 266 | + | ||||
| 267 | + | mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger()) | |||
| 268 | + | wrappedHandler := mw(handler) | |||
| 269 | + | ||||
| 270 | + | req := httptest.NewRequest(http.MethodGet, "/admin/status", nil) | |||
| 271 | + | req.Header.Set("Authorization", "Bearer ephemeral-token") | |||
| 272 | + | rec := httptest.NewRecorder() | |||
| 273 | + | wrappedHandler.ServeHTTP(rec, req) | |||
| 274 | + | ||||
| 275 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 276 | + | assert.True(t, handlerCalled, "handler should be called for ephemeral tokens") | |||
| 277 | + | } | |||
| 278 | + | ||||
| 219 | func TestAuthMiddleware_MissingAdminScope(t *testing.T) { | 279 | func TestAuthMiddleware_MissingAdminScope(t *testing.T) { | ||
| 220 | authSvc := inmocks.NewMockAuthService(t) | 280 | authSvc := inmocks.NewMockAuthService(t) | ||
| 221 | globalLimiter := outmocks.NewMockRateLimiter(t) | 281 | globalLimiter := outmocks.NewMockRateLimiter(t) | ||
| @@ -225,8 +285,9 @@ func TestAuthMiddleware_MissingAdminScope(t *testing.T) { | |||||
| 225 | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | 285 | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | ||
| 226 | authSvc.EXPECT().IsEnabled().Return(true) | 286 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 227 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-token").Return(&domain.TokenClaims{ | 287 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-token").Return(&domain.TokenClaims{ | ||
| 228 | - | Subject: "user1", | 288 | + | Subject: "user1", |
| 229 | - | Scopes: []string{"push", "pull"}, // No admin scopes | 289 | + | Scopes: []string{"push", "pull"}, // No admin scopes |
| 290 | + | IsEphemeral: true, | |||
| 230 | }, nil) | 291 | }, nil) | ||
| 231 | 292 | ||||
| 232 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | 293 | handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | ||
| @@ -255,8 +316,9 @@ func TestAuthMiddleware_Success(t *testing.T) { | |||||
| 255 | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | 316 | ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true) | ||
| 256 | authSvc.EXPECT().IsEnabled().Return(true) | 317 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 257 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | 318 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | ||
| 258 | - | Subject: "admin", | 319 | + | Subject: "admin", |
| 259 | - | Scopes: []string{"admin:*:*"}, | 320 | + | Scopes: []string{"admin:*:*"}, |
| 321 | + | IsEphemeral: true, | |||
| 260 | }, nil) | 322 | }, nil) | ||
| 261 | // ExtendToken is called after successful validation; non-fatal if it fails | 323 | // ExtendToken is called after successful validation; non-fatal if it fails | ||
| 262 | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | 324 | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil) | ||
| @@ -376,8 +438,9 @@ func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) { | |||||
| 376 | 438 | ||||
| 377 | authSvc.EXPECT().IsEnabled().Return(true) | 439 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 378 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | 440 | authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{ | ||
| 379 | - | Subject: "admin", | 441 | + | Subject: "admin", |
| 380 | - | Scopes: []string{"admin:*:*"}, | 442 | + | Scopes: []string{"admin:*:*"}, |
| 443 | + | IsEphemeral: true, | |||
| 381 | }, nil) | 444 | }, nil) | ||
| 382 | // ExtendToken fails — response must still be 200 (non-fatal) | 445 | // ExtendToken fails — response must still be 200 (non-fatal) | ||
| 383 | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("", errors.New("store unavailable")) | 446 | authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("", errors.New("store unavailable")) | ||
| @@ -405,8 +468,9 @@ func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) { | |||||
| 405 | 468 | ||||
| 406 | authSvc.EXPECT().IsEnabled().Return(true) | 469 | authSvc.EXPECT().IsEnabled().Return(true) | ||
| 407 | authSvc.EXPECT().ValidateToken(mock.Anything, "old-token").Return(&domain.TokenClaims{ | 470 | authSvc.EXPECT().ValidateToken(mock.Anything, "old-token").Return(&domain.TokenClaims{ | ||
| 408 | - | Subject: "admin", | 471 | + | Subject: "admin", |
| 409 | - | Scopes: []string{"admin:*:*"}, | 472 | + | Scopes: []string{"admin:*:*"}, |
| 473 | + | IsEphemeral: true, | |||
| 410 | }, nil) | 474 | }, nil) | ||
| 411 | // ExtendToken returns a rotated token — must appear in response header | 475 | // ExtendToken returns a rotated token — must appear in response header | ||
| 412 | authSvc.EXPECT().ExtendToken(mock.Anything, "old-token").Return("rotated-token", nil) | 476 | authSvc.EXPECT().ExtendToken(mock.Anything, "old-token").Return("rotated-token", nil) | ||
M internal/adapters/in/http/middleware/auth.go +70 -13
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/subtle" | |
| 6 | 6 | "encoding/json" | |
| 7 | + | "errors" | |
| 7 | 8 | "net/http" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ||
| @@ -15,6 +16,10 @@ import ( | |||
| 15 | 16 | "github.com/bnema/gordon/internal/domain" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 19 | + | // errLongLivedToken is returned when a long-lived (stored) token is used on an | |
| 20 | + | // endpoint that requires an ephemeral access token from /auth/token. | |
| 21 | + | var errLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token") | |
| 22 | + | ||
| 18 | 23 | // TokenClaimsKey is context key for storing token claims. | |
| 19 | 24 | // Using domain key for consistency across all auth flows. | |
| 20 | 25 | const TokenClaimsKey = domain.TokenClaimsKey | |
| @@ -91,12 +96,14 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 91 | 96 | // Authenticate based on auth type | |
| 92 | 97 | // When auth type is "password", accept BOTH password and token auth | |
| 93 | 98 | // (allows CI/CD tokens while still supporting interactive password login) | |
| 94 | - | var authenticated bool | |
| 95 | - | var tokenClaims *domain.TokenClaims | |
| 96 | - | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |
| 99 | + | tokenClaims, authErr := authenticateToken(ctx, r, authSvc, log) | |
| 97 | 100 | ||
| 98 | - | if !authenticated { | |
| 99 | - | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | |
| 101 | + | if authErr != nil { | |
| 102 | + | if errors.Is(authErr, errLongLivedToken) { | |
| 103 | + | sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, errLongLivedToken.Error()) | |
| 104 | + | } else { | |
| 105 | + | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | |
| 106 | + | } | |
| 100 | 107 | return | |
| 101 | 108 | } | |
| 102 | 109 | ||
| @@ -119,8 +126,9 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||
| 119 | 126 | ||
| 120 | 127 | // authenticateToken handles token-based authentication. | |
| 121 | 128 | // It supports both Bearer token in Authorization header and token-as-password for CI. | |
| 122 | - | // Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure. | |
| 123 | - | func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (bool, *domain.TokenClaims) { | |
| 129 | + | // Returns (tokenClaims, error). A nil error means authentication succeeded. | |
| 130 | + | // Returns errLongLivedToken when a stored (non-ephemeral) token is used. | |
| 131 | + | func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (*domain.TokenClaims, error) { | |
| 124 | 132 | // First, check for Bearer token | |
| 125 | 133 | authHeader := r.Header.Get("Authorization") | |
| 126 | 134 | if strings.HasPrefix(authHeader, "Bearer ") { | |
| @@ -132,7 +140,16 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 132 | 140 | Str(zerowrap.FieldMethod, r.Method). | |
| 133 | 141 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 134 | 142 | Msg("bearer token validation failed") | |
| 135 | - | return false, nil | |
| 143 | + | return nil, err | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | if !claims.IsEphemeral { | |
| 147 | + | log.Warn(). | |
| 148 | + | Str("subject", claims.Subject). | |
| 149 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 150 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 151 | + | Msg("long-lived token rejected on registry endpoint") | |
| 152 | + | return nil, errLongLivedToken | |
| 136 | 153 | } | |
| 137 | 154 | ||
| 138 | 155 | log.Debug(). | |
| @@ -140,7 +157,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 140 | 157 | Str(zerowrap.FieldMethod, r.Method). | |
| 141 | 158 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 142 | 159 | Msg("bearer token authentication successful") | |
| 143 | - | return true, claims | |
| 160 | + | return claims, nil | |
| 144 | 161 | } | |
| 145 | 162 | ||
| 146 | 163 | // Fall back to token-as-password (for CI/automation) | |
| @@ -151,7 +168,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 151 | 168 | Str(zerowrap.FieldMethod, r.Method). | |
| 152 | 169 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 153 | 170 | Msg("no auth credentials provided") | |
| 154 | - | return false, nil | |
| 171 | + | return nil, errors.New("no auth credentials provided") | |
| 155 | 172 | } | |
| 156 | 173 | ||
| 157 | 174 | // Try to validate the password as a JWT token | |
| @@ -163,7 +180,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 163 | 180 | Str(zerowrap.FieldMethod, r.Method). | |
| 164 | 181 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 165 | 182 | Msg("token-as-password validation failed") | |
| 166 | - | return false, nil | |
| 183 | + | return nil, err | |
| 167 | 184 | } | |
| 168 | 185 | ||
| 169 | 186 | // Verify the username matches the token subject | |
| @@ -174,7 +191,16 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 174 | 191 | Str(zerowrap.FieldMethod, r.Method). | |
| 175 | 192 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 176 | 193 | Msg("username does not match token subject") | |
| 177 | - | return false, nil | |
| 194 | + | return nil, errors.New("username does not match token subject") | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | if !claims.IsEphemeral { | |
| 198 | + | log.Warn(). | |
| 199 | + | Str("subject", claims.Subject). | |
| 200 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 201 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 202 | + | Msg("long-lived token rejected on registry endpoint") | |
| 203 | + | return nil, errLongLivedToken | |
| 178 | 204 | } | |
| 179 | 205 | ||
| 180 | 206 | log.Debug(). | |
| @@ -182,7 +208,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 182 | 208 | Str(zerowrap.FieldMethod, r.Method). | |
| 183 | 209 | Str(zerowrap.FieldPath, r.URL.Path). | |
| 184 | 210 | Msg("token-as-password authentication successful") | |
| 185 | - | return true, claims | |
| 211 | + | return claims, nil | |
| 186 | 212 | } | |
| 187 | 213 | ||
| 188 | 214 | func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool { | |
| @@ -283,6 +309,37 @@ func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) | |||
| 283 | 309 | Msg("forbidden: insufficient scope for operation") | |
| 284 | 310 | } | |
| 285 | 311 | ||
| 312 | + | // sendUnauthorizedMsg sends an HTTP 401 response with a custom error message. | |
| 313 | + | func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request, msg string) { | |
| 314 | + | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| 315 | + | ||
| 316 | + | switch authType { | |
| 317 | + | case domain.AuthTypeToken: | |
| 318 | + | realmHost := host | |
| 319 | + | scheme := "http" | |
| 320 | + | if r.TLS != nil { | |
| 321 | + | scheme = "https" | |
| 322 | + | } | |
| 323 | + | realmHost = sanitizeHeaderValue(realmHost) | |
| 324 | + | realm := scheme + "://" + realmHost + "/auth/token" | |
| 325 | + | w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`) | |
| 326 | + | default: | |
| 327 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | w.Header().Set("Content-Type", "application/json") | |
| 331 | + | w.WriteHeader(http.StatusUnauthorized) | |
| 332 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: msg}) | |
| 333 | + | ||
| 334 | + | log.Warn(). | |
| 335 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 336 | + | Str(zerowrap.FieldAdapter, "http"). | |
| 337 | + | Str(zerowrap.FieldMethod, r.Method). | |
| 338 | + | Str(zerowrap.FieldPath, r.URL.Path). | |
| 339 | + | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |
| 340 | + | Msg("unauthorized registry access attempt") | |
| 341 | + | } | |
| 342 | + | ||
| 286 | 343 | // sendUnauthorized sends an HTTP 401 response with appropriate headers. | |
| 287 | 344 | func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) { | |
| 288 | 345 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "crypto/subtle" | 5 | "crypto/subtle" | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | + | "errors" | |||
| 7 | "net/http" | 8 | "net/http" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | 10 | ||||
| @@ -15,6 +16,10 @@ import ( | |||||
| 15 | "github.com/bnema/gordon/internal/domain" | 16 | "github.com/bnema/gordon/internal/domain" | ||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 19 | + | // errLongLivedToken is returned when a long-lived (stored) token is used on an | |||
| 20 | + | // endpoint that requires an ephemeral access token from /auth/token. | |||
| 21 | + | var errLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token") | |||
| 22 | + | ||||
| 18 | // TokenClaimsKey is context key for storing token claims. | 23 | // TokenClaimsKey is context key for storing token claims. | ||
| 19 | // Using domain key for consistency across all auth flows. | 24 | // Using domain key for consistency across all auth flows. | ||
| 20 | const TokenClaimsKey = domain.TokenClaimsKey | 25 | const TokenClaimsKey = domain.TokenClaimsKey | ||
| @@ -91,12 +96,14 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 91 | // Authenticate based on auth type | 96 | // Authenticate based on auth type | ||
| 92 | // When auth type is "password", accept BOTH password and token auth | 97 | // When auth type is "password", accept BOTH password and token auth | ||
| 93 | // (allows CI/CD tokens while still supporting interactive password login) | 98 | // (allows CI/CD tokens while still supporting interactive password login) | ||
| 94 | - | var authenticated bool | 99 | + | tokenClaims, authErr := authenticateToken(ctx, r, authSvc, log) |
| 95 | - | var tokenClaims *domain.TokenClaims | |||
| 96 | - | authenticated, tokenClaims = authenticateToken(ctx, r, authSvc, log) | |||
| 97 | 100 | ||||
| 98 | - | if !authenticated { | 101 | + | if authErr != nil { |
| 99 | - | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | 102 | + | if errors.Is(authErr, errLongLivedToken) { |
| 103 | + | sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, errLongLivedToken.Error()) | |||
| 104 | + | } else { | |||
| 105 | + | sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r) | |||
| 106 | + | } | |||
| 100 | return | 107 | return | ||
| 101 | } | 108 | } | ||
| 102 | 109 | ||||
| @@ -119,8 +126,9 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 119 | 126 | ||||
| 120 | // authenticateToken handles token-based authentication. | 127 | // authenticateToken handles token-based authentication. | ||
| 121 | // It supports both Bearer token in Authorization header and token-as-password for CI. | 128 | // It supports both Bearer token in Authorization header and token-as-password for CI. | ||
| 122 | - | // Returns (authenticated, tokenClaims) where tokenClaims may be nil on failure. | 129 | + | // Returns (tokenClaims, error). A nil error means authentication succeeded. |
| 123 | - | func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (bool, *domain.TokenClaims) { | 130 | + | // Returns errLongLivedToken when a stored (non-ephemeral) token is used. |
| 131 | + | func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (*domain.TokenClaims, error) { | |||
| 124 | // First, check for Bearer token | 132 | // First, check for Bearer token | ||
| 125 | authHeader := r.Header.Get("Authorization") | 133 | authHeader := r.Header.Get("Authorization") | ||
| 126 | if strings.HasPrefix(authHeader, "Bearer ") { | 134 | if strings.HasPrefix(authHeader, "Bearer ") { | ||
| @@ -132,7 +140,16 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||||
| 132 | Str(zerowrap.FieldMethod, r.Method). | 140 | Str(zerowrap.FieldMethod, r.Method). | ||
| 133 | Str(zerowrap.FieldPath, r.URL.Path). | 141 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 134 | Msg("bearer token validation failed") | 142 | Msg("bearer token validation failed") | ||
| 135 | - | return false, nil | 143 | + | return nil, err |
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | if !claims.IsEphemeral { | |||
| 147 | + | log.Warn(). | |||
| 148 | + | Str("subject", claims.Subject). | |||
| 149 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 150 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 151 | + | Msg("long-lived token rejected on registry endpoint") | |||
| 152 | + | return nil, errLongLivedToken | |||
| 136 | } | 153 | } | ||
| 137 | 154 | ||||
| 138 | log.Debug(). | 155 | log.Debug(). | ||
| @@ -140,7 +157,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||||
| 140 | Str(zerowrap.FieldMethod, r.Method). | 157 | Str(zerowrap.FieldMethod, r.Method). | ||
| 141 | Str(zerowrap.FieldPath, r.URL.Path). | 158 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 142 | Msg("bearer token authentication successful") | 159 | Msg("bearer token authentication successful") | ||
| 143 | - | return true, claims | 160 | + | return claims, nil |
| 144 | } | 161 | } | ||
| 145 | 162 | ||||
| 146 | // Fall back to token-as-password (for CI/automation) | 163 | // Fall back to token-as-password (for CI/automation) | ||
| @@ -151,7 +168,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||||
| 151 | Str(zerowrap.FieldMethod, r.Method). | 168 | Str(zerowrap.FieldMethod, r.Method). | ||
| 152 | Str(zerowrap.FieldPath, r.URL.Path). | 169 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 153 | Msg("no auth credentials provided") | 170 | Msg("no auth credentials provided") | ||
| 154 | - | return false, nil | 171 | + | return nil, errors.New("no auth credentials provided") |
| 155 | } | 172 | } | ||
| 156 | 173 | ||||
| 157 | // Try to validate the password as a JWT token | 174 | // Try to validate the password as a JWT token | ||
| @@ -163,7 +180,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||||
| 163 | Str(zerowrap.FieldMethod, r.Method). | 180 | Str(zerowrap.FieldMethod, r.Method). | ||
| 164 | Str(zerowrap.FieldPath, r.URL.Path). | 181 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 165 | Msg("token-as-password validation failed") | 182 | Msg("token-as-password validation failed") | ||
| 166 | - | return false, nil | 183 | + | return nil, err |
| 167 | } | 184 | } | ||
| 168 | 185 | ||||
| 169 | // Verify the username matches the token subject | 186 | // Verify the username matches the token subject | ||
| @@ -174,7 +191,16 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||||
| 174 | Str(zerowrap.FieldMethod, r.Method). | 191 | Str(zerowrap.FieldMethod, r.Method). | ||
| 175 | Str(zerowrap.FieldPath, r.URL.Path). | 192 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 176 | Msg("username does not match token subject") | 193 | Msg("username does not match token subject") | ||
| 177 | - | return false, nil | 194 | + | return nil, errors.New("username does not match token subject") |
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | if !claims.IsEphemeral { | |||
| 198 | + | log.Warn(). | |||
| 199 | + | Str("subject", claims.Subject). | |||
| 200 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 201 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 202 | + | Msg("long-lived token rejected on registry endpoint") | |||
| 203 | + | return nil, errLongLivedToken | |||
| 178 | } | 204 | } | ||
| 179 | 205 | ||||
| 180 | log.Debug(). | 206 | log.Debug(). | ||
| @@ -182,7 +208,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||||
| 182 | Str(zerowrap.FieldMethod, r.Method). | 208 | Str(zerowrap.FieldMethod, r.Method). | ||
| 183 | Str(zerowrap.FieldPath, r.URL.Path). | 209 | Str(zerowrap.FieldPath, r.URL.Path). | ||
| 184 | Msg("token-as-password authentication successful") | 210 | Msg("token-as-password authentication successful") | ||
| 185 | - | return true, claims | 211 | + | return claims, nil |
| 186 | } | 212 | } | ||
| 187 | 213 | ||||
| 188 | func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool { | 214 | func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool { | ||
| @@ -283,6 +309,37 @@ func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request) | |||||
| 283 | Msg("forbidden: insufficient scope for operation") | 309 | Msg("forbidden: insufficient scope for operation") | ||
| 284 | } | 310 | } | ||
| 285 | 311 | ||||
| 312 | + | // sendUnauthorizedMsg sends an HTTP 401 response with a custom error message. | |||
| 313 | + | func sendUnauthorizedMsg(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request, msg string) { | |||
| 314 | + | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |||
| 315 | + | ||||
| 316 | + | switch authType { | |||
| 317 | + | case domain.AuthTypeToken: | |||
| 318 | + | realmHost := host | |||
| 319 | + | scheme := "http" | |||
| 320 | + | if r.TLS != nil { | |||
| 321 | + | scheme = "https" | |||
| 322 | + | } | |||
| 323 | + | realmHost = sanitizeHeaderValue(realmHost) | |||
| 324 | + | realm := scheme + "://" + realmHost + "/auth/token" | |||
| 325 | + | w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`",service="gordon-registry"`) | |||
| 326 | + | default: | |||
| 327 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | w.Header().Set("Content-Type", "application/json") | |||
| 331 | + | w.WriteHeader(http.StatusUnauthorized) | |||
| 332 | + | _ = json.NewEncoder(w).Encode(dto.ErrorResponse{Error: msg}) | |||
| 333 | + | ||||
| 334 | + | log.Warn(). | |||
| 335 | + | Str(zerowrap.FieldLayer, "adapter"). | |||
| 336 | + | Str(zerowrap.FieldAdapter, "http"). | |||
| 337 | + | Str(zerowrap.FieldMethod, r.Method). | |||
| 338 | + | Str(zerowrap.FieldPath, r.URL.Path). | |||
| 339 | + | Str(zerowrap.FieldClientIP, r.RemoteAddr). | |||
| 340 | + | Msg("unauthorized registry access attempt") | |||
| 341 | + | } | |||
| 342 | + | ||||
| 286 | // sendUnauthorized sends an HTTP 401 response with appropriate headers. | 343 | // sendUnauthorized sends an HTTP 401 response with appropriate headers. | ||
| 287 | func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) { | 344 | func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) { | ||
| 288 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | 345 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | ||
M internal/adapters/in/http/middleware/auth_test.go +101 -0
| @@ -341,6 +341,107 @@ func TestCheckScopeAccess_SpecialRoutes(t *testing.T) { | |||
| 341 | 341 | } | |
| 342 | 342 | } | |
| 343 | 343 | ||
| 344 | + | func TestRegistryAuthV2_RejectsLongLivedToken(t *testing.T) { | |
| 345 | + | log := testLogger() | |
| 346 | + | authSvc := stubAuthService{ | |
| 347 | + | enabled: true, | |
| 348 | + | authType: domain.AuthTypeToken, | |
| 349 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { | |
| 350 | + | return &domain.TokenClaims{ | |
| 351 | + | Subject: "admin", | |
| 352 | + | Scopes: []string{"repository:*:pull,push"}, | |
| 353 | + | IsEphemeral: false, // long-lived stored token | |
| 354 | + | }, nil | |
| 355 | + | }, | |
| 356 | + | } | |
| 357 | + | internalAuth := InternalRegistryAuth{ | |
| 358 | + | Username: "internal", | |
| 359 | + | Password: "secret", | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 363 | + | w.WriteHeader(http.StatusOK) | |
| 364 | + | })) | |
| 365 | + | ||
| 366 | + | req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil) | |
| 367 | + | req.RemoteAddr = "10.0.0.1:12345" | |
| 368 | + | req.Header.Set("Authorization", "Bearer some-long-lived-jwt") | |
| 369 | + | rec := httptest.NewRecorder() | |
| 370 | + | ||
| 371 | + | handler.ServeHTTP(rec, req) | |
| 372 | + | ||
| 373 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 374 | + | assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token") | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | func TestRegistryAuthV2_AcceptsEphemeralToken(t *testing.T) { | |
| 378 | + | log := testLogger() | |
| 379 | + | authSvc := stubAuthService{ | |
| 380 | + | enabled: true, | |
| 381 | + | authType: domain.AuthTypeToken, | |
| 382 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { | |
| 383 | + | return &domain.TokenClaims{ | |
| 384 | + | Subject: "admin", | |
| 385 | + | Scopes: []string{"repository:*:pull,push"}, | |
| 386 | + | IsEphemeral: true, // short-lived ephemeral token | |
| 387 | + | }, nil | |
| 388 | + | }, | |
| 389 | + | } | |
| 390 | + | internalAuth := InternalRegistryAuth{ | |
| 391 | + | Username: "internal", | |
| 392 | + | Password: "secret", | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | handlerCalled := false | |
| 396 | + | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 397 | + | handlerCalled = true | |
| 398 | + | w.WriteHeader(http.StatusOK) | |
| 399 | + | })) | |
| 400 | + | ||
| 401 | + | req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil) | |
| 402 | + | req.RemoteAddr = "10.0.0.1:12345" | |
| 403 | + | req.Header.Set("Authorization", "Bearer some-ephemeral-jwt") | |
| 404 | + | rec := httptest.NewRecorder() | |
| 405 | + | ||
| 406 | + | handler.ServeHTTP(rec, req) | |
| 407 | + | ||
| 408 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 409 | + | assert.True(t, handlerCalled, "handler should be called for ephemeral tokens") | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | func TestRegistryAuthV2_RejectsLongLivedTokenAsPassword(t *testing.T) { | |
| 413 | + | log := testLogger() | |
| 414 | + | authSvc := stubAuthService{ | |
| 415 | + | enabled: true, | |
| 416 | + | authType: domain.AuthTypeToken, | |
| 417 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { | |
| 418 | + | return &domain.TokenClaims{ | |
| 419 | + | Subject: "ci-user", | |
| 420 | + | Scopes: []string{"repository:*:pull,push"}, | |
| 421 | + | IsEphemeral: false, // long-lived stored token | |
| 422 | + | }, nil | |
| 423 | + | }, | |
| 424 | + | } | |
| 425 | + | internalAuth := InternalRegistryAuth{ | |
| 426 | + | Username: "internal", | |
| 427 | + | Password: "secret", | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 431 | + | w.WriteHeader(http.StatusOK) | |
| 432 | + | })) | |
| 433 | + | ||
| 434 | + | req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil) | |
| 435 | + | req.RemoteAddr = "10.0.0.1:12345" | |
| 436 | + | req.SetBasicAuth("ci-user", "some-long-lived-jwt") | |
| 437 | + | rec := httptest.NewRecorder() | |
| 438 | + | ||
| 439 | + | handler.ServeHTTP(rec, req) | |
| 440 | + | ||
| 441 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |
| 442 | + | assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token") | |
| 443 | + | } | |
| 444 | + | ||
| 344 | 445 | func TestCheckScopeAccess_DenialCases(t *testing.T) { | |
| 345 | 446 | log := testLogger() | |
| 346 | 447 | ||
| @@ -341,6 +341,107 @@ func TestCheckScopeAccess_SpecialRoutes(t *testing.T) { | |||||
| 341 | } | 341 | } | ||
| 342 | } | 342 | } | ||
| 343 | 343 | ||||
| 344 | + | func TestRegistryAuthV2_RejectsLongLivedToken(t *testing.T) { | |||
| 345 | + | log := testLogger() | |||
| 346 | + | authSvc := stubAuthService{ | |||
| 347 | + | enabled: true, | |||
| 348 | + | authType: domain.AuthTypeToken, | |||
| 349 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { | |||
| 350 | + | return &domain.TokenClaims{ | |||
| 351 | + | Subject: "admin", | |||
| 352 | + | Scopes: []string{"repository:*:pull,push"}, | |||
| 353 | + | IsEphemeral: false, // long-lived stored token | |||
| 354 | + | }, nil | |||
| 355 | + | }, | |||
| 356 | + | } | |||
| 357 | + | internalAuth := InternalRegistryAuth{ | |||
| 358 | + | Username: "internal", | |||
| 359 | + | Password: "secret", | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 363 | + | w.WriteHeader(http.StatusOK) | |||
| 364 | + | })) | |||
| 365 | + | ||||
| 366 | + | req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil) | |||
| 367 | + | req.RemoteAddr = "10.0.0.1:12345" | |||
| 368 | + | req.Header.Set("Authorization", "Bearer some-long-lived-jwt") | |||
| 369 | + | rec := httptest.NewRecorder() | |||
| 370 | + | ||||
| 371 | + | handler.ServeHTTP(rec, req) | |||
| 372 | + | ||||
| 373 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 374 | + | assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token") | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | func TestRegistryAuthV2_AcceptsEphemeralToken(t *testing.T) { | |||
| 378 | + | log := testLogger() | |||
| 379 | + | authSvc := stubAuthService{ | |||
| 380 | + | enabled: true, | |||
| 381 | + | authType: domain.AuthTypeToken, | |||
| 382 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { | |||
| 383 | + | return &domain.TokenClaims{ | |||
| 384 | + | Subject: "admin", | |||
| 385 | + | Scopes: []string{"repository:*:pull,push"}, | |||
| 386 | + | IsEphemeral: true, // short-lived ephemeral token | |||
| 387 | + | }, nil | |||
| 388 | + | }, | |||
| 389 | + | } | |||
| 390 | + | internalAuth := InternalRegistryAuth{ | |||
| 391 | + | Username: "internal", | |||
| 392 | + | Password: "secret", | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | handlerCalled := false | |||
| 396 | + | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 397 | + | handlerCalled = true | |||
| 398 | + | w.WriteHeader(http.StatusOK) | |||
| 399 | + | })) | |||
| 400 | + | ||||
| 401 | + | req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil) | |||
| 402 | + | req.RemoteAddr = "10.0.0.1:12345" | |||
| 403 | + | req.Header.Set("Authorization", "Bearer some-ephemeral-jwt") | |||
| 404 | + | rec := httptest.NewRecorder() | |||
| 405 | + | ||||
| 406 | + | handler.ServeHTTP(rec, req) | |||
| 407 | + | ||||
| 408 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 409 | + | assert.True(t, handlerCalled, "handler should be called for ephemeral tokens") | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | func TestRegistryAuthV2_RejectsLongLivedTokenAsPassword(t *testing.T) { | |||
| 413 | + | log := testLogger() | |||
| 414 | + | authSvc := stubAuthService{ | |||
| 415 | + | enabled: true, | |||
| 416 | + | authType: domain.AuthTypeToken, | |||
| 417 | + | validateToken: func(_ context.Context, _ string) (*domain.TokenClaims, error) { | |||
| 418 | + | return &domain.TokenClaims{ | |||
| 419 | + | Subject: "ci-user", | |||
| 420 | + | Scopes: []string{"repository:*:pull,push"}, | |||
| 421 | + | IsEphemeral: false, // long-lived stored token | |||
| 422 | + | }, nil | |||
| 423 | + | }, | |||
| 424 | + | } | |||
| 425 | + | internalAuth := InternalRegistryAuth{ | |||
| 426 | + | Username: "internal", | |||
| 427 | + | Password: "secret", | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 431 | + | w.WriteHeader(http.StatusOK) | |||
| 432 | + | })) | |||
| 433 | + | ||||
| 434 | + | req := httptest.NewRequest("GET", "/v2/myrepo/manifests/latest", nil) | |||
| 435 | + | req.RemoteAddr = "10.0.0.1:12345" | |||
| 436 | + | req.SetBasicAuth("ci-user", "some-long-lived-jwt") | |||
| 437 | + | rec := httptest.NewRecorder() | |||
| 438 | + | ||||
| 439 | + | handler.ServeHTTP(rec, req) | |||
| 440 | + | ||||
| 441 | + | assert.Equal(t, http.StatusUnauthorized, rec.Code) | |||
| 442 | + | assert.Contains(t, rec.Body.String(), "long-lived tokens must be exchanged via /auth/token") | |||
| 443 | + | } | |||
| 444 | + | ||||
| 344 | func TestCheckScopeAccess_DenialCases(t *testing.T) { | 445 | func TestCheckScopeAccess_DenialCases(t *testing.T) { | ||
| 345 | log := testLogger() | 446 | log := testLogger() | ||
| 346 | 447 | ||||