From 26da0d3fb882e4f11c1cc43a81d34eb500c2c0cf Mon Sep 17 00:00:00 2001 From: Diego Alonso Marquez Palacios Date: Wed, 28 Jun 2023 18:39:44 -0400 Subject: [PATCH] feat: support GDC-H Credentials (#1642) * chore: initial additions to handle GDC-H API audience * chore: add unit tests for GDC-H * chore: cleanup of logic * chore: decompose tests into separate methods * chore: fix clirr diff check * chore: fmt:format * chore: add support in `ClientSettings` * chore: add showcase IT for GDCH credentials * chore: comments * chore: improve tests * chore: add partial IT for testing context credential * chore: recreate GdchCredentials with audience using convenience method * chore: more readable api audience logic * chore: no wildcard imports * chore: javadoc for public methods * chore: gdch test to use default null initialization * chore: tear down for gdch IT * chore: `assertThrows` for gdch ITs * chore: mvn fmt:format * test: remove context test * docs: explain that audience will be overriden if set through client/stub settings * test: test audience setting should modify initial credentials * chore: clirr check * chore: ignore gdch changes * chore: format * chore: default to endpoint if audience not provided * test: refresh gdch creds to confirm audience works * chore: fmt * chore: fmt * chore: better test names in ClientContextTest * chore: better test names for showcase tests * chore: simplify refresh verification logic * chore: include outcome in gdch it test names * chore: expand comments in GDCH ITs * test: intercept mock transport to verify audience * chore: fmt * chore: move auth test-jar to shared dependencies * chore: cleanup * chore: use inferred version for auth library * deps: update google-auth-java-library to 1.19.0 * choreL fmt ITGdch.java * chore: import auth test-jar using common version variable * chore: remove auth test-jar import from first-party-dependencies * chore: add license headers to new files * chore: revert google-auth-version to be obtained from main branch * chore: correct showcase parent pom indentation * chore: remove resource declaration for native test build --- gax-java/.gitignore | 1 + gax-java/gax/clirr-ignored-differences.xml | 6 + .../com/google/api/gax/rpc/ClientContext.java | 48 +++- .../google/api/gax/rpc/ClientSettings.java | 25 ++ .../com/google/api/gax/rpc/StubSettings.java | 31 +++ .../google/api/gax/rpc/ClientContextTest.java | 160 +++++++++++++ showcase/gapic-showcase/pom.xml | 8 + .../google/showcase/v1beta1/it/ITGdch.java | 218 ++++++++++++++++++ .../InterceptingMockTokenServerTransport.java | 50 ++++ ...eptingMockTokenServerTransportFactory.java | 31 +++ .../src/test/resources/fake_cert.pem | 15 ++ .../test/resources/test_gdch_credential.json | 10 + 12 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITGdch.java create mode 100644 showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/util/InterceptingMockTokenServerTransport.java create mode 100644 showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/util/InterceptingMockTokenServerTransportFactory.java create mode 100644 showcase/gapic-showcase/src/test/resources/fake_cert.pem create mode 100644 showcase/gapic-showcase/src/test/resources/test_gdch_credential.json diff --git a/gax-java/.gitignore b/gax-java/.gitignore index e6010998cb..05e9d91921 100644 --- a/gax-java/.gitignore +++ b/gax-java/.gitignore @@ -15,6 +15,7 @@ target # IntelliJ .idea +.ijwb *.iml out diff --git a/gax-java/gax/clirr-ignored-differences.xml b/gax-java/gax/clirr-ignored-differences.xml index a0516a55d4..15b4a76c8a 100644 --- a/gax-java/gax/clirr-ignored-differences.xml +++ b/gax-java/gax/clirr-ignored-differences.xml @@ -7,6 +7,12 @@ com/google/api/gax/paging/Page * stream*(*) + + + 7013 + com/google/api/gax/rpc/* + * *Gdch*(*) + 7006 com/google/api/gax/rpc/ServerStreamingCallSettings$Builder diff --git a/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientContext.java b/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientContext.java index 91e10aa7bf..c26d4a0f4d 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientContext.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientContext.java @@ -29,6 +29,7 @@ */ package com.google.api.gax.rpc; +import com.google.api.client.util.Strings; import com.google.api.core.ApiClock; import com.google.api.core.BetaApi; import com.google.api.core.NanoClock; @@ -40,11 +41,13 @@ import com.google.api.gax.tracing.ApiTracerFactory; import com.google.api.gax.tracing.BaseApiTracerFactory; import com.google.auth.Credentials; +import com.google.auth.oauth2.GdchCredentials; import com.google.auto.value.AutoValue; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Sets; import java.io.IOException; +import java.net.URI; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -109,6 +112,13 @@ public abstract class ClientContext { @Nonnull public abstract ApiTracerFactory getTracerFactory(); + /** + * Gets the API audience used when creating a Client that uses {@link + * com.google.auth.oauth2.GdchCredentials} + */ + @Nullable + public abstract String getGdchApiAudience(); + public static Builder newBuilder() { return new AutoValue_ClientContext.Builder() .setBackgroundResources(Collections.emptyList()) @@ -119,7 +129,8 @@ public static Builder newBuilder() { .setStreamWatchdog(null) .setStreamWatchdogCheckInterval(Duration.ZERO) .setTracerFactory(BaseApiTracerFactory.getInstance()) - .setQuotaProjectId(null); + .setQuotaProjectId(null) + .setGdchApiAudience(null); } public abstract Builder toBuilder(); @@ -167,6 +178,30 @@ public static ClientContext create(StubSettings settings) throws IOException { Credentials credentials = settings.getCredentialsProvider().getCredentials(); + String settingsGdchApiAudience = settings.getGdchApiAudience(); + if (credentials instanceof GdchCredentials) { + // We recompute the GdchCredentials with the audience + String audienceString; + if (!Strings.isNullOrEmpty(settingsGdchApiAudience)) { + audienceString = settingsGdchApiAudience; + } else if (!Strings.isNullOrEmpty(settings.getEndpoint())) { + audienceString = settings.getEndpoint(); + } else { + throw new IllegalArgumentException("Could not infer GDCH api audience from settings"); + } + + URI gdchAudienceUri; + try { + gdchAudienceUri = URI.create(audienceString); + } catch (IllegalArgumentException ex) { // thrown when passing a malformed uri string + throw new IllegalArgumentException("The GDC-H API audience string is not a valid URI", ex); + } + credentials = ((GdchCredentials) credentials).createWithGdchAudience(gdchAudienceUri); + } else if (!Strings.isNullOrEmpty(settingsGdchApiAudience)) { + throw new IllegalArgumentException( + "GDC-H API audience can only be set when using GdchCredentials"); + } + if (settings.getQuotaProjectId() != null && credentials != null) { // If the quotaProjectId is set, wrap original credentials with correct quotaProjectId as // QuotaProjectIdHidingCredentials. @@ -325,6 +360,17 @@ public abstract static class Builder { @BetaApi("The surface for tracing is not stable yet and may change in the future.") public abstract Builder setTracerFactory(ApiTracerFactory tracerFactory); + /** + * Sets the API audience used by {@link com.google.auth.oauth2.GdchCredentials} It cannot be + * used if other type of {@link com.google.auth.Credentials} is used + * + *

If the provided credentials already contain an api audience, it will be overriden by this + * one + * + * @param gdchApiAudience the audience to be used - must be a valid URI string + */ + public abstract Builder setGdchApiAudience(String gdchApiAudience); + public abstract ClientContext build(); } } diff --git a/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientSettings.java b/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientSettings.java index a1097a4100..04b2c9f55b 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientSettings.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/rpc/ClientSettings.java @@ -111,6 +111,11 @@ public final Duration getWatchdogCheckInterval() { return stubSettings.getStreamWatchdogCheckInterval(); } + /** Gets the GDCH API audience that was previously set in this Builder */ + public final String getGdchApiAudience() { + return stubSettings.getGdchApiAudience(); + } + public String toString() { return MoreObjects.toStringHelper(this) .add("executorProvider", getExecutorProvider()) @@ -124,6 +129,7 @@ public String toString() { .add("quotaProjectId", getQuotaProjectId()) .add("watchdogProvider", getWatchdogProvider()) .add("watchdogCheckInterval", getWatchdogCheckInterval()) + .add("gdchApiAudience", getGdchApiAudience()) .toString(); } @@ -255,6 +261,18 @@ public B setWatchdogCheckInterval(@Nullable Duration checkInterval) { return self(); } + /** + * Sets the GDC-H api audience. This is intended only to be used with {@link + * com.google.auth.oauth2.GdchCredentials} If this field is set and other type of {@link + * com.google.auth.Credentials} is used then an {@link IllegalArgumentException} will be thrown. + * If the provided credentials already have an api audience, then it will be overriden by this + * audience + */ + public B setGdchApiAudience(@Nullable String gdchApiAudience) { + stubSettings.setGdchApiAudience(gdchApiAudience); + return self(); + } + /** * Gets the ExecutorProvider that was previously set on this Builder. This ExecutorProvider is * to use for running asynchronous API call logic (such as retries and long-running operations), @@ -322,6 +340,12 @@ public Duration getWatchdogCheckInterval() { return stubSettings.getStreamWatchdogCheckInterval(); } + /** Gets the GDCH API audience that was previously set in this Builder */ + @Nullable + public String getGdchApiAudience() { + return stubSettings.getGdchApiAudience(); + } + /** Applies the given settings updater function to the given method settings builders. */ protected static void applyToAllUnaryMethods( Iterable> methodSettingsBuilders, @@ -344,6 +368,7 @@ public String toString() { .add("quotaProjectId", getQuotaProjectId()) .add("watchdogProvider", getWatchdogProvider()) .add("watchdogCheckInterval", getWatchdogCheckInterval()) + .add("gdchApiAudience", getGdchApiAudience()) .toString(); } } diff --git a/gax-java/gax/src/main/java/com/google/api/gax/rpc/StubSettings.java b/gax-java/gax/src/main/java/com/google/api/gax/rpc/StubSettings.java index 877b03e29f..7ebbe327c8 100644 --- a/gax-java/gax/src/main/java/com/google/api/gax/rpc/StubSettings.java +++ b/gax-java/gax/src/main/java/com/google/api/gax/rpc/StubSettings.java @@ -73,6 +73,7 @@ public abstract class StubSettings> { private final String endpoint; private final String mtlsEndpoint; private final String quotaProjectId; + @Nullable private final String gdchApiAudience; @Nullable private final WatchdogProvider streamWatchdogProvider; @Nonnull private final Duration streamWatchdogCheckInterval; @Nonnull private final ApiTracerFactory tracerFactory; @@ -103,6 +104,7 @@ protected StubSettings(Builder builder) { this.streamWatchdogCheckInterval = builder.streamWatchdogCheckInterval; this.tracerFactory = builder.tracerFactory; this.deprecatedExecutorProviderSet = builder.deprecatedExecutorProviderSet; + this.gdchApiAudience = builder.gdchApiAudience; } /** @deprecated Please use {@link #getBackgroundExecutorProvider()}. */ @@ -172,6 +174,12 @@ public ApiTracerFactory getTracerFactory() { return tracerFactory; } + /** Gets the GDCH API audience to be used with {@link com.google.auth.oauth2.GdchCredentials} */ + @Nullable + public final String getGdchApiAudience() { + return gdchApiAudience; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -188,6 +196,7 @@ public String toString() { .add("streamWatchdogProvider", streamWatchdogProvider) .add("streamWatchdogCheckInterval", streamWatchdogCheckInterval) .add("tracerFactory", tracerFactory) + .add("gdchApiAudience", gdchApiAudience) .toString(); } @@ -205,6 +214,7 @@ public abstract static class Builder< private String endpoint; private String mtlsEndpoint; private String quotaProjectId; + @Nullable private String gdchApiAudience; @Nullable private WatchdogProvider streamWatchdogProvider; @Nonnull private Duration streamWatchdogCheckInterval; @Nonnull private ApiTracerFactory tracerFactory; @@ -234,6 +244,7 @@ protected Builder(StubSettings settings) { this.streamWatchdogCheckInterval = settings.streamWatchdogCheckInterval; this.tracerFactory = settings.tracerFactory; this.deprecatedExecutorProviderSet = settings.deprecatedExecutorProviderSet; + this.gdchApiAudience = settings.gdchApiAudience; } /** Get Quota Project ID from Client Context * */ @@ -268,6 +279,7 @@ protected Builder(ClientContext clientContext) { this.streamWatchdogCheckInterval = Duration.ofSeconds(10); this.tracerFactory = BaseApiTracerFactory.getInstance(); this.deprecatedExecutorProviderSet = false; + this.gdchApiAudience = null; } else { ExecutorProvider fixedExecutorProvider = FixedExecutorProvider.create(clientContext.getExecutor()); @@ -289,6 +301,7 @@ protected Builder(ClientContext clientContext) { this.streamWatchdogCheckInterval = clientContext.getStreamWatchdogCheckInterval(); this.tracerFactory = clientContext.getTracerFactory(); this.quotaProjectId = getQuotaProjectIdFromClientContext(clientContext); + this.gdchApiAudience = clientContext.getGdchApiAudience(); } } @@ -435,6 +448,18 @@ public B setStreamWatchdogCheckInterval(@Nonnull Duration checkInterval) { return self(); } + /** + * Sets the API audience used by {@link com.google.auth.oauth2.GdchCredentials} It cannot be + * used if other type of {@link com.google.auth.Credentials} is used. If the provided + * credentials already have an api audience set, then it will be overriden by this audience + * + * @param gdchApiAudience the audience to be used - must be a valid URI string + */ + public B setGdchApiAudience(String gdchApiAudience) { + this.gdchApiAudience = gdchApiAudience; + return self(); + } + /** * Configures the {@link ApiTracerFactory} that will be used to generate traces. * @@ -513,6 +538,11 @@ public ApiTracerFactory getTracerFactory() { return tracerFactory; } + /** Gets the GDCH API audience that was previously set in this Builder */ + public String getGdchApiAudience() { + return gdchApiAudience; + } + /** Applies the given settings updater function to the given method settings builders. */ protected static void applyToAllUnaryMethods( Iterable> methodSettingsBuilders, @@ -540,6 +570,7 @@ public String toString() { .add("streamWatchdogProvider", streamWatchdogProvider) .add("streamWatchdogCheckInterval", streamWatchdogCheckInterval) .add("tracerFactory", tracerFactory) + .add("gdchApiAudience", gdchApiAudience) .toString(); } } diff --git a/gax-java/gax/src/test/java/com/google/api/gax/rpc/ClientContextTest.java b/gax-java/gax/src/test/java/com/google/api/gax/rpc/ClientContextTest.java index 7b69e30feb..ebe7a66712 100644 --- a/gax-java/gax/src/test/java/com/google/api/gax/rpc/ClientContextTest.java +++ b/gax-java/gax/src/test/java/com/google/api/gax/rpc/ClientContextTest.java @@ -32,8 +32,13 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import com.google.api.core.ApiClock; import com.google.api.gax.core.BackgroundResource; @@ -50,10 +55,13 @@ import com.google.api.gax.rpc.testing.FakeStubSettings; import com.google.api.gax.rpc.testing.FakeTransportChannel; import com.google.auth.Credentials; +import com.google.auth.oauth2.ComputeEngineCredentials; +import com.google.auth.oauth2.GdchCredentials; import com.google.auth.oauth2.GoogleCredentials; import com.google.common.collect.ImmutableMap; import com.google.common.truth.Truth; import java.io.IOException; +import java.net.URI; import java.util.Collections; import java.util.List; import java.util.Map; @@ -790,4 +798,156 @@ public void testExecutorSettings() throws Exception { transportChannel = (FakeTransportChannel) context.getTransportChannel(); assertThat(transportChannel.getExecutor()).isSameInstanceAs(executorProvider.getExecutor()); } + + private GdchCredentials getMockGdchCredentials() throws IOException { + GdchCredentials creds = Mockito.mock(GdchCredentials.class); + + // GdchCredentials builder is mocked to accept a well-formed uri + GdchCredentials.Builder gdchCredsBuilder = Mockito.mock(GdchCredentials.Builder.class); + Mockito.when(gdchCredsBuilder.setGdchAudience(Mockito.any(URI.class))) + .thenReturn(gdchCredsBuilder); + Mockito.when(gdchCredsBuilder.build()).thenReturn(creds); + Mockito.when(creds.toBuilder()).thenReturn(gdchCredsBuilder); + Mockito.when(creds.createWithGdchAudience(Mockito.any())) + .thenAnswer((uri) -> getMockGdchCredentials()); + return creds; + } + + private TransportChannelProvider getFakeTransportChannelProvider() { + return new FakeTransportProvider( + FakeTransportChannel.create(new FakeChannel()), null, true, null, null); + } + + @Test + public void testCreateClientContext_withGdchCredentialNoAudienceNoEndpoint_throws() + throws IOException { + TransportChannelProvider transportChannelProvider = getFakeTransportChannelProvider(); + Credentials creds = getMockGdchCredentials(); + + CredentialsProvider provider = FixedCredentialsProvider.create(creds); + StubSettings settings = new FakeStubSettings.Builder().setGdchApiAudience(null).build(); + FakeClientSettings.Builder clientSettingsBuilder = new FakeClientSettings.Builder(settings); + clientSettingsBuilder.setCredentialsProvider(provider); + clientSettingsBuilder.setTransportChannelProvider(transportChannelProvider); + + // should throw + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> ClientContext.create(clientSettingsBuilder.build())); + assertEquals("Could not infer GDCH api audience from settings", ex.getMessage()); + } + + @Test + public void testCreateClientContext_withGdchCredentialWithoutAudienceWithEndpoint_correct() + throws IOException { + TransportChannelProvider transportChannelProvider = getFakeTransportChannelProvider(); + Credentials creds = getMockGdchCredentials(); + + // it should correctly create a client context with gdch creds and null audience + CredentialsProvider provider = FixedCredentialsProvider.create(creds); + StubSettings settings = + new FakeStubSettings.Builder() + .setGdchApiAudience(null) + .setEndpoint("test-endpoint") + .build(); + FakeClientSettings.Builder clientSettingsBuilder = new FakeClientSettings.Builder(settings); + clientSettingsBuilder.setCredentialsProvider(provider); + clientSettingsBuilder.setTransportChannelProvider(transportChannelProvider); + + // should not throw + ClientContext context = ClientContext.create(clientSettingsBuilder.build()); + + Credentials fromContext = context.getCredentials(); + Credentials fromProvider = provider.getCredentials(); + assertNotNull(fromProvider); + assertNotNull(fromContext); + assertThat(fromContext).isInstanceOf(GdchCredentials.class); + assertThat(fromProvider).isInstanceOf(GdchCredentials.class); + assertNotSame(fromContext, fromProvider); + verify((GdchCredentials) fromProvider, times(1)) + .createWithGdchAudience(URI.create("test-endpoint")); + } + + @Test + public void testCreateClientContext_withGdchCredentialAndValidAudience() throws IOException { + Credentials creds = getMockGdchCredentials(); + CredentialsProvider provider = FixedCredentialsProvider.create(creds); + TransportChannelProvider transportChannelProvider = getFakeTransportChannelProvider(); + + // it should throw if both apiAudience and GDC-H creds are set but apiAudience is not a valid + // uri + StubSettings settings = + new FakeStubSettings.Builder() + .setEndpoint("test-endpoint") + .setGdchApiAudience("valid-uri") + .build(); + ClientSettings.Builder clientSettingsBuilder = new FakeClientSettings.Builder(settings); + clientSettingsBuilder.setCredentialsProvider(provider); + clientSettingsBuilder.setTransportChannelProvider(transportChannelProvider); + ClientContext context = ClientContext.create(clientSettingsBuilder.build()); + Credentials fromContext = context.getCredentials(); + Credentials fromProvider = provider.getCredentials(); + assertNotNull(fromProvider); + assertNotNull(fromContext); + // using an audience should have made the context to recreate the credentials + assertNotSame(fromContext, fromProvider); + verify((GdchCredentials) fromProvider, times(1)) + .createWithGdchAudience(URI.create("valid-uri")); + verify((GdchCredentials) fromProvider, times(0)) + .createWithGdchAudience(URI.create("test-endpoint")); + } + + @Test + public void testCreateClientContext_withGdchCredentialAndInvalidAudience_throws() + throws IOException { + TransportChannelProvider transportChannelProvider = getFakeTransportChannelProvider(); + Credentials creds = getMockGdchCredentials(); + CredentialsProvider provider = FixedCredentialsProvider.create(creds); + + // it should throw if both apiAudience and GDC-H creds are set but apiAudience is not a valid + // uri + StubSettings settings = + new FakeStubSettings.Builder() + .setGdchApiAudience("$invalid-uri:") + .setEndpoint("test-endpoint") + .build(); + ClientSettings.Builder clientSettingsBuilder = new FakeClientSettings.Builder(settings); + clientSettingsBuilder.setCredentialsProvider(provider); + clientSettingsBuilder.setTransportChannelProvider(transportChannelProvider); + final ClientSettings withGdchCredentialsAndMalformedApiAudience = clientSettingsBuilder.build(); + // should throw + String exMessage = + assertThrows( + IllegalArgumentException.class, + () -> ClientContext.create(withGdchCredentialsAndMalformedApiAudience)) + .getMessage(); + assertThat(exMessage).contains("The GDC-H API audience string is not a valid URI"); + + Credentials fromProvider = provider.getCredentials(); + verify((GdchCredentials) fromProvider, times(0)) + .createWithGdchAudience(URI.create("test-endpoint")); + } + + @Test + public void testCreateClientContext_withNonGdchCredentialAndAnyAudience_throws() + throws IOException { + TransportChannelProvider transportChannelProvider = getFakeTransportChannelProvider(); + + // it should throw if apiAudience is set but not using GDC-H creds + StubSettings settings = + new FakeStubSettings.Builder().setGdchApiAudience("audience:test").build(); + Credentials creds = Mockito.mock(ComputeEngineCredentials.class); + CredentialsProvider provider = FixedCredentialsProvider.create(creds); + ClientSettings.Builder clientSettingsBuilder = new FakeClientSettings.Builder(settings); + clientSettingsBuilder.setCredentialsProvider(provider); + clientSettingsBuilder.setTransportChannelProvider(transportChannelProvider); + final ClientSettings withComputeCredentials = clientSettingsBuilder.build(); + // should throw + String exMessage = + assertThrows( + IllegalArgumentException.class, () -> ClientContext.create(withComputeCredentials)) + .getMessage(); + assertThat(exMessage).contains("GDC-H API audience can only be set when using GdchCredentials"); + } } diff --git a/showcase/gapic-showcase/pom.xml b/showcase/gapic-showcase/pom.xml index 8663ade568..6112e56e9a 100644 --- a/showcase/gapic-showcase/pom.xml +++ b/showcase/gapic-showcase/pom.xml @@ -229,5 +229,13 @@ testlib test + + com.google.auth + google-auth-library-oauth2-http + ${google.auth.version} + test-jar + testlib + test + diff --git a/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITGdch.java b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITGdch.java new file mode 100644 index 0000000000..83dfb3d704 --- /dev/null +++ b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/ITGdch.java @@ -0,0 +1,218 @@ +/* + * Copyright 2023 Google LLC + * + * 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 + * + * https://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 com.google.showcase.v1beta1.it; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.api.gax.rpc.ClientContext; +import com.google.auth.Credentials; +import com.google.auth.oauth2.GdchCredentials; +import com.google.auth.oauth2.GdchCredentialsTestUtil; +import com.google.showcase.v1beta1.EchoClient; +import com.google.showcase.v1beta1.EchoSettings; +import com.google.showcase.v1beta1.it.util.InterceptingMockTokenServerTransportFactory; +import com.google.showcase.v1beta1.stub.EchoStub; +import com.google.showcase.v1beta1.stub.EchoStubSettings; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Test suite to confirm a client can be instantiated with GDCH credentials. No calls are made since + * it is not feasible to test against real GDCH servers (or replicate an environment) + */ +public class ITGdch { + + private static final String TEST_GDCH_CREDENTIAL_FILE = "/test_gdch_credential.json"; + private static final String CA_CERT_RESOURCE_PATH = "/fake_cert.pem"; + private static final String CA_CERT_JSON_KEY = "ca_cert_path"; + private static final String TEMP_CREDENTIAL_JSON_FILENAME = "temp_gdch_credential.json"; + private static final String GDCH_TOKEN_STRING = "1/MkSJoj1xsli0AccessToken_NKPY2"; + private static final String SID_NAME = "service-identity-name"; + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + private EchoClient client; + private EchoSettings settings; + private EchoStubSettings stubSettings; + private Credentials initialCredentials; + private ClientContext context; + private EchoStub stub; + private InterceptingMockTokenServerTransportFactory transportFactory; + private String projectId; + private URI tokenUri; + + @Before + public void setup() throws IOException, URISyntaxException { + transportFactory = new InterceptingMockTokenServerTransportFactory(); + prepareCredentials(); + tempFolder.create(); + settings = + EchoSettings.newBuilder() + .setCredentialsProvider(FixedCredentialsProvider.create(initialCredentials)) + .build(); + } + + @After + public void tearDown() { + if (client != null) { + client.close(); + } + } + + private void prepareCredentials() throws IOException, URISyntaxException { + // compute absolute path of the CA certificate + Path caCertPath = Paths.get(getClass().getResource(CA_CERT_RESOURCE_PATH).toURI()); + + // open gdch credential json (still needs its "ca_cert_path" to point to the CA certificate + // obtained from above) + JsonFactory factory = new GsonFactory(); + GenericJson converted = + factory.fromInputStream( + getClass().getResourceAsStream(TEST_GDCH_CREDENTIAL_FILE), GenericJson.class); + + // modify and save to a temporary folder + converted.set(CA_CERT_JSON_KEY, caCertPath.toAbsolutePath().toString()); + projectId = converted.get("project").toString(); + tokenUri = URI.create(converted.get("token_uri").toString()); + + File tempGdchCredentialFile = tempFolder.newFile(TEMP_CREDENTIAL_JSON_FILENAME); + try (FileWriter fileWriter = new FileWriter(tempGdchCredentialFile)) { + String preparedJson = converted.toPrettyString(); + fileWriter.write(preparedJson); + } + + // use temp location to instantiate credentials + initialCredentials = GdchCredentialsTestUtil.fromJson(converted, transportFactory); + } + + /** + * {@link com.google.api.gax.rpc.ClientContext} will create a new {@link GdchCredentials} with an + * audience defaulted to the endpoint if the audience is not manually passed. This test confirms + * that a new credential is created from the context and can be refreshed + * + * @throws IOException + */ + @Test + public void testCreateClient_withGdchCredentialAndNoAudience_defaultsToEndpointBasedAudience() + throws IOException { + + // we create the client as usual - no audience passed + String testEndpoint = "custom-endpoint:123"; + settings = settings.toBuilder().setEndpoint(testEndpoint).build(); + context = ClientContext.create(settings); + stubSettings = EchoStubSettings.newBuilder(context).build(); + client = EchoClient.create(stubSettings.createStub()); + + // We retrieve from context and from client + // the client has only access to creds provider, which may differ from the actual credentials + // used in the Context + Credentials fromContext = context.getCredentials(); + Credentials fromClient = initialCredentials; + + // Since ClientContext.create() uses a modified version of GdchCredentials + // via GdchCredentials.createWithGdchAudience(), they should be different objects + assertNotSame(fromContext, fromClient); + + // When credentials don't have an audience (such as the ones we passed to client creation and + // now stored in the + // provider) they will throw if we try to refresh them + NullPointerException expectedEx = + assertThrows(NullPointerException.class, () -> initialCredentials.refresh()); + assertTrue( + expectedEx.getMessage().contains("Audience are not configured for GDCH service account")); + + // However, the credentials prepared in ClientContext should be able to refresh since the + // audience would be + // internally defaulted the endpoint of the StubSettings + registerCredential(fromContext); + ((GdchCredentials) fromContext).refreshAccessToken(); + String usedAudience = transportFactory.transport.getLastAudienceSent(); + assertEquals(testEndpoint, usedAudience); + } + + /** + * Confirms creating a client with a valid audience is successful. We cannot confirm which + * audience is chosen (our passed audience or the endpoint) but this is confirmed in the unit + * tests. + * + * @throws IOException + */ + @Test + public void + testCreateClient_withGdchCredentialWithValidAudience_usesCredentialWithPassedAudience() + throws IOException { + + // Similar to the previous test, create a client as usual but this time we pass a explicit + // audience. It should + // be created without issues + String testAudience = "valid-audience"; + settings = settings.toBuilder().setGdchApiAudience(testAudience).build(); + context = ClientContext.create(settings); + stubSettings = EchoStubSettings.newBuilder(context).build(); + client = EchoClient.create(stubSettings.createStub()); + + // We retrieve both creds from the creds provider and the ones prepared in the context (which + // should have been + // re-created using GdchCredentials.createWithAudience("valid-audience")) + Credentials fromContext = context.getCredentials(); + assertNotSame(fromContext, initialCredentials); + + // Again, since the initial credentials don't have an audience, we should not be able to refresh + // them + NullPointerException thrownByClientCreds = + assertThrows(NullPointerException.class, () -> initialCredentials.refresh()); + assertTrue( + thrownByClientCreds + .getMessage() + .contains("Audience are not configured for GDCH service account")); + + // But the credentials prepared in ClientContext should be able to refresh since the audience + // would be internally + // set to the one passed in stub settings + registerCredential(fromContext); + ((GdchCredentials) fromContext).refreshAccessToken(); + String usedAudience = transportFactory.transport.getLastAudienceSent(); + assertEquals(testAudience, usedAudience); + } + + private void registerCredential(Credentials fromContext) { + GdchCredentialsTestUtil.registerGdchCredentialWithMockTransport( + (GdchCredentials) fromContext, + transportFactory.transport, + projectId, + SID_NAME, + GDCH_TOKEN_STRING, + tokenUri); + } +} diff --git a/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/util/InterceptingMockTokenServerTransport.java b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/util/InterceptingMockTokenServerTransport.java new file mode 100644 index 0000000000..40860d97be --- /dev/null +++ b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/util/InterceptingMockTokenServerTransport.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 Google LLC + * + * 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 + * + * https://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 com.google.showcase.v1beta1.it.util; + +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.api.client.testing.http.MockLowLevelHttpRequest; +import com.google.api.client.util.StreamingContent; +import com.google.auth.TestUtils; +import com.google.auth.oauth2.MockTokenServerTransport; + +import java.io.IOException; +import java.util.Map; + +public class InterceptingMockTokenServerTransport extends MockTokenServerTransport { + private MockLowLevelHttpRequest lastRequest; + private static final JsonFactory JSON_FACTORY = new GsonFactory(); + + @Override + public LowLevelHttpRequest buildRequest(String method, String url) throws IOException { + MockLowLevelHttpRequest baseRequest = (MockLowLevelHttpRequest) super.buildRequest(method, url); + lastRequest = baseRequest; + return baseRequest; + } + + public String getLastAudienceSent() throws IOException { + String contentString = lastRequest.getContentAsString(); + Map query = TestUtils.parseQuery(contentString); + String assertion = query.get("assertion"); + JsonWebSignature signature = JsonWebSignature.parse(JSON_FACTORY, assertion); + String foundTargetAudience = (String) signature.getPayload().get("api_audience"); + return foundTargetAudience; + } +} diff --git a/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/util/InterceptingMockTokenServerTransportFactory.java b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/util/InterceptingMockTokenServerTransportFactory.java new file mode 100644 index 0000000000..175beb61d6 --- /dev/null +++ b/showcase/gapic-showcase/src/test/java/com/google/showcase/v1beta1/it/util/InterceptingMockTokenServerTransportFactory.java @@ -0,0 +1,31 @@ +/* + * Copyright 2023 Google LLC + * + * 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 + * + * https://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 com.google.showcase.v1beta1.it.util; + +import com.google.api.client.http.HttpTransport; +import com.google.auth.oauth2.MockTokenServerTransport; +import com.google.auth.oauth2.MockTokenServerTransportFactory; + +public class InterceptingMockTokenServerTransportFactory extends MockTokenServerTransportFactory { + + public InterceptingMockTokenServerTransport transport = new InterceptingMockTokenServerTransport(); + + @Override + public HttpTransport create() { + return transport; + } +} diff --git a/showcase/gapic-showcase/src/test/resources/fake_cert.pem b/showcase/gapic-showcase/src/test/resources/fake_cert.pem new file mode 100644 index 0000000000..03febfd3ae --- /dev/null +++ b/showcase/gapic-showcase/src/test/resources/fake_cert.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICUTCCAfugAwIBAgIBADANBgkqhkiG9w0BAQQFADBXMQswCQYDVQQGEwJDTjEL +MAkGA1UECBMCUE4xCzAJBgNVBAcTAkNOMQswCQYDVQQKEwJPTjELMAkGA1UECxMC +VU4xFDASBgNVBAMTC0hlcm9uZyBZYW5nMB4XDTA1MDcxNTIxMTk0N1oXDTA1MDgx +NDIxMTk0N1owVzELMAkGA1UEBhMCQ04xCzAJBgNVBAgTAlBOMQswCQYDVQQHEwJD +TjELMAkGA1UEChMCT04xCzAJBgNVBAsTAlVOMRQwEgYDVQQDEwtIZXJvbmcgWWFu +ZzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQCp5hnG7ogBhtlynpOS21cBewKE/B7j +V14qeyslnr26xZUsSVko36ZnhiaO/zbMOoRcKK9vEcgMtcLFuQTWDl3RAgMBAAGj +gbEwga4wHQYDVR0OBBYEFFXI70krXeQDxZgbaCQoR4jUDncEMH8GA1UdIwR4MHaA +FFXI70krXeQDxZgbaCQoR4jUDncEoVukWTBXMQswCQYDVQQGEwJDTjELMAkGA1UE +CBMCUE4xCzAJBgNVBAcTAkNOMQswCQYDVQQKEwJPTjELMAkGA1UECxMCVU4xFDAS +BgNVBAMTC0hlcm9uZyBZYW5nggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEE +BQADQQA/ugzBrjjK9jcWnDVfGHlk3icNRq0oV7Ri32z/+HQX67aRfgZu7KWdI+Ju +Wm7DCfrPNGVwFWUQOmsPue9rZBgO +-----END CERTIFICATE----- diff --git a/showcase/gapic-showcase/src/test/resources/test_gdch_credential.json b/showcase/gapic-showcase/src/test/resources/test_gdch_credential.json new file mode 100644 index 0000000000..16c2582cdc --- /dev/null +++ b/showcase/gapic-showcase/src/test/resources/test_gdch_credential.json @@ -0,0 +1,10 @@ +{ + "type": "gdch_service_account", + "format_version": "1", + "project": "project-id", + "private_key_id": "d84a4fefcf50791d4a90f2d7af17469d6282df9d", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALX0PQoe1igW12i\nkv1bN/r9lN749y2ijmbc/mFHPyS3hNTyOCjDvBbXYbDhQJzWVUikh4mvGBA07qTj79Xc3yBDfKP2IeyYQIFe0t0\nzkd7R9Zdn98Y2rIQC47aAbDfubtkU1U72t4zL11kHvoa0/RuFZjncvlr42X7be7lYh4p3NAgMBAAECgYASk5wDw\n4Az2ZkmeuN6Fk/y9H+Lcb2pskJIXjrL533vrDWGOC48LrsThMQPv8cxBky8HFSEklPpkfTF95tpD43iVwJRB/Gr\nCtGTw65IfJ4/tI09h6zGc4yqvIo1cHX/LQ+SxKLGyir/dQM925rGt/VojxY5ryJR7GLbCzxPnJm/oQJBANwOCO6\nD2hy1LQYJhXh7O+RLtA/tSnT1xyMQsGT+uUCMiKS2bSKx2wxo9k7h3OegNJIu1q6nZ6AbxDK8H3+d0dUCQQDTrP\nSXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAut\nLPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEA\ngidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJ\nADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ\n==\n-----END PRIVATE KEY-----\n", + "name": "service-identity-name", + "ca_cert_path": "fake-cert-path", + "token_uri": "https://service-identity.fake-domain/authenticate" +}