Skip to content

Commit 70767e3

Browse files
Implement ServiceAccountSigner for ImpersonatedCredentials (#279)
* Add Signer for impersonatied credentials * add lint fixes * back to the future * Update oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java Co-Authored-By: Jeff Ching <[email protected]>
1 parent fcbc426 commit 70767e3

File tree

3 files changed

+252
-7
lines changed

3 files changed

+252
-7
lines changed

oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java

+92-3
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import static com.google.common.base.MoreObjects.firstNonNull;
3535

3636
import java.io.IOException;
37+
import java.io.InputStream;
3738
import java.text.DateFormat;
3839
import java.text.ParseException;
3940
import java.text.SimpleDateFormat;
@@ -46,9 +47,11 @@
4647

4748
import com.google.api.client.http.GenericUrl;
4849
import com.google.api.client.http.HttpContent;
50+
import com.google.api.client.http.HttpHeaders;
4951
import com.google.api.client.http.HttpRequest;
5052
import com.google.api.client.http.HttpRequestFactory;
5153
import com.google.api.client.http.HttpResponse;
54+
import com.google.api.client.http.HttpStatusCodes;
5255
import com.google.api.client.http.HttpTransport;
5356
import com.google.api.client.http.json.JsonHttpContent;
5457
import com.google.api.client.json.JsonObjectParser;
@@ -57,6 +60,9 @@
5760
import com.google.auth.http.HttpTransportFactory;
5861
import com.google.common.base.MoreObjects;
5962
import com.google.common.collect.ImmutableMap;
63+
import com.google.common.io.BaseEncoding;
64+
65+
import com.google.auth.ServiceAccountSigner;
6066

6167
/**
6268
* ImpersonatedCredentials allowing credentials issued to a user or service account to impersonate
@@ -81,16 +87,20 @@
8187
* System.out.println(b);
8288
* </pre>
8389
*/
84-
public class ImpersonatedCredentials extends GoogleCredentials {
90+
public class ImpersonatedCredentials extends GoogleCredentials implements ServiceAccountSigner {
8591

8692
private static final long serialVersionUID = -2133257318957488431L;
8793
private static final String RFC3339 = "yyyy-MM-dd'T'HH:mm:ss'Z'";
8894
private static final int ONE_HOUR_IN_SECONDS = 3600;
8995
private static final String CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
90-
private static final String IAM_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken";
96+
private static final String IAM_ACCESS_TOKEN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken";
97+
private static final String IAM_ID_TOKEN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateIdToken";
98+
private static final String IAM_SIGN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:signBlob";
9199

92100
private static final String SCOPE_EMPTY_ERROR = "Scopes cannot be null";
93101
private static final String LIFETIME_EXCEEDED_ERROR = "lifetime must be less than or equal to 3600";
102+
private static final String PARSE_ERROR_SIGNATURE = "Error parsing signature response. ";
103+
private static final String PARSE_ERROR_MESSAGE = "Error parsing error message response. ";
94104

95105
private GoogleCredentials sourceCredentials;
96106
private String targetPrincipal;
@@ -153,6 +163,85 @@ public static ImpersonatedCredentials create(GoogleCredentials sourceCredentials
153163
.build();
154164
}
155165

166+
/**
167+
* Returns the email field of the serviceAccount that is being impersonated.
168+
*
169+
* @return email address of the impesonated service account.
170+
*/
171+
@Override
172+
public String getAccount() {
173+
return this.targetPrincipal;
174+
}
175+
176+
/**
177+
* Signs the provided bytes using the private key associated with the impersonated
178+
* service account
179+
*
180+
* @param toSign bytes to sign
181+
* @return signed bytes
182+
* @throws SigningException if the attempt to sign the provided bytes failed
183+
* @see <a href="https://cloud.google.com/iam/credentials/reference/rest/v1/projects.serviceAccounts/signBlob">Blob Signing</a>
184+
*/
185+
@Override
186+
public byte[] sign(byte[] toSign) {
187+
BaseEncoding base64 = BaseEncoding.base64();
188+
String signature;
189+
try {
190+
signature = getSignature(base64.encode(toSign));
191+
} catch (IOException ex) {
192+
throw new SigningException("Failed to sign the provided bytes", ex);
193+
}
194+
return base64.decode(signature);
195+
}
196+
197+
private String getSignature(String bytes) throws IOException {
198+
String signBlobUrl = String.format(IAM_SIGN_ENDPOINT, getAccount());
199+
GenericUrl genericUrl = new GenericUrl(signBlobUrl);
200+
201+
GenericData signRequest = new GenericData();
202+
signRequest.set("delegates", this.delegates);
203+
signRequest.set("payload", bytes);
204+
JsonHttpContent signContent = new JsonHttpContent(OAuth2Utils.JSON_FACTORY, signRequest);
205+
HttpTransport httpTransport = this.transportFactory.create();
206+
HttpCredentialsAdapter adapter = new HttpCredentialsAdapter(sourceCredentials);
207+
HttpRequestFactory requestFactory = httpTransport.createRequestFactory();
208+
209+
HttpRequest request = requestFactory.buildPostRequest(genericUrl, signContent);
210+
Map<String, List<String>> headers = getRequestMetadata();
211+
HttpHeaders requestHeaders = request.getHeaders();
212+
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
213+
requestHeaders.put(entry.getKey(), entry.getValue());
214+
}
215+
JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
216+
adapter.initialize(request);
217+
request.setParser(parser);
218+
request.setThrowExceptionOnExecuteError(false);
219+
220+
HttpResponse response = request.execute();
221+
int statusCode = response.getStatusCode();
222+
if (statusCode >= 400 && statusCode < HttpStatusCodes.STATUS_CODE_SERVER_ERROR) {
223+
GenericData responseError = response.parseAs(GenericData.class);
224+
Map<String, Object> error = OAuth2Utils.validateMap(responseError, "error", PARSE_ERROR_MESSAGE);
225+
String errorMessage = OAuth2Utils.validateString(error, "message", PARSE_ERROR_MESSAGE);
226+
throw new IOException(String.format("Error code %s trying to sign provided bytes: %s",
227+
statusCode, errorMessage));
228+
}
229+
if (statusCode != HttpStatusCodes.STATUS_CODE_OK) {
230+
throw new IOException(String.format("Unexpected Error code %s trying to sign provided bytes: %s", statusCode,
231+
response.parseAsString()));
232+
}
233+
InputStream content = response.getContent();
234+
if (content == null) {
235+
// Throw explicitly here on empty content to avoid NullPointerException from parseAs call.
236+
// Mock transports will have success code with empty content by default.
237+
throw new IOException("Empty content from sign blob server request.");
238+
}
239+
240+
GenericData responseData = response.parseAs(GenericData.class);
241+
return OAuth2Utils.validateString(responseData, "signedBlob", PARSE_ERROR_SIGNATURE);
242+
}
243+
244+
156245
private ImpersonatedCredentials(Builder builder) {
157246
this.sourceCredentials = builder.getSourceCredentials();
158247
this.targetPrincipal = builder.getTargetPrincipal();
@@ -192,7 +281,7 @@ public AccessToken refreshAccessToken() throws IOException {
192281
HttpCredentialsAdapter adapter = new HttpCredentialsAdapter(sourceCredentials);
193282
HttpRequestFactory requestFactory = httpTransport.createRequestFactory();
194283

195-
String endpointUrl = String.format(IAM_ENDPOINT, this.targetPrincipal);
284+
String endpointUrl = String.format(IAM_ACCESS_TOKEN_ENDPOINT, this.targetPrincipal);
196285
GenericUrl url = new GenericUrl(endpointUrl);
197286

198287
Map<String, Object> body = ImmutableMap.<String, Object>of("delegates", this.delegates, "scope",

oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java

+93
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@
3131

3232
package com.google.auth.oauth2;
3333

34+
import static org.junit.Assert.assertArrayEquals;
3435
import static org.junit.Assert.assertEquals;
3536
import static org.junit.Assert.assertSame;
3637
import static org.junit.Assert.assertTrue;
38+
import static org.junit.Assert.assertNotNull;
3739
import static org.junit.Assert.fail;
3840

3941
import java.io.ByteArrayOutputStream;
@@ -51,6 +53,7 @@
5153
import com.google.api.client.util.Clock;
5254
import com.google.auth.http.HttpTransportFactory;
5355
import com.google.auth.oauth2.GoogleCredentialsTest.MockTokenServerTransportFactory;
56+
import com.google.auth.ServiceAccountSigner.SigningException;
5457

5558
import org.junit.Test;
5659
import org.junit.runner.RunWith;
@@ -247,6 +250,96 @@ public void refreshAccessToken_invalidDate() throws IOException, IllegalStateExc
247250
}
248251
}
249252

253+
@Test
254+
public void getAccount_sameAs() throws IOException {
255+
GoogleCredentials sourceCredentials = getSourceCredentials();
256+
MockIAMCredentialsServiceTransportFactory mtransportFactory =
257+
new MockIAMCredentialsServiceTransportFactory();
258+
mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL);
259+
mtransportFactory.transport.setAccessToken(ACCESS_TOKEN);
260+
mtransportFactory.transport.setexpireTime(getDefaultExpireTime());
261+
ImpersonatedCredentials targetCredentials = ImpersonatedCredentials.create(sourceCredentials,
262+
IMPERSONATED_CLIENT_EMAIL, null, SCOPES, VALID_LIFETIME, mtransportFactory);
263+
264+
assertEquals(IMPERSONATED_CLIENT_EMAIL, targetCredentials.getAccount());
265+
}
266+
267+
268+
@Test
269+
public void sign_sameAs() throws IOException {
270+
GoogleCredentials sourceCredentials = getSourceCredentials();
271+
MockIAMCredentialsServiceTransportFactory mtransportFactory =
272+
new MockIAMCredentialsServiceTransportFactory();
273+
mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL);
274+
mtransportFactory.transport.setAccessToken(ACCESS_TOKEN);
275+
mtransportFactory.transport.setexpireTime(getDefaultExpireTime());
276+
ImpersonatedCredentials targetCredentials = ImpersonatedCredentials.create(sourceCredentials,
277+
IMPERSONATED_CLIENT_EMAIL, null, SCOPES, VALID_LIFETIME, mtransportFactory);
278+
279+
byte[] expectedSignature = {0xD, 0xE, 0xA, 0xD};
280+
281+
mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL);
282+
mtransportFactory.transport.setSignedBlob(expectedSignature);
283+
284+
assertArrayEquals(expectedSignature, targetCredentials.sign(expectedSignature));
285+
}
286+
287+
@Test
288+
public void sign_accessDenied_throws() throws IOException {
289+
GoogleCredentials sourceCredentials = getSourceCredentials();
290+
MockIAMCredentialsServiceTransportFactory mtransportFactory =
291+
new MockIAMCredentialsServiceTransportFactory();
292+
mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL);
293+
mtransportFactory.transport.setAccessToken(ACCESS_TOKEN);
294+
mtransportFactory.transport.setexpireTime(getDefaultExpireTime());
295+
ImpersonatedCredentials targetCredentials = ImpersonatedCredentials.create(sourceCredentials,
296+
IMPERSONATED_CLIENT_EMAIL, null, SCOPES, VALID_LIFETIME, mtransportFactory);
297+
298+
byte[] expectedSignature = {0xD, 0xE, 0xA, 0xD};
299+
300+
mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL);
301+
mtransportFactory.transport.setSignedBlob(expectedSignature);
302+
mtransportFactory.transport.setSigningErrorResponseCodeAndMessage(HttpStatusCodes.STATUS_CODE_FORBIDDEN, "Sign Error");
303+
304+
try {
305+
byte[] bytes = {0xD, 0xE, 0xA, 0xD};
306+
targetCredentials.sign(bytes);
307+
fail("Signing should have failed");
308+
} catch (SigningException e) {
309+
assertEquals("Failed to sign the provided bytes", e.getMessage());
310+
assertNotNull(e.getCause());
311+
assertTrue(e.getCause().getMessage().contains("403"));
312+
}
313+
}
314+
315+
@Test
316+
public void sign_serverError_throws() throws IOException {
317+
GoogleCredentials sourceCredentials = getSourceCredentials();
318+
MockIAMCredentialsServiceTransportFactory mtransportFactory =
319+
new MockIAMCredentialsServiceTransportFactory();
320+
mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL);
321+
mtransportFactory.transport.setAccessToken(ACCESS_TOKEN);
322+
mtransportFactory.transport.setexpireTime(getDefaultExpireTime());
323+
ImpersonatedCredentials targetCredentials = ImpersonatedCredentials.create(sourceCredentials,
324+
IMPERSONATED_CLIENT_EMAIL, null, SCOPES, VALID_LIFETIME, mtransportFactory);
325+
326+
byte[] expectedSignature = {0xD, 0xE, 0xA, 0xD};
327+
328+
mtransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL);
329+
mtransportFactory.transport.setSignedBlob(expectedSignature);
330+
mtransportFactory.transport.setSigningErrorResponseCodeAndMessage(HttpStatusCodes.STATUS_CODE_SERVER_ERROR, "Sign Error");
331+
332+
try {
333+
byte[] bytes = {0xD, 0xE, 0xA, 0xD};
334+
targetCredentials.sign(bytes);
335+
fail("Signing should have failed");
336+
} catch (SigningException e) {
337+
assertEquals("Failed to sign the provided bytes", e.getMessage());
338+
assertNotNull(e.getCause());
339+
assertTrue(e.getCause().getMessage().contains("500"));
340+
}
341+
}
342+
250343
@Test
251344
public void hashCode_equals() throws IOException {
252345
GoogleCredentials sourceCredentials = getSourceCredentials();

oauth2_http/javatests/com/google/auth/oauth2/MockIAMCredentialsServiceTransport.java

+67-4
Original file line numberDiff line numberDiff line change
@@ -32,25 +32,33 @@
3232
package com.google.auth.oauth2;
3333

3434
import java.io.IOException;
35+
import java.util.List;
3536

3637
import com.google.api.client.http.LowLevelHttpRequest;
3738
import com.google.api.client.http.LowLevelHttpResponse;
39+
import com.google.api.client.http.HttpStatusCodes;
3840
import com.google.api.client.json.GenericJson;
3941
import com.google.api.client.json.Json;
4042
import com.google.api.client.testing.http.MockHttpTransport;
4143
import com.google.api.client.testing.http.MockLowLevelHttpRequest;
4244
import com.google.api.client.testing.http.MockLowLevelHttpResponse;
45+
import com.google.common.io.BaseEncoding;
4346

47+
import com.google.auth.TestUtils;
4448
/**
4549
* Transport that simulates the IAMCredentials server for access tokens.
4650
*/
4751
public class MockIAMCredentialsServiceTransport extends MockHttpTransport {
4852

49-
private static final String IAM_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken";
50-
53+
private static final String IAM_ACCESS_TOKEN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken";
54+
private static final String IAM_ID_TOKEN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateIdToken";
55+
private static final String IAM_SIGN_ENDPOINT = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:signBlob";
5156
private Integer tokenResponseErrorCode;
5257
private String tokenResponseErrorContent;
5358
private String targetPrincipal;
59+
private byte[] signedBlob;
60+
private int responseCode = HttpStatusCodes.STATUS_CODE_OK;
61+
private String errorMessage;
5462

5563
private String accessToken;
5664
private String expireTime;
@@ -78,11 +86,21 @@ public void setexpireTime(String expireTime) {
7886
this.expireTime = expireTime;
7987
}
8088

89+
public void setSignedBlob(byte[] signedBlob) {
90+
this.signedBlob = signedBlob;
91+
}
92+
93+
public void setSigningErrorResponseCodeAndMessage(int responseCode, String errorMessage) {
94+
this.responseCode = responseCode;
95+
this.errorMessage = errorMessage;
96+
}
97+
8198
@Override
8299
public LowLevelHttpRequest buildRequest(String method, String url) throws IOException {
83100

84-
String formattedUrl = String.format(IAM_ENDPOINT, this.targetPrincipal);
85-
if (url.equals(formattedUrl)) {
101+
String iamAccesssTokenformattedUrl = String.format(IAM_ACCESS_TOKEN_ENDPOINT, this.targetPrincipal);
102+
String iamSignBlobformattedUrl = String.format(IAM_SIGN_ENDPOINT, this.targetPrincipal);
103+
if (url.equals(iamAccesssTokenformattedUrl)) {
86104
return new MockLowLevelHttpRequest(url) {
87105
@Override
88106
public LowLevelHttpResponse execute() throws IOException {
@@ -105,7 +123,52 @@ public LowLevelHttpResponse execute() throws IOException {
105123
.setContent(refreshText);
106124
}
107125
};
126+
} else if (url.equals(iamSignBlobformattedUrl) && responseCode != HttpStatusCodes.STATUS_CODE_OK) {
127+
return new MockLowLevelHttpRequest(url) {
128+
@Override
129+
public LowLevelHttpResponse execute() throws IOException {
130+
131+
if (tokenResponseErrorCode != null) {
132+
return new MockLowLevelHttpResponse()
133+
.setStatusCode(tokenResponseErrorCode)
134+
.setContentType(Json.MEDIA_TYPE)
135+
.setContent(tokenResponseErrorContent);
136+
}
137+
138+
BaseEncoding base64 = BaseEncoding.base64();
139+
GenericJson refreshContents = new GenericJson();
140+
refreshContents.setFactory(OAuth2Utils.JSON_FACTORY);
141+
refreshContents.put("signedBlob", base64.encode(signedBlob));
142+
String refreshText = refreshContents.toPrettyString();
143+
return new MockLowLevelHttpResponse()
144+
.setStatusCode(responseCode)
145+
.setContent(TestUtils.errorJson(errorMessage));
146+
}
147+
};
148+
} else if (url.equals(iamSignBlobformattedUrl)) {
149+
return new MockLowLevelHttpRequest(url) {
150+
@Override
151+
public LowLevelHttpResponse execute() throws IOException {
152+
153+
if (tokenResponseErrorCode != null) {
154+
return new MockLowLevelHttpResponse()
155+
.setStatusCode(tokenResponseErrorCode)
156+
.setContentType(Json.MEDIA_TYPE)
157+
.setContent(tokenResponseErrorContent);
158+
}
159+
160+
BaseEncoding base64 = BaseEncoding.base64();
161+
GenericJson refreshContents = new GenericJson();
162+
refreshContents.setFactory(OAuth2Utils.JSON_FACTORY);
163+
refreshContents.put("signedBlob", base64.encode(signedBlob));
164+
String refreshText = refreshContents.toPrettyString();
165+
return new MockLowLevelHttpResponse()
166+
.setContentType(Json.MEDIA_TYPE)
167+
.setContent(refreshText);
168+
}
169+
};
108170
}
171+
109172
return super.buildRequest(method, url);
110173
}
111174

0 commit comments

Comments
 (0)