Skip to content

Commit 76705c2

Browse files
authored
xds: Implement GcpAuthenticationFilter (#11638)
1 parent a5db67d commit 76705c2

File tree

5 files changed

+410
-0
lines changed

5 files changed

+410
-0
lines changed

xds/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ java_library(
3535
"//:auto_value_annotations",
3636
"//alts",
3737
"//api",
38+
"//auth",
3839
"//context",
3940
"//core:internal",
4041
"//netty",
@@ -45,6 +46,7 @@ java_library(
4546
"@com_google_googleapis//google/rpc:rpc_java_proto",
4647
"@com_google_protobuf//:protobuf_java",
4748
"@com_google_protobuf//:protobuf_java_util",
49+
"@maven//:com_google_auth_google_auth_library_oauth2_http",
4850
artifact("com.google.code.findbugs:jsr305"),
4951
artifact("com.google.code.gson:gson"),
5052
artifact("com.google.errorprone:error_prone_annotations"),
@@ -73,6 +75,7 @@ java_proto_library(
7375
"@envoy_api//envoy/extensions/clusters/aggregate/v3:pkg",
7476
"@envoy_api//envoy/extensions/filters/common/fault/v3:pkg",
7577
"@envoy_api//envoy/extensions/filters/http/fault/v3:pkg",
78+
"@envoy_api//envoy/extensions/filters/http/gcp_authn/v3:pkg",
7679
"@envoy_api//envoy/extensions/filters/http/rbac/v3:pkg",
7780
"@envoy_api//envoy/extensions/filters/http/router/v3:pkg",
7881
"@envoy_api//envoy/extensions/filters/network/http_connection_manager/v3:pkg",
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/*
2+
* Copyright 2021 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.xds;
18+
19+
import com.google.auth.oauth2.ComputeEngineCredentials;
20+
import com.google.auth.oauth2.IdTokenCredentials;
21+
import com.google.common.primitives.UnsignedLongs;
22+
import com.google.protobuf.Any;
23+
import com.google.protobuf.InvalidProtocolBufferException;
24+
import com.google.protobuf.Message;
25+
import io.envoyproxy.envoy.extensions.filters.http.gcp_authn.v3.GcpAuthnFilterConfig;
26+
import io.envoyproxy.envoy.extensions.filters.http.gcp_authn.v3.TokenCacheConfig;
27+
import io.grpc.CallCredentials;
28+
import io.grpc.CallOptions;
29+
import io.grpc.Channel;
30+
import io.grpc.ClientCall;
31+
import io.grpc.ClientInterceptor;
32+
import io.grpc.CompositeCallCredentials;
33+
import io.grpc.LoadBalancer.PickSubchannelArgs;
34+
import io.grpc.Metadata;
35+
import io.grpc.MethodDescriptor;
36+
import io.grpc.Status;
37+
import io.grpc.auth.MoreCallCredentials;
38+
import io.grpc.xds.Filter.ClientInterceptorBuilder;
39+
import java.util.LinkedHashMap;
40+
import java.util.Map;
41+
import java.util.concurrent.ScheduledExecutorService;
42+
import java.util.function.Function;
43+
import javax.annotation.Nullable;
44+
45+
/**
46+
* A {@link Filter} that injects a {@link CallCredentials} to handle
47+
* authentication for xDS credentials.
48+
*/
49+
final class GcpAuthenticationFilter implements Filter, ClientInterceptorBuilder {
50+
51+
static final String TYPE_URL =
52+
"type.googleapis.com/envoy.extensions.filters.http.gcp_authn.v3.GcpAuthnFilterConfig";
53+
54+
@Override
55+
public String[] typeUrls() {
56+
return new String[] { TYPE_URL };
57+
}
58+
59+
@Override
60+
public ConfigOrError<? extends FilterConfig> parseFilterConfig(Message rawProtoMessage) {
61+
GcpAuthnFilterConfig gcpAuthnProto;
62+
if (!(rawProtoMessage instanceof Any)) {
63+
return ConfigOrError.fromError("Invalid config type: " + rawProtoMessage.getClass());
64+
}
65+
Any anyMessage = (Any) rawProtoMessage;
66+
67+
try {
68+
gcpAuthnProto = anyMessage.unpack(GcpAuthnFilterConfig.class);
69+
} catch (InvalidProtocolBufferException e) {
70+
return ConfigOrError.fromError("Invalid proto: " + e);
71+
}
72+
73+
long cacheSize = 10;
74+
// Validate cache_config
75+
TokenCacheConfig cacheConfig = gcpAuthnProto.getCacheConfig();
76+
if (cacheConfig != null) {
77+
cacheSize = cacheConfig.getCacheSize().getValue();
78+
if (cacheSize == 0) {
79+
return ConfigOrError.fromError(
80+
"cache_config.cache_size must be greater than zero");
81+
}
82+
// LruCache's size is an int and briefly exceeds its maximum size before evicting entries
83+
cacheSize = UnsignedLongs.min(cacheSize, Integer.MAX_VALUE - 1);
84+
}
85+
86+
GcpAuthenticationConfig config = new GcpAuthenticationConfig((int) cacheSize);
87+
return ConfigOrError.fromConfig(config);
88+
}
89+
90+
@Override
91+
public ConfigOrError<? extends FilterConfig> parseFilterConfigOverride(Message rawProtoMessage) {
92+
return parseFilterConfig(rawProtoMessage);
93+
}
94+
95+
@Nullable
96+
@Override
97+
public ClientInterceptor buildClientInterceptor(FilterConfig config,
98+
@Nullable FilterConfig overrideConfig, PickSubchannelArgs args,
99+
ScheduledExecutorService scheduler) {
100+
101+
ComputeEngineCredentials credentials = ComputeEngineCredentials.create();
102+
LruCache<String, CallCredentials> callCredentialsCache =
103+
new LruCache<>(((GcpAuthenticationConfig) config).getCacheSize());
104+
return new ClientInterceptor() {
105+
@Override
106+
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
107+
MethodDescriptor<ReqT, RespT> method, CallOptions callOptions, Channel next) {
108+
109+
/*String clusterName = callOptions.getOption(InternalXdsAttributes.ATTR_CLUSTER_NAME);
110+
if (clusterName == null) {
111+
return next.newCall(method, callOptions);
112+
}*/
113+
114+
// TODO: Fetch the CDS resource for the cluster.
115+
// If the CDS resource is not available, fail the RPC with Status.UNAVAILABLE.
116+
117+
// TODO: Extract the audience from the CDS resource metadata.
118+
// If the audience is not found or is in the wrong format, fail the RPC.
119+
String audience = "TEST_AUDIENCE";
120+
121+
try {
122+
CallCredentials existingCallCredentials = callOptions.getCredentials();
123+
CallCredentials newCallCredentials =
124+
getCallCredentials(callCredentialsCache, audience, credentials);
125+
if (existingCallCredentials != null) {
126+
callOptions = callOptions.withCallCredentials(
127+
new CompositeCallCredentials(existingCallCredentials, newCallCredentials));
128+
} else {
129+
callOptions = callOptions.withCallCredentials(newCallCredentials);
130+
}
131+
}
132+
catch (Exception e) {
133+
// If we fail to attach CallCredentials due to any reason, return a FailingClientCall
134+
return new FailingClientCall<>(Status.UNAUTHENTICATED
135+
.withDescription("Failed to attach CallCredentials.")
136+
.withCause(e));
137+
}
138+
return next.newCall(method, callOptions);
139+
}
140+
};
141+
}
142+
143+
private CallCredentials getCallCredentials(LruCache<String, CallCredentials> cache,
144+
String audience, ComputeEngineCredentials credentials) {
145+
146+
synchronized (cache) {
147+
return cache.getOrInsert(audience, key -> {
148+
IdTokenCredentials creds = IdTokenCredentials.newBuilder()
149+
.setIdTokenProvider(credentials)
150+
.setTargetAudience(audience)
151+
.build();
152+
return MoreCallCredentials.from(creds);
153+
});
154+
}
155+
}
156+
157+
static final class GcpAuthenticationConfig implements FilterConfig {
158+
159+
private final int cacheSize;
160+
161+
public GcpAuthenticationConfig(int cacheSize) {
162+
this.cacheSize = cacheSize;
163+
}
164+
165+
public int getCacheSize() {
166+
return cacheSize;
167+
}
168+
169+
@Override
170+
public String typeUrl() {
171+
return GcpAuthenticationFilter.TYPE_URL;
172+
}
173+
}
174+
175+
/** An implementation of {@link ClientCall} that fails when started. */
176+
private static final class FailingClientCall<ReqT, RespT> extends ClientCall<ReqT, RespT> {
177+
178+
private final Status error;
179+
180+
public FailingClientCall(Status error) {
181+
this.error = error;
182+
}
183+
184+
@Override
185+
public void start(ClientCall.Listener<RespT> listener, Metadata headers) {
186+
listener.onClose(error, new Metadata());
187+
}
188+
189+
@Override
190+
public void request(int numMessages) {}
191+
192+
@Override
193+
public void cancel(String message, Throwable cause) {}
194+
195+
@Override
196+
public void halfClose() {}
197+
198+
@Override
199+
public void sendMessage(ReqT message) {}
200+
}
201+
202+
private static final class LruCache<K, V> {
203+
204+
private final Map<K, V> cache;
205+
206+
LruCache(int maxSize) {
207+
this.cache = new LinkedHashMap<K, V>(
208+
maxSize,
209+
0.75f,
210+
true) {
211+
@Override
212+
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
213+
return size() > maxSize;
214+
}
215+
};
216+
}
217+
218+
V getOrInsert(K key, Function<K, V> create) {
219+
return cache.computeIfAbsent(key, create);
220+
}
221+
}
222+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
* Copyright 2024 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.xds;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
import static org.junit.Assert.assertEquals;
21+
import static org.junit.Assert.assertNotNull;
22+
import static org.junit.Assert.assertNull;
23+
import static org.junit.Assert.assertSame;
24+
import static org.junit.Assert.assertTrue;
25+
import static org.mockito.ArgumentMatchers.eq;
26+
27+
import com.google.protobuf.Any;
28+
import com.google.protobuf.Empty;
29+
import com.google.protobuf.Message;
30+
import com.google.protobuf.UInt64Value;
31+
import io.envoyproxy.envoy.extensions.filters.http.gcp_authn.v3.GcpAuthnFilterConfig;
32+
import io.envoyproxy.envoy.extensions.filters.http.gcp_authn.v3.TokenCacheConfig;
33+
import io.grpc.CallOptions;
34+
import io.grpc.Channel;
35+
import io.grpc.ClientInterceptor;
36+
import io.grpc.MethodDescriptor;
37+
import io.grpc.testing.TestMethodDescriptors;
38+
import org.junit.Test;
39+
import org.junit.runner.RunWith;
40+
import org.junit.runners.JUnit4;
41+
import org.mockito.ArgumentCaptor;
42+
import org.mockito.Mockito;
43+
44+
@RunWith(JUnit4.class)
45+
public class GcpAuthenticationFilterTest {
46+
47+
@Test
48+
public void testParseFilterConfig_withValidConfig() {
49+
GcpAuthnFilterConfig config = GcpAuthnFilterConfig.newBuilder()
50+
.setCacheConfig(TokenCacheConfig.newBuilder().setCacheSize(UInt64Value.of(20)))
51+
.build();
52+
Any anyMessage = Any.pack(config);
53+
54+
GcpAuthenticationFilter filter = new GcpAuthenticationFilter();
55+
ConfigOrError<? extends Filter.FilterConfig> result = filter.parseFilterConfig(anyMessage);
56+
57+
assertNotNull(result.config);
58+
assertNull(result.errorDetail);
59+
assertEquals(20L,
60+
((GcpAuthenticationFilter.GcpAuthenticationConfig) result.config).getCacheSize());
61+
}
62+
63+
@Test
64+
public void testParseFilterConfig_withZeroCacheSize() {
65+
GcpAuthnFilterConfig config = GcpAuthnFilterConfig.newBuilder()
66+
.setCacheConfig(TokenCacheConfig.newBuilder().setCacheSize(UInt64Value.of(0)))
67+
.build();
68+
Any anyMessage = Any.pack(config);
69+
70+
GcpAuthenticationFilter filter = new GcpAuthenticationFilter();
71+
ConfigOrError<? extends Filter.FilterConfig> result = filter.parseFilterConfig(anyMessage);
72+
73+
assertNull(result.config);
74+
assertNotNull(result.errorDetail);
75+
assertTrue(result.errorDetail.contains("cache_config.cache_size must be greater than zero"));
76+
}
77+
78+
@Test
79+
public void testParseFilterConfig_withInvalidMessageType() {
80+
GcpAuthenticationFilter filter = new GcpAuthenticationFilter();
81+
Message invalidMessage = Empty.getDefaultInstance();
82+
ConfigOrError<? extends Filter.FilterConfig> result = filter.parseFilterConfig(invalidMessage);
83+
84+
assertNull(result.config);
85+
assertThat(result.errorDetail).contains("Invalid config type");
86+
}
87+
88+
@Test
89+
public void testClientInterceptor_createsAndReusesCachedCredentials() {
90+
GcpAuthenticationFilter.GcpAuthenticationConfig config =
91+
new GcpAuthenticationFilter.GcpAuthenticationConfig(10);
92+
GcpAuthenticationFilter filter = new GcpAuthenticationFilter();
93+
94+
// Create interceptor
95+
ClientInterceptor interceptor = filter.buildClientInterceptor(config, null, null, null);
96+
MethodDescriptor<Void, Void> methodDescriptor = TestMethodDescriptors.voidMethod();
97+
98+
// Mock channel and capture CallOptions
99+
Channel mockChannel = Mockito.mock(Channel.class);
100+
ArgumentCaptor<CallOptions> callOptionsCaptor = ArgumentCaptor.forClass(CallOptions.class);
101+
102+
// Execute interception twice to check caching
103+
interceptor.interceptCall(methodDescriptor, CallOptions.DEFAULT, mockChannel);
104+
interceptor.interceptCall(methodDescriptor, CallOptions.DEFAULT, mockChannel);
105+
106+
// Capture and verify CallOptions for CallCredentials presence
107+
Mockito.verify(mockChannel, Mockito.times(2))
108+
.newCall(eq(methodDescriptor), callOptionsCaptor.capture());
109+
110+
// Retrieve the CallOptions captured from both calls
111+
CallOptions firstCapturedOptions = callOptionsCaptor.getAllValues().get(0);
112+
CallOptions secondCapturedOptions = callOptionsCaptor.getAllValues().get(1);
113+
114+
// Ensure that CallCredentials was added
115+
assertNotNull(firstCapturedOptions.getCredentials());
116+
assertNotNull(secondCapturedOptions.getCredentials());
117+
118+
// Ensure that the CallCredentials from both calls are the same, indicating caching
119+
assertSame(firstCapturedOptions.getCredentials(), secondCapturedOptions.getCredentials());
120+
}
121+
}

xds/third_party/envoy/import.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ envoy/extensions/clusters/aggregate/v3/cluster.proto
7575
envoy/extensions/filters/common/fault/v3/fault.proto
7676
envoy/extensions/filters/http/fault/v3/fault.proto
7777
envoy/extensions/filters/http/rate_limit_quota/v3/rate_limit_quota.proto
78+
envoy/extensions/filters/http/gcp_authn/v3/gcp_authn.proto
7879
envoy/extensions/filters/http/rbac/v3/rbac.proto
7980
envoy/extensions/filters/http/router/v3/router.proto
8081
envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.proto

0 commit comments

Comments
 (0)