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.CallCredentials2; 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.Constructor; 33 import java.lang.reflect.InvocationTargetException; 34 import java.lang.reflect.Method; 35 import java.net.URI; 36 import java.net.URISyntaxException; 37 import java.security.PrivateKey; 38 import java.util.Collection; 39 import java.util.List; 40 import java.util.Map; 41 import java.util.concurrent.Executor; 42 import java.util.logging.Level; 43 import java.util.logging.Logger; 44 import javax.annotation.Nullable; 45 46 /** 47 * Wraps {@link Credentials} as a {@link CallCredentials}. 48 */ 49 final class GoogleAuthLibraryCallCredentials extends CallCredentials2 { 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> googleCredentialsClass 55 = loadGoogleCredentialsClass(); 56 57 private final boolean requirePrivacy; 58 @VisibleForTesting 59 final Credentials creds; 60 61 private Metadata lastHeaders; 62 private Map<String, List<String>> lastMetadata; 63 GoogleAuthLibraryCallCredentials(Credentials creds)64 public GoogleAuthLibraryCallCredentials(Credentials creds) { 65 this(creds, jwtHelper); 66 } 67 68 @VisibleForTesting GoogleAuthLibraryCallCredentials(Credentials creds, JwtHelper jwtHelper)69 GoogleAuthLibraryCallCredentials(Credentials creds, JwtHelper jwtHelper) { 70 checkNotNull(creds, "creds"); 71 boolean requirePrivacy = false; 72 if (googleCredentialsClass != null) { 73 // All GoogleCredentials instances are bearer tokens and should only be used on private 74 // channels. This catches all return values from GoogleCredentials.getApplicationDefault(). 75 // This should be checked before upgrading the Service Account to JWT, as JWT is also a bearer 76 // token. 77 requirePrivacy = googleCredentialsClass.isInstance(creds); 78 } 79 if (jwtHelper != null) { 80 creds = jwtHelper.tryServiceAccountToJwt(creds); 81 } 82 this.requirePrivacy = requirePrivacy; 83 this.creds = creds; 84 } 85 86 @Override thisUsesUnstableApi()87 public void thisUsesUnstableApi() {} 88 89 @Override applyRequestMetadata( RequestInfo info, Executor appExecutor, final MetadataApplier applier)90 public void applyRequestMetadata( 91 RequestInfo info, Executor appExecutor, final MetadataApplier applier) { 92 SecurityLevel security = info.getSecurityLevel(); 93 if (requirePrivacy && security != SecurityLevel.PRIVACY_AND_INTEGRITY) { 94 applier.fail(Status.UNAUTHENTICATED 95 .withDescription("Credentials require channel with PRIVACY_AND_INTEGRITY security level. " 96 + "Observed security level: " + security)); 97 return; 98 } 99 100 String authority = checkNotNull(info.getAuthority(), "authority"); 101 final URI uri; 102 try { 103 uri = serviceUri(authority, info.getMethodDescriptor()); 104 } catch (StatusException e) { 105 applier.fail(e.getStatus()); 106 return; 107 } 108 // Credentials is expected to manage caching internally if the metadata is fetched over 109 // the network. 110 creds.getRequestMetadata(uri, appExecutor, new RequestMetadataCallback() { 111 @Override 112 public void onSuccess(Map<String, List<String>> metadata) { 113 // Some implementations may pass null metadata. 114 115 // Re-use the headers if getRequestMetadata() returns the same map. It may return a 116 // different map based on the provided URI, i.e., for JWT. However, today it does not 117 // cache JWT and so we won't bother tring to save its return value based on the URI. 118 Metadata headers; 119 try { 120 synchronized (GoogleAuthLibraryCallCredentials.this) { 121 if (lastMetadata == null || lastMetadata != metadata) { 122 lastHeaders = toHeaders(metadata); 123 lastMetadata = metadata; 124 } 125 headers = lastHeaders; 126 } 127 } catch (Throwable t) { 128 applier.fail(Status.UNAUTHENTICATED 129 .withDescription("Failed to convert credential metadata") 130 .withCause(t)); 131 return; 132 } 133 applier.apply(headers); 134 } 135 136 @Override 137 public void onFailure(Throwable e) { 138 if (e instanceof IOException) { 139 // Since it's an I/O failure, let the call be retried with UNAVAILABLE. 140 applier.fail(Status.UNAVAILABLE 141 .withDescription("Credentials failed to obtain metadata") 142 .withCause(e)); 143 } else { 144 applier.fail(Status.UNAUTHENTICATED 145 .withDescription("Failed computing credential metadata") 146 .withCause(e)); 147 } 148 } 149 }); 150 } 151 152 /** 153 * Generate a JWT-specific service URI. The URI is simply an identifier with enough information 154 * for a service to know that the JWT was intended for it. The URI will commonly be verified with 155 * a simple string equality check. 156 */ serviceUri(String authority, MethodDescriptor<?, ?> method)157 private static URI serviceUri(String authority, MethodDescriptor<?, ?> method) 158 throws StatusException { 159 // Always use HTTPS, by definition. 160 final String scheme = "https"; 161 final int defaultPort = 443; 162 String path = "/" + MethodDescriptor.extractFullServiceName(method.getFullMethodName()); 163 URI uri; 164 try { 165 uri = new URI(scheme, authority, path, null, null); 166 } catch (URISyntaxException e) { 167 throw Status.UNAUTHENTICATED.withDescription("Unable to construct service URI for auth") 168 .withCause(e).asException(); 169 } 170 // The default port must not be present. Alternative ports should be present. 171 if (uri.getPort() == defaultPort) { 172 uri = removePort(uri); 173 } 174 return uri; 175 } 176 removePort(URI uri)177 private static URI removePort(URI uri) throws StatusException { 178 try { 179 return new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), -1 /* port */, 180 uri.getPath(), uri.getQuery(), uri.getFragment()); 181 } catch (URISyntaxException e) { 182 throw Status.UNAUTHENTICATED.withDescription( 183 "Unable to construct service URI after removing port").withCause(e).asException(); 184 } 185 } 186 187 @SuppressWarnings("BetaApi") // BaseEncoding is stable in Guava 20.0 toHeaders(@ullable Map<String, List<String>> metadata)188 private static Metadata toHeaders(@Nullable Map<String, List<String>> metadata) { 189 Metadata headers = new Metadata(); 190 if (metadata != null) { 191 for (String key : metadata.keySet()) { 192 if (key.endsWith("-bin")) { 193 Metadata.Key<byte[]> headerKey = Metadata.Key.of(key, Metadata.BINARY_BYTE_MARSHALLER); 194 for (String value : metadata.get(key)) { 195 headers.put(headerKey, BaseEncoding.base64().decode(value)); 196 } 197 } else { 198 Metadata.Key<String> headerKey = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); 199 for (String value : metadata.get(key)) { 200 headers.put(headerKey, value); 201 } 202 } 203 } 204 } 205 return headers; 206 } 207 208 @VisibleForTesting 209 @Nullable createJwtHelperOrNull(ClassLoader loader)210 static JwtHelper createJwtHelperOrNull(ClassLoader loader) { 211 Class<?> rawServiceAccountClass; 212 try { 213 // Specify loader so it can be overridden in tests 214 rawServiceAccountClass 215 = Class.forName("com.google.auth.oauth2.ServiceAccountCredentials", false, loader); 216 } catch (ClassNotFoundException ex) { 217 return null; 218 } 219 Exception caughtException; 220 try { 221 return new JwtHelper(rawServiceAccountClass, loader); 222 } catch (ClassNotFoundException ex) { 223 caughtException = ex; 224 } catch (NoSuchMethodException ex) { 225 caughtException = ex; 226 } 227 if (caughtException != null) { 228 // Failure is a bug in this class, but we still choose to gracefully recover 229 log.log(Level.WARNING, "Failed to create JWT helper. This is unexpected", caughtException); 230 } 231 return null; 232 } 233 234 @Nullable loadGoogleCredentialsClass()235 private static Class<? extends Credentials> loadGoogleCredentialsClass() { 236 Class<?> rawGoogleCredentialsClass; 237 try { 238 // Can't use a loader as it disables ProGuard's reference detection and would fail to rename 239 // this reference. Unfortunately this will initialize the class. 240 rawGoogleCredentialsClass = Class.forName("com.google.auth.oauth2.GoogleCredentials"); 241 } catch (ClassNotFoundException ex) { 242 log.log(Level.FINE, "Failed to load GoogleCredentials", ex); 243 return null; 244 } 245 return rawGoogleCredentialsClass.asSubclass(Credentials.class); 246 } 247 248 @VisibleForTesting 249 static class JwtHelper { 250 private final Class<? extends Credentials> serviceAccountClass; 251 private final Constructor<? extends Credentials> jwtConstructor; 252 private final Method getScopes; 253 private final Method getClientId; 254 private final Method getClientEmail; 255 private final Method getPrivateKey; 256 private final Method getPrivateKeyId; 257 JwtHelper(Class<?> rawServiceAccountClass, ClassLoader loader)258 public JwtHelper(Class<?> rawServiceAccountClass, ClassLoader loader) 259 throws ClassNotFoundException, NoSuchMethodException { 260 serviceAccountClass = rawServiceAccountClass.asSubclass(Credentials.class); 261 getScopes = serviceAccountClass.getMethod("getScopes"); 262 getClientId = serviceAccountClass.getMethod("getClientId"); 263 getClientEmail = serviceAccountClass.getMethod("getClientEmail"); 264 getPrivateKey = serviceAccountClass.getMethod("getPrivateKey"); 265 getPrivateKeyId = serviceAccountClass.getMethod("getPrivateKeyId"); 266 Class<? extends Credentials> jwtClass = Class.forName( 267 "com.google.auth.oauth2.ServiceAccountJwtAccessCredentials", false, loader) 268 .asSubclass(Credentials.class); 269 jwtConstructor 270 = jwtClass.getConstructor(String.class, String.class, PrivateKey.class, String.class); 271 } 272 tryServiceAccountToJwt(Credentials creds)273 public Credentials tryServiceAccountToJwt(Credentials creds) { 274 if (!serviceAccountClass.isInstance(creds)) { 275 return creds; 276 } 277 Exception caughtException; 278 try { 279 creds = serviceAccountClass.cast(creds); 280 Collection<?> scopes = (Collection<?>) getScopes.invoke(creds); 281 if (scopes.size() != 0) { 282 // Leave as-is, since the scopes may limit access within the service. 283 return creds; 284 } 285 return jwtConstructor.newInstance( 286 getClientId.invoke(creds), 287 getClientEmail.invoke(creds), 288 getPrivateKey.invoke(creds), 289 getPrivateKeyId.invoke(creds)); 290 } catch (IllegalAccessException ex) { 291 caughtException = ex; 292 } catch (InvocationTargetException ex) { 293 caughtException = ex; 294 } catch (InstantiationException ex) { 295 caughtException = ex; 296 } 297 if (caughtException != null) { 298 // Failure is a bug in this class, but we still choose to gracefully recover 299 log.log( 300 Level.WARNING, 301 "Failed converting service account credential to JWT. This is unexpected", 302 caughtException); 303 } 304 return creds; 305 } 306 } 307 } 308