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.
This commit is contained in:
Paul Meyer 2019-01-11 20:54:15 +00:00
parent 7e34579b7e
commit aa29facdae
4 changed files with 465 additions and 11 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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()

View File

@ -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
}