mirror of
https://github.com/google/nomulus.git
synced 2025-04-30 03:57:51 +02:00
Implement Keyless Delegated credential (#1836)
Add a implementation of Delegated credential without using downloaded private key. This is a stop-gap implementation while waiting for a solution from the Java auth library. Also added a verifier action to test the new credential in production. Testing is helpful because: Configuration is per-environment, therefore, success in alpha does not fully validate prod. The relevant use case is triggered by low-frequency activities. Problem may not pop out for hours or longer.
This commit is contained in:
parent
25bdf5bee4
commit
2812df303d
11 changed files with 547 additions and 0 deletions
|
@ -21,6 +21,7 @@ import static google.registry.batch.AsyncTaskEnqueuer.PARAM_RESOURCE_KEY;
|
||||||
import static google.registry.batch.AsyncTaskEnqueuer.QUEUE_ASYNC_ACTIONS;
|
import static google.registry.batch.AsyncTaskEnqueuer.QUEUE_ASYNC_ACTIONS;
|
||||||
import static google.registry.batch.AsyncTaskEnqueuer.QUEUE_ASYNC_DELETE;
|
import static google.registry.batch.AsyncTaskEnqueuer.QUEUE_ASYNC_DELETE;
|
||||||
import static google.registry.batch.AsyncTaskEnqueuer.QUEUE_ASYNC_HOST_RENAME;
|
import static google.registry.batch.AsyncTaskEnqueuer.QUEUE_ASYNC_HOST_RENAME;
|
||||||
|
import static google.registry.batch.CannedScriptExecutionAction.SCRIPT_PARAM;
|
||||||
import static google.registry.request.RequestParameters.extractBooleanParameter;
|
import static google.registry.request.RequestParameters.extractBooleanParameter;
|
||||||
import static google.registry.request.RequestParameters.extractIntParameter;
|
import static google.registry.request.RequestParameters.extractIntParameter;
|
||||||
import static google.registry.request.RequestParameters.extractLongParameter;
|
import static google.registry.request.RequestParameters.extractLongParameter;
|
||||||
|
@ -145,4 +146,11 @@ public class BatchModule {
|
||||||
static Queue provideAsyncHostRenamePullQueue() {
|
static Queue provideAsyncHostRenamePullQueue() {
|
||||||
return getQueue(QUEUE_ASYNC_HOST_RENAME);
|
return getQueue(QUEUE_ASYNC_HOST_RENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(b/234424397): remove method after credential changes are rolled out.
|
||||||
|
@Provides
|
||||||
|
@Parameter(SCRIPT_PARAM)
|
||||||
|
static String provideScriptName(HttpServletRequest req) {
|
||||||
|
return extractRequiredParameter(req, SCRIPT_PARAM);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Copyright 2022 The Nomulus Authors. All Rights Reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package google.registry.batch;
|
||||||
|
|
||||||
|
import static google.registry.request.Action.Method.POST;
|
||||||
|
|
||||||
|
import com.google.common.collect.ImmutableMap;
|
||||||
|
import com.google.common.flogger.FluentLogger;
|
||||||
|
import google.registry.batch.cannedscript.GroupsApiChecker;
|
||||||
|
import google.registry.request.Action;
|
||||||
|
import google.registry.request.Parameter;
|
||||||
|
import google.registry.request.auth.Auth;
|
||||||
|
import javax.inject.Inject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action that executes a canned script specified by the caller.
|
||||||
|
*
|
||||||
|
* <p>This class is introduced to help the safe rollout of credential changes. The delegated
|
||||||
|
* credentials in particular, benefit from this: they require manual configuration of the peer
|
||||||
|
* system in each environment, and may wait hours or even days after deployment until triggered by
|
||||||
|
* user activities.
|
||||||
|
*
|
||||||
|
* <p>This action can be invoked using the Nomulus CLI command: {@code nomulus -e ${env} curl
|
||||||
|
* --service BACKEND -X POST -u '/_dr/task/executeCannedScript?script=${script_name}'}
|
||||||
|
*/
|
||||||
|
// TODO(b/234424397): remove class after credential changes are rolled out.
|
||||||
|
@Action(
|
||||||
|
service = Action.Service.BACKEND,
|
||||||
|
path = "/_dr/task/executeCannedScript",
|
||||||
|
method = POST,
|
||||||
|
automaticallyPrintOk = true,
|
||||||
|
auth = Auth.AUTH_INTERNAL_OR_ADMIN)
|
||||||
|
public class CannedScriptExecutionAction implements Runnable {
|
||||||
|
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
|
||||||
|
|
||||||
|
static final String SCRIPT_PARAM = "script";
|
||||||
|
|
||||||
|
static final ImmutableMap<String, Runnable> SCRIPTS =
|
||||||
|
ImmutableMap.of("runGroupsApiChecks", GroupsApiChecker::runGroupsApiChecks);
|
||||||
|
|
||||||
|
private final String scriptName;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
CannedScriptExecutionAction(@Parameter(SCRIPT_PARAM) String scriptName) {
|
||||||
|
logger.atInfo().log("Received request to run script %s", scriptName);
|
||||||
|
this.scriptName = scriptName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
if (!SCRIPTS.containsKey(scriptName)) {
|
||||||
|
throw new IllegalArgumentException("Script not found:" + scriptName);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
SCRIPTS.get(scriptName).run();
|
||||||
|
logger.atInfo().log("Finished running %s.", scriptName);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
logger.atWarning().withCause(t).log("Error executing %s", scriptName);
|
||||||
|
throw new RuntimeException("Execution failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
// Copyright 2022 The Nomulus Authors. All Rights Reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package google.registry.batch.cannedscript;
|
||||||
|
|
||||||
|
import static com.google.common.collect.ImmutableList.toImmutableList;
|
||||||
|
import static google.registry.util.RegistrarUtils.normalizeRegistrarId;
|
||||||
|
|
||||||
|
import com.google.api.services.admin.directory.Directory;
|
||||||
|
import com.google.api.services.groupssettings.Groupssettings;
|
||||||
|
import com.google.common.base.Supplier;
|
||||||
|
import com.google.common.base.Suppliers;
|
||||||
|
import com.google.common.base.Throwables;
|
||||||
|
import com.google.common.collect.Streams;
|
||||||
|
import com.google.common.flogger.FluentLogger;
|
||||||
|
import dagger.Component;
|
||||||
|
import dagger.Module;
|
||||||
|
import dagger.Provides;
|
||||||
|
import google.registry.config.CredentialModule;
|
||||||
|
import google.registry.config.CredentialModule.AdcDelegatedCredential;
|
||||||
|
import google.registry.config.RegistryConfig.Config;
|
||||||
|
import google.registry.config.RegistryConfig.ConfigModule;
|
||||||
|
import google.registry.groups.DirectoryGroupsConnection;
|
||||||
|
import google.registry.model.registrar.Registrar;
|
||||||
|
import google.registry.model.registrar.RegistrarPoc;
|
||||||
|
import google.registry.util.GoogleCredentialsBundle;
|
||||||
|
import google.registry.util.UtilsModule;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import javax.inject.Singleton;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that the credential with the {@link AdcDelegatedCredential} annotation can be used to
|
||||||
|
* access the Google Workspace Groups API.
|
||||||
|
*/
|
||||||
|
// TODO(b/234424397): remove class after credential changes are rolled out.
|
||||||
|
public class GroupsApiChecker {
|
||||||
|
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
|
||||||
|
|
||||||
|
private static final Supplier<GroupsConnectionComponent> COMPONENT_SUPPLIER =
|
||||||
|
Suppliers.memoize(DaggerGroupsApiChecker_GroupsConnectionComponent::create);
|
||||||
|
|
||||||
|
public static void runGroupsApiChecks() {
|
||||||
|
GroupsConnectionComponent component = COMPONENT_SUPPLIER.get();
|
||||||
|
DirectoryGroupsConnection groupsConnection = component.groupsConnection();
|
||||||
|
|
||||||
|
List<Registrar> registrars =
|
||||||
|
Streams.stream(Registrar.loadAllCached())
|
||||||
|
.filter(registrar -> registrar.isLive() && registrar.getType() == Registrar.Type.REAL)
|
||||||
|
.collect(toImmutableList());
|
||||||
|
for (Registrar registrar : registrars) {
|
||||||
|
for (final RegistrarPoc.Type type : RegistrarPoc.Type.values()) {
|
||||||
|
String groupKey =
|
||||||
|
String.format(
|
||||||
|
"%s-%s-contacts@%s",
|
||||||
|
normalizeRegistrarId(registrar.getRegistrarId()),
|
||||||
|
type.getDisplayName(),
|
||||||
|
component.gSuiteDomainName());
|
||||||
|
try {
|
||||||
|
Set<String> currentMembers = groupsConnection.getMembersOfGroup(groupKey);
|
||||||
|
logger.atInfo().log("Found %s members for %s.", currentMembers.size(), groupKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Throwables.throwIfUnchecked(e);
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Component(
|
||||||
|
modules = {
|
||||||
|
ConfigModule.class,
|
||||||
|
CredentialModule.class,
|
||||||
|
GroupsApiModule.class,
|
||||||
|
UtilsModule.class
|
||||||
|
})
|
||||||
|
interface GroupsConnectionComponent {
|
||||||
|
DirectoryGroupsConnection groupsConnection();
|
||||||
|
|
||||||
|
@Config("gSuiteDomainName")
|
||||||
|
String gSuiteDomainName();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
static class GroupsApiModule {
|
||||||
|
@Provides
|
||||||
|
static Directory provideDirectory(
|
||||||
|
@AdcDelegatedCredential GoogleCredentialsBundle credentialsBundle,
|
||||||
|
@Config("projectId") String projectId) {
|
||||||
|
return new Directory.Builder(
|
||||||
|
credentialsBundle.getHttpTransport(),
|
||||||
|
credentialsBundle.getJsonFactory(),
|
||||||
|
credentialsBundle.getHttpRequestInitializer())
|
||||||
|
.setApplicationName(projectId)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
static Groupssettings provideGroupsSettings(
|
||||||
|
@AdcDelegatedCredential GoogleCredentialsBundle credentialsBundle,
|
||||||
|
@Config("projectId") String projectId) {
|
||||||
|
return new Groupssettings.Builder(
|
||||||
|
credentialsBundle.getHttpTransport(),
|
||||||
|
credentialsBundle.getJsonFactory(),
|
||||||
|
credentialsBundle.getHttpRequestInitializer())
|
||||||
|
.setApplicationName(projectId)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,14 +14,17 @@
|
||||||
|
|
||||||
package google.registry.config;
|
package google.registry.config;
|
||||||
|
|
||||||
|
import static com.google.common.base.Preconditions.checkArgument;
|
||||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
|
||||||
|
import com.google.auth.ServiceAccountSigner;
|
||||||
import com.google.auth.oauth2.GoogleCredentials;
|
import com.google.auth.oauth2.GoogleCredentials;
|
||||||
import com.google.common.collect.ImmutableList;
|
import com.google.common.collect.ImmutableList;
|
||||||
import dagger.Module;
|
import dagger.Module;
|
||||||
import dagger.Provides;
|
import dagger.Provides;
|
||||||
import google.registry.config.RegistryConfig.Config;
|
import google.registry.config.RegistryConfig.Config;
|
||||||
import google.registry.keyring.api.KeyModule.Key;
|
import google.registry.keyring.api.KeyModule.Key;
|
||||||
|
import google.registry.util.Clock;
|
||||||
import google.registry.util.GoogleCredentialsBundle;
|
import google.registry.util.GoogleCredentialsBundle;
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
@ -29,6 +32,7 @@ import java.io.UncheckedIOException;
|
||||||
import java.lang.annotation.Documented;
|
import java.lang.annotation.Documented;
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
import java.lang.annotation.RetentionPolicy;
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.time.Duration;
|
||||||
import javax.inject.Qualifier;
|
import javax.inject.Qualifier;
|
||||||
import javax.inject.Singleton;
|
import javax.inject.Singleton;
|
||||||
|
|
||||||
|
@ -158,6 +162,48 @@ public abstract class CredentialModule {
|
||||||
.createScoped(requiredScopes));
|
.createScoped(requiredScopes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a {@link GoogleCredentialsBundle} with delegated access to Google Workspace APIs for
|
||||||
|
* the application default credential user.
|
||||||
|
*
|
||||||
|
* <p>The Workspace domain must grant delegated admin access to the default service account user
|
||||||
|
* (project-id@appspot.gserviceaccount.com on AppEngine) with all scopes in {@code defaultScopes}
|
||||||
|
* and {@code delegationScopes}.
|
||||||
|
*/
|
||||||
|
@AdcDelegatedCredential
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
public static GoogleCredentialsBundle provideSelfSignedDelegatedCredential(
|
||||||
|
@Config("defaultCredentialOauthScopes") ImmutableList<String> defaultScopes,
|
||||||
|
@Config("delegatedCredentialOauthScopes") ImmutableList<String> delegationScopes,
|
||||||
|
@ApplicationDefaultCredential GoogleCredentialsBundle credentialsBundle,
|
||||||
|
@Config("gSuiteAdminAccountEmailAddress") String gSuiteAdminAccountEmailAddress,
|
||||||
|
@Config("tokenRefreshDelay") Duration tokenRefreshDelay,
|
||||||
|
Clock clock) {
|
||||||
|
GoogleCredentials signer = credentialsBundle.getGoogleCredentials();
|
||||||
|
|
||||||
|
checkArgument(
|
||||||
|
signer instanceof ServiceAccountSigner,
|
||||||
|
"Expecting a ServiceAccountSigner, found %s.",
|
||||||
|
signer.getClass().getSimpleName());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Refreshing as sanity check on the ADC.
|
||||||
|
signer.refresh();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Cannot refresh the ApplicationDefaultCredential", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
DelegatedCredentials credential =
|
||||||
|
DelegatedCredentials.createSelfSignedDelegatedCredential(
|
||||||
|
(ServiceAccountSigner) signer,
|
||||||
|
ImmutableList.<String>builder().addAll(defaultScopes).addAll(delegationScopes).build(),
|
||||||
|
gSuiteAdminAccountEmailAddress,
|
||||||
|
clock,
|
||||||
|
tokenRefreshDelay);
|
||||||
|
return GoogleCredentialsBundle.create(credential);
|
||||||
|
}
|
||||||
|
|
||||||
/** Dagger qualifier for the scope-less Application Default Credential. */
|
/** Dagger qualifier for the scope-less Application Default Credential. */
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Documented
|
@Documented
|
||||||
|
@ -195,6 +241,15 @@ public abstract class CredentialModule {
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
public @interface DelegatedCredential {}
|
public @interface DelegatedCredential {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dagger qualifier for a credential with delegated admin access for a dasher domain (for Google
|
||||||
|
* Workspace) backed by the application default credential (ADC).
|
||||||
|
*/
|
||||||
|
@Qualifier
|
||||||
|
@Documented
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface AdcDelegatedCredential {}
|
||||||
|
|
||||||
/** Dagger qualifier for the local credential used in the nomulus tool. */
|
/** Dagger qualifier for the local credential used in the nomulus tool. */
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@Documented
|
@Documented
|
||||||
|
|
|
@ -0,0 +1,268 @@
|
||||||
|
// Copyright 2022 The Nomulus Authors. All Rights Reserved.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package google.registry.config;
|
||||||
|
|
||||||
|
import static com.google.common.base.Preconditions.checkArgument;
|
||||||
|
|
||||||
|
import com.google.api.client.http.GenericUrl;
|
||||||
|
import com.google.api.client.http.HttpBackOffIOExceptionHandler;
|
||||||
|
import com.google.api.client.http.HttpBackOffUnsuccessfulResponseHandler;
|
||||||
|
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.HttpTransport;
|
||||||
|
import com.google.api.client.http.UrlEncodedContent;
|
||||||
|
import com.google.api.client.http.javanet.NetHttpTransport;
|
||||||
|
import com.google.api.client.json.JsonFactory;
|
||||||
|
import com.google.api.client.json.JsonObjectParser;
|
||||||
|
import com.google.api.client.json.gson.GsonFactory;
|
||||||
|
import com.google.api.client.json.webtoken.JsonWebSignature;
|
||||||
|
import com.google.api.client.json.webtoken.JsonWebToken;
|
||||||
|
import com.google.api.client.util.ExponentialBackOff;
|
||||||
|
import com.google.api.client.util.GenericData;
|
||||||
|
import com.google.api.client.util.StringUtils;
|
||||||
|
import com.google.auth.ServiceAccountSigner;
|
||||||
|
import com.google.auth.http.HttpTransportFactory;
|
||||||
|
import com.google.auth.oauth2.AccessToken;
|
||||||
|
import com.google.auth.oauth2.GoogleCredentials;
|
||||||
|
import com.google.common.base.Joiner;
|
||||||
|
import com.google.common.collect.ImmutableList;
|
||||||
|
import com.google.common.collect.Iterables;
|
||||||
|
import google.registry.util.Clock;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.ServiceLoader;
|
||||||
|
import org.apache.commons.codec.binary.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 credentials for accessing Google Workspace APIs with domain-wide delegation. It fetches
|
||||||
|
* access tokens using JSON Web Tokens (JWT) signed by a user-provided {@link ServiceAccountSigner}.
|
||||||
|
*
|
||||||
|
* <p>This class accepts the application-default-credential as {@code ServiceAccountSigner},
|
||||||
|
* avoiding the need for exported private keys. In this case, the default credential user itself
|
||||||
|
* (project-id@appspot.gserviceaccount.com on AppEngine) must have domain-wide delegation to the
|
||||||
|
* Workspace APIs. The default credential user also must have the Token Creator role to itself.
|
||||||
|
*
|
||||||
|
* <p>If the user provides a credential {@code S} that carries its own private key, such as {@link
|
||||||
|
* com.google.auth.oauth2.ServiceAccountCredentials}, this class can use {@code S} to impersonate
|
||||||
|
* another service account {@code D} and gain delegated access as {@code D}, as long as S has the
|
||||||
|
* Token Creator role to {@code D}. This usage is documented here for future reference.
|
||||||
|
*
|
||||||
|
* <p>As of October 2022, the functionalities described above are not implemented in the GCP Java
|
||||||
|
* Auth library, although they are available in the Python library. We have filed a <a
|
||||||
|
* href="https://github.com/googleapis/google-auth-library-java/issues/1064">feature request</a>.
|
||||||
|
* This class is a stop-gap implementation.
|
||||||
|
*
|
||||||
|
* <p>The main body of this class is adapted from {@link
|
||||||
|
* com.google.auth.oauth2.ServiceAccountCredentials} with cosmetic changes. The important changes
|
||||||
|
* include the removal of all uses of the private key and the signing of the JWT (in {@link
|
||||||
|
* #signAssertion}). We choose not to extend {@code ServiceAccountCredentials} because it would add
|
||||||
|
* dependency to the non-public details of that class.
|
||||||
|
*/
|
||||||
|
public class DelegatedCredentials extends GoogleCredentials {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 617127523756785546L;
|
||||||
|
|
||||||
|
private static final String DEFAULT_TOKEN_URI = "https://accounts.google.com/o/oauth2/token";
|
||||||
|
private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
||||||
|
|
||||||
|
private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
|
||||||
|
private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
|
||||||
|
|
||||||
|
private static final String VALUE_NOT_FOUND_MESSAGE = "%sExpected value %s not found.";
|
||||||
|
private static final String VALUE_WRONG_TYPE_MESSAGE = "%sExpected %s value %s of wrong type.";
|
||||||
|
private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. ";
|
||||||
|
|
||||||
|
private static final Duration MAX_TOKEN_REFRESH_DELAY = Duration.ofHours(1);
|
||||||
|
|
||||||
|
private final ServiceAccountSigner signer;
|
||||||
|
private final String delegatedServiceAccountUser;
|
||||||
|
private final ImmutableList<String> scopes;
|
||||||
|
private final String delegatingUserEmail;
|
||||||
|
|
||||||
|
private final Clock clock;
|
||||||
|
private final Duration tokenRefreshDelay;
|
||||||
|
|
||||||
|
private final HttpTransportFactory transportFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a {@link DelegatedCredentials} instance that is self-signed by the signer, which must
|
||||||
|
* have delegated access to the Workspace APIs.
|
||||||
|
*
|
||||||
|
* @param signer Signs for the generated JWT tokens. This may be the application default
|
||||||
|
* credential
|
||||||
|
* @param scopes The scopes to use when generating JWT tokens
|
||||||
|
* @param delegatingUserEmail The Workspace user whose permissions are delegated to the signer
|
||||||
|
* @param clock Used for setting token expiration times.
|
||||||
|
* @param tokenRefreshDelay The lifetime of each token. Should not exceed one hour according to
|
||||||
|
* GCP recommendations.
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
static DelegatedCredentials createSelfSignedDelegatedCredential(
|
||||||
|
ServiceAccountSigner signer,
|
||||||
|
Collection<String> scopes,
|
||||||
|
String delegatingUserEmail,
|
||||||
|
Clock clock,
|
||||||
|
Duration tokenRefreshDelay) {
|
||||||
|
return new DelegatedCredentials(
|
||||||
|
signer, signer.getAccount(), scopes, delegatingUserEmail, clock, tokenRefreshDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DelegatedCredentials(
|
||||||
|
ServiceAccountSigner signer,
|
||||||
|
String delegatedServiceAccountUser,
|
||||||
|
Collection<String> scopes,
|
||||||
|
String delegatingUserEmail,
|
||||||
|
Clock clock,
|
||||||
|
Duration tokenRefreshDelay) {
|
||||||
|
checkArgument(
|
||||||
|
tokenRefreshDelay.getSeconds() <= MAX_TOKEN_REFRESH_DELAY.getSeconds(),
|
||||||
|
"Max refresh delay must not exceed %s.",
|
||||||
|
MAX_TOKEN_REFRESH_DELAY);
|
||||||
|
|
||||||
|
this.signer = signer;
|
||||||
|
this.delegatedServiceAccountUser = delegatedServiceAccountUser;
|
||||||
|
this.scopes = ImmutableList.copyOf(scopes);
|
||||||
|
this.delegatingUserEmail = delegatingUserEmail;
|
||||||
|
|
||||||
|
this.clock = clock;
|
||||||
|
this.tokenRefreshDelay = tokenRefreshDelay;
|
||||||
|
|
||||||
|
this.transportFactory =
|
||||||
|
getFromServiceLoader(
|
||||||
|
HttpTransportFactory.class, DelegatedCredentials::provideHttpTransport);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the OAuth2 access token by getting a new access token using a JSON Web Token (JWT).
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public AccessToken refreshAccessToken() throws IOException {
|
||||||
|
JsonFactory jsonFactory = JSON_FACTORY;
|
||||||
|
long currentTime = clock.nowUtc().getMillis();
|
||||||
|
String assertion = createAssertion(jsonFactory, currentTime);
|
||||||
|
|
||||||
|
GenericData tokenRequest = new GenericData();
|
||||||
|
tokenRequest.set("grant_type", GRANT_TYPE);
|
||||||
|
tokenRequest.set("assertion", assertion);
|
||||||
|
UrlEncodedContent content = new UrlEncodedContent(tokenRequest);
|
||||||
|
|
||||||
|
HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory();
|
||||||
|
HttpRequest request =
|
||||||
|
requestFactory.buildPostRequest(new GenericUrl(DEFAULT_TOKEN_URI), content);
|
||||||
|
request.setParser(new JsonObjectParser(jsonFactory));
|
||||||
|
|
||||||
|
request.setIOExceptionHandler(new HttpBackOffIOExceptionHandler(new ExponentialBackOff()));
|
||||||
|
request.setUnsuccessfulResponseHandler(
|
||||||
|
new HttpBackOffUnsuccessfulResponseHandler(new ExponentialBackOff())
|
||||||
|
.setBackOffRequired(
|
||||||
|
response -> {
|
||||||
|
int code = response.getStatusCode();
|
||||||
|
return (
|
||||||
|
// Server error --- includes timeout errors, which use 500 instead of 408
|
||||||
|
code / 100 == 5
|
||||||
|
// Forbidden error --- for historical reasons, used for rate_limit_exceeded
|
||||||
|
// errors instead of 429, but there currently seems no robust automatic way
|
||||||
|
// to
|
||||||
|
// distinguish these cases: see
|
||||||
|
// https://github.com/google/google-api-java-client/issues/662
|
||||||
|
|| code == 403);
|
||||||
|
}));
|
||||||
|
|
||||||
|
HttpResponse response;
|
||||||
|
try {
|
||||||
|
response = request.execute();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IOException(
|
||||||
|
String.format("Error getting access token for service account: %s", e.getMessage()), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
GenericData responseData = response.parseAs(GenericData.class);
|
||||||
|
String accessToken = validateString(responseData, "access_token", PARSE_ERROR_PREFIX);
|
||||||
|
int expiresInSeconds = validateInt32(responseData, "expires_in", PARSE_ERROR_PREFIX);
|
||||||
|
long expiresAtMilliseconds = clock.nowUtc().getMillis() + expiresInSeconds * 1000L;
|
||||||
|
return new AccessToken(accessToken, new Date(expiresAtMilliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
String createAssertion(JsonFactory jsonFactory, long currentTime) throws IOException {
|
||||||
|
JsonWebSignature.Header header = new JsonWebSignature.Header();
|
||||||
|
header.setAlgorithm("RS256");
|
||||||
|
header.setType("JWT");
|
||||||
|
|
||||||
|
JsonWebToken.Payload payload = new JsonWebToken.Payload();
|
||||||
|
payload.setIssuer(this.delegatedServiceAccountUser);
|
||||||
|
payload.setIssuedAtTimeSeconds(currentTime / 1000);
|
||||||
|
payload.setExpirationTimeSeconds(currentTime / 1000 + tokenRefreshDelay.getSeconds());
|
||||||
|
payload.setSubject(delegatingUserEmail);
|
||||||
|
payload.put("scope", Joiner.on(' ').join(scopes));
|
||||||
|
payload.setAudience(DEFAULT_TOKEN_URI);
|
||||||
|
|
||||||
|
return signAssertion(jsonFactory, header, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
String signAssertion(
|
||||||
|
JsonFactory jsonFactory, JsonWebSignature.Header header, JsonWebToken.Payload payload)
|
||||||
|
throws IOException {
|
||||||
|
String content =
|
||||||
|
Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(header))
|
||||||
|
+ "."
|
||||||
|
+ Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(payload));
|
||||||
|
byte[] contentBytes = StringUtils.getBytesUtf8(content);
|
||||||
|
byte[] signature = signer.sign(contentBytes); // Changed from ServiceAccountCredentials.
|
||||||
|
return content + "." + Base64.encodeBase64URLSafeString(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
static HttpTransport provideHttpTransport() {
|
||||||
|
return HTTP_TRANSPORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static <T> T getFromServiceLoader(Class<? extends T> clazz, T defaultInstance) {
|
||||||
|
return Iterables.getFirst(ServiceLoader.load(clazz), defaultInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the specified string from JSON or throw a helpful error message. */
|
||||||
|
static String validateString(Map<String, Object> map, String key, String errorPrefix)
|
||||||
|
throws IOException {
|
||||||
|
Object value = map.get(key);
|
||||||
|
if (value == null) {
|
||||||
|
throw new IOException(String.format(VALUE_NOT_FOUND_MESSAGE, errorPrefix, key));
|
||||||
|
}
|
||||||
|
if (!(value instanceof String)) {
|
||||||
|
throw new IOException(String.format(VALUE_WRONG_TYPE_MESSAGE, errorPrefix, "string", key));
|
||||||
|
}
|
||||||
|
return (String) value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the specified integer from JSON or throw a helpful error message. */
|
||||||
|
static int validateInt32(Map<String, Object> map, String key, String errorPrefix)
|
||||||
|
throws IOException {
|
||||||
|
Object value = map.get(key);
|
||||||
|
if (value == null) {
|
||||||
|
throw new IOException(String.format(VALUE_NOT_FOUND_MESSAGE, errorPrefix, key));
|
||||||
|
}
|
||||||
|
if (value instanceof BigDecimal) {
|
||||||
|
BigDecimal bigDecimalValue = (BigDecimal) value;
|
||||||
|
return bigDecimalValue.intValueExact();
|
||||||
|
}
|
||||||
|
if (!(value instanceof Integer)) {
|
||||||
|
throw new IOException(String.format(VALUE_WRONG_TYPE_MESSAGE, errorPrefix, "integer", key));
|
||||||
|
}
|
||||||
|
return (Integer) value;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1223,6 +1223,12 @@ public final class RegistryConfig {
|
||||||
return ImmutableList.copyOf(config.credentialOAuth.localCredentialOauthScopes);
|
return ImmutableList.copyOf(config.credentialOAuth.localCredentialOauthScopes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Config("tokenRefreshDelay")
|
||||||
|
public static java.time.Duration provideTokenRefreshDelay(RegistryConfigSettings config) {
|
||||||
|
return java.time.Duration.ofSeconds(config.credentialOAuth.tokenRefreshDelaySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
/** OAuth client ID used by the nomulus tool. */
|
/** OAuth client ID used by the nomulus tool. */
|
||||||
@Provides
|
@Provides
|
||||||
@Config("toolsClientId")
|
@Config("toolsClientId")
|
||||||
|
|
|
@ -67,6 +67,7 @@ public class RegistryConfigSettings {
|
||||||
public List<String> defaultCredentialOauthScopes;
|
public List<String> defaultCredentialOauthScopes;
|
||||||
public List<String> delegatedCredentialOauthScopes;
|
public List<String> delegatedCredentialOauthScopes;
|
||||||
public List<String> localCredentialOauthScopes;
|
public List<String> localCredentialOauthScopes;
|
||||||
|
public int tokenRefreshDelaySeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Configuration options for the G Suite account used by Nomulus. */
|
/** Configuration options for the G Suite account used by Nomulus. */
|
||||||
|
|
|
@ -340,6 +340,9 @@ credentialOAuth:
|
||||||
- https://www.googleapis.com/auth/userinfo.email
|
- https://www.googleapis.com/auth/userinfo.email
|
||||||
# View and manage your applications deployed on Google App Engine
|
# View and manage your applications deployed on Google App Engine
|
||||||
- https://www.googleapis.com/auth/appengine.admin
|
- https://www.googleapis.com/auth/appengine.admin
|
||||||
|
# The lifetime of an access token generated by our custom credentials classes
|
||||||
|
# Must be shorter than one hour.
|
||||||
|
tokenRefreshDelaySeconds: 1800
|
||||||
|
|
||||||
icannReporting:
|
icannReporting:
|
||||||
# URL we PUT monthly ICANN transactions reports to.
|
# URL we PUT monthly ICANN transactions reports to.
|
||||||
|
|
|
@ -293,6 +293,12 @@ have been in the database for a certain period of time. -->
|
||||||
<url-pattern>/_dr/task/wipeOutCloudSql</url-pattern>
|
<url-pattern>/_dr/task/wipeOutCloudSql</url-pattern>
|
||||||
</servlet-mapping>
|
</servlet-mapping>
|
||||||
|
|
||||||
|
<!-- Action to execute canned scripts -->
|
||||||
|
<servlet-mapping>
|
||||||
|
<servlet-name>backend-servlet</servlet-name>
|
||||||
|
<url-pattern>/_dr/task/executeCannedScript</url-pattern>
|
||||||
|
</servlet-mapping>
|
||||||
|
|
||||||
<!-- Security config -->
|
<!-- Security config -->
|
||||||
<security-constraint>
|
<security-constraint>
|
||||||
<web-resource-collection>
|
<web-resource-collection>
|
||||||
|
|
|
@ -17,6 +17,7 @@ package google.registry.module.backend;
|
||||||
import dagger.Module;
|
import dagger.Module;
|
||||||
import dagger.Subcomponent;
|
import dagger.Subcomponent;
|
||||||
import google.registry.batch.BatchModule;
|
import google.registry.batch.BatchModule;
|
||||||
|
import google.registry.batch.CannedScriptExecutionAction;
|
||||||
import google.registry.batch.DeleteExpiredDomainsAction;
|
import google.registry.batch.DeleteExpiredDomainsAction;
|
||||||
import google.registry.batch.DeleteLoadTestDataAction;
|
import google.registry.batch.DeleteLoadTestDataAction;
|
||||||
import google.registry.batch.DeleteProberDataAction;
|
import google.registry.batch.DeleteProberDataAction;
|
||||||
|
@ -102,6 +103,8 @@ interface BackendRequestComponent {
|
||||||
|
|
||||||
BrdaCopyAction brdaCopyAction();
|
BrdaCopyAction brdaCopyAction();
|
||||||
|
|
||||||
|
CannedScriptExecutionAction cannedScriptExecutionAction();
|
||||||
|
|
||||||
CopyDetailReportsAction copyDetailReportAction();
|
CopyDetailReportsAction copyDetailReportAction();
|
||||||
|
|
||||||
DeleteExpiredDomainsAction deleteExpiredDomainsAction();
|
DeleteExpiredDomainsAction deleteExpiredDomainsAction();
|
||||||
|
|
|
@ -7,6 +7,7 @@ PATH CLASS
|
||||||
/_dr/task/deleteExpiredDomains DeleteExpiredDomainsAction GET n INTERNAL,API APP ADMIN
|
/_dr/task/deleteExpiredDomains DeleteExpiredDomainsAction GET n INTERNAL,API APP ADMIN
|
||||||
/_dr/task/deleteLoadTestData DeleteLoadTestDataAction POST n INTERNAL,API APP ADMIN
|
/_dr/task/deleteLoadTestData DeleteLoadTestDataAction POST n INTERNAL,API APP ADMIN
|
||||||
/_dr/task/deleteProberData DeleteProberDataAction POST n INTERNAL,API APP ADMIN
|
/_dr/task/deleteProberData DeleteProberDataAction POST n INTERNAL,API APP ADMIN
|
||||||
|
/_dr/task/executeCannedScript CannedScriptExecutionAction POST y INTERNAL,API APP ADMIN
|
||||||
/_dr/task/expandRecurringBillingEvents ExpandRecurringBillingEventsAction GET n INTERNAL,API APP ADMIN
|
/_dr/task/expandRecurringBillingEvents ExpandRecurringBillingEventsAction GET n INTERNAL,API APP ADMIN
|
||||||
/_dr/task/exportDomainLists ExportDomainListsAction POST n INTERNAL,API APP ADMIN
|
/_dr/task/exportDomainLists ExportDomainListsAction POST n INTERNAL,API APP ADMIN
|
||||||
/_dr/task/exportPremiumTerms ExportPremiumTermsAction POST n INTERNAL,API APP ADMIN
|
/_dr/task/exportPremiumTerms ExportPremiumTermsAction POST n INTERNAL,API APP ADMIN
|
||||||
|
|
Loading…
Add table
Reference in a new issue