Bug 957863 - Use FxA auth clock skew in hawk, jwcrypto, and sync. r=warner, r=rnewman
authorJed Parsons <jedp@mozilla.com>
Thu, 23 Jan 2014 18:04:38 -0800
changeset 166414 ebb67b1952de033e5e8e4adbb57e59ca0ed5456c
parent 166413 82e30c1856ee9bd45f7b808adc40bcbd8e2d68d6
child 166415 357071e6fd87ee4d81418f06bfd3e286724b63f7
push id39185
push userryanvm@gmail.com
push dateSat, 01 Feb 2014 02:16:26 +0000
treeherdermozilla-inbound@7e2d6d56c282 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerswarner, rnewman
bugs957863
milestone29.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 957863 - Use FxA auth clock skew in hawk, jwcrypto, and sync. r=warner, r=rnewman
services/common/Makefile.in
services/common/hawk.js
services/common/rest.js
services/common/tests/unit/test_hawk.js
services/common/tests/unit/test_restrequest.js
services/common/tests/unit/xpcshell.ini
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/FxAccountsClient.jsm
services/fxaccounts/tests/xpcshell/test_accounts.js
services/fxaccounts/tests/xpcshell/test_client.js
services/sync/modules/browserid_identity.js
services/sync/tests/unit/test_browserid_identity.js
toolkit/identity/jwcrypto.jsm
toolkit/identity/tests/unit/test_jwcrypto.js
--- a/services/common/Makefile.in
+++ b/services/common/Makefile.in
@@ -1,13 +1,14 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this file,
 # You can obtain one at http://mozilla.org/MPL/2.0/.
 
 modules := \
+  hawk.js \
   storageservice.js \
   stringbundle.js \
   tokenserverclient.js \
   utils.js \
   $(NULL)
 
 pp_modules := \
   async.js \
new file mode 100644
--- /dev/null
+++ b/services/common/hawk.js
@@ -0,0 +1,201 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/*
+ * HAWK is an HTTP authentication scheme using a message authentication code
+ * (MAC) algorithm to provide partial HTTP request cryptographic verification.
+ *
+ * For details, see: https://github.com/hueniverse/hawk
+ *
+ * With HAWK, it is essential that the clocks on clients and server not have an
+ * absolute delta of greater than one minute, as the HAWK protocol uses
+ * timestamps to reduce the possibility of replay attacks.  However, it is
+ * likely that some clients' clocks will be more than a little off, especially
+ * in mobile devices, which would break HAWK-based services (like sync and
+ * firefox accounts) for those clients.
+ *
+ * This library provides a stateful HAWK client that calculates (roughly) the
+ * clock delta on the client vs the server.  The library provides an interface
+ * for deriving HAWK credentials and making HAWK-authenticated REST requests to
+ * a single remote server.  Therefore, callers who want to interact with
+ * multiple HAWK services should instantiate one HawkClient per service.
+ */
+
+this.EXPORTED_SYMBOLS = ["HawkClient"];
+
+const {interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://services-crypto/utils.js");
+Cu.import("resource://services-common/rest.js");
+Cu.import("resource://gre/modules/Promise.jsm");
+
+/*
+ * A general purpose client for making HAWK authenticated requests to a single
+ * host.  Keeps track of the clock offset between the client and the host for
+ * computation of the timestamp in the HAWK Authorization header.
+ *
+ * Clients should create one HawkClient object per each server they wish to
+ * interact with.
+ *
+ * @param host
+ *        The url of the host
+ */
+function HawkClient(host) {
+  this.host = host;
+
+  // Clock offset in milliseconds between our client's clock and the date
+  // reported in responses from our host.
+  this._localtimeOffsetMsec = 0;
+}
+
+HawkClient.prototype = {
+
+  /*
+   * Construct an error message for a response.  Private.
+   *
+   * @param restResponse
+   *        A RESTResponse object from a RESTRequest
+   *
+   * @param errorString
+   *        A string describing the error
+   */
+  _constructError: function(restResponse, errorString) {
+    return {
+      error: errorString,
+      message: restResponse.statusText,
+      code: restResponse.status,
+      errno: restResponse.status
+    };
+  },
+
+  /*
+   *
+   * Update clock offset by determining difference from date gives in the (RFC
+   * 1123) Date header of a server response.  Because HAWK tolerates a window
+   * of one minute of clock skew (so two minutes total since the skew can be
+   * positive or negative), the simple method of calculating offset here is
+   * probably good enough.  We keep the value in milliseconds to make life
+   * easier, even though the value will not have millisecond accuracy.
+   *
+   * @param dateString
+   *        An RFC 1123 date string (e.g., "Mon, 13 Jan 2014 21:45:06 GMT")
+   *
+   * For HAWK clock skew and replay protection, see
+   * https://github.com/hueniverse/hawk#replay-protection
+   */
+  _updateClockOffset: function(dateString) {
+    try {
+      let serverDateMsec = Date.parse(dateString);
+      this._localtimeOffsetMsec = serverDateMsec - this.now();
+      log.debug("Clock offset vs " + this.host + ": " + this._localtimeOffsetMsec);
+    } catch(err) {
+      log.warn("Bad date header in server response: " + dateString);
+    }
+  },
+
+  /*
+   * Get the current clock offset in milliseconds.
+   *
+   * The offset is the number of milliseconds that must be added to the client
+   * clock to make it equal to the server clock.  For example, if the client is
+   * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
+   */
+  get localtimeOffsetMsec() {
+    return this._localtimeOffsetMsec;
+  },
+
+  /*
+   * return current time in milliseconds
+   */
+  now: function() {
+    return Date.now();
+  },
+
+  /* A general method for sending raw RESTRequest calls authorized using HAWK
+   *
+   * @param path
+   *        API endpoint path
+   * @param method
+   *        The HTTP request method
+   * @param credentials
+   *        Hawk credentials
+   * @param payloadObj
+   *        An object that can be encodable as JSON as the payload of the
+   *        request
+   * @return Promise
+   *        Returns a promise that resolves to the text response of the API call,
+   *        or is rejected with an error.  If the server response can be parsed
+   *        as JSON and contains an 'error' property, the promise will be
+   *        rejected with this JSON-parsed response.
+   */
+  request: function(path, method, credentials=null, payloadObj={}, retryOK=true) {
+    method = method.toLowerCase();
+
+    let deferred = Promise.defer();
+    let uri = this.host + path;
+    let self = this;
+
+    function onComplete(error) {
+      let restResponse = this.response;
+      let status = restResponse.status;
+
+      log.debug("(Response) code: " + status +
+                " - Status text: " + restResponse.statusText,
+                " - Response text: " + restResponse.body);
+
+      if (error) {
+        // When things really blow up, reconstruct an error object that follows
+        // the general format of the server on error responses.
+        return deferred.reject(self._constructError(restResponse, error));
+      }
+
+      self._updateClockOffset(restResponse.headers["date"]);
+
+      if (status === 401 && retryOK) {
+        // Retry once if we were rejected due to a bad timestamp.
+        // Clock offset is adjusted already in the top of this function.
+        log.debug("Received 401 for " + path + ": retrying");
+        return deferred.resolve(
+            self.request(path, method, credentials, payloadObj, false));
+      }
+
+      // If the server returned a json error message, use it in the rejection
+      // of the promise.
+      //
+      // In the case of a 401, in which we are probably being rejected for a
+      // bad timestamp, retry exactly once, during which time clock offset will
+      // be adjusted.
+
+      let jsonResponse = {};
+      try {
+        jsonResponse = JSON.parse(restResponse.body);
+      } catch(notJSON) {}
+
+      let okResponse = (200 <= status && status < 300);
+      if (!okResponse || jsonResponse.error) {
+        if (jsonResponse.error) {
+          return deferred.reject(jsonResponse);
+        }
+        return deferred.reject(self._constructError(restResponse, "Request failed"));
+      }
+      // It's up to the caller to know how to decode the response.
+      // We just return the raw text.
+      deferred.resolve(this.response.body);
+    };
+
+    let extra = {
+      now: this.now(),
+      localtimeOffsetMsec: this.localtimeOffsetMsec,
+    };
+
+    let request = new HAWKAuthenticatedRESTRequest(uri, credentials, extra);
+    request[method](payloadObj, onComplete);
+
+    return deferred.promise;
+  }
+}
--- a/services/common/rest.js
+++ b/services/common/rest.js
@@ -4,17 +4,18 @@
 
 #ifndef MERGED_COMPARTMENT
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 this.EXPORTED_SYMBOLS = [
   "RESTRequest",
   "RESTResponse",
-  "TokenAuthenticatedRESTRequest"
+  "TokenAuthenticatedRESTRequest",
+  "HAWKAuthenticatedRESTRequest",
 ];
 
 #endif
 
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Log.jsm");
@@ -142,16 +143,21 @@ RESTRequest.prototype = {
 
   NOT_SENT:    0,
   SENT:        1,
   IN_PROGRESS: 2,
   COMPLETED:   4,
   ABORTED:     8,
 
   /**
+   * HTTP status text of response
+   */
+  statusText: null,
+
+  /**
    * Request timeout (in seconds, though decimal values can be used for
    * up to millisecond granularity.)
    *
    * 0 for no timeout.
    */
   timeout: null,
 
   /**
@@ -607,35 +613,49 @@ RESTResponse.prototype = {
   request: null,
 
   /**
    * HTTP status code
    */
   get status() {
     let status;
     try {
-      let channel = this.request.channel.QueryInterface(Ci.nsIHttpChannel);
-      status = channel.responseStatus;
+      status = this.request.channel.responseStatus;
     } catch (ex) {
       this._log.debug("Caught exception fetching HTTP status code:" +
                       CommonUtils.exceptionStr(ex));
       return null;
     }
     delete this.status;
     return this.status = status;
   },
 
   /**
+   * HTTP status text
+   */
+  get statusText() {
+    let statusText;
+    try {
+      statusText = this.request.channel.responseStatusText;
+    } catch (ex) {
+      this._log.debug("Caught exception fetching HTTP status text:" +
+                      CommonUtils.exceptionStr(ex));
+      return null;
+    }
+    delete this.statusText;
+    return this.statusText = statusText;
+  },
+
+  /**
    * Boolean flag that indicates whether the HTTP status code is 2xx or not.
    */
   get success() {
     let success;
     try {
-      let channel = this.request.channel.QueryInterface(Ci.nsIHttpChannel);
-      success = channel.requestSucceeded;
+      success = this.request.channel.requestSucceeded;
     } catch (ex) {
       this._log.debug("Caught exception fetching HTTP success flag:" +
                       CommonUtils.exceptionStr(ex));
       return null;
     }
     delete this.success;
     return this.success = success;
   },
@@ -699,8 +719,65 @@ TokenAuthenticatedRESTRequest.prototype 
 
     this.setHeader("Authorization", sig.getHeader());
 
     return RESTRequest.prototype.dispatch.call(
       this, method, data, onComplete, onProgress
     );
   },
 };
+
+/**
+ * Single-use HAWK-authenticated HTTP requests to RESTish resources.
+ *
+ * @param uri
+ *        (String) URI for the RESTRequest constructor
+ *
+ * @param credentials
+ *        (Object) Optional credentials for computing HAWK authentication
+ *        header.
+ *
+ * @param payloadObj
+ *        (Object) Optional object to be converted to JSON payload
+ *
+ * @param extra
+ *        (Object) Optional extra params for HAWK header computation.
+ *        Valid properties are:
+ *
+ *          now:                 <current time in milliseconds>,
+ *          localtimeOffsetMsec: <local clock offset vs server>
+ *
+ * extra.localtimeOffsetMsec is the value in milliseconds that must be added to
+ * the local clock to make it agree with the server's clock.  For instance, if
+ * the local clock is two minutes ahead of the server, the time offset in
+ * milliseconds will be -120000.
+ */
+this.HAWKAuthenticatedRESTRequest =
+ function HawkAuthenticatedRESTRequest(uri, credentials, extra={}) {
+  RESTRequest.call(this, uri);
+
+  this.credentials = credentials;
+  this.now = extra.now || Date.now();
+  this.localtimeOffsetMsec = extra.localtimeOffsetMsec || 0;
+  this._log.trace("local time, offset: " + this.now + ", " + (this.localtimeOffsetMsec));
+};
+HAWKAuthenticatedRESTRequest.prototype = {
+  __proto__: RESTRequest.prototype,
+
+  dispatch: function dispatch(method, data, onComplete, onProgress) {
+    if (this.credentials) {
+      let options = {
+        now: this.now,
+        localtimeOffsetMsec: this.localtimeOffsetMsec,
+        credentials: this.credentials,
+        payload: data && JSON.stringify(data) || "",
+        contentType: "application/json; charset=utf-8",
+      };
+      let header = CryptoUtils.computeHAWK(this.uri, method, options);
+      this.setHeader("Authorization", header.field);
+      this._log.trace("hawk auth header: " + header.field);
+    }
+
+    return RESTRequest.prototype.dispatch.call(
+      this, method, data, onComplete, onProgress
+    );
+  }
+};
new file mode 100644
--- /dev/null
+++ b/services/common/tests/unit/test_hawk.js
@@ -0,0 +1,485 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://services-common/hawk.js");
+
+const SECOND_MS = 1000;
+const MINUTE_MS = SECOND_MS * 60;
+const HOUR_MS = MINUTE_MS * 60;
+
+const TEST_CREDS = {
+  id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+  key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+  algorithm: "sha256"
+};
+
+initTestLogging("Trace");
+
+add_task(function test_now() {
+  let client = new HawkClient("https://example.com");
+
+  do_check_true(client.now() - Date.now() < SECOND_MS);
+  run_next_test();
+});
+
+add_task(function test_updateClockOffset() {
+  let client = new HawkClient("https://example.com");
+
+  let now = new Date();
+  let serverDate = now.toUTCString();
+
+  // Client's clock is off
+  client.now = () => { return now.valueOf() + HOUR_MS; }
+
+  client._updateClockOffset(serverDate);
+
+  // Check that they're close; there will likely be a one-second rounding
+  // error, so checking strict equality will likely fail.
+  //
+  // localtimeOffsetMsec is how many milliseconds to add to the local clock so
+  // that it agrees with the server.  We are one hour ahead of the server, so
+  // our offset should be -1 hour.
+  do_check_true(Math.abs(client.localtimeOffsetMsec + HOUR_MS) <= SECOND_MS);
+
+  run_next_test();
+});
+
+add_task(function test_authenticated_get_request() {
+  let message = "{\"msg\": \"Great Success!\"}";
+  let method = "GET";
+
+  let server = httpd_setup({"/foo": (request, response) => {
+      do_check_true(request.hasHeader("Authorization"));
+
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  let client = new HawkClient(server.baseURI);
+
+  let response = yield client.request("/foo", method, TEST_CREDS);
+  let result = JSON.parse(response);
+
+  do_check_eq("Great Success!", result.msg);
+
+  yield deferredStop(server);
+});
+
+add_task(function test_authenticated_post_request() {
+  let method = "POST";
+
+  let server = httpd_setup({"/foo": (request, response) => {
+      do_check_true(request.hasHeader("Authorization"));
+
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      response.setHeader("Content-Type", "application/json");
+      response.bodyOutputStream.writeFrom(request.bodyInputStream, request.bodyInputStream.available());
+    }
+  });
+
+  let client = new HawkClient(server.baseURI);
+
+  let response = yield client.request("/foo", method, TEST_CREDS, {foo: "bar"});
+  let result = JSON.parse(response);
+
+  do_check_eq("bar", result.foo);
+
+  yield deferredStop(server);
+});
+
+add_task(function test_credentials_optional() {
+  let method = "GET";
+  let server = httpd_setup({
+    "/foo": (request, response) => {
+      do_check_false(request.hasHeader("Authorization"));
+
+      let message = JSON.stringify({msg: "you're in the friend zone"});
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      response.setHeader("Content-Type", "application/json");
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  let client = new HawkClient(server.baseURI);
+  let result = yield client.request("/foo", method); // credentials undefined
+  do_check_eq(JSON.parse(result).msg, "you're in the friend zone");
+  yield deferredStop(server);
+});
+
+add_task(function test_server_error() {
+  let message = "Ohai!";
+  let method = "GET";
+
+  let server = httpd_setup({"/foo": (request, response) => {
+      response.setStatusLine(request.httpVersion, 418, "I am a Teapot");
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  let client = new HawkClient(server.baseURI);
+
+  try {
+    yield client.request("/foo", method, TEST_CREDS);
+  } catch(err) {
+    do_check_eq(418, err.code);
+    do_check_eq("I am a Teapot", err.message);
+  }
+
+  yield deferredStop(server);
+});
+
+add_task(function test_server_error_json() {
+  let message = JSON.stringify({error: "Cannot get ye flask."});
+  let method = "GET";
+
+  let server = httpd_setup({"/foo": (request, response) => {
+      response.setStatusLine(request.httpVersion, 400, "What wouldst thou deau?");
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  let client = new HawkClient(server.baseURI);
+
+  try {
+    yield client.request("/foo", method, TEST_CREDS);
+  } catch(err) {
+    do_check_eq("Cannot get ye flask.", err.error);
+  }
+
+  yield deferredStop(server);
+});
+
+add_task(function test_offset_after_request() {
+  let message = "Ohai!";
+  let method = "GET";
+
+  let server = httpd_setup({"/foo": (request, response) => {
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  let client = new HawkClient(server.baseURI);
+  let now = Date.now();
+  client.now = () => { return now + HOUR_MS; };
+
+  do_check_eq(client.localtimeOffsetMsec, 0);
+
+  let response = yield client.request("/foo", method, TEST_CREDS);
+  // Should be about an hour off
+  do_check_true(Math.abs(client.localtimeOffsetMsec + HOUR_MS) < SECOND_MS);
+
+  yield deferredStop(server);
+});
+
+add_task(function test_offset_in_hawk_header() {
+  let message = "Ohai!";
+  let method = "GET";
+
+  let server = httpd_setup({
+    "/first": function(request, response) {
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      response.bodyOutputStream.write(message, message.length);
+    },
+
+    "/second": function(request, response) {
+      // We see a better date now in the ts component of the header
+      let delta = getTimestampDelta(request.getHeader("Authorization"));
+      let message = "Delta: " + delta;
+
+      // We're now within HAWK's one-minute window.
+      // I hope this isn't a recipe for intermittent oranges ...
+      if (delta < MINUTE_MS) {
+        response.setStatusLine(request.httpVersion, 200, "OK");
+      } else {
+        response.setStatusLine(request.httpVersion, 400, "Delta: " + delta);
+      }
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  let client = new HawkClient(server.baseURI);
+  function getOffset() {
+    return client.localtimeOffsetMsec;
+  }
+
+  client.now = () => {
+    return Date.now() + 12 * HOUR_MS;
+  };
+
+  // We begin with no offset
+  do_check_eq(client.localtimeOffsetMsec, 0);
+  yield client.request("/first", method, TEST_CREDS);
+
+  // After the first server response, our offset is updated to -12 hours.
+  // We should be safely in the window, now.
+  do_check_true(Math.abs(client.localtimeOffsetMsec + 12 * HOUR_MS) < MINUTE_MS);
+  yield client.request("/second", method, TEST_CREDS);
+
+  yield deferredStop(server);
+});
+
+add_task(function test_2xx_success() {
+  // Just to ensure that we're not biased toward 200 OK for success
+  let credentials = {
+    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+    algorithm: "sha256"
+  };
+  let method = "GET";
+
+  let server = httpd_setup({"/foo": (request, response) => {
+      response.setStatusLine(request.httpVersion, 202, "Accepted");
+    }
+  });
+
+  let client = new HawkClient(server.baseURI);
+
+  let response = yield client.request("/foo", method, credentials);
+
+  // Shouldn't be any content in a 202
+  do_check_eq(response, "");
+
+  yield deferredStop(server);
+
+});
+
+add_task(function test_retry_request_on_fail() {
+  let attempts = 0;
+  let credentials = {
+    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+    algorithm: "sha256"
+  };
+  let method = "GET";
+
+  let server = httpd_setup({
+    "/maybe": function(request, response) {
+      // This path should be hit exactly twice; once with a bad timestamp, and
+      // again when the client retries the request with a corrected timestamp.
+      attempts += 1;
+      do_check_true(attempts <= 2);
+
+      let delta = getTimestampDelta(request.getHeader("Authorization"));
+
+      // First time through, we should have a bad timestamp
+      if (attempts === 1) {
+        do_check_true(delta > MINUTE_MS);
+        let message = "never!!!";
+        response.setStatusLine(request.httpVersion, 401, "Unauthorized");
+        return response.bodyOutputStream.write(message, message.length);
+      }
+
+      // Second time through, timestamp should be corrected by client
+      do_check_true(delta < MINUTE_MS);
+      let message = "i love you!!!";
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  let client = new HawkClient(server.baseURI);
+  function getOffset() {
+    return client.localtimeOffsetMsec;
+  }
+
+  client.now = () => {
+    return Date.now() + 12 * HOUR_MS;
+  };
+
+  // We begin with no offset
+  do_check_eq(client.localtimeOffsetMsec, 0);
+
+  // Request will have bad timestamp; client will retry once
+  let response = yield client.request("/maybe", method, credentials);
+  do_check_eq(response, "i love you!!!");
+
+  yield deferredStop(server);
+});
+
+add_task(function test_multiple_401_retry_once() {
+  // Like test_retry_request_on_fail, but always return a 401
+  // and ensure that the client only retries once.
+  let attempts = 0;
+  let credentials = {
+    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+    algorithm: "sha256"
+  };
+  let method = "GET";
+
+  let server = httpd_setup({
+    "/maybe": function(request, response) {
+      // This path should be hit exactly twice; once with a bad timestamp, and
+      // again when the client retries the request with a corrected timestamp.
+      attempts += 1;
+
+      do_check_true(attempts <= 2);
+
+      let message = "never!!!";
+      response.setStatusLine(request.httpVersion, 401, "Unauthorized");
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  let client = new HawkClient(server.baseURI);
+  function getOffset() {
+    return client.localtimeOffsetMsec;
+  }
+
+  client.now = () => {
+    return Date.now() - 12 * HOUR_MS;
+  };
+
+  // We begin with no offset
+  do_check_eq(client.localtimeOffsetMsec, 0);
+
+  // Request will have bad timestamp; client will retry once
+  try {
+    yield client.request("/maybe", method, credentials);
+  } catch (err) {
+    do_check_eq(err.code, 401);
+  }
+  do_check_eq(attempts, 2);
+
+  yield deferredStop(server);
+});
+
+add_task(function test_500_no_retry() {
+  // If we get a 500 error, the client should not retry (as it would with a
+  // 401)
+  let attempts = 0;
+  let credentials = {
+    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+    algorithm: "sha256"
+  };
+  let method = "GET";
+
+  let server = httpd_setup({
+    "/no-shutup": function() {
+      attempts += 1;
+      let message = "Cannot get ye flask.";
+      response.setStatusLine(request.httpVersion, 500, "Internal server error");
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  let client = new HawkClient(server.baseURI);
+  function getOffset() {
+    return client.localtimeOffsetMsec;
+  }
+
+  // Throw off the clock so the HawkClient would want to retry the request if
+  // it could
+  client.now = () => {
+    return Date.now() - 12 * HOUR_MS;
+  };
+
+  // Request will 500; no retries
+  try {
+    yield client.request("/no-shutup", method, credentials);
+  } catch(err) {
+    do_check_eq(err.code, 500);
+  }
+  do_check_eq(attempts, 1);
+
+  yield deferredStop(server);
+
+});
+
+add_task(function test_401_then_500() {
+  // Like test_multiple_401_retry_once, but return a 500 to the
+  // second request, ensuring that the promise is properly rejected
+  // in client.request.
+  let attempts = 0;
+  let credentials = {
+    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+    algorithm: "sha256"
+  };
+  let method = "GET";
+
+  let server = httpd_setup({
+    "/maybe": function(request, response) {
+      // This path should be hit exactly twice; once with a bad timestamp, and
+      // again when the client retries the request with a corrected timestamp.
+      attempts += 1;
+      do_check_true(attempts <= 2);
+
+      let delta = getTimestampDelta(request.getHeader("Authorization"));
+
+      // First time through, we should have a bad timestamp
+      // Client will retry
+      if (attempts === 1) {
+        do_check_true(delta > MINUTE_MS);
+        let message = "never!!!";
+        response.setStatusLine(request.httpVersion, 401, "Unauthorized");
+        return response.bodyOutputStream.write(message, message.length);
+      }
+
+      // Second time through, timestamp should be corrected by client
+      // And fail on the client
+      do_check_true(delta < MINUTE_MS);
+      let message = "Cannot get ye flask.";
+      response.setStatusLine(request.httpVersion, 500, "Internal server error");
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  let client = new HawkClient(server.baseURI);
+  function getOffset() {
+    return client.localtimeOffsetMsec;
+  }
+
+  client.now = () => {
+    return Date.now() - 12 * HOUR_MS;
+  };
+
+  // We begin with no offset
+  do_check_eq(client.localtimeOffsetMsec, 0);
+
+  // Request will have bad timestamp; client will retry once
+  try {
+    yield client.request("/maybe", method, credentials);
+  } catch(err) {
+    do_check_eq(err.code, 500);
+  }
+  do_check_eq(attempts, 2);
+
+  yield deferredStop(server);
+});
+
+add_task(function throw_if_not_json_body() {
+  do_test_pending();
+  let client = new HawkClient("https://example.com");
+  try {
+    yield client.request("/bogus", "GET", {}, "I am not json");
+  } catch(err) {
+    do_check_true(!!err.message);
+    do_test_finished();
+  }
+});
+
+// End of tests.
+// Utility functions follow
+
+function getTimestampDelta(authHeader, now=Date.now()) {
+  let tsMS = new Date(
+      parseInt(/ts="(\d+)"/.exec(authHeader)[1], 10) * SECOND_MS);
+  return Math.abs(tsMS - now);
+}
+
+function deferredStop(server) {
+  let deferred = Promise.defer();
+  server.stop(deferred.resolve);
+  return deferred.promise;
+}
+
+function run_test() {
+  initTestLogging("Trace");
+  run_next_test();
+}
+
--- a/services/common/tests/unit/test_restrequest.js
+++ b/services/common/tests/unit/test_restrequest.js
@@ -826,8 +826,67 @@ add_test(function test_not_sending_cooki
   let res = new RESTRequest(server.baseURI + "/test");
   res.get(function (error) {
     do_check_null(error);
     do_check_true(this.response.success);
     do_check_eq("COOKIE!", this.response.body);
     server.stop(run_next_test);
   });
 });
+
+add_test(function test_hawk_authenticated_request() {
+  do_test_pending();
+
+  let onProgressCalled = false;
+  let postData = {your: "data"};
+
+  // An arbitrary date - Feb 2, 1971.  It ends in a bunch of zeroes to make our
+  // computation with the hawk timestamp easier, since hawk throws away the
+  // millisecond values.
+  let then = 34329600000;
+
+  let clockSkew = 120000;
+  let timeOffset = -1 * clockSkew;
+  let localTime = then + clockSkew;
+
+  let credentials = {
+    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+    algorithm: "sha256"
+  };
+
+  let server = httpd_setup({
+    "/elysium": function(request, response) {
+      do_check_true(request.hasHeader("Authorization"));
+
+      // check that the header timestamp is our arbitrary system date, not
+      // today's date.  Note that hawk header timestamps are in seconds, not
+      // milliseconds.
+      let authorization = request.getHeader("Authorization");
+      let tsMS = parseInt(/ts="(\d+)"/.exec(authorization)[1], 10) * 1000;
+      do_check_eq(tsMS, then);
+
+      let message = "yay";
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      response.bodyOutputStream.write(message, message.length);
+    }
+  });
+
+  function onProgress() {
+    onProgressCalled = true;
+  }
+
+  function onComplete(error) {
+    do_check_eq(200, this.response.status);
+    do_check_eq(this.response.body, "yay");
+    do_check_true(onProgressCalled);
+    do_test_finished();
+    server.stop(run_next_test);
+  }
+
+  let url = server.baseURI + "/elysium";
+  let extra = {
+    now: localTime,
+    localtimeOffsetMsec: timeOffset
+  };
+  let request = new HAWKAuthenticatedRESTRequest(url, credentials, extra);
+  request.post(postData, onComplete, onProgress);
+});
--- a/services/common/tests/unit/xpcshell.ini
+++ b/services/common/tests/unit/xpcshell.ini
@@ -20,16 +20,17 @@ firefox-appdir = browser
 [test_utils_stackTrace.js]
 [test_utils_utf8.js]
 [test_utils_uuid.js]
 
 [test_async_chain.js]
 [test_async_querySpinningly.js]
 [test_bagheera_server.js]
 [test_bagheera_client.js]
+[test_hawk.js]
 [test_observers.js]
 [test_restrequest.js]
 [test_tokenauthenticatedrequest.js]
 
 # Storage service APIs
 [test_storageservice_bso.js]
 [test_storageservice_client.js]
 
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -63,16 +63,36 @@ InternalMethods = function(mock) {
       filename: DEFAULT_STORAGE_FILENAME,
       baseDir: OS.Constants.Path.profileDir,
     });
   }
 }
 InternalMethods.prototype = {
 
   /**
+   * Return the current time in milliseconds as an integer.  Allows tests to
+   * manipulate the date to simulate certificate expiration.
+   */
+  now: function() {
+    return this.fxAccountsClient.now();
+  },
+
+  /**
+   * Return clock offset in milliseconds, as reported by the fxAccountsClient.
+   * This can be overridden for testing.
+   *
+   * The offset is the number of milliseconds that must be added to the client
+   * clock to make it equal to the server clock.  For example, if the client is
+   * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
+   */
+  get localtimeOffsetMsec() {
+    return this.fxAccountsClient.localtimeOffsetMsec;
+  },
+
+  /**
    * Ask the server whether the user's email has been verified
    */
   checkEmailStatus: function checkEmailStatus(sessionToken) {
     return this.fxAccountsClient.recoveryEmailStatus(sessionToken);
   },
 
   /**
    * Once the user's email is verified, we can request the keys
@@ -201,19 +221,23 @@ InternalMethods.prototype = {
       return data;
     }.bind(this));
   },
 
   getAssertionFromCert: function(data, keyPair, cert, audience) {
     log.debug("getAssertionFromCert");
     let payload = {};
     let d = Promise.defer();
+    let options = {
+      localtimeOffsetMsec: internal.localtimeOffsetMsec,
+      now: internal.now()
+    };
     // "audience" should look like "http://123done.org".
     // The generated assertion will expire in two minutes.
-    jwcrypto.generateAssertion(cert, keyPair, audience, function(err, signed) {
+    jwcrypto.generateAssertion(cert, keyPair, audience, options, (err, signed) => {
       if (err) {
         log.error("getAssertionFromCert: " + err);
         d.reject(err);
       } else {
         log.debug("getAssertionFromCert returning signed: " + signed);
         d.resolve(signed);
       }
     });
@@ -223,17 +247,17 @@ InternalMethods.prototype = {
   getCertificate: function(data, keyPair, mustBeValidUntil) {
     log.debug("getCertificate" + JSON.stringify(this.signedInUserStorage));
     // TODO: get the lifetime from the cert's .exp field
     if (this.cert && this.cert.validUntil > mustBeValidUntil) {
       log.debug(" getCertificate already had one");
       return Promise.resolve(this.cert.cert);
     }
     // else get our cert signed
-    let willBeValidUntil = this.now() + CERT_LIFETIME;
+    let willBeValidUntil = internal.now() + CERT_LIFETIME;
     return this.getCertificateSigned(data.sessionToken,
                                      keyPair.serializedPublicKey,
                                      CERT_LIFETIME)
       .then((cert) => {
         this.cert = {
           cert: cert,
           validUntil: willBeValidUntil
         };
@@ -250,17 +274,17 @@ InternalMethods.prototype = {
   },
 
   getKeyPair: function(mustBeValidUntil) {
     if (this.keyPair && (this.keyPair.validUntil > mustBeValidUntil)) {
       log.debug("getKeyPair: already have a keyPair");
       return Promise.resolve(this.keyPair.keyPair);
     }
     // Otherwse, create a keypair and set validity limit.
-    let willBeValidUntil = this.now() + KEY_LIFETIME;
+    let willBeValidUntil = internal.now() + KEY_LIFETIME;
     let d = Promise.defer();
     jwcrypto.generateKeyPair("DS160", (err, kp) => {
       if (err) {
         d.reject(err);
       } else {
         this.keyPair = {
           keyPair: kp,
           validUntil: willBeValidUntil
--- a/services/fxaccounts/FxAccountsClient.jsm
+++ b/services/fxaccounts/FxAccountsClient.jsm
@@ -1,34 +1,42 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 this.EXPORTED_SYMBOLS = ["FxAccountsClient"];
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
+Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://services-common/utils.js");
+Cu.import("resource://services-common/hawk.js");
 Cu.import("resource://services-crypto/utils.js");
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
 
 // Default can be changed by the preference 'identity.fxaccounts.auth.uri'
 let _host = "https://api-accounts.dev.lcip.org/v1";
 try {
   _host = Services.prefs.getCharPref("identity.fxaccounts.auth.uri");
 } catch(keepDefault) {}
 
 const HOST = _host;
-const PREFIX_NAME = "identity.mozilla.com/picl/v1/";
+const PROTOCOL_VERSION = "identity.mozilla.com/picl/v1/";
 
-const XMLHttpRequest =
-  Components.Constructor("@mozilla.org/xmlextras/xmlhttprequest;1");
-
+function KW(context) {
+  // This is used as a salt.  It's specified by the protocol.  Note that the
+  // value of PROTOCOL_VERSION does not refer in any wy to the version of the
+  // Firefox Accounts API.  For this reason, it is not exposed as a pref.
+  //
+  // See:
+  // https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#creating-the-account
+  return PROTOCOL_VERSION + context;
+}
 
 function stringToHex(str) {
   let encoder = new TextEncoder("utf-8");
   let bytes = encoder.encode(str);
   return bytesToHex(bytes);
 }
 
 // XXX Sadly, CommonUtils.bytesAsHex doesn't handle typed arrays.
@@ -38,19 +46,47 @@ function bytesToHex(bytes) {
     hex.push((bytes[i] >>> 4).toString(16));
     hex.push((bytes[i] & 0xF).toString(16));
   }
   return hex.join("");
 }
 
 this.FxAccountsClient = function(host = HOST) {
   this.host = host;
+
+  // The FxA auth server expects requests to certain endpoints to be authorized
+  // using Hawk.
+  this.hawk = new HawkClient(host);
 };
 
 this.FxAccountsClient.prototype = {
+
+  /**
+   * Return client clock offset, in milliseconds, as determined by hawk client.
+   * Provided because callers should not have to know about hawk
+   * implementation.
+   *
+   * The offset is the number of milliseconds that must be added to the client
+   * clock to make it equal to the server clock.  For example, if the client is
+   * five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
+   */
+  get localtimeOffsetMsec() {
+    return this.hawk.localtimeOffsetMsec;
+  },
+
+  /*
+   * Return current time in milliseconds
+   *
+   * Not used by this module, but made available to the FxAccounts.jsm
+   * that uses this client.
+   */
+  now: function() {
+    return this.hawk.now();
+  },
+
   /**
    * Create a new Firefox Account and authenticate
    *
    * @param email
    *        The email address for the account (utf8)
    * @param password
    *        The user's password
    * @return Promise
@@ -144,17 +180,17 @@ this.FxAccountsClient.prototype = {
    *          kA: an encryption key for recevorable data
    *          wrapKB: an encryption key that requires knowledge of the user's password
    *        }
    */
   accountKeys: function (keyFetchTokenHex) {
     let creds = this._deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken");
     let keyRequestKey = creds.extra.slice(0, 32);
     let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined,
-                                     PREFIX_NAME + "account/keys", 3 * 32);
+                                     KW("account/keys"), 3 * 32);
     let respHMACKey = morecreds.slice(0, 32);
     let respXORKey = morecreds.slice(32, 96);
 
     return this._request("/account/keys", "GET", creds).then(resp => {
       if (!resp.bundle) {
         throw new Error("failed to retrieve keys");
       }
 
@@ -194,18 +230,20 @@ this.FxAccountsClient.prototype = {
   signCertificate: function (sessionTokenHex, serializedPublicKey, lifetime) {
     let creds = this._deriveHawkCredentials(sessionTokenHex, "sessionToken");
 
     let body = { publicKey: serializedPublicKey,
                  duration: lifetime };
     return Promise.resolve()
       .then(_ => this._request("/certificate/sign", "POST", creds, body))
       .then(resp => resp.cert,
-            err => {dump("HAWK.signCertificate error: " + JSON.stringify(err) + "\n");
-                    throw err;});
+            err => {
+              log.error("HAWK.signCertificate error: " + JSON.stringify(err));
+              throw err;
+            });
   },
 
   /**
    * Determine if an account exists
    *
    * @param email
    *        The email address to check
    * @return Promise
@@ -214,18 +252,20 @@ this.FxAccountsClient.prototype = {
    */
   accountExists: function (email) {
     let hexEmail = stringToHex(email);
     return this._request("/auth/start", "POST", null, { email: hexEmail })
       .then(
         // the account exists
         (result) => true,
         (err) => {
+          log.error("accountExists: error: " + JSON.stringify(err));
           // the account doesn't exist
           if (err.errno === 102) {
+            log.debug("returning false for errno 102");
             return false;
           }
           // propogate other request errors
           throw err;
         }
       );
   },
 
@@ -246,17 +286,17 @@ this.FxAccountsClient.prototype = {
    *          algorithm: sha256
    *          id: the Hawk id (from the first 32 bytes derived)
    *          key: the Hawk key (from bytes 32 to 64)
    *          extra: size - 64 extra bytes
    *        }
    */
   _deriveHawkCredentials: function (tokenHex, context, size) {
     let token = CommonUtils.hexToBytes(tokenHex);
-    let out = CryptoUtils.hkdf(token, undefined, PREFIX_NAME + context, size || 3 * 32);
+    let out = CryptoUtils.hkdf(token, undefined, KW(context), size || 3 * 32);
 
     return {
       algorithm: "sha256",
       key: out.slice(32, 64),
       extra: out.slice(64),
       id: CommonUtils.bytesAsHex(out.slice(0, 32))
     };
   },
@@ -281,70 +321,28 @@ this.FxAccountsClient.prototype = {
    *          "errno": 107, // stable application-level error number
    *          "error": "Bad Request", // string description of the error type
    *          "message": "the value of salt is not allowed to be undefined",
    *          "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error
    *        }
    */
   _request: function hawkRequest(path, method, credentials, jsonPayload) {
     let deferred = Promise.defer();
-    let xhr = new XMLHttpRequest({mozSystem: true});
-    let URI = this.host + path;
-    let payload;
-
-    xhr.mozBackgroundRequest = true;
-
-    if (jsonPayload) {
-      payload = JSON.stringify(jsonPayload);
-    }
-
-    log.debug("(HAWK request) - Path: " + path + " - Method: " + method +
-              " - Payload: " + payload);
-
-    xhr.open(method, URI);
-    xhr.channel.loadFlags = Ci.nsIChannel.LOAD_BYPASS_CACHE |
-                            Ci.nsIChannel.INHIBIT_CACHING;
-
-    // When things really blow up, reconstruct an error object that follows the general format
-    // of the server on error responses.
-    function constructError(err) {
-      return { error: err, message: xhr.statusText, code: xhr.status, errno: xhr.status };
-    }
-
-    xhr.onerror = function() {
-      deferred.reject(constructError('Request failed'));
-    };
 
-    xhr.onload = function onload() {
-      try {
-        let response = JSON.parse(xhr.responseText);
-        log.debug("(Response) Code: " + xhr.status + " - Status text: " +
-                  xhr.statusText + " - Response text: " + xhr.responseText);
-        if (xhr.status !== 200 || response.error) {
-          // In this case, the response is an object with error information.
-          return deferred.reject(response);
+    this.hawk.request(path, method, credentials, jsonPayload).then(
+      (responseText) => {
+        try {
+          let response = JSON.parse(responseText);
+          deferred.resolve(response);
+        } catch (err) {
+          deferred.reject({error: err});
         }
-        deferred.resolve(response);
-      } catch (e) {
-        log.error("(Response) Code: " + xhr.status + " - Status text: " +
-                  xhr.statusText);
-        deferred.reject(constructError(e));
+      },
+
+      (error) => {
+        deferred.reject(error);
       }
-    };
-
-    let uri = Services.io.newURI(URI, null, null);
-
-    if (credentials) {
-      let header = CryptoUtils.computeHAWK(uri, method, {
-                          credentials: credentials,
-                          payload: payload,
-                          contentType: "application/json"
-                        });
-      xhr.setRequestHeader("authorization", header.field);
-    }
-
-    xhr.setRequestHeader("Content-Type", "application/json");
-    xhr.send(payload);
+    );
 
     return deferred.promise;
   },
 };
 
--- a/services/fxaccounts/tests/xpcshell/test_accounts.js
+++ b/services/fxaccounts/tests/xpcshell/test_accounts.js
@@ -6,16 +6,22 @@
 Cu.import("resource://services-common/utils.js");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/FxAccounts.jsm");
 Cu.import("resource://gre/modules/FxAccountsClient.jsm");
 Cu.import("resource://gre/modules/FxAccountsCommon.js");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/Log.jsm");
 
+const ONE_HOUR_MS = 1000 * 60 * 60;
+const ONE_DAY_MS = ONE_HOUR_MS * 24;
+const TWO_MINUTES_MS = 1000 * 60 * 2;
+
+initTestLogging("Trace");
+
 // XXX until bug 937114 is fixed
 Cu.importGlobalProperties(['atob']);
 
 let log = Log.repository.getLogger("Services.FxAccounts.test");
 log.level = Log.Level.Debug;
 
 // See verbose logging from FxAccounts.jsm
 Services.prefs.setCharPref("identity.fxaccounts.loglevel", "DEBUG");
@@ -378,82 +384,106 @@ add_task(function test_getAssertion() {
     kB: expandHex("66"),
     verified: true
   };
   // By putting kA/kB/verified in "creds", we skip ahead
   // to the "we're ready" stage.
   yield fxa.setSignedInUser(creds);
 
   _("== ready to go\n");
-  let now = 138000000*1000;
-  let start = Date.now();
+  // Start with a nice arbitrary but realistic date.  Here we use a nice RFC
+  // 1123 date string like we would get from an HTTP header. Over the course of
+  // the test, we will update 'now', but leave 'start' where it is.
+  let now = Date.parse("Mon, 13 Jan 2014 21:45:06 GMT");
+  let start = now;
   fxa._now_is = now;
+
   let d = fxa.getAssertion("audience.example.com");
   // At this point, a thread has been spawned to generate the keys.
   _("-- back from fxa.getAssertion\n");
   fxa._d_signCertificate.resolve("cert1");
   let assertion = yield d;
-  let finish = Date.now();
   do_check_eq(fxa._getCertificateSigned_calls.length, 1);
   do_check_eq(fxa._getCertificateSigned_calls[0][0], "sessionToken");
   do_check_neq(assertion, null);
-  _("ASSERTION: "+assertion+"\n");
+  _("ASSERTION: " + assertion + "\n");
   let pieces = assertion.split("~");
   do_check_eq(pieces[0], "cert1");
   do_check_neq(fxa.internal.keyPair, undefined);
-  _(fxa.internal.keyPair.validUntil+"\n");
+  _(fxa.internal.keyPair.validUntil + "\n");
   let p2 = pieces[1].split(".");
   let header = JSON.parse(atob(p2[0]));
-  _("HEADER: "+JSON.stringify(header)+"\n");
+  _("HEADER: " + JSON.stringify(header) + "\n");
   do_check_eq(header.alg, "DS128");
   let payload = JSON.parse(atob(p2[1]));
-  _("PAYLOAD: "+JSON.stringify(payload)+"\n");
+  _("PAYLOAD: " + JSON.stringify(payload) + "\n");
   do_check_eq(payload.aud, "audience.example.com");
-  // FxAccounts KEY_LIFETIME
-  do_check_eq(fxa.internal.keyPair.validUntil, now + (12*3600*1000));
-  // FxAccounts CERT_LIFETIME
-  do_check_eq(fxa.internal.cert.validUntil, now + (6*3600*1000));
-  _("delta: "+(new Date(payload.exp) - now)+"\n");
+  do_check_eq(fxa.internal.keyPair.validUntil, start + KEY_LIFETIME);
+  do_check_eq(fxa.internal.cert.validUntil, start + CERT_LIFETIME);
+  _("delta: " + Date.parse(payload.exp - start) + "\n");
   let exp = Number(payload.exp);
-  // jwcrypto.jsm uses an unmocked Date.now()+2min to decide on the
-  // expiration time, so we test that it's inside a specific timebox
-  do_check_true(start + 2*60*1000 <= exp);
-  do_check_true(exp <= finish + 2*60*1000);
+
+  do_check_eq(exp, now + TWO_MINUTES_MS);
 
   // Reset for next call.
   fxa._d_signCertificate = Promise.defer();
 
-  // Getting a new assertion "soon" (i.e. w/o incrementing "now"), even for
+  // Getting a new assertion "soon" (i.e., w/o incrementing "now"), even for
   // a new audience, should not provoke key generation or a signing request.
   assertion = yield fxa.getAssertion("other.example.com");
+
+  // There were no additional calls - same number of getcert calls as before
   do_check_eq(fxa._getCertificateSigned_calls.length, 1);
 
-  // But "waiting" (i.e. incrementing "now") will need a new key+signature.
-  fxa._now_is = now + 24*3600*1000;
-  start = Date.now();
-  d = fxa.getAssertion("third.example.com");
+  // Wait an hour; assertion expires, but not the certificate
+  now += ONE_HOUR_MS;
+  fxa._now_is = now;
+
+  // This won't block on anything - will make an assertion, but not get a
+  // new certificate.
+  assertion = yield fxa.getAssertion("third.example.com");
+
+  // Test will time out if that failed (i.e., if that had to go get a new cert)
+  pieces = assertion.split("~");
+  do_check_eq(pieces[0], "cert1");
+  p2 = pieces[1].split(".");
+  header = JSON.parse(atob(p2[0]));
+  payload = JSON.parse(atob(p2[1]));
+  do_check_eq(payload.aud, "third.example.com");
+
+  // The keypair and cert should have the same validity as before, but the
+  // expiration time of the assertion should be different.  We compare this to
+  // the initial start time, to which they are relative, not the current value
+  // of "now".
+
+  do_check_eq(fxa.internal.keyPair.validUntil, start + KEY_LIFETIME);
+  do_check_eq(fxa.internal.cert.validUntil, start + CERT_LIFETIME);
+  exp = Number(payload.exp);
+  do_check_eq(exp, now + TWO_MINUTES_MS);
+
+  // Now we wait even longer, and expect both assertion and cert to expire.  So
+  // we will have to get a new keypair and cert.
+  now += ONE_DAY_MS;
+  fxa._now_is = now;
+  d = fxa.getAssertion("fourth.example.com");
   fxa._d_signCertificate.resolve("cert2");
   assertion = yield d;
-  finish = Date.now();
   do_check_eq(fxa._getCertificateSigned_calls.length, 2);
   do_check_eq(fxa._getCertificateSigned_calls[1][0], "sessionToken");
   pieces = assertion.split("~");
   do_check_eq(pieces[0], "cert2");
   p2 = pieces[1].split(".");
   header = JSON.parse(atob(p2[0]));
   payload = JSON.parse(atob(p2[1]));
-  do_check_eq(payload.aud, "third.example.com");
-  // 12*3600*1000 === FxAccounts KEY_LIFETIME
-  do_check_eq(fxa.internal.keyPair.validUntil, now + 24*3600*1000 + (12*3600*1000));
-  // 6*3600*1000 === FxAccounts CERT_LIFETIME
-  do_check_eq(fxa.internal.cert.validUntil, now + 24*3600*1000 + (6*3600*1000));
+  do_check_eq(payload.aud, "fourth.example.com");
+  do_check_eq(fxa.internal.keyPair.validUntil, now + KEY_LIFETIME);
+  do_check_eq(fxa.internal.cert.validUntil, now + CERT_LIFETIME);
   exp = Number(payload.exp);
-  do_check_true(start + 2*60*1000 <= exp);
-  do_check_true(exp <= finish + 2*60*1000);
 
+  do_check_eq(exp, now + TWO_MINUTES_MS);
   _("----- DONE ----\n");
 });
 
 add_task(function test_resend_email_not_signed_in() {
   let fxa = new MockFxAccounts();
 
   try {
     yield fxa.resendVerificationEmail();
--- a/services/fxaccounts/tests/xpcshell/test_client.js
+++ b/services/fxaccounts/tests/xpcshell/test_client.js
@@ -1,39 +1,40 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 Cu.import("resource://gre/modules/FxAccountsClient.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://services-common/utils.js");
 
+const FAKE_SESSION_TOKEN = "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf";
+
 function run_test() {
   run_next_test();
 }
 
 function deferredStop(server) {
-    let deferred = Promise.defer();
-    server.stop(deferred.resolve);
-    return deferred.promise;
+  let deferred = Promise.defer();
+  server.stop(deferred.resolve);
+  return deferred.promise;
 }
 
 add_test(function test_hawk_credentials() {
   let client = new FxAccountsClient();
 
   let sessionToken = "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf";
   let result = client._deriveHawkCredentials(sessionToken, "session");
 
   do_check_eq(result.id, "639503a218ffbb62983e9628be5cd64a0438d0ae81b2b9dadeb900a83470bc6b");
   do_check_eq(CommonUtils.bytesAsHex(result.key), "3a0188943837ab228fe74e759566d0e4837cbcc7494157aac4da82025b2811b2");
 
   run_next_test();
 });
 
 add_task(function test_authenticated_get_request() {
-
   let message = "{\"msg\": \"Great Success!\"}";
   let credentials = {
     id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
     key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
     algorithm: "sha256"
   };
   let method = "GET";
 
@@ -74,17 +75,16 @@ add_task(function test_authenticated_pos
 
   let result = yield client._request("/foo", method, credentials, {foo: "bar"});
   do_check_eq("bar", result.foo);
 
   yield deferredStop(server);
 });
 
 add_task(function test_500_error() {
-
   let message = "<h1>Ooops!</h1>";
   let method = "GET";
 
   let server = httpd_setup({"/foo": function(request, response) {
       response.setStatusLine(request.httpVersion, 500, "Internal Server Error");
       response.bodyOutputStream.write(message, message.length);
     }
   });
@@ -176,17 +176,17 @@ add_task(function test_api_endpoints() {
   let result = undefined;
 
   result = yield client.signUp('you@example.com', 'biggersecret');
   do_check_eq("NotARealUid", result.uid);
 
   result = yield client.signIn('mé@example.com', 'bigsecret');
   do_check_eq("NotARealToken", result.sessionToken);
 
-  result = yield client.signOut('NotARealToken');
+  result = yield client.signOut(FAKE_SESSION_TOKEN);
   do_check_eq(typeof result, "object");
 
   result = yield client.recoveryEmailStatus('NotARealToken');
   do_check_eq(result.verified, true);
 
   result = yield client.signCertificate('NotARealToken', JSON.stringify({foo: "bar"}), 600);
   do_check_eq("baz", result.bar);
 
--- a/services/sync/modules/browserid_identity.js
+++ b/services/sync/modules/browserid_identity.js
@@ -15,16 +15,17 @@ Cu.import("resource://services-common/to
 Cu.import("resource://services-crypto/utils.js");
 Cu.import("resource://services-sync/identity.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-common/tokenserverclient.js");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://services-sync/stages/cluster.js");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
 
 // Lazy imports to prevent unnecessary load on startup.
 XPCOMUtils.defineLazyModuleGetter(this, "BulkKeyBundle",
                                   "resource://services-sync/keys.js");
 
 XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
                                   "resource://gre/modules/FxAccounts.jsm");
 
@@ -144,17 +145,17 @@ this.BrowserIDManager.prototype = {
         this._shouldHaveSyncKeyBundle = true; // but we probably don't have one...
         this.whenReadyToAuthenticate.reject(err);
         // report what failed...
         this._log.error("Background fetch for key bundle failed: " + err);
         throw err;
       });
       // and we are done - the fetch continues on in the background...
     }).then(null, err => {
-      dump("err in processing logged in account "+err.message);
+      this._log.error("Processing logged in account: " + err.message);
     });
   },
 
   observe: function (subject, topic, data) {
     switch (topic) {
     case fxAccountsCommon.ONLOGIN_NOTIFICATION:
       this.initializeWithCurrentIdentity(true);
       break;
@@ -188,17 +189,21 @@ this.BrowserIDManager.prototype = {
   _computeXClientState: function(kBbytes) {
     return CommonUtils.bytesAsHex(this._sha256(kBbytes).slice(0, 16), false);
   },
 
   /**
    * Provide override point for testing token expiration.
    */
   _now: function() {
-    return Date.now();
+    return this._fxaService.internal.now()
+  },
+
+  get _localtimeOffsetMsec() {
+    return this._fxaService.internal.localtimeOffsetMsec;
   },
 
   get account() {
     return this._account;
   },
 
   /**
    * Sets the active account name.
@@ -459,18 +464,26 @@ this.BrowserIDManager.prototype = {
         return null;
       }
     }
     let credentials = {algorithm: "sha256",
                        id: this._token.id,
                        key: this._token.key,
                       };
     method = method || httpObject.method;
-    let headerValue = CryptoUtils.computeHAWK(httpObject.uri, method,
-                                              {credentials: credentials});
+
+    // Get the local clock offset from the Firefox Accounts server.  This should
+    // be close to the offset from the storage server.
+    let options = {
+      now: this._now(),
+      localtimeOffsetMsec: this._localtimeOffsetMsec,
+      credentials: credentials,
+    };
+
+    let headerValue = CryptoUtils.computeHAWK(httpObject.uri, method, options);
     return {headers: {authorization: headerValue.field}};
   },
 
   _addAuthenticationHeader: function(request, method) {
     let header = this._getAuthenticationHeader(request, method);
     if (!header) {
       return null;
     }
--- a/services/sync/tests/unit/test_browserid_identity.js
+++ b/services/sync/tests/unit/test_browserid_identity.js
@@ -3,21 +3,58 @@
 
 Cu.import("resource://gre/modules/FxAccounts.jsm");
 Cu.import("resource://services-sync/browserid_identity.js");
 Cu.import("resource://services-sync/rest.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-common/utils.js");
 Cu.import("resource://services-crypto/utils.js");
 Cu.import("resource://testing-common/services/sync/utils.js");
+Cu.import("resource://services-common/hawk.js");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+
+const SECOND_MS = 1000;
+const MINUTE_MS = SECOND_MS * 60;
+const HOUR_MS = MINUTE_MS * 60;
 
 let identityConfig = makeIdentityConfig();
 let browseridManager = new BrowserIDManager();
 configureFxAccountIdentity(browseridManager, identityConfig);
 
+/**
+ * Mock client clock and skew vs server in FxAccounts signed-in user module and
+ * API client.  browserid_identity.js queries these values to construct HAWK
+ * headers.  We will use this to test clock skew compensation in these headers
+ * below.
+ */
+let MockFxAccountsClient = function() {
+  FxAccountsClient.apply(this);
+};
+MockFxAccountsClient.prototype = {
+  __proto__: FxAccountsClient.prototype
+};
+
+let MockFxAccounts = function() {
+  this._now_is = Date.now();
+
+  let mockInternal = {
+    now: () => {
+      return this._now_is;
+    },
+
+    fxAccountsClient: new MockFxAccountsClient()
+  };
+
+  FxAccounts.apply(this, [mockInternal]);
+};
+MockFxAccounts.prototype = {
+  __proto__: FxAccounts.prototype,
+};
+
 function run_test() {
   initTestLogging("Trace");
   Log.repository.getLogger("Sync.Identity").level = Log.Level.Trace;
   run_next_test();
 };
 
 add_test(function test_initial_state() {
     _("Verify initial state");
@@ -55,16 +92,121 @@ add_test(function test_getRESTRequestAut
     do_check_eq(request.uri, output.uri);
     do_check_true(output._headers.authorization.startsWith('Hawk'));
     do_check_true(output._headers.authorization.contains('nonce'));
     do_check_true(browseridManager.hasValidToken());
     run_next_test();
   }
 );
 
+add_test(function test_resourceAuthenticatorSkew() {
+  _("BrowserIDManager Resource Authenticator compensates for clock skew in Hawk header.");
+
+  // Clock is skewed 12 hours into the future
+  // We pick a date in the past so we don't risk concealing bugs in code that
+  // uses new Date() instead of our given date.
+  let now = new Date("Fri Apr 09 2004 00:00:00 GMT-0700").valueOf() + 12 * HOUR_MS;
+  let browseridManager = new BrowserIDManager();
+  let hawkClient = new HawkClient("https://example.net/v1", "/foo");
+
+  // mock fxa hawk client skew
+  hawkClient.now = function() {
+    dump("mocked client now: " + now + '\n');
+    return now;
+  }
+  // Imagine there's already been one fxa request and the hawk client has
+  // already detected skew vs the fxa auth server.
+  let localtimeOffsetMsec = -1 * 12 * HOUR_MS;
+  hawkClient._localtimeOffsetMsec = localtimeOffsetMsec;
+
+  let fxaClient = new MockFxAccountsClient();
+  fxaClient.hawk = hawkClient;
+
+  // Sanity check
+  do_check_eq(hawkClient.now(), now);
+  do_check_eq(hawkClient.localtimeOffsetMsec, localtimeOffsetMsec);
+
+  // Properly picked up by the client
+  do_check_eq(fxaClient.now(), now);
+  do_check_eq(fxaClient.localtimeOffsetMsec, localtimeOffsetMsec);
+
+  let fxa = new MockFxAccounts();
+  fxa._now_is = now;
+  fxa.internal.fxAccountsClient = fxaClient;
+
+  // Picked up by the signed-in user module
+  do_check_eq(fxa.internal.now(), now);
+  do_check_eq(fxa.internal.localtimeOffsetMsec, localtimeOffsetMsec);
+
+  // Mocks within mocks...
+  configureFxAccountIdentity(browseridManager, identityConfig);
+  browseridManager._fxaService = fxa;
+  do_check_eq(browseridManager._fxaService.internal.now(), now);
+  do_check_eq(browseridManager._fxaService.internal.localtimeOffsetMsec,
+      localtimeOffsetMsec);
+
+  let request = new SyncStorageRequest("https://example.net/i/like/pie/");
+  let authenticator = browseridManager.getResourceAuthenticator();
+  let output = authenticator(request, 'GET');
+  dump("output" + JSON.stringify(output));
+  let authHeader = output.headers.authorization;
+  do_check_true(authHeader.startsWith('Hawk'));
+
+  // Skew correction is applied in the header and we're within the two-minute
+  // window.
+  do_check_eq(getTimestamp(authHeader), now - 12 * HOUR_MS);
+  do_check_true(
+      (getTimestampDelta(authHeader, now) - 12 * HOUR_MS) < 2 * MINUTE_MS);
+
+  run_next_test();
+});
+
+add_test(function test_RESTResourceAuthenticatorSkew() {
+  _("BrowserIDManager REST Resource Authenticator compensates for clock skew in Hawk header.");
+
+  // Clock is skewed 12 hours into the future from our arbitary date
+  let now = new Date("Fri Apr 09 2004 00:00:00 GMT-0700").valueOf() + 12 * HOUR_MS;
+  let browseridManager = new BrowserIDManager();
+  let hawkClient = new HawkClient("https://example.net/v1", "/foo");
+
+  // mock fxa hawk client skew
+  hawkClient.now = function() {
+    return now;
+  }
+  // Imagine there's already been one fxa request and the hawk client has
+  // already detected skew vs the fxa auth server.
+  hawkClient._localtimeOffsetMsec = -1 * 12 * HOUR_MS;
+
+  let fxaClient = new MockFxAccountsClient();
+  fxaClient.hawk = hawkClient;
+  let fxa = new MockFxAccounts();
+  fxa._now_is = now;
+  fxa.internal.fxAccountsClient = fxaClient;
+
+  configureFxAccountIdentity(browseridManager, identityConfig);
+  browseridManager._fxaService = fxa;
+
+  do_check_eq(browseridManager._fxaService.internal.now(), now);
+
+  let request = new SyncStorageRequest("https://example.net/i/like/pie/");
+  let authenticator = browseridManager.getResourceAuthenticator();
+  let output = authenticator(request, 'GET');
+  dump("output" + JSON.stringify(output));
+  let authHeader = output.headers.authorization;
+  do_check_true(authHeader.startsWith('Hawk'));
+
+  // Skew correction is applied in the header and we're within the two-minute
+  // window.
+  do_check_eq(getTimestamp(authHeader), now - 12 * HOUR_MS);
+  do_check_true(
+      (getTimestampDelta(authHeader, now) - 12 * HOUR_MS) < 2 * MINUTE_MS);
+
+  run_next_test();
+});
+
 add_test(function test_tokenExpiration() {
     _("BrowserIDManager notices token expiration:");
     let bimExp = new BrowserIDManager();
     configureFxAccountIdentity(bimExp, identityConfig);
 
     let authenticator = bimExp.getResourceAuthenticator();
     do_check_true(!!authenticator);
     let req = {uri: CommonUtils.makeURI(
@@ -138,8 +280,19 @@ add_test(function test_computeXClientSta
 
   let bidUser = new BrowserIDManager();
   let header = bidUser._computeXClientState(kB);
 
   do_check_eq(header, "6ae94683571c7a7c54dab4700aa3995f");
   run_next_test();
 });
 
+// End of tests
+// Utility functions follow
+
+function getTimestamp(hawkAuthHeader) {
+  return parseInt(/ts="(\d+)"/.exec(hawkAuthHeader)[1], 10) * SECOND_MS;
+}
+
+function getTimestampDelta(hawkAuthHeader, now=Date.now()) {
+  return Math.abs(getTimestamp(hawkAuthHeader) - now);
+}
+
--- a/toolkit/identity/jwcrypto.jsm
+++ b/toolkit/identity/jwcrypto.jsm
@@ -19,16 +19,17 @@ Cu.import("resource://gre/modules/identi
 XPCOMUtils.defineLazyServiceGetter(this,
                                    "IdentityCryptoService",
                                    "@mozilla.org/identity/crypto-service;1",
                                    "nsIIdentityCryptoService");
 
 this.EXPORTED_SYMBOLS = ["jwcrypto"];
 
 const ALGORITHMS = { RS256: "RS256", DS160: "DS160" };
+const DURATION_MS = 1000 * 60 * 2; // 2 minutes default assertion lifetime
 
 function log(...aMessageArgs) {
   Logger.log.apply(Logger, ["jwcrypto"].concat(aMessageArgs));
 }
 
 function generateKeyPair(aAlgorithmName, aCallback) {
   log("Generate key pair; alg =", aAlgorithmName);
 
@@ -82,37 +83,87 @@ function sign(aPayload, aKeypair, aCallb
   });
 }
 
 function jwcryptoClass()
 {
 }
 
 jwcryptoClass.prototype = {
+  /*
+   * Determine the expiration of the assertion.  Returns expiry date
+   * in milliseconds as integer.
+   *
+   * @param localtimeOffsetMsec (optional)
+   *        The number of milliseconds that must be added to the local clock
+   *        for it to agree with the server.  For example, if the local clock
+   *        if two minutes fast, localtimeOffsetMsec would be -120000
+   *
+   * @param now (options)
+   *        Current date in milliseconds.  Useful for mocking clock
+   *        skew in testing.
+   */
+  getExpiration: function(duration=DURATION_MS, localtimeOffsetMsec=0, now=Date.now()) {
+    return now + localtimeOffsetMsec + duration;
+  },
+
   isCertValid: function(aCert, aCallback) {
     // XXX check expiration, bug 769850
     aCallback(true);
   },
 
   generateKeyPair: function(aAlgorithmName, aCallback) {
     log("generating");
     generateKeyPair(aAlgorithmName, aCallback);
   },
 
-  generateAssertion: function(aCert, aKeyPair, aAudience, aCallback) {
+  /*
+   * Generate an assertion and return it through the provided callback.
+   *
+   * @param aCert
+   *        Identity certificate
+   *
+   * @param aKeyPair
+   *        KeyPair object
+   *
+   * @param aAudience
+   *        Audience of the assertion
+   *
+   * @param aOptions (optional)
+   *        Can include:
+   *        {
+   *          localtimeOffsetMsec: <clock offset in milliseconds>,
+   *          now: <current date in milliseconds>
+   *          duration: <validity duration for this assertion in milliseconds>
+   *        }
+   *
+   *        localtimeOffsetMsec is the number of milliseconds that need to be
+   *        added to the local clock time to make it concur with the server.
+   *        For example, if the local clock is two minutes fast, the offset in
+   *        milliseconds would be -120000.
+   *
+   * @param aCallback
+   *        Function to invoke with resulting assertion.  Assertion
+   *        will be string or null on failure.
+   */
+  generateAssertion: function(aCert, aKeyPair, aAudience, aOptions, aCallback) {
+    if (typeof aOptions == "function") {
+      aCallback = aOptions;
+      aOptions = { };
+    }
+
     // for now, we hack the algorithm name
     // XXX bug 769851
     var header = {"alg": "DS128"};
     var headerBytes = IdentityCryptoService.base64UrlEncode(
                           JSON.stringify(header));
 
     var payload = {
-      // expires in 2 minutes
-      // XXX clock skew needs exploration bug 769852
-      exp: Date.now() + (2 * 60 * 1000),
+      exp: this.getExpiration(
+               aOptions.duration, aOptions.localtimeOffsetMsec, aOptions.now),
       aud: aAudience
     };
     var payloadBytes = IdentityCryptoService.base64UrlEncode(
                           JSON.stringify(payload));
 
     log("payload bytes", payload, payloadBytes);
     sign(headerBytes + "." + payloadBytes, aKeyPair, function(err, signature) {
       if (err)
--- a/toolkit/identity/tests/unit/test_jwcrypto.js
+++ b/toolkit/identity/tests/unit/test_jwcrypto.js
@@ -7,19 +7,28 @@ Cu.import('resource://gre/modules/identi
 
 XPCOMUtils.defineLazyModuleGetter(this, "IDService",
                                   "resource://gre/modules/identity/Identity.jsm",
                                   "IdentityService");
 
 XPCOMUtils.defineLazyModuleGetter(this, "jwcrypto",
                                   "resource://gre/modules/identity/jwcrypto.jsm");
 
+XPCOMUtils.defineLazyServiceGetter(this,
+                                   "CryptoService",
+                                   "@mozilla.org/identity/crypto-service;1",
+                                   "nsIIdentityCryptoService");
+
 const RP_ORIGIN = "http://123done.org";
 const INTERNAL_ORIGIN = "browserid://";
 
+const SECOND_MS = 1000;
+const MINUTE_MS = SECOND_MS * 60;
+const HOUR_MS = MINUTE_MS * 60;
+
 function test_sanity() {
   do_test_pending();
 
   jwcrypto.generateKeyPair("DS160", function(err, kp) {
     do_check_null(err);
 
     do_test_finished();
     run_next_test();
@@ -38,21 +47,21 @@ function test_generate() {
 }
 
 function test_get_assertion() {
   do_test_pending();
 
   jwcrypto.generateKeyPair(
     "DS160",
     function(err, kp) {
-      jwcrypto.generateAssertion("fake-cert", kp, RP_ORIGIN, function(err, assertion) {
+      jwcrypto.generateAssertion("fake-cert", kp, RP_ORIGIN, (err, backedAssertion) => {
         do_check_null(err);
 
-        // more checks on assertion
-        log("assertion", assertion);
+        do_check_eq(backedAssertion.split("~").length, 2);
+        do_check_eq(backedAssertion.split(".").length, 3);
 
         do_test_finished();
         run_next_test();
       });
     });
 }
 
 function test_rsa() {
@@ -111,17 +120,138 @@ function test_dsa() {
 */
     do_test_finished();
     run_next_test();
   };
 
   jwcrypto.generateKeyPair("DS160", checkDSA);
 }
 
-var TESTS = [test_sanity, test_generate, test_get_assertion];
+function test_get_assertion_with_offset() {
+  do_test_pending();
+
+
+  // Use an arbitrary date in the past to ensure we don't accidentally pass
+  // this test with current dates, missing offsets, etc.
+  let serverMsec = Date.parse("Tue Oct 31 2000 00:00:00 GMT-0800");
+
+  // local clock skew
+  // clock is 12 hours fast; -12 hours offset must be applied
+  let localtimeOffsetMsec = -1 * 12 * HOUR_MS;
+  let localMsec = serverMsec - localtimeOffsetMsec;
+
+  jwcrypto.generateKeyPair(
+    "DS160",
+    function(err, kp) {
+      jwcrypto.generateAssertion("fake-cert", kp, RP_ORIGIN,
+        { duration: MINUTE_MS,
+          localtimeOffsetMsec: localtimeOffsetMsec,
+          now: localMsec},
+          function(err, backedAssertion) {
+            do_check_null(err);
+
+            // properly formed
+            let cert;
+            let assertion;
+            [cert, assertion] = backedAssertion.split("~");
+
+            do_check_eq(cert, "fake-cert");
+            do_check_eq(assertion.split(".").length, 3);
+
+            let components = extractComponents(assertion);
+
+            // Expiry is within two minutes, corrected for skew
+            let exp = parseInt(components.payload.exp, 10);
+            do_check_true(exp - serverMsec === MINUTE_MS);
+
+            do_test_finished();
+            run_next_test();
+          }
+      );
+    }
+  );
+}
+
+function test_assertion_lifetime() {
+  do_test_pending();
+
+  jwcrypto.generateKeyPair(
+    "DS160",
+    function(err, kp) {
+      jwcrypto.generateAssertion("fake-cert", kp, RP_ORIGIN,
+        {duration: MINUTE_MS},
+        function(err, backedAssertion) {
+          do_check_null(err);
+
+          // properly formed
+          let cert;
+          let assertion;
+          [cert, assertion] = backedAssertion.split("~");
+
+          do_check_eq(cert, "fake-cert");
+          do_check_eq(assertion.split(".").length, 3);
+
+          let components = extractComponents(assertion);
+
+          // Expiry is within one minute, as we specified above
+          let exp = parseInt(components.payload.exp, 10);
+          do_check_true(Math.abs(Date.now() - exp) > 50 * SECOND_MS);
+          do_check_true(Math.abs(Date.now() - exp) <= MINUTE_MS);
+
+          do_test_finished();
+          run_next_test();
+        }
+      );
+    }
+  );
+}
+
+// End of tests
+// Helper function follow
+
+function extractComponents(signedObject) {
+  if (typeof(signedObject) != 'string') {
+    throw new Error("malformed signature " + typeof(signedObject));
+  }
+
+  let parts = signedObject.split(".");
+  if (parts.length != 3) {
+    throw new Error("signed object must have three parts, this one has " + parts.length);
+  }
+
+  let headerSegment = parts[0];
+  let payloadSegment = parts[1];
+  let cryptoSegment = parts[2];
+
+  let header = JSON.parse(CryptoService.base64UrlDecode(headerSegment));
+  let payload = JSON.parse(CryptoService.base64UrlDecode(payloadSegment));
+
+  // Ensure well-formed header
+  do_check_eq(Object.keys(header).length, 1);
+  do_check_true(!!header.alg);
+
+  // Ensure well-formed payload
+  for (let field of ["exp", "aud"]) {
+    do_check_true(!!payload[field]);
+  }
+
+  return {header: header,
+          payload: payload,
+          headerSegment: headerSegment,
+          payloadSegment: payloadSegment,
+          cryptoSegment: cryptoSegment};
+};
+
+let TESTS = [
+  test_sanity,
+  test_generate,
+  test_get_assertion,
+  test_get_assertion_with_offset,
+  test_assertion_lifetime,
+];
 
 TESTS = TESTS.concat([test_rsa, test_dsa]);
 
 TESTS.forEach(add_test);
 
 function run_test() {
   run_next_test();
 }