Skip to content

Commit

Permalink
Expose CLI for creating tokens
Browse files Browse the repository at this point in the history
This PR exposes the ability for a user at an origin to create tokens for testing. It can
be accessed via `pelican origin token create`. There are several claims that all default
tokens will posses, namely iat, exp, nbf, iss, and jti. These can be overridden by passing
them specifically to the tool as claims.

By passing a profile with the --profile flag, the tool will provide information about
additional claims that are needed by the profile. NOTE: it only provides information about
required values that ARE NOT auto-generated!

The lifetime and private-key flags can be used to set the token lifetime (in seconds) and
the private signing key, respectively.

Next up is to create a CLI for verifying a provided token against a specific profile. While
the basic infrastructure was set up for that via `pelican origin token verify`, the function
remains unimplemented.
  • Loading branch information
jhiemstrawisc committed Sep 27, 2023
1 parent a6fc00c commit ae8029c
Show file tree
Hide file tree
Showing 4 changed files with 453 additions and 1 deletion.
38 changes: 38 additions & 0 deletions cmd/origin.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,34 @@ var (
RunE: serveOrigin,
SilenceUsage: true,
}

// Expose the token manipulation CLI
originTokenCmd = &cobra.Command{
Use: "token",
Short: "Manage Pelican origin tokens",
}

originTokenCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a Pelican origin token",
Long: `Create a JSON web token (JWT) using the origin's signing keys:
Pelican origins use JWTs as bearer tokens for authorizing specific requests,
such as reading from or writing to the origin's underlying storage, advertising
to a director, etc. For more information about the makeup of a JWT, see
https://jwt.io/introduction.
Additional profiles that expand on JWT are supported. They include scitokens2 and
wlcg1. For more information about these profiles, see https://scitokens.org/technical_docs/Claims
and https://github.com/WLCG-AuthZ-WG/common-jwt-profile/blob/master/profile.md, respectively`,
RunE: cliTokenCreate,
}

originTokenVerifyCmd = &cobra.Command{
Use: "verify",
Short: "Verify a Pelican origin token",
RunE: verifyToken,
}
)

func configOrigin( /*cmd*/ *cobra.Command /*args*/, []string) {
Expand All @@ -59,4 +87,14 @@ func init() {
panic(err)
}
originServeCmd.Flags().AddFlag(portFlag)

originCmd.AddCommand(originTokenCmd)
originTokenCmd.AddCommand(originTokenCreateCmd)
originTokenCmd.PersistentFlags().String("profile", "", "Passing a profile ensures the created token adheres to the profile's requirements. Accepted values are scitokens2 and wlcg1")
originTokenCreateCmd.Flags().Int("lifetime", 1200, "The lifetime of the token, in seconds.")
originTokenCreateCmd.Flags().String("private-key", viper.GetString("IssuerKey"), "Filepath designating the location of the private key in PEM format to be used for signing, if different from the origin's default.")
if err := viper.BindPFlag("IssuerKey", originTokenCreateCmd.Flags().Lookup("private-key")); err != nil {
panic(err)
}
originTokenCmd.AddCommand(originTokenVerifyCmd)
}
230 changes: 230 additions & 0 deletions cmd/origin_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
package main

import (
"fmt"

"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
"net/url"
"regexp"
"time"

"strconv"
"strings"

"github.com/pelicanplatform/pelican/config"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

// The verifyCreate* funcs only act on the provided claims maps, because they attempt
// to verify certain aspects of the token before it is created for simplicity. To verify
// an actual token object, use the analagous "verifyToken"
func verifyCreateSciTokens2(claimsMap *map[string]string) error {
/*
Don't check for the following claims because ALL base tokens have them:
- iat
- exp
- nbf
- iss
*/
if len(*claimsMap) == 0 {
return errors.New("To create a valid SciToken, the 'aud' and 'scope' claims must be passed, but none were found.")
}
requiredClaims := []string{"aud", "ver", "scope"}
for _, reqClaim := range requiredClaims {
if val, exists := (*claimsMap)[reqClaim]; !exists {
// we can set ver because we know what it should be
if reqClaim == "ver" {
(*claimsMap)["ver"] = "scitokens:2.0"
} else {
// We can't set scope or aud, however
errMsg := "The claim '" + reqClaim + "' is required for the scitokens2 profile, but it could not be found."
return errors.New(errMsg)
}
} else {
// The claim exists. While we're okay setting ver if it's not included, it
// feels wrong to correct an explicitly-provided version that isn't correct,
// so in that event, fail.
if reqClaim == "ver" {
verPattern := `^scitokens:2\.[0-9]+$`
re := regexp.MustCompile(verPattern)

if !re.MatchString(val) {
errMsg := "The provided version '" + val + "' is not valid. It must match 'scitokens:<version>', where version is of the form 2.x"
return errors.New(errMsg)
}
}
}
}

return nil
}

func verifyCreateWLCG1(claimsMap *map[string]string) error {
/*
Don't check for the following claims because ALL base tokens have them:
- iat
- exp
- nbf
- iss
- jti
*/
if len(*claimsMap) == 0 {
return errors.New("To create a valid wlcg, the 'aud' and 'sub' claims must be passed, but none were found.")
}

requiredClaims := []string{"sub", "wlcg.ver", "aud"}
for _, reqClaim := range requiredClaims {
if val, exists := (*claimsMap)[reqClaim]; !exists {
// we can set wlcg.ver because we know what it should be
if reqClaim == "wlcg.ver" {
(*claimsMap)["wlcg.ver"] = "1.0"
} else {
// We can't set the rest
errMsg := "The claim '" + reqClaim + "' is required for the wlcg1 profile, but it could not be found."
return errors.New(errMsg)
}
} else {
if reqClaim == "wlcg.ver" {
verPattern := `^1\.[0-9]+$`
re := regexp.MustCompile(verPattern)
if !re.MatchString(val) {
errMsg := "The provided version '" + val + "' is not valid. It must be of the form '1.x'"
return errors.New(errMsg)
}
}
}
}

return nil
}

func parseClaims(claims []string) (map[string]string, error) {
claimsMap := make(map[string]string)
// We assume each claim has exactly one "=" delimiter
for _, claim := range claims {
parts := strings.Split(claim, "=")
if len(parts) != 2 {
if len(parts) < 2 {
errMsg := "The claim '" + claim + "' is invalid. Did you forget an '='?"
return nil, errors.New(errMsg)
} else {
errMsg := "The claim '" + claim + "' is invalid. Does it contain more than one '='?"
return nil, errors.New(errMsg)
}
}
key := parts[0]
val := parts[1]

if existingVal, exists := claimsMap[key]; exists {
claimsMap[key] = existingVal + " " + val
} else {
claimsMap[key] = val
}
}
return claimsMap, nil
}

func createEncodedToken(claimsMap map[string]string, profile string, lifetime int) (string, error) {
var err error
if profile != "" {
if profile == "scitokens2" {
err = verifyCreateSciTokens2(&claimsMap)
if err != nil {
return "", errors.Wrap(err, "Token does not conform to scitokens2 requirements")
}
} else if profile == "wlcg1" {
err = verifyCreateWLCG1(&claimsMap)
if err != nil {
return "", errors.Wrap(err, "Token does not conform to wlcg1 requirements")
}
} else {
errMsg := "The provided profile '" + profile + "' is not recognized. Valid options are 'scitokens2' or 'wlcg1'"
return "", errors.New(errMsg)
}
}

lifetimeDuration := time.Duration(lifetime)
// Create a jti using uuid4. This will be added to all tokens.
u, err := uuid.NewRandom()
if err != nil {
return "", errors.Wrap(err, "Failed to generate uuid4 for token jti")
}

issuerUrlStr := viper.GetString("IssuerUrl")
issuerUrl, err := url.Parse(issuerUrlStr)
if err != nil {
return "", errors.Wrap(err, "Failed to parse the configured IssuerUrl")
}

now := time.Now()
builder := jwt.NewBuilder()
builder.Issuer(issuerUrl.String()).
IssuedAt(now).
Expiration(now.Add(time.Second * lifetimeDuration)).
NotBefore(now).
JwtID(u.String())

// Add cli-passed claims after setting up the basic token so that we
// expose a method to override anything we already set.
for key, val := range claimsMap {
builder.Claim(key, val)
}

tok, err := builder.Build()
if err != nil {
return "", errors.Wrap(err, "Failed to generate token")
}

// Now that we have a token, it needs signing. Note that GetOriginJWK
// will get the private key passed via the command line because that
// file path has already been bound to IssuerKey
key, err := config.GetOriginJWK()
if err != nil {
return "", errors.Wrap(err, "Failed to load signing keys. Either generate one at the default location by serving an origin, or provide one via the --private-key flag")
}

// Get/assign the kid, needed for verification by the client
err = jwk.AssignKeyID(*key)
if err != nil {
return "", errors.Wrap(err, "Failed to assign kid to the token")
}

signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES512, *key))
if err != nil {
return "", errors.Wrap(err, "Failed to sign the deletion token")
}

return string(signed), nil
}

func cliTokenCreate( /*cmd*/ cmd *cobra.Command /*args*/, args []string) error {
claimsMap, err := parseClaims(args)
if err != nil {
return errors.Wrap(err, "Failed to parse token claims")
}

// Check if a profile was provided and verify what we need to from the claimsMap
profile := cmd.Flags().Lookup("profile").Value.String()

lifetime, err := strconv.Atoi(cmd.Flags().Lookup("lifetime").Value.String())
if err != nil {
return errors.Wrapf(err, "Failed to parse lifetime '%d' as an integer", lifetime)
}

token, err := createEncodedToken(claimsMap, profile, lifetime)
if err != nil {
return errors.Wrap(err, "Failed to create the token")
}

fmt.Println(token)
return nil
}

func verifyToken( /*cmd*/ cmd *cobra.Command /*args*/, args []string) error {
return errors.New("Token verification not yet implemented")
}
Loading

0 comments on commit ae8029c

Please sign in to comment.