Skip to content

Commit c781460

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

27 files changed

+892
-35
lines changed

alts/build.gradle

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
plugins {
22
id "java-library"
3+
id "java-test-fixtures"
34
id "maven-publish"
45

56
id "com.google.protobuf"
@@ -36,6 +37,9 @@ dependencies {
3637
libraries.mockito.core,
3738
libraries.truth
3839

40+
testFixturesImplementation libraries.junit,
41+
libraries.guava
42+
3943
testImplementation libraries.guava.testlib
4044
testRuntimeOnly libraries.netty.tcnative,
4145
libraries.netty.tcnative.classes
@@ -105,3 +109,6 @@ publishing {
105109
}
106110
}
107111
}
112+
113+
components.java.withVariantsFromConfiguration(configurations.testFixturesApiElements) { skip() }
114+
components.java.withVariantsFromConfiguration(configurations.testFixturesRuntimeElements) { skip() }
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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 static final long serialVersionUID = 452556614608513984L;
37+
private String path = null;
38+
39+
private JwtTokenFileCallCredentials(String path) {
40+
this.path = path;
41+
}
42+
43+
@Override
44+
public AccessToken refreshAccessToken() throws IOException {
45+
String tokenString = new String(Files.toByteArray(new File(path)), StandardCharsets.UTF_8);
46+
Long expTime = JsonWebSignature.parse(new GsonFactory(), tokenString)
47+
.getPayload()
48+
.getExpirationTimeSeconds();
49+
if (expTime == null) {
50+
throw new IOException("No expiration time found for JWT token");
51+
}
52+
53+
return AccessToken.newBuilder()
54+
.setTokenValue(tokenString)
55+
.setExpirationTime(new Date(expTime * 1000L))
56+
.build();
57+
}
58+
59+
// using {@link MoreCallCredentials} adapter to be compatible with {@link CallCredentials} iface
60+
public static CallCredentials create(String path) {
61+
JwtTokenFileCallCredentials jwtTokenFileCallCredentials = new JwtTokenFileCallCredentials(path);
62+
return MoreCallCredentials.from(jwtTokenFileCallCredentials);
63+
}
64+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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.truth.Truth;
24+
import java.io.File;
25+
import java.io.IOException;
26+
import java.lang.reflect.Constructor;
27+
import java.nio.charset.StandardCharsets;
28+
import java.nio.file.Files;
29+
import java.time.Instant;
30+
import java.util.Date;
31+
import java.util.concurrent.TimeUnit;
32+
import org.junit.Before;
33+
import org.junit.Rule;
34+
import org.junit.Test;
35+
import org.junit.experimental.runners.Enclosed;
36+
import org.junit.rules.TemporaryFolder;
37+
import org.junit.runner.RunWith;
38+
import org.junit.runners.JUnit4;
39+
40+
/** Unit tests for {@link JwtTokenFileCallCredentials}. */
41+
@RunWith(Enclosed.class)
42+
public class JwtTokenFileCallCredentialsTest {
43+
@RunWith(JUnit4.class)
44+
public static class WithEmptyJwtTokenTest {
45+
@Rule
46+
public TemporaryFolder tempFolder = new TemporaryFolder();
47+
48+
private File jwtTokenFile;
49+
private JwtTokenFileCallCredentials unit;
50+
51+
@Before
52+
public void setUp() throws Exception {
53+
this.jwtTokenFile = JwtTokenFileTestUtils.createEmptyJwtToken(tempFolder);
54+
55+
Constructor<JwtTokenFileCallCredentials> ctor =
56+
JwtTokenFileCallCredentials.class.getDeclaredConstructor(String.class);
57+
ctor.setAccessible(true);
58+
this.unit = ctor.newInstance(jwtTokenFile.toString());
59+
}
60+
61+
@Test
62+
public void givenJwtTokenFileEmpty_WhenTokenRefreshed_ExpectException() {
63+
assertThrows(IllegalArgumentException.class, () -> {
64+
unit.refreshAccessToken();
65+
});
66+
}
67+
}
68+
69+
@RunWith(JUnit4.class)
70+
public static class WithInvalidJwtTokenTest {
71+
@Rule
72+
public TemporaryFolder tempFolder = new TemporaryFolder();
73+
74+
private File jwtTokenFile;
75+
private JwtTokenFileCallCredentials unit;
76+
77+
@Before
78+
public void setUp() throws Exception {
79+
this.jwtTokenFile = JwtTokenFileTestUtils.createJwtTokenWithoutExpiration(tempFolder);
80+
81+
Constructor<JwtTokenFileCallCredentials> ctor =
82+
JwtTokenFileCallCredentials.class.getDeclaredConstructor(String.class);
83+
ctor.setAccessible(true);
84+
this.unit = ctor.newInstance(jwtTokenFile.toString());
85+
}
86+
87+
@Test
88+
public void givenJwtTokenFileWithoutExpiration_WhenTokenRefreshed_ExpectException()
89+
throws Exception {
90+
Exception ex = assertThrows(IOException.class, () -> {
91+
unit.refreshAccessToken();
92+
});
93+
94+
String expectedMsg = "No expiration time found for JWT token";
95+
String actualMsg = ex.getMessage();
96+
97+
assertEquals(expectedMsg, actualMsg);
98+
}
99+
}
100+
101+
@RunWith(JUnit4.class)
102+
public static class WithValidJwtTokenTest {
103+
@Rule
104+
public TemporaryFolder tempFolder = new TemporaryFolder();
105+
106+
private File jwtTokenFile;
107+
private JwtTokenFileCallCredentials unit;
108+
private Long givenExpTimeInSeconds;
109+
110+
@Before
111+
public void setUp() throws Exception {
112+
this.givenExpTimeInSeconds = Instant.now().getEpochSecond() + TimeUnit.HOURS.toSeconds(1);
113+
114+
this.jwtTokenFile = JwtTokenFileTestUtils.createValidJwtToken(
115+
tempFolder, givenExpTimeInSeconds);
116+
117+
Constructor<JwtTokenFileCallCredentials> ctor =
118+
JwtTokenFileCallCredentials.class.getDeclaredConstructor(String.class);
119+
ctor.setAccessible(true);
120+
this.unit = ctor.newInstance(jwtTokenFile.toString());
121+
}
122+
123+
@Test
124+
public void givenValidJwtTokenFile_WhenTokenRefreshed_ExpectAccessTokenInstance()
125+
throws Exception {
126+
final Date givenExpTimeDate = new Date(TimeUnit.SECONDS.toMillis(givenExpTimeInSeconds));
127+
128+
String givenTokenValue = new String(
129+
Files.readAllBytes(jwtTokenFile.toPath()),
130+
StandardCharsets.UTF_8);
131+
132+
AccessToken token = unit.refreshAccessToken();
133+
134+
Truth.assertThat(token.getExpirationTime())
135+
.isEquivalentAccordingToCompareTo(givenExpTimeDate);
136+
assertEquals(token.getTokenValue(), givenTokenValue);
137+
}
138+
}
139+
}
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.common.io.BaseEncoding;
20+
import java.io.File;
21+
import java.io.FileOutputStream;
22+
import java.nio.charset.StandardCharsets;
23+
import org.junit.rules.TemporaryFolder;
24+
25+
public class JwtTokenFileTestUtils {
26+
public static File createEmptyJwtToken(TemporaryFolder tempFolder) throws Exception {
27+
File jwtToken = tempFolder.newFile(new String("jwt.token"));
28+
return jwtToken;
29+
}
30+
31+
public static File createJwtTokenWithoutExpiration(TemporaryFolder tempFolder) throws Exception {
32+
File jwtToken = tempFolder.newFile(new String("jwt.token"));
33+
FileOutputStream outputStream = new FileOutputStream(jwtToken);
34+
String content =
35+
BaseEncoding.base64().encode(
36+
new String("{\"typ\": \"JWT\", \"alg\": \"HS256\"}").getBytes(StandardCharsets.UTF_8))
37+
+ "."
38+
+ BaseEncoding.base64().encode(
39+
new String("{\"name\": \"Google\"}").getBytes(StandardCharsets.UTF_8))
40+
+ "."
41+
+ BaseEncoding.base64().encode(new String("signature").getBytes(StandardCharsets.UTF_8));
42+
outputStream.write(content.getBytes(StandardCharsets.UTF_8));
43+
outputStream.close();
44+
return jwtToken;
45+
}
46+
47+
public static File createValidJwtToken(TemporaryFolder tempFolder, Long expTime)
48+
throws Exception {
49+
File jwtToken = tempFolder.newFile(new String("jwt.token"));
50+
FileOutputStream outputStream = new FileOutputStream(jwtToken);
51+
String content =
52+
BaseEncoding.base64().encode(
53+
new String("{\"typ\": \"JWT\", \"alg\": \"HS256\"}").getBytes(StandardCharsets.UTF_8))
54+
+ "."
55+
+ BaseEncoding.base64().encode(
56+
String.format("{\"exp\": %d}", expTime).getBytes(StandardCharsets.UTF_8))
57+
+ "."
58+
+ BaseEncoding.base64().encode(new String("signature").getBytes(StandardCharsets.UTF_8));
59+
outputStream.write(content.getBytes(StandardCharsets.UTF_8));
60+
outputStream.close();
61+
return jwtToken;
62+
}
63+
}

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
}

0 commit comments

Comments
 (0)