Introduce LFS operation authorization token for FS backend

Issue:
Literally anyone can download/upload LFS objects if object SHA-256 is
known (one can see it in LFS debug mode).

Solution:
Introduce request signing that is similar to what LFS for S3 backend
does: operation specific token is generated that in addition to SHA-256
includes timeout beyond which operation is no longer possible anyway.

Design:
128 bit AES was selected as encryption algorithm. In fact the following
transformation is used: AES/CBC/PKCS5PADDING where
CBC - CipherBlockChining (see details in [1])
PKCS5PADDING - is for padding for input text to be multiplication of key
size (16 bytes)

AES symmetric-key is generated each time when LfsFsRequestAuthorizer is
instantiated (which happens only when LFS plugin gets loaded). In
addition, for each operation, random initialization vector (called IV in
[1]) parameter is generated so that even the same set of input
parameters (unlikely to happen) will not generate the same token. It
gets added at the beginning of security token (before Base64 encoding)
and used when token is verified. Token contains 3 values:
- operation (download, upload)
- SHA-256
- date and time until token is valid (default timeout is 10s)
Upon successful token decrypt, in LfsFsContentServlet, all three values
are examined and in case they are not valid SC_UNAUTHORIZED (401) error
is sent back to client.

[1] https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation
Change-Id: I6ec304cd4a8d694aa45fdd71767f4da75791a1e9
Signed-off-by: Jacek Centkowski <geminica.programs@gmail.com>
diff --git a/BUCK b/BUCK
index 07fc19f..db9e942 100644
--- a/BUCK
+++ b/BUCK
@@ -13,6 +13,9 @@
     ':jgit-lfs',
     ':jgit-lfs-server',
   ],
+  provided_deps = [
+    '//lib/httpcomponents:httpcore',
+  ],
   manifest_entries = [
     'Gerrit-PluginName: lfs',
     'Gerrit-Module: com.googlesource.gerrit.plugins.lfs.Module',
@@ -60,3 +63,14 @@
     'plugin.properties',
   ],
 )
+
+java_test(
+  name = 'lfs_tests',
+  srcs = glob(['src/test/java/**/*.java']),
+  labels = ['lfs'],
+  source_under_test = [':lfs__plugin'],
+  deps = GERRIT_PLUGIN_API + GERRIT_TESTS + [
+    ':lfs__plugin',
+    '//plugins/lfs:jgit-lfs',
+  ],
+)
\ No newline at end of file
diff --git a/lib/httpcomponents/BUCK b/lib/httpcomponents/BUCK
new file mode 100644
index 0000000..60f1699
--- /dev/null
+++ b/lib/httpcomponents/BUCK
@@ -0,0 +1,10 @@
+include_defs('//bucklets/maven_jar.bucklet')
+
+# httpcore version should match version used in Gerrit
+maven_jar(
+  name = 'httpcore',
+  id = 'org.apache.httpcomponents:httpcore:4.4.1',
+  bin_sha1 = 'f5aa318bda4c6c8d688c9d00b90681dcd82ce636',
+  src_sha1 = '9700be0d0a331691654a8e901943c9a74e33c5fc',
+  license = 'Apache2.0',
+)
\ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/HttpModule.java
index 7997466..c107418 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/HttpModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/HttpModule.java
@@ -22,16 +22,18 @@
 import com.google.gerrit.httpd.plugins.HttpPluginModule;
 import com.google.inject.Inject;
 
-import com.googlesource.gerrit.plugins.lfs.fs.LfsFsContentServlet;
 import com.googlesource.gerrit.plugins.lfs.fs.LocalLargeFileRepository;
 import com.googlesource.gerrit.plugins.lfs.s3.S3LargeFileRepository;
 
+import org.eclipse.jgit.lfs.server.fs.LfsFsContentServlet;
+
 import java.util.Map;
 
 public class HttpModule extends HttpPluginModule {
   private final LocalLargeFileRepository.Factory fsRepoFactory;
   private final S3LargeFileRepository.Factory s3RepoFactory;
   private final LfsRepositoriesCache cache;
+  private final LfsFsContentServlet.Factory fsServletFactory;
   private final LfsBackend defaultBackend;
   private final Map<String, LfsBackend> backends;
 
@@ -39,10 +41,12 @@
   HttpModule(LocalLargeFileRepository.Factory fsRepoFactory,
       S3LargeFileRepository.Factory s3RepoFactory,
       LfsRepositoriesCache cache,
+      LfsFsContentServlet.Factory fsServletFactory,
       LfsConfigurationFactory configFactory) {
     this.fsRepoFactory = fsRepoFactory;
     this.s3RepoFactory = s3RepoFactory;
     this.cache = cache;
+    this.fsServletFactory = fsServletFactory;
 
     LfsGlobalConfig config = configFactory.getGlobalConfig();
     this.defaultBackend = config.getDefaultBackend();
@@ -88,6 +92,6 @@
         fsRepoFactory.create(backend);
     cache.put(backend, repository);
     serve(repository.getServletUrlPattern())
-        .with(new LfsFsContentServlet(repository));
+        .with(fsServletFactory.create(repository));
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/Module.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/Module.java
index 3b064df..bd7519a 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/Module.java
@@ -22,6 +22,8 @@
 import com.googlesource.gerrit.plugins.lfs.fs.LocalLargeFileRepository;
 import com.googlesource.gerrit.plugins.lfs.s3.S3LargeFileRepository;
 
+import org.eclipse.jgit.lfs.server.fs.LfsFsContentServlet;
+
 public class Module extends FactoryModule {
 
   @Override
@@ -37,5 +39,6 @@
 
     factory(S3LargeFileRepository.Factory.class);
     factory(LocalLargeFileRepository.Factory.class);
+    factory(LfsFsContentServlet.Factory.class);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsContentServlet.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsContentServlet.java
deleted file mode 100644
index bc9fc05..0000000
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsContentServlet.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// 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 com.googlesource.gerrit.plugins.lfs.fs;
-
-import org.eclipse.jgit.lfs.server.fs.FileLfsServlet;
-
-public class LfsFsContentServlet extends FileLfsServlet {
-  private static final long serialVersionUID = 1L;
-
-  public LfsFsContentServlet(LocalLargeFileRepository largeFileRepository) {
-    super(largeFileRepository, 0);
-  }
-}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java
new file mode 100644
index 0000000..41bb374
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java
@@ -0,0 +1,144 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.lfs.fs;
+
+import com.google.common.base.Strings;
+import com.google.common.primitives.Bytes;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.lfs.lib.AnyLongObjectId;
+import org.eclipse.jgit.util.Base64;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.joda.time.format.DateTimeFormat;
+import org.joda.time.format.DateTimeFormatter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.security.AlgorithmParameters;
+import java.security.GeneralSecurityException;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.spec.InvalidParameterSpecException;
+import java.util.Arrays;
+
+import javax.crypto.Cipher;
+import javax.crypto.KeyGenerator;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.IvParameterSpec;
+
+@Singleton
+public class LfsFsRequestAuthorizer {
+  private static final Logger log = LoggerFactory.getLogger(LfsFsRequestAuthorizer.class);
+  private static final int IV_LENGTH = 16;
+  private static final String ALGORITHM = "AES";
+  static final DateTimeFormatter DATE_TIME =
+      DateTimeFormat.forPattern("YYYYMMDDHHmmss");
+  static final int TOKEN_TIMEOUT = 10;
+
+  private final SecureRandom rndm;
+  private final SecretKey key;
+  private final int timeout;
+
+  @Inject
+  LfsFsRequestAuthorizer() {
+    this.rndm = new SecureRandom();
+    this.key = generateKey();
+    this.timeout = TOKEN_TIMEOUT;
+  }
+
+  public String generateToken(String operation, AnyLongObjectId id) {
+    try {
+      byte [] initVector = new byte[IV_LENGTH];
+      rndm.nextBytes(initVector);
+      Cipher cipher = cipher(initVector, Cipher.ENCRYPT_MODE);
+      return Base64.encodeBytes(Bytes.concat(initVector,
+          cipher.doFinal(String.format("%s-%s-%s", operation,
+              id.name(), timeout()).getBytes(StandardCharsets.UTF_8))));
+    } catch (GeneralSecurityException e) {
+      log.error("Token generation failed with error", e);
+      throw new RuntimeException(e);
+    }
+  }
+
+  public boolean verifyAgainstToken(String token, String operation,
+      AnyLongObjectId id) {
+    if (Strings.isNullOrEmpty(token)) {
+      return false;
+    }
+
+    byte[] bytes = Base64.decode(token);
+    byte[] initVector = Arrays.copyOf(bytes, IV_LENGTH);
+    try {
+      Cipher cipher = cipher(initVector, Cipher.DECRYPT_MODE);
+      String data = new String(
+          cipher.doFinal(Arrays.copyOfRange(bytes, IV_LENGTH, bytes.length)),
+          StandardCharsets.UTF_8);
+      String oid = id.name();
+      String prefix = String.format("%s-%s-", operation, oid);
+      return data.startsWith(prefix)
+          && onTime(data.substring(prefix.length()), operation, oid);
+    } catch (GeneralSecurityException e) {
+      log.error("Exception was thrown during token verification", e);
+    }
+
+    return false;
+  }
+
+  boolean onTime(String dateTime, String operation, String id) {
+    String now = DATE_TIME.print(now());
+    if (now.compareTo(dateTime) > 0) {
+      log.info("Operation {} on id {} timed out", operation, id);
+      return false;
+    }
+
+    return true;
+  }
+
+  private String timeout() {
+    return DATE_TIME.print(now().plusSeconds(timeout));
+  }
+
+  private DateTime now() {
+    return DateTime.now().toDateTime(DateTimeZone.UTC);
+  }
+
+  private Cipher cipher(byte[] initVector, int mode) throws NoSuchAlgorithmException,
+      NoSuchPaddingException, InvalidParameterSpecException,
+      InvalidKeyException, InvalidAlgorithmParameterException {
+    IvParameterSpec spec = new IvParameterSpec(initVector);
+    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING");
+    AlgorithmParameters params = AlgorithmParameters.getInstance(ALGORITHM);
+    params.init(spec);
+    cipher.init(mode, key, params);
+    return cipher;
+  }
+
+  private SecretKey generateKey() {
+    try {
+      KeyGenerator generator = KeyGenerator.getInstance(ALGORITHM);
+      generator.init(128, rndm);
+      return generator.generateKey();
+    } catch (NoSuchAlgorithmException e) {
+      log.error("Generating key failed with error", e);
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LocalLargeFileRepository.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LocalLargeFileRepository.java
index 83251a4..2e9cd00 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LocalLargeFileRepository.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LocalLargeFileRepository.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.lfs.fs;
 
 import static com.googlesource.gerrit.plugins.lfs.LfsBackend.DEFAULT;
+import static org.eclipse.jgit.util.HttpSupport.HDR_AUTHORIZATION;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.annotations.PluginCanonicalWebUrl;
@@ -26,12 +27,15 @@
 import com.googlesource.gerrit.plugins.lfs.LfsConfigurationFactory;
 import com.googlesource.gerrit.plugins.lfs.LfsGlobalConfig;
 
+import org.eclipse.jgit.lfs.lib.AnyLongObjectId;
+import org.eclipse.jgit.lfs.server.Response;
 import org.eclipse.jgit.lfs.server.fs.FileLfsRepository;
 
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.Collections;
 
 public class LocalLargeFileRepository extends FileLfsRepository {
   public interface Factory {
@@ -39,17 +43,22 @@
   }
 
   public static final String CONTENT_PATH = "content";
+  public static final String UPLOAD = "upload";
+  public static final String DOWNLOAD = "download";
 
   private final String servletUrlPattern;
+  private final LfsFsRequestAuthorizer authorizer;
 
   @Inject
   LocalLargeFileRepository(LfsConfigurationFactory configFactory,
+      LfsFsRequestAuthorizer authorizer,
       @PluginCanonicalWebUrl String url,
       @PluginData Path defaultDataDir,
       @Assisted LfsBackend backend) throws IOException {
     super(getContentUrl(url, backend),
         getOrCreateDataDir(configFactory.getGlobalConfig(),
             backend, defaultDataDir));
+    this.authorizer = authorizer;
     this.servletUrlPattern = "/" + getContentPath(backend) + "*";
   }
 
@@ -57,6 +66,22 @@
     return servletUrlPattern;
   }
 
+  @Override
+  public Response.Action getDownloadAction(AnyLongObjectId id) {
+    Response.Action action = super.getDownloadAction(id);
+    action.header = Collections.singletonMap(HDR_AUTHORIZATION,
+        authorizer.generateToken(DOWNLOAD, id));
+    return action;
+  }
+
+  @Override
+  public Response.Action getUploadAction(AnyLongObjectId id, long size) {
+    Response.Action action = super.getUploadAction(id, size);
+    action.header = Collections.singletonMap(HDR_AUTHORIZATION,
+        authorizer.generateToken(UPLOAD, id));
+    return action;
+  }
+
   private static String getContentUrl(String url, LfsBackend backend) {
     // for default FS we still need to define namespace as otherwise it would
     // interfere with rest of FS backends
diff --git a/src/main/java/org/eclipse/jgit/lfs/server/fs/LfsFsContentServlet.java b/src/main/java/org/eclipse/jgit/lfs/server/fs/LfsFsContentServlet.java
new file mode 100644
index 0000000..b523795
--- /dev/null
+++ b/src/main/java/org/eclipse/jgit/lfs/server/fs/LfsFsContentServlet.java
@@ -0,0 +1,128 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// 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 org.eclipse.jgit.lfs.server.fs;
+// TODO move file back to com.googlesource.gerrit.plugin.lfs.fs package when
+// https://git.eclipse.org/r/#/c/84933/ is picked up by gerrit
+
+import static com.googlesource.gerrit.plugins.lfs.fs.LocalLargeFileRepository.DOWNLOAD;
+import static com.googlesource.gerrit.plugins.lfs.fs.LocalLargeFileRepository.UPLOAD;
+import static org.eclipse.jgit.util.HttpSupport.HDR_AUTHORIZATION;
+
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import com.googlesource.gerrit.plugins.lfs.fs.LfsFsRequestAuthorizer;
+import com.googlesource.gerrit.plugins.lfs.fs.LocalLargeFileRepository;
+
+import org.apache.http.HttpStatus;
+import org.eclipse.jgit.lfs.errors.InvalidLongObjectIdException;
+import org.eclipse.jgit.lfs.lib.AnyLongObjectId;
+import org.eclipse.jgit.lfs.lib.Constants;
+import org.eclipse.jgit.lfs.lib.LongObjectId;
+import org.eclipse.jgit.lfs.server.internal.LfsServerText;
+
+import java.io.IOException;
+import java.text.MessageFormat;
+
+import javax.servlet.AsyncContext;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public class LfsFsContentServlet extends FileLfsServlet {
+  public interface Factory {
+    LfsFsContentServlet create(LocalLargeFileRepository largeFileRepository);
+  }
+
+  private static final long serialVersionUID = 1L;
+
+  private final LfsFsRequestAuthorizer authorizer;
+  private final LocalLargeFileRepository repository;
+  private final long timeout;
+
+  @Inject
+  public LfsFsContentServlet(LfsFsRequestAuthorizer authorizer,
+      @Assisted LocalLargeFileRepository repository) {
+    super(repository, 0);
+    this.authorizer = authorizer;
+    this.repository = repository;
+    this.timeout = 0;
+  }
+
+  @Override
+  protected void doGet(HttpServletRequest req, HttpServletResponse rsp)
+      throws ServletException, IOException {
+    AnyLongObjectId obj = getObjectToTransfer(req, rsp);
+    if (obj == null) {
+      return;
+    }
+
+    if (repository.getSize(obj) == -1) {
+      sendError(rsp, HttpStatus.SC_NOT_FOUND, MessageFormat
+          .format(LfsServerText.get().objectNotFound, obj.getName()));
+      return;
+    }
+
+    if (!authorizer.verifyAgainstToken(req.getHeader(HDR_AUTHORIZATION),
+        DOWNLOAD, obj)) {
+      sendError(rsp, HttpStatus.SC_UNAUTHORIZED, MessageFormat.format(
+          LfsServerText.get().failedToCalcSignature, "Invalid authorization token"));
+      return;
+    }
+
+    AsyncContext context = req.startAsync();
+    context.setTimeout(timeout);
+    rsp.getOutputStream().setWriteListener(
+        new ObjectDownloadListener(repository, context, rsp, obj));
+  }
+
+  @Override
+  protected void doPut(HttpServletRequest req, HttpServletResponse rsp)
+      throws ServletException, IOException {
+    AnyLongObjectId id = getObjectToTransfer(req, rsp);
+    if (id == null) {
+      return;
+    }
+
+    if (!authorizer.verifyAgainstToken(
+        req.getHeader(HDR_AUTHORIZATION), UPLOAD, id)) {
+      sendError(rsp, HttpStatus.SC_UNAUTHORIZED,
+          MessageFormat.format(LfsServerText.get().failedToCalcSignature,
+              "Invalid authorization token"));
+      return;
+    }
+
+    AsyncContext context = req.startAsync();
+    context.setTimeout(timeout);
+    req.getInputStream().setReadListener(
+        new ObjectUploadListener(repository, context, req, rsp, id));
+  }
+
+  private AnyLongObjectId getObjectToTransfer(HttpServletRequest req,
+      HttpServletResponse rsp) throws IOException {
+    String info = req.getPathInfo();
+    if (info.length() != 1 + Constants.LONG_OBJECT_ID_STRING_LENGTH) {
+      sendError(rsp, HttpStatus.SC_UNPROCESSABLE_ENTITY,
+          MessageFormat.format(LfsServerText.get().invalidPathInfo, info));
+      return null;
+    }
+    try {
+      return LongObjectId.fromString(info.substring(1, 65));
+    } catch (InvalidLongObjectIdException e) {
+      sendError(rsp, HttpStatus.SC_UNPROCESSABLE_ENTITY, e.getMessage());
+      return null;
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizerTest.java b/src/test/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizerTest.java
new file mode 100644
index 0000000..75e9a56
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizerTest.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// 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 com.googlesource.gerrit.plugins.lfs.fs;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.lfs.fs.LfsFsRequestAuthorizer.DATE_TIME;
+import static org.eclipse.jgit.lfs.lib.LongObjectId.zeroId;
+
+import org.eclipse.jgit.lfs.lib.LongObjectId;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.Test;
+
+public class LfsFsRequestAuthorizerTest {
+  private final LfsFsRequestAuthorizer auth = new LfsFsRequestAuthorizer();
+
+  @Test
+  public void testExpiredTime() throws Exception {
+    DateTime now = DateTime.now().toDateTime(DateTimeZone.UTC);
+    // test that even 1s expiration is enough
+    assertThat(auth.onTime(DATE_TIME.print(now.minusSeconds(1)), "o", "id"))
+            .isFalse();
+  }
+
+  @Test
+  public void testOnTime() throws Exception {
+    DateTime now = DateTime.now().toDateTime(DateTimeZone.UTC);
+    // if there is at least 1s before there is no timeout
+    assertThat(auth.onTime(DATE_TIME.print(now.plusSeconds(1)), "o", "id"))
+            .isTrue();
+  }
+
+  @Test
+  public void testVerifyAgainstMissingToken() throws Exception {
+    assertThat(auth.verifyAgainstToken("", "o", zeroId())).isFalse();
+    assertThat(auth.verifyAgainstToken(null, "o", zeroId())).isFalse();
+  }
+
+  @Test
+  public void testVerifyAgainstToken() throws Exception {
+    String token = auth.generateToken("o", zeroId());
+    assertThat(auth.verifyAgainstToken(token, "o", zeroId())).isTrue();
+  }
+
+  @Test
+  public void testVerifyAgainstInvalidToken() throws Exception {
+    String token = auth.generateToken("o", zeroId());
+    // replace 1st and 2nd token letters with each other
+    assertThat(auth.verifyAgainstToken(
+        token.substring(1, 2) + token.substring(0, 1) + token.substring(2), "o",
+        zeroId())).isFalse();
+  }
+
+  @Test
+  public void testVerifyAgainstDifferentOperation() throws Exception {
+    String token = auth.generateToken("o", zeroId());
+    assertThat(auth.verifyAgainstToken(token, "p", zeroId())).isFalse();
+  }
+
+  @Test
+  public void testVerifyAgainstDifferentObjectId() throws Exception {
+    String token = auth.generateToken("o", zeroId());
+    assertThat(auth.verifyAgainstToken(token, "o",
+        LongObjectId.fromString(
+            "123456789012345678901234567890"
+            + "123456789012345678901234567890"
+            + "1234"))).isFalse();
+  }
+}