Add an optional IAP-enabled ID token when using the Nomulus tool (#1887)

We can use the saved refresh token associated with the nomulus tool to
request an ID token with an audience of the IAP client in order to
satisfy IAP with with the Nomulus tool.

Note: this requires that the user of the Nomulus tool, e.g.
"gbrodman@google.com" has a User object stored in SQL.

Tested on QA
This commit is contained in:
gbrodman 2023-01-04 11:43:31 -05:00 committed by GitHub
parent dae5a0b6b6
commit de9dee4623
5 changed files with 114 additions and 4 deletions

View file

@ -1153,6 +1153,12 @@ public final class RegistryConfig {
return ImmutableSet.copyOf(config.oAuth.allowedOauthClientIds);
}
@Provides
@Config("iapClientId")
public static Optional<String> provideIapClientId(RegistryConfigSettings config) {
return Optional.ofNullable(config.oAuth.iapClientId);
}
/**
* Provides the OAuth scopes required for accessing Google APIs using the default credential.
*/

View file

@ -61,6 +61,7 @@ public class RegistryConfigSettings {
public List<String> availableOauthScopes;
public List<String> requiredOauthScopes;
public List<String> allowedOauthClientIds;
public String iapClientId;
}
/** Configuration options for accessing Google APIs. */

View file

@ -306,6 +306,9 @@ oAuth:
# in this list. Client IDs are typically of the format
# numbers-alphanumerics.apps.googleusercontent.com
allowedOauthClientIds: []
# GCP Identity-Aware Proxy client ID, if set up (note: this requires manual setup
# of User objects in the database for Nomulus tool users)
iapClientId: null
credentialOAuth:
# OAuth scopes required for accessing Google APIs using the default

View file

@ -14,13 +14,23 @@
package google.registry.tools;
import com.google.api.client.http.GenericUrl;
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.UrlEncodedContent;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.util.GenericData;
import com.google.auth.oauth2.UserCredentials;
import dagger.Module;
import dagger.Provides;
import google.registry.config.CredentialModule.DefaultCredential;
import google.registry.config.RegistryConfig;
import google.registry.config.RegistryConfig.Config;
import google.registry.util.GoogleCredentialsBundle;
import java.io.IOException;
import java.net.URI;
import java.util.Optional;
/**
* Module for providing the HttpRequestFactory.
@ -33,9 +43,21 @@ class RequestFactoryModule {
static final int REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
/**
* Server to use if we want to manually request an IAP ID token
*
* <p>If we need to have an IAP-enabled audience, we can use the existing refresh token and the
* IAP client ID audience to request an IAP-enabled ID token. This token is read and used by
* {@link google.registry.request.auth.IapHeaderAuthenticationMechanism}, and it requires that the
* user have a {@link google.registry.model.console.User} object present in the database.
*/
private static final GenericUrl TOKEN_SERVER_URL =
new GenericUrl(URI.create("https://oauth2.googleapis.com/token"));
@Provides
static HttpRequestFactory provideHttpRequestFactory(
@DefaultCredential GoogleCredentialsBundle credentialsBundle) {
@DefaultCredential GoogleCredentialsBundle credentialsBundle,
@Config("iapClientId") Optional<String> iapClientId) {
if (RegistryConfig.areServersLocal()) {
return new NetHttpTransport()
.createRequestFactory(
@ -47,7 +69,15 @@ class RequestFactoryModule {
return new NetHttpTransport()
.createRequestFactory(
request -> {
credentialsBundle.getHttpRequestInitializer().initialize(request);
// If using IAP, use the refresh token to acquire an IAP-enabled ID token and use
// that for authentication.
if (iapClientId.isPresent()) {
String idToken = getIdToken(credentialsBundle, iapClientId.get());
request.getHeaders().setAuthorization("Bearer " + idToken);
} else {
// Otherwise, use the standard credential HTTP initializer
credentialsBundle.getHttpRequestInitializer().initialize(request);
}
// GAE request times out after 10 min, so here we set the timeout to 10 min. This is
// needed to support some nomulus commands like updating premium lists that take
// a lot of time to complete.
@ -58,4 +88,32 @@ class RequestFactoryModule {
});
}
}
/**
* Uses the saved desktop-app refresh token to acquire an IAP ID token.
*
* <p>This is lifted mostly from the Google Auth Library's {@link UserCredentials}
* "doRefreshAccessToken" method (which is private and thus inaccessible) while adding in the
* audience of the IAP client ID. That addition of the audience is what allows us to satisfy IAP
* auth. See
* https://cloud.google.com/iap/docs/authentication-howto#authenticating_from_a_desktop_app for
* more details.
*/
private static String getIdToken(GoogleCredentialsBundle credentialsBundle, String iapClientId)
throws IOException {
UserCredentials credentials = (UserCredentials) credentialsBundle.getGoogleCredentials();
GenericData tokenRequest = new GenericData();
tokenRequest.set("client_id", credentials.getClientId());
tokenRequest.set("client_secret", credentials.getClientSecret());
tokenRequest.set("refresh_token", credentials.getRefreshToken());
tokenRequest.set("audience", iapClientId);
tokenRequest.set("grant_type", "refresh_token");
UrlEncodedContent content = new UrlEncodedContent(tokenRequest);
HttpRequestFactory requestFactory = credentialsBundle.getHttpTransport().createRequestFactory();
HttpRequest request = requestFactory.buildPostRequest(TOKEN_SERVER_URL, content);
request.setParser(credentialsBundle.getJsonFactory().createJsonObjectParser());
HttpResponse response = request.execute();
return response.parseAs(GenericData.class).get("id_token").toString();
}
}

View file

@ -16,6 +16,8 @@ package google.registry.tools;
import static com.google.common.truth.Truth.assertThat;
import static google.registry.tools.RequestFactoryModule.REQUEST_TIMEOUT_MS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
@ -25,9 +27,15 @@ import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestFactory;
import com.google.api.client.http.HttpRequestInitializer;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.util.GenericData;
import com.google.auth.oauth2.UserCredentials;
import google.registry.config.RegistryConfig;
import google.registry.testing.SystemPropertyExtension;
import google.registry.util.GoogleCredentialsBundle;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -57,7 +65,7 @@ public class RequestFactoryModuleTest {
RegistryConfig.CONFIG_SETTINGS.get().gcpProject.isLocal = true;
try {
HttpRequestFactory factory =
RequestFactoryModule.provideHttpRequestFactory(credentialsBundle);
RequestFactoryModule.provideHttpRequestFactory(credentialsBundle, Optional.empty());
HttpRequestInitializer initializer = factory.getInitializer();
assertThat(initializer).isNotNull();
HttpRequest request = factory.buildGetRequest(new GenericUrl("http://localhost"));
@ -76,7 +84,7 @@ public class RequestFactoryModuleTest {
RegistryConfig.CONFIG_SETTINGS.get().gcpProject.isLocal = false;
try {
HttpRequestFactory factory =
RequestFactoryModule.provideHttpRequestFactory(credentialsBundle);
RequestFactoryModule.provideHttpRequestFactory(credentialsBundle, Optional.empty());
HttpRequestInitializer initializer = factory.getInitializer();
assertThat(initializer).isNotNull();
// HttpRequestFactory#buildGetRequest() calls initialize() once.
@ -89,4 +97,38 @@ public class RequestFactoryModuleTest {
RegistryConfig.CONFIG_SETTINGS.get().gcpProject.isLocal = origIsLocal;
}
}
@Test
void test_provideHttpRequestFactory_remote_withIap() throws Exception {
// Mock the request/response to/from the IAP server requesting an ID token
UserCredentials mockUserCredentials = mock(UserCredentials.class);
when(credentialsBundle.getGoogleCredentials()).thenReturn(mockUserCredentials);
HttpTransport mockTransport = mock(HttpTransport.class);
when(credentialsBundle.getHttpTransport()).thenReturn(mockTransport);
when(credentialsBundle.getJsonFactory()).thenReturn(GsonFactory.getDefaultInstance());
HttpRequestFactory mockRequestFactory = mock(HttpRequestFactory.class);
when(mockTransport.createRequestFactory()).thenReturn(mockRequestFactory);
HttpRequest mockPostRequest = mock(HttpRequest.class);
when(mockRequestFactory.buildPostRequest(any(), any())).thenReturn(mockPostRequest);
HttpResponse mockResponse = mock(HttpResponse.class);
when(mockPostRequest.execute()).thenReturn(mockResponse);
GenericData genericDataResponse = new GenericData();
genericDataResponse.set("id_token", "iapIdToken");
when(mockResponse.parseAs(GenericData.class)).thenReturn(genericDataResponse);
boolean origIsLocal = RegistryConfig.CONFIG_SETTINGS.get().gcpProject.isLocal;
RegistryConfig.CONFIG_SETTINGS.get().gcpProject.isLocal = false;
try {
HttpRequestFactory factory =
RequestFactoryModule.provideHttpRequestFactory(
credentialsBundle, Optional.of("iapClientId"));
HttpRequest request = factory.buildGetRequest(new GenericUrl("http://localhost"));
assertThat(request.getHeaders().getAuthorization()).isEqualTo("Bearer iapIdToken");
assertThat(request.getConnectTimeout()).isEqualTo(REQUEST_TIMEOUT_MS);
assertThat(request.getReadTimeout()).isEqualTo(REQUEST_TIMEOUT_MS);
verifyNoMoreInteractions(httpRequestInitializer);
} finally {
RegistryConfig.CONFIG_SETTINGS.get().gcpProject.isLocal = origIsLocal;
}
}
}