Skip to content

Commit

Permalink
feat: add Id token support for UserCredentials (#650)
Browse files Browse the repository at this point in the history
IdtokenProvider implementation for UserCredentials with unit tests for idtoken
  • Loading branch information
TimurSadykov committed May 26, 2021
1 parent b001f71 commit 5a8f467
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 22 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ target/
# Intellij
*.iml
.idea/

# VS Code
.vscode/
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,12 @@ public class IdTokenCredentials extends OAuth2Credentials {

private IdTokenCredentials(Builder builder) {
this.idTokenProvider = Preconditions.checkNotNull(builder.getIdTokenProvider());
this.targetAudience = Preconditions.checkNotNull(builder.getTargetAudience());

// target audience can't be used for UserCredentials
if (!(this.idTokenProvider instanceof UserCredentials)) {
this.targetAudience = Preconditions.checkNotNull(builder.getTargetAudience());
}

this.options = builder.getOptions();
}

Expand Down
71 changes: 54 additions & 17 deletions oauth2_http/java/com/google/auth/oauth2/UserCredentials.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
import java.util.Objects;

/** OAuth2 Credentials representing a user's identity and consent. */
public class UserCredentials extends GoogleCredentials implements QuotaProjectIdProvider {
public class UserCredentials extends GoogleCredentials
implements QuotaProjectIdProvider, IdTokenProvider {

private static final String GRANT_TYPE = "refresh_token";
private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. ";
Expand Down Expand Up @@ -186,22 +187,7 @@ public static UserCredentials fromStream(
/** Refreshes the OAuth2 access token by getting a new access token from the refresh token */
@Override
public AccessToken refreshAccessToken() throws IOException {
if (refreshToken == null) {
throw new IllegalStateException(
"UserCredentials instance cannot refresh because there is no" + " refresh token.");
}
GenericData tokenRequest = new GenericData();
tokenRequest.set("client_id", clientId);
tokenRequest.set("client_secret", clientSecret);
tokenRequest.set("refresh_token", refreshToken);
tokenRequest.set("grant_type", GRANT_TYPE);
UrlEncodedContent content = new UrlEncodedContent(tokenRequest);

HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory();
HttpRequest request = requestFactory.buildPostRequest(new GenericUrl(tokenServerUri), content);
request.setParser(new JsonObjectParser(JSON_FACTORY));
HttpResponse response = request.execute();
GenericData responseData = response.parseAs(GenericData.class);
GenericData responseData = doRefreshAccessToken();
String accessToken =
OAuth2Utils.validateString(responseData, "access_token", PARSE_ERROR_PREFIX);
int expiresInSeconds =
Expand All @@ -210,6 +196,33 @@ public AccessToken refreshAccessToken() throws IOException {
return new AccessToken(accessToken, new Date(expiresAtMilliseconds));
}

/**
* Returns a Google ID Token from the refresh token response.
*
* @param targetAudience This can't be used for UserCredentials.
* @param options list of Credential specific options for the token. Currently unused for
* UserCredentials.
* @throws IOException if the attempt to get an IdToken failed
* @return IdToken object which includes the raw id_token, expiration and audience
*/
@Override
public IdToken idTokenWithAudience(String targetAudience, List<Option> options)
throws IOException {
GenericData responseData = doRefreshAccessToken();
String idTokenKey = "id_token";
if (responseData.containsKey(idTokenKey)) {
String idTokenString =
OAuth2Utils.validateString(responseData, idTokenKey, PARSE_ERROR_PREFIX);
return IdToken.create(idTokenString);
}

throw new IOException(
"UserCredentials can obtain an id token only when authenticated through"
+ " gcloud running 'gcloud auth login --update-adc' or 'gcloud auth application-default"
+ " login'. The latter form would not work for Cloud Run, but would still generate an"
+ " id token.");
}

/**
* Returns client ID of the credential from the console.
*
Expand Down Expand Up @@ -237,6 +250,30 @@ public final String getRefreshToken() {
return refreshToken;
}

/**
* Does refresh access token request
*
* @return Refresh token response data
*/
private GenericData doRefreshAccessToken() throws IOException {
if (refreshToken == null) {
throw new IllegalStateException(
"UserCredentials instance cannot refresh because there is no refresh token.");
}
GenericData tokenRequest = new GenericData();
tokenRequest.set("client_id", clientId);
tokenRequest.set("client_secret", clientSecret);
tokenRequest.set("refresh_token", refreshToken);
tokenRequest.set("grant_type", GRANT_TYPE);
UrlEncodedContent content = new UrlEncodedContent(tokenRequest);

HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory();
HttpRequest request = requestFactory.buildPostRequest(new GenericUrl(tokenServerUri), content);
request.setParser(new JsonObjectParser(JSON_FACTORY));
HttpResponse response = request.execute();
return response.parseAs(GenericData.class);
}

/**
* Returns the instance of InputStream containing the following user credentials in JSON format: -
* RefreshToken - ClientId - ClientSecret - ServerTokenUri
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
/** Mock transport to simulate providing Google OAuth2 access tokens */
public class MockTokenServerTransport extends MockHttpTransport {

public static final String REFRESH_TOKEN_WITH_USER_SCOPE = "refresh_token_with_user.email_scope";
static final String EXPECTED_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer";
static final JsonFactory JSON_FACTORY = new GsonFactory();
int buildRequestCount;
Expand Down Expand Up @@ -130,9 +131,9 @@ public LowLevelHttpRequest buildRequest(String method, String url) throws IOExce
throw error;
}
int questionMarkPos = url.indexOf('?');
final String urlWithoutQUery = (questionMarkPos > 0) ? url.substring(0, questionMarkPos) : url;
final String urlWithoutQuery = (questionMarkPos > 0) ? url.substring(0, questionMarkPos) : url;
final String query = (questionMarkPos > 0) ? url.substring(questionMarkPos + 1) : "";
if (urlWithoutQUery.equals(tokenServerUri.toString())) {
if (urlWithoutQuery.equals(tokenServerUri.toString())) {
return new MockLowLevelHttpRequest(url) {
@Override
public LowLevelHttpResponse execute() throws IOException {
Expand All @@ -156,6 +157,7 @@ public LowLevelHttpResponse execute() throws IOException {
boolean generateAccessToken = true;

String foundId = query.get("client_id");
boolean isUserEmailScope = false;
if (foundId != null) {
if (!clients.containsKey(foundId)) {
throw new IOException("Client ID not found.");
Expand All @@ -178,6 +180,9 @@ public LowLevelHttpResponse execute() throws IOException {
if (!refreshTokens.containsKey(refreshToken)) {
throw new IOException("Refresh Token not found.");
}
if (refreshToken.equals(REFRESH_TOKEN_WITH_USER_SCOPE)) {
isUserEmailScope = true;
}
accessToken = refreshTokens.get(refreshToken);
} else if (query.containsKey("grant_type")) {
String grantType = query.get("grant_type");
Expand Down Expand Up @@ -219,7 +224,8 @@ public LowLevelHttpResponse execute() throws IOException {
if (refreshToken != null) {
responseContents.put("refresh_token", refreshToken);
}
} else {
}
if (isUserEmailScope || !generateAccessToken) {
responseContents.put("id_token", ServiceAccountCredentialsTest.DEFAULT_ID_TOKEN);
}
String refreshText = responseContents.toPrettyString();
Expand All @@ -229,7 +235,7 @@ public LowLevelHttpResponse execute() throws IOException {
.setContent(refreshText);
}
};
} else if (urlWithoutQUery.equals(OAuth2Utils.TOKEN_REVOKE_URI.toString())) {
} else if (urlWithoutQuery.equals(OAuth2Utils.TOKEN_REVOKE_URI.toString())) {
return new MockLowLevelHttpRequest(url) {
@Override
public LowLevelHttpResponse execute() throws IOException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
Expand Down Expand Up @@ -73,6 +74,12 @@ public class UserCredentialsTest extends BaseSerializationTest {
private static final String QUOTA_PROJECT = "sample-quota-project-id";
private static final Collection<String> SCOPES = Collections.singletonList("dummy.scope");
private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo");
public static final String DEFAULT_ID_TOKEN =
"eyJhbGciOiJSUzI1NiIsImtpZCI6ImRmMzc1ODkwOGI3OTIyO"
+ "TNhZDk3N2EwYjk5MWQ5OGE3N2Y0ZWVlY2QiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJodHRwczovL2Zvby5iYXIiL"
+ "CJhenAiOiIxMDIxMDE1NTA4MzQyMDA3MDg1NjgiLCJleHAiOjE1NjQ0NzUwNTEsImlhdCI6MTU2NDQ3MTQ1MSwi"
+ "aXNzIjoiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tIiwic3ViIjoiMTAyMTAxNTUwODM0MjAwNzA4NTY4In0"
+ ".redacted";

@Test(expected = IllegalStateException.class)
public void constructor_accessAndRefreshTokenNull_throws() {
Expand Down Expand Up @@ -699,6 +706,59 @@ public void onFailure(Throwable exception) {
assertTrue("Should have run onSuccess() callback", success.get());
}

@Test
public void IdTokenCredentials_WithUserEmailScope_success() throws IOException {
MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory();
String refreshToken = MockTokenServerTransport.REFRESH_TOKEN_WITH_USER_SCOPE;

transportFactory.transport.addClient(CLIENT_ID, CLIENT_SECRET);
transportFactory.transport.addRefreshToken(refreshToken, ACCESS_TOKEN);
InputStream userStream = writeUserStream(CLIENT_ID, CLIENT_SECRET, refreshToken, QUOTA_PROJECT);

UserCredentials credentials = UserCredentials.fromStream(userStream, transportFactory);
credentials.refresh();

assertEquals(ACCESS_TOKEN, credentials.getAccessToken().getTokenValue());

IdTokenCredentials tokenCredential =
IdTokenCredentials.newBuilder().setIdTokenProvider(credentials).build();

assertNull(tokenCredential.getAccessToken());
assertNull(tokenCredential.getIdToken());

// trigger the refresh like it would happen during a request build
tokenCredential.getRequestMetadata();

assertEquals(DEFAULT_ID_TOKEN, tokenCredential.getAccessToken().getTokenValue());
assertEquals(DEFAULT_ID_TOKEN, tokenCredential.getIdToken().getTokenValue());
}

@Test
public void IdTokenCredentials_NoUserEmailScope_throws() throws IOException {
MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory();
transportFactory.transport.addClient(CLIENT_ID, CLIENT_SECRET);
transportFactory.transport.addRefreshToken(REFRESH_TOKEN, ACCESS_TOKEN);
InputStream userStream =
writeUserStream(CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN, QUOTA_PROJECT);

UserCredentials credentials = UserCredentials.fromStream(userStream, transportFactory);

IdTokenCredentials tokenCredential =
IdTokenCredentials.newBuilder().setIdTokenProvider(credentials).build();

String expectedMessageContent =
"UserCredentials can obtain an id token only when authenticated through"
+ " gcloud running 'gcloud auth login --update-adc' or 'gcloud auth application-default"
+ " login'. The latter form would not work for Cloud Run, but would still generate an"
+ " id token.";

try {
tokenCredential.refresh();
} catch (IOException expected) {
assertTrue(expected.getMessage().equals(expectedMessageContent));
}
}

static GenericJson writeUserJson(
String clientId, String clientSecret, String refreshToken, String quotaProjectId) {
GenericJson json = new GenericJson();
Expand Down

0 comments on commit 5a8f467

Please sign in to comment.