Skip to content

Commit c253efa

Browse files
authored
GCP Auth: update header injection implementation (#1860)
1 parent fc199eb commit c253efa

File tree

7 files changed

+122
-65
lines changed

7 files changed

+122
-65
lines changed

gcp-auth-extension/build.gradle.kts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ dependencies {
5555
tasks {
5656
test {
5757
useJUnitPlatform()
58+
// Unset relevant environment variables to provide a clean state for the tests
59+
environment("GOOGLE_CLOUD_PROJECT", "")
60+
environment("GOOGLE_CLOUD_QUOTA_PROJECT", "")
5861
// exclude integration test
5962
exclude("io/opentelemetry/contrib/gcp/auth/GcpAuthExtensionEndToEndTest.class")
6063
}
@@ -103,15 +106,15 @@ tasks.register<Copy>("copyAgent") {
103106
})
104107
}
105108

106-
tasks.register<Test>("IntegrationTest") {
109+
tasks.register<Test>("IntegrationTestUserCreds") {
107110
dependsOn(tasks.shadowJar)
108111
dependsOn(tasks.named("copyAgent"))
109112

110113
useJUnitPlatform()
111114
// include only the integration test file
112115
include("io/opentelemetry/contrib/gcp/auth/GcpAuthExtensionEndToEndTest.class")
113116

114-
val fakeCredsFilePath = project.file("src/test/resources/fakecreds.json").absolutePath
117+
val fakeCredsFilePath = project.file("src/test/resources/fake_user_creds.json").absolutePath
115118

116119
environment("GOOGLE_CLOUD_QUOTA_PROJECT", "quota-project-id")
117120
environment("GOOGLE_APPLICATION_CREDENTIALS", fakeCredsFilePath)
@@ -127,6 +130,7 @@ tasks.register<Test>("IntegrationTest") {
127130
"-Dotel.metrics.exporter=none",
128131
"-Dotel.logs.exporter=none",
129132
"-Dotel.exporter.otlp.protocol=http/protobuf",
130-
"-Dmockserver.logLevel=off"
133+
"-Dotel.javaagent.debug=false",
134+
"-Dmockserver.logLevel=trace"
131135
)
132136
}

gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/ConfigurableOption.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException;
99
import java.util.Locale;
10+
import java.util.Optional;
1011
import java.util.function.Supplier;
1112

1213
/**
@@ -101,4 +102,20 @@ String getConfiguredValueWithFallback(Supplier<String> fallback) {
101102
return fallback.get();
102103
}
103104
}
105+
106+
/**
107+
* Retrieves the value for this option, prioritizing environment variables before system
108+
* properties. If neither an environment variable nor a system property is set for this option,
109+
* then an empty {@link Optional} is returned.
110+
*
111+
* @return The configured value for the option, if set, obtained from the environment variable,
112+
* system property, or empty {@link Optional}, in that order of precedence.
113+
*/
114+
Optional<String> getConfiguredValueAsOptional() {
115+
try {
116+
return Optional.of(this.getConfiguredValue());
117+
} catch (ConfigurationException e) {
118+
return Optional.empty();
119+
}
120+
}
104121
}

gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProvider.java

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@
2020
import io.opentelemetry.sdk.resources.Resource;
2121
import io.opentelemetry.sdk.trace.export.SpanExporter;
2222
import java.io.IOException;
23-
import java.util.HashMap;
23+
import java.util.List;
2424
import java.util.Map;
25+
import java.util.Objects;
26+
import java.util.Optional;
27+
import java.util.stream.Collectors;
2528

2629
/**
2730
* An AutoConfigurationCustomizerProvider for Google Cloud Platform (GCP) OpenTelemetry (OTLP)
@@ -40,7 +43,7 @@
4043
public class GcpAuthAutoConfigurationCustomizerProvider
4144
implements AutoConfigurationCustomizerProvider {
4245

43-
static final String QUOTA_USER_PROJECT_HEADER = "X-Goog-User-Project";
46+
static final String QUOTA_USER_PROJECT_HEADER = "x-goog-user-project";
4447
static final String GCP_USER_PROJECT_ID_KEY = "gcp.project_id";
4548

4649
/**
@@ -95,20 +98,34 @@ private static SpanExporter addAuthorizationHeaders(
9598
}
9699

97100
private static Map<String, String> getRequiredHeaderMap(GoogleCredentials credentials) {
98-
Map<String, String> gcpHeaders = new HashMap<>();
101+
Map<String, List<String>> gcpHeaders;
99102
try {
100-
credentials.refreshIfExpired();
103+
// this also refreshes the credentials, if required
104+
gcpHeaders = credentials.getRequestMetadata();
101105
} catch (IOException e) {
102106
throw new GoogleAuthException(Reason.FAILED_ADC_REFRESH, e);
103107
}
104-
gcpHeaders.put("Authorization", "Bearer " + credentials.getAccessToken().getTokenValue());
105-
String configuredQuotaProjectId =
106-
ConfigurableOption.GOOGLE_CLOUD_QUOTA_PROJECT.getConfiguredValueWithFallback(
107-
credentials::getQuotaProjectId);
108-
if (configuredQuotaProjectId != null && !configuredQuotaProjectId.isEmpty()) {
109-
gcpHeaders.put(QUOTA_USER_PROJECT_HEADER, configuredQuotaProjectId);
108+
// flatten list
109+
Map<String, String> flattenedHeaders =
110+
gcpHeaders.entrySet().stream()
111+
.collect(
112+
Collectors.toMap(
113+
Map.Entry::getKey,
114+
entry ->
115+
entry.getValue().stream()
116+
.filter(Objects::nonNull) // Filter nulls
117+
.filter(s -> !s.isEmpty()) // Filter empty strings
118+
.collect(Collectors.joining(","))));
119+
// Add quota user project header if not detected by the auth library and user provided it via
120+
// system properties.
121+
if (!flattenedHeaders.containsKey(QUOTA_USER_PROJECT_HEADER)) {
122+
Optional<String> maybeConfiguredQuotaProjectId =
123+
ConfigurableOption.GOOGLE_CLOUD_QUOTA_PROJECT.getConfiguredValueAsOptional();
124+
maybeConfiguredQuotaProjectId.ifPresent(
125+
configuredQuotaProjectId ->
126+
flattenedHeaders.put(QUOTA_USER_PROJECT_HEADER, configuredQuotaProjectId));
110127
}
111-
return gcpHeaders;
128+
return flattenedHeaders;
112129
}
113130

114131
// Updates the current resource with the attributes required for ingesting OTLP data on GCP.

gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import io.opentelemetry.sdk.common.CompletableResultCode;
3636
import io.opentelemetry.sdk.trace.data.SpanData;
3737
import io.opentelemetry.sdk.trace.export.SpanExporter;
38+
import java.io.IOException;
3839
import java.time.Duration;
3940
import java.time.Instant;
4041
import java.util.AbstractMap.SimpleEntry;
@@ -214,26 +215,33 @@ public void testCustomizerFailWithMissingResourceProject() {
214215
@ParameterizedTest
215216
@MethodSource("provideQuotaBehaviorTestCases")
216217
@SuppressWarnings("CannotMockMethod")
217-
public void testQuotaProjectBehavior(QuotaProjectIdTestBehavior testCase) {
218+
public void testQuotaProjectBehavior(QuotaProjectIdTestBehavior testCase) throws IOException {
218219
// Set resource project system property
219220
System.setProperty(
220221
ConfigurableOption.GOOGLE_CLOUD_PROJECT.getSystemProperty(), DUMMY_GCP_RESOURCE_PROJECT_ID);
221-
// Configure mock credentials to return fake access token
222-
Mockito.when(mockedGoogleCredentials.getAccessToken())
223-
.thenReturn(new AccessToken("fake", Date.from(Instant.now())));
224-
225-
// To prevent unncecessary stubbings, mock getQuotaProjectId only when necessary
226-
if (testCase.getUserSpecifiedQuotaProjectId() == null
227-
|| testCase.getUserSpecifiedQuotaProjectId().isEmpty()) {
228-
String quotaProjectFromCredential =
229-
testCase.getIsQuotaProjectPresentInCredentials() ? DUMMY_GCP_QUOTA_PROJECT_ID : null;
230-
Mockito.when(mockedGoogleCredentials.getQuotaProjectId())
231-
.thenReturn(quotaProjectFromCredential);
222+
223+
// Prepare request metadata
224+
AccessToken fakeAccessToken = new AccessToken("fake", Date.from(Instant.now()));
225+
ImmutableMap<String, List<String>> mockedRequestMetadata;
226+
if (testCase.getIsQuotaProjectPresentInMetadata()) {
227+
mockedRequestMetadata =
228+
ImmutableMap.of(
229+
"Authorization",
230+
Collections.singletonList("Bearer " + fakeAccessToken.getTokenValue()),
231+
QUOTA_USER_PROJECT_HEADER,
232+
Collections.singletonList(DUMMY_GCP_QUOTA_PROJECT_ID));
233+
} else {
234+
mockedRequestMetadata =
235+
ImmutableMap.of(
236+
"Authorization",
237+
Collections.singletonList("Bearer " + fakeAccessToken.getTokenValue()));
232238
}
239+
// mock credentials to return the prepared request metadata
240+
Mockito.when(mockedGoogleCredentials.getRequestMetadata()).thenReturn(mockedRequestMetadata);
233241

234242
// configure environment according to test case
235243
String quotaProjectId = testCase.getUserSpecifiedQuotaProjectId(); // maybe empty string
236-
if (testCase.getUserSpecifiedQuotaProjectId() != null) {
244+
if (quotaProjectId != null) {
237245
// user specified a quota project id
238246
System.setProperty(
239247
ConfigurableOption.GOOGLE_CLOUD_QUOTA_PROJECT.getSystemProperty(), quotaProjectId);
@@ -288,58 +296,62 @@ public void testQuotaProjectBehavior(QuotaProjectIdTestBehavior testCase) {
288296
* indicates the expectation that the QUOTA_USER_PROJECT_HEADER should not be present in the
289297
* export headers.
290298
*
291-
* <p>{@code true} for {@link QuotaProjectIdTestBehavior#getIsQuotaProjectPresentInCredentials()}
299+
* <p>{@code true} for {@link QuotaProjectIdTestBehavior#getIsQuotaProjectPresentInMetadata()}
292300
* indicates that the mocked credentials are configured to provide DUMMY_GCP_QUOTA_PROJECT_ID as
293301
* the quota project ID.
294302
*/
295303
private static Stream<Arguments> provideQuotaBehaviorTestCases() {
296304
return Stream.of(
305+
// If quota project present in metadata, it will be used
297306
Arguments.of(
298307
QuotaProjectIdTestBehavior.builder()
299308
.setUserSpecifiedQuotaProjectId(DUMMY_GCP_QUOTA_PROJECT_ID)
300-
.setIsQuotaProjectPresentInCredentials(true)
309+
.setIsQuotaProjectPresentInMetadata(true)
301310
.setExpectedQuotaProjectInHeader(DUMMY_GCP_QUOTA_PROJECT_ID)
302311
.build()),
303312
Arguments.of(
304313
QuotaProjectIdTestBehavior.builder()
305-
.setUserSpecifiedQuotaProjectId(DUMMY_GCP_QUOTA_PROJECT_ID)
306-
.setIsQuotaProjectPresentInCredentials(false)
314+
.setUserSpecifiedQuotaProjectId("my-custom-quota-project-id")
315+
.setIsQuotaProjectPresentInMetadata(true)
307316
.setExpectedQuotaProjectInHeader(DUMMY_GCP_QUOTA_PROJECT_ID)
308317
.build()),
318+
// If quota project not present in request metadata, then user specified project is used
309319
Arguments.of(
310320
QuotaProjectIdTestBehavior.builder()
311-
.setUserSpecifiedQuotaProjectId("my-custom-quota-project-id")
312-
.setIsQuotaProjectPresentInCredentials(true)
313-
.setExpectedQuotaProjectInHeader("my-custom-quota-project-id")
321+
.setUserSpecifiedQuotaProjectId(DUMMY_GCP_QUOTA_PROJECT_ID)
322+
.setIsQuotaProjectPresentInMetadata(false)
323+
.setExpectedQuotaProjectInHeader(DUMMY_GCP_QUOTA_PROJECT_ID)
314324
.build()),
315325
Arguments.of(
316326
QuotaProjectIdTestBehavior.builder()
317327
.setUserSpecifiedQuotaProjectId("my-custom-quota-project-id")
318-
.setIsQuotaProjectPresentInCredentials(false)
328+
.setIsQuotaProjectPresentInMetadata(false)
319329
.setExpectedQuotaProjectInHeader("my-custom-quota-project-id")
320330
.build()),
331+
// Testing for special edge case inputs
332+
// user-specified quota project is empty
321333
Arguments.of(
322334
QuotaProjectIdTestBehavior.builder()
323335
.setUserSpecifiedQuotaProjectId("") // user explicitly specifies empty
324-
.setIsQuotaProjectPresentInCredentials(true)
336+
.setIsQuotaProjectPresentInMetadata(true)
325337
.setExpectedQuotaProjectInHeader(DUMMY_GCP_QUOTA_PROJECT_ID)
326338
.build()),
327339
Arguments.of(
328340
QuotaProjectIdTestBehavior.builder()
329-
.setUserSpecifiedQuotaProjectId(null) // user omits specifying quota project
330-
.setIsQuotaProjectPresentInCredentials(true)
331-
.setExpectedQuotaProjectInHeader(DUMMY_GCP_QUOTA_PROJECT_ID)
341+
.setUserSpecifiedQuotaProjectId("")
342+
.setIsQuotaProjectPresentInMetadata(false)
343+
.setExpectedQuotaProjectInHeader(null)
332344
.build()),
333345
Arguments.of(
334346
QuotaProjectIdTestBehavior.builder()
335-
.setUserSpecifiedQuotaProjectId("")
336-
.setIsQuotaProjectPresentInCredentials(false)
337-
.setExpectedQuotaProjectInHeader(null)
347+
.setUserSpecifiedQuotaProjectId(null) // user omits specifying quota project
348+
.setIsQuotaProjectPresentInMetadata(true)
349+
.setExpectedQuotaProjectInHeader(DUMMY_GCP_QUOTA_PROJECT_ID)
338350
.build()),
339351
Arguments.of(
340352
QuotaProjectIdTestBehavior.builder()
341353
.setUserSpecifiedQuotaProjectId(null)
342-
.setIsQuotaProjectPresentInCredentials(false)
354+
.setIsQuotaProjectPresentInMetadata(false)
343355
.setExpectedQuotaProjectInHeader(null)
344356
.build()));
345357
}
@@ -367,7 +379,7 @@ abstract static class QuotaProjectIdTestBehavior {
367379
@Nullable
368380
abstract String getUserSpecifiedQuotaProjectId();
369381

370-
abstract boolean getIsQuotaProjectPresentInCredentials();
382+
abstract boolean getIsQuotaProjectPresentInMetadata();
371383

372384
// If expected quota project in header is null, the header entry should not be present in export
373385
@Nullable
@@ -382,9 +394,15 @@ static Builder builder() {
382394
abstract static class Builder {
383395
abstract Builder setUserSpecifiedQuotaProjectId(String quotaProjectId);
384396

385-
abstract Builder setIsQuotaProjectPresentInCredentials(
386-
boolean quotaProjectPresentInCredentials);
397+
abstract Builder setIsQuotaProjectPresentInMetadata(boolean quotaProjectPresentInMetadata);
387398

399+
/**
400+
* Sets the expected quota project header value for the test case. A null value is allowed,
401+
* and it indicates that the header should not be present in the export request.
402+
*
403+
* @param expectedQuotaProjectInHeader the expected header value to match in the export
404+
* headers.
405+
*/
388406
abstract Builder setExpectedQuotaProjectInHeader(String expectedQuotaProjectInHeader);
389407

390408
abstract QuotaProjectIdTestBehavior build();
@@ -393,10 +411,18 @@ abstract Builder setIsQuotaProjectPresentInCredentials(
393411

394412
@SuppressWarnings("CannotMockMethod")
395413
private void prepareMockBehaviorForGoogleCredentials() {
396-
Mockito.when(mockedGoogleCredentials.getQuotaProjectId())
397-
.thenReturn(DUMMY_GCP_QUOTA_PROJECT_ID);
398-
Mockito.when(mockedGoogleCredentials.getAccessToken())
399-
.thenReturn(new AccessToken("fake", Date.from(Instant.now())));
414+
AccessToken fakeAccessToken = new AccessToken("fake", Date.from(Instant.now()));
415+
try {
416+
Mockito.when(mockedGoogleCredentials.getRequestMetadata())
417+
.thenReturn(
418+
ImmutableMap.of(
419+
"Authorization",
420+
Collections.singletonList("Bearer " + fakeAccessToken.getTokenValue()),
421+
QUOTA_USER_PROJECT_HEADER,
422+
Collections.singletonList(DUMMY_GCP_QUOTA_PROJECT_ID)));
423+
} catch (IOException e) {
424+
throw new RuntimeException(e);
425+
}
400426
}
401427

402428
private OpenTelemetrySdk buildOpenTelemetrySdkWithExporter(SpanExporter spanExporter) {

gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthExtensionEndToEndTest.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,9 @@ public static void setup() throws NoSuchAlgorithmException, KeyManagementExcepti
8989
// Set up mock OTLP backend server to which traces will be exported
9090
backendServer = ClientAndServer.startClientAndServer(EXPORTER_ENDPOINT_PORT);
9191
backendServer.when(request()).respond(response().withStatusCode(200));
92-
93-
// Set up the mock gcp metadata server to provide fake credentials
9492
String accessTokenResponse =
9593
"{\"access_token\": \"fake.access_token\",\"expires_in\": 3600, \"token_type\": \"Bearer\"}";
94+
9695
mockGcpOAuth2Server = ClientAndServer.startClientAndServer(MOCK_GCP_OAUTH2_PORT);
9796

9897
MockServerClient mockServerClient =
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"client_id": "....apps.googleusercontent.com",
3+
"client_secret": "...",
4+
"refresh_token": "1//...",
5+
"quota_project_id": "your-configured-quota-project",
6+
"type": "authorized_user"
7+
}

gcp-auth-extension/src/test/resources/fakecreds.json

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)