• 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.Attributes;
26 import io.grpc.CallCredentials;
27 import io.grpc.Metadata;
28 import io.grpc.MethodDescriptor;
29 import io.grpc.SecurityLevel;
30 import io.grpc.Status;
31 import io.grpc.StatusException;
32 import java.io.IOException;
33 import java.lang.reflect.Constructor;
34 import java.lang.reflect.InvocationTargetException;
35 import java.lang.reflect.Method;
36 import java.net.URI;
37 import java.net.URISyntaxException;
38 import java.security.PrivateKey;
39 import java.util.Collection;
40 import java.util.List;
41 import java.util.Map;
42 import java.util.concurrent.Executor;
43 import java.util.logging.Level;
44 import java.util.logging.Logger;
45 import javax.annotation.Nullable;
46 
47 /**
48  * Wraps {@link Credentials} as a {@link CallCredentials}.
49  */
50 final class GoogleAuthLibraryCallCredentials implements CallCredentials {
51   private static final Logger log
52       = Logger.getLogger(GoogleAuthLibraryCallCredentials.class.getName());
53   private static final JwtHelper jwtHelper
54       = createJwtHelperOrNull(GoogleAuthLibraryCallCredentials.class.getClassLoader());
55   private static final Class<? extends Credentials> googleCredentialsClass
56       = loadGoogleCredentialsClass();
57 
58   private final boolean requirePrivacy;
59   @VisibleForTesting
60   final Credentials creds;
61 
62   private Metadata lastHeaders;
63   private Map<String, List<String>> lastMetadata;
64 
GoogleAuthLibraryCallCredentials(Credentials creds)65   public GoogleAuthLibraryCallCredentials(Credentials creds) {
66     this(creds, jwtHelper);
67   }
68 
69   @VisibleForTesting
GoogleAuthLibraryCallCredentials(Credentials creds, JwtHelper jwtHelper)70   GoogleAuthLibraryCallCredentials(Credentials creds, JwtHelper jwtHelper) {
71     checkNotNull(creds, "creds");
72     boolean requirePrivacy = false;
73     if (googleCredentialsClass != null) {
74       // All GoogleCredentials instances are bearer tokens and should only be used on private
75       // channels. This catches all return values from GoogleCredentials.getApplicationDefault().
76       // This should be checked before upgrading the Service Account to JWT, as JWT is also a bearer
77       // token.
78       requirePrivacy = googleCredentialsClass.isInstance(creds);
79     }
80     if (jwtHelper != null) {
81       creds = jwtHelper.tryServiceAccountToJwt(creds);
82     }
83     this.requirePrivacy = requirePrivacy;
84     this.creds = creds;
85   }
86 
87   @Override
thisUsesUnstableApi()88   public void thisUsesUnstableApi() {}
89 
90   @Override
applyRequestMetadata(MethodDescriptor<?, ?> method, Attributes attrs, Executor appExecutor, final MetadataApplier applier)91   public void applyRequestMetadata(MethodDescriptor<?, ?> method, Attributes attrs,
92       Executor appExecutor, final MetadataApplier applier) {
93     SecurityLevel security = attrs.get(ATTR_SECURITY_LEVEL);
94     if (security == null) {
95       // Although the API says ATTR_SECURITY_LEVEL is required, no one was really looking at it thus
96       // there may be transports that got away without setting it.  Now we start to check it, it'd
97       // be less disruptive to tolerate nulls.
98       security = SecurityLevel.NONE;
99     }
100     if (requirePrivacy && security != SecurityLevel.PRIVACY_AND_INTEGRITY) {
101       applier.fail(Status.UNAUTHENTICATED
102           .withDescription("Credentials require channel with PRIVACY_AND_INTEGRITY security level. "
103               + "Observed security level: " + security));
104       return;
105     }
106 
107     String authority = checkNotNull(attrs.get(ATTR_AUTHORITY), "authority");
108     final URI uri;
109     try {
110       uri = serviceUri(authority, method);
111     } catch (StatusException e) {
112       applier.fail(e.getStatus());
113       return;
114     }
115     // Credentials is expected to manage caching internally if the metadata is fetched over
116     // the network.
117     creds.getRequestMetadata(uri, appExecutor, new RequestMetadataCallback() {
118       @Override
119       public void onSuccess(Map<String, List<String>> metadata) {
120         // Some implementations may pass null metadata.
121 
122         // Re-use the headers if getRequestMetadata() returns the same map. It may return a
123         // different map based on the provided URI, i.e., for JWT. However, today it does not
124         // cache JWT and so we won't bother tring to save its return value based on the URI.
125         Metadata headers;
126         try {
127           synchronized (GoogleAuthLibraryCallCredentials.this) {
128             if (lastMetadata == null || lastMetadata != metadata) {
129               lastHeaders = toHeaders(metadata);
130               lastMetadata = metadata;
131             }
132             headers = lastHeaders;
133           }
134         } catch (Throwable t) {
135           applier.fail(Status.UNAUTHENTICATED
136               .withDescription("Failed to convert credential metadata")
137               .withCause(t));
138           return;
139         }
140         applier.apply(headers);
141       }
142 
143       @Override
144       public void onFailure(Throwable e) {
145         if (e instanceof IOException) {
146           // Since it's an I/O failure, let the call be retried with UNAVAILABLE.
147           applier.fail(Status.UNAVAILABLE
148               .withDescription("Credentials failed to obtain metadata")
149               .withCause(e));
150         } else {
151           applier.fail(Status.UNAUTHENTICATED
152               .withDescription("Failed computing credential metadata")
153               .withCause(e));
154         }
155       }
156     });
157   }
158 
159   /**
160    * Generate a JWT-specific service URI. The URI is simply an identifier with enough information
161    * for a service to know that the JWT was intended for it. The URI will commonly be verified with
162    * a simple string equality check.
163    */
serviceUri(String authority, MethodDescriptor<?, ?> method)164   private static URI serviceUri(String authority, MethodDescriptor<?, ?> method)
165       throws StatusException {
166     // Always use HTTPS, by definition.
167     final String scheme = "https";
168     final int defaultPort = 443;
169     String path = "/" + MethodDescriptor.extractFullServiceName(method.getFullMethodName());
170     URI uri;
171     try {
172       uri = new URI(scheme, authority, path, null, null);
173     } catch (URISyntaxException e) {
174       throw Status.UNAUTHENTICATED.withDescription("Unable to construct service URI for auth")
175           .withCause(e).asException();
176     }
177     // The default port must not be present. Alternative ports should be present.
178     if (uri.getPort() == defaultPort) {
179       uri = removePort(uri);
180     }
181     return uri;
182   }
183 
removePort(URI uri)184   private static URI removePort(URI uri) throws StatusException {
185     try {
186       return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), -1 /* port */,
187           uri.getPath(), uri.getQuery(), uri.getFragment());
188     } catch (URISyntaxException e) {
189       throw Status.UNAUTHENTICATED.withDescription(
190            "Unable to construct service URI after removing port").withCause(e).asException();
191     }
192   }
193 
194   @SuppressWarnings("BetaApi") // BaseEncoding is stable in Guava 20.0
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   @VisibleForTesting
256   static class JwtHelper {
257     private final Class<? extends Credentials> serviceAccountClass;
258     private final Constructor<? extends Credentials> jwtConstructor;
259     private final Method getScopes;
260     private final Method getClientId;
261     private final Method getClientEmail;
262     private final Method getPrivateKey;
263     private final Method getPrivateKeyId;
264 
JwtHelper(Class<?> rawServiceAccountClass, ClassLoader loader)265     public JwtHelper(Class<?> rawServiceAccountClass, ClassLoader loader)
266         throws ClassNotFoundException, NoSuchMethodException {
267       serviceAccountClass = rawServiceAccountClass.asSubclass(Credentials.class);
268       getScopes = serviceAccountClass.getMethod("getScopes");
269       getClientId = serviceAccountClass.getMethod("getClientId");
270       getClientEmail = serviceAccountClass.getMethod("getClientEmail");
271       getPrivateKey = serviceAccountClass.getMethod("getPrivateKey");
272       getPrivateKeyId = serviceAccountClass.getMethod("getPrivateKeyId");
273       Class<? extends Credentials> jwtClass = Class.forName(
274           "com.google.auth.oauth2.ServiceAccountJwtAccessCredentials", false, loader)
275           .asSubclass(Credentials.class);
276       jwtConstructor
277           = jwtClass.getConstructor(String.class, String.class, PrivateKey.class, String.class);
278     }
279 
tryServiceAccountToJwt(Credentials creds)280     public Credentials tryServiceAccountToJwt(Credentials creds) {
281       if (!serviceAccountClass.isInstance(creds)) {
282         return creds;
283       }
284       Exception caughtException;
285       try {
286         creds = serviceAccountClass.cast(creds);
287         Collection<?> scopes = (Collection<?>) getScopes.invoke(creds);
288         if (scopes.size() != 0) {
289           // Leave as-is, since the scopes may limit access within the service.
290           return creds;
291         }
292         return jwtConstructor.newInstance(
293             getClientId.invoke(creds),
294             getClientEmail.invoke(creds),
295             getPrivateKey.invoke(creds),
296             getPrivateKeyId.invoke(creds));
297       } catch (IllegalAccessException ex) {
298         caughtException = ex;
299       } catch (InvocationTargetException ex) {
300         caughtException = ex;
301       } catch (InstantiationException ex) {
302         caughtException = ex;
303       }
304       if (caughtException != null) {
305         // Failure is a bug in this class, but we still choose to gracefully recover
306         log.log(
307             Level.WARNING,
308             "Failed converting service account credential to JWT. This is unexpected",
309             caughtException);
310       }
311       return creds;
312     }
313   }
314 }
315