distribution/contrib/token-server/token.go

221 lines
6.0 KiB
Go
Raw Normal View History

package main
import (
"context"
"crypto"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"regexp"
"strings"
"time"
dcontext "github.com/distribution/distribution/v3/context"
"github.com/distribution/distribution/v3/registry/auth"
"github.com/distribution/distribution/v3/registry/auth/token"
"github.com/docker/libtrust"
)
// ResolveScopeSpecifiers converts a list of scope specifiers from a token
// request's `scope` query parameters into a list of standard access objects.
func ResolveScopeSpecifiers(ctx context.Context, scopeSpecs []string) []auth.Access {
requestedAccessSet := make(map[auth.Access]struct{}, 2*len(scopeSpecs))
for _, scopeSpecifier := range scopeSpecs {
// There should be 3 parts, separated by a `:` character.
parts := strings.SplitN(scopeSpecifier, ":", 3)
if len(parts) != 3 {
dcontext.GetLogger(ctx).Infof("ignoring unsupported scope format %s", scopeSpecifier)
continue
}
resourceType, resourceName, actions := parts[0], parts[1], parts[2]
resourceType, resourceClass := splitResourceClass(resourceType)
if resourceType == "" {
continue
}
// Actions should be a comma-separated list of actions.
for _, action := range strings.Split(actions, ",") {
requestedAccess := auth.Access{
Resource: auth.Resource{
Type: resourceType,
Class: resourceClass,
Name: resourceName,
},
Action: action,
}
// Add this access to the requested access set.
requestedAccessSet[requestedAccess] = struct{}{}
}
}
requestedAccessList := make([]auth.Access, 0, len(requestedAccessSet))
for requestedAccess := range requestedAccessSet {
requestedAccessList = append(requestedAccessList, requestedAccess)
}
return requestedAccessList
}
var typeRegexp = regexp.MustCompile(`^([a-z0-9]+)(\([a-z0-9]+\))?$`)
func splitResourceClass(t string) (string, string) {
matches := typeRegexp.FindStringSubmatch(t)
if len(matches) < 2 {
return "", ""
}
if len(matches) == 2 || len(matches[2]) < 2 {
return matches[1], ""
}
return matches[1], matches[2][1 : len(matches[2])-1]
}
// ResolveScopeList converts a scope list from a token request's
// `scope` parameter into a list of standard access objects.
func ResolveScopeList(ctx context.Context, scopeList string) []auth.Access {
scopes := strings.Split(scopeList, " ")
return ResolveScopeSpecifiers(ctx, scopes)
}
func scopeString(a auth.Access) string {
if a.Class != "" {
return fmt.Sprintf("%s(%s):%s:%s", a.Type, a.Class, a.Name, a.Action)
}
return fmt.Sprintf("%s:%s:%s", a.Type, a.Name, a.Action)
}
// ToScopeList converts a list of access to a
// scope list string
func ToScopeList(access []auth.Access) string {
var s []string
for _, a := range access {
s = append(s, scopeString(a))
}
return strings.Join(s, ",")
}
// TokenIssuer represents an issuer capable of generating JWT tokens
type TokenIssuer struct {
Issuer string
SigningKey libtrust.PrivateKey
Expiration time.Duration
}
// CreateJWT creates and signs a JSON Web Token for the given subject and
// audience with the granted access.
func (issuer *TokenIssuer) CreateJWT(subject string, audience string, grantedAccessList []auth.Access) (string, error) {
// Make a set of access entries to put in the token's claimset.
resourceActionSets := make(map[auth.Resource]map[string]struct{}, len(grantedAccessList))
for _, access := range grantedAccessList {
actionSet, exists := resourceActionSets[access.Resource]
if !exists {
actionSet = map[string]struct{}{}
resourceActionSets[access.Resource] = actionSet
}
actionSet[access.Action] = struct{}{}
}
accessEntries := make([]*token.ResourceActions, 0, len(resourceActionSets))
for resource, actionSet := range resourceActionSets {
actions := make([]string, 0, len(actionSet))
for action := range actionSet {
actions = append(actions, action)
}
accessEntries = append(accessEntries, &token.ResourceActions{
Type: resource.Type,
Class: resource.Class,
Name: resource.Name,
Actions: actions,
})
}
randomBytes := make([]byte, 15)
_, err := io.ReadFull(rand.Reader, randomBytes)
if err != nil {
return "", err
}
randomID := base64.URLEncoding.EncodeToString(randomBytes)
now := time.Now()
signingHash := crypto.SHA256
var alg string
switch issuer.SigningKey.KeyType() {
case "RSA":
alg = "RS256"
case "EC":
alg = "ES256"
default:
panic(fmt.Errorf("unsupported signing key type %q", issuer.SigningKey.KeyType()))
}
joseHeader := token.Header{
Type: "JWT",
SigningAlg: alg,
}
if x5c := issuer.SigningKey.GetExtendedField("x5c"); x5c != nil {
joseHeader.X5c = x5c.([]string)
} else {
var jwkMessage json.RawMessage
jwkMessage, err = issuer.SigningKey.PublicKey().MarshalJSON()
if err != nil {
return "", err
}
joseHeader.RawJWK = &jwkMessage
}
exp := issuer.Expiration
if exp == 0 {
exp = 5 * time.Minute
}
claimSet := token.ClaimSet{
Issuer: issuer.Issuer,
Subject: subject,
Audience: audience,
Expiration: now.Add(exp).Unix(),
NotBefore: now.Unix(),
IssuedAt: now.Unix(),
JWTID: randomID,
Access: accessEntries,
}
var (
joseHeaderBytes []byte
claimSetBytes []byte
)
if joseHeaderBytes, err = json.Marshal(joseHeader); err != nil {
return "", fmt.Errorf("unable to encode jose header: %s", err)
}
if claimSetBytes, err = json.Marshal(claimSet); err != nil {
return "", fmt.Errorf("unable to encode claim set: %s", err)
}
encodedJoseHeader := joseBase64Encode(joseHeaderBytes)
encodedClaimSet := joseBase64Encode(claimSetBytes)
encodingToSign := fmt.Sprintf("%s.%s", encodedJoseHeader, encodedClaimSet)
var signatureBytes []byte
if signatureBytes, _, err = issuer.SigningKey.Sign(strings.NewReader(encodingToSign), signingHash); err != nil {
return "", fmt.Errorf("unable to sign jwt payload: %s", err)
}
signature := joseBase64Encode(signatureBytes)
return fmt.Sprintf("%s.%s", encodingToSign, signature), nil
}
func joseBase64Encode(data []byte) string {
return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
}