Files
zot/pkg/api/bearer_test.go
2026-02-03 09:25:38 -08:00

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)
})
})
}