Skip to content

[xDS] A97 - JWT token file call creds #12242

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions alts/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id "java-library"
id "java-test-fixtures"
id "maven-publish"

id "com.google.protobuf"
Expand Down Expand Up @@ -36,6 +37,9 @@ dependencies {
libraries.mockito.core,
libraries.truth

testFixturesImplementation libraries.junit,
libraries.guava

testImplementation libraries.guava.testlib
testRuntimeOnly libraries.netty.tcnative,
libraries.netty.tcnative.classes
Expand Down Expand Up @@ -105,3 +109,6 @@ publishing {
}
}
}

components.java.withVariantsFromConfiguration(configurations.testFixturesApiElements) { skip() }
components.java.withVariantsFromConfiguration(configurations.testFixturesRuntimeElements) { skip() }
64 changes: 64 additions & 0 deletions alts/src/main/java/io/grpc/alts/JwtTokenFileCallCredentials.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2025 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.alts;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why were these files added to alts? I'd have expected xds.


import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.json.webtoken.JsonWebSignature;
import com.google.auth.oauth2.AccessToken;
import com.google.auth.oauth2.OAuth2Credentials;
import com.google.common.io.Files;
import io.grpc.CallCredentials;
import io.grpc.auth.MoreCallCredentials;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Date;

/**
* JWT token file call credentials.
* See gRFC A97 (https://github.com/grpc/proposal/pull/492).
*/
public final class JwtTokenFileCallCredentials extends OAuth2Credentials {
private static final long serialVersionUID = 452556614608513984L;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: You can just use the value 0 or 1, or your favorite number 42. The ugly computed number is necessary when you were previously relying on the JVM to calculate the version for you, and you want to remain compatible with the previously-computed value.

private String path = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

final


private JwtTokenFileCallCredentials(String path) {
this.path = path;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkNotNull(path, "path")

Any time we save a value for later, we should be checking for null if it shouldn't be null. That means it is more likely the stack trace will tell us who provided the wrong value. The "path" string is an arbitrary semi-unique string, and useful just because line numbers can be unreliable (the number of times users don't know the version they are running or are incorrect...). We just repeat the variable name because it is unique (for the function), short, and requires zero thought.

}

@Override
public AccessToken refreshAccessToken() throws IOException {
String tokenString = new String(Files.toByteArray(new File(path)), StandardCharsets.UTF_8);
Long expTime = JsonWebSignature.parse(new GsonFactory(), tokenString)
.getPayload()
.getExpirationTimeSeconds();
if (expTime == null) {
throw new IOException("No expiration time found for JWT token");
}

return AccessToken.newBuilder()
.setTokenValue(tokenString)
.setExpirationTime(new Date(expTime * 1000L))
.build();
}

// using {@link MoreCallCredentials} adapter to be compatible with {@link CallCredentials} iface
public static CallCredentials create(String path) {
JwtTokenFileCallCredentials jwtTokenFileCallCredentials = new JwtTokenFileCallCredentials(path);
return MoreCallCredentials.from(jwtTokenFileCallCredentials);
}
}
139 changes: 139 additions & 0 deletions alts/src/test/java/io/grpc/alts/JwtTokenFileCallCredentialsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Copyright 2025 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.alts;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;

import com.google.auth.oauth2.AccessToken;
import com.google.common.truth.Truth;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Instant;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.experimental.runners.Enclosed;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/** Unit tests for {@link JwtTokenFileCallCredentials}. */
@RunWith(Enclosed.class)
public class JwtTokenFileCallCredentialsTest {
@RunWith(JUnit4.class)
public static class WithEmptyJwtTokenTest {
@Rule
public TemporaryFolder tempFolder = new TemporaryFolder();

private File jwtTokenFile;
private JwtTokenFileCallCredentials unit;

@Before
public void setUp() throws Exception {
this.jwtTokenFile = JwtTokenFileTestUtils.createEmptyJwtToken(tempFolder);

Constructor<JwtTokenFileCallCredentials> ctor =
JwtTokenFileCallCredentials.class.getDeclaredConstructor(String.class);
ctor.setAccessible(true);
this.unit = ctor.newInstance(jwtTokenFile.toString());
}

@Test
public void givenJwtTokenFileEmpty_WhenTokenRefreshed_ExpectException() {
assertThrows(IllegalArgumentException.class, () -> {
unit.refreshAccessToken();
});
}
}

@RunWith(JUnit4.class)
public static class WithInvalidJwtTokenTest {
@Rule
public TemporaryFolder tempFolder = new TemporaryFolder();

private File jwtTokenFile;
private JwtTokenFileCallCredentials unit;

@Before
public void setUp() throws Exception {
this.jwtTokenFile = JwtTokenFileTestUtils.createJwtTokenWithoutExpiration(tempFolder);

Constructor<JwtTokenFileCallCredentials> ctor =
JwtTokenFileCallCredentials.class.getDeclaredConstructor(String.class);
ctor.setAccessible(true);
this.unit = ctor.newInstance(jwtTokenFile.toString());
}

@Test
public void givenJwtTokenFileWithoutExpiration_WhenTokenRefreshed_ExpectException()
throws Exception {
Exception ex = assertThrows(IOException.class, () -> {
unit.refreshAccessToken();
});

String expectedMsg = "No expiration time found for JWT token";
String actualMsg = ex.getMessage();

assertEquals(expectedMsg, actualMsg);
}
}

@RunWith(JUnit4.class)
public static class WithValidJwtTokenTest {
@Rule
public TemporaryFolder tempFolder = new TemporaryFolder();

private File jwtTokenFile;
private JwtTokenFileCallCredentials unit;
private Long givenExpTimeInSeconds;

@Before
public void setUp() throws Exception {
this.givenExpTimeInSeconds = Instant.now().getEpochSecond() + TimeUnit.HOURS.toSeconds(1);

this.jwtTokenFile = JwtTokenFileTestUtils.createValidJwtToken(
tempFolder, givenExpTimeInSeconds);

Constructor<JwtTokenFileCallCredentials> ctor =
JwtTokenFileCallCredentials.class.getDeclaredConstructor(String.class);
ctor.setAccessible(true);
this.unit = ctor.newInstance(jwtTokenFile.toString());
}

@Test
public void givenValidJwtTokenFile_WhenTokenRefreshed_ExpectAccessTokenInstance()
throws Exception {
final Date givenExpTimeDate = new Date(TimeUnit.SECONDS.toMillis(givenExpTimeInSeconds));

String givenTokenValue = new String(
Files.readAllBytes(jwtTokenFile.toPath()),
StandardCharsets.UTF_8);

AccessToken token = unit.refreshAccessToken();

Truth.assertThat(token.getExpirationTime())
.isEquivalentAccordingToCompareTo(givenExpTimeDate);
assertEquals(token.getTokenValue(), givenTokenValue);
}
}
}
63 changes: 63 additions & 0 deletions alts/src/testFixtures/java/io/grpc/alts/JwtTokenFileTestUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright 2025 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.alts;

import com.google.common.io.BaseEncoding;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
import org.junit.rules.TemporaryFolder;

public class JwtTokenFileTestUtils {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are only used by one test, and I don't expect many other tests to use this. We'd typically just have these as private methods in the test class. But if you do really want this in a separate file, that's fine, but just have it sit next to the test until it seems like it is going to be used.

If you are going to keep this as a separate file, you probably should remove the TemporaryFolder usages in the class. That is mixing responsibilities and reduces the reusability.

public static File createEmptyJwtToken(TemporaryFolder tempFolder) throws Exception {
File jwtToken = tempFolder.newFile(new String("jwt.token"));
return jwtToken;
}

public static File createJwtTokenWithoutExpiration(TemporaryFolder tempFolder) throws Exception {
File jwtToken = tempFolder.newFile(new String("jwt.token"));
FileOutputStream outputStream = new FileOutputStream(jwtToken);
String content =
BaseEncoding.base64().encode(
new String("{\"typ\": \"JWT\", \"alg\": \"HS256\"}").getBytes(StandardCharsets.UTF_8))
+ "."
+ BaseEncoding.base64().encode(
new String("{\"name\": \"Google\"}").getBytes(StandardCharsets.UTF_8))
+ "."
+ BaseEncoding.base64().encode(new String("signature").getBytes(StandardCharsets.UTF_8));
outputStream.write(content.getBytes(StandardCharsets.UTF_8));
outputStream.close();
return jwtToken;
}

public static File createValidJwtToken(TemporaryFolder tempFolder, Long expTime)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/Long/long/. It doesn't look like null is valid.

throws Exception {
File jwtToken = tempFolder.newFile(new String("jwt.token"));
FileOutputStream outputStream = new FileOutputStream(jwtToken);
String content =
BaseEncoding.base64().encode(
new String("{\"typ\": \"JWT\", \"alg\": \"HS256\"}").getBytes(StandardCharsets.UTF_8))
+ "."
+ BaseEncoding.base64().encode(
String.format("{\"exp\": %d}", expTime).getBytes(StandardCharsets.UTF_8))
+ "."
+ BaseEncoding.base64().encode(new String("signature").getBytes(StandardCharsets.UTF_8));
outputStream.write(content.getBytes(StandardCharsets.UTF_8));
outputStream.close();
return jwtToken;
}
}
64 changes: 63 additions & 1 deletion xds/src/main/java/io/grpc/xds/GrpcBootstrapperImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@

import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableMap;
import io.grpc.CallCredentials;
import io.grpc.ChannelCredentials;
import io.grpc.CompositeCallCredentials;
import io.grpc.internal.GrpcUtil;
import io.grpc.internal.JsonUtil;
import io.grpc.xds.client.BootstrapperImpl;
import io.grpc.xds.client.XdsInitializationException;
Expand All @@ -33,6 +36,8 @@ class GrpcBootstrapperImpl extends BootstrapperImpl {
private static final String BOOTSTRAP_PATH_SYS_PROPERTY = "io.grpc.xds.bootstrap";
private static final String BOOTSTRAP_CONFIG_SYS_ENV_VAR = "GRPC_XDS_BOOTSTRAP_CONFIG";
private static final String BOOTSTRAP_CONFIG_SYS_PROPERTY = "io.grpc.xds.bootstrapConfig";
private static final String GRPC_EXPERIMENTAL_XDS_BOOTSTRAP_CALL_CREDS =
"GRPC_EXPERIMENTAL_XDS_BOOTSTRAP_CALL_CREDS";
@VisibleForTesting
String bootstrapPathFromEnvVar = System.getenv(BOOTSTRAP_PATH_SYS_ENV_VAR);
@VisibleForTesting
Expand All @@ -41,6 +46,9 @@ class GrpcBootstrapperImpl extends BootstrapperImpl {
String bootstrapConfigFromEnvVar = System.getenv(BOOTSTRAP_CONFIG_SYS_ENV_VAR);
@VisibleForTesting
String bootstrapConfigFromSysProp = System.getProperty(BOOTSTRAP_CONFIG_SYS_PROPERTY);
@VisibleForTesting
static boolean xdsBootstrapCallCredsEnabled = GrpcUtil.getFlag(
GRPC_EXPERIMENTAL_XDS_BOOTSTRAP_CALL_CREDS, false);

GrpcBootstrapperImpl() {
super();
Expand Down Expand Up @@ -90,7 +98,7 @@ protected String getJsonContent() throws XdsInitializationException, IOException
}

@Override
protected Object getImplSpecificConfig(Map<String, ?> serverConfig, String serverUri)
protected Object getImplSpecificChannelCredConfig(Map<String, ?> serverConfig, String serverUri)
throws XdsInitializationException {
return getChannelCredentials(serverConfig, serverUri);
}
Expand Down Expand Up @@ -135,4 +143,58 @@ private static ChannelCredentials parseChannelCredentials(List<Map<String, ?>> j
}
return null;
}

@Override
protected Object getImplSpecificCallCredConfig(Map<String, ?> serverConfig, String serverUri)
throws XdsInitializationException {
return getCallCredentials(serverConfig, serverUri);
}

private static CallCredentials getCallCredentials(Map<String, ?> serverConfig,
String serverUri)
throws XdsInitializationException {
List<?> rawCallCredsList = JsonUtil.getList(serverConfig, "call_creds");
if (rawCallCredsList == null || rawCallCredsList.isEmpty()) {
return null;
}
CallCredentials callCredentials =
parseCallCredentials(JsonUtil.checkObjectList(rawCallCredsList), serverUri);
return callCredentials;
}

@Nullable
private static CallCredentials parseCallCredentials(List<Map<String, ?>> jsonList,
String serverUri)
throws XdsInitializationException {
CallCredentials callCredentials = null;
if (xdsBootstrapCallCredsEnabled) {
for (Map<String, ?> callCreds : jsonList) {
String type = JsonUtil.getString(callCreds, "type");
if (type != null) {
XdsCredentialsProvider provider = XdsCredentialsRegistry.getDefaultRegistry()
.getProvider(type);
if (provider != null) {
Map<String, ?> config = JsonUtil.getObject(callCreds, "config");
if (config == null) {
config = ImmutableMap.of();
}
CallCredentials parsedCallCredentials = provider.newCallCredentials(config);
if (parsedCallCredentials == null) {
throw new XdsInitializationException(
"Invalid bootstrap: server " + serverUri + " with invalid 'config' for " + type
+ " 'call_creds'");
}

if (callCredentials == null) {
callCredentials = parsedCallCredentials;
} else {
callCredentials = new CompositeCallCredentials(
callCredentials, parsedCallCredentials);
}
}
}
}
}
return callCredentials;
}
}
Loading
Loading