Skip to content

Commit

Permalink
Merge pull request #5570 from weisdd/feature/azure-workload-identity
Browse files Browse the repository at this point in the history
feat(AzureDNS): Add support for Workload Identity
  • Loading branch information
jetstack-bot committed Nov 30, 2022
2 parents f85c8c9 + df20fcd commit 77c410f
Show file tree
Hide file tree
Showing 2 changed files with 192 additions and 1 deletion.
71 changes: 70 additions & 1 deletion pkg/issuer/acme/dns/azuredns/azuredns.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ package azuredns
import (
"context"
"fmt"
"os"
"strings"

"github.com/go-logr/logr"
Expand Down Expand Up @@ -71,6 +72,35 @@ func NewDNSProviderCredentials(environment, clientID, clientSecret, subscription
}, nil
}

// getFederatedSPT prepares an SPT for a Workload Identity-enabled setup
func getFederatedSPT(env azure.Environment, options adal.ManagedIdentityOptions) (*adal.ServicePrincipalToken, error) {
// NOTE: all related environment variables are described here: https://azure.github.io/azure-workload-identity/docs/installation/mutating-admission-webhook.html
oauthConfig, err := adal.NewOAuthConfig(env.ActiveDirectoryEndpoint, os.Getenv("AZURE_TENANT_ID"))
if err != nil {
return nil, fmt.Errorf("failed to retrieve OAuth config: %v", err)
}

jwt, err := os.ReadFile(os.Getenv("AZURE_FEDERATED_TOKEN_FILE"))
if err != nil {
return nil, fmt.Errorf("failed to read a file with a federated token: %v", err)
}

// AZURE_CLIENT_ID will be empty in case azure.workload.identity/client-id annotation is not set
// Also, some users might want to use a different MSI for a particular DNS zone
// Thus, it's important to offer optional ClientID overrides
clientID := os.Getenv("AZURE_CLIENT_ID")
if options.ClientID != "" {
clientID = options.ClientID
}

token, err := adal.NewServicePrincipalTokenFromFederatedToken(*oauthConfig, clientID, string(jwt), env.ResourceManagerEndpoint)
if err != nil {
return nil, fmt.Errorf("failed to create a workload identity token: %v", err)
}

return token, nil
}

func getAuthorization(env azure.Environment, clientID, clientSecret, subscriptionID, tenantID string, ambient bool, managedIdentity *cmacme.AzureManagedIdentity) (*adal.ServicePrincipalToken, error) {
if clientID != "" {
logf.Log.V(logf.InfoLevel).Info("azuredns authenticating with clientID and secret key")
Expand All @@ -84,7 +114,7 @@ func getAuthorization(env azure.Environment, clientID, clientSecret, subscriptio
}
return spt, nil
}
logf.Log.V(logf.InfoLevel).Info("No ClientID found: authenticating azuredns with managed identity (MSI)")
logf.Log.V(logf.InfoLevel).Info("No ClientID found: attempting to authenticate with ambient credentials (Azure Workload Identity or Azure Managed Service Identity, in that order)")
if !ambient {
return nil, fmt.Errorf("ClientID is not set but neither `--cluster-issuer-ambient-credentials` nor `--issuer-ambient-credentials` are set. These are necessary to enable Azure Managed Identities")
}
Expand All @@ -96,6 +126,45 @@ func getAuthorization(env azure.Environment, clientID, clientSecret, subscriptio
opt.IdentityResourceID = managedIdentity.ResourceID
}

// Use Workload Identity if present
if os.Getenv("AZURE_FEDERATED_TOKEN_FILE") != "" {
spt, err := getFederatedSPT(env, opt)
if err != nil {
return nil, err
}

// adal does not offer methods to dynamically replace a federated token, thus we need to have a wrapper to make sure
// we're using up-to-date secret while requesting an access token.
// NOTE: There's no RefreshToken in the whole process (in fact, it's absent in AAD responses). An AccessToken can be
// received only in exchange for a federated token.
var refreshFunc adal.TokenRefresh = func(context context.Context, resource string) (*adal.Token, error) {
newSPT, err := getFederatedSPT(env, opt)
if err != nil {
return nil, err
}

// An AccessToken gets populated into an spt only when .Refresh() is called. Normally, it's something that happens implicitly when
// a first request to manipulate Azure resources is made. Since our goal here is only to receive a fresh AccessToken, we need to make
// an explicit call.
// .Refresh() itself results in a call to Oauth endpoint. During the process, a federated token is exchanged for an AccessToken.
// RefreshToken is absent from responses.
err = newSPT.Refresh()
if err != nil {
return nil, err
}

accessToken := newSPT.Token()

return &accessToken, nil
}

spt.SetCustomRefreshFunc(refreshFunc)

return spt, nil
}

logf.Log.V(logf.InfoLevel).Info("No Azure Workload Identity found: attempting to authenticate with an Azure Managed Service Identity (MSI)")

spt, err := adal.NewServicePrincipalTokenFromManagedIdentity(env.ServiceManagementEndpoint, &opt)
if err != nil {
return nil, fmt.Errorf("failed to create the managed service identity token: %v", err)
Expand Down
122 changes: 122 additions & 0 deletions pkg/issuer/acme/dns/azuredns/azuredns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ this directory.
package azuredns

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"

"github.com/Azure/go-autorest/autorest/adal"
"github.com/Azure/go-autorest/autorest/azure"
v1 "github.com/cert-manager/cert-manager/pkg/apis/acme/v1"
"github.com/cert-manager/cert-manager/pkg/issuer/acme/dns/util"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -77,3 +83,119 @@ func TestInvalidAzureDns(t *testing.T) {
_, err := NewDNSProviderCredentials("invalid env", "cid", "secret", "", "", "", "", util.RecursiveNameservers, false, &v1.AzureManagedIdentity{})
assert.Error(t, err)
}

func populateFederatedToken(t *testing.T, filename string, content string) {
t.Helper()

f, err := os.Create(filename)
if err != nil {
assert.FailNow(t, err.Error())
}

if _, err := io.WriteString(f, content); err != nil {
assert.FailNow(t, err.Error())
}

if err := f.Close(); err != nil {
assert.FailNow(t, err.Error())
}
}

func TestGetAuthorizationFederatedSPT(t *testing.T) {
// Create a file that will be used to store a federated token
f, err := os.CreateTemp("", "")
if err != nil {
assert.FailNow(t, err.Error())
}
defer os.Remove(f.Name())

// Close the file to simplify logic within populateFederatedToken helper
if err := f.Close(); err != nil {
assert.FailNow(t, err.Error())
}

// The initial federated token is never used, so we don't care about the value yet
// Though, it's a requirement from adal to have a non-empty value set
populateFederatedToken(t, f.Name(), "random-jwt")

// Prepare environment variables adal will rely on. Skip changes for some envs if they are already defined (=live environment)
// Envs themselves are described here: https://azure.github.io/azure-workload-identity/docs/installation/mutating-admission-webhook.html
if os.Getenv("AZURE_TENANT_ID") == "" {
t.Setenv("AZURE_TENANT_ID", "fakeTenantID")
}

if os.Getenv("AZURE_CLIENT_ID") == "" {
t.Setenv("AZURE_CLIENT_ID", "fakeClientID")
}

t.Setenv("AZURE_FEDERATED_TOKEN_FILE", f.Name())

t.Run("token refresh", func(t *testing.T) {
// Basically, we want one token to be exchanged for the other (key and value respectively)
tokens := map[string]string{
"initialFederatedToken": "initialAccessToken",
"refreshedFederatedToken": "refreshedAccessToken",
}

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
assert.FailNow(t, err.Error())
}

w.Header().Set("Content-Type", "application/json")
receivedFederatedToken := r.FormValue("client_assertion")
accessToken := adal.Token{AccessToken: tokens[receivedFederatedToken]}

if err := json.NewEncoder(w).Encode(accessToken); err != nil {
assert.FailNow(t, err.Error())
}

// Expected format: http://<server>/<tenant-ID>/oauth2/token?api-version=1.0
assert.Contains(t, r.RequestURI, os.Getenv("AZURE_TENANT_ID"), "URI should contain the tenant ID exposed through env variable")

assert.Equal(t, os.Getenv("AZURE_CLIENT_ID"), r.FormValue("client_id"), "client_id should match the value exposed through env variable")
}))
defer ts.Close()

ambient := true
env := azure.Environment{ActiveDirectoryEndpoint: ts.URL, ResourceManagerEndpoint: ts.URL}
managedIdentity := &v1.AzureManagedIdentity{ClientID: ""}

spt, err := getAuthorization(env, "", "", "", "", ambient, managedIdentity)
assert.NoError(t, err)

for federatedToken, accessToken := range tokens {
populateFederatedToken(t, f.Name(), federatedToken)
assert.NoError(t, spt.Refresh(), "Token refresh failed")
assert.Equal(t, accessToken, spt.Token().AccessToken, "Access token should have been set to a value returned by the webserver")
}
})

t.Run("clientID overrides through managedIdentity section", func(t *testing.T) {
managedIdentity := &v1.AzureManagedIdentity{ClientID: "anotherClientID"}

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
assert.FailNow(t, err.Error())
}

w.Header().Set("Content-Type", "application/json")
accessToken := adal.Token{AccessToken: "abc"}

if err := json.NewEncoder(w).Encode(accessToken); err != nil {
assert.FailNow(t, err.Error())
}

assert.Equal(t, managedIdentity.ClientID, r.FormValue("client_id"), "client_id should match the value passed through managedIdentity section")
}))
defer ts.Close()

ambient := true
env := azure.Environment{ActiveDirectoryEndpoint: ts.URL, ResourceManagerEndpoint: ts.URL}

spt, err := getAuthorization(env, "", "", "", "", ambient, managedIdentity)
assert.NoError(t, err)

assert.NoError(t, spt.Refresh(), "Token refresh failed")
})
}

0 comments on commit 77c410f

Please sign in to comment.