diff --git a/xds/BUILD.bazel b/xds/BUILD.bazel index 66c790a654d..830fa02cbb3 100644 --- a/xds/BUILD.bazel +++ b/xds/BUILD.bazel @@ -35,6 +35,8 @@ java_library( "@com_google_protobuf//:protobuf_java", "@com_google_protobuf//:protobuf_java_util", "@maven//:com_google_auth_google_auth_library_oauth2_http", + "@maven//:com_google_http_client_google_http_client", + "@maven//:com_google_http_client_google_http_client_gson", artifact("com.google.code.findbugs:jsr305"), artifact("com.google.code.gson:gson"), artifact("com.google.errorprone:error_prone_annotations"), diff --git a/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java b/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java index f61fab42cae..1228c22234c 100644 --- a/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java +++ b/xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java @@ -18,7 +18,11 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableMap; +import io.grpc.CallCredentials; import io.grpc.ChannelCredentials; +import io.grpc.CompositeCallCredentials; +import io.grpc.CompositeChannelCredentials; +import io.grpc.internal.GrpcUtil; import io.grpc.internal.JsonUtil; import io.grpc.xds.client.BootstrapperImpl; import io.grpc.xds.client.XdsInitializationException; @@ -33,6 +37,8 @@ class GrpcBootstrapperImpl extends BootstrapperImpl { private static final String BOOTSTRAP_PATH_SYS_PROPERTY = "io.grpc.xds.bootstrap"; private static final String BOOTSTRAP_CONFIG_SYS_ENV_VAR = "GRPC_XDS_BOOTSTRAP_CONFIG"; private static final String BOOTSTRAP_CONFIG_SYS_PROPERTY = "io.grpc.xds.bootstrapConfig"; + private static final String GRPC_EXPERIMENTAL_XDS_BOOTSTRAP_CALL_CREDS = + "GRPC_EXPERIMENTAL_XDS_BOOTSTRAP_CALL_CREDS"; @VisibleForTesting String bootstrapPathFromEnvVar = System.getenv(BOOTSTRAP_PATH_SYS_ENV_VAR); @VisibleForTesting @@ -41,6 +47,9 @@ class GrpcBootstrapperImpl extends BootstrapperImpl { String bootstrapConfigFromEnvVar = System.getenv(BOOTSTRAP_CONFIG_SYS_ENV_VAR); @VisibleForTesting String bootstrapConfigFromSysProp = System.getProperty(BOOTSTRAP_CONFIG_SYS_PROPERTY); + @VisibleForTesting + static boolean xdsBootstrapCallCredsEnabled = GrpcUtil.getFlag( + GRPC_EXPERIMENTAL_XDS_BOOTSTRAP_CALL_CREDS, false); GrpcBootstrapperImpl() { super(); @@ -92,7 +101,12 @@ protected String getJsonContent() throws XdsInitializationException, IOException @Override protected Object getImplSpecificConfig(Map serverConfig, String serverUri) throws XdsInitializationException { - return getChannelCredentials(serverConfig, serverUri); + ChannelCredentials channelCreds = getChannelCredentials(serverConfig, serverUri); + CallCredentials callCreds = getCallCredentials(serverConfig, serverUri); + if (callCreds != null) { + channelCreds = CompositeChannelCredentials.create(channelCreds, callCreds); + } + return channelCreds; } private static ChannelCredentials getChannelCredentials(Map serverConfig, @@ -135,4 +149,55 @@ private static ChannelCredentials parseChannelCredentials(List> j } return null; } + + private static CallCredentials getCallCredentials(Map serverConfig, + String serverUri) + throws XdsInitializationException { + List rawCallCredsList = JsonUtil.getList(serverConfig, "call_creds"); + if (rawCallCredsList == null || rawCallCredsList.isEmpty()) { + return null; + } + CallCredentials callCredentials = + parseCallCredentials(JsonUtil.checkObjectList(rawCallCredsList), serverUri); + return callCredentials; + } + + @Nullable + private static CallCredentials parseCallCredentials(List> jsonList, + String serverUri) + throws XdsInitializationException { + if (!xdsBootstrapCallCredsEnabled) { + return null; + } + + CallCredentials callCredentials = null; + for (Map callCreds : jsonList) { + String type = JsonUtil.getString(callCreds, "type"); + if (type == null) { + continue; + } + + XdsCallCredentialsProvider provider = XdsCallCredentialsRegistry.getDefaultRegistry() + .getProvider(type); + if (provider == null) { + continue; + } + + Map config = JsonUtil.getObject(callCreds, "config"); + if (config == null) { + config = ImmutableMap.of(); + } + CallCredentials parsedCallCredentials = provider.newCallCredentials(config); + if (parsedCallCredentials == null) { + throw new XdsInitializationException( + "Invalid bootstrap: server " + serverUri + " with invalid 'config' for " + type + + " 'call_creds'"); + } + + callCredentials = (callCredentials == null) + ? parsedCallCredentials + : new CompositeCallCredentials(callCredentials, parsedCallCredentials); + } + return callCredentials; + } } diff --git a/xds/src/main/java/io/grpc/xds/XdsCallCredentialsProvider.java b/xds/src/main/java/io/grpc/xds/XdsCallCredentialsProvider.java new file mode 100644 index 00000000000..21dc348d260 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/XdsCallCredentialsProvider.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds; + +import io.grpc.CallCredentials; +import io.grpc.Internal; +import java.util.Map; + +/** + * Provider of credentials data which will be propagated to the server for each RPC. The actual call + * credential to be used for a particular xDS communication will be chosen based on the bootstrap + * configuration. + */ +@Internal +public abstract class XdsCallCredentialsProvider { + /** + * Creates a {@link CallCredentials} from the given jsonConfig, or + * {@code null} if the given config is invalid. The provider is free to ignore + * the config if it's not needed for producing the call credentials. + * + * @param jsonConfig json config that can be consumed by the provider to create + * the call credentials + * + */ + protected abstract CallCredentials newCallCredentials(Map jsonConfig); + + /** + * Returns the xDS call credential name associated with this provider which makes it selectable + * via {@link XdsCallCredentialsRegistry#getProvider}. This is called only when the class is + * loaded. It shouldn't change, and there is no point doing so. + */ + protected abstract String getName(); +} diff --git a/xds/src/main/java/io/grpc/xds/XdsCallCredentialsRegistry.java b/xds/src/main/java/io/grpc/xds/XdsCallCredentialsRegistry.java new file mode 100644 index 00000000000..a5e461622c3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/XdsCallCredentialsRegistry.java @@ -0,0 +1,76 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.annotations.VisibleForTesting; +import io.grpc.xds.internal.JwtTokenFileXdsCallCredentialsProvider; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; +import javax.annotation.concurrent.ThreadSafe; + +/** + * Registry of {@link XdsCallCredentialsProvider}s. The {@link #getDefaultRegistry default + * instance} loads hardcoded providers at runtime. + */ +@ThreadSafe +final class XdsCallCredentialsRegistry { + private static XdsCallCredentialsRegistry instance; + + private final Map registeredProviders = + new HashMap<>(); + + /** + * Returns the default registry that loads hardcoded providers at runtime. + */ + public static synchronized XdsCallCredentialsRegistry getDefaultRegistry() { + if (instance == null) { + instance = newRegistry().register(new JwtTokenFileXdsCallCredentialsProvider()); + } + return instance; + } + + @VisibleForTesting + static XdsCallCredentialsRegistry newRegistry() { + return new XdsCallCredentialsRegistry(); + } + + @VisibleForTesting + XdsCallCredentialsRegistry register(XdsCallCredentialsProvider... providers) { + for (XdsCallCredentialsProvider provider : providers) { + registeredProviders.put(provider.getName(), provider); + } + return this; + } + + @VisibleForTesting + synchronized Map providers() { + return registeredProviders; + } + + /** + * Returns the registered provider for the given xDS call credential name, or {@code null} if no + * suitable provider can be found. + * Each provider declares its name via {@link XdsCallCredentialsProvider#getName}. + */ + @Nullable + public synchronized XdsCallCredentialsProvider getProvider(String name) { + return registeredProviders.get(checkNotNull(name, "name")); + } +} diff --git a/xds/src/main/java/io/grpc/xds/XdsCredentialsRegistry.java b/xds/src/main/java/io/grpc/xds/XdsCredentialsRegistry.java index 9dfefaf1a65..43e3e94267e 100644 --- a/xds/src/main/java/io/grpc/xds/XdsCredentialsRegistry.java +++ b/xds/src/main/java/io/grpc/xds/XdsCredentialsRegistry.java @@ -20,16 +20,14 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.errorprone.annotations.concurrent.GuardedBy; import io.grpc.InternalServiceProviders; -import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.logging.Level; import java.util.logging.Logger; import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; @@ -109,7 +107,7 @@ public static synchronized XdsCredentialsRegistry getDefaultRegistry() { if (instance == null) { List providerList = InternalServiceProviders.loadAll( XdsCredentialsProvider.class, - getHardCodedClasses(), + ImmutableList.of(), XdsCredentialsProvider.class.getClassLoader(), new XdsCredentialsProviderPriorityAccessor()); if (providerList.isEmpty()) { @@ -147,33 +145,6 @@ public synchronized XdsCredentialsProvider getProvider(String name) { return effectiveProviders.get(checkNotNull(name, "name")); } - @VisibleForTesting - static List> getHardCodedClasses() { - // Class.forName(String) is used to remove the need for ProGuard configuration. Note that - // ProGuard does not detect usages of Class.forName(String, boolean, ClassLoader): - // https://sourceforge.net/p/proguard/bugs/418/ - ArrayList> list = new ArrayList<>(); - try { - list.add(Class.forName("io.grpc.xds.internal.GoogleDefaultXdsCredentialsProvider")); - } catch (ClassNotFoundException e) { - logger.log(Level.WARNING, "Unable to find GoogleDefaultXdsCredentialsProvider", e); - } - - try { - list.add(Class.forName("io.grpc.xds.internal.InsecureXdsCredentialsProvider")); - } catch (ClassNotFoundException e) { - logger.log(Level.WARNING, "Unable to find InsecureXdsCredentialsProvider", e); - } - - try { - list.add(Class.forName("io.grpc.xds.internal.TlsXdsCredentialsProvider")); - } catch (ClassNotFoundException e) { - logger.log(Level.WARNING, "Unable to find TlsXdsCredentialsProvider", e); - } - - return Collections.unmodifiableList(list); - } - private static final class XdsCredentialsProviderPriorityAccessor implements InternalServiceProviders.PriorityAccessor { @Override diff --git a/xds/src/main/java/io/grpc/xds/internal/JwtTokenFileCallCredentials.java b/xds/src/main/java/io/grpc/xds/internal/JwtTokenFileCallCredentials.java new file mode 100644 index 00000000000..64b233204e1 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/JwtTokenFileCallCredentials.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.OAuth2Credentials; +import com.google.common.io.Files; +import io.grpc.CallCredentials; +import io.grpc.auth.MoreCallCredentials; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +/** + * JWT token file call credentials. + * See gRFC A97 (https://github.com/grpc/proposal/pull/492). + */ +public final class JwtTokenFileCallCredentials extends OAuth2Credentials { + private static final long serialVersionUID = 0L; + private final String path; + + private JwtTokenFileCallCredentials(String path) { + this.path = checkNotNull(path, "path"); + } + + @Override + public AccessToken refreshAccessToken() throws IOException { + String tokenString = new String(Files.toByteArray(new File(path)), StandardCharsets.UTF_8); + Long expTime = JsonWebSignature.parse(new GsonFactory(), tokenString) + .getPayload() + .getExpirationTimeSeconds(); + if (expTime == null) { + throw new IOException("No expiration time found for JWT token"); + } + + return AccessToken.newBuilder() + .setTokenValue(tokenString) + .setExpirationTime(new Date(expTime * 1000L)) + .build(); + } + + // using {@link MoreCallCredentials} adapter to be compatible with {@link CallCredentials} iface + public static CallCredentials create(String path) { + JwtTokenFileCallCredentials jwtTokenFileCallCredentials = new JwtTokenFileCallCredentials(path); + return MoreCallCredentials.from(jwtTokenFileCallCredentials); + } +} diff --git a/xds/src/main/java/io/grpc/xds/internal/JwtTokenFileXdsCallCredentialsProvider.java b/xds/src/main/java/io/grpc/xds/internal/JwtTokenFileXdsCallCredentialsProvider.java new file mode 100644 index 00000000000..cafe0411a73 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/JwtTokenFileXdsCallCredentialsProvider.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal; + +import io.grpc.CallCredentials; +import io.grpc.internal.JsonUtil; +import io.grpc.xds.XdsCallCredentialsProvider; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * A wrapper class that supports {@link JwtTokenFileXdsCallCredentialsProvider} for + * xDS by implementing {@link XdsCredentialsProvider}. + */ +public final class JwtTokenFileXdsCallCredentialsProvider extends XdsCallCredentialsProvider { + private static final Logger logger = Logger.getLogger( + JwtTokenFileXdsCallCredentialsProvider.class.getName()); + private static final String CREDS_NAME = "jwt_token_file"; + + @Override + protected CallCredentials newCallCredentials(Map jsonConfig) { + if (jsonConfig == null) { + return null; + } + + String jwtTokenPath = JsonUtil.getString(jsonConfig, getName()); + if (jwtTokenPath == null) { + logger.log(Level.WARNING, "jwt_token_file credential requires jwt_token_file in the config"); + return null; + } + + return JwtTokenFileCallCredentials.create(jwtTokenPath); + } + + @Override + protected String getName() { + return CREDS_NAME; + } +} diff --git a/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java b/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java index 3f93cc6f191..7f56ecda461 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java +++ b/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java @@ -17,6 +17,7 @@ package io.grpc.xds; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; @@ -24,6 +25,9 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; +import io.grpc.CallCredentials; +import io.grpc.CompositeCallCredentials; +import io.grpc.CompositeChannelCredentials; import io.grpc.InsecureChannelCredentials; import io.grpc.TlsChannelCredentials; import io.grpc.internal.GrpcUtil; @@ -60,11 +64,13 @@ public class GrpcBootstrapperImplTest { private String originalBootstrapConfigFromEnvVar; private String originalBootstrapConfigFromSysProp; private boolean originalExperimentalXdsFallbackFlag; + private boolean originalExperimentalXdsBootstrapCallCredsFlag; @Before public void setUp() { saveEnvironment(); originalExperimentalXdsFallbackFlag = CommonBootstrapperTestUtils.setEnableXdsFallback(true); + GrpcBootstrapperImpl.xdsBootstrapCallCredsEnabled = true; bootstrapper.bootstrapPathFromEnvVar = BOOTSTRAP_FILE_PATH; } @@ -73,6 +79,8 @@ private void saveEnvironment() { originalBootstrapPathFromSysProp = bootstrapper.bootstrapPathFromSysProp; originalBootstrapConfigFromEnvVar = bootstrapper.bootstrapConfigFromEnvVar; originalBootstrapConfigFromSysProp = bootstrapper.bootstrapConfigFromSysProp; + originalExperimentalXdsBootstrapCallCredsFlag = + GrpcBootstrapperImpl.xdsBootstrapCallCredsEnabled; } @After @@ -82,6 +90,8 @@ public void restoreEnvironment() { bootstrapper.bootstrapConfigFromEnvVar = originalBootstrapConfigFromEnvVar; bootstrapper.bootstrapConfigFromSysProp = originalBootstrapConfigFromSysProp; CommonBootstrapperTestUtils.setEnableXdsFallback(originalExperimentalXdsFallbackFlag); + GrpcBootstrapperImpl.xdsBootstrapCallCredsEnabled = + originalExperimentalXdsBootstrapCallCredsFlag; } @Test @@ -898,6 +908,95 @@ public void badFederationConfig() { } } + @Test + public void parseNotSupportedCallCredentials() throws Exception { + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ],\n" + + " \"call_creds\": [\n" + + " {\"type\": \"unknown\"}\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + BootstrapInfo info = bootstrapper.bootstrap(); + assertThat(info.servers()).hasSize(1); + ServerInfo serverInfo = Iterables.getOnlyElement(info.servers()); + assertThat(serverInfo.implSpecificConfig()).isInstanceOf(InsecureChannelCredentials.class); + } + + @Test + public void parseSupportedCallCredentialsWithInvalidConfig() throws Exception { + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ],\n" + + " \"call_creds\": [\n" + + " {\n" + + " \"type\": \"jwt_token_file\",\n" + + " \"config\": {}\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + Exception ex = assertThrows(XdsInitializationException.class, () -> { + bootstrapper.bootstrap(); + }); + + String expectedMsg = "Invalid bootstrap: server " + + SERVER_URI + " with invalid 'config' for jwt_token_file 'call_creds'"; + String actualMsg = ex.getMessage(); + + assertEquals(expectedMsg, actualMsg); + } + + @Test + public void parseTwoSupportedCallCredentialsWithValidConfig() throws Exception { + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ],\n" + + " \"call_creds\": [\n" + + " {\n" + + " \"type\": \"jwt_token_file\",\n" + + " \"config\": {\n" + + " \"jwt_token_file\": \"/first/path/to/jwt.token\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"type\": \"jwt_token_file\",\n" + + " \"config\": {\n" + + " \"jwt_token_file\": \"/second/path/to/jwt.token\"\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + BootstrapInfo info = bootstrapper.bootstrap(); + assertThat(info.servers()).hasSize(1); + ServerInfo serverInfo = Iterables.getOnlyElement(info.servers()); + assertThat(serverInfo.implSpecificConfig()).isInstanceOf(CompositeChannelCredentials.class); + CallCredentials callCredentials = + ((CompositeChannelCredentials) serverInfo.implSpecificConfig()).getCallCredentials(); + assertThat(callCredentials).isInstanceOf(CompositeCallCredentials.class); + } + private static BootstrapperImpl.FileReader createFileReader( final String expectedPath, final String rawData) { return new BootstrapperImpl.FileReader() { diff --git a/xds/src/test/java/io/grpc/xds/SharedXdsClientPoolProviderTest.java b/xds/src/test/java/io/grpc/xds/SharedXdsClientPoolProviderTest.java index 24f1750d5a8..2eb18a81a2e 100644 --- a/xds/src/test/java/io/grpc/xds/SharedXdsClientPoolProviderTest.java +++ b/xds/src/test/java/io/grpc/xds/SharedXdsClientPoolProviderTest.java @@ -28,6 +28,7 @@ import com.google.auth.oauth2.OAuth2Credentials; import com.google.common.util.concurrent.SettableFuture; import io.grpc.CallCredentials; +import io.grpc.CompositeChannelCredentials; import io.grpc.Grpc; import io.grpc.InsecureChannelCredentials; import io.grpc.InsecureServerCredentials; @@ -47,10 +48,17 @@ import io.grpc.xds.client.XdsClient; import io.grpc.xds.client.XdsClient.ResourceWatcher; import io.grpc.xds.client.XdsInitializationException; +import io.grpc.xds.internal.JwtTokenFileCallCredentials; +import io.grpc.xds.internal.JwtTokenFileTestUtils; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; import java.util.Collections; import java.util.concurrent.TimeUnit; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; import org.mockito.Mock; @@ -63,6 +71,8 @@ public class SharedXdsClientPoolProviderTest { private static final String SERVER_URI = "trafficdirector.googleapis.com"; @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); private final Node node = Node.newBuilder().setId("SharedXdsClientPoolProviderTest").build(); private final MetricRecorder metricRecorder = new MetricRecorder() {}; @@ -212,4 +222,56 @@ public void xdsClient_usesCallCredentials() throws Exception { xdsClientPool.returnObject(xdsClient); xdsServer.shutdownNow(); } + + @Test + public void xdsClient_usesJwtTokenFileCallCredentials() throws Exception { + GrpcBootstrapperImpl.xdsBootstrapCallCredsEnabled = true; + + Long givenExpTimeInSeconds = Instant.now().getEpochSecond() + TimeUnit.HOURS.toSeconds(1); + File jwtToken = tempFolder.newFile("jwt.token"); + JwtTokenFileTestUtils.writeValidJwtTokenContent(jwtToken, givenExpTimeInSeconds); + String jwtTokenContent = new String( + Files.readAllBytes(jwtToken.toPath()), + StandardCharsets.UTF_8); + + // Set up fake xDS server + XdsTestControlPlaneService fakeXdsService = new XdsTestControlPlaneService(); + CallCredsServerInterceptor callCredentialsInterceptor = new CallCredsServerInterceptor(); + Server xdsServer = + Grpc.newServerBuilderForPort(0, InsecureServerCredentials.create()) + .addService(fakeXdsService) + .intercept(callCredentialsInterceptor) + .build() + .start(); + String xdsServerUri = "localhost:" + xdsServer.getPort(); + + // Set up bootstrap & xDS client pool provider + ServerInfo server = ServerInfo.create( + xdsServerUri, + CompositeChannelCredentials.create( + InsecureChannelCredentials.create(), + JwtTokenFileCallCredentials.create(jwtToken.toString()))); + BootstrapInfo bootstrapInfo = + BootstrapInfo.builder().servers(Collections.singletonList(server)).node(node).build(); + when(bootstrapper.bootstrap()).thenReturn(bootstrapInfo); + SharedXdsClientPoolProvider provider = new SharedXdsClientPoolProvider(bootstrapper); + + // Create xDS client that uses the JwtTokenFileCallCredentials on the transport + ObjectPool xdsClientPool = + provider.getOrCreate( + "target", + metricRecorder, + JwtTokenFileCallCredentials.create(jwtToken.toString())); + XdsClient xdsClient = xdsClientPool.getObject(); + xdsClient.watchXdsResource( + XdsListenerResource.getInstance(), "someLDSresource", ldsResourceWatcher); + + // Wait for xDS server to get the request and verify that it received the CallCredentials + assertThat(callCredentialsInterceptor.getTokenWithTimeout(5, TimeUnit.SECONDS)) + .isEqualTo("Bearer " + jwtTokenContent); + + // Clean up + xdsClientPool.returnObject(xdsClient); + xdsServer.shutdownNow(); + } } diff --git a/xds/src/test/java/io/grpc/xds/XdsCallCredentialsRegistryTest.java b/xds/src/test/java/io/grpc/xds/XdsCallCredentialsRegistryTest.java new file mode 100644 index 00000000000..b7082c613a3 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/XdsCallCredentialsRegistryTest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.when; + +import io.grpc.xds.internal.JwtTokenFileXdsCallCredentialsProvider; +import java.util.Map; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +/** Unit tests for {@link XdsCallCredentialsRegistry}. */ +@RunWith(JUnit4.class) +public class XdsCallCredentialsRegistryTest { + @Rule + public final MockitoRule mocks = MockitoJUnit.rule(); + + @Mock + XdsCallCredentialsProvider mockedProvider; + + String providerName = "test_creds_provider"; + + XdsCallCredentialsRegistry unit; + + @Before + public void setUp() { + when(mockedProvider.getName()).thenReturn(providerName); + unit = XdsCallCredentialsRegistry.newRegistry().register(mockedProvider); + } + + @Test + public void getDefaultRegistry_returnsSameInstance() { + XdsCallCredentialsRegistry reg1 = XdsCallCredentialsRegistry.getDefaultRegistry(); + XdsCallCredentialsRegistry reg2 = XdsCallCredentialsRegistry.getDefaultRegistry(); + + assertThat(reg1).isNotNull(); + assertThat(reg2).isNotNull(); + assertSame(reg1, reg2); + } + + @Test + public void getProvider_throwsWhenProviderNameIsNull() { + try { + unit.getProvider(null); + fail("Should throw"); + } catch (NullPointerException e) { + assertThat(e).hasMessageThat().contains("name"); + } + } + + @Test + public void getProvider_returnsRegisteredProvider() { + XdsCallCredentialsProvider provider = unit.getProvider(providerName); + assertThat(provider).isNotNull(); + } + + + @Test + public void getProvider_returnsNullForNotExistingProvider() { + String notExistingProviderName = "gRPC"; + XdsCallCredentialsProvider provider = unit.getProvider(notExistingProviderName); + assertThat(provider).isNull(); + } + + @Test + public void defaultRegistry_providers() { + Map providers = + XdsCallCredentialsRegistry.getDefaultRegistry().providers(); + assertThat(providers).hasSize(1); + assertThat(providers.get("jwt_token_file").getClass()) + .isEqualTo(JwtTokenFileXdsCallCredentialsProvider.class); + } +} diff --git a/xds/src/test/java/io/grpc/xds/XdsCredentialsRegistryTest.java b/xds/src/test/java/io/grpc/xds/XdsCredentialsRegistryTest.java index facaffc67a2..4a358815032 100644 --- a/xds/src/test/java/io/grpc/xds/XdsCredentialsRegistryTest.java +++ b/xds/src/test/java/io/grpc/xds/XdsCredentialsRegistryTest.java @@ -28,7 +28,6 @@ import io.grpc.xds.internal.GoogleDefaultXdsCredentialsProvider; import io.grpc.xds.internal.InsecureXdsCredentialsProvider; import io.grpc.xds.internal.TlsXdsCredentialsProvider; -import java.util.List; import java.util.Map; import org.junit.Test; import org.junit.runner.RunWith; @@ -145,15 +144,6 @@ public void defaultRegistry_providers() { .isEqualTo(TlsXdsCredentialsProvider.class); } - @Test - public void getClassesViaHardcoded_classesPresent() throws Exception { - List> classes = XdsCredentialsRegistry.getHardCodedClasses(); - assertThat(classes).containsExactly( - GoogleDefaultXdsCredentialsProvider.class, - InsecureXdsCredentialsProvider.class, - TlsXdsCredentialsProvider.class); - } - @Test public void getProvider_null() { try { diff --git a/xds/src/test/java/io/grpc/xds/internal/JwtTokenFileCallCredentialsTest.java b/xds/src/test/java/io/grpc/xds/internal/JwtTokenFileCallCredentialsTest.java new file mode 100644 index 00000000000..a08e71d1f8c --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/JwtTokenFileCallCredentialsTest.java @@ -0,0 +1,140 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import com.google.auth.oauth2.AccessToken; +import com.google.common.truth.Truth; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.util.Date; +import java.util.concurrent.TimeUnit; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.experimental.runners.Enclosed; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link JwtTokenFileCallCredentials}. */ +@RunWith(Enclosed.class) +public class JwtTokenFileCallCredentialsTest { + @RunWith(JUnit4.class) + public static class WithEmptyJwtTokenTest { + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private File jwtTokenFile; + private JwtTokenFileCallCredentials unit; + + @Before + public void setUp() throws Exception { + this.jwtTokenFile = tempFolder.newFile("empty_jwt.token"); + + Constructor ctor = + JwtTokenFileCallCredentials.class.getDeclaredConstructor(String.class); + ctor.setAccessible(true); + this.unit = ctor.newInstance(jwtTokenFile.toString()); + } + + @Test + public void givenJwtTokenFileEmpty_WhenTokenRefreshed_ExpectException() { + assertThrows(IllegalArgumentException.class, () -> { + unit.refreshAccessToken(); + }); + } + } + + @RunWith(JUnit4.class) + public static class WithInvalidJwtTokenTest { + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private File jwtTokenFile; + private JwtTokenFileCallCredentials unit; + + @Before + public void setUp() throws Exception { + this.jwtTokenFile = tempFolder.newFile("invalid_jwt.token"); + JwtTokenFileTestUtils.writeJwtTokenContentWithoutExpiration(jwtTokenFile); + + Constructor ctor = + JwtTokenFileCallCredentials.class.getDeclaredConstructor(String.class); + ctor.setAccessible(true); + this.unit = ctor.newInstance(jwtTokenFile.toString()); + } + + @Test + public void givenJwtTokenFileWithoutExpiration_WhenTokenRefreshed_ExpectException() + throws Exception { + Exception ex = assertThrows(IOException.class, () -> { + unit.refreshAccessToken(); + }); + + String expectedMsg = "No expiration time found for JWT token"; + String actualMsg = ex.getMessage(); + + assertEquals(expectedMsg, actualMsg); + } + } + + @RunWith(JUnit4.class) + public static class WithValidJwtTokenTest { + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + private File jwtTokenFile; + private JwtTokenFileCallCredentials unit; + private Long givenExpTimeInSeconds; + + @Before + public void setUp() throws Exception { + this.jwtTokenFile = tempFolder.newFile("jwt.token"); + this.givenExpTimeInSeconds = Instant.now().getEpochSecond() + TimeUnit.HOURS.toSeconds(1); + + JwtTokenFileTestUtils.writeValidJwtTokenContent(jwtTokenFile, givenExpTimeInSeconds); + + Constructor ctor = + JwtTokenFileCallCredentials.class.getDeclaredConstructor(String.class); + ctor.setAccessible(true); + this.unit = ctor.newInstance(jwtTokenFile.toString()); + } + + @Test + public void givenValidJwtTokenFile_WhenTokenRefreshed_ExpectAccessTokenInstance() + throws Exception { + final Date givenExpTimeDate = new Date(TimeUnit.SECONDS.toMillis(givenExpTimeInSeconds)); + + String givenTokenValue = new String( + Files.readAllBytes(jwtTokenFile.toPath()), + StandardCharsets.UTF_8); + + AccessToken token = unit.refreshAccessToken(); + + Truth.assertThat(token.getExpirationTime()) + .isEquivalentAccordingToCompareTo(givenExpTimeDate); + assertEquals(token.getTokenValue(), givenTokenValue); + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/JwtTokenFileTestUtils.java b/xds/src/test/java/io/grpc/xds/internal/JwtTokenFileTestUtils.java new file mode 100644 index 00000000000..80471095c95 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/JwtTokenFileTestUtils.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal; + +import com.google.common.io.BaseEncoding; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public class JwtTokenFileTestUtils { + public static void writeJwtTokenContentWithoutExpiration(File jwtToken) throws IOException { + FileOutputStream outputStream = new FileOutputStream(jwtToken); + String content = + BaseEncoding.base64().encode( + new String("{\"typ\": \"JWT\", \"alg\": \"HS256\"}").getBytes(StandardCharsets.UTF_8)) + + "." + + BaseEncoding.base64().encode( + new String("{\"name\": \"Google\"}").getBytes(StandardCharsets.UTF_8)) + + "." + + BaseEncoding.base64().encode(new String("signature").getBytes(StandardCharsets.UTF_8)); + outputStream.write(content.getBytes(StandardCharsets.UTF_8)); + outputStream.close(); + } + + public static void writeValidJwtTokenContent(File jwtToken, long expTime) + throws Exception { + FileOutputStream outputStream = new FileOutputStream(jwtToken); + String content = + BaseEncoding.base64().encode( + new String("{\"typ\": \"JWT\", \"alg\": \"HS256\"}").getBytes(StandardCharsets.UTF_8)) + + "." + + BaseEncoding.base64().encode( + String.format("{\"exp\": %d}", expTime).getBytes(StandardCharsets.UTF_8)) + + "." + + BaseEncoding.base64().encode(new String("signature").getBytes(StandardCharsets.UTF_8)); + outputStream.write(content.getBytes(StandardCharsets.UTF_8)); + outputStream.close(); + } +} diff --git a/xds/src/test/java/io/grpc/xds/internal/JwtTokenFileXdsCallCredentialsProviderTest.java b/xds/src/test/java/io/grpc/xds/internal/JwtTokenFileXdsCallCredentialsProviderTest.java new file mode 100644 index 00000000000..f81d4860331 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/JwtTokenFileXdsCallCredentialsProviderTest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2025 The gRPC Authors + * + * Licensed 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 io.grpc.xds.internal; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import com.google.common.collect.ImmutableMap; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + + +/** Unit tests for {@link JwtTokenFileXdsCallCredentialsProvider}. */ +@RunWith(JUnit4.class) +public class JwtTokenFileXdsCallCredentialsProviderTest { + private JwtTokenFileXdsCallCredentialsProvider provider = + new JwtTokenFileXdsCallCredentialsProvider(); + + @Test + public void callCredentialsWhenNullConfig() { + assertNull(provider.newCallCredentials(null)); + } + + @Test + public void callCredentialsWhenWrongConfig() { + Map jsonConfig = ImmutableMap.of("not_expected_config_key", "some_value"); + assertNull(provider.newCallCredentials(jsonConfig)); + } + + @Test + public void callCredentialsWhenExpectedConfig() throws Exception { + Map jsonConfig = ImmutableMap.of("jwt_token_file", "/path/to/jwt.token"); + assertEquals("io.grpc.auth.GoogleAuthLibraryCallCredentials", + provider.newCallCredentials(jsonConfig).getClass().getName()); + } +}