This is an automated email from the ASF dual-hosted git repository.

etudenhoefner pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 8ae75596c8 Azure: Add support to specify token credential provider 
(#14136)
8ae75596c8 is described below

commit 8ae75596c8ba68b37d05ebd0647c8ba4e3a76cbf
Author: S N Munendra <[email protected]>
AuthorDate: Tue Sep 30 11:54:55 2025 +0530

    Azure: Add support to specify token credential provider (#14136)
---
 .../iceberg/azure/AdlsTokenCredentialProvider.java |  34 +++++
 .../azure/AdlsTokenCredentialProviders.java        |  96 +++++++++++++
 .../org/apache/iceberg/azure/AzureProperties.java  |  30 +++-
 .../azure/TestAdlsTokenCredentialProviders.java    | 152 +++++++++++++++++++++
 .../apache/iceberg/azure/TestAzureProperties.java  |  76 +++++++++++
 5 files changed, 385 insertions(+), 3 deletions(-)

diff --git 
a/azure/src/main/java/org/apache/iceberg/azure/AdlsTokenCredentialProvider.java 
b/azure/src/main/java/org/apache/iceberg/azure/AdlsTokenCredentialProvider.java
new file mode 100644
index 0000000000..00d4e95b27
--- /dev/null
+++ 
b/azure/src/main/java/org/apache/iceberg/azure/AdlsTokenCredentialProvider.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.azure;
+
+import com.azure.core.credential.TokenCredential;
+import java.util.Map;
+
+public interface AdlsTokenCredentialProvider {
+
+  TokenCredential credential();
+
+  /**
+   * Initialize Azure credential provider from provider properties.
+   *
+   * @param properties credential provider properties
+   */
+  void initialize(Map<String, String> properties);
+}
diff --git 
a/azure/src/main/java/org/apache/iceberg/azure/AdlsTokenCredentialProviders.java
 
b/azure/src/main/java/org/apache/iceberg/azure/AdlsTokenCredentialProviders.java
new file mode 100644
index 0000000000..09faaa959c
--- /dev/null
+++ 
b/azure/src/main/java/org/apache/iceberg/azure/AdlsTokenCredentialProviders.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.azure;
+
+import com.azure.core.credential.TokenCredential;
+import com.azure.identity.DefaultAzureCredentialBuilder;
+import java.util.Map;
+import org.apache.iceberg.common.DynConstructors;
+import org.apache.iceberg.relocated.com.google.common.base.Strings;
+import org.apache.iceberg.util.PropertyUtil;
+
+public class AdlsTokenCredentialProviders {
+
+  private static final DefaultTokenCredentialProvider 
DEFAULT_TOKEN_CREDENTIAL_PROVIDER =
+      new DefaultTokenCredentialProvider();
+
+  private AdlsTokenCredentialProviders() {}
+
+  public static AdlsTokenCredentialProvider defaultFactory() {
+    return DEFAULT_TOKEN_CREDENTIAL_PROVIDER;
+  }
+
+  public static AdlsTokenCredentialProvider from(Map<String, String> 
properties) {
+    String providerImpl =
+        PropertyUtil.propertyAsString(
+            properties, AzureProperties.ADLS_TOKEN_CREDENTIAL_PROVIDER, null);
+    Map<String, String> credentialProviderProperties =
+        PropertyUtil.propertiesWithPrefix(properties, 
AzureProperties.ADLS_TOKEN_PROVIDER_PREFIX);
+    return loadCredentialProvider(providerImpl, credentialProviderProperties);
+  }
+
+  private static AdlsTokenCredentialProvider loadCredentialProvider(
+      String impl, Map<String, String> properties) {
+    if (Strings.isNullOrEmpty(impl)) {
+      AdlsTokenCredentialProvider provider = defaultFactory();
+      provider.initialize(properties);
+      return provider;
+    }
+
+    DynConstructors.Ctor<AdlsTokenCredentialProvider> ctor;
+    try {
+      ctor =
+          DynConstructors.builder(AdlsTokenCredentialProvider.class)
+              .loader(AdlsTokenCredentialProviders.class.getClassLoader())
+              .hiddenImpl(impl)
+              .buildChecked();
+    } catch (NoSuchMethodException e) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Cannot initialize AdlsTokenCredentialProvider, missing no-arg 
constructor: %s",
+              impl),
+          e);
+    }
+
+    AdlsTokenCredentialProvider provider;
+    try {
+      provider = ctor.newInstance();
+    } catch (ClassCastException e) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Cannot initialize AdlsTokenCredentialProvider, %s does not 
implement AdlsTokenCredentialProvider.",
+              impl),
+          e);
+    }
+
+    provider.initialize(properties);
+    return provider;
+  }
+
+  static class DefaultTokenCredentialProvider implements 
AdlsTokenCredentialProvider {
+
+    @Override
+    public TokenCredential credential() {
+      return new DefaultAzureCredentialBuilder().build();
+    }
+
+    @Override
+    public void initialize(Map<String, String> properties) {}
+  }
+}
diff --git a/azure/src/main/java/org/apache/iceberg/azure/AzureProperties.java 
b/azure/src/main/java/org/apache/iceberg/azure/AzureProperties.java
index 9e2143f943..38ac573b59 100644
--- a/azure/src/main/java/org/apache/iceberg/azure/AzureProperties.java
+++ b/azure/src/main/java/org/apache/iceberg/azure/AzureProperties.java
@@ -21,7 +21,6 @@ package org.apache.iceberg.azure;
 import com.azure.core.credential.AccessToken;
 import com.azure.core.credential.TokenCredential;
 import com.azure.core.credential.TokenRequestContext;
-import com.azure.identity.DefaultAzureCredentialBuilder;
 import com.azure.storage.common.StorageSharedKeyCredential;
 import com.azure.storage.file.datalake.DataLakeFileSystemClientBuilder;
 import java.io.Serializable;
@@ -50,6 +49,29 @@ public class AzureProperties implements Serializable {
   public static final String ADLS_SHARED_KEY_ACCOUNT_KEY = 
"adls.auth.shared-key.account.key";
   public static final String ADLS_TOKEN = "adls.token";
 
+  /**
+   * Configure the ADLS token credential provider used to get {@link 
TokenCredential}. A fully
+   * qualified concrete class with package that implements the {@link 
AdlsTokenCredentialProvider}
+   * interface is required.
+   *
+   * <p>The implementation class must have a no-arg constructor and will be 
initialized by calling
+   * the {@link AdlsTokenCredentialProvider#initialize(Map)} method with the 
catalog properties.
+   *
+   * <p>Example: 
adls.token-credential-provider=com.example.MyCustomTokenCredentialProvider
+   *
+   * <p>When set, the {@link AdlsTokenCredentialProviders#from(Map)} method 
will use this provider
+   * to get ADLS credentials instead of using the default.
+   */
+  public static final String ADLS_TOKEN_CREDENTIAL_PROVIDER = 
"adls.token-credential-provider";
+
+  /**
+   * Used by the configured {@link #ADLS_TOKEN_CREDENTIAL_PROVIDER} value that 
will be used by
+   * {@link AdlsTokenCredentialProviders#defaultFactory()} and other token 
credential provider
+   * classes to pass provider-specific properties. Each property consists of a 
key name and an
+   * associated value.
+   */
+  public static final String ADLS_TOKEN_PROVIDER_PREFIX = 
"adls.token-credential-provider.";
+
   /**
    * When set, the {@link VendedAdlsCredentialProvider} will be used to fetch 
and refresh vended
    * credentials from this endpoint.
@@ -68,7 +90,7 @@ public class AzureProperties implements Serializable {
   private String adlsRefreshCredentialsEndpoint;
   private boolean adlsRefreshCredentialsEnabled;
   private String token;
-  private Map<String, String> allProperties;
+  private Map<String, String> allProperties = Collections.emptyMap();
 
   public AzureProperties() {}
 
@@ -153,7 +175,9 @@ public class AzureProperties implements Serializable {
             };
         builder.credential(tokenCredential);
       } else {
-        builder.credential(new DefaultAzureCredentialBuilder().build());
+        AdlsTokenCredentialProvider credentialProvider =
+            AdlsTokenCredentialProviders.from(allProperties);
+        builder.credential(credentialProvider.credential());
       }
     }
 
diff --git 
a/azure/src/test/java/org/apache/iceberg/azure/TestAdlsTokenCredentialProviders.java
 
b/azure/src/test/java/org/apache/iceberg/azure/TestAdlsTokenCredentialProviders.java
new file mode 100644
index 0000000000..5060a9bc44
--- /dev/null
+++ 
b/azure/src/test/java/org/apache/iceberg/azure/TestAdlsTokenCredentialProviders.java
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.azure;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static 
org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+import com.azure.core.credential.AccessToken;
+import com.azure.core.credential.TokenCredential;
+import com.azure.core.credential.TokenRequestContext;
+import java.time.OffsetDateTime;
+import java.util.Map;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+public class TestAdlsTokenCredentialProviders {
+
+  @Test
+  public void useDefaultFactory() {
+    assertThat(AdlsTokenCredentialProviders.defaultFactory())
+        .isNotNull()
+        
.isInstanceOf(AdlsTokenCredentialProviders.DefaultTokenCredentialProvider.class);
+  }
+
+  @Test
+  public void emptyPropertiesWithNoProvider() {
+    assertThat(AdlsTokenCredentialProviders.from(ImmutableMap.of()))
+        .isNotNull()
+        
.isInstanceOf(AdlsTokenCredentialProviders.DefaultTokenCredentialProvider.class);
+  }
+
+  @Test
+  public void emptyCredentialProvider() {
+    Map<String, String> properties =
+        ImmutableMap.of(AzureProperties.ADLS_TOKEN_CREDENTIAL_PROVIDER, "");
+    assertThat(AdlsTokenCredentialProviders.from(properties))
+        .isNotNull()
+        
.isInstanceOf(AdlsTokenCredentialProviders.DefaultTokenCredentialProvider.class);
+  }
+
+  @Test
+  public void defaultProviderAsCredentialProvider() {
+    Map<String, String> properties =
+        ImmutableMap.of(
+            AzureProperties.ADLS_TOKEN_CREDENTIAL_PROVIDER,
+            
AdlsTokenCredentialProviders.DefaultTokenCredentialProvider.class.getName());
+    assertThat(AdlsTokenCredentialProviders.from(properties))
+        .isNotNull()
+        
.isInstanceOf(AdlsTokenCredentialProviders.DefaultTokenCredentialProvider.class);
+  }
+
+  @Test
+  public void customProviderAsCredentialProvider() {
+    Map<String, String> properties =
+        ImmutableMap.of(
+            AzureProperties.ADLS_TOKEN_CREDENTIAL_PROVIDER,
+            
TestAdlsTokenCredentialProviders.DummyTokenCredentialProvider.class.getName());
+    AdlsTokenCredentialProvider provider = 
AdlsTokenCredentialProviders.from(properties);
+
+    
assertThat(provider).isNotNull().isInstanceOf(DummyTokenCredentialProvider.class);
+    assertThat(provider.credential()).isInstanceOf(DummyTokenCredential.class);
+  }
+
+  @Test
+  public void nonExistentCredentialProvider() {
+    Map<String, String> properties =
+        ImmutableMap.of(
+            AzureProperties.ADLS_TOKEN_CREDENTIAL_PROVIDER,
+            "org.apache.iceberg.azure.NonExistentProvider");
+
+    assertThatIllegalArgumentException()
+        .isThrownBy(() -> AdlsTokenCredentialProviders.from(properties))
+        .withMessageContaining(
+            "Cannot initialize AdlsTokenCredentialProvider, missing no-arg 
constructor");
+  }
+
+  @Test
+  public void nonImplementingClassAsCredentialProvider() {
+    Map<String, String> properties =
+        ImmutableMap.of(AzureProperties.ADLS_TOKEN_CREDENTIAL_PROVIDER, 
"java.lang.String");
+    assertThatIllegalArgumentException()
+        .isThrownBy(() -> AdlsTokenCredentialProviders.from(properties))
+        .withMessageContaining("java.lang.String does not implement 
AdlsTokenCredentialProvider");
+  }
+
+  @Test
+  public void loadCredentialProviderWithProperties() {
+    Map<String, String> properties =
+        ImmutableMap.of(
+            AzureProperties.ADLS_TOKEN_CREDENTIAL_PROVIDER,
+            
TestAdlsTokenCredentialProviders.DummyTokenCredentialProvider.class.getName(),
+            AzureProperties.ADLS_TOKEN_PROVIDER_PREFIX + "client-id",
+            "clientId",
+            AzureProperties.ADLS_TOKEN_PROVIDER_PREFIX + "client-secret",
+            "clientSecret",
+            "custom.property",
+            "custom.value");
+
+    AdlsTokenCredentialProvider provider = 
AdlsTokenCredentialProviders.from(properties);
+    assertThat(provider).isInstanceOf(DummyTokenCredentialProvider.class);
+    DummyTokenCredentialProvider credentialProvider = 
(DummyTokenCredentialProvider) provider;
+    assertThat(credentialProvider.properties())
+        .containsEntry("client-id", "clientId")
+        .containsEntry("client-secret", "clientSecret")
+        .doesNotContainKey("custom.property")
+        .doesNotContainKey(AzureProperties.ADLS_TOKEN_CREDENTIAL_PROVIDER);
+    assertThat(provider.credential()).isInstanceOf(DummyTokenCredential.class);
+  }
+
+  static class DummyTokenCredentialProvider implements 
AdlsTokenCredentialProvider {
+
+    private Map<String, String> properties;
+
+    @Override
+    public TokenCredential credential() {
+      return new DummyTokenCredential();
+    }
+
+    @Override
+    public void initialize(Map<String, String> credentialProperties) {
+      this.properties = credentialProperties;
+    }
+
+    public Map<String, String> properties() {
+      return properties;
+    }
+  }
+
+  static class DummyTokenCredential implements TokenCredential {
+    @Override
+    public Mono<AccessToken> getToken(TokenRequestContext request) {
+      return Mono.just(new AccessToken("dummy-token", 
OffsetDateTime.now().plusHours(1)));
+    }
+  }
+}
diff --git 
a/azure/src/test/java/org/apache/iceberg/azure/TestAzureProperties.java 
b/azure/src/test/java/org/apache/iceberg/azure/TestAzureProperties.java
index 12cde198ea..514e7faad4 100644
--- a/azure/src/test/java/org/apache/iceberg/azure/TestAzureProperties.java
+++ b/azure/src/test/java/org/apache/iceberg/azure/TestAzureProperties.java
@@ -42,6 +42,9 @@ import com.azure.identity.DefaultAzureCredential;
 import com.azure.storage.common.StorageSharedKeyCredential;
 import com.azure.storage.file.datalake.DataLakeFileSystemClientBuilder;
 import java.io.IOException;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.Map;
 import java.util.Optional;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.TestHelpers;
@@ -68,6 +71,8 @@ public class TestAzureProperties {
                 .put(ADLS_WRITE_BLOCK_SIZE, "42")
                 .put(ADLS_SHARED_KEY_ACCOUNT_NAME, "me")
                 .put(ADLS_SHARED_KEY_ACCOUNT_KEY, "secret")
+                .put(AzureProperties.ADLS_TOKEN_CREDENTIAL_PROVIDER, 
"provider")
+                .put(AzureProperties.ADLS_TOKEN_PROVIDER_PREFIX + "client-id", 
"clientId")
                 .build());
 
     AzureProperties serdedProps = roundTripSerializer.apply(props);
@@ -235,4 +240,75 @@ public class TestAzureProperties {
     verify(clientBuilder, 
never()).credential(any(StorageSharedKeyCredential.class));
     verify(clientBuilder, 
never()).credential(any(com.azure.identity.DefaultAzureCredential.class));
   }
+
+  @Test
+  public void testDefaultTokenCredentialProvider() {
+    // No SAS, no shared key, no explicit token, no refresh endpoint -> 
default token provider
+    AzureProperties props = new AzureProperties(ImmutableMap.of());
+
+    DataLakeFileSystemClientBuilder clientBuilder = 
mock(DataLakeFileSystemClientBuilder.class);
+
+    props.applyClientConfiguration("account", clientBuilder);
+
+    // Default provider should be DefaultAzureCredential
+    verify(clientBuilder).credential(any(DefaultAzureCredential.class));
+    verify(clientBuilder, never()).sasToken(any());
+    verify(clientBuilder, 
never()).credential(any(StorageSharedKeyCredential.class));
+  }
+
+  @Test
+  public void testCustomTokenCredentialProvider() {
+    ImmutableMap<String, String> properties =
+        ImmutableMap.<String, String>builder()
+            .put(
+                AzureProperties.ADLS_TOKEN_CREDENTIAL_PROVIDER,
+                
TestAzureProperties.DummyTokenCredentialProvider.class.getName())
+            .put(AzureProperties.ADLS_TOKEN_PROVIDER_PREFIX + "client-id", 
"clientId")
+            .put(AzureProperties.ADLS_TOKEN_PROVIDER_PREFIX + "client-secret", 
"clientSecret")
+            .put("custom.property", "custom.value")
+            .build();
+
+    AzureProperties props = new AzureProperties(properties);
+
+    DataLakeFileSystemClientBuilder clientBuilder = 
mock(DataLakeFileSystemClientBuilder.class);
+    ArgumentCaptor<TokenCredential> credentialCaptor =
+        ArgumentCaptor.forClass(TokenCredential.class);
+
+    props.applyClientConfiguration("account", clientBuilder);
+
+    verify(clientBuilder).credential(credentialCaptor.capture());
+    TokenCredential credential = credentialCaptor.getValue();
+    assertThat(credential).isInstanceOf(DummyTokenCredential.class);
+
+    // Provider should receive only prefixed properties, with prefix stripped
+    assertThat(DummyTokenCredentialProvider.properties)
+        .containsEntry("client-id", "clientId")
+        .containsEntry("client-secret", "clientSecret")
+        .doesNotContainKey("custom.property")
+        .doesNotContainKey(AzureProperties.ADLS_TOKEN_CREDENTIAL_PROVIDER);
+
+    verify(clientBuilder, never()).sasToken(any());
+    verify(clientBuilder, 
never()).credential(any(StorageSharedKeyCredential.class));
+  }
+
+  static class DummyTokenCredential implements TokenCredential {
+    @Override
+    public Mono<AccessToken> getToken(TokenRequestContext request) {
+      return Mono.just(new AccessToken("dummy", 
OffsetDateTime.now(ZoneOffset.UTC).plusHours(1)));
+    }
+  }
+
+  static class DummyTokenCredentialProvider implements 
AdlsTokenCredentialProvider {
+    static Map<String, String> properties;
+
+    @Override
+    public TokenCredential credential() {
+      return new DummyTokenCredential();
+    }
+
+    @Override
+    public void initialize(Map<String, String> credentialProperties) {
+      properties = credentialProperties;
+    }
+  }
 }

Reply via email to