Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Id token support for UserCredentials #650

Merged
merged 23 commits into from May 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cb3866e
new adapter for idtoken
TimurSadykov May 6, 2021
ef781ec
Merge remote-tracking branch 'origin/master' into idtoken-cloudrun
May 6, 2021
9def95a
poc of the IdtokenProvider implementation for UserCredentials
May 6, 2021
3096bd0
removing HttpUserCredentialsAdapter as we go with IdTokenProvider app…
May 6, 2021
164821e
Merge remote-tracking branch 'origin/master' into idtoken-cloudrun
May 11, 2021
74b0994
fix build
May 11, 2021
195e37f
linter fixes
TimurSadykov May 11, 2021
9a0a669
linter fixes
TimurSadykov May 11, 2021
bce601b
actual linter fixes
TimurSadykov May 11, 2021
c1ea597
unit tests for idtoken
May 15, 2021
83cbd33
Merge branch 'idtoken-cloudrun' of https://github.com/googleapis/goog…
May 15, 2021
21b1c79
linter fixes
May 16, 2021
2a997aa
Merge remote-tracking branch 'origin/master' into idtoken-cloudrun
May 16, 2021
58f0c48
comments addressed
TimurSadykov May 18, 2021
9b2b10d
Merge branch 'master' into idtoken-cloudrun
TimurSadykov May 18, 2021
308670d
Merge remote-tracking branch 'origin/master' into idtoken-cloudrun
TimurSadykov May 20, 2021
ded1cb9
Merge remote-tracking branch 'origin/master' into idtoken-cloudrun
TimurSadykov May 24, 2021
85114c4
remove redundant idtoken refresh
TimurSadykov May 24, 2021
5ab8df6
fix of removing redundant idtoken referesh
TimurSadykov May 25, 2021
5dc530e
fix tests
TimurSadykov May 25, 2021
903058c
Merge branch 'idtoken-cloudrun' of https://github.com/googleapis/goog…
TimurSadykov May 25, 2021
cf6cbb2
null check and more docs
TimurSadykov May 26, 2021
9877f93
Merge remote-tracking branch 'origin/master' into idtoken-cloudrun
TimurSadykov May 26, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Expand Up @@ -9,3 +9,6 @@ target/
# Intellij
*.iml
.idea/

# VS Code
.vscode/
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
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
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
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();
TimurSadykov marked this conversation as resolved.
Show resolved Hide resolved

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