• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 2016 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.auth.Credentials;
22 import com.google.auth.RequestMetadataCallback;
23 import com.google.common.annotations.VisibleForTesting;
24 import com.google.common.io.BaseEncoding;
25 import io.grpc.InternalMayRequireSpecificExecutor;
26 import io.grpc.Metadata;
27 import io.grpc.MethodDescriptor;
28 import io.grpc.SecurityLevel;
29 import io.grpc.Status;
30 import io.grpc.StatusException;
31 import java.io.IOException;
32 import java.lang.reflect.InvocationTargetException;
33 import java.lang.reflect.Method;
34 import java.net.URI;
35 import java.net.URISyntaxException;
36 import java.util.ArrayList;
37 import java.util.Collection;
38 import java.util.List;
39 import java.util.Map;
40 import java.util.concurrent.Executor;
41 import java.util.logging.Level;
42 import java.util.logging.Logger;
43 import javax.annotation.Nullable;
44 
45 /**
46  * Wraps {@link Credentials} as a {@link io.grpc.CallCredentials}.
47  */
48 final class GoogleAuthLibraryCallCredentials extends io.grpc.CallCredentials
49     implements InternalMayRequireSpecificExecutor {
50   private static final Logger log
51       = Logger.getLogger(GoogleAuthLibraryCallCredentials.class.getName());
52   private static final JwtHelper jwtHelper
53       = createJwtHelperOrNull(GoogleAuthLibraryCallCredentials.class.getClassLoader());
54   private static final Class<? extends Credentials> GOOGLE_CREDENTIALS_CLASS
55       = loadGoogleCredentialsClass();
56   private static final Class<?> APP_ENGINE_CREDENTIALS_CLASS
57       = loadAppEngineCredentials();
58 
59   private final boolean requirePrivacy;
60   @VisibleForTesting
61   final Credentials creds;
62 
63   private Metadata lastHeaders;
64   private Map<String, List<String>> lastMetadata;
65 
66   private final boolean requiresSpecificExecutor;
67 
GoogleAuthLibraryCallCredentials(Credentials creds)68   public GoogleAuthLibraryCallCredentials(Credentials creds) {
69     this(creds, jwtHelper);
70   }
71 
72   @VisibleForTesting
GoogleAuthLibraryCallCredentials(Credentials creds, JwtHelper jwtHelper)73   GoogleAuthLibraryCallCredentials(Credentials creds, JwtHelper jwtHelper) {
74     checkNotNull(creds, "creds");
75     boolean requirePrivacy = false;
76     if (GOOGLE_CREDENTIALS_CLASS != null) {
77       // All GoogleCredentials instances are bearer tokens and should only be used on private
78       // channels. This catches all return values from GoogleCredentials.getApplicationDefault().
79       // This should be checked before upgrading the Service Account to JWT, as JWT is also a bearer
80       // token.
81       requirePrivacy = GOOGLE_CREDENTIALS_CLASS.isInstance(creds);
82     }
83     if (jwtHelper != null) {
84       creds = jwtHelper.tryServiceAccountToJwt(creds);
85     }
86     this.requirePrivacy = requirePrivacy;
87     this.creds = creds;
88 
89     // Cache the value so we only need to try to load the class once
90     if (APP_ENGINE_CREDENTIALS_CLASS == null) {
91       requiresSpecificExecutor = false;
92     } else {
93       requiresSpecificExecutor = APP_ENGINE_CREDENTIALS_CLASS.isInstance(creds);
94     }
95   }
96 
97   @Override
applyRequestMetadata( RequestInfo info, Executor appExecutor, final MetadataApplier applier)98   public void applyRequestMetadata(
99       RequestInfo info, Executor appExecutor, final MetadataApplier applier) {
100     SecurityLevel security = info.getSecurityLevel();
101     if (requirePrivacy && security != SecurityLevel.PRIVACY_AND_INTEGRITY) {
102       applier.fail(Status.UNAUTHENTICATED
103           .withDescription("Credentials require channel with PRIVACY_AND_INTEGRITY security level. "
104               + "Observed security level: " + security));
105       return;
106     }
107 
108     String authority = checkNotNull(info.getAuthority(), "authority");
109     final URI uri;
110     try {
111       uri = serviceUri(authority, info.getMethodDescriptor());
112     } catch (StatusException e) {
113       applier.fail(e.getStatus());
114       return;
115     }
116     // Credentials is expected to manage caching internally if the metadata is fetched over
117     // the network.
118     creds.getRequestMetadata(uri, appExecutor, new RequestMetadataCallback() {
119       @Override
120       public void onSuccess(Map<String, List<String>> metadata) {
121         // Some implementations may pass null metadata.
122 
123         // Re-use the headers if getRequestMetadata() returns the same map. It may return a
124         // different map based on the provided URI, i.e., for JWT. However, today it does not
125         // cache JWT and so we won't bother tring to save its return value based on the URI.
126         Metadata headers;
127         try {
128           synchronized (GoogleAuthLibraryCallCredentials.this) {
129             if (lastMetadata == null || lastMetadata != metadata) {
130               lastHeaders = toHeaders(metadata);
131               lastMetadata = metadata;
132             }
133             headers = lastHeaders;
134           }
135         } catch (Throwable t) {
136           applier.fail(Status.UNAUTHENTICATED
137               .withDescription("Failed to convert credential metadata")
138               .withCause(t));
139           return;
140         }
141         applier.apply(headers);
142       }
143 
144       @Override
145       public void onFailure(Throwable e) {
146         if (e instanceof IOException) {
147           // Since it's an I/O failure, let the call be retried with UNAVAILABLE.
148           applier.fail(Status.UNAVAILABLE
149               .withDescription("Credentials failed to obtain metadata")
150               .withCause(e));
151         } else {
152           applier.fail(Status.UNAUTHENTICATED
153               .withDescription("Failed computing credential metadata")
154               .withCause(e));
155         }
156       }
157     });
158   }
159 
160   /**
161    * Generate a JWT-specific service URI. The URI is simply an identifier with enough information
162    * for a service to know that the JWT was intended for it. The URI will commonly be verified with
163    * a simple string equality check.
164    */
serviceUri(String authority, MethodDescriptor<?, ?> method)165   private static URI serviceUri(String authority, MethodDescriptor<?, ?> method)
166       throws StatusException {
167     // Always use HTTPS, by definition.
168     final String scheme = "https";
169     final int defaultPort = 443;
170     String path = "/" + method.getServiceName();
171     URI uri;
172     try {
173       uri = new URI(scheme, authority, path, null, null);
174     } catch (URISyntaxException e) {
175       throw Status.UNAUTHENTICATED.withDescription("Unable to construct service URI for auth")
176           .withCause(e).asException();
177     }
178     // The default port must not be present. Alternative ports should be present.
179     if (uri.getPort() == defaultPort) {
180       uri = removePort(uri);
181     }
182     return uri;
183   }
184 
removePort(URI uri)185   private static URI removePort(URI uri) throws StatusException {
186     try {
187       return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), -1 /* port */,
188           uri.getPath(), uri.getQuery(), uri.getFragment());
189     } catch (URISyntaxException e) {
190       throw Status.UNAUTHENTICATED.withDescription(
191            "Unable to construct service URI after removing port").withCause(e).asException();
192     }
193   }
194 
toHeaders(@ullable Map<String, List<String>> metadata)195   private static Metadata toHeaders(@Nullable Map<String, List<String>> metadata) {
196     Metadata headers = new Metadata();
197     if (metadata != null) {
198       for (String key : metadata.keySet()) {
199         if (key.endsWith("-bin")) {
200           Metadata.Key<byte[]> headerKey = Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER);
201           for (String value : metadata.get(key)) {
202             headers.put(headerKey, BaseEncoding.base64().decode(value));
203           }
204         } else {
205           Metadata.Key<String> headerKey = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER);
206           for (String value : metadata.get(key)) {
207             headers.put(headerKey, value);
208           }
209         }
210       }
211     }
212     return headers;
213   }
214 
215   @VisibleForTesting
216   @Nullable
createJwtHelperOrNull(ClassLoader loader)217   static JwtHelper createJwtHelperOrNull(ClassLoader loader) {
218     Class<?> rawServiceAccountClass;
219     try {
220       // Specify loader so it can be overridden in tests
221       rawServiceAccountClass
222           = Class.forName("com.google.auth.oauth2.ServiceAccountCredentials", false, loader);
223     } catch (ClassNotFoundException ex) {
224       return null;
225     }
226     Exception caughtException;
227     try {
228       return new JwtHelper(rawServiceAccountClass, loader);
229     } catch (ClassNotFoundException ex) {
230       caughtException = ex;
231     } catch (NoSuchMethodException ex) {
232       caughtException = ex;
233     }
234     if (caughtException != null) {
235       // Failure is a bug in this class, but we still choose to gracefully recover
236       log.log(Level.WARNING, "Failed to create JWT helper. This is unexpected", caughtException);
237     }
238     return null;
239   }
240 
241   @Nullable
loadGoogleCredentialsClass()242   private static Class<? extends Credentials> loadGoogleCredentialsClass() {
243     Class<?> rawGoogleCredentialsClass;
244     try {
245       // Can't use a loader as it disables ProGuard's reference detection and would fail to rename
246       // this reference. Unfortunately this will initialize the class.
247       rawGoogleCredentialsClass = Class.forName("com.google.auth.oauth2.GoogleCredentials");
248     } catch (ClassNotFoundException ex) {
249       log.log(Level.FINE, "Failed to load GoogleCredentials", ex);
250       return null;
251     }
252     return rawGoogleCredentialsClass.asSubclass(Credentials.class);
253   }
254 
255   @Nullable
loadAppEngineCredentials()256   private static Class<?> loadAppEngineCredentials() {
257     try {
258       return Class.forName("com.google.auth.appengine.AppEngineCredentials");
259     } catch (ClassNotFoundException ex) {
260       log.log(Level.FINE, "AppEngineCredentials not available in classloader", ex);
261       return null;
262     }
263   }
264 
265   private static class MethodPair {
266     private final Method getter;
267     private final Method builderSetter;
268 
MethodPair(Method getter, Method builderSetter)269     private MethodPair(Method getter, Method builderSetter) {
270       this.getter = getter;
271       this.builderSetter = builderSetter;
272     }
273 
apply(Credentials credentials, Object builder)274     private void apply(Credentials credentials, Object builder)
275         throws InvocationTargetException, IllegalAccessException {
276       builderSetter.invoke(builder, getter.invoke(credentials));
277     }
278   }
279 
280   @VisibleForTesting
281   static class JwtHelper {
282     private final Class<? extends Credentials> serviceAccountClass;
283     private final Method newJwtBuilder;
284     private final Method build;
285     private final Method getScopes;
286     private final List<MethodPair> methodPairs;
287 
JwtHelper(Class<?> rawServiceAccountClass, ClassLoader loader)288     public JwtHelper(Class<?> rawServiceAccountClass, ClassLoader loader)
289         throws ClassNotFoundException, NoSuchMethodException {
290       serviceAccountClass = rawServiceAccountClass.asSubclass(Credentials.class);
291       getScopes = serviceAccountClass.getMethod("getScopes");
292       Class<? extends Credentials> jwtClass = Class.forName(
293           "com.google.auth.oauth2.ServiceAccountJwtAccessCredentials", false, loader)
294           .asSubclass(Credentials.class);
295       newJwtBuilder = jwtClass.getDeclaredMethod("newBuilder");
296       Class<?> builderClass = newJwtBuilder.getReturnType();
297       build = builderClass.getMethod("build");
298 
299       methodPairs = new ArrayList<>();
300 
301       {
302         Method getter = serviceAccountClass.getMethod("getClientId");
303         Method setter = builderClass.getMethod("setClientId", getter.getReturnType());
304         methodPairs.add(new MethodPair(getter, setter));
305       }
306       {
307         Method getter = serviceAccountClass.getMethod("getClientEmail");
308         Method setter = builderClass.getMethod("setClientEmail", getter.getReturnType());
309         methodPairs.add(new MethodPair(getter, setter));
310       }
311       {
312         Method getter = serviceAccountClass.getMethod("getPrivateKey");
313         Method setter = builderClass.getMethod("setPrivateKey", getter.getReturnType());
314         methodPairs.add(new MethodPair(getter, setter));
315       }
316       {
317         Method getter = serviceAccountClass.getMethod("getPrivateKeyId");
318         Method setter = builderClass.getMethod("setPrivateKeyId", getter.getReturnType());
319         methodPairs.add(new MethodPair(getter, setter));
320       }
321       {
322         Method getter = serviceAccountClass.getMethod("getQuotaProjectId");
323         Method setter = builderClass.getMethod("setQuotaProjectId", getter.getReturnType());
324         methodPairs.add(new MethodPair(getter, setter));
325       }
326     }
327 
328     /**
329      * This method tries to convert a {@link Credentials} object to a
330      * ServiceAccountJwtAccessCredentials.  The original credentials will be returned if:
331      * <ul>
332      *   <li> The Credentials is not a ServiceAccountCredentials</li>
333      *   <li> The ServiceAccountCredentials has scopes</li>
334      *   <li> Something unexpected happens </li>
335      * </ul>
336      * @param creds the Credentials to convert
337      * @return either the original Credentials or a fully formed ServiceAccountJwtAccessCredentials.
338      */
tryServiceAccountToJwt(Credentials creds)339     public Credentials tryServiceAccountToJwt(Credentials creds) {
340       if (!serviceAccountClass.isInstance(creds)) {
341         return creds;
342       }
343       Exception caughtException;
344       try {
345         creds = serviceAccountClass.cast(creds);
346         Collection<?> scopes = (Collection<?>) getScopes.invoke(creds);
347         if (scopes.size() != 0) {
348           // Leave as-is, since the scopes may limit access within the service.
349           return creds;
350         }
351         // Create the JWT Credentials Builder
352         Object builder = newJwtBuilder.invoke(null);
353 
354         // Get things from the credentials, and set them on the builder.
355         for (MethodPair pair : this.methodPairs) {
356           pair.apply(creds, builder);
357         }
358 
359         // Call builder.build()
360         return (Credentials) build.invoke(builder);
361       } catch (IllegalAccessException ex) {
362         caughtException = ex;
363       } catch (InvocationTargetException ex) {
364         caughtException = ex;
365       }
366       if (caughtException != null) {
367         // Failure is a bug in this class, but we still choose to gracefully recover
368         log.log(
369             Level.WARNING,
370             "Failed converting service account credential to JWT. This is unexpected",
371             caughtException);
372       }
373       return creds;
374     }
375   }
376 
377   /**
378    * This method is to support the hack for AppEngineCredentials which need to run on a
379    * specific thread.
380    * @return Whether a specific executor is needed or if any executor can be used
381    */
382   @Override
isSpecificExecutorRequired()383   public boolean isSpecificExecutorRequired() {
384     return requiresSpecificExecutor;
385   }
386 
387 }
388