From 3ae1c0e22564a017fb747a701b25827ef6b79245 Mon Sep 17 00:00:00 2001 From: Julio Lopez <1953782+julio-lopez@users.noreply.github.com> Date: Sat, 19 Jul 2025 12:24:03 -0700 Subject: [PATCH] feat(providers): Azure Federated Identity support (#4728) Add authentication support for Azure Federated Identity (AFI) Authored-by: @alisonb-veeam Authored-by: Alison Burgess --- cli/storage_azure.go | 1 + repo/blob/azure/azure_options.go | 3 ++ repo/blob/azure/azure_storage.go | 15 ++++++- repo/blob/azure/azure_storage_test.go | 63 ++++++++++++++++++++++----- 4 files changed, 69 insertions(+), 13 deletions(-) diff --git a/cli/storage_azure.go b/cli/storage_azure.go index 1a7d9b98e..14abda32b 100644 --- a/cli/storage_azure.go +++ b/cli/storage_azure.go @@ -26,6 +26,7 @@ func (c *storageAzureFlags) Setup(svc StorageProviderServices, cmd *kingpin.CmdC cmd.Flag("client-id", "Azure service principle client ID (overrides AZURE_CLIENT_ID environment variable)").Envar(svc.EnvName("AZURE_CLIENT_ID")).StringVar(&c.azOptions.ClientID) cmd.Flag("client-secret", "Azure service principle client secret (overrides AZURE_CLIENT_SECRET environment variable)").Envar(svc.EnvName("AZURE_CLIENT_SECRET")).StringVar(&c.azOptions.ClientSecret) cmd.Flag("client-cert", "Azure client certificate (overrides AZURE_CLIENT_CERTIFICATE environment variable)").Envar(svc.EnvName("AZURE_CLIENT_CERTIFICATE")).StringVar(&c.azOptions.ClientCertificate) + cmd.Flag("azure-federated-token-file", "Path to a file containing an Azure Federated Token (overrides AZURE_FEDERATED_TOKEN_FILE environment variable)").Envar(svc.EnvName("AZURE_FEDERATED_TOKEN_FILE")).StringVar(&c.azOptions.AzureFederatedTokenFile) commonThrottlingFlags(cmd, &c.azOptions.Limits) diff --git a/repo/blob/azure/azure_options.go b/repo/blob/azure/azure_options.go index ce5e487e0..2731e1481 100644 --- a/repo/blob/azure/azure_options.go +++ b/repo/blob/azure/azure_options.go @@ -31,6 +31,9 @@ type Options struct { // ClientCertificate are used for creating ClientCertificateCredentials ClientCertificate string `json:"clientCertificate,omitempty" kopia:"sensitive"` + // AzureFederatedTokenFile is the path to a file containing an Azure Federated Token. + AzureFederatedTokenFile string `json:"azureFederatedTokenFile,omitempty"` + StorageDomain string `json:"storageDomain,omitempty"` throttling.Limits diff --git a/repo/blob/azure/azure_storage.go b/repo/blob/azure/azure_storage.go index fbb200357..3d810f315 100644 --- a/repo/blob/azure/azure_storage.go +++ b/repo/blob/azure/azure_storage.go @@ -403,6 +403,7 @@ func New(ctx context.Context, opt *Options, isCreate bool) (blob.Storage, error) return az, nil } +//nolint:gocyclo func getAZService(opt *Options, storageHostname string) (*azblob.Client, error) { var ( service *azblob.Client @@ -445,9 +446,21 @@ func getAZService(opt *Options, storageHostname string) (*azblob.Client, error) return nil, errors.Wrap(credErr, "unable to initialize client cert credential") } + service, serviceErr = azblob.NewClient(fmt.Sprintf("https://%s/", storageHostname), cred, nil) + // Azure Federated Token + case opt.TenantID != "" && opt.ClientID != "" && opt.AzureFederatedTokenFile != "": + cred, err := azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{ + ClientID: opt.ClientID, + TenantID: opt.TenantID, + TokenFilePath: opt.AzureFederatedTokenFile, + }) + if err != nil { + return nil, errors.Wrap(err, "unable to initialize Azure Federated Identity workload identity credential") + } + service, serviceErr = azblob.NewClient(fmt.Sprintf("https://%s/", storageHostname), cred, nil) default: - return nil, errors.New("one of the storage key, SAS token, client secret or client certificate must be provided") + return nil, errors.New("one of the storage key, SAS token, client secret, client certificate, or Azure Federated Token must be provided") } return service, errors.Wrap(serviceErr, "unable to create azure client") diff --git a/repo/blob/azure/azure_storage_test.go b/repo/blob/azure/azure_storage_test.go index 7d737b912..089893b13 100644 --- a/repo/blob/azure/azure_storage_test.go +++ b/repo/blob/azure/azure_storage_test.go @@ -22,18 +22,19 @@ ) const ( - testContainerEnv = "KOPIA_AZURE_TEST_CONTAINER" - testStorageAccountEnv = "KOPIA_AZURE_TEST_STORAGE_ACCOUNT" - testStorageKeyEnv = "KOPIA_AZURE_TEST_STORAGE_KEY" - testStorageSASTokenEnv = "KOPIA_AZURE_TEST_SAS_TOKEN" - testImmutableContainerEnv = "KOPIA_AZURE_TEST_IMMUTABLE_CONTAINER" - testImmutableStorageAccountEnv = "KOPIA_AZURE_TEST_IMMUTABLE_STORAGE_ACCOUNT" - testImmutableStorageKeyEnv = "KOPIA_AZURE_TEST_IMMUTABLE_STORAGE_KEY" - testImmutableStorageSASTokenEnv = "KOPIA_AZURE_TEST_IMMUTABLE_SAS_TOKEN" - testStorageTenantIDEnv = "KOPIA_AZURE_TEST_TENANT_ID" - testStorageClientIDEnv = "KOPIA_AZURE_TEST_CLIENT_ID" - testStorageClientSecretEnv = "KOPIA_AZURE_TEST_CLIENT_SECRET" - testStorageClientCertEnv = "KOPIA_AZURE_TEST_CLIENT_CERTIFICATE" + testContainerEnv = "KOPIA_AZURE_TEST_CONTAINER" + testStorageAccountEnv = "KOPIA_AZURE_TEST_STORAGE_ACCOUNT" + testStorageKeyEnv = "KOPIA_AZURE_TEST_STORAGE_KEY" + testStorageSASTokenEnv = "KOPIA_AZURE_TEST_SAS_TOKEN" + testImmutableContainerEnv = "KOPIA_AZURE_TEST_IMMUTABLE_CONTAINER" + testImmutableStorageAccountEnv = "KOPIA_AZURE_TEST_IMMUTABLE_STORAGE_ACCOUNT" + testImmutableStorageKeyEnv = "KOPIA_AZURE_TEST_IMMUTABLE_STORAGE_KEY" + testImmutableStorageSASTokenEnv = "KOPIA_AZURE_TEST_IMMUTABLE_SAS_TOKEN" + testStorageTenantIDEnv = "KOPIA_AZURE_TEST_TENANT_ID" + testStorageClientIDEnv = "KOPIA_AZURE_TEST_CLIENT_ID" + testStorageClientSecretEnv = "KOPIA_AZURE_TEST_CLIENT_SECRET" + testStorageClientCertEnv = "KOPIA_AZURE_TEST_CLIENT_CERTIFICATE" + testAzureFederatedIdentityFilePathEnv = "KOPIA_AZURE_FEDERATED_IDENTITY_FILE_PATH" ) func getEnvOrSkip(t *testing.T, name string) string { @@ -240,6 +241,44 @@ func TestAzureStorageClientCertificate(t *testing.T) { require.NoError(t, providervalidation.ValidateProvider(ctx, st, blobtesting.TestValidationOptions)) } +func TestAzureFederatedIdentity(t *testing.T) { + t.Parallel() + testutil.ProviderTest(t) + + container := getEnvOrSkip(t, testContainerEnv) + storageAccount := getEnvOrSkip(t, testStorageAccountEnv) + tenantID := getEnvOrSkip(t, testStorageTenantIDEnv) + clientID := getEnvOrSkip(t, testStorageClientIDEnv) + azureFederatedTokenFilePath := getEnvOrSkip(t, testAzureFederatedIdentityFilePathEnv) + + data := make([]byte, 8) + rand.Read(data) + + ctx := testlogging.Context(t) + + // use context that gets canceled after storage is initialize, + // to verify we do not depend on the original context past initialization. + newctx, cancel := context.WithCancel(ctx) + st, err := azure.New(newctx, &azure.Options{ + Container: container, + StorageAccount: storageAccount, + TenantID: tenantID, + ClientID: clientID, + AzureFederatedTokenFile: azureFederatedTokenFilePath, + Prefix: fmt.Sprintf("sastest-%v-%x/", clock.Now().Unix(), data), + }, false) + + require.NoError(t, err) + cancel() + + defer st.Close(ctx) + defer blobtesting.CleanupOldData(ctx, t, st, 0) + + blobtesting.VerifyStorage(ctx, t, st, blob.PutOptions{}) + blobtesting.AssertConnectionInfoRoundTrips(ctx, t, st) + require.NoError(t, providervalidation.ValidateProvider(ctx, st, blobtesting.TestValidationOptions)) +} + func TestAzureStorageInvalidBlob(t *testing.T) { testutil.ProviderTest(t)