// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package jwt import ( "context" "crypto" "fmt" "time" "github.com/armon/go-metrics" "github.com/hashicorp/cap/jwt" "golang.org/x/exp/slices" "github.com/hashicorp/nomad/helper" "github.com/hashicorp/nomad/nomad/structs" ) // Validate performs token signature verification and JWT header validation, // and returns a list of claims or an error in case any validation or signature // verification fails. func Validate(ctx context.Context, token string, methodConf *structs.ACLAuthMethodConfig) (map[string]any, error) { var ( keySet jwt.KeySet err error ) // JWT validation can happen in 3 ways: // - via embedded public keys, locally // - via JWKS // - or via OIDC provider if len(methodConf.JWTValidationPubKeys) != 0 { keySet, err = usingStaticKeys(methodConf.JWTValidationPubKeys) if err != nil { return nil, err } } else if methodConf.JWKSURL != "" { keySet, err = usingJWKS(ctx, methodConf.JWKSURL, methodConf.JWKSCACert) if err != nil { return nil, err } } else if methodConf.OIDCDiscoveryURL != "" { keySet, err = usingOIDC(ctx, methodConf.OIDCDiscoveryURL, methodConf.DiscoveryCaPem) if err != nil { return nil, err } } // SigningAlgs field is a string, we need to convert it to a type the go-jwt // accepts in order to validate. toAlgFn := func(m string) jwt.Alg { return jwt.Alg(m) } algorithms := helper.ConvertSlice(methodConf.SigningAlgs, toAlgFn) expected := jwt.Expected{ Audiences: methodConf.BoundAudiences, SigningAlgorithms: algorithms, NotBeforeLeeway: methodConf.NotBeforeLeeway, ExpirationLeeway: methodConf.ExpirationLeeway, ClockSkewLeeway: methodConf.ClockSkewLeeway, } validator, err := jwt.NewValidator(keySet) if err != nil { return nil, err } claims, err := validator.Validate(ctx, token, expected) if err != nil { return nil, fmt.Errorf("unable to verify signature of JWT token: %v", err) } // validate issuer manually, because we allow users to specify an array if len(methodConf.BoundIssuer) > 0 { if _, ok := claims["iss"]; !ok { return nil, fmt.Errorf( "auth method specifies BoundIssuers but the provided token does not contain issuer information", ) } if iss, ok := claims["iss"].(string); !ok { return nil, fmt.Errorf("unable to read iss property of provided token") } else if !slices.Contains(methodConf.BoundIssuer, iss) { return nil, fmt.Errorf("invalid JWT issuer: %v", claims["iss"]) } } return claims, nil } func usingStaticKeys(keys []string) (jwt.KeySet, error) { var parsedKeys []crypto.PublicKey for _, v := range keys { key, err := jwt.ParsePublicKeyPEM([]byte(v)) parsedKeys = append(parsedKeys, key) if err != nil { return nil, fmt.Errorf("unable to parse public key for JWT auth: %v", err) } } return jwt.NewStaticKeySet(parsedKeys) } func usingJWKS(ctx context.Context, jwksurl, jwkscapem string) (jwt.KeySet, error) { // Measure the JWKS endpoint performance. defer metrics.MeasureSince([]string{"nomad", "acl", "jwt", "jwks"}, time.Now()) keySet, err := jwt.NewJSONWebKeySet(ctx, jwksurl, jwkscapem) if err != nil { return nil, fmt.Errorf("unable to get validation keys from JWKS: %v", err) } return keySet, nil } func usingOIDC(ctx context.Context, oidcurl string, oidccapem []string) (jwt.KeySet, error) { // Measure the OIDC endpoint performance. defer metrics.MeasureSince([]string{"nomad", "acl", "jwt", "oidc_jwt"}, time.Now()) // TODO why do we have DiscoverCaPem as an array but JWKSCaPem as a single string? pem := "" if len(oidccapem) > 0 { pem = oidccapem[0] } keySet, err := jwt.NewOIDCDiscoveryKeySet(ctx, oidcurl, pem) if err != nil { return nil, fmt.Errorf("unable to get validation keys from OIDC provider: %v", err) } return keySet, nil }