github.com/pelicanplatform/pelican@v1.0.5/cmd/origin_token.go (about)

     1  package main
     2  
     3  import (
     4  	"crypto/rand"
     5  	"encoding/base64"
     6  	"fmt"
     7  	"net/url"
     8  	"regexp"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/lestrrat-go/jwx/v2/jwa"
    13  	"github.com/lestrrat-go/jwx/v2/jwk"
    14  	"github.com/lestrrat-go/jwx/v2/jwt"
    15  	"github.com/pkg/errors"
    16  	"github.com/spf13/cobra"
    17  	"github.com/spf13/viper"
    18  
    19  	"github.com/pelicanplatform/pelican/config"
    20  )
    21  
    22  // The verifyCreate* funcs only act on the provided claims maps, because they attempt
    23  // to verify certain aspects of the token before it is created for simplicity. To verify
    24  // an actual token object, use the analagous "verifyToken"
    25  func verifyCreateSciTokens2(claimsMap *map[string]string) error {
    26  	/*
    27  		Don't check for the following claims because ALL base tokens have them:
    28  		- iat
    29  		- exp
    30  		- nbf
    31  		- iss
    32  		- jti
    33  	*/
    34  	if len(*claimsMap) == 0 {
    35  		return errors.New("To create a valid SciToken, the 'aud' and 'scope' claims must be passed, but none were found.")
    36  	}
    37  	requiredClaims := []string{"aud", "ver", "scope"}
    38  	for _, reqClaim := range requiredClaims {
    39  		if val, exists := (*claimsMap)[reqClaim]; !exists {
    40  			// we can set ver because we know what it should be
    41  			if reqClaim == "ver" {
    42  				(*claimsMap)["ver"] = "scitokens:2.0"
    43  			} else {
    44  				// We can't set scope or aud, however
    45  				errMsg := "The claim '" + reqClaim + "' is required for the scitokens2 profile, but it could not be found."
    46  				return errors.New(errMsg)
    47  			}
    48  		} else {
    49  			// The claim exists. While we're okay setting ver if it's not included, it
    50  			// feels wrong to correct an explicitly-provided version that isn't correct,
    51  			// so in that event, fail.
    52  			if reqClaim == "ver" {
    53  				verPattern := `^scitokens:2\.[0-9]+$`
    54  				re := regexp.MustCompile(verPattern)
    55  
    56  				if !re.MatchString(val) {
    57  					errMsg := "The provided version '" + val +
    58  						"' is not valid. It must match 'scitokens:<version>', where version is of the form 2.x"
    59  					return errors.New(errMsg)
    60  				}
    61  			}
    62  		}
    63  	}
    64  
    65  	return nil
    66  }
    67  
    68  func verifyCreateWLCG(claimsMap *map[string]string) error {
    69  	/*
    70  		Don't check for the following claims because ALL base tokens have them:
    71  		- iat
    72  		- exp
    73  		- nbf
    74  		- iss
    75  		- jti
    76  	*/
    77  	if len(*claimsMap) == 0 {
    78  		return errors.New("To create a valid wlcg, the 'aud' and 'sub' claims must be passed, but none were found.")
    79  	}
    80  
    81  	requiredClaims := []string{"sub", "wlcg.ver", "aud"}
    82  	for _, reqClaim := range requiredClaims {
    83  		if val, exists := (*claimsMap)[reqClaim]; !exists {
    84  			// we can set wlcg.ver because we know what it should be
    85  			if reqClaim == "wlcg.ver" {
    86  				(*claimsMap)["wlcg.ver"] = "1.0"
    87  			} else {
    88  				// We can't set the rest
    89  				errMsg := "The claim '" + reqClaim +
    90  					"' is required for the wlcg profile, but it could not be found."
    91  				return errors.New(errMsg)
    92  			}
    93  		} else {
    94  			if reqClaim == "wlcg.ver" {
    95  				verPattern := `^1\.[0-9]+$`
    96  				re := regexp.MustCompile(verPattern)
    97  				if !re.MatchString(val) {
    98  					errMsg := "The provided version '" + val + "' is not valid. It must be of the form '1.x'"
    99  					return errors.New(errMsg)
   100  				}
   101  			}
   102  		}
   103  	}
   104  
   105  	return nil
   106  }
   107  
   108  func parseClaims(claims []string) (map[string]string, error) {
   109  	claimsMap := make(map[string]string)
   110  	for _, claim := range claims {
   111  		// Split by the first "=" delimiter
   112  		parts := strings.SplitN(claim, "=", 2)
   113  		if len(parts) < 2 {
   114  			errMsg := "The claim '" + claim + "' is invalid. Did you forget an '='?"
   115  			return nil, errors.New(errMsg)
   116  		}
   117  		key := parts[0]
   118  		val := parts[1]
   119  
   120  		if existingVal, exists := claimsMap[key]; exists {
   121  			claimsMap[key] = existingVal + " " + val
   122  		} else {
   123  			claimsMap[key] = val
   124  		}
   125  	}
   126  	return claimsMap, nil
   127  }
   128  
   129  func CreateEncodedToken(claimsMap map[string]string, profile string, lifetime int) (string, error) {
   130  	var err error
   131  	if profile != "" {
   132  		if profile == "scitokens2" {
   133  			err = verifyCreateSciTokens2(&claimsMap)
   134  			if err != nil {
   135  				return "", errors.Wrap(err, "Token does not conform to scitokens2 requirements")
   136  			}
   137  		} else if profile == "wlcg" {
   138  			err = verifyCreateWLCG(&claimsMap)
   139  			if err != nil {
   140  				return "", errors.Wrap(err, "Token does not conform to wlcg requirements")
   141  			}
   142  		} else {
   143  			errMsg := "The provided profile '" + profile +
   144  				"' is not recognized. Valid options are 'scitokens2' or 'wlcg'"
   145  			return "", errors.New(errMsg)
   146  		}
   147  	}
   148  
   149  	lifetimeDuration := time.Duration(lifetime)
   150  	// Create a json token identifier (jti). This will be added to all tokens.
   151  	jti_bytes := make([]byte, 16)
   152  	if _, err := rand.Read(jti_bytes); err != nil {
   153  		return "", err
   154  	}
   155  	jti := base64.RawURLEncoding.EncodeToString(jti_bytes)
   156  
   157  	issuerUrlStr := viper.GetString("IssuerUrl")
   158  	issuerUrl, err := url.Parse(issuerUrlStr)
   159  	if err != nil {
   160  		return "", errors.Wrap(err, "Failed to parse the configured IssuerUrl")
   161  	}
   162  	// issuer might be empty if not configured, so we need to be careful as it's required
   163  	issuerFound := true
   164  	if issuerUrl.String() == "" {
   165  		issuerFound = false
   166  	}
   167  
   168  	// We allow the audience to be passed in the map, but we need to convert it to a list of strings
   169  	extractAudFromClaims := func(claimsMap *map[string]string) []string {
   170  		audience, exists := (*claimsMap)["aud"]
   171  		if !exists {
   172  			return nil
   173  		}
   174  		audienceSlice := strings.Split(audience, " ")
   175  		delete(*claimsMap, "aud")
   176  		return audienceSlice
   177  	}(&claimsMap)
   178  
   179  	now := time.Now()
   180  	builder := jwt.NewBuilder()
   181  	builder.Issuer(issuerUrl.String()).
   182  		IssuedAt(now).
   183  		Expiration(now.Add(time.Second * lifetimeDuration)).
   184  		NotBefore(now).
   185  		Audience(extractAudFromClaims).
   186  		JwtID(jti)
   187  
   188  	// Add cli-passed claims after setting up the basic token so that we
   189  	// expose a method to override anything we already set.
   190  	for key, val := range claimsMap {
   191  		builder.Claim(key, val)
   192  		if key == "iss" && val != "" {
   193  			issuerFound = true
   194  		}
   195  	}
   196  
   197  	if !issuerFound {
   198  		return "", errors.New("No issuer was found in the configuration file, and none was provided as a claim")
   199  	}
   200  
   201  	tok, err := builder.Build()
   202  	if err != nil {
   203  		return "", errors.Wrap(err, "Failed to generate token")
   204  	}
   205  
   206  	// Now that we have a token, it needs signing. Note that GetIssuerPrivateJWK
   207  	// will get the private key passed via the command line because that
   208  	// file path has already been bound to IssuerKey
   209  	key, err := config.GetIssuerPrivateJWK()
   210  	if err != nil {
   211  		return "", errors.Wrap(err, "Failed to load signing keys. Either generate one at the default "+
   212  			"location by serving an origin, or provide one via the --private-key flag")
   213  	}
   214  
   215  	// Get/assign the kid, needed for verification by the client
   216  	err = jwk.AssignKeyID(key)
   217  	if err != nil {
   218  		return "", errors.Wrap(err, "Failed to assign kid to the token")
   219  	}
   220  
   221  	signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256, key))
   222  	if err != nil {
   223  		return "", errors.Wrap(err, "Failed to sign the deletion token")
   224  	}
   225  
   226  	return string(signed), nil
   227  }
   228  
   229  // Take an input slice and append its claim name
   230  func parseInputSlice(rawSlice *[]string, claimPrefix string) []string {
   231  	if len(*rawSlice) == 0 {
   232  		return nil
   233  	}
   234  	slice := []string{}
   235  	for _, val := range *rawSlice {
   236  		slice = append(slice, claimPrefix+"="+val)
   237  	}
   238  
   239  	return slice
   240  }
   241  
   242  func cliTokenCreate(cmd *cobra.Command, args []string) error {
   243  	// Additional claims can be passed via the --claims flag, or
   244  	// they can be passed as args. We join those two slices here
   245  	claimsSlice, err := cmd.Flags().GetStringSlice("claim")
   246  	if err != nil {
   247  		return errors.Wrap(err, "Failed to load claims passed via --claim flag")
   248  	}
   249  	args = append(args, claimsSlice...)
   250  
   251  	// Similarly for scopes. Scopes could be passed like --scope "read:/storage write:/storage"
   252  	// or they could be pased like --scope read:/storage --scope write:/storage. However, because
   253  	// we already know the name of these claims and don't expect naming via the cli, we parse the
   254  	// claims to name them here
   255  	rawScopesSlice, err := cmd.Flags().GetStringSlice("scope")
   256  	if err != nil {
   257  		return errors.Wrap(err, "Failed to load scopes passed via --scope flag")
   258  	}
   259  	scopesSlice := parseInputSlice(&rawScopesSlice, "scope")
   260  	if len(scopesSlice) > 0 {
   261  		args = append(args, scopesSlice...)
   262  	}
   263  
   264  	// Like scopes, we allow multiple audiences and we need to add the claim name.
   265  	rawAudSlice, err := cmd.Flags().GetStringSlice("audience")
   266  	if err != nil {
   267  		return errors.Wrap(err, "Failed to load audience passed via --audience flag")
   268  	}
   269  	audSlice := parseInputSlice(&rawAudSlice, "aud")
   270  	if len(audSlice) > 0 {
   271  		args = append(args, audSlice...)
   272  	}
   273  
   274  	claimsMap, err := parseClaims(args)
   275  	if err != nil {
   276  		return errors.Wrap(err, "Failed to parse token claims")
   277  	}
   278  
   279  	// Get flags used for auxiliary parts of token creation that can't be fed directly to claimsMap
   280  	profile, err := cmd.Flags().GetString("profile")
   281  	if err != nil {
   282  		return errors.Wrapf(err, "Failed to get profile '%s' from input", profile)
   283  	}
   284  
   285  	lifetime, err := cmd.Flags().GetInt("lifetime")
   286  	if err != nil {
   287  		return errors.Wrapf(err, "Failed to get lifetime '%d' from input", lifetime)
   288  	}
   289  
   290  	// Flags to populate claimsMap
   291  	// Note that we don't get the issuer here, because that's bound to viper
   292  	subject, err := cmd.Flags().GetString("subject")
   293  	if err != nil {
   294  		return errors.Wrapf(err, "Failed to get subject '%s' from input", subject)
   295  	}
   296  	if subject != "" {
   297  		claimsMap["sub"] = subject
   298  	}
   299  
   300  	// Finally, create the token
   301  	token, err := CreateEncodedToken(claimsMap, profile, lifetime)
   302  	if err != nil {
   303  		return errors.Wrap(err, "Failed to create the token")
   304  	}
   305  
   306  	fmt.Println(token)
   307  	return nil
   308  }
   309  
   310  func verifyToken(cmd *cobra.Command, args []string) error {
   311  	return errors.New("Token verification not yet implemented")
   312  }