This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 6d0a90d84b [Cherry-pick to branch-1.3] [#11734] feat(s3): introduce
WebIdentityTokenSource SPI with file-based source (#11735) (#11779)
6d0a90d84b is described below
commit 6d0a90d84b2264860122700e28d1fa9a29ac91f2
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Jun 23 11:55:47 2026 -0700
[Cherry-pick to branch-1.3] [#11734] feat(s3): introduce
WebIdentityTokenSource SPI with file-based source (#11735) (#11779)
**Cherry-pick Information:**
- Original commit: 8515fb545fab99fa63996123574dcbe452b91bbc
- Target branch: `branch-1.3`
- Status: ✅ Clean cherry-pick (no conflicts)
Co-authored-by: Qi Yu <[email protected]>
---
bundles/aws/build.gradle.kts | 2 +
.../s3/credential/AwsIrsaCredentialGenerator.java | 175 ++++++++++---------
.../webidentity/FileWebIdentityTokenSource.java | 88 ++++++++++
.../webidentity/WebIdentityTokenSource.java | 52 ++++++
.../webidentity/WebIdentityTokenSourceConfig.java | 41 +++++
.../webidentity/WebIdentityTokenSources.java | 108 ++++++++++++
...3.credential.webidentity.WebIdentityTokenSource | 19 +++
.../credential/TestAwsIrsaCredentialGenerator.java | 129 ++++++++++++++
.../TestFileWebIdentityTokenSource.java | 128 ++++++++++++++
.../webidentity/TestWebIdentityTokenSources.java | 187 +++++++++++++++++++++
10 files changed, 854 insertions(+), 75 deletions(-)
diff --git a/bundles/aws/build.gradle.kts b/bundles/aws/build.gradle.kts
index 27ba900d5d..7b6d4f1ec1 100644
--- a/bundles/aws/build.gradle.kts
+++ b/bundles/aws/build.gradle.kts
@@ -46,6 +46,8 @@ dependencies {
compileOnly(libs.hadoop3.client.api)
testImplementation(libs.aws.iam)
+ testImplementation(libs.aws.policy)
+ testImplementation(libs.aws.sts)
testImplementation(libs.junit.jupiter.api)
testImplementation(libs.junit.jupiter.params)
testRuntimeOnly(libs.junit.jupiter.engine)
diff --git
a/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/AwsIrsaCredentialGenerator.java
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/AwsIrsaCredentialGenerator.java
index 3c1641506e..c960da6900 100644
---
a/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/AwsIrsaCredentialGenerator.java
+++
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/AwsIrsaCredentialGenerator.java
@@ -20,15 +20,14 @@ package org.apache.gravitino.s3.credential;
import java.io.IOException;
import java.net.URI;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Paths;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Stream;
import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.credential.AwsIrsaCredential;
@@ -36,9 +35,10 @@ import org.apache.gravitino.credential.CredentialContext;
import org.apache.gravitino.credential.CredentialGenerator;
import org.apache.gravitino.credential.PathBasedCredentialContext;
import org.apache.gravitino.credential.config.S3CredentialConfig;
+import org.apache.gravitino.s3.credential.webidentity.WebIdentityTokenSource;
+import org.apache.gravitino.s3.credential.webidentity.WebIdentityTokenSources;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
-import
software.amazon.awssdk.auth.credentials.WebIdentityTokenFileCredentialsProvider;
import software.amazon.awssdk.policybuilder.iam.IamConditionOperator;
import software.amazon.awssdk.policybuilder.iam.IamEffect;
import software.amazon.awssdk.policybuilder.iam.IamPolicy;
@@ -47,6 +47,7 @@ import software.amazon.awssdk.policybuilder.iam.IamStatement;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.sts.StsClient;
import software.amazon.awssdk.services.sts.StsClientBuilder;
+import
software.amazon.awssdk.services.sts.auth.StsAssumeRoleWithWebIdentityCredentialsProvider;
import
software.amazon.awssdk.services.sts.model.AssumeRoleWithWebIdentityRequest;
import
software.amazon.awssdk.services.sts.model.AssumeRoleWithWebIdentityResponse;
import software.amazon.awssdk.services.sts.model.Credentials;
@@ -54,17 +55,18 @@ import
software.amazon.awssdk.services.sts.model.Credentials;
/** Generate AWS IRSA credentials according to the read and write paths. */
public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCredential> {
- private WebIdentityTokenFileCredentialsProvider baseCredentialsProvider;
+ private WebIdentityTokenSource tokenSource;
private String roleArn;
private int tokenExpireSecs;
private String region;
private String stsEndpoint;
private boolean listLocationPrefix;
+ private StsClient basicModeStsClient;
+ private Map<String, StsAssumeRoleWithWebIdentityCredentialsProvider>
basicModeProviders;
@Override
public void initialize(Map<String, String> properties) {
- // Use WebIdentityTokenFileCredentialsProvider for base IRSA configuration
- this.baseCredentialsProvider =
WebIdentityTokenFileCredentialsProvider.create();
+ this.tokenSource = WebIdentityTokenSources.create(properties);
S3CredentialConfig s3CredentialConfig = new S3CredentialConfig(properties);
this.roleArn = s3CredentialConfig.s3RoleArn();
@@ -72,32 +74,14 @@ public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCr
this.region = s3CredentialConfig.region();
this.stsEndpoint = s3CredentialConfig.stsEndpoint();
this.listLocationPrefix = s3CredentialConfig.listLocationPrefix();
+ this.basicModeStsClient = createStsClient();
+ this.basicModeProviders = new ConcurrentHashMap<>();
}
@Override
public AwsIrsaCredential generate(CredentialContext context) {
if (!(context instanceof PathBasedCredentialContext)) {
- // Fallback to original behavior for non-path-based contexts
- AwsCredentials creds = baseCredentialsProvider.resolveCredentials();
- if (creds instanceof AwsSessionCredentials) {
- AwsSessionCredentials sessionCreds = (AwsSessionCredentials) creds;
- if (!sessionCreds.expirationTime().isPresent()) {
- throw new IllegalStateException(
- "AWS IRSA session credentials must include an expiration time
for vended credential"
- + " refresh");
- }
- long expiration = sessionCreds.expirationTime().get().toEpochMilli();
- return new AwsIrsaCredential(
- sessionCreds.accessKeyId(),
- sessionCreds.secretAccessKey(),
- sessionCreds.sessionToken(),
- expiration);
- } else {
- throw new IllegalStateException(
- "AWS IRSA credentials must be of type AwsSessionCredentials. "
- + "Check your EKS/IRSA configuration. Got: "
- + creds.getClass().getName());
- }
+ return
toAwsIrsaCredential(createBasicModeCredentials(context.getUserName()));
}
PathBasedCredentialContext pathBasedCredentialContext =
(PathBasedCredentialContext) context;
@@ -107,11 +91,7 @@ public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCr
pathBasedCredentialContext.getReadPaths(),
pathBasedCredentialContext.getWritePaths(),
pathBasedCredentialContext.getUserName());
- return new AwsIrsaCredential(
- s3Token.accessKeyId(),
- s3Token.secretAccessKey(),
- s3Token.sessionToken(),
- s3Token.expiration().toEpochMilli());
+ return toAwsIrsaCredential(s3Token);
}
private Credentials createCredentialsWithSessionPolicy(
@@ -119,22 +99,61 @@ public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCr
validateInputParameters(readLocations, writeLocations, userName);
IamPolicy sessionPolicy = createSessionPolicy(readLocations,
writeLocations, region);
- String webIdentityTokenFile = getValidatedWebIdentityTokenFile();
+ return createCredentials(userName, Optional.of(sessionPolicy));
+ }
+
+ private Credentials createCredentials(String userName, Optional<IamPolicy>
sessionPolicy) {
String effectiveRoleArn = getValidatedRoleArn(roleArn);
+ String tokenContent = tokenSource.getToken();
+ return assumeRoleWithWebIdentity(effectiveRoleArn, userName, tokenContent,
sessionPolicy);
+ }
- try {
- String tokenContent =
- new String(Files.readAllBytes(Paths.get(webIdentityTokenFile)),
StandardCharsets.UTF_8);
- if (StringUtils.isBlank(tokenContent)) {
- throw new IllegalStateException(
- "Web identity token file is empty: " + webIdentityTokenFile);
- }
+ private AwsCredentials createBasicModeCredentials(String userName) {
+ return basicModeProviders
+ .computeIfAbsent(userName, this::createBasicModeCredentialsProvider)
+ .resolveCredentials();
+ }
+
+ private StsAssumeRoleWithWebIdentityCredentialsProvider
createBasicModeCredentialsProvider(
+ String userName) {
+ return StsAssumeRoleWithWebIdentityCredentialsProvider.builder()
+ .stsClient(basicModeStsClient)
+ .refreshRequest(
+ () ->
+ createAssumeRoleWithWebIdentityRequest(
+ getValidatedRoleArn(roleArn),
+ userName,
+ tokenSource.getToken(),
+ Optional.empty()))
+ .build();
+ }
- return assumeRoleWithSessionPolicy(effectiveRoleArn, userName,
tokenContent, sessionPolicy);
- } catch (Exception e) {
- throw new RuntimeException(
- "Failed to create credentials with session policy for user: " +
userName, e);
+ private AwsIrsaCredential toAwsIrsaCredential(Credentials credentials) {
+ return new AwsIrsaCredential(
+ credentials.accessKeyId(),
+ credentials.secretAccessKey(),
+ credentials.sessionToken(),
+ credentials.expiration().toEpochMilli());
+ }
+
+ private AwsIrsaCredential toAwsIrsaCredential(AwsCredentials credentials) {
+ if (!(credentials instanceof AwsSessionCredentials)) {
+ throw new IllegalStateException(
+ "AWS IRSA credentials must be of type AwsSessionCredentials. "
+ + "Check your EKS/IRSA configuration. Got: "
+ + credentials.getClass().getName());
}
+ AwsSessionCredentials sessionCredentials = (AwsSessionCredentials)
credentials;
+ if (!sessionCredentials.expirationTime().isPresent()) {
+ throw new IllegalStateException(
+ "AWS IRSA session credentials must include an expiration time for
vended credential"
+ + " refresh");
+ }
+ return new AwsIrsaCredential(
+ sessionCredentials.accessKeyId(),
+ sessionCredentials.secretAccessKey(),
+ sessionCredentials.sessionToken(),
+ sessionCredentials.expirationTime().get().toEpochMilli());
}
private IamPolicy createSessionPolicy(
@@ -309,20 +328,6 @@ public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCr
}
}
- private String getValidatedWebIdentityTokenFile() {
- String webIdentityTokenFile = System.getenv("AWS_WEB_IDENTITY_TOKEN_FILE");
- if (StringUtils.isBlank(webIdentityTokenFile)) {
- throw new IllegalStateException(
- "AWS_WEB_IDENTITY_TOKEN_FILE environment variable is not set. "
- + "Ensure IRSA is properly configured in your EKS cluster.");
- }
- if (!Files.exists(Paths.get(webIdentityTokenFile))) {
- throw new IllegalStateException(
- "Web identity token file does not exist: " + webIdentityTokenFile);
- }
- return webIdentityTokenFile;
- }
-
private String getValidatedRoleArn(String configRoleArn) {
String effectiveRoleArn =
StringUtils.isNotBlank(configRoleArn) ? configRoleArn :
System.getenv("AWS_ROLE_ARN");
@@ -336,8 +341,18 @@ public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCr
return effectiveRoleArn;
}
- private Credentials assumeRoleWithSessionPolicy(
- String roleArn, String userName, String webIdentityToken, IamPolicy
sessionPolicy) {
+ private Credentials assumeRoleWithWebIdentity(
+ String roleArn, String userName, String webIdentityToken,
Optional<IamPolicy> sessionPolicy) {
+ try (StsClient stsClient = createStsClient()) {
+ AssumeRoleWithWebIdentityResponse response =
+ stsClient.assumeRoleWithWebIdentity(
+ createAssumeRoleWithWebIdentityRequest(
+ roleArn, userName, webIdentityToken, sessionPolicy));
+ return response.credentials();
+ }
+ }
+
+ private StsClient createStsClient() {
StsClientBuilder stsBuilder = StsClient.builder();
if (StringUtils.isNotBlank(region)) {
stsBuilder.region(Region.of(region));
@@ -345,22 +360,32 @@ public class AwsIrsaCredentialGenerator implements
CredentialGenerator<AwsIrsaCr
if (StringUtils.isNotBlank(stsEndpoint)) {
stsBuilder.endpointOverride(URI.create(stsEndpoint));
}
+ return stsBuilder.build();
+ }
- try (StsClient stsClient = stsBuilder.build()) {
- AssumeRoleWithWebIdentityRequest request =
- AssumeRoleWithWebIdentityRequest.builder()
- .roleArn(roleArn)
- .roleSessionName("gravitino_irsa_session_" + userName)
- .durationSeconds(tokenExpireSecs)
- .webIdentityToken(webIdentityToken)
- .policy(sessionPolicy.toJson())
- .build();
-
- AssumeRoleWithWebIdentityResponse response =
stsClient.assumeRoleWithWebIdentity(request);
- return response.credentials();
- }
+ private AssumeRoleWithWebIdentityRequest
createAssumeRoleWithWebIdentityRequest(
+ String roleArn, String userName, String webIdentityToken,
Optional<IamPolicy> sessionPolicy) {
+ AssumeRoleWithWebIdentityRequest.Builder requestBuilder =
+ AssumeRoleWithWebIdentityRequest.builder()
+ .roleArn(roleArn)
+ .roleSessionName("gravitino_irsa_session_" + userName)
+ .durationSeconds(tokenExpireSecs)
+ .webIdentityToken(webIdentityToken);
+ sessionPolicy.ifPresent(policy -> requestBuilder.policy(policy.toJson()));
+ return requestBuilder.build();
}
@Override
- public void close() throws IOException {}
+ public void close() throws IOException {
+ if (basicModeProviders != null) {
+
basicModeProviders.values().forEach(StsAssumeRoleWithWebIdentityCredentialsProvider::close);
+ basicModeProviders.clear();
+ }
+ if (basicModeStsClient != null) {
+ basicModeStsClient.close();
+ }
+ if (tokenSource != null) {
+ tokenSource.close();
+ }
+ }
}
diff --git
a/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/webidentity/FileWebIdentityTokenSource.java
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/webidentity/FileWebIdentityTokenSource.java
new file mode 100644
index 0000000000..5a49ea1d33
--- /dev/null
+++
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/webidentity/FileWebIdentityTokenSource.java
@@ -0,0 +1,88 @@
+/*
+ * 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.gravitino.s3.credential.webidentity;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Map;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * A {@link WebIdentityTokenSource} that reads the token from a file on disk.
Matches the AWS IRSA /
+ * Kubernetes projected service account token pattern.
+ *
+ * <p>The path is taken from {@code s3-web-identity-token-file}; if absent,
the {@code
+ * AWS_WEB_IDENTITY_TOKEN_FILE} environment variable is used as a fallback so
existing IRSA setups
+ * continue to work without configuration changes.
+ *
+ * <p>The token file is read on every call to {@link #getToken()} so that
token rotations performed
+ * by the kubelet (or any external rotator) are picked up automatically.
+ */
+public class FileWebIdentityTokenSource implements WebIdentityTokenSource {
+
+ public static final String NAME = "file";
+ static final String AWS_WEB_IDENTITY_TOKEN_FILE_ENV =
"AWS_WEB_IDENTITY_TOKEN_FILE";
+
+ private Path tokenFile;
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public void initialize(Map<String, String> properties) {
+ String configured = properties.get(WebIdentityTokenSourceConfig.FILE_PATH);
+ String path =
+ StringUtils.isNotBlank(configured)
+ ? configured
+ : System.getenv(AWS_WEB_IDENTITY_TOKEN_FILE_ENV);
+ if (StringUtils.isBlank(path)) {
+ throw new IllegalStateException(
+ "No WebIdentity token file is configured. Set "
+ + WebIdentityTokenSourceConfig.FILE_PATH
+ + " or the "
+ + AWS_WEB_IDENTITY_TOKEN_FILE_ENV
+ + " environment variable.");
+ }
+ this.tokenFile = Paths.get(path);
+ }
+
+ @Override
+ public String getToken() {
+ if (!Files.exists(tokenFile)) {
+ throw new IllegalStateException("WebIdentity token file does not exist:
" + tokenFile);
+ }
+ try {
+ String token = new String(Files.readAllBytes(tokenFile),
StandardCharsets.UTF_8).trim();
+ if (StringUtils.isBlank(token)) {
+ throw new IllegalStateException("WebIdentity token file is empty: " +
tokenFile);
+ }
+ return token;
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to read WebIdentity token file:
" + tokenFile, e);
+ }
+ }
+
+ @Override
+ public void close() {}
+}
diff --git
a/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/webidentity/WebIdentityTokenSource.java
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/webidentity/WebIdentityTokenSource.java
new file mode 100644
index 0000000000..95e730a132
--- /dev/null
+++
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/webidentity/WebIdentityTokenSource.java
@@ -0,0 +1,52 @@
+/*
+ * 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.gravitino.s3.credential.webidentity;
+
+import java.io.Closeable;
+import java.util.Map;
+
+/**
+ * SPI for obtaining a WebIdentity token (an OIDC id_token or access_token)
that can be exchanged
+ * with AWS STS {@code AssumeRoleWithWebIdentity} for temporary S3 credentials.
+ *
+ * <p>Implementations decide how the token is sourced (local file, OAuth flow,
etc.) and are
+ * responsible for any caching and refresh behavior.
+ *
+ * <p>This SPI is scoped to AWS S3 credential vending (IRSA / {@code
AssumeRoleWithWebIdentity}); it
+ * is not a generic cross-storage abstraction. Other storage backends (e.g.
GCS, Azure) use
+ * different workload-identity mechanisms and are out of scope here.
+ */
+public interface WebIdentityTokenSource extends Closeable {
+
+ /**
+ * The configuration name used to select this source via {@code
s3-web-identity-token-source}.
+ *
+ * <p>Must be unique across all implementations on the classpath.
+ */
+ String name();
+
+ /** Initialize the source with the credential provider's properties. */
+ void initialize(Map<String, String> properties);
+
+ /**
+ * Returns a current WebIdentity token. Implementations may cache and
refresh internally; callers
+ * should invoke this for every credential request.
+ */
+ String getToken();
+}
diff --git
a/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/webidentity/WebIdentityTokenSourceConfig.java
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/webidentity/WebIdentityTokenSourceConfig.java
new file mode 100644
index 0000000000..1174093f49
--- /dev/null
+++
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/webidentity/WebIdentityTokenSourceConfig.java
@@ -0,0 +1,41 @@
+/*
+ * 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.gravitino.s3.credential.webidentity;
+
+/**
+ * Property keys shared by {@link WebIdentityTokenSource} implementations.
+ *
+ * <p>These keys are AWS S3-specific (prefixed {@code s3-}); the WebIdentity
token source is scoped
+ * to AWS S3 credential vending and is not a generic cross-storage abstraction.
+ */
+public final class WebIdentityTokenSourceConfig {
+
+ /** Selects which {@link WebIdentityTokenSource} implementation to use. */
+ public static final String SOURCE = "s3-web-identity-token-source";
+
+ /**
+ * Path to a file containing the WebIdentity token. Used by {@link
FileWebIdentityTokenSource}.
+ */
+ public static final String FILE_PATH = "s3-web-identity-token-file";
+
+ /** Default source name used when {@link #SOURCE} is not set. */
+ public static final String DEFAULT_SOURCE = FileWebIdentityTokenSource.NAME;
+
+ private WebIdentityTokenSourceConfig() {}
+}
diff --git
a/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/webidentity/WebIdentityTokenSources.java
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/webidentity/WebIdentityTokenSources.java
new file mode 100644
index 0000000000..80f77a0490
--- /dev/null
+++
b/bundles/aws/src/main/java/org/apache/gravitino/s3/credential/webidentity/WebIdentityTokenSources.java
@@ -0,0 +1,108 @@
+/*
+ * 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.gravitino.s3.credential.webidentity;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.ServiceLoader;
+import org.apache.commons.lang3.StringUtils;
+
+/** Factory for resolving and initializing a {@link WebIdentityTokenSource}
from properties. */
+public final class WebIdentityTokenSources {
+
+ /**
+ * Resolve the {@link WebIdentityTokenSource} configured by {@code
s3-web-identity-token-source}
+ * (defaults to {@code file}) and initialize it with the given properties.
+ *
+ * <p>Sources are discovered through {@link ServiceLoader}, so additional
implementations can be
+ * registered by listing them under {@code
+ *
META-INF/services/org.apache.gravitino.s3.credential.webidentity.WebIdentityTokenSource}.
+ */
+ public static WebIdentityTokenSource create(Map<String, String> properties) {
+ return create(properties,
ServiceLoader.load(WebIdentityTokenSource.class));
+ }
+
+ /**
+ * Package-private overload that takes the candidate sources directly,
enabling tests to exercise
+ * the resolution logic (in particular duplicate-name detection) without
touching the {@link
+ * ServiceLoader} registry.
+ */
+ static WebIdentityTokenSource create(
+ Map<String, String> properties, Iterable<WebIdentityTokenSource>
candidates) {
+ String type =
+ properties.getOrDefault(
+ WebIdentityTokenSourceConfig.SOURCE,
WebIdentityTokenSourceConfig.DEFAULT_SOURCE);
+
+ List<String> available = new ArrayList<>();
+ List<WebIdentityTokenSource> matched = new ArrayList<>();
+ Map<String, WebIdentityTokenSource> sourcesByName = new HashMap<>();
+ for (WebIdentityTokenSource candidate : candidates) {
+ String candidateName = candidate.name();
+ if (StringUtils.isBlank(candidateName)) {
+ throw new IllegalStateException(
+ "WebIdentity token source "
+ + candidate.getClass().getName()
+ + " must return a non-blank name().");
+ }
+ available.add(candidateName);
+ String normalizedName = candidateName.toLowerCase(Locale.ROOT);
+ WebIdentityTokenSource existing =
sourcesByName.putIfAbsent(normalizedName, candidate);
+ if (existing != null) {
+ throw new IllegalStateException(
+ "Multiple WebIdentity token sources registered with name '"
+ + existing.name()
+ + "': "
+ + existing.getClass().getName()
+ + ", "
+ + candidate.getClass().getName()
+ + ". Each implementation must return a unique name().");
+ }
+ if (type.equalsIgnoreCase(candidateName)) {
+ matched.add(candidate);
+ }
+ }
+ if (matched.isEmpty()) {
+ throw new IllegalArgumentException(
+ "Unknown WebIdentity token source: "
+ + type
+ + ". Available sources: "
+ + String.join(", ", available));
+ }
+ if (matched.size() > 1) {
+ List<String> classes = new ArrayList<>();
+ for (WebIdentityTokenSource source : matched) {
+ classes.add(source.getClass().getName());
+ }
+ throw new IllegalStateException(
+ "Multiple WebIdentity token sources registered with name '"
+ + type
+ + "': "
+ + String.join(", ", classes)
+ + ". Each implementation must return a unique name().");
+ }
+ WebIdentityTokenSource source = matched.get(0);
+ source.initialize(properties);
+ return source;
+ }
+
+ private WebIdentityTokenSources() {}
+}
diff --git
a/bundles/aws/src/main/resources/META-INF/services/org.apache.gravitino.s3.credential.webidentity.WebIdentityTokenSource
b/bundles/aws/src/main/resources/META-INF/services/org.apache.gravitino.s3.credential.webidentity.WebIdentityTokenSource
new file mode 100644
index 0000000000..7d96b37fe9
--- /dev/null
+++
b/bundles/aws/src/main/resources/META-INF/services/org.apache.gravitino.s3.credential.webidentity.WebIdentityTokenSource
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+org.apache.gravitino.s3.credential.webidentity.FileWebIdentityTokenSource
diff --git
a/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/TestAwsIrsaCredentialGenerator.java
b/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/TestAwsIrsaCredentialGenerator.java
new file mode 100644
index 0000000000..dbe75f2d42
--- /dev/null
+++
b/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/TestAwsIrsaCredentialGenerator.java
@@ -0,0 +1,129 @@
+/*
+ * 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.gravitino.s3.credential;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.gravitino.credential.AwsIrsaCredential;
+import
org.apache.gravitino.s3.credential.webidentity.WebIdentityTokenSourceConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class TestAwsIrsaCredentialGenerator {
+
+ private HttpServer server;
+ private AtomicReference<String> requestBody;
+ private AtomicInteger requestCount;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ requestBody = new AtomicReference<>();
+ requestCount = new AtomicInteger();
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", this::handleAssumeRoleWithWebIdentity);
+ server.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ server.stop(0);
+ }
+
+ @Test
+ void basicModeUsesConfiguredTokenSourceWithoutSessionPolicy(@TempDir Path
dir)
+ throws IOException {
+ try (AwsIrsaCredentialGenerator generator = new
AwsIrsaCredentialGenerator()) {
+ generator.initialize(createProperties(dir));
+
+ AwsIrsaCredential credential = generator.generate(() -> "test-user");
+
+ assertEquals("access-key", credential.accessKeyId());
+ assertEquals("secret-key", credential.secretAccessKey());
+ assertEquals("session-token", credential.sessionToken());
+ }
+
+ String body = URLDecoder.decode(requestBody.get(),
StandardCharsets.UTF_8.name());
+ assertTrue(body.contains("WebIdentityToken=configured-token"));
+
assertTrue(body.contains("RoleSessionName=gravitino_irsa_session_test-user"));
+ assertFalse(body.contains("Policy="));
+ }
+
+ @Test
+ void basicModeReusesCachedStsCredentials(@TempDir Path dir) throws
IOException {
+ try (AwsIrsaCredentialGenerator generator = new
AwsIrsaCredentialGenerator()) {
+ generator.initialize(createProperties(dir));
+
+ generator.generate(() -> "test-user");
+ generator.generate(() -> "test-user");
+ }
+
+ assertEquals(1, requestCount.get());
+ }
+
+ private Map<String, String> createProperties(Path dir) throws IOException {
+ Path tokenFile = dir.resolve("token");
+ Files.write(tokenFile,
"configured-token".getBytes(StandardCharsets.UTF_8));
+
+ Map<String, String> properties = new HashMap<>();
+ properties.put(WebIdentityTokenSourceConfig.FILE_PATH,
tokenFile.toString());
+ properties.put("s3-role-arn", "arn:aws:iam::123456789012:role/test-role");
+ properties.put("s3-region", "us-east-1");
+ properties.put(
+ "s3-token-service-endpoint",
+ String.format("http://127.0.0.1:%s", server.getAddress().getPort()));
+ return properties;
+ }
+
+ private void handleAssumeRoleWithWebIdentity(HttpExchange exchange) throws
IOException {
+ requestCount.incrementAndGet();
+ requestBody.set(new String(exchange.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8));
+ byte[] response =
+ ("<AssumeRoleWithWebIdentityResponse
xmlns=\"https://sts.amazonaws.com/doc/2011-06-15/\">"
+ + "<AssumeRoleWithWebIdentityResult><Credentials>"
+ + "<AccessKeyId>access-key</AccessKeyId>"
+ + "<SecretAccessKey>secret-key</SecretAccessKey>"
+ + "<SessionToken>session-token</SessionToken>"
+ + "<Expiration>"
+ + Instant.now().plusSeconds(3600)
+ + "</Expiration>"
+ + "</Credentials></AssumeRoleWithWebIdentityResult>"
+ + "</AssumeRoleWithWebIdentityResponse>")
+ .getBytes(StandardCharsets.UTF_8);
+ exchange.sendResponseHeaders(200, response.length);
+ exchange.getResponseBody().write(response);
+ exchange.close();
+ }
+}
diff --git
a/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/webidentity/TestFileWebIdentityTokenSource.java
b/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/webidentity/TestFileWebIdentityTokenSource.java
new file mode 100644
index 0000000000..7d8ac9bcf3
--- /dev/null
+++
b/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/webidentity/TestFileWebIdentityTokenSource.java
@@ -0,0 +1,128 @@
+/*
+ * 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.gravitino.s3.credential.webidentity;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class TestFileWebIdentityTokenSource {
+
+ @Test
+ void nameIsFile() {
+ assertEquals("file", new FileWebIdentityTokenSource().name());
+ }
+
+ @Test
+ void readsTokenFromConfiguredPath(@TempDir Path dir) throws IOException {
+ Path tokenFile = dir.resolve("token");
+ Files.write(tokenFile, "abc123".getBytes(StandardCharsets.UTF_8));
+
+ FileWebIdentityTokenSource source = new FileWebIdentityTokenSource();
+ source.initialize(
+ Collections.singletonMap(WebIdentityTokenSourceConfig.FILE_PATH,
tokenFile.toString()));
+
+ assertEquals("abc123", source.getToken());
+ }
+
+ @Test
+ void trimsTrailingWhitespace(@TempDir Path dir) throws IOException {
+ Path tokenFile = dir.resolve("token");
+ Files.write(tokenFile, "abc123\n".getBytes(StandardCharsets.UTF_8));
+
+ FileWebIdentityTokenSource source = new FileWebIdentityTokenSource();
+ source.initialize(
+ Collections.singletonMap(WebIdentityTokenSourceConfig.FILE_PATH,
tokenFile.toString()));
+
+ assertEquals("abc123", source.getToken());
+ }
+
+ @Test
+ void rereadsOnEachCallSoRotatedTokensArePickedUp(@TempDir Path dir) throws
IOException {
+ Path tokenFile = dir.resolve("token");
+ Files.write(tokenFile, "first".getBytes(StandardCharsets.UTF_8));
+
+ FileWebIdentityTokenSource source = new FileWebIdentityTokenSource();
+ source.initialize(
+ Collections.singletonMap(WebIdentityTokenSourceConfig.FILE_PATH,
tokenFile.toString()));
+ assertEquals("first", source.getToken());
+
+ Files.write(tokenFile, "second".getBytes(StandardCharsets.UTF_8));
+ assertEquals("second", source.getToken());
+ }
+
+ @Test
+ void initializeFailsWhenNeitherPropertyNorEnvIsSet() {
+ assumeTrue(
+ StringUtils.isBlank(
+
System.getenv(FileWebIdentityTokenSource.AWS_WEB_IDENTITY_TOKEN_FILE_ENV)));
+
+ FileWebIdentityTokenSource source = new FileWebIdentityTokenSource();
+
+ IllegalStateException error =
+ assertThrows(IllegalStateException.class, () ->
source.initialize(Collections.emptyMap()));
+
assertTrue(error.getMessage().contains(WebIdentityTokenSourceConfig.FILE_PATH));
+ }
+
+ @Test
+ void getTokenFailsWhenFileMissing(@TempDir Path dir) {
+ Path tokenFile = dir.resolve("missing-token");
+
+ FileWebIdentityTokenSource source = new FileWebIdentityTokenSource();
+ source.initialize(
+ Collections.singletonMap(WebIdentityTokenSourceConfig.FILE_PATH,
tokenFile.toString()));
+
+ IllegalStateException error = assertThrows(IllegalStateException.class,
source::getToken);
+ assertTrue(error.getMessage().contains("does not exist"));
+ }
+
+ @Test
+ void getTokenFailsWhenFileIsEmpty(@TempDir Path dir) throws IOException {
+ Path tokenFile = dir.resolve("empty-token");
+ Files.write(tokenFile, new byte[0]);
+
+ FileWebIdentityTokenSource source = new FileWebIdentityTokenSource();
+ source.initialize(
+ Collections.singletonMap(WebIdentityTokenSourceConfig.FILE_PATH,
tokenFile.toString()));
+
+ IllegalStateException error = assertThrows(IllegalStateException.class,
source::getToken);
+ assertTrue(error.getMessage().contains("empty"));
+ }
+
+ @Test
+ void getTokenWrapsReadFailureAsIllegalStateException(@TempDir Path dir) {
+ FileWebIdentityTokenSource source = new FileWebIdentityTokenSource();
+ source.initialize(
+ Collections.singletonMap(WebIdentityTokenSourceConfig.FILE_PATH,
dir.toString()));
+
+ IllegalStateException error = assertThrows(IllegalStateException.class,
source::getToken);
+ assertTrue(error.getMessage().contains("Failed to read WebIdentity token
file"));
+ assertTrue(error.getCause() instanceof IOException);
+ }
+}
diff --git
a/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/webidentity/TestWebIdentityTokenSources.java
b/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/webidentity/TestWebIdentityTokenSources.java
new file mode 100644
index 0000000000..56b960894a
--- /dev/null
+++
b/bundles/aws/src/test/java/org/apache/gravitino/s3/credential/webidentity/TestWebIdentityTokenSources.java
@@ -0,0 +1,187 @@
+/*
+ * 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.gravitino.s3.credential.webidentity;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class TestWebIdentityTokenSources {
+
+ @Test
+ void defaultsToFileSource(@TempDir Path dir) throws IOException {
+ Path tokenFile = dir.resolve("token");
+ Files.write(tokenFile, "tok".getBytes(StandardCharsets.UTF_8));
+
+ Map<String, String> props = new HashMap<>();
+ props.put(WebIdentityTokenSourceConfig.FILE_PATH, tokenFile.toString());
+
+ WebIdentityTokenSource source = WebIdentityTokenSources.create(props);
+ assertInstanceOf(FileWebIdentityTokenSource.class, source);
+ assertEquals("tok", source.getToken());
+ }
+
+ @Test
+ void selectsSourceByName(@TempDir Path dir) throws IOException {
+ Path tokenFile = dir.resolve("token");
+ Files.write(tokenFile, "tok".getBytes(StandardCharsets.UTF_8));
+
+ Map<String, String> props = new HashMap<>();
+ props.put(WebIdentityTokenSourceConfig.SOURCE, "file");
+ props.put(WebIdentityTokenSourceConfig.FILE_PATH, tokenFile.toString());
+
+ WebIdentityTokenSource source = WebIdentityTokenSources.create(props);
+ assertInstanceOf(FileWebIdentityTokenSource.class, source);
+ }
+
+ @Test
+ void selectsSourceByNameIgnoringCase(@TempDir Path dir) throws IOException {
+ Path tokenFile = dir.resolve("token");
+ Files.write(tokenFile, "tok".getBytes(StandardCharsets.UTF_8));
+
+ Map<String, String> props = new HashMap<>();
+ props.put(WebIdentityTokenSourceConfig.SOURCE, "FILE");
+ props.put(WebIdentityTokenSourceConfig.FILE_PATH, tokenFile.toString());
+
+ WebIdentityTokenSource source = WebIdentityTokenSources.create(props);
+ assertInstanceOf(FileWebIdentityTokenSource.class, source);
+ }
+
+ @Test
+ void throwsForUnknownSource() {
+ Map<String, String> props = new HashMap<>();
+ props.put(WebIdentityTokenSourceConfig.SOURCE, "no-such-source");
+
+ IllegalArgumentException error =
+ assertThrows(IllegalArgumentException.class, () ->
WebIdentityTokenSources.create(props));
+ assertTrue(error.getMessage().contains("no-such-source"));
+ }
+
+ @Test
+ void blankSourcePropertyIsRejected(@TempDir Path dir) throws IOException {
+ Path tokenFile = dir.resolve("token");
+ Files.write(tokenFile, "tok".getBytes(StandardCharsets.UTF_8));
+
+ // The default applies only when the key is absent; an explicitly blank
value
+ // is treated as an unknown source rather than silently falling back.
+ Map<String, String> props = new HashMap<>();
+ props.put(WebIdentityTokenSourceConfig.SOURCE, "");
+ props.put(WebIdentityTokenSourceConfig.FILE_PATH, tokenFile.toString());
+
+ assertThrows(IllegalArgumentException.class, () ->
WebIdentityTokenSources.create(props));
+ }
+
+ @Test
+ void throwsWhenMultipleSourcesShareTheSameName() {
+ Map<String, String> props = new HashMap<>();
+ props.put(WebIdentityTokenSourceConfig.SOURCE, "dup");
+
+ WebIdentityTokenSource first = new NamedNoopSource("dup");
+ WebIdentityTokenSource second = new NamedNoopSource("dup");
+
+ IllegalStateException error =
+ assertThrows(
+ IllegalStateException.class,
+ () -> WebIdentityTokenSources.create(props, Arrays.asList(first,
second)));
+ assertTrue(error.getMessage().contains("Multiple WebIdentity token
sources"));
+ assertTrue(error.getMessage().contains("dup"));
+ assertTrue(error.getMessage().contains(NamedNoopSource.class.getName()));
+ }
+
+ @Test
+ void throwsWhenMultipleSourcesShareTheSameNameIgnoringCase() {
+ Map<String, String> props = new HashMap<>();
+ props.put(WebIdentityTokenSourceConfig.SOURCE, "dup");
+
+ WebIdentityTokenSource first = new NamedNoopSource("dup");
+ WebIdentityTokenSource second = new NamedNoopSource("DUP");
+
+ IllegalStateException error =
+ assertThrows(
+ IllegalStateException.class,
+ () -> WebIdentityTokenSources.create(props, Arrays.asList(first,
second)));
+ assertTrue(error.getMessage().contains("Multiple WebIdentity token
sources"));
+ assertTrue(error.getMessage().contains("dup"));
+ }
+
+ @Test
+ void throwsWhenUnselectedSourcesShareTheSameName() {
+ Map<String, String> props = new HashMap<>();
+ props.put(WebIdentityTokenSourceConfig.SOURCE, "selected");
+
+ WebIdentityTokenSource selected = new NamedNoopSource("selected");
+ WebIdentityTokenSource firstDuplicate = new NamedNoopSource("dup");
+ WebIdentityTokenSource secondDuplicate = new NamedNoopSource("dup");
+
+ IllegalStateException error =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ WebIdentityTokenSources.create(
+ props, Arrays.asList(selected, firstDuplicate,
secondDuplicate)));
+ assertTrue(error.getMessage().contains("Multiple WebIdentity token
sources"));
+ assertTrue(error.getMessage().contains("dup"));
+ }
+
+ @Test
+ void throwsWhenSourceNameIsBlank() {
+ IllegalStateException error =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ WebIdentityTokenSources.create(
+ new HashMap<>(), Arrays.asList(new
NamedNoopSource(null))));
+ assertTrue(error.getMessage().contains("must return a non-blank name"));
+ }
+
+ private static final class NamedNoopSource implements WebIdentityTokenSource
{
+ private final String name;
+
+ NamedNoopSource(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String name() {
+ return name;
+ }
+
+ @Override
+ public void initialize(Map<String, String> properties) {}
+
+ @Override
+ public String getToken() {
+ return "noop";
+ }
+
+ @Override
+ public void close() {}
+ }
+}