mirror of
https://github.com/project-zot/zot.git
synced 2026-06-18 05:28:07 +08:00
0e5a339f11
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
396 lines
11 KiB
Go
396 lines
11 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
. "github.com/smartystreets/goconvey/convey"
|
|
|
|
zerr "zotregistry.dev/zot/v2/errors"
|
|
"zotregistry.dev/zot/v2/pkg/api"
|
|
)
|
|
|
|
func TestBearerAuthorizer(t *testing.T) {
|
|
Convey("Test bearer token authorization", t, func() {
|
|
signingMethod := jwt.SigningMethodRS256
|
|
|
|
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
pubKey := privKey.Public()
|
|
keyFunc := func(_ context.Context, token *jwt.Token) (any, error) {
|
|
return pubKey, nil
|
|
}
|
|
|
|
authorizer := api.NewBearerAuthorizer("realm", "service", keyFunc)
|
|
|
|
Convey("Empty authorization header given", func() {
|
|
err := authorizer.Authorize(context.Background(), "", nil)
|
|
So(err, ShouldBeError, zerr.ErrNoBearerToken)
|
|
})
|
|
|
|
Convey("Valid token", func() {
|
|
access := []api.ResourceAccess{
|
|
{
|
|
Name: "authorized-repository",
|
|
Type: "repository",
|
|
Actions: []string{"pull"},
|
|
},
|
|
}
|
|
|
|
now := time.Now()
|
|
claims := api.ClaimsWithAccess{
|
|
Access: access,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute * 1)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
Issuer: "Zot",
|
|
Audience: []string{"Zot Registry"},
|
|
},
|
|
}
|
|
|
|
token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
authHeader := "Bearer " + token
|
|
|
|
Convey("Unauthorized type", func() {
|
|
requested := &api.ResourceAction{
|
|
Type: "registry",
|
|
Name: "catalog",
|
|
Action: "*",
|
|
}
|
|
|
|
err := authorizer.Authorize(context.Background(), authHeader, requested)
|
|
So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{})
|
|
So(err, ShouldBeError, zerr.ErrInsufficientScope)
|
|
})
|
|
|
|
Convey("Unauthorized name", func() {
|
|
requested := &api.ResourceAction{
|
|
Type: "repository",
|
|
Name: "unauthorized-repository",
|
|
Action: "pull",
|
|
}
|
|
|
|
err := authorizer.Authorize(context.Background(), authHeader, requested)
|
|
So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{})
|
|
So(err, ShouldBeError, zerr.ErrInsufficientScope)
|
|
})
|
|
|
|
Convey("Unauthorized action", func() {
|
|
requested := &api.ResourceAction{
|
|
Type: "repository",
|
|
Name: "authorized-repository",
|
|
Action: "push",
|
|
}
|
|
|
|
err := authorizer.Authorize(context.Background(), authHeader, requested)
|
|
So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{})
|
|
So(err, ShouldBeError, zerr.ErrInsufficientScope)
|
|
})
|
|
|
|
Convey("Successful authorization with requested access", func() {
|
|
requested := &api.ResourceAction{
|
|
Type: "repository",
|
|
Name: "authorized-repository",
|
|
Action: "pull",
|
|
}
|
|
|
|
err := authorizer.Authorize(context.Background(), authHeader, requested)
|
|
So(err, ShouldBeNil)
|
|
})
|
|
|
|
Convey("Successful authorization without requested access", func() {
|
|
err := authorizer.Authorize(context.Background(), authHeader, nil)
|
|
So(err, ShouldBeNil)
|
|
})
|
|
})
|
|
|
|
Convey("Access entry with per-entry ExpiresAt", func() {
|
|
now := time.Now()
|
|
|
|
Convey("Authorized when ExpiresAt is in the future", func() {
|
|
access := []api.ResourceAccess{
|
|
{
|
|
Name: "authorized-repository",
|
|
Type: "repository",
|
|
Actions: []string{"pull"},
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
|
},
|
|
}
|
|
|
|
claims := api.ClaimsWithAccess{
|
|
Access: access,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
Issuer: "Zot",
|
|
Audience: []string{"Zot Registry"},
|
|
},
|
|
}
|
|
|
|
token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey)
|
|
So(err, ShouldBeNil)
|
|
|
|
requested := &api.ResourceAction{
|
|
Type: "repository",
|
|
Name: "authorized-repository",
|
|
Action: "pull",
|
|
}
|
|
|
|
err = authorizer.Authorize(context.Background(), "Bearer "+token, requested)
|
|
So(err, ShouldBeNil)
|
|
})
|
|
|
|
Convey("Denied when ExpiresAt is in the past", func() {
|
|
access := []api.ResourceAccess{
|
|
{
|
|
Name: "authorized-repository",
|
|
Type: "repository",
|
|
Actions: []string{"pull"},
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(-time.Hour)),
|
|
},
|
|
}
|
|
|
|
claims := api.ClaimsWithAccess{
|
|
Access: access,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
Issuer: "Zot",
|
|
Audience: []string{"Zot Registry"},
|
|
},
|
|
}
|
|
|
|
token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey)
|
|
So(err, ShouldBeNil)
|
|
|
|
requested := &api.ResourceAction{
|
|
Type: "repository",
|
|
Name: "authorized-repository",
|
|
Action: "pull",
|
|
}
|
|
|
|
err = authorizer.Authorize(context.Background(), "Bearer "+token, requested)
|
|
So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{})
|
|
So(err, ShouldBeError, zerr.ErrInsufficientScope)
|
|
})
|
|
|
|
Convey("Only the expired entry is skipped, other entries still work", func() {
|
|
access := []api.ResourceAccess{
|
|
{
|
|
Name: "authorized-repository",
|
|
Type: "repository",
|
|
Actions: []string{"pull"},
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(-time.Hour)),
|
|
},
|
|
{
|
|
Name: "authorized-repository",
|
|
Type: "repository",
|
|
Actions: []string{"pull", "push"},
|
|
},
|
|
}
|
|
|
|
claims := api.ClaimsWithAccess{
|
|
Access: access,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
Issuer: "Zot",
|
|
Audience: []string{"Zot Registry"},
|
|
},
|
|
}
|
|
|
|
token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey)
|
|
So(err, ShouldBeNil)
|
|
|
|
requested := &api.ResourceAction{
|
|
Type: "repository",
|
|
Name: "authorized-repository",
|
|
Action: "pull",
|
|
}
|
|
|
|
err = authorizer.Authorize(context.Background(), "Bearer "+token, requested)
|
|
So(err, ShouldBeNil)
|
|
})
|
|
|
|
Convey("All entries expired results in insufficient scope", func() {
|
|
access := []api.ResourceAccess{
|
|
{
|
|
Name: "authorized-repository",
|
|
Type: "repository",
|
|
Actions: []string{"pull"},
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(-time.Hour)),
|
|
},
|
|
{
|
|
Name: "authorized-repository",
|
|
Type: "repository",
|
|
Actions: []string{"pull"},
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(-2 * time.Hour)),
|
|
},
|
|
}
|
|
|
|
claims := api.ClaimsWithAccess{
|
|
Access: access,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
Issuer: "Zot",
|
|
Audience: []string{"Zot Registry"},
|
|
},
|
|
}
|
|
|
|
token, err := jwt.NewWithClaims(signingMethod, claims).SignedString(privKey)
|
|
So(err, ShouldBeNil)
|
|
|
|
requested := &api.ResourceAction{
|
|
Type: "repository",
|
|
Name: "authorized-repository",
|
|
Action: "pull",
|
|
}
|
|
|
|
err = authorizer.Authorize(context.Background(), "Bearer "+token, requested)
|
|
So(err, ShouldHaveSameTypeAs, &api.AuthChallengeError{})
|
|
So(err, ShouldBeError, zerr.ErrInsufficientScope)
|
|
})
|
|
})
|
|
|
|
Convey("Invalid token", func() {
|
|
authHeader := "invalid"
|
|
|
|
err := authorizer.Authorize(context.Background(), authHeader, nil)
|
|
So(err, ShouldWrap, zerr.ErrInvalidBearerToken)
|
|
})
|
|
})
|
|
}
|
|
|
|
// TestBearerAuthorizerJWKSEdDSA verifies that an Ed25519 key pair in JWKS format can be used to
|
|
// sign and verify JWTs through the BearerAuthorizer. The hardcoded JWKS key pair below was
|
|
// generated externally using standard JWKS tooling.
|
|
func TestBearerAuthorizerJWKSEdDSA(t *testing.T) {
|
|
Convey("Test bearer authorization with JWKS Ed25519 key pair", t, func() {
|
|
// Hardcoded Ed25519 JWKS private key set (generated externally).
|
|
const privateJWKS = `{
|
|
"keys": [
|
|
{
|
|
"use": "sig",
|
|
"kty": "OKP",
|
|
"kid": "01f0ff96-0286-62c9-9fe0-68c6ac4f48e0",
|
|
"crv": "Ed25519",
|
|
"alg": "EdDSA",
|
|
"x": "3pL95mHbZYNG6-YT_MqXKibGQrXF7WziWk25EcgEJGs",
|
|
"d": "YJxZxGtBfy7lKKwuld1SQJn_9-YANmP0P_ZYG_ExUj4"
|
|
}
|
|
]
|
|
}`
|
|
|
|
// Hardcoded Ed25519 JWKS public key set (generated externally).
|
|
const publicJWKS = `{
|
|
"keys": [
|
|
{
|
|
"use": "sig",
|
|
"kty": "OKP",
|
|
"kid": "01f0ff96-0286-62c9-9fe0-68c6ac4f48e0",
|
|
"crv": "Ed25519",
|
|
"alg": "EdDSA",
|
|
"x": "3pL95mHbZYNG6-YT_MqXKibGQrXF7WziWk25EcgEJGs"
|
|
}
|
|
]
|
|
}`
|
|
|
|
// Parse the JWKS public key set (same logic as loadPublicKeyFromBytes).
|
|
var pubKeySet jose.JSONWebKeySet
|
|
err := json.Unmarshal([]byte(publicJWKS), &pubKeySet)
|
|
So(err, ShouldBeNil)
|
|
So(pubKeySet.Keys, ShouldHaveLength, 1)
|
|
|
|
pubJWK := pubKeySet.Keys[0]
|
|
So(pubJWK.KeyID, ShouldEqual, "01f0ff96-0286-62c9-9fe0-68c6ac4f48e0")
|
|
|
|
pubKey, ok := pubJWK.Key.(ed25519.PublicKey)
|
|
So(ok, ShouldBeTrue)
|
|
|
|
// Parse the JWKS private key set to sign JWTs.
|
|
var privKeySet jose.JSONWebKeySet
|
|
err = json.Unmarshal([]byte(privateJWKS), &privKeySet)
|
|
So(err, ShouldBeNil)
|
|
So(privKeySet.Keys, ShouldHaveLength, 1)
|
|
|
|
privJWK := privKeySet.Keys[0]
|
|
privKey, ok := privJWK.Key.(ed25519.PrivateKey)
|
|
So(ok, ShouldBeTrue)
|
|
|
|
// Build a keyFunc that selects the public key by kid.
|
|
keyFunc := func(_ context.Context, token *jwt.Token) (any, error) {
|
|
kid, ok := token.Header["kid"]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: missing kid", zerr.ErrInvalidBearerToken)
|
|
}
|
|
if kid != pubJWK.KeyID {
|
|
return nil, fmt.Errorf("%w: unknown kid %v", zerr.ErrInvalidBearerToken, kid)
|
|
}
|
|
|
|
return pubKey, nil
|
|
}
|
|
|
|
authorizer := api.NewBearerAuthorizer("realm", "service", keyFunc)
|
|
|
|
Convey("Sign and verify a JWT using JWKS Ed25519 keys", func() {
|
|
now := time.Now()
|
|
claims := api.ClaimsWithAccess{
|
|
Access: []api.ResourceAccess{
|
|
{
|
|
Name: "test-repo",
|
|
Type: "repository",
|
|
Actions: []string{"pull"},
|
|
},
|
|
},
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute)),
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
Issuer: "https://test-issuer",
|
|
},
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
|
token.Header["kid"] = pubJWK.KeyID
|
|
|
|
signedToken, err := token.SignedString(privKey)
|
|
So(err, ShouldBeNil)
|
|
|
|
authHeader := "Bearer " + signedToken
|
|
|
|
requested := &api.ResourceAction{
|
|
Type: "repository",
|
|
Name: "test-repo",
|
|
Action: "pull",
|
|
}
|
|
err = authorizer.Authorize(context.Background(), authHeader, requested)
|
|
So(err, ShouldBeNil)
|
|
})
|
|
|
|
Convey("Verify a pre-signed JWT using JWKS Ed25519 public key", func() {
|
|
// This JWT was signed externally with the same private key above.
|
|
//nolint:lll
|
|
const preSignedJWT = `eyJhbGciOiJFZERTQSIsImtpZCI6IjAxZjBmZjk2LTAyODYtNjJjOS05ZmUwLTY4YzZhYzRmNDhlMCIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIwMWYwZmY5Ni0wYWNjLTY3YjMtYWY5Yy02OGM2YWM0ZjQ4ZTAiLCJpc3MiOiJodHRwczovL3Rlc3QtaXNzdWVyIiwic3ViIjoiYy1lN2YyMjFhY2ZlZmJiOWNlIiwiYXVkIjpbImZsdXgtb3BlcmF0b3IiXSwiZXhwIjoxODAxNTA0MDMzLCJpYXQiOjE3Njk5NjgwMzMsIm5iZiI6MTc2OTk2ODAzM30.-4_9d1llJ8nCvW8AQdyQKvidx6DtV9lm78pWhbS0w49hq5tRcx3bt_zGGyhj-VGPFIGF86LTL25hcgOVKLEZBg`
|
|
|
|
authHeader := "Bearer " + preSignedJWT
|
|
err := authorizer.Authorize(context.Background(), authHeader, nil)
|
|
So(err, ShouldBeNil)
|
|
})
|
|
})
|
|
}
|