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 }