From 6bc5e3ead9bd71bf577a76e0329338b0ad6be957 Mon Sep 17 00:00:00 2001 From: Khooshi Asmi Date: Wed, 29 Jul 2026 04:24:48 +0000 Subject: [PATCH 1/4] Implement Core JWT Call Credentials (Task 1) --- auth/build.gradle | 3 +- .../auth/JwtTokenFileCallCredentials.java | 319 +++++++++++ .../auth/JwtTokenFileCallCredentialsTest.java | 526 ++++++++++++++++++ 3 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 auth/src/main/java/io/grpc/auth/JwtTokenFileCallCredentials.java create mode 100644 auth/src/test/java/io/grpc/auth/JwtTokenFileCallCredentialsTest.java diff --git a/auth/build.gradle b/auth/build.gradle index d56802c14ca..3b80fee476f 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -17,7 +17,8 @@ tasks.named("jar").configure { dependencies { api project(':grpc-api'), libraries.google.auth.credentials - implementation libraries.guava + implementation libraries.guava, + libraries.gson testImplementation project(':grpc-testing'), project(':grpc-core'), project(":grpc-context"), // Override google-auth dependency with our newer version diff --git a/auth/src/main/java/io/grpc/auth/JwtTokenFileCallCredentials.java b/auth/src/main/java/io/grpc/auth/JwtTokenFileCallCredentials.java new file mode 100644 index 00000000000..b7fae9f7fec --- /dev/null +++ b/auth/src/main/java/io/grpc/auth/JwtTokenFileCallCredentials.java @@ -0,0 +1,319 @@ +/* + * Copyright 2026 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.auth; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.annotations.VisibleForTesting; +import io.grpc.CallCredentials; +import io.grpc.Metadata; +import io.grpc.SecurityLevel; +import io.grpc.Status; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * A {@link CallCredentials} implementation that loads a JWT token from a file, + * parses it to extract its expiration time, and caches/refreshes it. + */ +public final class JwtTokenFileCallCredentials extends CallCredentials { + + private static final Logger log = Logger.getLogger(JwtTokenFileCallCredentials.class.getName()); + + private static final Metadata.Key AUTHORIZATION_HEADER = + Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER); + + private static final long INITIAL_BACKOFF_MS = 1000; + private static final long MAX_BACKOFF_MS = 60000; + private static final double BACKOFF_MULTIPLIER = 2.0; + + private final String filePath; + private final TimeProvider timeProvider; + private final Object lock = new Object(); + + private enum ReadState { + IDLE, + READING, + BACKOFF + } + + private String cachedToken; + private long expirationTimeMillis; + private ReadState readState = ReadState.IDLE; + private Status lastReadFailureStatus; + private long currentBackoffMs; + private long nextAttemptTimeMillis; + private final List queuedAppliers = new ArrayList<>(); + + interface TimeProvider { + long currentTimeMillis(); + } + + private static final TimeProvider SYSTEM_TIME_PROVIDER = new TimeProvider() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + }; + + public JwtTokenFileCallCredentials(String filePath) { + this(filePath, SYSTEM_TIME_PROVIDER); + } + + @VisibleForTesting + JwtTokenFileCallCredentials(String filePath, TimeProvider timeProvider) { + this.filePath = checkNotNull(filePath, "filePath"); + this.timeProvider = checkNotNull(timeProvider, "timeProvider"); + } + + @Override + public void applyRequestMetadata( + RequestInfo requestInfo, Executor appExecutor, MetadataApplier applier) { + checkNotNull(requestInfo, "requestInfo"); + checkNotNull(appExecutor, "appExecutor"); + checkNotNull(applier, "applier"); + + if (requestInfo.getSecurityLevel() != SecurityLevel.PRIVACY_AND_INTEGRITY) { + applier.fail(Status.UNAUTHENTICATED + .withDescription("Channel security level is not PRIVACY_AND_INTEGRITY")); + return; + } + + long now = timeProvider.currentTimeMillis(); + TokenInfo tokenToApply = null; + boolean triggerRead = false; + Status failStatus = null; + + synchronized (lock) { + if (readState == ReadState.BACKOFF && now >= nextAttemptTimeMillis) { + readState = ReadState.IDLE; + } + + if (readState == ReadState.BACKOFF) { + failStatus = lastReadFailureStatus != null ? lastReadFailureStatus : Status.UNAVAILABLE; + } else { + boolean hasValidCache = cachedToken != null && now < expirationTimeMillis; + boolean expiringSoon = hasValidCache && (expirationTimeMillis - now <= 60000); + + if (hasValidCache) { + tokenToApply = new TokenInfo(cachedToken, expirationTimeMillis); + if (expiringSoon && readState == ReadState.IDLE) { + readState = ReadState.READING; + triggerRead = true; + } + } else { + if (readState == ReadState.IDLE) { + readState = ReadState.READING; + triggerRead = true; + } + queuedAppliers.add(applier); + } + } + } + + if (failStatus != null) { + applier.fail(failStatus); + return; + } + + if (tokenToApply != null) { + Metadata headers = new Metadata(); + headers.put(AUTHORIZATION_HEADER, "Bearer " + tokenToApply.token); + applier.apply(headers); + } + + if (triggerRead) { + try { + appExecutor.execute(new Runnable() { + @Override + public void run() { + loadToken(); + } + }); + } catch (java.util.concurrent.RejectedExecutionException e) { + handleExecutorRejection(e, tokenToApply != null); + } + } + } + + private void handleExecutorRejection( + java.util.concurrent.RejectedExecutionException e, boolean isBackgroundReload) { + log.log(Level.WARNING, "Executor rejected token read task", e); + List appliersToFail = new ArrayList<>(); + synchronized (lock) { + readState = ReadState.IDLE; + if (!isBackgroundReload) { + appliersToFail.addAll(queuedAppliers); + queuedAppliers.clear(); + } + } + for (MetadataApplier applier : appliersToFail) { + try { + applier.fail(Status.UNAVAILABLE + .withDescription("Executor rejected token read task") + .withCause(e)); + } catch (Throwable t) { + log.log(Level.WARNING, "Error calling fail on applier", t); + } + } + } + + private void loadToken() { + TokenInfo tokenInfo = null; + Status status = null; + try { + tokenInfo = readAndParseTokenFile(); + } catch (IOException e) { + status = Status.UNAVAILABLE + .withDescription("Failed to read token file") + .withCause(e); + } catch (IllegalArgumentException e) { + status = Status.UNAUTHENTICATED + .withDescription("Malformed token or invalid claims") + .withCause(e); + } catch (Throwable e) { + status = Status.UNAVAILABLE + .withDescription("Unexpected error loading token") + .withCause(e); + } + + List appliersToApply = new ArrayList<>(); + List appliersToFail = new ArrayList<>(); + + synchronized (lock) { + if (status == null) { + cachedToken = tokenInfo.token; + expirationTimeMillis = tokenInfo.expirationTimeMillis; + readState = ReadState.IDLE; + lastReadFailureStatus = null; + currentBackoffMs = 0; + nextAttemptTimeMillis = 0; + + appliersToApply.addAll(queuedAppliers); + queuedAppliers.clear(); + } else { + lastReadFailureStatus = status; + readState = ReadState.BACKOFF; + + if (currentBackoffMs == 0) { + currentBackoffMs = INITIAL_BACKOFF_MS; + } else { + currentBackoffMs = Math.min( + (long) (currentBackoffMs * BACKOFF_MULTIPLIER), MAX_BACKOFF_MS); + } + nextAttemptTimeMillis = timeProvider.currentTimeMillis() + currentBackoffMs; + + appliersToFail.addAll(queuedAppliers); + queuedAppliers.clear(); + } + } + + if (status == null) { + Metadata headers = new Metadata(); + headers.put(AUTHORIZATION_HEADER, "Bearer " + tokenInfo.token); + for (MetadataApplier applier : appliersToApply) { + try { + applier.apply(headers); + } catch (Throwable t) { + log.log(Level.WARNING, "Error applying credentials", t); + } + } + } else { + log.log(Level.WARNING, "Failed to load token: " + status.getDescription(), status.getCause()); + for (MetadataApplier applier : appliersToFail) { + try { + applier.fail(status); + } catch (Throwable t) { + log.log(Level.WARNING, "Error calling fail on applier", t); + } + } + } + } + + private TokenInfo readAndParseTokenFile() throws IOException { + File file = new File(filePath); + long length = file.length(); + if (length > 1048576) { + throw new IOException("File size exceeds 1 MB limit: " + length); + } + byte[] bytes = com.google.common.io.Files.toByteArray(file); + if (bytes.length > 1048576) { + throw new IOException("File size exceeds 1 MB limit: " + bytes.length); + } + String token = new String(bytes, StandardCharsets.UTF_8).trim(); + if (token.isEmpty()) { + throw new IllegalArgumentException("Token file is empty"); + } + String[] segments = token.split("\\.", -1); + if (segments.length != 3) { + throw new IllegalArgumentException("JWT must have 3 segments"); + } + byte[] payloadBytes; + try { + payloadBytes = com.google.common.io.BaseEncoding.base64Url() + .omitPadding().decode(segments[1]); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid Base64URL encoding in payload", e); + } + String payloadJson = new String(payloadBytes, StandardCharsets.UTF_8); + com.google.gson.JsonObject jsonObject; + try { + com.google.gson.JsonElement jsonElement = com.google.gson.JsonParser.parseString(payloadJson); + if (!jsonElement.isJsonObject()) { + throw new IllegalArgumentException("Payload is not a JSON object"); + } + jsonObject = jsonElement.getAsJsonObject(); + } catch (com.google.gson.JsonSyntaxException e) { + throw new IllegalArgumentException("Invalid JSON payload", e); + } + if (!jsonObject.has("exp")) { + throw new IllegalArgumentException("Payload does not contain 'exp' claim"); + } + com.google.gson.JsonElement expElement = jsonObject.get("exp"); + if (!expElement.isJsonPrimitive() || !expElement.getAsJsonPrimitive().isNumber()) { + throw new IllegalArgumentException("'exp' claim is not a number"); + } + long expSeconds = expElement.getAsLong(); + if (expSeconds <= 0) { + throw new IllegalArgumentException("Invalid 'exp' claim value: " + expSeconds); + } + long expirationTimeMillis = (expSeconds - 30) * 1000; + return new TokenInfo(token, expirationTimeMillis); + } + + @Override + @SuppressWarnings("deprecation") + public void thisUsesUnstableApi() { + // Yes + } + + private static class TokenInfo { + final String token; + final long expirationTimeMillis; + + TokenInfo(String token, long expirationTimeMillis) { + this.token = token; + this.expirationTimeMillis = expirationTimeMillis; + } + } +} diff --git a/auth/src/test/java/io/grpc/auth/JwtTokenFileCallCredentialsTest.java b/auth/src/test/java/io/grpc/auth/JwtTokenFileCallCredentialsTest.java new file mode 100644 index 00000000000..da01cd989f2 --- /dev/null +++ b/auth/src/test/java/io/grpc/auth/JwtTokenFileCallCredentialsTest.java @@ -0,0 +1,526 @@ +/* + * Copyright 2026 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.auth; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import io.grpc.Attributes; +import io.grpc.CallCredentials; +import io.grpc.CallCredentials.MetadataApplier; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.SecurityLevel; +import io.grpc.Status; +import io.grpc.testing.TestMethodDescriptors; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import org.junit.After; +import org.junit.Before; +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.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +@RunWith(JUnit4.class) +public class JwtTokenFileCallCredentialsTest { + + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); + @Rule public final TemporaryFolder tempFolder = new TemporaryFolder(); + + @Mock private MetadataApplier applier1; + @Mock private MetadataApplier applier2; + + @Captor private ArgumentCaptor headersCaptor; + @Captor private ArgumentCaptor statusCaptor; + + private static final Metadata.Key AUTHORIZATION_HEADER = + Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER); + + private FakeTimeProvider timeProvider; + private FakeExecutor executor; + + @Before + public void setUp() { + timeProvider = new FakeTimeProvider(); + executor = new FakeExecutor(); + } + + @After + public void tearDown() { + assertEquals(0, executor.runnables.size()); + } + + private String createJwtToken(long expSeconds) { + String header = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"; // {"alg":"RS256","typ":"JWT"} + String payload = com.google.common.io.BaseEncoding.base64Url().omitPadding().encode( + ("{\"exp\":" + expSeconds + "}").getBytes(StandardCharsets.UTF_8)); + String signature = "signature"; + return header + "." + payload + "." + signature; + } + + private File writeTokenToFile(String content) throws IOException { + File file = tempFolder.newFile(); + java.io.FileOutputStream fos = new java.io.FileOutputStream(file); + try { + fos.write(content.getBytes(StandardCharsets.UTF_8)); + } finally { + fos.close(); + } + return file; + } + + private void updateTokenFile(File file, String content) throws IOException { + java.io.FileOutputStream fos = new java.io.FileOutputStream(file); + try { + fos.write(content.getBytes(StandardCharsets.UTF_8)); + } finally { + fos.close(); + } + } + + @Test + public void applyMetadata_insecureChannel_fails() throws Exception { + long nowSecs = timeProvider.currentTimeMillis() / 1000; + File tokenFile = writeTokenToFile(createJwtToken(nowSecs + 1000)); + JwtTokenFileCallCredentials credentials = + new JwtTokenFileCallCredentials(tokenFile.getAbsolutePath(), timeProvider); + + RequestInfoImpl requestInfo = new RequestInfoImpl(SecurityLevel.NONE); + credentials.applyRequestMetadata(requestInfo, executor, applier1); + + verify(applier1).fail(statusCaptor.capture()); + Status status = statusCaptor.getValue(); + assertEquals(Status.Code.UNAUTHENTICATED, status.getCode()); + assertTrue(status.getDescription() + .contains("Channel security level is not PRIVACY_AND_INTEGRITY")); + } + + @Test + public void applyMetadata_validCachedToken_cacheHit() throws Exception { + String token = createJwtToken(timeProvider.currentTimeMillis() / 1000 + 1000); + File tokenFile = writeTokenToFile(token); + JwtTokenFileCallCredentials credentials = + new JwtTokenFileCallCredentials(tokenFile.getAbsolutePath(), timeProvider); + + RequestInfoImpl requestInfo = new RequestInfoImpl(SecurityLevel.PRIVACY_AND_INTEGRITY); + + // First load to populate the cache + credentials.applyRequestMetadata(requestInfo, executor, applier1); + assertEquals(1, executor.runnables.size()); + executor.runNext(); + + verify(applier1).apply(headersCaptor.capture()); + assertEquals("Bearer " + token, headersCaptor.getValue().get(AUTHORIZATION_HEADER)); + + // Second load - should be synchronous cache hit + credentials.applyRequestMetadata(requestInfo, executor, applier2); + // Executor should NOT have any new runnables + assertEquals(0, executor.runnables.size()); + + verify(applier2).apply(headersCaptor.capture()); + assertEquals("Bearer " + token, headersCaptor.getValue().get(AUTHORIZATION_HEADER)); + } + + @Test + public void applyMetadata_tokenExpiringSoon_triggersBackgroundRefresh() throws Exception { + timeProvider.set(100_000); + // exp = 180 seconds, meaning expirationTimeMillis = (180-30)*1000 = 150_000. + String firstToken = createJwtToken(180); + File tokenFile = writeTokenToFile(firstToken); + + JwtTokenFileCallCredentials credentials = + new JwtTokenFileCallCredentials(tokenFile.getAbsolutePath(), timeProvider); + + RequestInfoImpl requestInfo = new RequestInfoImpl(SecurityLevel.PRIVACY_AND_INTEGRITY); + + // First load to populate the cache + credentials.applyRequestMetadata(requestInfo, executor, applier1); + assertEquals(1, executor.runnables.size()); + executor.runNext(); + + verify(applier1).apply(headersCaptor.capture()); + assertEquals("Bearer " + firstToken, headersCaptor.getValue().get(AUTHORIZATION_HEADER)); + + // Update the token file with a new token + // exp = 1000 seconds, meaning expirationTimeMillis = 970_000. + String secondToken = createJwtToken(1000); + updateTokenFile(tokenFile, secondToken); + + // Call apply again. Time hasn't changed (still 100_000), so firstToken is valid + // (expires at 150_000) but expiring soon (150_000 - 100_000 = 50_000 <= 60_000). + // It should synchronously apply firstToken and queue a background read. + credentials.applyRequestMetadata(requestInfo, executor, applier2); + + // Check that it synchronously applied the first token + verify(applier2).apply(headersCaptor.capture()); + assertEquals("Bearer " + firstToken, headersCaptor.getValue().get(AUTHORIZATION_HEADER)); + + // And triggered a background read + assertEquals(1, executor.runnables.size()); + executor.runNext(); + + // Now, a subsequent call should return the second (newly cached) token synchronously! + MetadataApplier applier3 = Mockito.mock(MetadataApplier.class); + credentials.applyRequestMetadata(requestInfo, executor, applier3); + assertEquals(0, executor.runnables.size()); + + ArgumentCaptor headersCaptor3 = ArgumentCaptor.forClass(Metadata.class); + verify(applier3).apply(headersCaptor3.capture()); + assertEquals("Bearer " + secondToken, headersCaptor3.getValue().get(AUTHORIZATION_HEADER)); + } + + @Test + public void applyMetadata_concurrentCalls_queued() throws Exception { + String token = createJwtToken(timeProvider.currentTimeMillis() / 1000 + 1000); + File tokenFile = writeTokenToFile(token); + JwtTokenFileCallCredentials credentials = + new JwtTokenFileCallCredentials(tokenFile.getAbsolutePath(), timeProvider); + + RequestInfoImpl requestInfo = new RequestInfoImpl(SecurityLevel.PRIVACY_AND_INTEGRITY); + + // First call starts loading + credentials.applyRequestMetadata(requestInfo, executor, applier1); + assertEquals(1, executor.runnables.size()); + + // Second call while loading is in progress + credentials.applyRequestMetadata(requestInfo, executor, applier2); + // Executor should still have only 1 runnable + assertEquals(1, executor.runnables.size()); + + // Neither applier should have received a token or failed yet + verify(applier1, never()).apply(any()); + verify(applier1, never()).fail(any()); + verify(applier2, never()).apply(any()); + verify(applier2, never()).fail(any()); + + // Run the loader runnable + executor.runNext(); + + // Both appliers should now be successfully invoked + verify(applier1).apply(headersCaptor.capture()); + assertEquals("Bearer " + token, headersCaptor.getValue().get(AUTHORIZATION_HEADER)); + + ArgumentCaptor headersCaptor2 = ArgumentCaptor.forClass(Metadata.class); + verify(applier2).apply(headersCaptor2.capture()); + assertEquals("Bearer " + token, headersCaptor2.getValue().get(AUTHORIZATION_HEADER)); + } + + @Test + public void applyMetadata_fileNotFound_failsUnavailable() throws Exception { + JwtTokenFileCallCredentials credentials = + new JwtTokenFileCallCredentials( + tempFolder.getRoot().getAbsolutePath() + "/non-existent.txt", timeProvider); + + RequestInfoImpl requestInfo = new RequestInfoImpl(SecurityLevel.PRIVACY_AND_INTEGRITY); + + credentials.applyRequestMetadata(requestInfo, executor, applier1); + assertEquals(1, executor.runnables.size()); + executor.runNext(); + + verify(applier1).fail(statusCaptor.capture()); + Status status = statusCaptor.getValue(); + assertEquals(Status.Code.UNAVAILABLE, status.getCode()); + assertTrue(status.getCause() instanceof IOException); + } + + @Test + public void applyMetadata_fileReadError_backoff() throws Exception { + JwtTokenFileCallCredentials credentials = + new JwtTokenFileCallCredentials( + tempFolder.getRoot().getAbsolutePath() + "/non-existent.txt", timeProvider); + + RequestInfoImpl requestInfo = new RequestInfoImpl(SecurityLevel.PRIVACY_AND_INTEGRITY); + + // 1. First attempt fails + timeProvider.set(10000); + credentials.applyRequestMetadata(requestInfo, executor, applier1); + assertEquals(1, executor.runnables.size()); + executor.runNext(); + + verify(applier1).fail(statusCaptor.capture()); + Status firstStatus = statusCaptor.getValue(); + assertEquals(Status.Code.UNAVAILABLE, firstStatus.getCode()); + + // 2. Call again before backoff expires (at t=10500) + timeProvider.set(10500); + credentials.applyRequestMetadata(requestInfo, executor, applier2); + // Should fail synchronously (fail-fast) + verify(applier2).fail(statusCaptor.capture()); + Status secondStatus = statusCaptor.getValue(); + assertEquals(Status.Code.UNAVAILABLE, secondStatus.getCode()); + assertEquals(firstStatus.getDescription(), secondStatus.getDescription()); + assertEquals(firstStatus.getCause(), secondStatus.getCause()); + // Executor should NOT have been invoked + assertEquals(0, executor.runnables.size()); + + // 3. Move time past backoff limit (t=11001) + timeProvider.set(11001); + MetadataApplier applier3 = Mockito.mock(MetadataApplier.class); + credentials.applyRequestMetadata(requestInfo, executor, applier3); + // Should NOT fail fast. Instead, it should trigger a new attempt. + assertEquals(1, executor.runnables.size()); + // Clean up the task from executor list + executor.runNext(); + } + + @Test + public void applyMetadata_malformedJwt_failsUnauthenticated() throws Exception { + RequestInfoImpl requestInfo = new RequestInfoImpl(SecurityLevel.PRIVACY_AND_INTEGRITY); + + // Case 1: Invalid segment count + File tokenFile1 = writeTokenToFile("header.payload"); // Only 2 segments + JwtTokenFileCallCredentials credentials1 = + new JwtTokenFileCallCredentials(tokenFile1.getAbsolutePath(), timeProvider); + credentials1.applyRequestMetadata(requestInfo, executor, applier1); + assertEquals(1, executor.runnables.size()); + executor.runNext(); + verify(applier1).fail(statusCaptor.capture()); + assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor.getValue().getCode()); + assertTrue(statusCaptor.getValue().getDescription().contains("Malformed token")); + + // Case 2: Invalid base64url payload segment + File tokenFile2 = writeTokenToFile("header.invalid_base64_symbols#$%.signature"); + JwtTokenFileCallCredentials credentials2 = + new JwtTokenFileCallCredentials(tokenFile2.getAbsolutePath(), timeProvider); + MetadataApplier applier2 = Mockito.mock(MetadataApplier.class); + ArgumentCaptor statusCaptor2 = ArgumentCaptor.forClass(Status.class); + credentials2.applyRequestMetadata(requestInfo, executor, applier2); + assertEquals(1, executor.runnables.size()); + executor.runNext(); + verify(applier2).fail(statusCaptor2.capture()); + assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor2.getValue().getCode()); + assertTrue(statusCaptor2.getValue().getDescription().contains("Malformed token")); + } + + @Test + public void applyMetadata_missingExpClaim_failsUnauthenticated() throws Exception { + RequestInfoImpl requestInfo = new RequestInfoImpl(SecurityLevel.PRIVACY_AND_INTEGRITY); + + // Case 1: Missing exp claim + String header = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"; + String payloadNoExp = com.google.common.io.BaseEncoding.base64Url().omitPadding().encode( + "{\"foo\":\"bar\"}".getBytes(StandardCharsets.UTF_8)); + File tokenFile1 = writeTokenToFile(header + "." + payloadNoExp + ".signature"); + JwtTokenFileCallCredentials credentials1 = + new JwtTokenFileCallCredentials(tokenFile1.getAbsolutePath(), timeProvider); + credentials1.applyRequestMetadata(requestInfo, executor, applier1); + assertEquals(1, executor.runnables.size()); + executor.runNext(); + verify(applier1).fail(statusCaptor.capture()); + assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor.getValue().getCode()); + assertTrue(statusCaptor.getValue().getDescription() + .contains("Malformed token or invalid claims")); + + // Case 2: exp is not a number + String payloadExpString = com.google.common.io.BaseEncoding.base64Url().omitPadding().encode( + "{\"exp\":\"not-a-number\"}".getBytes(StandardCharsets.UTF_8)); + File tokenFile2 = writeTokenToFile(header + "." + payloadExpString + ".signature"); + JwtTokenFileCallCredentials credentials2 = + new JwtTokenFileCallCredentials(tokenFile2.getAbsolutePath(), timeProvider); + MetadataApplier applier2 = Mockito.mock(MetadataApplier.class); + ArgumentCaptor statusCaptor2 = ArgumentCaptor.forClass(Status.class); + credentials2.applyRequestMetadata(requestInfo, executor, applier2); + assertEquals(1, executor.runnables.size()); + executor.runNext(); + verify(applier2).fail(statusCaptor2.capture()); + assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor2.getValue().getCode()); + assertTrue(statusCaptor2.getValue().getDescription() + .contains("Malformed token or invalid claims")); + + // Case 3: exp is <= 0 + String payloadExpNegative = com.google.common.io.BaseEncoding.base64Url().omitPadding().encode( + "{\"exp\":-10}".getBytes(StandardCharsets.UTF_8)); + File tokenFile3 = writeTokenToFile(header + "." + payloadExpNegative + ".signature"); + JwtTokenFileCallCredentials credentials3 = + new JwtTokenFileCallCredentials(tokenFile3.getAbsolutePath(), timeProvider); + MetadataApplier applier3 = Mockito.mock(MetadataApplier.class); + ArgumentCaptor statusCaptor3 = ArgumentCaptor.forClass(Status.class); + credentials3.applyRequestMetadata(requestInfo, executor, applier3); + assertEquals(1, executor.runnables.size()); + executor.runNext(); + verify(applier3).fail(statusCaptor3.capture()); + assertEquals(Status.Code.UNAUTHENTICATED, statusCaptor3.getValue().getCode()); + assertTrue(statusCaptor3.getValue().getDescription() + .contains("Malformed token or invalid claims")); + } + + @Test + public void applyMetadata_fileTooLarge_failsUnavailable() throws Exception { + byte[] largeContent = new byte[1048577]; + java.util.Arrays.fill(largeContent, (byte) 'a'); + File tokenFile = tempFolder.newFile("large-token.txt"); + java.io.FileOutputStream fos = new java.io.FileOutputStream(tokenFile); + try { + fos.write(largeContent); + } finally { + fos.close(); + } + + JwtTokenFileCallCredentials credentials = + new JwtTokenFileCallCredentials(tokenFile.getAbsolutePath(), timeProvider); + + RequestInfoImpl requestInfo = new RequestInfoImpl(SecurityLevel.PRIVACY_AND_INTEGRITY); + credentials.applyRequestMetadata(requestInfo, executor, applier1); + assertEquals(1, executor.runnables.size()); + executor.runNext(); + + verify(applier1).fail(statusCaptor.capture()); + Status status = statusCaptor.getValue(); + assertEquals(Status.Code.UNAVAILABLE, status.getCode()); + assertTrue(status.getDescription().contains("Failed to read token file")); + } + + @Test + public void applyMetadata_executorRejection_failsUnavailable() throws Exception { + long nowSecs = timeProvider.currentTimeMillis() / 1000; + File tokenFile = writeTokenToFile(createJwtToken(nowSecs + 1000)); + JwtTokenFileCallCredentials credentials = + new JwtTokenFileCallCredentials(tokenFile.getAbsolutePath(), timeProvider); + + RequestInfoImpl requestInfo = new RequestInfoImpl(SecurityLevel.PRIVACY_AND_INTEGRITY); + + Executor rejectingExecutor = new Executor() { + @Override + public void execute(Runnable command) { + throw new RejectedExecutionException("Rejected!"); + } + }; + + credentials.applyRequestMetadata(requestInfo, rejectingExecutor, applier1); + + assertEquals(0, executor.runnables.size()); + + verify(applier1).fail(statusCaptor.capture()); + Status status = statusCaptor.getValue(); + assertEquals(Status.Code.UNAVAILABLE, status.getCode()); + assertTrue(status.getDescription().contains("Executor rejected token read task")); + assertTrue(status.getCause() instanceof RejectedExecutionException); + } + + @Test + public void applyMetadata_executorRejection_duringBackgroundRefresh_doesNotFail() + throws Exception { + timeProvider.set(100_000); + String firstToken = createJwtToken(180); + File tokenFile = writeTokenToFile(firstToken); + + JwtTokenFileCallCredentials credentials = + new JwtTokenFileCallCredentials(tokenFile.getAbsolutePath(), timeProvider); + + RequestInfoImpl requestInfo = new RequestInfoImpl(SecurityLevel.PRIVACY_AND_INTEGRITY); + + // First load to populate the cache + credentials.applyRequestMetadata(requestInfo, executor, applier1); + assertEquals(1, executor.runnables.size()); + executor.runNext(); + verify(applier1).apply(any()); + + Executor rejectingExecutor = new Executor() { + @Override + public void execute(Runnable command) { + throw new RejectedExecutionException("Rejected!"); + } + }; + + // Second call triggers background refresh. + // It should synchronously apply the cached token, and attempt background refresh. + // The background refresh fails to execute, but the applier should succeed! + credentials.applyRequestMetadata(requestInfo, rejectingExecutor, applier2); + + verify(applier2).apply(headersCaptor.capture()); + assertEquals("Bearer " + firstToken, headersCaptor.getValue().get(AUTHORIZATION_HEADER)); + verify(applier2, never()).fail(any()); + } + + private static class FakeTimeProvider implements JwtTokenFileCallCredentials.TimeProvider { + private long currentTimeMillis = 0; + + @Override + public long currentTimeMillis() { + return currentTimeMillis; + } + + void set(long timeMillis) { + currentTimeMillis = timeMillis; + } + } + + private static class FakeExecutor implements Executor { + private final List runnables = new ArrayList<>(); + + @Override + public void execute(Runnable command) { + runnables.add(command); + } + + void runNext() { + if (runnables.isEmpty()) { + throw new IllegalStateException("No runnables queued"); + } + runnables.remove(0).run(); + } + } + + private static final class RequestInfoImpl extends CallCredentials.RequestInfo { + private final SecurityLevel securityLevel; + + RequestInfoImpl(SecurityLevel securityLevel) { + this.securityLevel = securityLevel; + } + + @Override + public MethodDescriptor getMethodDescriptor() { + return MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNKNOWN) + .setFullMethodName("a.service/method") + .setRequestMarshaller(TestMethodDescriptors.voidMarshaller()) + .setResponseMarshaller(TestMethodDescriptors.voidMarshaller()) + .build(); + } + + @Override + public SecurityLevel getSecurityLevel() { + return securityLevel; + } + + @Override + public String getAuthority() { + return "testauthority"; + } + + @Override + public Attributes getTransportAttrs() { + return Attributes.EMPTY; + } + } +} From 86b77fb5585ae7a1d2697c4dc45348b1bc0a6fa6 Mon Sep 17 00:00:00 2001 From: Khooshi Asmi Date: Wed, 29 Jul 2026 04:45:33 +0000 Subject: [PATCH 2/4] Implement xDS Bootstrap Parsing and Transport Integration (Task 2) --- .../io/grpc/xds/GrpcXdsTransportFactory.java | 10 +- .../java/io/grpc/xds/client/Bootstrapper.java | 17 +- .../io/grpc/xds/client/BootstrapperImpl.java | 53 ++++- .../io/grpc/xds/GrpcBootstrapperImplTest.java | 202 ++++++++++++++++++ .../grpc/xds/GrpcXdsTransportFactoryTest.java | 64 ++++++ 5 files changed, 342 insertions(+), 4 deletions(-) diff --git a/xds/src/main/java/io/grpc/xds/GrpcXdsTransportFactory.java b/xds/src/main/java/io/grpc/xds/GrpcXdsTransportFactory.java index 3db088435f9..13ff70432ed 100644 --- a/xds/src/main/java/io/grpc/xds/GrpcXdsTransportFactory.java +++ b/xds/src/main/java/io/grpc/xds/GrpcXdsTransportFactory.java @@ -24,6 +24,7 @@ import io.grpc.ChannelConfigurator; import io.grpc.ChannelCredentials; import io.grpc.ClientCall; +import io.grpc.CompositeCallCredentials; import io.grpc.Context; import io.grpc.Grpc; import io.grpc.ManagedChannel; @@ -87,7 +88,14 @@ public GrpcXdsTransport(Bootstrapper.ServerInfo serverInfo, channelBuilder.childChannelConfigurator(channelConfigurator); } this.channel = channelBuilder.build(); - this.callCredentials = callCredentials; + if (callCredentials != null && serverInfo.callCredentials() != null) { + this.callCredentials = new CompositeCallCredentials( + callCredentials, serverInfo.callCredentials()); + } else if (serverInfo.callCredentials() != null) { + this.callCredentials = serverInfo.callCredentials(); + } else { + this.callCredentials = callCredentials; + } } @VisibleForTesting diff --git a/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java b/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java index b8d6444e3b3..41093c9b807 100644 --- a/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java +++ b/xds/src/main/java/io/grpc/xds/client/Bootstrapper.java @@ -22,6 +22,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import io.grpc.CallCredentials; import io.grpc.Internal; import io.grpc.xds.client.EnvoyProtoData.Node; import java.util.List; @@ -68,10 +69,12 @@ public abstract static class ServerInfo { public abstract boolean failOnDataErrors(); + @Nullable public abstract CallCredentials callCredentials(); + @VisibleForTesting public static ServerInfo create(String target, @Nullable Object implSpecificConfig) { return new AutoValue_Bootstrapper_ServerInfo(target, implSpecificConfig, - false, false, false, false); + false, false, false, false, null); } @VisibleForTesting @@ -81,7 +84,17 @@ public static ServerInfo create( boolean resourceTimerIsTransientError, boolean failOnDataErrors) { return new AutoValue_Bootstrapper_ServerInfo(target, implSpecificConfig, ignoreResourceDeletion, isTrustedXdsServer, - resourceTimerIsTransientError, failOnDataErrors); + resourceTimerIsTransientError, failOnDataErrors, null); + } + + public static ServerInfo create( + String target, Object implSpecificConfig, + boolean ignoreResourceDeletion, boolean isTrustedXdsServer, + boolean resourceTimerIsTransientError, boolean failOnDataErrors, + @Nullable CallCredentials callCredentials) { + return new AutoValue_Bootstrapper_ServerInfo(target, implSpecificConfig, + ignoreResourceDeletion, isTrustedXdsServer, + resourceTimerIsTransientError, failOnDataErrors, callCredentials); } } diff --git a/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java b/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java index 3f4ea8eb5c6..247c0c51239 100644 --- a/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java +++ b/xds/src/main/java/io/grpc/xds/client/BootstrapperImpl.java @@ -19,8 +19,11 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import io.grpc.CallCredentials; +import io.grpc.CompositeCallCredentials; import io.grpc.Internal; import io.grpc.InternalLogId; +import io.grpc.auth.JwtTokenFileCallCredentials; import io.grpc.internal.GrpcUtil; import io.grpc.internal.GrpcUtil.GrpcBuildVersion; import io.grpc.internal.JsonParser; @@ -31,6 +34,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -65,6 +69,10 @@ public abstract class BootstrapperImpl extends Bootstrapper { @VisibleForTesting static boolean enableXdsFallback = GrpcUtil.getFlag(GRPC_EXPERIMENTAL_XDS_FALLBACK, true); + @VisibleForTesting + static boolean enableXdsBootstrapCallCreds = GrpcUtil.getFlag( + "GRPC_EXPERIMENTAL_XDS_BOOTSTRAP_CALL_CREDS", false); + @VisibleForTesting public static boolean xdsDataErrorHandlingEnabled = GrpcUtil.getFlag(GRPC_EXPERIMENTAL_XDS_DATA_ERROR_HANDLING, false); @@ -283,15 +291,58 @@ private List parseServerInfos(List rawServerConfigs, XdsLogger lo failOnDataErrors = xdsDataErrorHandlingEnabled && serverFeatures.contains(SERVER_FEATURE_FAIL_ON_DATA_ERRORS); } + CallCredentials callCredentials = null; + List rawCallCreds = JsonUtil.getList(serverConfig, "call_creds"); + if (enableXdsBootstrapCallCreds && rawCallCreds != null) { + List> callCredsList = JsonUtil.checkObjectList(rawCallCreds); + callCredentials = parseCallCredentials(callCredsList, serverUri); + } servers.add( ServerInfo.create(serverUri, implSpecificConfig, ignoreResourceDeletion, serverFeatures != null && serverFeatures.contains(SERVER_FEATURE_TRUSTED_XDS_SERVER), - resourceTimerIsTransientError, failOnDataErrors)); + resourceTimerIsTransientError, failOnDataErrors, callCredentials)); } return servers.build(); } + @Nullable + private CallCredentials parseCallCredentials(List> jsonList, String serverUri) + throws XdsInitializationException { + List parsedCreds = new ArrayList<>(); + for (Map credJson : jsonList) { + String type = JsonUtil.getString(credJson, "type"); + if (type == null) { + throw new XdsInitializationException( + "Invalid bootstrap: server " + serverUri + " with 'call_creds' type unspecified"); + } + if ("jwt_token_file".equals(type)) { + Map config = JsonUtil.getObject(credJson, "config"); + if (config == null) { + throw new XdsInitializationException( + "Invalid bootstrap: server " + serverUri + " with 'jwt_token_file' config missing"); + } + String tokenFile = JsonUtil.getString(config, "token_file"); + if (tokenFile == null || tokenFile.isEmpty()) { + throw new XdsInitializationException( + "Invalid bootstrap: server " + serverUri + + " with 'jwt_token_file' token_file missing or empty"); + } + parsedCreds.add(new JwtTokenFileCallCredentials(tokenFile)); + } else { + logger.log(XdsLogLevel.INFO, "Skipping unsupported call credential type: {0}", type); + } + } + if (parsedCreds.isEmpty()) { + return null; + } + CallCredentials combined = parsedCreds.get(0); + for (int i = 1; i < parsedCreds.size(); i++) { + combined = new CompositeCallCredentials(combined, parsedCreds.get(i)); + } + return combined; + } + @VisibleForTesting public void setFileReader(FileReader reader) { this.reader = reader; diff --git a/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java b/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java index d4ee4159bc2..84b9430dad8 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java +++ b/xds/src/test/java/io/grpc/xds/GrpcBootstrapperImplTest.java @@ -26,6 +26,7 @@ import com.google.common.collect.Iterables; import io.grpc.InsecureChannelCredentials; import io.grpc.TlsChannelCredentials; +import io.grpc.auth.JwtTokenFileCallCredentials; import io.grpc.internal.GrpcUtil; import io.grpc.internal.GrpcUtil.GrpcBuildVersion; import io.grpc.xds.client.AllowedGrpcServices; @@ -1058,4 +1059,205 @@ private static Node.Builder getNodeBuilder() { .addClientFeatures(GrpcBootstrapperImpl.CLIENT_FEATURE_DISABLE_OVERPROVISIONING) .addClientFeatures(GrpcBootstrapperImpl.CLIENT_FEATURE_RESOURCE_IN_SOTW); } + + private static void setEnableXdsBootstrapCallCreds(boolean enable) { + try { + java.lang.reflect.Field field = + io.grpc.xds.client.BootstrapperImpl.class + .getDeclaredField("enableXdsBootstrapCallCreds"); + field.setAccessible(true); + field.set(null, enable); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static String getFilePath(JwtTokenFileCallCredentials credentials) { + try { + java.lang.reflect.Field field = + JwtTokenFileCallCredentials.class.getDeclaredField("filePath"); + field.setAccessible(true); + return (String) field.get(credentials); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void parseBootstrap_callCreds_flagDisabled() throws Exception { + setEnableXdsBootstrapCallCreds(false); + try { + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [{\"type\": \"insecure\"}],\n" + + " \"call_creds\": [\n" + + " {\n" + + " \"type\": \"jwt_token_file\",\n" + + " \"config\": {\n" + + " \"token_file\": \"/var/run/secrets/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.callCredentials()).isNull(); + } finally { + setEnableXdsBootstrapCallCreds(false); + } + } + + @Test + public void parseBootstrap_xdsServers_jwtTokenFileCallCreds() throws Exception { + setEnableXdsBootstrapCallCreds(true); + try { + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [{\"type\": \"insecure\"}],\n" + + " \"call_creds\": [\n" + + " {\n" + + " \"type\": \"jwt_token_file\",\n" + + " \"config\": {\n" + + " \"token_file\": \"/var/run/secrets/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.callCredentials()) + .isInstanceOf(JwtTokenFileCallCredentials.class); + assertThat(getFilePath((JwtTokenFileCallCredentials) serverInfo.callCredentials())) + .isEqualTo("/var/run/secrets/token"); + } finally { + setEnableXdsBootstrapCallCreds(false); + } + } + + @Test + public void parseBootstrap_authorities_jwtTokenFileCallCreds() throws Exception { + setEnableXdsBootstrapCallCreds(true); + try { + String rawData = "{\n" + + " \"authorities\": {\n" + + " \"a.com\": {\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"td2.googleapis.com:443\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ],\n" + + " \"call_creds\": [\n" + + " {\n" + + " \"type\": \"jwt_token_file\",\n" + + " \"config\": {\n" + + " \"token_file\": \"/var/run/secrets/authority_token\"\n" + + " }\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + " }\n" + + " },\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [\n" + + " {\"type\": \"insecure\"}\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + BootstrapInfo info = bootstrapper.bootstrap(); + assertThat(info.authorities()).hasSize(1); + AuthorityInfo authorityInfo = info.authorities().get("a.com"); + assertThat(authorityInfo.xdsServers()).hasSize(1); + ServerInfo serverInfo = authorityInfo.xdsServers().get(0); + assertThat(serverInfo.callCredentials()) + .isInstanceOf(JwtTokenFileCallCredentials.class); + assertThat(getFilePath((JwtTokenFileCallCredentials) serverInfo.callCredentials())) + .isEqualTo("/var/run/secrets/authority_token"); + } finally { + setEnableXdsBootstrapCallCreds(false); + } + } + + @Test + public void parseBootstrap_unsupportedCallCredsType_ignored() throws Exception { + setEnableXdsBootstrapCallCreds(true); + try { + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [{\"type\": \"insecure\"}],\n" + + " \"call_creds\": [\n" + + " {\n" + + " \"type\": \"unsupported_type\",\n" + + " \"config\": {\n" + + " \"some_field\": \"some_val\"\n" + + " }\n" + + " },\n" + + " {\n" + + " \"type\": \"jwt_token_file\",\n" + + " \"config\": {\n" + + " \"token_file\": \"/var/run/secrets/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.callCredentials()) + .isInstanceOf(JwtTokenFileCallCredentials.class); + assertThat(getFilePath((JwtTokenFileCallCredentials) serverInfo.callCredentials())) + .isEqualTo("/var/run/secrets/token"); + } finally { + setEnableXdsBootstrapCallCreds(false); + } + } + + @Test + public void parseBootstrap_malformedCallCreds_throws() throws Exception { + setEnableXdsBootstrapCallCreds(true); + try { + String rawData = "{\n" + + " \"xds_servers\": [\n" + + " {\n" + + " \"server_uri\": \"" + SERVER_URI + "\",\n" + + " \"channel_creds\": [{\"type\": \"insecure\"}],\n" + + " \"call_creds\": [\n" + + " {\n" + + " \"type\": \"jwt_token_file\",\n" + + " \"config\": {}\n" + + " }\n" + + " ]\n" + + " }\n" + + " ]\n" + + "}"; + bootstrapper.setFileReader(createFileReader(BOOTSTRAP_FILE_PATH, rawData)); + XdsInitializationException e = assertThrows(XdsInitializationException.class, + bootstrapper::bootstrap); + assertThat(e).hasMessageThat().contains("jwt_token_file' token_file missing or empty"); + } finally { + setEnableXdsBootstrapCallCreds(false); + } + } } diff --git a/xds/src/test/java/io/grpc/xds/GrpcXdsTransportFactoryTest.java b/xds/src/test/java/io/grpc/xds/GrpcXdsTransportFactoryTest.java index e0b41f64943..a884643d855 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcXdsTransportFactoryTest.java +++ b/xds/src/test/java/io/grpc/xds/GrpcXdsTransportFactoryTest.java @@ -28,11 +28,13 @@ import io.envoyproxy.envoy.service.discovery.v3.DiscoveryRequest; import io.envoyproxy.envoy.service.discovery.v3.DiscoveryResponse; import io.grpc.BindableService; +import io.grpc.CallCredentials; import io.grpc.CallOptions; import io.grpc.Channel; import io.grpc.ChannelConfigurator; import io.grpc.ClientCall; import io.grpc.ClientInterceptor; +import io.grpc.CompositeCallCredentials; import io.grpc.Grpc; import io.grpc.InsecureChannelCredentials; import io.grpc.InsecureServerCredentials; @@ -333,5 +335,67 @@ public void configureChannelBuilder(ManagedChannelBuilder builder) { verify(mockBuilder).intercept(interceptor1); verify(mockBuilder).intercept(interceptor2); } + + private static CallCredentials getCallCredentials( + XdsTransportFactory.XdsTransport transport) throws Exception { + java.lang.reflect.Field field = + GrpcXdsTransportFactory.GrpcXdsTransport.class + .getDeclaredField("callCredentials"); + field.setAccessible(true); + return (CallCredentials) field.get(transport); + } + + private static CallCredentials getCredentials1( + CompositeCallCredentials composite) throws Exception { + java.lang.reflect.Field field = + CompositeCallCredentials.class.getDeclaredField("credentials1"); + field.setAccessible(true); + return (CallCredentials) field.get(composite); + } + + private static CallCredentials getCredentials2( + CompositeCallCredentials composite) throws Exception { + java.lang.reflect.Field field = + CompositeCallCredentials.class.getDeclaredField("credentials2"); + field.setAccessible(true); + return (CallCredentials) field.get(composite); + } + + @Test + public void createTransport_combinesCallCredentials() throws Exception { + CallCredentials factoryCreds = mock(CallCredentials.class); + CallCredentials serverCreds = mock(CallCredentials.class); + + // 1. Both factory and server callCredentials are non-null + GrpcXdsTransportFactory factoryBoth = new GrpcXdsTransportFactory(factoryCreds, null); + Bootstrapper.ServerInfo serverInfoBoth = Bootstrapper.ServerInfo.create( + "localhost:8080", InsecureChannelCredentials.create(), + false, false, false, false, serverCreds); + XdsTransportFactory.XdsTransport transportBoth = factoryBoth.create(serverInfoBoth); + CallCredentials combined = getCallCredentials(transportBoth); + assertThat(combined).isInstanceOf(CompositeCallCredentials.class); + CompositeCallCredentials composite = (CompositeCallCredentials) combined; + assertThat(getCredentials1(composite)).isSameInstanceAs(factoryCreds); + assertThat(getCredentials2(composite)).isSameInstanceAs(serverCreds); + transportBoth.shutdown(); + + // 2. Server credentials are null -> resolves to factory credentials + GrpcXdsTransportFactory factoryOnly = new GrpcXdsTransportFactory(factoryCreds, null); + Bootstrapper.ServerInfo serverInfoNoCreds = Bootstrapper.ServerInfo.create( + "localhost:8080", InsecureChannelCredentials.create(), + false, false, false, false, null); + XdsTransportFactory.XdsTransport transportFactoryOnly = factoryOnly.create(serverInfoNoCreds); + assertThat(getCallCredentials(transportFactoryOnly)).isSameInstanceAs(factoryCreds); + transportFactoryOnly.shutdown(); + + // 3. Factory credentials are null -> resolves to server credentials + GrpcXdsTransportFactory factoryNone = new GrpcXdsTransportFactory(null, null); + Bootstrapper.ServerInfo serverInfoWithCreds = Bootstrapper.ServerInfo.create( + "localhost:8080", InsecureChannelCredentials.create(), + false, false, false, false, serverCreds); + XdsTransportFactory.XdsTransport transportServerOnly = factoryNone.create(serverInfoWithCreds); + assertThat(getCallCredentials(transportServerOnly)).isSameInstanceAs(serverCreds); + transportServerOnly.shutdown(); + } } From c97e6b5a1e9bcb0e50c806841d48c3ed3400b84a Mon Sep 17 00:00:00 2001 From: Khooshi Asmi Date: Wed, 29 Jul 2026 05:16:35 +0000 Subject: [PATCH 3/4] Implement Integration Verification (Task 3) --- .../xds/XdsJwtCallCredsIntegrationTest.java | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 xds/src/test/java/io/grpc/xds/XdsJwtCallCredsIntegrationTest.java diff --git a/xds/src/test/java/io/grpc/xds/XdsJwtCallCredsIntegrationTest.java b/xds/src/test/java/io/grpc/xds/XdsJwtCallCredsIntegrationTest.java new file mode 100644 index 00000000000..d6a91859d39 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/XdsJwtCallCredsIntegrationTest.java @@ -0,0 +1,252 @@ +/* + * Copyright 2026 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 com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import io.grpc.ChannelConfigurator; +import io.grpc.Grpc; +import io.grpc.InsecureChannelCredentials; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; +import io.grpc.NameResolverRegistry; +import io.grpc.Server; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerCredentials; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.TlsServerCredentials; +import io.grpc.internal.testing.TestUtils; +import io.grpc.testing.TlsTesting; +import io.grpc.testing.protobuf.SimpleRequest; +import io.grpc.testing.protobuf.SimpleServiceGrpc; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import javax.net.ssl.TrustManagerFactory; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** + * Integration test for verifying that when the xDS client connects to a fake xDS control plane, the + * request contains the Authorization header with a JWT token configured via the bootstrap config. + */ +@RunWith(JUnit4.class) +public class XdsJwtCallCredsIntegrationTest { + + private Path trustStorePath; + private Path jwtTokenFile; + private String jwtToken; + private Server server; + private XdsTestControlPlaneService controlPlaneService; + private XdsNameResolverProvider nameResolverProvider; + private ManagedChannel channel; + private final AtomicReference receivedAuthHeader = new AtomicReference<>(); + private final CountDownLatch authHeaderLatch = new CountDownLatch(1); + + @Before + public void setUp() throws Exception { + setEnableXdsBootstrapCallCreds(true); + + // Client-side TLS trust setup + trustStorePath = generateTrustStore(); + System.setProperty("javax.net.ssl.trustStore", trustStorePath.toAbsolutePath().toString()); + System.setProperty("javax.net.ssl.trustStorePassword", "changeit"); + System.setProperty("javax.net.ssl.trustStoreType", "JKS"); + createDefaultTrustManager(); + + // Create JWT token file + jwtTokenFile = Files.createTempFile("jwt-token", ".txt"); + jwtToken = generateJwtToken(); + Files.write(jwtTokenFile, jwtToken.getBytes(StandardCharsets.UTF_8)); + } + + @After + public void tearDown() throws Exception { + if (channel != null) { + channel.shutdownNow(); + channel.awaitTermination(5, TimeUnit.SECONDS); + } + if (server != null) { + server.shutdownNow(); + server.awaitTermination(5, TimeUnit.SECONDS); + } + if (nameResolverProvider != null) { + NameResolverRegistry.getDefaultRegistry().deregister(nameResolverProvider); + } + + System.clearProperty("javax.net.ssl.trustStore"); + System.clearProperty("javax.net.ssl.trustStorePassword"); + System.clearProperty("javax.net.ssl.trustStoreType"); + createDefaultTrustManager(); + + if (trustStorePath != null) { + Files.deleteIfExists(trustStorePath); + } + if (jwtTokenFile != null) { + Files.deleteIfExists(jwtTokenFile); + } + setEnableXdsBootstrapCallCreds(false); + } + + @Test + public void jwtCallCredsAppliedToXdsControlPlane() throws Exception { + controlPlaneService = new XdsTestControlPlaneService(); + + File certFile = TestUtils.loadCert("server1.pem"); + File keyFile = TestUtils.loadCert("server1.key"); + ServerCredentials serverCreds = TlsServerCredentials.newBuilder() + .keyManager(certFile, keyFile) + .build(); + + ServerInterceptor authCheckingInterceptor = new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + String authHeader = headers.get( + Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER)); + if (authHeader != null) { + receivedAuthHeader.set(authHeader); + authHeaderLatch.countDown(); + } + return next.startCall(call, headers); + } + }; + + server = Grpc.newServerBuilderForPort(0, serverCreds) + .addService(ServerInterceptors.intercept(controlPlaneService, authCheckingInterceptor)) + .build() + .start(); + + // Setup bootstrap configuration with call_creds pointing to the JWT token file. + Map bootstrapOverride = ImmutableMap.of( + "node", ImmutableMap.of( + "id", UUID.randomUUID().toString(), + "cluster", "cluster0"), + "xds_servers", Collections.singletonList( + ImmutableMap.of( + "server_uri", "localhost:" + server.getPort(), + "channel_creds", Collections.singletonList( + ImmutableMap.of("type", "tls") + ), + "call_creds", Collections.singletonList( + ImmutableMap.of( + "type", "jwt_token_file", + "config", ImmutableMap.of( + "token_file", jwtTokenFile.toAbsolutePath().toString()) + ) + ), + "server_features", Lists.newArrayList("xds_v3") + ) + ), + "server_listener_resource_name_template", "grpc/server?udpa.resource.listening_address=" + ); + + // Register name resolver + nameResolverProvider = XdsNameResolverProvider.createForTest("test-xds", bootstrapOverride); + NameResolverRegistry.getDefaultRegistry().register(nameResolverProvider); + + // Create channel and make a dummy RPC call to trigger name resolver and connection + channel = Grpc.newChannelBuilder("test-xds:///test-server", InsecureChannelCredentials.create()) + .childChannelConfigurator(new ChannelConfigurator() { + @Override + public void configureChannelBuilder(ManagedChannelBuilder builder) { + builder.overrideAuthority("waterzooi.test.google.be"); + } + }) + .build(); + SimpleServiceGrpc.SimpleServiceBlockingStub blockingStub = + SimpleServiceGrpc.newBlockingStub(channel); + + try { + blockingStub.unaryRpc(SimpleRequest.getDefaultInstance()); + } catch (Exception e) { + // Expected to fail since control plane doesn't actually serve the LDS/RDS configs for + // test-server, but the connection/stream to control plane should still happen. + } + + // Verify control plane received the token in Authorization header + assertThat(authHeaderLatch.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(receivedAuthHeader.get()).isEqualTo("Bearer " + jwtToken); + } + + private static void setEnableXdsBootstrapCallCreds(boolean enable) { + try { + java.lang.reflect.Field field = + io.grpc.xds.client.BootstrapperImpl.class + .getDeclaredField("enableXdsBootstrapCallCreds"); + field.setAccessible(true); + field.set(null, enable); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static Path generateTrustStore() throws Exception { + KeyStore keystore = KeyStore.getInstance("JKS"); + keystore.load(null, null); + try (InputStream caCertStream = TlsTesting.loadCert("ca.pem")) { + keystore.setCertificateEntry("testca", + CertificateFactory.getInstance("X.509").generateCertificate(caCertStream)); + } + File trustStoreFile = File.createTempFile("testca-truststore", ".jks"); + trustStoreFile.deleteOnExit(); + try (FileOutputStream out = new FileOutputStream(trustStoreFile)) { + keystore.store(out, "changeit".toCharArray()); + } + return trustStoreFile.toPath(); + } + + private static void createDefaultTrustManager() throws Exception { + TrustManagerFactory factory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init((KeyStore) null); + } + + private static String generateJwtToken() { + String header = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; + String payload = "{\"exp\":2000000000}"; + String signature = ""; + + String headerBase64 = com.google.common.io.BaseEncoding.base64Url().omitPadding() + .encode(header.getBytes(StandardCharsets.UTF_8)); + String payloadBase64 = com.google.common.io.BaseEncoding.base64Url().omitPadding() + .encode(payload.getBytes(StandardCharsets.UTF_8)); + String signatureBase64 = com.google.common.io.BaseEncoding.base64Url().omitPadding() + .encode(signature.getBytes(StandardCharsets.UTF_8)); + + return headerBase64 + "." + payloadBase64 + "." + signatureBase64; + } +} From e5863c6fdcdda6a92b40cbe6913c927a0a804577 Mon Sep 17 00:00:00 2001 From: Khooshi Asmi <73297365+khush2520@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:00:12 +0530 Subject: [PATCH 4/4] Add gson in bazel BUILD --- auth/BUILD.bazel | 1 + 1 file changed, 1 insertion(+) diff --git a/auth/BUILD.bazel b/auth/BUILD.bazel index da44243e583..870936ca561 100644 --- a/auth/BUILD.bazel +++ b/auth/BUILD.bazel @@ -11,6 +11,7 @@ java_library( "//api", artifact("com.google.auth:google-auth-library-credentials"), artifact("com.google.code.findbugs:jsr305"), + artifact("com.google.code.gson:gson"), artifact("com.google.guava:guava"), ], )