Skip to content

Commit 8fb186f

Browse files
committed
fix: address PR review findings across mTLS rotation, retries, and auth caching
1 parent d733f16 commit 8fb186f

9 files changed

Lines changed: 261 additions & 22 deletions

File tree

google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
import java.util.Collections;
5353
import java.util.List;
5454
import java.util.Map;
55+
import java.util.Objects;
5556
import java.util.regex.Pattern;
5657
import org.slf4j.Logger;
5758
import org.slf4j.LoggerFactory;
@@ -234,7 +235,78 @@ static CertInfo getAgentIdentityCertInfo() throws IOException {
234235
return null;
235236
}
236237

237-
return loadAndVerifyCredentials(paths.getCertPath(), paths.getKeyPath());
238+
return getCachedOrLoadCredentials(paths.getCertPath(), paths.getKeyPath());
239+
}
240+
241+
private static final Object certInfoCacheLock = new Object();
242+
private static volatile CachedCertInfo cachedCertInfo = null;
243+
244+
static void clearCertInfoCache() {
245+
synchronized (certInfoCacheLock) {
246+
cachedCertInfo = null;
247+
}
248+
}
249+
250+
private static CertInfo getCachedOrLoadCredentials(String certPath, String keyPath)
251+
throws IOException {
252+
CachedCertInfo currentCache = cachedCertInfo;
253+
if (currentCache != null && isCacheValid(currentCache, certPath, keyPath)) {
254+
return currentCache.certInfo;
255+
}
256+
synchronized (certInfoCacheLock) {
257+
if (cachedCertInfo != null && isCacheValid(cachedCertInfo, certPath, keyPath)) {
258+
return cachedCertInfo.certInfo;
259+
}
260+
CertInfo info = loadAndVerifyCredentials(certPath, keyPath);
261+
long certMtime = getFileMtime(certPath);
262+
long keyMtime = getFileMtime(keyPath);
263+
cachedCertInfo = new CachedCertInfo(certPath, keyPath, certMtime, keyMtime, info);
264+
return info;
265+
}
266+
}
267+
268+
private static boolean isCacheValid(CachedCertInfo cache, String certPath, String keyPath) {
269+
if (!Objects.equals(cache.certPath, certPath) || !Objects.equals(cache.keyPath, keyPath)) {
270+
return false;
271+
}
272+
return cache.certLastModifiedTime == getFileMtime(certPath)
273+
&& cache.keyLastModifiedTime == getFileMtime(keyPath);
274+
}
275+
276+
private static long getFileMtime(String path) {
277+
if (path == null) {
278+
return -1;
279+
}
280+
try {
281+
java.nio.file.Path p = Paths.get(path);
282+
if (Files.exists(p)) {
283+
return Files.getLastModifiedTime(p).toMillis();
284+
}
285+
} catch (IOException e) {
286+
// Ignore exception and return -1
287+
}
288+
return -1;
289+
}
290+
291+
private static class CachedCertInfo {
292+
final String certPath;
293+
final String keyPath;
294+
final long certLastModifiedTime;
295+
final long keyLastModifiedTime;
296+
final CertInfo certInfo;
297+
298+
CachedCertInfo(
299+
String certPath,
300+
String keyPath,
301+
long certLastModifiedTime,
302+
long keyLastModifiedTime,
303+
CertInfo certInfo) {
304+
this.certPath = certPath;
305+
this.keyPath = keyPath;
306+
this.certLastModifiedTime = certLastModifiedTime;
307+
this.keyLastModifiedTime = keyLastModifiedTime;
308+
this.certInfo = certInfo;
309+
}
238310
}
239311

240312
/**

google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import static org.junit.jupiter.api.Assertions.assertFalse;
3535
import static org.junit.jupiter.api.Assertions.assertNotNull;
3636
import static org.junit.jupiter.api.Assertions.assertNull;
37+
import static org.junit.jupiter.api.Assertions.assertSame;
3738
import static org.junit.jupiter.api.Assertions.assertThrows;
3839
import static org.junit.jupiter.api.Assertions.assertTrue;
3940
import static org.mockito.Mockito.mock;
@@ -75,13 +76,15 @@ class AgentIdentityUtilsTest {
7576

7677
@BeforeEach
7778
void setUp() throws IOException {
79+
AgentIdentityUtils.clearCertInfoCache();
7880
envProvider = new TestEnvironmentProvider();
7981
AgentIdentityUtils.setEnvReader(envProvider::getEnv);
8082
tempDir = Files.createTempDirectory("agent_identity_test");
8183
}
8284

8385
@AfterEach
8486
void tearDown() throws IOException {
87+
AgentIdentityUtils.clearCertInfoCache();
8588
AgentIdentityUtils.resetTimeService();
8689
AgentIdentityUtils.setWellKnownDir("/var/run/secrets/workload-spiffe-credentials/");
8790
AgentIdentityUtils.setEnvReader(System::getenv);
@@ -186,6 +189,32 @@ public void getAgentIdentityCertificate_happyPath_loadsCertificate() throws IOEx
186189
assertTrue(info.getCertificate().getIssuerDN().getName().contains("unit-tests"));
187190
}
188191

192+
@Test
193+
public void testAgentIdentityCertInfoIsCachedAndReloadedWhenModified() throws IOException {
194+
URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem");
195+
assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found");
196+
String certPath = new File(certUrl.getFile()).getAbsolutePath();
197+
File configFile = tempDir.resolve("config_cache.json").toFile();
198+
String configJson =
199+
"{"
200+
+ " \"cert_configs\": {"
201+
+ " \"workload\": {"
202+
+ " \"cert_path\": \""
203+
+ certPath.replace("\\", "\\\\")
204+
+ "\""
205+
+ " }"
206+
+ " }"
207+
+ "}";
208+
try (FileOutputStream fos = new FileOutputStream(configFile)) {
209+
fos.write(configJson.getBytes(StandardCharsets.UTF_8));
210+
}
211+
envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath());
212+
AgentIdentityUtils.CertInfo info1 = AgentIdentityUtils.getAgentIdentityCertInfo();
213+
AgentIdentityUtils.CertInfo info2 = AgentIdentityUtils.getAgentIdentityCertInfo();
214+
assertNotNull(info1);
215+
assertSame(info1, info2);
216+
}
217+
189218
@Test
190219
public void getAgentIdentityCertificate_timeout_throwsIOException() {
191220
envProvider.setEnv(

sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/ChannelPool.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -712,7 +712,13 @@ public <RequestT, ResponseT> ClientCall<RequestT, ResponseT> newCall(
712712
}
713713
}
714714

715-
/** ClientCall wrapper that makes sure to decrement the outstanding RPC count on completion. */
715+
/**
716+
* ClientCall wrapper that makes sure to decrement the outstanding RPC count on completion.
717+
*
718+
* <p>Contract: Exactly one call to {@link #start(Listener, Metadata)} or explicit release via
719+
* {@link #cancel(String, Throwable)} is required to balance reference counts. Early cancellation
720+
* before {@code start()} safely decrements the reference count via atomic compare-and-set.
721+
*/
716722
static class ReleasingClientCall<ReqT, RespT> extends SimpleForwardingClientCall<ReqT, RespT> {
717723
private @Nullable CancellationException cancellationException;
718724
final Entry entry;

sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannel.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,6 @@ public void refresh() {
153153
return;
154154
}
155155

156-
this.activeCertFingerprint = currentDiskFingerprint;
157156
LOG.info("mTLS certificate rotation detected. Triggering HTTP/JSON channel pool refresh.");
158157

159158
// Prune terminated entries to prevent memory leak
@@ -162,6 +161,7 @@ public void refresh() {
162161
ChannelEntry newEntry = new ChannelEntry(channelFactory.get());
163162
allEntries.add(newEntry);
164163
ChannelEntry oldEntry = activeEntry.getAndSet(newEntry);
164+
this.activeCertFingerprint = currentDiskFingerprint;
165165

166166
if (oldEntry != null) {
167167
oldEntry.requestShutdown();
@@ -234,15 +234,22 @@ public boolean isTerminated() {
234234

235235
@Override
236236
public void shutdownNow() {
237-
for (ChannelEntry entry : allEntries) {
238-
entry.channel.shutdownNow();
237+
synchronized (refreshLock) {
238+
isShuttingDown = true;
239+
for (ChannelEntry entry : allEntries) {
240+
entry.requestShutdown();
241+
entry.channel.shutdownNow();
242+
}
239243
}
240244
}
241245

242246
@Override
243247
public boolean awaitTermination(long duration, TimeUnit unit) throws InterruptedException {
244248
long endNanos = System.nanoTime() + unit.toNanos(duration);
245249
for (ChannelEntry entry : allEntries) {
250+
if (entry.channel.isTerminated()) {
251+
continue;
252+
}
246253
long remainingNanos = endNanos - System.nanoTime();
247254
if (remainingNanos <= 0) {
248255
return false;

sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannelTest.java

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,21 @@
3131

3232
import static org.junit.jupiter.api.Assertions.assertEquals;
3333
import static org.junit.jupiter.api.Assertions.assertFalse;
34+
import static org.junit.jupiter.api.Assertions.assertThrows;
3435
import static org.junit.jupiter.api.Assertions.assertTrue;
3536
import static org.mockito.ArgumentMatchers.any;
37+
import static org.mockito.ArgumentMatchers.anyLong;
3638
import static org.mockito.Mockito.mock;
3739
import static org.mockito.Mockito.never;
3840
import static org.mockito.Mockito.verify;
3941
import static org.mockito.Mockito.when;
4042

43+
import java.util.ArrayList;
44+
import java.util.List;
45+
import java.util.concurrent.TimeUnit;
4146
import java.util.concurrent.atomic.AtomicInteger;
4247
import java.util.function.Supplier;
48+
import org.junit.jupiter.api.AfterEach;
4349
import org.junit.jupiter.api.BeforeEach;
4450
import org.junit.jupiter.api.Test;
4551
import org.mockito.ArgumentCaptor;
@@ -49,9 +55,14 @@ class RefreshingHttpJsonChannelTest {
4955
private ManagedHttpJsonChannel lastCreatedChannel;
5056
private String testCertPath = "/fake/path";
5157
private String testFingerprint = "fingerprint1";
58+
private boolean shouldThrowOnFactory = false;
59+
private List<RefreshingHttpJsonChannel> createdChannels;
5260

5361
private Supplier<ManagedHttpJsonChannel> channelFactory =
5462
() -> {
63+
if (shouldThrowOnFactory) {
64+
throw new RuntimeException("Simulated factory failure");
65+
}
5566
channelFactoryCount.incrementAndGet();
5667
lastCreatedChannel = mock(ManagedHttpJsonChannel.class);
5768
return lastCreatedChannel;
@@ -62,20 +73,32 @@ void setUp() {
6273
channelFactoryCount = new AtomicInteger(0);
6374
testCertPath = "/fake/path";
6475
testFingerprint = "fingerprint1";
76+
shouldThrowOnFactory = false;
77+
createdChannels = new ArrayList<>();
78+
}
79+
80+
@AfterEach
81+
void tearDown() {
82+
for (RefreshingHttpJsonChannel channel : createdChannels) {
83+
channel.shutdownNow();
84+
}
6585
}
6686

6787
private RefreshingHttpJsonChannel createTestChannel() {
68-
return new RefreshingHttpJsonChannel(channelFactory, "fake/cert/path.json") {
69-
@Override
70-
protected String getWorkloadCertPath() {
71-
return testCertPath;
72-
}
73-
74-
@Override
75-
protected String getCertificateFingerprint(String certPath) {
76-
return testFingerprint;
77-
}
78-
};
88+
RefreshingHttpJsonChannel ch =
89+
new RefreshingHttpJsonChannel(channelFactory, "fake/cert/path.json") {
90+
@Override
91+
protected String getWorkloadCertPath() {
92+
return testCertPath;
93+
}
94+
95+
@Override
96+
protected String getCertificateFingerprint(String certPath) {
97+
return testFingerprint;
98+
}
99+
};
100+
createdChannels.add(ch);
101+
return ch;
79102
}
80103

81104
@Test
@@ -194,4 +217,46 @@ void testRefreshDoesNotSpawnChannelWhenShutdown() throws InterruptedException {
194217
// Verify no new channel was spawned
195218
assertEquals(1, channelFactoryCount.get());
196219
}
220+
221+
@Test
222+
void testRefreshFactoryExceptionDoesNotWedgeFingerprint() throws InterruptedException {
223+
RefreshingHttpJsonChannel channel = createTestChannel();
224+
assertEquals(1, channelFactoryCount.get());
225+
226+
shouldThrowOnFactory = true;
227+
Thread.sleep(1001); // Invalidate 1-second cache
228+
testFingerprint = "fingerprint2";
229+
230+
assertThrows(RuntimeException.class, channel::refresh);
231+
232+
// Because factory threw, activeCertFingerprint should NOT be updated to fingerprint2
233+
// Therefore shouldRefresh() should still return true
234+
assertTrue(channel.shouldRefresh());
235+
236+
shouldThrowOnFactory = false;
237+
channel.refresh();
238+
assertEquals(2, channelFactoryCount.get());
239+
assertFalse(channel.shouldRefresh());
240+
}
241+
242+
@Test
243+
void testShutdownNowSetsIsShutdown() {
244+
RefreshingHttpJsonChannel channel = createTestChannel();
245+
assertFalse(channel.isShutdown());
246+
247+
channel.shutdownNow();
248+
249+
assertTrue(channel.isShutdown());
250+
}
251+
252+
@Test
253+
void testAwaitTerminationZeroTimeoutOnTerminatedChannelReturnsTrue() throws InterruptedException {
254+
RefreshingHttpJsonChannel channel = createTestChannel();
255+
ManagedHttpJsonChannel firstChannel = lastCreatedChannel;
256+
when(firstChannel.isTerminated()).thenReturn(true);
257+
when(firstChannel.awaitTermination(anyLong(), any())).thenReturn(true);
258+
259+
channel.shutdown();
260+
assertTrue(channel.awaitTermination(0, TimeUnit.MILLISECONDS));
261+
}
197262
}

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ApiCallContext.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ public interface ApiCallContext extends RetryingContext {
6666
/**
6767
* Returns the {@link TransportChannel} associated with this call context, or {@code null} if none
6868
* is set.
69+
*
70+
* <p>Note: By default, this method returns {@code null}. If an implementation does not override
71+
* this method, automatic mTLS certificate rotation and channel refreshing in retrying callables
72+
* will be disabled.
6973
*/
7074
default TransportChannel getTransportChannel() {
7175
return null;

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/ApiResultRetryAlgorithm.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ public boolean shouldRetry(Throwable previousThrowable, ResponseT previousRespon
5252
@Override
5353
public boolean shouldRetry(
5454
RetryingContext context, Throwable previousThrowable, ResponseT previousResponse) {
55+
// Check UnauthenticatedException retryability first to ensure mTLS certificate
56+
// rotation retries take precedence over static method retry codes.
5557
if (previousThrowable instanceof UnauthenticatedException
5658
&& ((UnauthenticatedException) previousThrowable).isRetryable()) {
5759
return true;

sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/AttemptCallable.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,18 @@ public ResponseT call() {
9595
TransportChannel transportChannel = finalContext.getTransportChannel();
9696
if (transportChannel != null && transportChannel.shouldRefresh()) {
9797
transportChannel.refresh();
98-
throw new UnauthenticatedException(
99-
unauthenticatedException.getMessage(),
100-
unauthenticatedException.getCause(),
101-
unauthenticatedException.getStatusCode(),
102-
true, // isRetryable = true
103-
unauthenticatedException.getErrorDetails());
98+
UnauthenticatedException newEx =
99+
new UnauthenticatedException(
100+
unauthenticatedException.getMessage(),
101+
unauthenticatedException,
102+
unauthenticatedException.getStatusCode(),
103+
true, // isRetryable = true
104+
unauthenticatedException.getErrorDetails());
105+
newEx.setStackTrace(unauthenticatedException.getStackTrace());
106+
for (Throwable suppressed : unauthenticatedException.getSuppressed()) {
107+
newEx.addSuppressed(suppressed);
108+
}
109+
throw newEx;
104110
}
105111
throw unauthenticatedException;
106112
},

0 commit comments

Comments
 (0)