diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index 87530ba37..1d50e1479 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -1153,6 +1153,12 @@ public final class RegistryConfig { return ImmutableSet.copyOf(config.oAuth.allowedOauthClientIds); } + @Provides + @Config("iapClientId") + public static Optional provideIapClientId(RegistryConfigSettings config) { + return Optional.ofNullable(config.oAuth.iapClientId); + } + /** * Provides the OAuth scopes required for accessing Google APIs using the default credential. */ diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index a32c41f3f..4e6e0a536 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -61,6 +61,7 @@ public class RegistryConfigSettings { public List availableOauthScopes; public List requiredOauthScopes; public List allowedOauthClientIds; + public String iapClientId; } /** Configuration options for accessing Google APIs. */ diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index cd8468bf7..223a3eb39 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -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 diff --git a/core/src/main/java/google/registry/tools/RequestFactoryModule.java b/core/src/main/java/google/registry/tools/RequestFactoryModule.java index 1478fde62..79b531a42 100644 --- a/core/src/main/java/google/registry/tools/RequestFactoryModule.java +++ b/core/src/main/java/google/registry/tools/RequestFactoryModule.java @@ -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 + * + *

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 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. + * + *

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(); + } } diff --git a/core/src/test/java/google/registry/tools/RequestFactoryModuleTest.java b/core/src/test/java/google/registry/tools/RequestFactoryModuleTest.java index 81976fe75..05132ff87 100644 --- a/core/src/test/java/google/registry/tools/RequestFactoryModuleTest.java +++ b/core/src/test/java/google/registry/tools/RequestFactoryModuleTest.java @@ -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; + } + } }