Merge "Merge branch 'stable-2.13'"
diff --git a/BUCK b/BUCK
index 81ea2f6..409fd6d 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',
@@ -61,3 +64,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..8b67591
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java
@@ -0,0 +1,143 @@
+// 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");
+
+  private final SecureRandom rndm;
+  private final SecretKey key;
+
+  @Inject
+  LfsFsRequestAuthorizer() {
+    this.rndm = new SecureRandom();
+    this.key = generateKey();
+  }
+
+  public String generateToken(String operation, AnyLongObjectId id,
+      int expirationSeconds) {
+    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(expirationSeconds))
+              .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(int expirationSeconds) {
+    return DATE_TIME.print(now().plusSeconds(expirationSeconds));
+  }
+
+  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..a810943 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,24 +43,50 @@
   }
 
   public static final String CONTENT_PATH = "content";
+  public static final String UPLOAD = "upload";
+  public static final String DOWNLOAD = "download";
+  private static final int DEFAULT_TIMEOUT = 10; //in seconds
 
   private final String servletUrlPattern;
+  private final LfsFsRequestAuthorizer authorizer;
+  private final int expirationSeconds;
 
   @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) + "*";
+    this.expirationSeconds = configFactory.getGlobalConfig()
+        .getInt(backend.type.name(), backend.name, "expirationSeconds",
+            DEFAULT_TIMEOUT);
   }
 
   public String getServletUrlPattern() {
     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, expirationSeconds));
+    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, expirationSeconds));
+    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/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 602d566..7cf8603 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -107,6 +107,14 @@
 : The directory in which to store data files. If not specified, defaults to
 the plugin's data folder: `$GERRIT_SITE/data/@PLUGIN@`.
 
+fs.expirationSeconds
+: Validity, in seconds, of authentication token for signed requests.
+Gerrit's LFS protocol handler signs requests to be issued by the git-lfs
+extension. This way the git-lfs extension doesn't need any credentials to
+access objects in the FS bucket. Validity of these request signatures expires
+after this period.
+: Default is `10` seconds.
+
 ### <a id="lfs-s3-backend"></a>Section `s3` - default S3 backend
 
 The following configuration options are only used when the backend is `s3`.
@@ -124,7 +132,7 @@
 [Amazon S3 storage class] used for storing large objects.
 : Default is `REDUCED_REDUNDANCY`
 
-s3.expiration
+s3.expirationSeconds
 : Expiration in seconds of validity of signed requests. Gerrit's LFS protocol
 handler signs requests to be issued by the git-lfs extension with the configured
 `accessKey` and `secretKey`. This way the git-lfs extension doesn't need
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..8cab1a3
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizerTest.java
@@ -0,0 +1,88 @@
+// 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(), 1);
+    assertThat(auth.verifyAgainstToken(token, "o", zeroId())).isTrue();
+  }
+
+  @Test
+  public void testVerifyAgainstInvalidToken() throws Exception {
+    String token = auth.generateToken("o", zeroId(), 1);
+    // 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(), 1);
+    assertThat(auth.verifyAgainstToken(token, "p", zeroId())).isFalse();
+  }
+
+  @Test
+  public void testVerifyAgainstDifferentObjectId() throws Exception {
+    String token = auth.generateToken("o", zeroId(), 1);
+    assertThat(auth.verifyAgainstToken(token, "o",
+        LongObjectId.fromString(
+            "123456789012345678901234567890"
+            + "123456789012345678901234567890"
+            + "1234"))).isFalse();
+  }
+
+  @Test
+  public void testVerifyAgainstExpiredToken() throws Exception {
+    // generate already expired token
+    String token = auth.generateToken("o", zeroId(), -1);
+    assertThat(auth.verifyAgainstToken(token, "o", zeroId())).isFalse();
+  }
+}