Skip to content

Commit a05ff49

Browse files
authored
Allow regular user JWTs on registry endpoints. (#174)
This lets tokens minted by hypeman-token work for direct OCI registry requests, including hypeman push, while preserving builder-token validation and mirror fallback behavior. Made-with: Cursor
1 parent cc53c42 commit a05ff49

2 files changed

Lines changed: 121 additions & 101 deletions

File tree

lib/middleware/oapi_auth.go

Lines changed: 79 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import (
1919
// errRepoNotAllowed is returned when a valid token doesn't have access to the requested repository.
2020
var errRepoNotAllowed = errors.New("repository not allowed by token")
2121

22+
// errInvalidTokenType is returned when a token is valid JWT but carries claims
23+
// reserved for registry-scoped builder tokens.
24+
var errInvalidTokenType = errors.New("invalid token type")
25+
2226
type contextKey string
2327

2428
const userIDKey contextKey = "user_id"
@@ -77,55 +81,17 @@ func OapiAuthenticationFunc(jwtSecret string) openapi3filter.AuthenticationFunc
7781
return fmt.Errorf("invalid authorization header format")
7882
}
7983

80-
// Parse and validate JWT
81-
claims := jwt.MapClaims{}
82-
parsedToken, err := jwt.ParseWithClaims(token, claims, func(token *jwt.Token) (interface{}, error) {
83-
// Validate signing method
84-
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
85-
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
86-
}
87-
return []byte(jwtSecret), nil
88-
})
89-
84+
claims, err := validateUserToken(token, jwtSecret)
9085
if err != nil {
91-
log.DebugContext(ctx, "failed to parse JWT", "error", err)
92-
return fmt.Errorf("invalid token")
93-
}
94-
95-
if !parsedToken.Valid {
96-
log.DebugContext(ctx, "invalid JWT token")
86+
log.DebugContext(ctx, "user token validation failed", "error", err)
87+
if errors.Is(err, errInvalidTokenType) {
88+
return errInvalidTokenType
89+
}
9790
return fmt.Errorf("invalid token")
9891
}
9992

100-
// Reject registry tokens - they should not be used for API authentication.
101-
// Registry tokens have specific claims (repos, scope, build_id) that user tokens don't have.
102-
if _, hasRepos := claims["repos"]; hasRepos {
103-
log.DebugContext(ctx, "rejected registry token used for API auth")
104-
return fmt.Errorf("invalid token type")
105-
}
106-
if _, hasScope := claims["scope"]; hasScope {
107-
log.DebugContext(ctx, "rejected registry token used for API auth")
108-
return fmt.Errorf("invalid token type")
109-
}
110-
if _, hasBuildID := claims["build_id"]; hasBuildID {
111-
log.DebugContext(ctx, "rejected registry token used for API auth")
112-
return fmt.Errorf("invalid token type")
113-
}
114-
115-
// Extract user ID from claims and add to context
116-
var userID string
117-
if sub, ok := claims["sub"].(string); ok {
118-
userID = sub
119-
}
120-
121-
// Update the context with user ID
122-
newCtx := context.WithValue(ctx, userIDKey, userID)
123-
124-
// Extract scoped permissions from the "permissions" claim.
125-
// Tokens without this claim get full access (backward compatibility).
126-
newCtx = extractPermissions(newCtx, claims)
127-
128-
// Update the request with the new context
93+
// Update the request with the authenticated user context.
94+
newCtx := contextWithUserClaims(ctx, claims)
12995
*input.RequestValidationInput.Request = *input.RequestValidationInput.Request.WithContext(newCtx)
13096

13197
return nil
@@ -339,6 +305,55 @@ func validateRegistryToken(tokenString, jwtSecret, requestPath, method string) (
339305
return claims, nil
340306
}
341307

308+
// validateUserToken validates a regular user JWT and rejects builder/registry tokens.
309+
func validateUserToken(tokenString, jwtSecret string) (jwt.MapClaims, error) {
310+
claims := jwt.MapClaims{}
311+
parsedToken, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
312+
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
313+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
314+
}
315+
return []byte(jwtSecret), nil
316+
})
317+
318+
if err != nil {
319+
return nil, fmt.Errorf("parse token: %w", err)
320+
}
321+
322+
if !parsedToken.Valid {
323+
return nil, fmt.Errorf("invalid token")
324+
}
325+
326+
// Reject registry tokens - they should not be used for API authentication.
327+
// Registry tokens have specific claims that user tokens don't have.
328+
if _, hasRepos := claims["repos"]; hasRepos {
329+
return nil, errInvalidTokenType
330+
}
331+
if _, hasScope := claims["scope"]; hasScope {
332+
return nil, errInvalidTokenType
333+
}
334+
if _, hasBuildID := claims["build_id"]; hasBuildID {
335+
return nil, errInvalidTokenType
336+
}
337+
// Also reject tokens with "builder-" prefix in subject as an extra safeguard.
338+
if sub, ok := claims["sub"].(string); ok && strings.HasPrefix(sub, "builder-") {
339+
return nil, errInvalidTokenType
340+
}
341+
342+
return claims, nil
343+
}
344+
345+
func contextWithUserClaims(ctx context.Context, claims jwt.MapClaims) context.Context {
346+
var userID string
347+
if sub, ok := claims["sub"].(string); ok {
348+
userID = sub
349+
}
350+
351+
ctx = context.WithValue(ctx, userIDKey, userID)
352+
353+
// Tokens without a permissions claim keep full access for backward compatibility.
354+
return extractPermissions(ctx, claims)
355+
}
356+
342357
// JwtAuth creates a chi middleware that validates JWT bearer tokens
343358
func JwtAuth(jwtSecret string) func(http.Handler) http.Handler {
344359
return func(next http.Handler) http.Handler {
@@ -397,6 +412,18 @@ func JwtAuth(jwtSecret string) func(http.Handler) http.Handler {
397412
fmt.Fprintf(w, `{"errors":[{"code":"UNAVAILABLE","message":"image not mirrored"}]}`)
398413
return
399414
}
415+
416+
// Fall back to regular user JWTs for direct registry access.
417+
userClaims, err := validateUserToken(token, jwtSecret)
418+
if err == nil {
419+
log.DebugContext(r.Context(), "user token validated for registry request",
420+
"auth_type", authType,
421+
"subject", userClaims["sub"])
422+
ctx := contextWithUserClaims(r.Context(), userClaims)
423+
next.ServeHTTP(w, r.WithContext(ctx))
424+
return
425+
}
426+
log.DebugContext(r.Context(), "user token validation failed for registry request", "error", err)
400427
} else {
401428
log.DebugContext(r.Context(), "failed to extract token", "error", err)
402429
}
@@ -429,68 +456,19 @@ func JwtAuth(jwtSecret string) func(http.Handler) http.Handler {
429456
return
430457
}
431458

432-
// Parse and validate as regular user JWT
433-
claims := jwt.MapClaims{}
434-
parsedToken, err := jwt.ParseWithClaims(token, claims, func(token *jwt.Token) (interface{}, error) {
435-
// Validate signing method
436-
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
437-
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
438-
}
439-
return []byte(jwtSecret), nil
440-
})
441-
459+
claims, err := validateUserToken(token, jwtSecret)
442460
if err != nil {
443-
log.DebugContext(r.Context(), "failed to parse JWT", "error", err)
444-
OapiErrorHandler(w, "invalid token", http.StatusUnauthorized)
445-
return
446-
}
447-
448-
if !parsedToken.Valid {
449-
log.DebugContext(r.Context(), "invalid JWT token")
461+
log.DebugContext(r.Context(), "user token validation failed", "error", err)
462+
if errors.Is(err, errInvalidTokenType) {
463+
OapiErrorHandler(w, errInvalidTokenType.Error(), http.StatusUnauthorized)
464+
return
465+
}
450466
OapiErrorHandler(w, "invalid token", http.StatusUnauthorized)
451467
return
452468
}
453469

454-
// Reject registry tokens - they should not be used for API authentication.
455-
// Registry tokens have specific claims that user tokens don't have.
456-
// This provides defense-in-depth even though BuildKit isolates build containers.
457-
if _, hasRepos := claims["repos"]; hasRepos {
458-
log.DebugContext(r.Context(), "rejected registry token used for API auth")
459-
OapiErrorHandler(w, "invalid token type", http.StatusUnauthorized)
460-
return
461-
}
462-
if _, hasScope := claims["scope"]; hasScope {
463-
log.DebugContext(r.Context(), "rejected registry token used for API auth")
464-
OapiErrorHandler(w, "invalid token type", http.StatusUnauthorized)
465-
return
466-
}
467-
if _, hasBuildID := claims["build_id"]; hasBuildID {
468-
log.DebugContext(r.Context(), "rejected registry token used for API auth")
469-
OapiErrorHandler(w, "invalid token type", http.StatusUnauthorized)
470-
return
471-
}
472-
// Also reject tokens with "builder-" prefix in subject as an extra safeguard
473-
if sub, ok := claims["sub"].(string); ok && strings.HasPrefix(sub, "builder-") {
474-
log.DebugContext(r.Context(), "rejected builder token used for API auth", "sub", sub)
475-
OapiErrorHandler(w, "invalid token type", http.StatusUnauthorized)
476-
return
477-
}
478-
479-
// Extract user ID from claims and add to context
480-
var userID string
481-
if sub, ok := claims["sub"].(string); ok {
482-
userID = sub
483-
}
484-
485-
// Update the context with user ID
486-
newCtx := context.WithValue(r.Context(), userIDKey, userID)
487-
488-
// Extract scoped permissions from the "permissions" claim.
489-
// Tokens without this claim get full access (backward compatibility).
490-
newCtx = extractPermissions(newCtx, claims)
491-
492470
// Call next handler with updated context
493-
next.ServeHTTP(w, r.WithContext(newCtx))
471+
next.ServeHTTP(w, r.WithContext(contextWithUserClaims(r.Context(), claims)))
494472
})
495473
}
496474
}

lib/middleware/oapi_auth_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,48 @@ func TestJwtAuth_TokenEndpointBypass(t *testing.T) {
236236
})
237237
}
238238

239+
func TestJwtAuth_RegistryPathAcceptsUserTokens(t *testing.T) {
240+
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
241+
w.Header().Set("X-User-ID", GetUserIDFromContext(r.Context()))
242+
if scopes.HasFullAccess(r.Context()) {
243+
w.Header().Set("X-Access", "full")
244+
} else {
245+
w.Header().Set("X-Access", "scoped")
246+
}
247+
w.WriteHeader(http.StatusOK)
248+
})
249+
250+
handler := JwtAuth(testJWTSecret)(nextHandler)
251+
252+
t.Run("regular user token is accepted for registry write path", func(t *testing.T) {
253+
token := generateUserToken(t, "user-registry-write")
254+
255+
req := httptest.NewRequest(http.MethodPut, "/v2/test-image/manifests/latest", nil)
256+
req.Header.Set("Authorization", "Bearer "+token)
257+
258+
rr := httptest.NewRecorder()
259+
handler.ServeHTTP(rr, req)
260+
261+
assert.Equal(t, http.StatusOK, rr.Code)
262+
assert.Equal(t, "user-registry-write", rr.Header().Get("X-User-ID"))
263+
assert.Equal(t, "full", rr.Header().Get("X-Access"))
264+
})
265+
266+
t.Run("scoped user token is accepted for registry read path", func(t *testing.T) {
267+
token := generateScopedToken(t, "user-registry-read", []string{"image:read"})
268+
269+
req := httptest.NewRequest(http.MethodGet, "/v2/test-image/manifests/latest", nil)
270+
req.Header.Set("Authorization", "Bearer "+token)
271+
272+
rr := httptest.NewRecorder()
273+
handler.ServeHTTP(rr, req)
274+
275+
assert.Equal(t, http.StatusOK, rr.Code)
276+
assert.Equal(t, "user-registry-read", rr.Header().Get("X-User-ID"))
277+
assert.Equal(t, "scoped", rr.Header().Get("X-Access"))
278+
})
279+
}
280+
239281
func TestJwtAuth_RegistryUnauthorizedResponse(t *testing.T) {
240282
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
241283
w.WriteHeader(http.StatusOK)

0 commit comments

Comments
 (0)