detached 589c4c3

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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(
8686 return
8787 }
8888
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+
89100 // Check if token has admin scopes
90101 hasAdminScope := false
91102 for _, scope := range claims.Scopes {
@@ -86,6 +86,17 @@ func AuthMiddleware(
86 return86 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 scopes100 // Check if token has admin scopes
90 hasAdminScope := false101 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) {
8585 ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)
8686 authSvc.EXPECT().IsEnabled().Return(true)
8787 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,
9091 }, nil)
9192 // ExtendToken is called after successful validation; non-fatal if it fails
9293 authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
@@ -114,8 +115,9 @@ func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
114115 // No rate limiters, auth still required
115116 authSvc.EXPECT().IsEnabled().Return(true)
116117 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,
119121 }, nil)
120122 // ExtendToken is called after successful validation; non-fatal if it fails
121123 authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
@@ -216,6 +218,64 @@ func TestAuthMiddleware_InvalidToken(t *testing.T) {
216218 assert.Contains(t, rec.Body.String(), "invalid token")
217219 }
218220
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+
219279 func TestAuthMiddleware_MissingAdminScope(t *testing.T) {
220280 authSvc := inmocks.NewMockAuthService(t)
221281 globalLimiter := outmocks.NewMockRateLimiter(t)
@@ -225,8 +285,9 @@ func TestAuthMiddleware_MissingAdminScope(t *testing.T) {
225285 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
226286 authSvc.EXPECT().IsEnabled().Return(true)
227287 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,
230291 }, nil)
231292
232293 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -255,8 +316,9 @@ func TestAuthMiddleware_Success(t *testing.T) {
255316 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
256317 authSvc.EXPECT().IsEnabled().Return(true)
257318 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,
260322 }, nil)
261323 // ExtendToken is called after successful validation; non-fatal if it fails
262324 authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
@@ -376,8 +438,9 @@ func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) {
376438
377439 authSvc.EXPECT().IsEnabled().Return(true)
378440 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,
381444 }, nil)
382445 // ExtendToken fails — response must still be 200 (non-fatal)
383446 authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("", errors.New("store unavailable"))
@@ -405,8 +468,9 @@ func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
405468
406469 authSvc.EXPECT().IsEnabled().Return(true)
407470 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,
410474 }, nil)
411475 // ExtendToken returns a rotated token — must appear in response header
412476 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 fails92 // 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 required115 // 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 fails122 // 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 scopes289+ 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 fails323 // 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 header475 // 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 (
44 "context"
55 "crypto/subtle"
66 "encoding/json"
7+ "errors"
78 "net/http"
89 "strings"
910
@@ -15,6 +16,10 @@ import (
1516 "github.com/bnema/gordon/internal/domain"
1617 )
1718
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+
1823 // TokenClaimsKey is context key for storing token claims.
1924 // Using domain key for consistency across all auth flows.
2025 const TokenClaimsKey = domain.TokenClaimsKey
@@ -91,12 +96,14 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
9196 // Authenticate based on auth type
9297 // When auth type is "password", accept BOTH password and token auth
9398 // (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)
97100
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+ }
100107 return
101108 }
102109
@@ -119,8 +126,9 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
119126
120127 // authenticateToken handles token-based authentication.
121128 // 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) {
124132 // First, check for Bearer token
125133 authHeader := r.Header.Get("Authorization")
126134 if strings.HasPrefix(authHeader, "Bearer ") {
@@ -132,7 +140,16 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
132140 Str(zerowrap.FieldMethod, r.Method).
133141 Str(zerowrap.FieldPath, r.URL.Path).
134142 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
136153 }
137154
138155 log.Debug().
@@ -140,7 +157,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
140157 Str(zerowrap.FieldMethod, r.Method).
141158 Str(zerowrap.FieldPath, r.URL.Path).
142159 Msg("bearer token authentication successful")
143- return true, claims
160+ return claims, nil
144161 }
145162
146163 // Fall back to token-as-password (for CI/automation)
@@ -151,7 +168,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
151168 Str(zerowrap.FieldMethod, r.Method).
152169 Str(zerowrap.FieldPath, r.URL.Path).
153170 Msg("no auth credentials provided")
154- return false, nil
171+ return nil, errors.New("no auth credentials provided")
155172 }
156173
157174 // Try to validate the password as a JWT token
@@ -163,7 +180,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
163180 Str(zerowrap.FieldMethod, r.Method).
164181 Str(zerowrap.FieldPath, r.URL.Path).
165182 Msg("token-as-password validation failed")
166- return false, nil
183+ return nil, err
167184 }
168185
169186 // Verify the username matches the token subject
@@ -174,7 +191,16 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
174191 Str(zerowrap.FieldMethod, r.Method).
175192 Str(zerowrap.FieldPath, r.URL.Path).
176193 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
178204 }
179205
180206 log.Debug().
@@ -182,7 +208,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
182208 Str(zerowrap.FieldMethod, r.Method).
183209 Str(zerowrap.FieldPath, r.URL.Path).
184210 Msg("token-as-password authentication successful")
185- return true, claims
211+ return claims, nil
186212 }
187213
188214 func isInternalRegistryAuth(r *http.Request, internalAuth InternalRegistryAuth) bool {
@@ -283,6 +309,37 @@ func sendForbidden(w http.ResponseWriter, log zerowrap.Logger, r *http.Request)
283309 Msg("forbidden: insufficient scope for operation")
284310 }
285311
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+
286343 // sendUnauthorized sends an HTTP 401 response with appropriate headers.
287344 func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
288345 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.TokenClaimsKey25 const TokenClaimsKey = domain.TokenClaimsKey
@@ -91,12 +96,14 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
91 // Authenticate based on auth type96 // Authenticate based on auth type
92 // When auth type is "password", accept BOTH password and token auth97 // 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 bool99+ 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 return107 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 token132 // 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, nil143+ 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, claims160+ 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, nil171+ return nil, errors.New("no auth credentials provided")
155 }172 }
156 173
157 // Try to validate the password as a JWT token174 // 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, nil183+ return nil, err
167 }184 }
168 185
169 // Verify the username matches the token subject186 // 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, nil194+ 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, claims211+ 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) {
341341 }
342342 }
343343
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+
344445 func TestCheckScopeAccess_DenialCases(t *testing.T) {
345446 log := testLogger()
346447
@@ -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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close