From aa29facdae3e85481b1aa858cdde25f715690eea Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Fri, 11 Jan 2019 20:54:15 +0000 Subject: [PATCH] Allow certificate bearer JWT client authentication This allows certificate based authentication, both by just referencing the certificate file as well as by providing a bearer JWT. This last option allows authentication without exposing the private key to packer using an expiring JWT containting the thumbprint (and sometimes the whole certificate for subject/issuer based auth), signed using the certificate private key. --- builder/azure/arm/authenticate_cert.go | 155 +++++++++++++++++++ builder/azure/arm/authenticate_jwt.go | 43 ++++++ builder/azure/arm/clientconfig.go | 78 ++++++++-- builder/azure/arm/clientconfig_test.go | 200 ++++++++++++++++++++++++- 4 files changed, 465 insertions(+), 11 deletions(-) create mode 100644 builder/azure/arm/authenticate_cert.go create mode 100644 builder/azure/arm/authenticate_jwt.go diff --git a/builder/azure/arm/authenticate_cert.go b/builder/azure/arm/authenticate_cert.go new file mode 100644 index 000000000..0c1fb6e49 --- /dev/null +++ b/builder/azure/arm/authenticate_cert.go @@ -0,0 +1,155 @@ +package arm + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "io/ioutil" + "os" + "time" + + "github.com/Azure/go-autorest/autorest/azure" + jwt "github.com/dgrijalva/jwt-go" +) + +func NewCertOAuthTokenProvider(env azure.Environment, clientID, clientCertPath, tenantID string) (oAuthTokenProvider, error) { + cert, key, err := readCert(clientCertPath) + if err != nil { + return nil, fmt.Errorf("Error reading certificate: %v", err) + } + + audience := fmt.Sprintf("%s%s/oauth2/token", env.ActiveDirectoryEndpoint, tenantID) + jwt, err := makeJWT(clientID, audience, cert, key, time.Hour, true) + if err != nil { + return nil, fmt.Errorf("Error generating JWT: %v", err) + } + + return NewJWTOAuthTokenProvider(env, clientID, jwt, tenantID), nil +} + +// Creates a new JSON Web Token to be used as bearer JWT to authenticate +// to the Azure AD token endpoint to retrieve an access token for `audience`. +// If the full certificate is included in the token, then issuer/subject name +// could be used to authenticate if configured by the identity provider (AAD). +func makeJWT(clientID string, audience string, + cert *x509.Certificate, privatekey interface{}, + validFor time.Duration, includeFullCertificate bool) (string, error) { + + // The jti (JWT ID) claim provides a unique identifier for the JWT. + // See https://tools.ietf.org/html/rfc7519#section-4.1.7 + jti := make([]byte, 20) + _, err := rand.Read(jti) + if err != nil { + return "", err + } + + var token *jwt.Token + if cert.PublicKeyAlgorithm == x509.RSA { + token = jwt.New(jwt.SigningMethodRS256) + } else if cert.PublicKeyAlgorithm == x509.ECDSA { + token = jwt.New(jwt.SigningMethodES256) + } else { + return "", fmt.Errorf("Don't know how to handle this type of key algorithm: %v", cert.PublicKeyAlgorithm) + } + + hasher := sha1.New() + if _, err := hasher.Write(cert.Raw); err != nil { + return "", err + } + thumbprint := base64.URLEncoding.EncodeToString(hasher.Sum(nil)) + + // X.509 thumbprint, see https://tools.ietf.org/html/rfc7515#section-4.1.7 + token.Header["x5t"] = thumbprint + if includeFullCertificate { + // X.509 certificate (chain), see https://tools.ietf.org/html/rfc7515#section-4.1.6 + token.Header["x5c"] = []string{base64.StdEncoding.EncodeToString(cert.Raw)} + } + + token.Claims = jwt.MapClaims{ + // See https://tools.ietf.org/html/rfc7519#section-4.1 + "aud": audience, + "iss": clientID, + "sub": clientID, + "jti": base64.URLEncoding.EncodeToString(jti), + "nbf": time.Now().Unix(), + "exp": time.Now().Add(validFor).Unix(), + } + + return token.SignedString(privatekey) +} + +func readCert(file string) (cert *x509.Certificate, key interface{}, err error) { + f, err := os.Open(file) + if err != nil { + return nil, nil, err + } + defer f.Close() + d, err := ioutil.ReadAll(f) + if err != nil { + return nil, nil, err + } + + blocks := []*pem.Block{} + for len(d) > 0 { + var b *pem.Block + b, d = pem.Decode(d) + if b == nil { + break + } + blocks = append(blocks, b) + } + + certs := []*x509.Certificate{} + for _, block := range blocks { + if block.Type == "CERTIFICATE" { + c, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, nil, fmt.Errorf( + "Failed to read certificate block: %v", err) + } + certs = append(certs, c) + } else if block.Type == "PRIVATE KEY" { + key, err = x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, nil, fmt.Errorf( + "Failed to read private key block: %v", err) + } + } + // Don't care about other types of blocks, ignore + } + + if key == nil { + return nil, nil, fmt.Errorf("Did not find private key in pem file") + } + + // find the certificate that belongs to the private key by comparing the public keys + switch key := key.(type) { + case *rsa.PrivateKey: + for _, c := range certs { + if cp, ok := c.PublicKey.(*rsa.PublicKey); ok && + (cp.N.Cmp(key.PublicKey.N) == 0) { + cert = c + } + } + + case *ecdsa.PrivateKey: + for _, c := range certs { + if cp, ok := c.PublicKey.(*ecdsa.PublicKey); ok && + (cp.X.Cmp(key.PublicKey.X) == 0) && + (cp.Y.Cmp(key.PublicKey.Y) == 0) { + cert = c + } + } + } + + if cert == nil { + return nil, nil, fmt.Errorf("Did not find certificate belonging to private key in pem file") + } + + return cert, key, nil +} diff --git a/builder/azure/arm/authenticate_jwt.go b/builder/azure/arm/authenticate_jwt.go new file mode 100644 index 000000000..d4bb41748 --- /dev/null +++ b/builder/azure/arm/authenticate_jwt.go @@ -0,0 +1,43 @@ +package arm + +import ( + "net/url" + + "github.com/Azure/go-autorest/autorest/adal" + "github.com/Azure/go-autorest/autorest/azure" +) + +// for clientID/bearer JWT auth +type jwtOAuthTokenProvider struct { + env azure.Environment + clientID, clientJWT, tenantID string +} + +func NewJWTOAuthTokenProvider(env azure.Environment, clientID, clientJWT, tenantID string) oAuthTokenProvider { + return &jwtOAuthTokenProvider{env, clientID, clientJWT, tenantID} +} + +func (tp *jwtOAuthTokenProvider) getServicePrincipalToken() (*adal.ServicePrincipalToken, error) { + return tp.getServicePrincipalTokenWithResource(tp.env.ResourceManagerEndpoint) +} + +func (tp *jwtOAuthTokenProvider) getServicePrincipalTokenWithResource(resource string) (*adal.ServicePrincipalToken, error) { + oauthConfig, err := adal.NewOAuthConfig(tp.env.ActiveDirectoryEndpoint, tp.tenantID) + if err != nil { + return nil, err + } + + return adal.NewServicePrincipalTokenWithSecret( + *oauthConfig, + tp.clientID, + resource, + tp) +} + +// implements github.com/Azure/go-autorest/autorest/adal.ServicePrincipalSecret +func (tp *jwtOAuthTokenProvider) SetAuthenticationValues( + t *adal.ServicePrincipalToken, v *url.Values) error { + v.Set("client_assertion", tp.clientJWT) + v.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + return nil +} diff --git a/builder/azure/arm/clientconfig.go b/builder/azure/arm/clientconfig.go index d4daff259..69dfffbea 100644 --- a/builder/azure/arm/clientconfig.go +++ b/builder/azure/arm/clientconfig.go @@ -2,10 +2,13 @@ package arm import ( "fmt" + "os" "strings" + "time" "github.com/Azure/go-autorest/autorest/adal" "github.com/Azure/go-autorest/autorest/azure" + jwt "github.com/dgrijalva/jwt-go" "github.com/hashicorp/packer/packer" ) @@ -21,7 +24,11 @@ type ClientConfig struct { // Client ID ClientID string `mapstructure:"client_id"` // Client secret/password - ClientSecret string `mapstructure:"client_secret"` + ClientSecret string `mapstructure:"client_secret"` + // Certificate path for client auth + ClientCertPath string `mapstructure:"client_cert_path"` + // JWT bearer token for client auth (RFC 7523, Sec. 2.2) + ClientJWT string `mapstructure:"client_jwt"` ObjectID string `mapstructure:"object_id"` TenantID string `mapstructure:"tenant_id"` SubscriptionID string `mapstructure:"subscription_id"` @@ -86,29 +93,69 @@ func (c ClientConfig) assertRequiredParametersSet(errs *packer.MultiError) { return } - if c.SubscriptionID == "" { - errs = packer.MultiErrorAppend(errs, fmt.Errorf("A subscription_id must be specified")) - } - if c.useDeviceLogin() { return } - if c.SubscriptionID != "" && c.ClientID != "" && c.ClientSecret != "" { + if c.SubscriptionID != "" && c.ClientID != "" && + c.ClientSecret != "" && + c.ClientCertPath == "" && + c.ClientJWT == "" { // Service principal using secret return } + if c.SubscriptionID != "" && c.ClientID != "" && + c.ClientSecret == "" && + c.ClientCertPath != "" && + c.ClientJWT == "" { + // Service principal using certificate + + if _, err := os.Stat(c.ClientCertPath); err != nil { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("client_cert_path is not an accessible file: %v", err)) + } + return + } + + if c.SubscriptionID != "" && c.ClientID != "" && + c.ClientSecret == "" && + c.ClientCertPath == "" && + c.ClientJWT != "" { + // Service principal using JWT + // Check that JWT is valid for at least 5 more minutes + + p := jwt.Parser{} + claims := jwt.StandardClaims{} + token, _, err := p.ParseUnverified(c.ClientJWT, &claims) + if err != nil { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("client_jwt is not a JWT: %v", err)) + } else { + if claims.ExpiresAt < time.Now().Add(5*time.Minute).Unix() { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("client_jwt will expire within 5 minutes, please use a JWT that is valid for at least 5 minutes")) + } + if t, ok := token.Header["x5t"]; !ok || t == "" { + errs = packer.MultiErrorAppend(errs, fmt.Errorf("client_jwt is missing the x5t header value, which is required for bearer JWT client authentication to Azure")) + } + } + + return + } + errs = packer.MultiErrorAppend(errs, fmt.Errorf("No valid set of authentication values specified:\n"+ - "* to use the Managed Identity of teh current machine, do not specify any of the fields below\n"+ - "* to use interactive user authentication, specify only subscription_id\n"+ - "* to use an Azure Active Directory service principal, specify subscription_id, client_id and client_secret.")) + " to use the Managed Identity of the current machine, do not specify any of the fields below\n"+ + " to use interactive user authentication, specify only subscription_id\n"+ + " to use an Azure Active Directory service principal, specify either:\n"+ + " - subscription_id, client_id and client_secret\n"+ + " - subscription_id, client_id and client_cert_path\n"+ + " - subscription_id, client_id and client_jwt.")) } func (c ClientConfig) useDeviceLogin() bool { return c.SubscriptionID != "" && c.ClientID == "" && c.ClientSecret == "" && + c.ClientJWT == "" && + c.ClientCertPath == "" && c.TenantID == "" } @@ -116,6 +163,8 @@ func (c ClientConfig) useMSI() bool { return c.SubscriptionID == "" && c.ClientID == "" && c.ClientSecret == "" && + c.ClientJWT == "" && + c.ClientCertPath == "" && c.TenantID == "" } @@ -135,9 +184,18 @@ func (c ClientConfig) getServicePrincipalTokens( } else if c.useMSI() { say("Getting tokens using Managed Identity for Azure") auth = NewMSIOAuthTokenProvider(*c.cloudEnvironment) - } else { + } else if c.ClientSecret != "" { say("Getting tokens using client secret") auth = NewSecretOAuthTokenProvider(*c.cloudEnvironment, c.ClientID, c.ClientSecret, tenantID) + } else if c.ClientCertPath != "" { + say("Getting tokens using client certificate") + auth, err = NewCertOAuthTokenProvider(*c.cloudEnvironment, c.ClientID, c.ClientCertPath, tenantID) + if err != nil { + return nil, nil, err + } + } else { + say("Getting tokens using client bearer JWT") + auth = NewJWTOAuthTokenProvider(*c.cloudEnvironment, c.ClientID, c.ClientJWT, tenantID) } servicePrincipalToken, err = auth.getServicePrincipalToken() diff --git a/builder/azure/arm/clientconfig_test.go b/builder/azure/arm/clientconfig_test.go index 87fefae09..f9fafd2f2 100644 --- a/builder/azure/arm/clientconfig_test.go +++ b/builder/azure/arm/clientconfig_test.go @@ -1,11 +1,19 @@ package arm import ( + crand "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/binary" "fmt" + "io" + mrand "math/rand" "os" "testing" + "time" "github.com/Azure/go-autorest/autorest/azure" + jwt "github.com/dgrijalva/jwt-go" "github.com/hashicorp/packer/packer" ) @@ -29,7 +37,7 @@ func Test_ClientConfig_RequiredParametersSet(t *testing.T) { wantErr: false, }, { - name: "client_id without client_secret should error", + name: "client_id without client_secret, client_cert_path or client_jwt should error", config: ClientConfig{ ClientID: "error", }, @@ -42,6 +50,20 @@ func Test_ClientConfig_RequiredParametersSet(t *testing.T) { }, wantErr: true, }, + { + name: "client_cert_path without client_id should error", + config: ClientConfig{ + ClientCertPath: "/dev/null", + }, + wantErr: true, + }, + { + name: "client_jwt without client_id should error", + config: ClientConfig{ + ClientJWT: "error", + }, + wantErr: true, + }, { name: "missing subscription_id when using secret", config: ClientConfig{ @@ -50,6 +72,42 @@ func Test_ClientConfig_RequiredParametersSet(t *testing.T) { }, wantErr: true, }, + { + name: "missing subscription_id when using certificate", + config: ClientConfig{ + ClientID: "ok", + ClientCertPath: "ok", + }, + wantErr: true, + }, + { + name: "missing subscription_id when using JWT", + config: ClientConfig{ + ClientID: "ok", + ClientJWT: "ok", + }, + wantErr: true, + }, + { + name: "too many client_* values", + config: ClientConfig{ + SubscriptionID: "ok", + ClientID: "ok", + ClientSecret: "ok", + ClientCertPath: "error", + }, + wantErr: true, + }, + { + name: "too many client_* values (2)", + config: ClientConfig{ + SubscriptionID: "ok", + ClientID: "ok", + ClientSecret: "ok", + ClientJWT: "error", + }, + wantErr: true, + }, { name: "tenant_id alone should fail", config: ClientConfig{ @@ -130,6 +188,66 @@ func Test_ClientConfig_ClientPassword(t *testing.T) { } } +func Test_ClientConfig_ClientCert(t *testing.T) { + cfg := ClientConfig{ + SubscriptionID: getEnvOrSkip(t, "AZURE_SUBSCRIPTION"), + ClientID: getEnvOrSkip(t, "AZURE_CLIENTID"), + ClientCertPath: getEnvOrSkip(t, "AZURE_CLIENTCERT"), + TenantID: getEnvOrSkip(t, "AZURE_TENANTID"), + cloudEnvironment: getCloud(), + } + assertValid(t, cfg) + + spt, sptkv, err := cfg.getServicePrincipalTokens(func(s string) { fmt.Printf("SAY: %s\n", s) }) + if err != nil { + t.Fatalf("Expected nil err, but got: %v", err) + } + token := spt.Token() + if token.AccessToken == "" { + t.Fatal("Expected management token to have non-nil access token") + } + if token.RefreshToken != "" { + t.Fatal("Expected management token to have no refresh token") + } + kvtoken := sptkv.Token() + if kvtoken.AccessToken == "" { + t.Fatal("Expected keyvault token to have non-nil access token") + } + if kvtoken.RefreshToken != "" { + t.Fatal("Expected keyvault token to have no refresh token") + } +} + +func Test_ClientConfig_ClientJWT(t *testing.T) { + cfg := ClientConfig{ + SubscriptionID: getEnvOrSkip(t, "AZURE_SUBSCRIPTION"), + ClientID: getEnvOrSkip(t, "AZURE_CLIENTID"), + ClientJWT: getEnvOrSkip(t, "AZURE_CLIENTJWT"), + TenantID: getEnvOrSkip(t, "AZURE_TENANTID"), + cloudEnvironment: getCloud(), + } + assertValid(t, cfg) + + spt, sptkv, err := cfg.getServicePrincipalTokens(func(s string) { fmt.Printf("SAY: %s\n", s) }) + if err != nil { + t.Fatalf("Expected nil err, but got: %v", err) + } + token := spt.Token() + if token.AccessToken == "" { + t.Fatal("Expected management token to have non-nil access token") + } + if token.RefreshToken != "" { + t.Fatal("Expected management token to have no refresh token") + } + kvtoken := sptkv.Token() + if kvtoken.AccessToken == "" { + t.Fatal("Expected keyvault token to have non-nil access token") + } + if kvtoken.RefreshToken != "" { + t.Fatal("Expected keyvault token to have no refresh token") + } +} + func getEnvOrSkip(t *testing.T, envVar string) string { v := os.Getenv(envVar) if v == "" { @@ -192,8 +310,88 @@ func Test_ClientConfig_CanUseClientSecretWithTenantID(t *testing.T) { assertValid(t, cfg) } +func Test_ClientConfig_CanUseClientJWT(t *testing.T) { + cfg := emptyClientConfig() + cfg.SubscriptionID = "12345" + cfg.ClientID = "12345" + cfg.ClientJWT = getJWT(10*time.Minute, true) + + assertValid(t, cfg) +} + +func Test_ClientConfig_CanUseClientJWTWithTenantID(t *testing.T) { + cfg := emptyClientConfig() + cfg.SubscriptionID = "12345" + cfg.ClientID = "12345" + cfg.ClientJWT = getJWT(10*time.Minute, true) + cfg.TenantID = "12345" + + assertValid(t, cfg) +} + +func Test_ClientConfig_CannotUseBothClientJWTAndSecret(t *testing.T) { + cfg := emptyClientConfig() + cfg.SubscriptionID = "12345" + cfg.ClientID = "12345" + cfg.ClientSecret = "12345" + cfg.ClientJWT = getJWT(10*time.Minute, true) + + assertInvalid(t, cfg) +} + +func Test_ClientConfig_ClientJWTShouldBeValidForAtLeast5Minutes(t *testing.T) { + cfg := emptyClientConfig() + cfg.SubscriptionID = "12345" + cfg.ClientID = "12345" + cfg.ClientJWT = getJWT(time.Minute, true) + + assertInvalid(t, cfg) +} + +func Test_ClientConfig_ClientJWTShouldHaveThumbprint(t *testing.T) { + cfg := emptyClientConfig() + cfg.SubscriptionID = "12345" + cfg.ClientID = "12345" + cfg.ClientJWT = getJWT(10*time.Minute, false) + + assertInvalid(t, cfg) +} + func emptyClientConfig() ClientConfig { cfg := ClientConfig{} _ = cfg.setCloudEnvironment() return cfg } + +func Test_getJWT(t *testing.T) { + if getJWT(time.Minute, true) == "" { + t.Fatalf("getJWT is broken") + } +} + +func newRandReader() io.Reader { + var seed int64 + binary.Read(crand.Reader, binary.LittleEndian, &seed) + + return mrand.New(mrand.NewSource(seed)) +} + +func getJWT(validFor time.Duration, withX5tHeader bool) string { + token := jwt.New(jwt.SigningMethodRS256) + key, _ := rsa.GenerateKey(newRandReader(), 2048) + + token.Claims = jwt.MapClaims{ + "aud": "https://login.microsoftonline.com/tenant.onmicrosoft.com/oauth2/token?api-version=1.0", + "iss": "355dff10-cd78-11e8-89fe-000d3afd16e3", + "sub": "355dff10-cd78-11e8-89fe-000d3afd16e3", + "jti": base64.URLEncoding.EncodeToString([]byte{0}), + "nbf": time.Now().Unix(), + "exp": time.Now().Add(validFor).Unix(), + } + if withX5tHeader { + token.Header["x5t"] = base64.URLEncoding.EncodeToString([]byte("thumbprint")) + } + + jwt, _ := token.SignedString(key) + return jwt +}