Skip to content

Commit a8f76b0

Browse files
committed
JWT token file call creds
1 parent 6ff8eca commit a8f76b0

24 files changed

+726
-35
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright 2025 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.alts;
18+
19+
import com.google.api.client.json.gson.GsonFactory;
20+
import com.google.api.client.json.webtoken.JsonWebSignature;
21+
import com.google.auth.oauth2.AccessToken;
22+
import com.google.auth.oauth2.OAuth2Credentials;
23+
import com.google.common.io.Files;
24+
import io.grpc.CallCredentials;
25+
import io.grpc.auth.MoreCallCredentials;
26+
import java.io.File;
27+
import java.io.IOException;
28+
import java.nio.charset.StandardCharsets;
29+
import java.util.Date;
30+
31+
/**
32+
* JWT token file call credentials.
33+
* See gRFC A97 (https://github.com/grpc/proposal/pull/492).
34+
*/
35+
public final class JwtTokenFileCallCredentials extends OAuth2Credentials {
36+
private String path = null;
37+
38+
private JwtTokenFileCallCredentials(String path) {
39+
this.path = path;
40+
}
41+
42+
@Override
43+
public AccessToken refreshAccessToken() throws IOException {
44+
String tokenString = new String(Files.toByteArray(new File(path)), StandardCharsets.UTF_8);
45+
Long expTime = JsonWebSignature.parse(new GsonFactory(), tokenString)
46+
.getPayload()
47+
.getExpirationTimeSeconds();
48+
if (expTime == null) {
49+
throw new IOException("No expiration time found for JWT token");
50+
}
51+
52+
return AccessToken.newBuilder()
53+
.setTokenValue(tokenString)
54+
.setExpirationTime(new Date(expTime * 1000L))
55+
.build();
56+
}
57+
58+
// using {@link MoreCallCredentials} adapter to be compatible with {@link CallCredentials} iface
59+
public static CallCredentials create(String path) {
60+
JwtTokenFileCallCredentials jwtTokenFileCallCredentials = new JwtTokenFileCallCredentials(path);
61+
return MoreCallCredentials.from(jwtTokenFileCallCredentials);
62+
}
63+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
* Copyright 2025 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.alts;
18+
19+
import static org.junit.Assert.assertEquals;
20+
import static org.junit.Assert.assertThrows;
21+
22+
import com.google.auth.oauth2.AccessToken;
23+
import com.google.common.io.BaseEncoding;
24+
import com.google.common.truth.Truth;
25+
import java.io.File;
26+
import java.io.FileOutputStream;
27+
import java.io.IOException;
28+
import java.lang.reflect.Constructor;
29+
import java.nio.charset.StandardCharsets;
30+
import java.util.Date;
31+
import org.junit.Before;
32+
import org.junit.Rule;
33+
import org.junit.Test;
34+
import org.junit.rules.TemporaryFolder;
35+
import org.junit.runner.RunWith;
36+
import org.junit.runners.JUnit4;
37+
38+
/** Unit tests for {@link JwtTokenFileCallCredentials}. */
39+
@RunWith(JUnit4.class)
40+
public class JwtTokenFileCallCredentialsTest {
41+
@Rule
42+
public TemporaryFolder tempFolder = new TemporaryFolder();
43+
44+
private File jwtTokenFile;
45+
private JwtTokenFileCallCredentials unit;
46+
47+
@Before
48+
public void setUp() throws Exception {
49+
jwtTokenFile = tempFolder.newFile(new String("jwt.json"));
50+
51+
Constructor<JwtTokenFileCallCredentials> ctor =
52+
JwtTokenFileCallCredentials.class.getDeclaredConstructor(String.class);
53+
ctor.setAccessible(true);
54+
unit = ctor.newInstance(jwtTokenFile.toString());
55+
}
56+
57+
private void fillJwtTokenWithoutExpiration(File jwtFile) throws Exception {
58+
FileOutputStream outputStream = new FileOutputStream(jwtFile);
59+
String content =
60+
BaseEncoding.base64().encode(
61+
new String("{\"typ\": \"JWT\", \"alg\": \"HS256\"}").getBytes(StandardCharsets.UTF_8))
62+
+ "."
63+
+ BaseEncoding.base64().encode(
64+
new String("{\"name\": \"Google\"}").getBytes(StandardCharsets.UTF_8))
65+
+ "."
66+
+ BaseEncoding.base64().encode(new String("signature").getBytes(StandardCharsets.UTF_8));
67+
outputStream.write(content.getBytes(StandardCharsets.UTF_8));
68+
outputStream.close();
69+
}
70+
71+
private String fillValidJwtToken(File jwtFile, Long expTime) throws Exception {
72+
FileOutputStream outputStream = new FileOutputStream(jwtFile);
73+
String content =
74+
BaseEncoding.base64().encode(
75+
new String("{\"typ\": \"JWT\", \"alg\": \"HS256\"}").getBytes(StandardCharsets.UTF_8))
76+
+ "."
77+
+ BaseEncoding.base64().encode(
78+
String.format("{\"exp\": %d}", expTime).getBytes(StandardCharsets.UTF_8))
79+
+ "."
80+
+ BaseEncoding.base64().encode(new String("signature").getBytes(StandardCharsets.UTF_8));
81+
outputStream.write(content.getBytes(StandardCharsets.UTF_8));
82+
outputStream.close();
83+
return content;
84+
}
85+
86+
@Test
87+
public void givenJwtTokenFileEmpty_WhenTokenRefreshed_ExpectException() {
88+
assertThrows(IllegalArgumentException.class, () -> {
89+
unit.refreshAccessToken();
90+
});
91+
}
92+
93+
@Test
94+
public void givenJwtTokenFileWithoutExpiration_WhenTokenRefreshed_ExpectException()
95+
throws Exception {
96+
97+
fillJwtTokenWithoutExpiration(jwtTokenFile);
98+
99+
Exception ex = assertThrows(IOException.class, () -> {
100+
unit.refreshAccessToken();
101+
});
102+
103+
String expectedMsg = "No expiration time found for JWT token";
104+
String actualMsg = ex.getMessage();
105+
106+
assertEquals(expectedMsg, actualMsg);
107+
}
108+
109+
@Test
110+
public void givenValidJwtTokenFile_WhenTokenRefreshed_ExpectAccessTokenInstance()
111+
throws Exception {
112+
final Long givenExpTimeInSeconds = 1753364000L;
113+
final Date givenExpTimeDate = new Date(givenExpTimeInSeconds * 1000L);
114+
115+
String givenTokenValue = fillValidJwtToken(jwtTokenFile, givenExpTimeInSeconds);
116+
117+
AccessToken token = unit.refreshAccessToken();
118+
119+
Truth.assertThat(token.getExpirationTime()).isEquivalentAccordingToCompareTo(givenExpTimeDate);
120+
assertEquals(token.getTokenValue(), givenTokenValue);
121+
}
122+
}

xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818

1919
import com.google.common.annotations.VisibleForTesting;
2020
import com.google.common.collect.ImmutableMap;
21+
import io.grpc.CallCredentials;
2122
import io.grpc.ChannelCredentials;
23+
import io.grpc.CompositeCallCredentials;
24+
import io.grpc.internal.GrpcUtil;
2225
import io.grpc.internal.JsonUtil;
2326
import io.grpc.xds.client.BootstrapperImpl;
2427
import io.grpc.xds.client.XdsInitializationException;
@@ -33,6 +36,8 @@ class GrpcBootstrapperImpl extends BootstrapperImpl {
3336
private static final String BOOTSTRAP_PATH_SYS_PROPERTY = "io.grpc.xds.bootstrap";
3437
private static final String BOOTSTRAP_CONFIG_SYS_ENV_VAR = "GRPC_XDS_BOOTSTRAP_CONFIG";
3538
private static final String BOOTSTRAP_CONFIG_SYS_PROPERTY = "io.grpc.xds.bootstrapConfig";
39+
private static final String GRPC_EXPERIMENTAL_XDS_BOOTSTRAP_CALL_CREDS =
40+
"GRPC_EXPERIMENTAL_XDS_BOOTSTRAP_CALL_CREDS";
3641
@VisibleForTesting
3742
String bootstrapPathFromEnvVar = System.getenv(BOOTSTRAP_PATH_SYS_ENV_VAR);
3843
@VisibleForTesting
@@ -41,6 +46,9 @@ class GrpcBootstrapperImpl extends BootstrapperImpl {
4146
String bootstrapConfigFromEnvVar = System.getenv(BOOTSTRAP_CONFIG_SYS_ENV_VAR);
4247
@VisibleForTesting
4348
String bootstrapConfigFromSysProp = System.getProperty(BOOTSTRAP_CONFIG_SYS_PROPERTY);
49+
@VisibleForTesting
50+
static boolean xdsBootstrapCallCredsEnabled = GrpcUtil.getFlag(
51+
GRPC_EXPERIMENTAL_XDS_BOOTSTRAP_CALL_CREDS, false);
4452

4553
GrpcBootstrapperImpl() {
4654
super();
@@ -90,7 +98,7 @@ protected String getJsonContent() throws XdsInitializationException, IOException
9098
}
9199

92100
@Override
93-
protected Object getImplSpecificConfig(Map<String, ?> serverConfig, String serverUri)
101+
protected Object getImplSpecificChannelCredConfig(Map<String, ?> serverConfig, String serverUri)
94102
throws XdsInitializationException {
95103
return getChannelCredentials(serverConfig, serverUri);
96104
}
@@ -135,4 +143,58 @@ private static ChannelCredentials parseChannelCredentials(List<Map<String, ?>> j
135143
}
136144
return null;
137145
}
146+
147+
@Override
148+
protected Object getImplSpecificCallCredConfig(Map<String, ?> serverConfig, String serverUri)
149+
throws XdsInitializationException {
150+
return getCallCredentials(serverConfig, serverUri);
151+
}
152+
153+
private static CallCredentials getCallCredentials(Map<String, ?> serverConfig,
154+
String serverUri)
155+
throws XdsInitializationException {
156+
List<?> rawCallCredsList = JsonUtil.getList(serverConfig, "call_creds");
157+
if (rawCallCredsList == null || rawCallCredsList.isEmpty()) {
158+
return null;
159+
}
160+
CallCredentials callCredentials =
161+
parseCallCredentials(JsonUtil.checkObjectList(rawCallCredsList), serverUri);
162+
return callCredentials;
163+
}
164+
165+
@Nullable
166+
private static CallCredentials parseCallCredentials(List<Map<String, ?>> jsonList,
167+
String serverUri)
168+
throws XdsInitializationException {
169+
CallCredentials callCredentials = null;
170+
if (xdsBootstrapCallCredsEnabled) {
171+
for (Map<String, ?> callCreds : jsonList) {
172+
String type = JsonUtil.getString(callCreds, "type");
173+
if (type != null) {
174+
XdsCredentialsProvider provider = XdsCredentialsRegistry.getDefaultRegistry()
175+
.getProvider(type);
176+
if (provider != null) {
177+
Map<String, ?> config = JsonUtil.getObject(callCreds, "config");
178+
if (config == null) {
179+
config = ImmutableMap.of();
180+
}
181+
CallCredentials parsedCallCredentials = provider.newCallCredentials(config);
182+
if (parsedCallCredentials == null) {
183+
throw new XdsInitializationException(
184+
"Invalid bootstrap: server " + serverUri + " with invalid 'config' for " + type
185+
+ " 'call_creds'");
186+
}
187+
188+
if (callCredentials == null) {
189+
callCredentials = parsedCallCredentials;
190+
} else {
191+
callCredentials = new CompositeCallCredentials(
192+
callCredentials, parsedCallCredentials);
193+
}
194+
}
195+
}
196+
}
197+
}
198+
return callCredentials;
199+
}
138200
}

xds/src/main/java/io/grpc/xds/GrpcXdsTransportFactory.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import io.grpc.CallOptions;
2424
import io.grpc.ChannelCredentials;
2525
import io.grpc.ClientCall;
26+
import io.grpc.CompositeCallCredentials;
27+
import io.grpc.CompositeChannelCredentials;
2628
import io.grpc.Context;
2729
import io.grpc.Grpc;
2830
import io.grpc.ManagedChannel;
@@ -68,11 +70,26 @@ public GrpcXdsTransport(ManagedChannel channel) {
6870

6971
public GrpcXdsTransport(Bootstrapper.ServerInfo serverInfo, CallCredentials callCredentials) {
7072
String target = serverInfo.target();
71-
ChannelCredentials channelCredentials = (ChannelCredentials) serverInfo.implSpecificConfig();
73+
ChannelCredentials channelCredentials =
74+
(ChannelCredentials) serverInfo.implSpecificChannelCredConfig();
75+
Object callCredConfig = serverInfo.implSpecificCallCredConfig();
76+
if (callCredConfig != null) {
77+
channelCredentials = CompositeChannelCredentials.create(
78+
channelCredentials, (CallCredentials) callCredConfig);
79+
}
80+
7281
this.channel = Grpc.newChannelBuilder(target, channelCredentials)
7382
.keepAliveTime(5, TimeUnit.MINUTES)
7483
.build();
75-
this.callCredentials = callCredentials;
84+
85+
if (callCredentials != null && callCredConfig != null) {
86+
this.callCredentials =
87+
new CompositeCallCredentials(callCredentials, (CallCredentials) callCredConfig);
88+
} else if (callCredConfig != null) {
89+
this.callCredentials = (CallCredentials) callCredConfig;
90+
} else {
91+
this.callCredentials = callCredentials;
92+
}
7693
}
7794

7895
@VisibleForTesting

xds/src/main/java/io/grpc/xds/XdsCredentialsProvider.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package io.grpc.xds;
1818

19+
import io.grpc.CallCredentials;
1920
import io.grpc.ChannelCredentials;
2021
import io.grpc.Internal;
2122
import java.util.Map;
@@ -49,6 +50,17 @@ public abstract class XdsCredentialsProvider {
4950
*/
5051
protected abstract ChannelCredentials newChannelCredentials(Map<String, ?> jsonConfig);
5152

53+
/**
54+
* Creates a {@link CallCredentials} from the given jsonConfig, or
55+
* {@code null} if the given config is invalid. The provider is free to ignore
56+
* the config if it's not needed for producing the channel credentials.
57+
*
58+
* @param jsonConfig json config that can be consumed by the provider to create
59+
* the channel credentials
60+
*
61+
*/
62+
protected abstract CallCredentials newCallCredentials(Map<String, ?> jsonConfig);
63+
5264
/**
5365
* Returns the xDS credential name associated with this provider which makes it selectable
5466
* via {@link XdsCredentialsRegistry#getProvider}. This is called only when the class is loaded.

xds/src/main/java/io/grpc/xds/XdsCredentialsRegistry.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ public static synchronized XdsCredentialsRegistry getDefaultRegistry() {
114114
new XdsCredentialsProviderPriorityAccessor());
115115
if (providerList.isEmpty()) {
116116
logger.warning("No XdsCredsRegistry found via ServiceLoader, including for GoogleDefault, "
117-
+ "TLS and Insecure. This is probably due to a broken build.");
117+
+ "TLS, Insecure and JWT token file. This is probably due to a broken build.");
118118
}
119119
instance = new XdsCredentialsRegistry();
120120
for (XdsCredentialsProvider provider : providerList) {
@@ -170,7 +170,13 @@ static List<Class<?>> getHardCodedClasses() {
170170
} catch (ClassNotFoundException e) {
171171
logger.log(Level.WARNING, "Unable to find TlsXdsCredentialsProvider", e);
172172
}
173-
173+
174+
try {
175+
list.add(Class.forName("io.grpc.xds.internal.JwtTokenFileXdsCredentialsProvider"));
176+
} catch (ClassNotFoundException e) {
177+
logger.log(Level.WARNING, "Unable to find JwtTokenFileXdsCredentialsProvider", e);
178+
}
179+
174180
return Collections.unmodifiableList(list);
175181
}
176182

0 commit comments

Comments
 (0)