package api import ( "crypto" "fmt" "regexp" "slices" "github.com/golang-jwt/jwt/v5" zerr "zotregistry.dev/zot/errors" ) var bearerTokenMatch = regexp.MustCompile("(?i)bearer (.*)") // ResourceAccess is a single entry in the private 'access' claim specified by the distribution token authentication // specification. type ResourceAccess struct { Type string `json:"type"` Name string `json:"name"` Actions []string `json:"actions"` } type ResourceAction struct { Type string `json:"type"` Name string `json:"name"` Action string `json:"action"` } // ClaimsWithAccess is a claim set containing the private 'access' claim specified by the distribution token // authentication specification, in addition to the standard registered claims. // https://distribution.github.io/distribution/spec/auth/jwt/ type ClaimsWithAccess struct { Access []ResourceAccess `json:"access"` jwt.RegisteredClaims } type AuthChallengeError struct { err error realm string service string resourceAction *ResourceAction } func (c AuthChallengeError) Error() string { return c.err.Error() } // Header constructs an appropriate value for the WWW-Authenticate header to be returned to the client. func (c AuthChallengeError) Header() string { if c.resourceAction == nil { // no access was requested, so return an empty scope return fmt.Sprintf("Bearer realm=\"%s\",service=\"%s\",scope=\"\"", c.realm, c.service) } return fmt.Sprintf("Bearer realm=\"%s\",service=\"%s\",scope=\"%s:%s:%s\"", c.realm, c.service, c.resourceAction.Type, c.resourceAction.Name, c.resourceAction.Action) } type BearerAuthorizer struct { realm string service string key crypto.PublicKey } func NewBearerAuthorizer(realm string, service string, key crypto.PublicKey) BearerAuthorizer { return BearerAuthorizer{ realm: realm, service: service, key: key, } } // Authorize verifies whether the bearer token in the given Authorization header is valid, and whether it has sufficient // scope for the requested resource action. If an authorization error occurs (e.g. no token is given or the token has // insufficient scope), an AuthChallengeError is returned as the error. func (a *BearerAuthorizer) Authorize(header string, requested *ResourceAction) error { challenge := &AuthChallengeError{ realm: a.realm, service: a.service, resourceAction: requested, } if header == "" { // if no bearer token is set in the authorization header, return the authentication challenge challenge.err = zerr.ErrNoBearerToken return challenge } signedString := bearerTokenMatch.ReplaceAllString(header, "$1") token, err := jwt.ParseWithClaims(signedString, &ClaimsWithAccess{}, func(token *jwt.Token) (interface{}, error) { return a.key, nil }, jwt.WithValidMethods(a.allowedSigningAlgorithms()), jwt.WithIssuedAt()) if err != nil { return fmt.Errorf("%w: %w", zerr.ErrInvalidBearerToken, err) } if requested == nil { // the token is valid and no access is requested, so we do not have to validate the access claim return nil } claims, ok := token.Claims.(*ClaimsWithAccess) if !ok { return fmt.Errorf("%w: invalid claims type", zerr.ErrInvalidBearerToken) } // check whether the requested access is allowed by the scope of the token for _, allowed := range claims.Access { if allowed.Type != requested.Type { continue } if allowed.Name != requested.Name { continue } if !slices.Contains(allowed.Actions, requested.Action) { continue } // requested action is allowed, so don't return an error return nil } challenge.err = zerr.ErrInsufficientScope return challenge } func (a *BearerAuthorizer) allowedSigningAlgorithms() []string { return []string{"EdDSA", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"} }