diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 895feb0ad..2276467c3 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -34,6 +34,7 @@ import static com.google.common.base.MoreObjects.firstNonNull; import java.io.IOException; +import java.io.InputStream; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -46,9 +47,11 @@ import com.google.api.client.http.GenericUrl; import com.google.api.client.http.HttpContent; +import com.google.api.client.http.HttpHeaders; import com.google.api.client.http.HttpRequest; import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpStatusCodes; import com.google.api.client.http.HttpTransport; import com.google.api.client.http.json.JsonHttpContent; import com.google.api.client.json.JsonObjectParser; @@ -57,6 +60,9 @@ import com.google.auth.http.HttpTransportFactory; import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableMap; +import com.google.common.io.BaseEncoding; + +import com.google.auth.ServiceAccountSigner; /** * ImpersonatedCredentials allowing credentials issued to a user or service account to impersonate @@ -81,16 +87,20 @@ * System.out.println(b); * */ -public class ImpersonatedCredentials extends GoogleCredentials { +public class ImpersonatedCredentials extends GoogleCredentials implements ServiceAccountSigner { private static final long serialVersionUID = -2133257318957488431L; private static final String RFC3339 = "yyyy-MM-dd'T'HH:mm:ss'Z'"; private static final int ONE_HOUR_IN_SECONDS = 3600; private static final String CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; - private static final String IAM_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken"; + private static final String IAM_ACCESS_TOKEN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken"; + private static final String IAM_ID_TOKEN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateIdToken"; + private static final String IAM_SIGN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:signBlob"; private static final String SCOPE_EMPTY_ERROR = "Scopes cannot be null"; private static final String LIFETIME_EXCEEDED_ERROR = "lifetime must be less than or equal to 3600"; + private static final String PARSE_ERROR_SIGNATURE = "Error parsing signature response. "; + private static final String PARSE_ERROR_MESSAGE = "Error parsing error message response. "; private GoogleCredentials sourceCredentials; private String targetPrincipal; @@ -153,6 +163,85 @@ public static ImpersonatedCredentials create(GoogleCredentials sourceCredentials .build(); } + /** + * Returns the email field of the serviceAccount that is being impersonated. + * + * @return email address of the impesonated service account. + */ + @Override + public String getAccount() { + return this.targetPrincipal; + } + + /** + * Signs the provided bytes using the private key associated with the impersonated + * service account + * + * @param toSign bytes to sign + * @return signed bytes + * @throws SigningException if the attempt to sign the provided bytes failed + * @see Blob Signing + */ + @Override + public byte[] sign(byte[] toSign) { + BaseEncoding base64 = BaseEncoding.base64(); + String signature; + try { + signature = getSignature(base64.encode(toSign)); + } catch (IOException ex) { + throw new SigningException("Failed to sign the provided bytes", ex); + } + return base64.decode(signature); + } + + private String getSignature(String bytes) throws IOException { + String signBlobUrl = String.format(IAM_SIGN_ENDPOINT, getAccount()); + GenericUrl genericUrl = new GenericUrl(signBlobUrl); + + GenericData signRequest = new GenericData(); + signRequest.set("delegates", this.delegates); + signRequest.set("payload", bytes); + JsonHttpContent signContent = new JsonHttpContent(OAuth2Utils.JSON_FACTORY, signRequest); + HttpTransport httpTransport = this.transportFactory.create(); + HttpCredentialsAdapter adapter = new HttpCredentialsAdapter(sourceCredentials); + HttpRequestFactory requestFactory = httpTransport.createRequestFactory(); + + HttpRequest request = requestFactory.buildPostRequest(genericUrl, signContent); + Map> headers = getRequestMetadata(); + HttpHeaders requestHeaders = request.getHeaders(); + for (Map.Entry> entry : headers.entrySet()) { + requestHeaders.put(entry.getKey(), entry.getValue()); + } + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + adapter.initialize(request); + request.setParser(parser); + request.setThrowExceptionOnExecuteError(false); + + HttpResponse response = request.execute(); + int statusCode = response.getStatusCode(); + if (statusCode >= 400 && statusCode < HttpStatusCodes.STATUS_CODE_SERVER_ERROR) { + GenericData responseError = response.parseAs(GenericData.class); + Map error = OAuth2Utils.validateMap(responseError, "error", PARSE_ERROR_MESSAGE); + String errorMessage = OAuth2Utils.validateString(error, "message", PARSE_ERROR_MESSAGE); + throw new IOException(String.format("Error code %s trying to sign provided bytes: %s", + statusCode, errorMessage)); + } + if (statusCode != HttpStatusCodes.STATUS_CODE_OK) { + throw new IOException(String.format("Unexpected Error code %s trying to sign provided bytes: %s", statusCode, + response.parseAsString())); + } + InputStream content = response.getContent(); + if (content == null) { + // Throw explicitly here on empty content to avoid NullPointerException from parseAs call. + // Mock transports will have success code with empty content by default. + throw new IOException("Empty content from sign blob server request."); + } + + GenericData responseData = response.parseAs(GenericData.class); + return OAuth2Utils.validateString(responseData, "signedBlob", PARSE_ERROR_SIGNATURE); + } + + private ImpersonatedCredentials(Builder builder) { this.sourceCredentials = builder.getSourceCredentials(); this.targetPrincipal = builder.getTargetPrincipal(); @@ -192,7 +281,7 @@ public AccessToken refreshAccessToken() throws IOException { HttpCredentialsAdapter adapter = new HttpCredentialsAdapter(sourceCredentials); HttpRequestFactory requestFactory = httpTransport.createRequestFactory(); - String endpointUrl = String.format(IAM_ENDPOINT, this.targetPrincipal); + String endpointUrl = String.format(IAM_ACCESS_TOKEN_ENDPOINT, this.targetPrincipal); GenericUrl url = new GenericUrl(endpointUrl); Map body = ImmutableMap.of("delegates", this.delegates, "scope", diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index 16bb93fed..eb1aa0ddf 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -31,9 +31,11 @@ package com.google.auth.oauth2; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.fail; import java.io.ByteArrayOutputStream; @@ -51,6 +53,7 @@ import com.google.api.client.util.Clock; import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.GoogleCredentialsTest.MockTokenServerTransportFactory; +import com.google.auth.ServiceAccountSigner.SigningException; import org.junit.Test; import org.junit.runner.RunWith; @@ -247,6 +250,96 @@ public void refreshAccessToken_invalidDate() throws IOException, IllegalStateExc } } + @Test + public void getAccount_sameAs() throws IOException { + GoogleCredentials sourceCredentials = getSourceCredentials(); + MockIAMCredentialsServiceTransportFactory mtransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mtransportFactory.transport.setAccessToken(ACCESS_TOKEN); + mtransportFactory.transport.setexpireTime(getDefaultExpireTime()); + ImpersonatedCredentials targetCredentials = ImpersonatedCredentials.create(sourceCredentials, + IMPERSONATED_CLIENT_EMAIL, null, SCOPES, VALID_LIFETIME, mtransportFactory); + + assertEquals(IMPERSONATED_CLIENT_EMAIL, targetCredentials.getAccount()); + } + + + @Test + public void sign_sameAs() throws IOException { + GoogleCredentials sourceCredentials = getSourceCredentials(); + MockIAMCredentialsServiceTransportFactory mtransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mtransportFactory.transport.setAccessToken(ACCESS_TOKEN); + mtransportFactory.transport.setexpireTime(getDefaultExpireTime()); + ImpersonatedCredentials targetCredentials = ImpersonatedCredentials.create(sourceCredentials, + IMPERSONATED_CLIENT_EMAIL, null, SCOPES, VALID_LIFETIME, mtransportFactory); + + byte[] expectedSignature = {0xD, 0xE, 0xA, 0xD}; + + mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mtransportFactory.transport.setSignedBlob(expectedSignature); + + assertArrayEquals(expectedSignature, targetCredentials.sign(expectedSignature)); + } + + @Test + public void sign_accessDenied_throws() throws IOException { + GoogleCredentials sourceCredentials = getSourceCredentials(); + MockIAMCredentialsServiceTransportFactory mtransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mtransportFactory.transport.setAccessToken(ACCESS_TOKEN); + mtransportFactory.transport.setexpireTime(getDefaultExpireTime()); + ImpersonatedCredentials targetCredentials = ImpersonatedCredentials.create(sourceCredentials, + IMPERSONATED_CLIENT_EMAIL, null, SCOPES, VALID_LIFETIME, mtransportFactory); + + byte[] expectedSignature = {0xD, 0xE, 0xA, 0xD}; + + mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mtransportFactory.transport.setSignedBlob(expectedSignature); + mtransportFactory.transport.setSigningErrorResponseCodeAndMessage(HttpStatusCodes.STATUS_CODE_FORBIDDEN, "Sign Error"); + + try { + byte[] bytes = {0xD, 0xE, 0xA, 0xD}; + targetCredentials.sign(bytes); + fail("Signing should have failed"); + } catch (SigningException e) { + assertEquals("Failed to sign the provided bytes", e.getMessage()); + assertNotNull(e.getCause()); + assertTrue(e.getCause().getMessage().contains("403")); + } + } + + @Test + public void sign_serverError_throws() throws IOException { + GoogleCredentials sourceCredentials = getSourceCredentials(); + MockIAMCredentialsServiceTransportFactory mtransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mtransportFactory.transport.setAccessToken(ACCESS_TOKEN); + mtransportFactory.transport.setexpireTime(getDefaultExpireTime()); + ImpersonatedCredentials targetCredentials = ImpersonatedCredentials.create(sourceCredentials, + IMPERSONATED_CLIENT_EMAIL, null, SCOPES, VALID_LIFETIME, mtransportFactory); + + byte[] expectedSignature = {0xD, 0xE, 0xA, 0xD}; + + mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mtransportFactory.transport.setSignedBlob(expectedSignature); + mtransportFactory.transport.setSigningErrorResponseCodeAndMessage(HttpStatusCodes.STATUS_CODE_SERVER_ERROR, "Sign Error"); + + try { + byte[] bytes = {0xD, 0xE, 0xA, 0xD}; + targetCredentials.sign(bytes); + fail("Signing should have failed"); + } catch (SigningException e) { + assertEquals("Failed to sign the provided bytes", e.getMessage()); + assertNotNull(e.getCause()); + assertTrue(e.getCause().getMessage().contains("500")); + } + } + @Test public void hashCode_equals() throws IOException { GoogleCredentials sourceCredentials = getSourceCredentials(); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java index 71a0ecb6d..75dddaa14 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java @@ -32,25 +32,33 @@ package com.google.auth.oauth2; import java.io.IOException; +import java.util.List; import com.google.api.client.http.LowLevelHttpRequest; import com.google.api.client.http.LowLevelHttpResponse; +import com.google.api.client.http.HttpStatusCodes; import com.google.api.client.json.GenericJson; import com.google.api.client.json.Json; import com.google.api.client.testing.http.MockHttpTransport; import com.google.api.client.testing.http.MockLowLevelHttpRequest; import com.google.api.client.testing.http.MockLowLevelHttpResponse; +import com.google.common.io.BaseEncoding; +import com.google.auth.TestUtils; /** * Transport that simulates the IAMCredentials server for access tokens. */ public class MockIAMCredentialsServiceTransport extends MockHttpTransport { - private static final String IAM_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken"; - + private static final String IAM_ACCESS_TOKEN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken"; + private static final String IAM_ID_TOKEN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateIdToken"; + private static final String IAM_SIGN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:signBlob"; private Integer tokenResponseErrorCode; private String tokenResponseErrorContent; private String targetPrincipal; + private byte[] signedBlob; + private int responseCode = HttpStatusCodes.STATUS_CODE_OK; + private String errorMessage; private String accessToken; private String expireTime; @@ -78,11 +86,21 @@ public void setexpireTime(String expireTime) { this.expireTime = expireTime; } + public void setSignedBlob(byte[] signedBlob) { + this.signedBlob = signedBlob; + } + + public void setSigningErrorResponseCodeAndMessage(int responseCode, String errorMessage) { + this.responseCode = responseCode; + this.errorMessage = errorMessage; + } + @Override public LowLevelHttpRequest buildRequest(String method, String url) throws IOException { - String formattedUrl = String.format(IAM_ENDPOINT, this.targetPrincipal); - if (url.equals(formattedUrl)) { + String iamAccesssTokenformattedUrl = String.format(IAM_ACCESS_TOKEN_ENDPOINT, this.targetPrincipal); + String iamSignBlobformattedUrl = String.format(IAM_SIGN_ENDPOINT, this.targetPrincipal); + if (url.equals(iamAccesssTokenformattedUrl)) { return new MockLowLevelHttpRequest(url) { @Override public LowLevelHttpResponse execute() throws IOException { @@ -105,7 +123,52 @@ public LowLevelHttpResponse execute() throws IOException { .setContent(refreshText); } }; + } else if (url.equals(iamSignBlobformattedUrl) && responseCode != HttpStatusCodes.STATUS_CODE_OK) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + + if (tokenResponseErrorCode != null) { + return new MockLowLevelHttpResponse() + .setStatusCode(tokenResponseErrorCode) + .setContentType(Json.MEDIA_TYPE) + .setContent(tokenResponseErrorContent); + } + + BaseEncoding base64 = BaseEncoding.base64(); + GenericJson refreshContents = new GenericJson(); + refreshContents.setFactory(OAuth2Utils.JSON_FACTORY); + refreshContents.put("signedBlob", base64.encode(signedBlob)); + String refreshText = refreshContents.toPrettyString(); + return new MockLowLevelHttpResponse() + .setStatusCode(responseCode) + .setContent(TestUtils.errorJson(errorMessage)); + } + }; + } else if (url.equals(iamSignBlobformattedUrl)) { + return new MockLowLevelHttpRequest(url) { + @Override + public LowLevelHttpResponse execute() throws IOException { + + if (tokenResponseErrorCode != null) { + return new MockLowLevelHttpResponse() + .setStatusCode(tokenResponseErrorCode) + .setContentType(Json.MEDIA_TYPE) + .setContent(tokenResponseErrorContent); + } + + BaseEncoding base64 = BaseEncoding.base64(); + GenericJson refreshContents = new GenericJson(); + refreshContents.setFactory(OAuth2Utils.JSON_FACTORY); + refreshContents.put("signedBlob", base64.encode(signedBlob)); + String refreshText = refreshContents.toPrettyString(); + return new MockLowLevelHttpResponse() + .setContentType(Json.MEDIA_TYPE) + .setContent(refreshText); + } + }; } + return super.buildRequest(method, url); }