Skip to content

Commit 1053f4b

Browse files
Implement gRFC A97: xDS JWT Call Credentials (#12951)
Implements [gRFC A97](https://github.com/grpc/proposal/blob/master/A97-xds-jwt-call-creds.md) to support file-based JSON Web Token (JWT) Call Credentials for xDS-enabled clients. Implemented using an experimental coding agent. Summary of Changes - Core JWT Capabilities: Added JwtTokenFileCallCredentials in grpc-auth for lazy-loading, caching, exp-claim validation, and asynchronous token refresh via ExponentialBackoffPolicy. - xDS Bootstrap Parser Integration: Updated BootstrapperImpl and ServerInfo in grpc-xds to parse call_creds JSON lists, combining them utilizing composite credentials. Flag-guarded securely behind GRPC_EXPERIMENTAL_XDS_BOOTSTRAP_CALL_CREDS. - Testing: Added rigorous unit tests and an end-to-end XdsJwtCallCredsIntegrationTest verifying real client-to-server TLS + JWT Authorization transmission. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 4718218 commit 1053f4b

22 files changed

Lines changed: 1653 additions & 29 deletions

auth/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ java_library(
1111
"//api",
1212
artifact("com.google.auth:google-auth-library-credentials"),
1313
artifact("com.google.code.findbugs:jsr305"),
14+
artifact("com.google.code.gson:gson"),
1415
artifact("com.google.guava:guava"),
1516
],
1617
)

auth/build.gradle

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ tasks.named("jar").configure {
1717
dependencies {
1818
api project(':grpc-api'),
1919
libraries.google.auth.credentials
20-
implementation libraries.guava
20+
implementation libraries.guava,
21+
libraries.gson
2122
testImplementation project(':grpc-testing'),
2223
project(':grpc-core'),
2324
project(":grpc-context"), // Override google-auth dependency with our newer version
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
/*
2+
* Copyright 2026 The gRPC Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.grpc.auth;
18+
19+
import static com.google.common.base.Preconditions.checkNotNull;
20+
21+
import com.google.common.annotations.VisibleForTesting;
22+
import com.google.common.io.BaseEncoding;
23+
import com.google.common.io.ByteStreams;
24+
import com.google.gson.JsonElement;
25+
import com.google.gson.JsonObject;
26+
import com.google.gson.JsonParser;
27+
import com.google.gson.JsonSyntaxException;
28+
import io.grpc.CallCredentials;
29+
import io.grpc.Metadata;
30+
import io.grpc.SecurityLevel;
31+
import io.grpc.Status;
32+
import java.io.File;
33+
import java.io.FileInputStream;
34+
import java.io.IOException;
35+
import java.io.InputStream;
36+
import java.nio.charset.StandardCharsets;
37+
import java.util.ArrayList;
38+
import java.util.List;
39+
import java.util.concurrent.Executor;
40+
import java.util.concurrent.RejectedExecutionException;
41+
import java.util.logging.Level;
42+
import java.util.logging.Logger;
43+
44+
/**
45+
* A {@link CallCredentials} implementation that loads a JWT token from a file,
46+
* parses it to extract its expiration time, and caches/refreshes it.
47+
*/
48+
public final class JwtTokenFileCallCredentials extends CallCredentials {
49+
private static final int MAX_FILE_SIZE_BYTES = 1048576;
50+
51+
private static final Logger log = Logger.getLogger(JwtTokenFileCallCredentials.class.getName());
52+
53+
private static final Metadata.Key<String> AUTHORIZATION_HEADER =
54+
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER);
55+
56+
private static final long INITIAL_BACKOFF_MILLIS = 1000;
57+
private static final long MAX_BACKOFF_MILLIS = 120000;
58+
private static final double BACKOFF_MULTIPLIER = 1.6;
59+
private static final double JITTER = 0.2;
60+
61+
private final String filePath;
62+
private final TimeProvider timeProvider;
63+
private final Object lock = new Object();
64+
65+
private enum ReadState {
66+
IDLE,
67+
READING,
68+
BACKOFF
69+
}
70+
71+
private String cachedToken;
72+
private long expirationTimeMillis;
73+
private ReadState readState = ReadState.IDLE;
74+
private Status lastReadFailureStatus;
75+
private long currentBackoffMillis;
76+
private long nextAttemptTimeMillis;
77+
private final List<MetadataApplier> queuedAppliers = new ArrayList<>();
78+
79+
interface TimeProvider {
80+
long currentTimeMillis();
81+
}
82+
83+
private static final TimeProvider SYSTEM_TIME_PROVIDER = new TimeProvider() {
84+
@Override
85+
public long currentTimeMillis() {
86+
return System.currentTimeMillis();
87+
}
88+
};
89+
90+
public JwtTokenFileCallCredentials(String filePath) {
91+
this(filePath, SYSTEM_TIME_PROVIDER);
92+
}
93+
94+
@VisibleForTesting
95+
JwtTokenFileCallCredentials(String filePath, TimeProvider timeProvider) {
96+
this.filePath = checkNotNull(filePath, "filePath");
97+
this.timeProvider = checkNotNull(timeProvider, "timeProvider");
98+
}
99+
100+
@Override
101+
public void applyRequestMetadata(
102+
RequestInfo requestInfo, Executor appExecutor, MetadataApplier applier) {
103+
checkNotNull(requestInfo, "requestInfo");
104+
checkNotNull(appExecutor, "appExecutor");
105+
checkNotNull(applier, "applier");
106+
107+
if (requestInfo.getSecurityLevel() != SecurityLevel.PRIVACY_AND_INTEGRITY) {
108+
applier.fail(Status.UNAUTHENTICATED
109+
.withDescription("Channel security level is not PRIVACY_AND_INTEGRITY"));
110+
return;
111+
}
112+
113+
long now = timeProvider.currentTimeMillis();
114+
TokenInfo tokenToApply = null;
115+
boolean triggerRead = false;
116+
Status failStatus = null;
117+
118+
synchronized (lock) {
119+
if (readState == ReadState.BACKOFF && now >= nextAttemptTimeMillis) {
120+
readState = ReadState.IDLE;
121+
}
122+
123+
boolean hasValidCache = cachedToken != null && now < expirationTimeMillis;
124+
boolean expiringSoon = hasValidCache && (expirationTimeMillis - now <= 60000);
125+
126+
if (hasValidCache) {
127+
tokenToApply = new TokenInfo(cachedToken, expirationTimeMillis);
128+
if (expiringSoon && readState == ReadState.IDLE) {
129+
readState = ReadState.READING;
130+
triggerRead = true;
131+
}
132+
} else {
133+
if (readState == ReadState.BACKOFF) {
134+
failStatus = lastReadFailureStatus != null ? lastReadFailureStatus : Status.UNAVAILABLE;
135+
} else {
136+
if (readState == ReadState.IDLE) {
137+
readState = ReadState.READING;
138+
triggerRead = true;
139+
}
140+
queuedAppliers.add(applier);
141+
}
142+
}
143+
}
144+
145+
if (failStatus != null) {
146+
applier.fail(failStatus);
147+
return;
148+
}
149+
150+
if (tokenToApply != null) {
151+
Metadata headers = new Metadata();
152+
headers.put(AUTHORIZATION_HEADER, "Bearer " + tokenToApply.token);
153+
applier.apply(headers);
154+
}
155+
156+
if (triggerRead) {
157+
try {
158+
appExecutor.execute(new Runnable() {
159+
@Override
160+
public void run() {
161+
loadToken();
162+
}
163+
});
164+
} catch (RejectedExecutionException e) {
165+
handleExecutorRejection(e, tokenToApply != null);
166+
}
167+
}
168+
}
169+
170+
private void handleExecutorRejection(
171+
RejectedExecutionException e, boolean isBackgroundReload) {
172+
log.log(Level.WARNING, "Executor rejected token read task", e);
173+
List<MetadataApplier> appliersToFail = new ArrayList<>();
174+
synchronized (lock) {
175+
readState = ReadState.IDLE;
176+
if (!isBackgroundReload) {
177+
appliersToFail.addAll(queuedAppliers);
178+
queuedAppliers.clear();
179+
}
180+
}
181+
for (MetadataApplier applier : appliersToFail) {
182+
try {
183+
applier.fail(Status.UNAVAILABLE
184+
.withDescription("Executor rejected token read task")
185+
.withCause(e));
186+
} catch (Throwable t) {
187+
log.log(Level.WARNING, "Error calling fail on applier", t);
188+
}
189+
}
190+
}
191+
192+
private void loadToken() {
193+
TokenInfo tokenInfo = null;
194+
Status status = null;
195+
try {
196+
tokenInfo = readAndParseTokenFile();
197+
} catch (IOException e) {
198+
status = Status.UNAVAILABLE
199+
.withDescription("Failed to read token file")
200+
.withCause(e);
201+
} catch (IllegalArgumentException e) {
202+
status = Status.UNAUTHENTICATED
203+
.withDescription("Malformed token or invalid claims")
204+
.withCause(e);
205+
} catch (Throwable e) {
206+
status = Status.UNAVAILABLE
207+
.withDescription("Unexpected error loading token")
208+
.withCause(e);
209+
}
210+
211+
List<MetadataApplier> appliersToApply = new ArrayList<>();
212+
List<MetadataApplier> appliersToFail = new ArrayList<>();
213+
214+
synchronized (lock) {
215+
if (status == null) {
216+
cachedToken = tokenInfo.token;
217+
expirationTimeMillis = tokenInfo.expirationTimeMillis;
218+
readState = ReadState.IDLE;
219+
lastReadFailureStatus = null;
220+
currentBackoffMillis = 0;
221+
nextAttemptTimeMillis = 0;
222+
223+
appliersToApply.addAll(queuedAppliers);
224+
queuedAppliers.clear();
225+
} else {
226+
lastReadFailureStatus = status;
227+
readState = ReadState.BACKOFF;
228+
229+
if (currentBackoffMillis == 0) {
230+
currentBackoffMillis = INITIAL_BACKOFF_MILLIS;
231+
} else {
232+
currentBackoffMillis = Math.min(
233+
(long) (currentBackoffMillis * BACKOFF_MULTIPLIER), MAX_BACKOFF_MILLIS);
234+
}
235+
double uniformRandom = Math.random() * 2 - 1;
236+
long jitteredBackoff = (long) (currentBackoffMillis
237+
+ uniformRandom * JITTER * currentBackoffMillis);
238+
nextAttemptTimeMillis = timeProvider.currentTimeMillis() + jitteredBackoff;
239+
240+
appliersToFail.addAll(queuedAppliers);
241+
queuedAppliers.clear();
242+
}
243+
}
244+
245+
if (status == null) {
246+
Metadata headers = new Metadata();
247+
headers.put(AUTHORIZATION_HEADER, "Bearer " + tokenInfo.token);
248+
for (MetadataApplier applier : appliersToApply) {
249+
try {
250+
applier.apply(headers);
251+
} catch (Throwable t) {
252+
log.log(Level.WARNING, "Error applying credentials", t);
253+
}
254+
}
255+
} else {
256+
log.log(Level.WARNING, "Failed to load token: " + status.getDescription(), status.getCause());
257+
for (MetadataApplier applier : appliersToFail) {
258+
try {
259+
applier.fail(status);
260+
} catch (Throwable t) {
261+
log.log(Level.WARNING, "Error calling fail on applier", t);
262+
}
263+
}
264+
}
265+
}
266+
267+
private TokenInfo readAndParseTokenFile() throws IOException {
268+
File file = new File(filePath);
269+
long length = file.length();
270+
if (length > MAX_FILE_SIZE_BYTES) {
271+
throw new IOException("File size exceeds 1 MB limit: " + length);
272+
}
273+
byte[] bytes;
274+
try (InputStream in = new FileInputStream(file)) {
275+
bytes = ByteStreams.toByteArray(
276+
ByteStreams.limit(in, MAX_FILE_SIZE_BYTES + 1));
277+
}
278+
if (bytes.length > MAX_FILE_SIZE_BYTES) {
279+
throw new IOException("File size exceeds 1 MB limit: " + bytes.length);
280+
}
281+
String token = new String(bytes, StandardCharsets.UTF_8).trim();
282+
if (token.isEmpty()) {
283+
throw new IllegalArgumentException("Token file is empty");
284+
}
285+
String[] segments = token.split("\\.", -1);
286+
if (segments.length != 3) {
287+
throw new IllegalArgumentException("JWT must have 3 segments");
288+
}
289+
byte[] payloadBytes;
290+
try {
291+
payloadBytes = BaseEncoding.base64Url()
292+
.omitPadding().decode(segments[1]);
293+
} catch (IllegalArgumentException e) {
294+
throw new IllegalArgumentException("Invalid Base64URL encoding in payload", e);
295+
}
296+
String payloadJson = new String(payloadBytes, StandardCharsets.UTF_8);
297+
JsonObject jsonObject;
298+
try {
299+
JsonElement jsonElement = JsonParser.parseString(payloadJson);
300+
if (jsonElement == null || !jsonElement.isJsonObject()) {
301+
throw new IllegalArgumentException("Payload is not a JSON object");
302+
}
303+
jsonObject = jsonElement.getAsJsonObject();
304+
} catch (JsonSyntaxException e) {
305+
throw new IllegalArgumentException("Invalid JSON payload", e);
306+
}
307+
if (!jsonObject.has("exp")) {
308+
throw new IllegalArgumentException("Payload does not contain 'exp' claim");
309+
}
310+
JsonElement expElement = jsonObject.get("exp");
311+
if (!expElement.isJsonPrimitive() || !expElement.getAsJsonPrimitive().isNumber()) {
312+
throw new IllegalArgumentException("'exp' claim is not a number");
313+
}
314+
long expSeconds = expElement.getAsLong();
315+
if (expSeconds <= 0) {
316+
throw new IllegalArgumentException("Invalid 'exp' claim value: " + expSeconds);
317+
}
318+
long expirationTimeMillis;
319+
if (expSeconds > Long.MAX_VALUE / 1000) {
320+
expirationTimeMillis = Long.MAX_VALUE;
321+
} else {
322+
expirationTimeMillis = (expSeconds - 30) * 1000;
323+
}
324+
return new TokenInfo(token, expirationTimeMillis);
325+
}
326+
327+
@Override
328+
@SuppressWarnings("deprecation")
329+
public void thisUsesUnstableApi() {
330+
// Yes
331+
}
332+
333+
private static class TokenInfo {
334+
final String token;
335+
final long expirationTimeMillis;
336+
337+
TokenInfo(String token, long expirationTimeMillis) {
338+
this.token = token;
339+
this.expirationTimeMillis = expirationTimeMillis;
340+
}
341+
}
342+
}

0 commit comments

Comments
 (0)