diff --git a/Framework/CMakeLists.txt b/Framework/CMakeLists.txt
index 139521f0bfc0686b82febeecfdc9b8e758d2758d..8eb0109da65cd4179225827030005e54e514f12f 100644
--- a/Framework/CMakeLists.txt
+++ b/Framework/CMakeLists.txt
@@ -116,6 +116,7 @@ add_subdirectory (Nexus)
 add_subdirectory (DataHandling)
 add_subdirectory (Algorithms)
 add_subdirectory (WorkflowAlgorithms)
+add_subdirectory (Catalog)
 add_subdirectory (CurveFitting)
 add_subdirectory (Crystal)
 add_subdirectory (ICat)
@@ -145,7 +146,7 @@ add_subdirectory (ScriptRepository)
 
 set ( FRAMEWORK_LIBS Kernel HistogramData Indexing Beamline Geometry API DataObjects
                      PythonInterface DataHandling Nexus Algorithms CurveFitting ICat
-                     Crystal MDAlgorithms WorkflowAlgorithms
+                     Catalog Crystal MDAlgorithms WorkflowAlgorithms
                      LiveData RemoteAlgorithms RemoteJobManagers
                      SINQ
 )
diff --git a/Framework/Catalog/CMakeLists.txt b/Framework/Catalog/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..3967f156b931e0c4e6523856b9a15db8d6f7e3ba
--- /dev/null
+++ b/Framework/Catalog/CMakeLists.txt
@@ -0,0 +1,65 @@
+set ( SRC_FILES
+    src/ONCat.cpp
+    src/ONCatEntity.cpp
+    src/OAuth.cpp
+)
+
+set ( INC_FILES
+    inc/MantidCatalog/DLLConfig.h
+    inc/MantidCatalog/Exception.h
+    inc/MantidCatalog/ONCat.h
+    inc/MantidCatalog/ONCatEntity.h
+    inc/MantidCatalog/OAuth.h
+)
+
+set ( TEST_FILES
+    ONCatTest.h
+    ONCatEntityTest.h
+    OAuthTest.h
+)
+
+if (COVERALLS)
+    foreach( loop_var ${SRC_FILES} ${INC_FILES})
+      set_property(GLOBAL APPEND PROPERTY COVERAGE_SRCS "${CMAKE_CURRENT_SOURCE_DIR}/${loop_var}")
+    endforeach(loop_var)
+endif()
+
+if(UNITY_BUILD)
+  include(UnityBuild)
+  enable_unity_build(Catalog SRC_FILES SRC_UNITY_IGNORE_FILES 10)
+endif(UNITY_BUILD)
+
+# Add ssl dependency
+include_directories ( ${OPENSSL_INCLUDE_DIR} )
+add_definitions ( -DWITH_OPENSSL -DWITH_NONAMESPACES )
+
+# Add a precompiled header where they are supported
+enable_precompiled_headers( inc/MantidCatalog/PrecompiledHeader.h SRC_FILES )
+# Add the target for this directory
+add_library ( Catalog ${SRC_FILES} ${INC_FILES})
+# Set the name of the generated library
+set_target_properties ( Catalog PROPERTIES OUTPUT_NAME MantidCatalog
+  COMPILE_DEFINITIONS IN_MANTID_CATALOG
+)
+
+if (OSX_VERSION VERSION_GREATER 10.8)
+  set_target_properties(Catalog PROPERTIES INSTALL_RPATH "@loader_path/../Contents/MacOS")
+elseif ( ${CMAKE_SYSTEM_NAME} STREQUAL "Linux" )
+  set_target_properties(Catalog PROPERTIES INSTALL_RPATH "\$ORIGIN/../${LIB_DIR}")
+endif ()
+
+# Add to the 'Framework' group in VS
+set_property ( TARGET Catalog PROPERTY FOLDER "MantidFramework" )
+
+include_directories ( inc )
+
+target_link_libraries ( Catalog LINK_PRIVATE ${TCMALLOC_LIBRARIES_LINKTIME} ${MANTIDLIBS} ${OPENSSL_LIBRARIES} ${JSONCPP_LIBRARIES})
+
+# Add the unit tests directory
+add_subdirectory ( test )
+
+###########################################################################
+# Installation settings
+###########################################################################
+
+install ( TARGETS Catalog ${SYSTEM_PACKAGE_TARGET} DESTINATION ${PLUGINS_DIR} )
diff --git a/Framework/Catalog/inc/MantidCatalog/DllConfig.h b/Framework/Catalog/inc/MantidCatalog/DllConfig.h
new file mode 100644
index 0000000000000000000000000000000000000000..2dd7225ace26c585c55b8c28ae57bdcf055aa7f4
--- /dev/null
+++ b/Framework/Catalog/inc/MantidCatalog/DllConfig.h
@@ -0,0 +1,39 @@
+#ifndef MANTID_CATALOG_DLLCONFIG_H_
+#define MANTID_CATALOG_DLLCONFIG_H_
+
+/*
+    This file contains the DLLExport/DLLImport linkage configuration for the
+   Catalog library
+
+    Copyright © 2018 ISIS Rutherford Appleton Laboratory, NScD Oak Ridge
+   National Laboratory & European Spallation Source
+
+    This file is part of Mantid.
+
+    Mantid is free software; you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation; either version 3 of the License, or
+    (at your option) any later version.
+
+    Mantid is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+    File change history is stored at: <https://github.com/mantidproject/mantid>.
+    Code Documentation is available at: <http://doxygen.mantidproject.org>
+*/
+#include "MantidKernel/System.h"
+
+#ifdef IN_MANTID_CATALOG
+#define MANTID_CATALOG_DLL DLLExport
+#define EXTERN_MANTID_CATALOG
+#else
+#define MANTID_CATALOG_DLL DLLImport
+#define EXTERN_MANTID_CATALOG EXTERN_IMPORT
+#endif /* IN_MANTID_CATALOG */
+
+#endif // MANTID_CATALOG_DLLCONFIG_H_
diff --git a/Framework/Catalog/inc/MantidCatalog/Exception.h b/Framework/Catalog/inc/MantidCatalog/Exception.h
new file mode 100644
index 0000000000000000000000000000000000000000..1814c4deee8e1f6c8e512227bde4029167a93e91
--- /dev/null
+++ b/Framework/Catalog/inc/MantidCatalog/Exception.h
@@ -0,0 +1,50 @@
+#ifndef MANTID_CATALOG_EXCEPTION_H_
+#define MANTID_CATALOG_EXCEPTION_H_
+
+#include <stdexcept>
+
+namespace Mantid {
+namespace Catalog {
+namespace Exception {
+
+class CatalogError : public std::runtime_error {
+public:
+  explicit CatalogError(const std::string & message) :
+    std::runtime_error(message) {}
+};
+
+class InvalidCredentialsError final : public CatalogError {
+public:
+  explicit InvalidCredentialsError(const std::string & message) :
+    CatalogError(message) {}
+};
+
+class TokenRejectedError final : public CatalogError {
+public:
+  explicit TokenRejectedError(const std::string & message) :
+    CatalogError(message) {}
+};
+
+class TokenParsingError final : public CatalogError {
+public:
+  explicit TokenParsingError(const std::string & message) :
+    CatalogError(message) {}
+};
+
+class InvalidRefreshTokenError final : public CatalogError {
+public:
+  explicit InvalidRefreshTokenError(const std::string & message) :
+    CatalogError(message) {}
+};
+
+class MalformedRepresentationError final : public CatalogError {
+public:
+  explicit MalformedRepresentationError(const std::string & message) :
+    CatalogError(message) {}
+};
+
+} // namespace Exception
+} // namespace Catalog
+} // namespace Mantid
+
+#endif // MANTID_CATALOG_EXCEPTION_H_
diff --git a/Framework/Catalog/inc/MantidCatalog/OAuth.h b/Framework/Catalog/inc/MantidCatalog/OAuth.h
new file mode 100644
index 0000000000000000000000000000000000000000..d3182a5e82e79f3e33fb7bc769663e0161fa2b90
--- /dev/null
+++ b/Framework/Catalog/inc/MantidCatalog/OAuth.h
@@ -0,0 +1,112 @@
+#ifndef MANTID_CATALOG_OAUTH_H_
+#define MANTID_CATALOG_OAUTH_H_
+
+#include "MantidCatalog/DllConfig.h"
+#include "MantidKernel/make_unique.h"
+#include "MantidKernel/DateAndTime.h"
+
+#include <boost/optional.hpp>
+
+namespace Mantid {
+namespace Catalog {
+namespace OAuth {
+
+using Types::Core::DateAndTime;
+
+/**
+  Classes providing basic Client Credentials / Resource Owner Credentials
+  OAuth functionality.
+
+  To be used by other cataloging classes and so it should not be necessary
+  to use this directly anywhere else.
+
+  @author Peter Parker
+  @date 2018
+
+  Copyright &copy; 2017 ISIS Rutherford Appleton Laboratory, NScD Oak Ridge
+  National Laboratory & European Spallation Source
+
+  This file is part of Mantid.
+
+  Mantid is free software; you can redistribute it and/or modify
+  it under the terms of the GNU General Public License as published by
+  the Free Software Foundation; either version 3 of the License, or
+  (at your option) any later version.
+
+  Mantid is distributed in the hope that it will be useful,
+  but WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  GNU General Public License for more details.
+
+  You should have received a copy of the GNU General Public License
+  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+  File change history is stored at: <https://github.com/mantidproject/mantid>
+  Code Documentation is available at: <http://doxygen.mantidproject.org>
+*/
+
+enum class OAuthFlow : uint8_t {
+  CLIENT_CREDENTIALS,
+  RESOURCE_OWNER_CREDENTIALS
+};
+
+class MANTID_CATALOG_DLL OAuthToken {
+public:
+  OAuthToken() = delete;
+  OAuthToken(
+    const std::string & tokenType,
+    int expiresIn,
+    const std::string & accessToken,
+    const std::string & scope,
+    const boost::optional<std::string> & refreshToken
+  );
+  ~OAuthToken();
+
+  std::string tokenType() const;
+  int expiresIn() const;
+  std::string accessToken() const;
+  std::string scope() const;
+  boost::optional<std::string> refreshToken() const;
+
+  bool isExpired() const;
+  bool isExpired(const DateAndTime & currentTime) const;
+
+  static OAuthToken fromJSONStream(std::istream & tokenStringStream);
+
+private:
+  DateAndTime m_expiresAt;
+
+  std::string m_tokenType;
+  int m_expiresIn;
+  std::string m_accessToken;
+  std::string m_scope;
+  boost::optional<std::string> m_refreshToken;
+};
+
+class MANTID_CATALOG_DLL IOAuthTokenStore {
+public:
+  virtual void setToken(const boost::optional<OAuthToken> & token) = 0;
+  virtual boost::optional<OAuthToken> getToken() = 0;
+};
+
+class MANTID_CATALOG_DLL ConfigServiceTokenStore : public IOAuthTokenStore {
+public:
+  ConfigServiceTokenStore() = default;
+  ConfigServiceTokenStore & operator=(
+    const ConfigServiceTokenStore& other
+  ) = default;
+  ~ConfigServiceTokenStore();
+
+  void setToken(const boost::optional<OAuthToken> & token) override;
+  boost::optional<OAuthToken> getToken() override;
+};
+
+using IOAuthTokenStore_uptr = std::unique_ptr<IOAuthTokenStore>;
+using IOAuthTokenStore_sptr = std::shared_ptr<IOAuthTokenStore>;
+using ConfigServiceTokenStore_uptr = std::unique_ptr<ConfigServiceTokenStore>;
+
+} // namespace OAuth
+} // namespace Catalog
+} // namespace Mantid
+
+#endif /* MANTID_CATALOG_OAUTH_H_ */
diff --git a/Framework/Catalog/inc/MantidCatalog/ONCat.h b/Framework/Catalog/inc/MantidCatalog/ONCat.h
new file mode 100644
index 0000000000000000000000000000000000000000..33a99002ef71e7bc40709a843f2a9074d578c9d0
--- /dev/null
+++ b/Framework/Catalog/inc/MantidCatalog/ONCat.h
@@ -0,0 +1,167 @@
+#ifndef MANTID_CATALOG_ONCAT_H_
+#define MANTID_CATALOG_ONCAT_H_
+
+#include "MantidCatalog/DllConfig.h"
+#include "MantidCatalog/OAuth.h"
+#include "MantidCatalog/ONCatEntity.h"
+#include "MantidKernel/DateAndTime.h"
+#include "MantidKernel/make_unique.h"
+
+#include <vector>
+
+#include <boost/optional.hpp>
+
+namespace Mantid {
+
+namespace Kernel {
+class InternetHelper;
+}
+
+namespace Catalog {
+namespace ONCat {
+
+using Mantid::Catalog::OAuth::OAuthFlow;
+using Mantid::Catalog::OAuth::IOAuthTokenStore_uptr;
+using Mantid::Catalog::OAuth::IOAuthTokenStore_sptr;
+using Types::Core::DateAndTime;
+
+// Here we use a vector of pairs rather than a map because we would like
+// the ability to set a parameter with a given name more than once --
+// this denotes an arrayed parameter.
+using QueryParameter = std::pair<std::string, std::string>;
+using QueryParameters = std::vector<QueryParameter>;
+
+/**
+  The main class to be used when interacting with ONCat from C++.  It can
+  be used to retrieve "entities" from REST-like "resources".  Please refer
+  to the API documentation at https://oncat.ornl.gov/#/build for more
+  information about each resource.
+
+  Rather than use constructors, the helper method ONCat::fromMantidSettings()
+  is strongly recommended.  This will create an ONCat object taking into
+  account the authentication settings configured in a given instance of
+  Mantid.
+
+  Creation of an ONCat object can be done as follows:
+
+      auto oncat = ONCat::fromMantidSettings();
+
+  Once you have that, logging in either assumes that a client ID and
+  client secret have been added to the Mantid.local.properties file (this
+  essentially allows machine-to-machine authentication for a use case like
+  auto-reduction, and there is no explicit "login" step), or that a user
+  is somehow prompted for their ORNL XCAMS / UCAMS username and password,
+  or that you will only be accessing unauthenticated resources.  If an
+  explicit login step is necessary it should look something like this:
+
+      oncat.login("some_user", "a_password");
+
+  From then on, basic usage is as follows:
+
+      // Get a list of the experiments for NOMAD, specifying the fields
+      // we are interested in as a "projection".
+      const auto nomadExperiments = oncat.list("api", "experiments", {
+        QueryParameter("facility", "SNS"),
+        QueryParameter("instrument", "NOM"),
+        QueryParameter("projection", "name"),
+        QueryParameter("projection", "size")
+      });
+
+      // Print out the IPTS numbers of each one.
+      for (const auto & experiment : nomadExperiments) {
+        std::cout
+          << *experiment.asString("name") << " has "
+          << *experiment.asInt("size") << " ingested datafiles.";
+      }
+
+  For logged in users, no further credential prompting should be required
+  as part of the standard workflow, although you should be prepared for
+  an authenticated user to have their tokens invalidated  *eventually*.
+  At that point, any call to the API will fail, and an error will be written
+  to the log asking them to login again.
+
+  @author Peter Parker
+  @date 2018
+
+  Copyright &copy; 2017 ISIS Rutherford Appleton Laboratory, NScD Oak Ridge
+  National Laboratory & European Spallation Source
+
+  This file is part of Mantid.
+
+  Mantid is free software; you can redistribute it and/or modify
+  it under the terms of the GNU General Public License as published by
+  the Free Software Foundation; either version 3 of the License, or
+  (at your option) any later version.
+
+  Mantid is distributed in the hope that it will be useful,
+  but WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  GNU General Public License for more details.
+
+  You should have received a copy of the GNU General Public License
+  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+  File change history is stored at: <https://github.com/mantidproject/mantid>
+  Code Documentation is available at: <http://doxygen.mantidproject.org>
+*/
+class MANTID_CATALOG_DLL ONCat {
+public:
+  static ONCat fromMantidSettings();
+
+  ONCat() = delete;
+  ONCat(const ONCat & other);
+  ~ONCat();
+
+  bool isUserLoggedIn() const;
+
+  void login(const std::string & username, const std::string & password);
+  void logout();
+
+  ONCatEntity retrieve(
+    const std::string & resourceNamespace,
+    const std::string & resource,
+    const std::string & identifier,
+    const QueryParameters & queryParameters);
+  std::vector<ONCatEntity> list(
+    const std::string & resourceNamespace,
+    const std::string & resource,
+    const QueryParameters & queryParameters);
+
+  //////////////////////////////////////////////////////////////////////
+  // Exposed publicly for testing purposes only.
+  //////////////////////////////////////////////////////////////////////
+  ONCat(
+    const std::string & url,
+    IOAuthTokenStore_uptr tokenStore,
+    OAuthFlow flow,
+    const std::string & clientId,
+    const boost::optional<std::string> & clientSecret = boost::none
+  );
+  void refreshTokenIfNeeded();
+  void refreshTokenIfNeeded(const DateAndTime & currentTime);
+  void setInternetHelper(
+    std::unique_ptr<Mantid::Kernel::InternetHelper> internetHelper
+  );
+  //////////////////////////////////////////////////////////////////////
+
+private:
+  void sendAPIRequest(
+    const std::string & uri,
+    const QueryParameters & queryParameters,
+    std::ostream & response
+  );
+
+  std::string m_url;
+  IOAuthTokenStore_sptr m_tokenStore;
+  std::string m_clientId;
+  boost::optional<std::string> m_clientSecret;
+
+  OAuthFlow m_flow;
+  std::unique_ptr<Mantid::Kernel::InternetHelper> m_internetHelper;
+};
+
+} // namespace ONCat
+} // namespace Catalog
+} // namespace Mantid
+
+#endif /* MANTID_CATALOG_ONCAT_H_ */
diff --git a/Framework/Catalog/inc/MantidCatalog/ONCatEntity.h b/Framework/Catalog/inc/MantidCatalog/ONCatEntity.h
new file mode 100644
index 0000000000000000000000000000000000000000..aa60ceae493179f2df5bb3482a255c23fe93ea21
--- /dev/null
+++ b/Framework/Catalog/inc/MantidCatalog/ONCatEntity.h
@@ -0,0 +1,121 @@
+#ifndef MANTID_CATALOG_ONCATENTITY_H_
+#define MANTID_CATALOG_ONCATENTITY_H_
+
+#include "MantidCatalog/DllConfig.h"
+#include "MantidKernel/make_unique.h"
+
+#include <vector>
+
+#include <boost/optional.hpp>
+
+namespace Json {
+  class Value;
+}
+
+namespace Mantid {
+namespace Catalog {
+namespace ONCat {
+
+using Content = Json::Value;
+using Content_uptr = std::unique_ptr<Content>;
+
+/**
+  A class to encapsulate the "entity" responses received from the ONCat API.
+
+  An ONCatEntity object (or a vector of objects) can be constructed when
+  given an istream, which is assumed to contain JSON information as defined
+  in the API documentation at https://oncat.ornl.gov/#/build.
+
+  Note that there are only two fields shared across all API entity types:
+  "id" and "type".  Further, all other fields can be optionally disabled
+  through the use of "projections", and a certain subset of fields may even
+  be completely missing for a given file because of the dynamic nature of
+  metadata resulting from Data Acquisition software changes.
+
+  For this reason, all other metadata will be retrieved in a way that
+  forces you to deal with the case where the field in question is not there.
+  There are two ways of doing this: the first is to specify a default value
+  to be used when a value is not present, and the second is to check for a
+  result on a boost::optional.
+
+  However, if your projection is such that you *know* a field will be present
+  (note that most fields on API resources will always be returned as long
+  as they are requested as part of a projection, for example the "location"
+  field of the Datafile resource), then feel free to assume it will be
+  there and resolve the boost::optional without checking for a result.
+
+  @author Peter Parker
+  @date 2018
+
+  Copyright &copy; 2017 ISIS Rutherford Appleton Laboratory, NScD Oak Ridge
+  National Laboratory & European Spallation Source
+
+  This file is part of Mantid.
+
+  Mantid is free software; you can redistribute it and/or modify
+  it under the terms of the GNU General Public License as published by
+  the Free Software Foundation; either version 3 of the License, or
+  (at your option) any later version.
+
+  Mantid is distributed in the hope that it will be useful,
+  but WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  GNU General Public License for more details.
+
+  You should have received a copy of the GNU General Public License
+  along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+  File change history is stored at: <https://github.com/mantidproject/mantid>
+  Code Documentation is available at: <http://doxygen.mantidproject.org>
+*/
+class MANTID_CATALOG_DLL ONCatEntity {
+public:
+  ONCatEntity() = delete;
+  ONCatEntity(const ONCatEntity &);
+  ~ONCatEntity();
+
+  // These are the only two fields the ONCat API guarantees will be
+  // there across *all* entity types.
+  std::string id() const;
+  std::string type() const;
+
+  // So, you can either supply a default value for when the field you
+  // want is not there ...
+  std::string asString(
+    const std::string & path, const std::string defaultValue) const;
+  int asInt(const std::string & path, int defaultValue) const;
+  float asFloat(const std::string & path, float defaultValue) const;
+  double asDouble(const std::string & path, double defaultValue) const;
+  bool asBool(const std::string & path, bool defaultValue) const;
+
+  // ... or, write conditional logic around boost's optional results.
+  boost::optional<std::string> asString(const std::string & path) const;
+  boost::optional<int> asInt(const std::string & path) const;
+  boost::optional<float> asFloat(const std::string & path) const;
+  boost::optional<double> asDouble(const std::string & path) const;
+  boost::optional<bool> asBool(const std::string & path) const;
+
+  std::string toString() const;
+
+  static ONCatEntity fromJSONStream(
+    std::istream & streamContent);
+  static std::vector<ONCatEntity> vectorFromJSONStream(
+    std::istream & streamContent);
+
+private:
+  ONCatEntity(
+    const std::string & id,
+    const std::string & type,
+    Content_uptr content
+  );
+
+  std::string m_id;
+  std::string m_type;
+  Content_uptr m_content;
+};
+
+} // namespace ONCat
+} // namespace Catalog
+} // namespace Mantid
+
+#endif /* MANTID_CATALOG_ONCATENTITY_H_ */
diff --git a/Framework/Catalog/src/OAuth.cpp b/Framework/Catalog/src/OAuth.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..8adfd8e825848ba035d0c9df7c4c1ff2ffeb2931
--- /dev/null
+++ b/Framework/Catalog/src/OAuth.cpp
@@ -0,0 +1,192 @@
+#include "MantidCatalog/OAuth.h"
+#include "MantidCatalog/Exception.h"
+#include "MantidKernel/ConfigService.h"
+
+#include <sstream>
+
+#include <Poco/Net/HTMLForm.h>
+#include <Poco/Net/HTTPResponse.h>
+
+#include <json/json.h>
+
+namespace Mantid {
+namespace Catalog {
+namespace OAuth {
+
+using Mantid::Catalog::Exception::TokenParsingError;
+
+//----------------------------------------------------------------------
+// OAuthToken
+//----------------------------------------------------------------------
+
+OAuthToken::OAuthToken(
+  const std::string & tokenType,
+  int expiresIn,
+  const std::string & accessToken,
+  const std::string & scope,
+  const boost::optional<std::string> & refreshToken
+) :
+  m_expiresAt(DateAndTime::getCurrentTime() + static_cast<double>(expiresIn)),
+  m_tokenType(tokenType),
+  m_expiresIn(expiresIn),
+  m_accessToken(accessToken),
+  m_scope(scope),
+  m_refreshToken(refreshToken) {}
+
+OAuthToken::~OAuthToken() {}
+
+OAuthToken OAuthToken::fromJSONStream(
+  std::istream & tokenStringStream
+) {
+  try {
+    Json::Value full_token;
+    tokenStringStream >> full_token;
+    
+    const auto tokenType = full_token["token_type"].asString();
+    const auto expiresIn =
+      static_cast<unsigned int>(full_token["expires_in"].asUInt());
+    const auto accessToken = full_token["access_token"].asString();
+    const auto scope = full_token["scope"].asString();
+
+    const auto parsedRefreshToken = full_token["refresh_token"].asString();
+    const boost::optional<std::string> refreshToken =
+      parsedRefreshToken == "" ?
+      boost::none :
+      boost::optional<std::string>(parsedRefreshToken);
+
+    return OAuthToken(
+      tokenType,
+      expiresIn,
+      accessToken,
+      scope,
+      refreshToken
+    );
+  } catch (...) {
+    throw TokenParsingError(
+      "Unable to parse authentication token!"
+    );
+  }
+}
+
+bool OAuthToken::isExpired() const {
+  return isExpired(DateAndTime::getCurrentTime());
+}
+
+bool OAuthToken::isExpired(const DateAndTime & currentTime) const {
+  return currentTime > m_expiresAt;
+}
+
+std::string OAuthToken::tokenType() const {
+  return m_tokenType;
+}
+
+int OAuthToken::expiresIn() const {
+  return m_expiresIn;
+}
+
+std::string OAuthToken::accessToken() const {
+  return m_accessToken;
+}
+
+std::string OAuthToken::scope() const {
+  return m_scope;
+}
+
+boost::optional<std::string> OAuthToken::refreshToken() const {
+  return m_refreshToken;
+}
+
+//----------------------------------------------------------------------
+// ConfigServiceTokenStore
+//----------------------------------------------------------------------
+
+namespace {
+  static const std::string CONFIG_PATH_BASE = "catalog.oncat.token.";
+}
+
+ConfigServiceTokenStore::~ConfigServiceTokenStore() {
+  try {
+    // Here we attempt to persist our OAuth token to disk before the
+    // up-until-now only-in-memory token store is destroyed.
+    //
+    // I don't believe this is a great solution.  Some things to
+    // consider:
+    //
+    // * We have to save the *entire* contents of the config.  This may
+    //   not be desirable.
+    // * Ideally we would persist on every token set.
+    auto & config = Mantid::Kernel::ConfigService::Instance();
+    config.saveConfig(config.getUserFilename());
+  } catch (...) {
+    // It's not the end of the world if there was an error persisting
+    // the token (the worst that could happen is a user has to login
+    // again), but it *is* the end of the world if we seg fault.
+  }
+}
+
+void ConfigServiceTokenStore::setToken(
+  const boost::optional<OAuthToken> & token
+) {
+  auto & config = Mantid::Kernel::ConfigService::Instance();
+
+  if (token) {
+    config.setString(CONFIG_PATH_BASE + "tokenType", token->tokenType());
+    config.setString(
+      CONFIG_PATH_BASE + "expiresIn",
+      std::to_string(token->expiresIn())
+    );
+    config.setString(CONFIG_PATH_BASE + "accessToken", token->accessToken());
+    config.setString(CONFIG_PATH_BASE + "scope", token->scope());
+    config.setString(
+      CONFIG_PATH_BASE + "refreshToken",
+      token->refreshToken() ? *token->refreshToken() : std::string("")
+    );
+  } else {
+    config.setString(CONFIG_PATH_BASE + "tokenType", "");
+    config.setString(CONFIG_PATH_BASE + "expiresIn", "");
+    config.setString(CONFIG_PATH_BASE + "accessToken", "");
+    config.setString(CONFIG_PATH_BASE + "scope", "");
+    config.setString(CONFIG_PATH_BASE + "refreshToken", "");
+  }
+}
+
+boost::optional<OAuthToken> ConfigServiceTokenStore::getToken() {
+  auto & config = Mantid::Kernel::ConfigService::Instance();
+
+  const auto tokenType = config.getString(CONFIG_PATH_BASE + "tokenType");
+  const auto expiresIn = config.getString(CONFIG_PATH_BASE + "expiresIn");
+  const auto accessToken = config.getString(
+    CONFIG_PATH_BASE + "accessToken"
+  );
+  const auto scope = config.getString(CONFIG_PATH_BASE + "scope");
+  const auto refreshToken = config.getString(
+    CONFIG_PATH_BASE + "refreshToken"
+  );
+
+  // A partially written-out token is useless and is therefore
+  // effectively the same as a token not having been written out at
+  // all.  So, it's all or nothing (excluding the refresh token of
+  // course, which is not present for all OAuth flows).
+  if (tokenType == "" || expiresIn == "" || accessToken == "" || scope == "") {
+    return boost::none;
+  }
+
+  try {
+    return boost::make_optional(OAuthToken(
+      tokenType,
+      std::stoi(expiresIn),
+      accessToken,
+      scope,
+      boost::make_optional(refreshToken != "", refreshToken)
+    ));
+  } catch (std::invalid_argument &) {
+    // Catching any std::stoi failures silently -- a malformed token is
+    // useless and may as well not be there.
+  }
+
+  return boost::none;
+}
+
+} // namespace OAuth
+} // namespace Catalog
+} // namespace Mantid
diff --git a/Framework/Catalog/src/ONCat.cpp b/Framework/Catalog/src/ONCat.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c0c02839f8f425e5566b94cdf740b0190f7c3e43
--- /dev/null
+++ b/Framework/Catalog/src/ONCat.cpp
@@ -0,0 +1,476 @@
+#include "MantidCatalog/ONCat.h"
+#include "MantidCatalog/OAuth.h"
+#include "MantidCatalog/Exception.h"
+#include "MantidKernel/ConfigService.h"
+#include "MantidKernel/Exception.h"
+#include "MantidKernel/InternetHelper.h"
+#include "MantidKernel/Logger.h"
+#include "MantidKernel/make_unique.h"
+
+#include <algorithm>
+#include <iostream>
+#include <sstream>
+
+#include <boost/algorithm/string/join.hpp>
+
+#include <Poco/Net/HTMLForm.h>
+#include <Poco/Net/HTTPResponse.h>
+
+namespace Mantid {
+namespace Catalog {
+namespace ONCat {
+
+using Poco::Net::HTTPResponse;
+
+using Mantid::Catalog::OAuth::ConfigServiceTokenStore;
+using Mantid::Catalog::OAuth::OAuthToken;
+using Mantid::Catalog::ONCat::ONCatEntity;
+using Mantid::Catalog::Exception::CatalogError;
+using Mantid::Catalog::Exception::InvalidCredentialsError;
+using Mantid::Catalog::Exception::InvalidRefreshTokenError;
+using Mantid::Catalog::Exception::TokenRejectedError;
+using Mantid::Kernel::Exception::InternetError;
+
+namespace {
+Mantid::Kernel::Logger g_log("ONCat");
+
+static const std::string CONFIG_PATH_BASE = "catalog.oncat.";
+// It could be argued that this should be read in from Facilities.xml or
+// similar, but I will put this off for now as it is unclear how to
+// reconcile ONCat's functionality with the current <soapendpoint> /
+// <externaldownload> tags in the XML.
+static const std::string DEFAULT_ONCAT_URL = "https://oncat.ornl.gov";
+static const std::string DEFAULT_CLIENT_ID =
+  "d16ea847-41ce-4b30-9167-40298588e755";
+}
+
+/**
+ * Constructs an ONCat object based on various settings gathered from
+ * the ConfigService.
+ *
+ * The resulting object will work with resources that require authorization
+ * in one of two possible modes:
+ *
+ * User Login Mode (Default)
+ * -------------------------
+ *
+ * Users must log in with their UCAMS / XCAMS credentials before calls
+ * to the ONCat API can be made.  This mode should work "out of the box"
+ * (requires no changes to config files), and is the default mode of
+ * operation.  User access to API information is governed by the same
+ * LDAP instance that controls file system access, so users should only
+ * see the experiment data they are allowed to see.
+ *
+ * This mode uses the "Resource Owner Credentials" OAuth flow.
+ *
+ * Machine-to-Machine Mode
+ * -----------------------
+ *
+ * No user login is necessary, but for it to be enabled a client ID and
+ * secret must exist in the ConfigService.  Recommended practice would
+ * be to add the following two entries to the Mantid.local.properties
+ * file on the machine to be given access, using the credentials issued
+ * by the ONCat administrator for the values:
+ *
+ *     catalog.oncat.client_id = "[CLIENT ID]"
+ *     catalog.oncat.client_secret = "[CLIENT SECRET]"
+ *
+ * API read access is completely unrestricted in this mode, which is
+ * intended for autoreduction use cases or similar.
+ *
+ * This mode uses the "Client Credentials" OAuth flow.
+ *
+ * @return The constructed ONCat object.
+ */
+ONCat ONCat::fromMantidSettings() {
+  auto & config = Mantid::Kernel::ConfigService::Instance();
+  const auto client_id = config.getString(CONFIG_PATH_BASE + "client_id");
+  const auto client_secret =
+    config.getString(CONFIG_PATH_BASE + "client_secret");
+  const bool hasClientCredentials = client_id != "" && client_secret != "";
+
+  if (hasClientCredentials) {
+    g_log.debug()
+      << "Found client credentials in Mantid.local.properties.  "
+      << "No user login required."
+      << std::endl;
+  } else {
+    g_log.debug()
+      << "Could not find client credentials in Mantid.local.properties.  "
+      << "Falling back to default -- user login required."
+      << std::endl;
+  }
+
+  return ONCat(
+    DEFAULT_ONCAT_URL,
+    Mantid::Kernel::make_unique<ConfigServiceTokenStore>(),
+    hasClientCredentials
+      ? OAuthFlow::CLIENT_CREDENTIALS
+      : OAuthFlow::RESOURCE_OWNER_CREDENTIALS,
+    hasClientCredentials ? client_id : DEFAULT_CLIENT_ID,
+    boost::make_optional(hasClientCredentials, client_secret)
+  );
+}
+
+ONCat::ONCat(
+  const std::string & url,
+  IOAuthTokenStore_uptr tokenStore,
+  OAuthFlow flow,
+  const std::string & clientId,
+  const boost::optional<std::string> & clientSecret
+) :
+  m_url(url),
+  m_tokenStore(std::move(tokenStore)),
+  m_clientId(clientId),
+  m_clientSecret(clientSecret),
+
+  m_flow(flow),
+  m_internetHelper(new Mantid::Kernel::InternetHelper()) {}
+
+ONCat::ONCat(const ONCat & other) :
+  m_url(other.m_url),
+  m_tokenStore(other.m_tokenStore),
+  m_clientId(other.m_clientId),
+  m_clientSecret(other.m_clientSecret),
+
+  m_flow(other.m_flow),
+  m_internetHelper(new Mantid::Kernel::InternetHelper()) {}
+
+ONCat::~ONCat() {}
+
+/**
+ * Whether or not a user is currently logged in.  (Not relevant when
+ * using machine-to-machine authentication as part of the Client
+ * Credentials flow, and not required when accessing unauthenticated
+ * parts of the API.)
+ *
+ * Something to bear in mind is that the term "logged in" is used quite
+ * loosely here.  In an OAuth context it roughly equates to, "there is a
+ * token stored locally", which is not quite the same thing. This may
+ * sound strange, but consider the following:
+ *
+ * - Tokens expire after a given amount of time, at which point they can
+ *   be "refreshed".  A successful token refresh happens behind the
+ *   scenes without the user even knowing it took place.
+ *
+ * - While it is possible to tell when a token needs to be refreshed,
+ *   token refreshes are not always successful.  If they fail then the
+ *   client must prompt the user to enter their credentials again.
+ *
+ * - There is no way for the client to know whether or not the refresh
+ *   will be successful ahead of time (i.e., whether a token has been
+ *   revoked server-side), since the OAuth spec provides no mechanism to
+ *   check the validity of a refresh token.
+ *
+ * - Tokens can be revoked at any time with absolutely no notice as part
+ *   of standard OAuth practice.  Also, only a limited number of tokens
+ *   can exist for each unique client / user combination at any one
+ *   time.
+ *
+ * Hopefully it is clear that working with OAuth client-side requires
+ * you to use an almost-Pythonic "ask for forgiveness rather than for
+ * permission" strategy -- i.e., code as if locally-stored tokens can be
+ * refreshed, but be ready to prompt the user for their credentials if
+ * the refresh fails.
+ *
+ * Some useful links with related information:
+ *
+ * - http://qr.ae/TUTke2 (quora.com)
+ * - https://stackoverflow.com/a/30826806/778572
+ *
+ * @param true if a user is "logged in", else false.
+ */
+bool ONCat::isUserLoggedIn() const {
+  if (m_flow == OAuthFlow::CLIENT_CREDENTIALS) {
+    // No users are ever authenticated as part of this flow.
+    return false;
+  }
+
+  return m_tokenStore->getToken();
+}
+
+void ONCat::logout() {
+  // Currently, ONCat OAuth does *not* allow clients to revoke tokens
+  // that are no longer needed (though this is defined in the OAtuh
+  // spec).  A "logout", then, is simply throwing away whatever token we
+  // previously stored client-side.
+  m_tokenStore->setToken(boost::none);
+  g_log.debug() << "Logging out." << std::endl;;
+}
+
+/**
+ * Log in as part of the Resource Ownder Credentials flow so that
+ * authenticated resources may be accessed on behalf of a user.
+ *
+ * @param username : The XCAMS / UCAMS ID of the user.
+ * @param password : The XCAMS / UCAMS password of the user.
+ *
+ * @exception Mantid::Catalog::Exception::InvalidCredentialsError :
+ *   Thrown when the given credentials are not valid.
+ */
+void ONCat::login(
+  const std::string & username, const std::string & password
+) {
+  if (m_flow != OAuthFlow::RESOURCE_OWNER_CREDENTIALS) {
+    g_log.warning()
+      << "Unexpected usage detected!  "
+      << "Logging in with user credentials in not required (and is not "
+      << "supported) when machine-to-machine credentials are being used."
+      << std::endl;;
+    return;
+  }
+
+  Poco::Net::HTMLForm form(Poco::Net::HTMLForm::ENCODING_MULTIPART);
+  form.set("username", username);
+  form.set("password", password);
+  form.set("client_id", m_clientId);
+  if (m_clientSecret) {
+    form.set("client_secret", m_clientSecret.get());
+  }
+  form.set("grant_type", "password");
+
+  m_internetHelper->setBody(form);
+
+  try {
+    std::stringstream ss;
+
+    const int statusCode = m_internetHelper->sendRequest(
+      m_url + "/oauth/token", ss
+    );
+
+    if (statusCode == HTTPResponse::HTTP_OK) {
+      m_tokenStore->setToken(OAuthToken::fromJSONStream(ss));
+    }
+
+    g_log.debug() << "Login was successful!" << std::endl;;
+  } catch (InternetError & ie) {
+    if (ie.errorCode() == HTTPResponse::HTTP_UNAUTHORIZED) {
+      throw InvalidCredentialsError(
+        "Invalid UCAMS / XCAMS credentials used for ONCat login."
+      );
+    }
+    throw CatalogError(ie.what());
+  }
+}
+
+/**
+ * Retrieve a single entity from the given resource (in the given namespace) of
+ * ONCat's API.
+ *
+ * Please see https://oncat.ornl.gov/#/build for more information about the
+ * currently-available resources, and what query parameters they allow.
+ *
+ * @param identifier :
+ *   The ID or name that uniquely identifies the entity.
+ * @param resourceNamespace :
+ *   The "namespace" of the resource.  The most common, "core" resources all
+ *   belong to the "api" namespace.
+ * @param resource :
+ *   The name of the resource to retrieve the entity from.  I.e., "Datafile"
+ *   entities can be retrieved from the "datafiles" resource.
+ * @param queryParameters :
+ *   The name-value-pair query-string parameters.
+ *
+ * @return The response from the API in the form on an ONCatEntity object.
+ *
+ * @exception Mantid::Catalog::Exception::CatalogError
+ */
+ONCatEntity ONCat::retrieve(
+  const std::string & resourceNamespace,
+  const std::string & resource,
+  const std::string & identifier,
+  const QueryParameters & queryParameters
+) {
+  const auto uri =
+    m_url + "/" + resourceNamespace + "/" + resource + "/" + identifier;
+  std::stringstream ss;
+
+  sendAPIRequest(uri, queryParameters, ss);
+
+  return ONCatEntity::fromJSONStream(ss);
+}
+
+/**
+ * Retrieve a collection of entities from the given resource (in the given
+ * namespace) of ONCat's API.
+ *
+ * Please see retrieve documentation for more info.
+ */
+std::vector<ONCatEntity> ONCat::list(
+  const std::string & resourceNamespace,
+  const std::string & resource,
+  const QueryParameters & queryParameters
+) {
+  const auto uri =
+    m_url + "/" + resourceNamespace + "/" + resource;
+  std::stringstream ss;
+
+  sendAPIRequest(uri, queryParameters, ss);
+
+  return ONCatEntity::vectorFromJSONStream(ss);
+}
+
+/**
+ * Refresh the current token if it has expired (and if it actually exists).
+ *
+ * To be called behind-the-scenes before each API query, so that we know
+ * our tokens are up-to-date before being used.
+ *
+ * @exception Mantid::Catalog::Exception::InvalidCredentialsError :
+ *   Thrown when the provider decides the current token cannot be refreshed.
+ */
+void ONCat::refreshTokenIfNeeded() {
+  refreshTokenIfNeeded(DateAndTime::getCurrentTime());
+}
+
+/**
+ * See overloaded method.
+ *
+ * @param currentTime : Used in testing to specify a different time.
+ *
+ * @exception Mantid::Catalog::Exception::InvalidRefreshTokenError :
+ *   Thrown when the provider decides the current token cannot be refreshed.
+ */
+void ONCat::refreshTokenIfNeeded(const DateAndTime & currentTime) {
+  const auto currentToken = m_tokenStore->getToken();
+
+  if (m_flow == OAuthFlow::CLIENT_CREDENTIALS) {
+    if (currentToken && !currentToken->isExpired(currentTime)) { return; }
+
+    Poco::Net::HTMLForm form(Poco::Net::HTMLForm::ENCODING_MULTIPART);
+    form.set("client_id", m_clientId);
+    if (m_clientSecret) {
+      form.set("client_secret", m_clientSecret.get());
+    }
+    form.set("grant_type", "client_credentials");
+
+    m_internetHelper->reset();
+    m_internetHelper->setBody(form);
+
+    try {
+      std::stringstream ss;
+
+      const int statusCode = m_internetHelper->sendRequest(
+        m_url + "/oauth/token", ss
+      );
+
+      if (statusCode == HTTPResponse::HTTP_OK) {
+        m_tokenStore->setToken(OAuthToken::fromJSONStream(ss));
+      }
+      g_log.debug() << "Token successfully refreshed." << std::endl;
+    } catch (InternetError & ie) {
+      throw CatalogError(ie.what());
+    }
+  } else if (m_flow == OAuthFlow::RESOURCE_OWNER_CREDENTIALS) {
+    if (!currentToken) { return; }
+    if (!currentToken->isExpired(currentTime)) { return; }
+    const auto currentRefreshToken = currentToken->refreshToken();
+    if (!currentRefreshToken) { return; }
+
+    Poco::Net::HTMLForm form(Poco::Net::HTMLForm::ENCODING_MULTIPART);
+    form.set("client_id", m_clientId);
+    if (m_clientSecret) {
+      form.set("client_secret", m_clientSecret.get());
+    }
+    form.set("grant_type", "refresh_token");
+    form.set("refresh_token", currentRefreshToken.get());
+
+    m_internetHelper->reset();
+    m_internetHelper->setBody(form);
+
+    try {
+      std::stringstream ss;
+
+      const int statusCode = m_internetHelper->sendRequest(
+        m_url + "/oauth/token", ss
+      );
+
+      if (statusCode == HTTPResponse::HTTP_OK) {
+        m_tokenStore->setToken(OAuthToken::fromJSONStream(ss));
+      }
+      g_log.debug() << "Token successfully refreshed." << std::endl;
+    } catch (InternetError & ie) {
+      if (ie.errorCode() == HTTPResponse::HTTP_UNAUTHORIZED) {
+        // As per OAuth spec, when a refresh token is no longer valid, we
+        // can consider ourselves logged out.
+        logout();
+        throw InvalidRefreshTokenError(
+          "You have been logged out.  Please login again."
+        );
+      }
+      throw CatalogError(ie.what());
+    }
+  }
+}
+
+void ONCat::setInternetHelper(
+  std::unique_ptr<Mantid::Kernel::InternetHelper> internetHelper
+) {
+  m_internetHelper = std::move(internetHelper);
+}
+
+void ONCat::sendAPIRequest(
+  const std::string & uri,
+  const QueryParameters & queryParameters,
+  std::ostream & response
+) {
+  refreshTokenIfNeeded();
+
+  const auto tokenType = m_tokenStore->getToken()->tokenType();
+  const auto accessToken = m_tokenStore->getToken()->accessToken();
+
+  m_internetHelper->clearHeaders();
+  m_internetHelper->setMethod("GET");
+  m_internetHelper->addHeader(
+    "Authorization", tokenType + " " + accessToken
+  );
+
+  std::vector<std::string> queryStringParts(queryParameters.size());
+  std::transform(
+    queryParameters.begin(), queryParameters.end(),
+    queryStringParts.begin(),
+    [](const QueryParameter & queryParameter) -> std::string {
+      return queryParameter.first + "=" + queryParameter.second;
+    }
+  );
+  const auto queryString = boost::algorithm::join(queryStringParts, "&");
+  const auto url = queryString.size() == 0 ? uri : uri + "?" + queryString;
+
+  g_log.debug()
+    << "About to make a call to the following ONCat URL: " << url;
+
+  try {
+    m_internetHelper->sendRequest(url, response);
+  } catch (InternetError & ie) {
+    if (
+      ie.errorCode() == HTTPResponse::HTTP_UNAUTHORIZED
+    ) {
+      std::string errorMessage;
+      switch(m_flow) {
+        case OAuthFlow::RESOURCE_OWNER_CREDENTIALS :
+          errorMessage = "You have been logged out.  Please login again.";
+          break;
+        case OAuthFlow::CLIENT_CREDENTIALS :
+          errorMessage =
+            "The stored OAuth token appears to be invalid.  "
+            "There are a few cases where this might be expected, but in "
+            "principle this should rarely happen.  "
+            "Please try again and if the problem persists contact the "
+            "ONCat administrator at oncat-support@ornl.gov.";
+          break;
+      }
+      // The ONCat API does *not* leak information in the case where a
+      // resource exists but a user is not allowed access -- a 404 would
+      // always be returned instead.  So, if we ever get a 401, it is
+      // because our locally-stored token is no longer valid and we
+      // should log out.
+      logout();
+      throw TokenRejectedError(errorMessage);
+    }
+    throw CatalogError(ie.what());
+  }
+}
+
+} // namespace ONCat
+} // namespace Catalog
+} // namespace Mantid
diff --git a/Framework/Catalog/src/ONCatEntity.cpp b/Framework/Catalog/src/ONCatEntity.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..862cb6ff6a5aa45de902066cf75ca6a6cdd73ecb
--- /dev/null
+++ b/Framework/Catalog/src/ONCatEntity.cpp
@@ -0,0 +1,251 @@
+#include "MantidCatalog/Exception.h"
+#include "MantidCatalog/ONCatEntity.h"
+#include "MantidKernel/StringTokenizer.h"
+
+#include <iostream>
+#include <sstream>
+#include <json/json.h>
+
+namespace Mantid {
+namespace Catalog {
+namespace ONCat {
+
+using Mantid::Kernel::StringTokenizer;
+using Mantid::Kernel::make_unique;
+using Mantid::Catalog::Exception::MalformedRepresentationError;
+
+//----------------------------------------------------------------------
+// Anonymous Helpers
+//----------------------------------------------------------------------
+namespace {
+
+class ContentError : public std::runtime_error {
+public:
+  explicit ContentError(const std::string & message) :
+    std::runtime_error(message) {}
+};
+
+Content getNestedContent(
+  const Content & content, const std::string & path
+) {
+  const auto pathTokens = StringTokenizer(
+    path, ".", Mantid::Kernel::StringTokenizer::TOK_TRIM
+  );
+
+  auto currentNode = content;
+
+  // Use the path tokens to drill down through the JSON nodes.
+  for (
+    auto pathToken = pathTokens.cbegin();
+    pathToken != pathTokens.cend(); ++pathToken
+  ) {
+    if (!currentNode.isMember(*pathToken)) {
+      throw ContentError("");
+    }
+    currentNode = currentNode[*pathToken];
+  }
+
+  return currentNode;
+}
+
+template<typename T>
+T getNestedContentValueAsType(
+  const Content & content, const std::string & path
+);
+template<> std::string getNestedContentValueAsType(
+  const Content & content, const std::string & path
+) { return getNestedContent(content, path).asString(); }
+template<> int getNestedContentValueAsType(
+  const Content & content, const std::string & path
+) { return getNestedContent(content, path).asInt(); }
+template<> float getNestedContentValueAsType(
+  const Content & content, const std::string & path
+) { return getNestedContent(content, path).asFloat(); }
+template<> double getNestedContentValueAsType(
+  const Content & content, const std::string & path
+) { return getNestedContent(content, path).asDouble(); }
+template<> bool getNestedContentValueAsType(
+  const Content & content, const std::string & path
+) { return getNestedContent(content, path).asBool(); }
+
+template<typename T>
+T getNestedContentValueElseDefault(
+  const Content & content, const std::string & path, T defaultValue
+) {
+  try {
+    return getNestedContentValueAsType<T>(content, path);
+  } catch (ContentError & ce) {
+    return defaultValue;
+  }
+}
+
+template<typename T>
+boost::optional<T> getNestedContentValueIfPresent(
+  const Content & content, const std::string & path
+) {
+  try {
+    return boost::make_optional(getNestedContentValueAsType<T>(content, path));
+  } catch (ContentError & ce) {
+    return boost::none;
+  }
+}
+
+}
+
+//----------------------------------------------------------------------
+// ONCatEntity
+//----------------------------------------------------------------------
+
+ONCatEntity::ONCatEntity(
+  const std::string & id,
+  const std::string & type,
+  Content_uptr content
+) :
+  m_id(id),
+  m_type(type),
+  m_content(std::move(content)) {}
+
+ONCatEntity::ONCatEntity(const ONCatEntity & other) :
+  m_id(other.m_id),
+  m_type(other.m_type),
+  m_content(make_unique<Content>(*other.m_content)) {}
+
+ONCatEntity::~ONCatEntity() {}
+
+std::string ONCatEntity::id() const {
+  return m_id;
+}
+
+std::string ONCatEntity::type() const {
+  return m_type;
+}
+
+std::string ONCatEntity::asString(
+  const std::string & path, const std::string defaultValue
+) const {
+  return getNestedContentValueElseDefault(*m_content, path, defaultValue);
+}
+
+int ONCatEntity::asInt(
+  const std::string & path, int defaultValue
+) const {
+  return getNestedContentValueElseDefault(*m_content, path, defaultValue);
+}
+
+float ONCatEntity::asFloat(
+  const std::string & path, float defaultValue
+) const {
+  return getNestedContentValueElseDefault(*m_content, path, defaultValue);
+}
+
+double ONCatEntity::asDouble(
+  const std::string & path, double defaultValue
+) const {
+  return getNestedContentValueElseDefault(*m_content, path, defaultValue);
+}
+
+bool ONCatEntity::asBool(
+  const std::string & path, bool defaultValue
+) const {
+  return getNestedContentValueElseDefault(*m_content, path, defaultValue);
+}
+
+boost::optional<std::string> ONCatEntity::asString(
+  const std::string & path
+) const {
+  return getNestedContentValueIfPresent<std::string>(*m_content, path);
+}
+
+boost::optional<int> ONCatEntity::asInt(
+  const std::string & path
+) const {
+  return getNestedContentValueIfPresent<int>(*m_content, path);
+}
+
+boost::optional<float> ONCatEntity::asFloat(
+  const std::string & path
+) const {
+  return getNestedContentValueIfPresent<float>(*m_content, path);
+}
+
+boost::optional<double> ONCatEntity::asDouble(
+  const std::string & path
+) const {
+  return getNestedContentValueIfPresent<double>(*m_content, path);
+}
+
+boost::optional<bool> ONCatEntity::asBool(
+  const std::string & path
+) const {
+  return getNestedContentValueIfPresent<bool>(*m_content, path);
+}
+
+std::string ONCatEntity::toString() const {
+  return m_content->toStyledString();
+}
+
+ONCatEntity ONCatEntity::fromJSONStream(
+  std::istream & streamContent
+) {
+  auto content = make_unique<Content>();
+
+  try {
+    streamContent >> *content;
+  } catch (Json::Exception & je) {
+    throw MalformedRepresentationError(je.what());
+  }
+
+  const auto id = content->get("id", "").asString();
+  const auto type = content->get("type", "").asString();
+
+  if (id == "" || type == "") {
+    throw MalformedRepresentationError(
+      "Expected \"id\" and \"type\" attributes from ONCat API, but these "
+      "were not found."
+    );
+  }
+
+  return ONCatEntity(id, type, std::move(content));
+}
+
+std::vector<ONCatEntity> ONCatEntity::vectorFromJSONStream(
+  std::istream & streamContent
+) {
+  auto content = make_unique<Content>();
+
+  try {
+    streamContent >> *content;
+  } catch (Json::Exception & je) {
+    throw MalformedRepresentationError(je.what());
+  }
+
+  if (!content->isArray()) {
+    throw MalformedRepresentationError(
+      "Expected JSON representation to be an array of entities."
+    );
+  }
+
+  std::vector<ONCatEntity> entities;
+
+  for (const auto & subContent : *content) {
+    const auto id = subContent.get("id", "").asString();
+    const auto type = subContent.get("type", "").asString();
+
+    if (id == "" || type == "") {
+      throw MalformedRepresentationError(
+        "Expected \"id\" and \"type\" attributes from ONCat API, but these "
+        "were not found."
+      );
+    }
+
+    entities.push_back(
+      ONCatEntity(id, type, make_unique<Content>(subContent))
+    );
+  }
+
+  return entities;
+}
+
+} // namespace ONCat
+} // namespace Catalog
+} // namespace Mantid
diff --git a/Framework/Catalog/test/CMakeLists.txt b/Framework/Catalog/test/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..9057de7df13b00dfc79ddb5c5558e8b6b6696444
--- /dev/null
+++ b/Framework/Catalog/test/CMakeLists.txt
@@ -0,0 +1,20 @@
+if ( CXXTEST_FOUND )
+  include_directories ( SYSTEM ${CXXTEST_INCLUDE_DIR} )
+
+  include_directories ( ../../TestHelpers/inc )
+  # This variable is used within the cxxtest_add_test macro to build this helper class into the test executable.
+  # It will go out of scope at the end of this file so doesn't need un-setting
+  # set ( TESTHELPER_SRCS CatalogICatTestHelper.cpp
+  #                       ../../TestHelpers/src/TearDownWorld.cpp
+  #     )
+
+  # The actual test suite
+  cxxtest_add_test ( CatalogTest ${TEST_FILES} )
+  target_link_libraries( CatalogTest LINK_PRIVATE ${TCMALLOC_LIBRARIES_LINKTIME} ${MANTIDLIBS}
+            Catalog
+            )
+  add_dependencies ( FrameworkTests CatalogTest )
+
+  # Add to the 'FrameworkTests' group in VS
+  set_property ( TARGET CatalogTest PROPERTY FOLDER "UnitTests" )
+endif ()
diff --git a/Framework/Catalog/test/OAuthTest.h b/Framework/Catalog/test/OAuthTest.h
new file mode 100644
index 0000000000000000000000000000000000000000..ec326739a3e64827351e40a6de504a9ff61fdc4b
--- /dev/null
+++ b/Framework/Catalog/test/OAuthTest.h
@@ -0,0 +1,47 @@
+#ifndef MANTID_CATALOG_OAUTHTEST_H_
+#define MANTID_CATALOG_OAUTHTEST_H_
+
+#include <cxxtest/TestSuite.h>
+
+#include "MantidCatalog/Exception.h"
+#include "MantidCatalog/OAuth.h"
+#include "MantidKernel/DateAndTime.h"
+
+using Mantid::Catalog::OAuth::OAuthToken;
+using Mantid::Catalog::OAuth::IOAuthTokenStore;
+using Mantid::Catalog::OAuth::IOAuthTokenStore_uptr;
+using Mantid::Types::Core::DateAndTime;
+
+class OAuthTest : public CxxTest::TestSuite {
+public:
+  // CxxTest boilerplate.
+  static OAuthTest *createSuite() { return new OAuthTest(); }
+  static void destroySuite(OAuthTest *suite) { delete suite; }
+
+  void test_oauth_token_from_json_stream() {
+    std::stringstream tokenStringSteam;
+    tokenStringSteam << std::string(
+      "{\"token_type\": \"Bearer\", \"expires_in\": 3600, "
+      "\"access_token\": \"2KSL5aEnLvIudMHIjc7LcBWBCfxOHZ\", "
+      "\"scope\": \"api:read data:read settings:read\", "
+      "\"refresh_token\": \"eZEiz7LbgFrkL5ZHv7R4ck9gOzXexb\"}"
+    );
+    const auto oauthToken = OAuthToken::fromJSONStream(tokenStringSteam);
+    TS_ASSERT_EQUALS(oauthToken.tokenType(), std::string("Bearer"));
+    TS_ASSERT_EQUALS(oauthToken.expiresIn(), 3600);
+    TS_ASSERT_EQUALS(
+      oauthToken.accessToken(),
+      std::string("2KSL5aEnLvIudMHIjc7LcBWBCfxOHZ")
+    );
+    TS_ASSERT_EQUALS(
+      oauthToken.scope(), std::string("api:read data:read settings:read"));
+    TS_ASSERT_EQUALS(
+      oauthToken.refreshToken(), std::string("eZEiz7LbgFrkL5ZHv7R4ck9gOzXexb")
+    );
+
+    TS_ASSERT(!oauthToken.isExpired());
+    TS_ASSERT(oauthToken.isExpired(DateAndTime::getCurrentTime() + 3601.0));
+  }
+};
+
+#endif /* MANTID_CATALOG_OAUTHTEST_H_ */
diff --git a/Framework/Catalog/test/ONCatEntityTest.h b/Framework/Catalog/test/ONCatEntityTest.h
new file mode 100644
index 0000000000000000000000000000000000000000..54f8582632b0938826e2221ce115a1dd965a6cdd
--- /dev/null
+++ b/Framework/Catalog/test/ONCatEntityTest.h
@@ -0,0 +1,155 @@
+#ifndef MANTID_CATALOG_ONCATENTITYTEST_H_
+#define MANTID_CATALOG_ONCATENTITYTEST_H_
+
+#include <cxxtest/TestSuite.h>
+
+#include "MantidCatalog/Exception.h"
+#include "MantidCatalog/ONCatEntity.h"
+
+using Mantid::Catalog::Exception::MalformedRepresentationError;
+using Mantid::Catalog::ONCat::ONCatEntity;
+
+class ONCatEntityTest : public CxxTest::TestSuite {
+public:
+  // CxxTest boilerplate.
+  static ONCatEntityTest *createSuite() {
+      return new ONCatEntityTest();
+  }
+  static void destroySuite(ONCatEntityTest *suite) { delete suite; }
+
+  void test_basic_attributes() {
+    std::string dummyRepresentation =
+      "{"
+      "  \"id\": \"3fa1d522-f1b8-4134-a56b-b61f24d20510\","
+      "  \"type\": \"dummy\""
+      "}";
+
+    std::stringstream ss;
+    ss << dummyRepresentation;
+
+    auto dummy = ONCatEntity::fromJSONStream(ss);
+
+    TS_ASSERT_EQUALS(
+      dummy.id(), std::string("3fa1d522-f1b8-4134-a56b-b61f24d20510")
+    );
+    TS_ASSERT_EQUALS(dummy.type(), std::string("dummy"));
+  }
+
+  void test_basic_attributes_of_entity_vector() {
+    std::string dummiesRepresentation =
+      "["
+      "  {"
+      "    \"id\": \"3fa1d522-f1b8-4134-a56b-b61f24d20510\","
+      "    \"type\": \"dummy\""
+      "  },"
+      "  {"
+      "    \"id\": \"4b1dec2a-0f15-416d-8d23-e08901ac4634\","
+      "    \"type\": \"dummy\""
+      "  }"
+      "]";
+
+    std::stringstream ss;
+    ss << dummiesRepresentation;
+
+    auto dummies = ONCatEntity::vectorFromJSONStream(ss);
+
+    TS_ASSERT_EQUALS(dummies.size(), 2);
+
+    TS_ASSERT_EQUALS(
+      dummies[0].id(), std::string("3fa1d522-f1b8-4134-a56b-b61f24d20510")
+    );
+    TS_ASSERT_EQUALS(dummies[0].type(), std::string("dummy"));
+
+    TS_ASSERT_EQUALS(
+      dummies[1].id(), std::string("4b1dec2a-0f15-416d-8d23-e08901ac4634")
+    );
+    TS_ASSERT_EQUALS(dummies[1].type(), std::string("dummy"));
+  }
+
+  void test_throws_on_malformed_json() {
+    std::string malformedRepresentation =
+      "{"
+      "  \"id\": \"3fa1d522-f1b8-4134-a56b-b61f24d20510\","
+      "  \"type\": \"dummy";
+
+    std::stringstream ss;
+    ss << malformedRepresentation;
+
+    TS_ASSERT_THROWS(
+      ONCatEntity::fromJSONStream(ss),
+      MalformedRepresentationError
+    );
+  }
+
+  void test_throws_on_malformed_representation() {
+    std::string missingTypeRepresentation =
+      "{"
+      "  \"id\": \"3fa1d522-f1b8-4134-a56b-b61f24d20510\""
+      "}";
+
+    std::stringstream ss;
+    ss << missingTypeRepresentation;
+
+    TS_ASSERT_THROWS(
+      ONCatEntity::fromJSONStream(ss),
+      MalformedRepresentationError
+    );
+  }
+
+  void test_nested_values_with_various_types() {
+    std::string dummyRepresentation =
+      "{"
+      "  \"id\": \"3fa1d522-f1b8-4134-a56b-b61f24d20510\","
+      "  \"type\": \"dummy\","
+      "  \"val\": {"
+      "    \"a\": {"
+      "      \"string\": \"value\","
+      "      \"int\": 1234,"
+      "      \"float\": 1234.5,"
+      "      \"double\": 1234.5,"
+      "      \"bool\": true"
+      "    }"
+      "  }"
+      "}";
+
+    std::stringstream ss;
+    ss << dummyRepresentation;
+
+    auto dummy = ONCatEntity::fromJSONStream(ss);
+
+    // Note implicit calls to get, i.e. "dummy.asType("val.a.type").get()".
+    TS_ASSERT_EQUALS(dummy.asString("val.a.string"), std::string("value"));
+    TS_ASSERT_EQUALS(dummy.asInt("val.a.int"), 1234);
+    TS_ASSERT_EQUALS(dummy.asFloat("val.a.float"), 1234.5f);
+    TS_ASSERT_EQUALS(dummy.asDouble("val.a.double"), 1234.5);
+    TS_ASSERT_EQUALS(dummy.asBool("val.a.bool"), true);
+
+    TS_ASSERT(!dummy.asString("a.string"));
+    TS_ASSERT(!dummy.asInt("a.int"));
+    TS_ASSERT(!dummy.asFloat("a.float"));
+    TS_ASSERT(!dummy.asDouble("a.double"));
+    TS_ASSERT(!dummy.asBool("a.bool"));
+  }
+
+  void test_default_values_with_various_types() {
+    std::string dummyRepresentation =
+      "{"
+      "  \"id\": \"3fa1d522-f1b8-4134-a56b-b61f24d20510\","
+      "  \"type\": \"dummy\""
+      "  }"
+      "}";
+
+    std::stringstream ss;
+    ss << dummyRepresentation;
+
+    auto dummy = ONCatEntity::fromJSONStream(ss);
+
+    TS_ASSERT_EQUALS(dummy.asString("a.string", "val"), std::string("val"));
+    TS_ASSERT_EQUALS(dummy.asInt("a.int", 1234), 1234);
+    TS_ASSERT_EQUALS(dummy.asFloat("a.float", 1234.5f), 1234.5f);
+    TS_ASSERT_EQUALS(dummy.asDouble("a.double", 1234.5), 1234.5);
+    TS_ASSERT_EQUALS(dummy.asBool("a.bool", true), true);
+  }
+};
+
+#endif /* MANTID_CATALOG_ONCATENTITYTEST_H_ */
diff --git a/Framework/Catalog/test/ONCatTest.h b/Framework/Catalog/test/ONCatTest.h
new file mode 100644
index 0000000000000000000000000000000000000000..933568a8d21e33023a14e30bbc103f36638beba6
--- /dev/null
+++ b/Framework/Catalog/test/ONCatTest.h
@@ -0,0 +1,461 @@
+#ifndef MANTID_CATALOG_ONCATTEST_H_
+#define MANTID_CATALOG_ONCATTEST_H_
+
+#include <cxxtest/TestSuite.h>
+
+#include "MantidCatalog/Exception.h"
+#include "MantidCatalog/ONCat.h"
+#include "MantidKernel/DateAndTime.h"
+#include "MantidKernel/Exception.h"
+#include "MantidKernel/InternetHelper.h"
+#include "MantidKernel/make_unique.h"
+
+#include <map>
+
+#include <Poco/Net/HTTPResponse.h>
+
+using Poco::Net::HTTPResponse;
+
+using Mantid::Catalog::Exception::InvalidCredentialsError;
+using Mantid::Catalog::Exception::InvalidRefreshTokenError;
+using Mantid::Catalog::Exception::TokenRejectedError;
+using Mantid::Catalog::OAuth::OAuthFlow;
+using Mantid::Catalog::OAuth::OAuthToken;
+using Mantid::Catalog::OAuth::ConfigServiceTokenStore;
+using Mantid::Catalog::OAuth::IOAuthTokenStore;
+using Mantid::Catalog::OAuth::IOAuthTokenStore_uptr;
+using Mantid::Catalog::ONCat::ONCat;
+using Mantid::Catalog::ONCat::QueryParameter;
+using Mantid::Types::Core::DateAndTime;
+using Mantid::Kernel::Exception::InternetError;
+
+//----------------------------------------------------------------------
+// Helpers, Mocks and Variables
+//----------------------------------------------------------------------
+
+namespace {
+
+using MockResponseMap = std::map<std::string, std::pair<int, std::string>>;
+using MockResponseCallCounts = std::map<std::string, unsigned int>;
+using MockResponseCallMapping = std::pair<const std::basic_string<char>, unsigned int>;
+
+class MockONCatAPI : public Mantid::Kernel::InternetHelper {
+public:
+  MockONCatAPI() = delete;
+  MockONCatAPI(
+    const MockResponseMap & responseMap
+  ) :
+    Mantid::Kernel::InternetHelper(),
+    m_responseMap(responseMap),
+    m_responseCallCounts()
+  {
+    for (const auto & mapping : responseMap) {
+      m_responseCallCounts[mapping.first] = 0;
+    }
+  }
+  ~MockONCatAPI() {
+    TS_ASSERT(allResponsesCalledOnce());
+  }
+
+  bool allResponsesCalledOnce() const {
+    return std::all_of(
+      m_responseCallCounts.cbegin(), m_responseCallCounts.cend(),
+      [](const MockResponseCallMapping & mapping){
+        return mapping.second == 1;
+      }
+    );
+  }
+
+protected:
+  int sendHTTPRequest(
+    const std::string &url, std::ostream & responseStream
+  ) override { return sendHTTPSRequest(url, responseStream); }
+
+  int sendHTTPSRequest(
+    const std::string &url, std::ostream & responseStream
+  ) override {
+    auto mockResponse = m_responseMap.find(url);
+
+    assert(mockResponse != m_responseMap.end());
+    m_responseCallCounts[url] += 1;
+
+    const auto statusCode = mockResponse->second.first;
+    const auto responseBody = mockResponse->second.second;
+
+    // Approximate the behaviour of the actual helper class when a
+    // non-OK response is observed.
+    if (statusCode != HTTPResponse::HTTP_OK) {
+      throw InternetError(responseBody, statusCode);
+    }
+
+    responseStream << responseBody;
+    return statusCode;
+  }
+
+private:
+  MockResponseMap m_responseMap;
+  MockResponseCallCounts m_responseCallCounts;
+};
+
+std::unique_ptr<MockONCatAPI> make_mock_oncat_api(
+  const MockResponseMap & responseMap
+) {
+  return Mantid::Kernel::make_unique<MockONCatAPI>(responseMap);
+}
+
+class MockTokenStore : public IOAuthTokenStore {
+public:
+  MockTokenStore() : m_token(boost::none) {}
+
+  void setToken(const boost::optional<OAuthToken> & token) override {
+    m_token = token;
+  }
+  boost::optional<OAuthToken> getToken() override { return m_token; }
+private:
+  boost::optional<OAuthToken> m_token;
+};
+
+IOAuthTokenStore_uptr make_mock_token_store() {
+  return Mantid::Kernel::make_unique<MockTokenStore>();
+}
+
+IOAuthTokenStore_uptr make_mock_token_store_already_logged_in() {
+  auto tokenStore = Mantid::Kernel::make_unique<MockTokenStore>();
+  tokenStore->setToken(OAuthToken(
+    "Bearer",
+    3600,
+    "2KSL5aEnLvIudMHIjc7LcBWBCfxOHZ",
+    "api:read data:read settings:read",
+    boost::make_optional<std::string>("eZEiz7LbgFrkL5ZHv7R4ck9gOzXexb")
+  ));
+  return std::move(tokenStore);
+}
+
+const static std::string DUMMY_URL = "https://not.a.real.url";
+const static std::string DUMMY_CLIENT_ID =
+  "0e527a36-297d-4cb4-8a35-84f6b11248d7";
+
+}
+
+//----------------------------------------------------------------------
+// Tests
+//----------------------------------------------------------------------
+
+class ONCatTest : public CxxTest::TestSuite {
+public:
+  // CxxTest boilerplate.
+  static ONCatTest *createSuite() { return new ONCatTest(); }
+  static void destroySuite(ONCatTest *suite) { delete suite; }
+
+  void test_login_with_invalid_credentials_throws() {
+    ONCat oncat(
+      DUMMY_URL,
+      make_mock_token_store(),
+      OAuthFlow::RESOURCE_OWNER_CREDENTIALS,
+      DUMMY_CLIENT_ID
+    );
+
+    TS_ASSERT(!oncat.isUserLoggedIn());
+
+    oncat.setInternetHelper(make_mock_oncat_api({
+      {
+        DUMMY_URL + "/oauth/token", std::make_pair(
+          HTTPResponse::HTTP_UNAUTHORIZED,
+          "{\"error\": \"invalid_grant\", "
+          "\"error_description\": \"Invalid credentials given.\"}"
+        )
+      }
+    }));
+
+    TS_ASSERT_THROWS(
+      oncat.login("user", "does_not_exist"),
+      InvalidCredentialsError
+    );
+    TS_ASSERT(!oncat.isUserLoggedIn());
+  }
+
+  void test_login_with_valid_credentials_is_successful() {
+    ONCat oncat(
+      DUMMY_URL,
+      make_mock_token_store(),
+      OAuthFlow::RESOURCE_OWNER_CREDENTIALS,
+      DUMMY_CLIENT_ID
+    );
+
+    TS_ASSERT(!oncat.isUserLoggedIn());
+
+    oncat.setInternetHelper(make_mock_oncat_api({
+      {
+        DUMMY_URL + "/oauth/token", std::make_pair(
+          HTTPResponse::HTTP_OK,
+          "{\"token_type\": \"Bearer\", \"expires_in\": 3600, "
+          "\"access_token\": \"2KSL5aEnLvIudMHIjc7LcBWBCfxOHZ\", "
+          "\"scope\": \"api:read data:read settings:read\", "
+          "\"refresh_token\": \"eZEiz7LbgFrkL5ZHv7R4ck9gOzXexb\"}"
+        )
+      }
+    }));
+
+    oncat.login("user", "does_exist");
+
+    TS_ASSERT(oncat.isUserLoggedIn());
+  }
+
+  void test_refreshing_token_when_needed() {
+    ONCat oncat(
+      DUMMY_URL,
+      make_mock_token_store(),
+      OAuthFlow::RESOURCE_OWNER_CREDENTIALS,
+      DUMMY_CLIENT_ID
+    );
+
+    oncat.setInternetHelper(make_mock_oncat_api({
+      {
+        DUMMY_URL + "/oauth/token", std::make_pair(
+          HTTPResponse::HTTP_OK,
+          "{\"token_type\": \"Bearer\", \"expires_in\": 3600, "
+          "\"access_token\": \"2KSL5aEnLvIudMHIjc7LcBWBCfxOHZ\", "
+          "\"scope\": \"api:read data:read settings:read\", "
+          "\"refresh_token\": \"eZEiz7LbgFrkL5ZHv7R4ck9gOzXexb\"}"
+        )
+      }
+    }));
+
+    oncat.login("user", "does_exist");
+
+    TS_ASSERT(oncat.isUserLoggedIn());
+
+    oncat.refreshTokenIfNeeded();
+    TS_ASSERT(oncat.isUserLoggedIn());
+
+    oncat.setInternetHelper(make_mock_oncat_api({
+      {
+        DUMMY_URL + "/oauth/token", std::make_pair(
+          HTTPResponse::HTTP_OK,
+          "{\"token_type\": \"Bearer\", \"expires_in\": 3600, "
+          "\"access_token\": \"7dS7flfhsf7ShndHJSFknfskfeu789\", "
+          "\"scope\": \"api:read data:read settings:read\", "
+          "\"refresh_token\": \"sdagSDGF87dsgljerg6gdfgddfgfdg\"}"
+        )
+      }
+    }));
+
+    oncat.refreshTokenIfNeeded(DateAndTime::getCurrentTime() + 3601.0);
+    TS_ASSERT(oncat.isUserLoggedIn());
+  }
+
+  void test_logged_out_when_refreshing_fails() {
+    ONCat oncat(
+      DUMMY_URL,
+      make_mock_token_store(),
+      OAuthFlow::RESOURCE_OWNER_CREDENTIALS,
+      DUMMY_CLIENT_ID
+    );
+
+    oncat.setInternetHelper(make_mock_oncat_api({
+      {
+        DUMMY_URL + "/oauth/token", std::make_pair(
+          HTTPResponse::HTTP_OK,
+          "{\"token_type\": \"Bearer\", \"expires_in\": 3600, "
+          "\"access_token\": \"2KSL5aEnLvIudMHIjc7LcBWBCfxOHZ\", "
+          "\"scope\": \"api:read data:read settings:read\", "
+          "\"refresh_token\": \"eZEiz7LbgFrkL5ZHv7R4ck9gOzXexb\"}"
+        )
+      }
+    }));
+
+    oncat.login("user", "does_exist");
+
+    TS_ASSERT(oncat.isUserLoggedIn());
+
+    oncat.refreshTokenIfNeeded();
+    TS_ASSERT(oncat.isUserLoggedIn());
+
+    oncat.setInternetHelper(make_mock_oncat_api({
+      {
+        DUMMY_URL + "/oauth/token", std::make_pair(
+          HTTPResponse::HTTP_UNAUTHORIZED,
+          "{\"error\": \"invalid_grant\", "
+          "\"error_description\": \"Bearer token not found.\"}"
+        )
+      }
+    }));
+
+    TS_ASSERT_THROWS(
+      oncat.refreshTokenIfNeeded(DateAndTime::getCurrentTime() + 3601.0),
+      InvalidRefreshTokenError
+    );
+
+    TS_ASSERT(!oncat.isUserLoggedIn());
+  }
+
+  void test_retrieve_entity() {
+    ONCat oncat(
+      DUMMY_URL,
+      make_mock_token_store_already_logged_in(),
+      OAuthFlow::RESOURCE_OWNER_CREDENTIALS,
+      DUMMY_CLIENT_ID
+    );
+
+    TS_ASSERT(oncat.isUserLoggedIn());
+
+    oncat.setInternetHelper(make_mock_oncat_api({
+      {
+        DUMMY_URL + "/api/instruments/HB2C?facility=HFIR", std::make_pair(
+          HTTPResponse::HTTP_OK,
+          "{\"facility\": \"HFIR\","
+          "\"name\": \"HB2C\","
+          "\"id\": \"HB2C\","
+          "\"type\": \"instrument\"}"
+        )
+      }
+    }));
+
+    const auto entity = oncat.retrieve("api", "instruments", "HB2C", {
+      QueryParameter("facility", "HFIR")
+    });
+
+    TS_ASSERT_EQUALS(entity.id(), std::string("HB2C"));
+    TS_ASSERT_EQUALS(entity.asString("name"), std::string("HB2C"));
+  }
+
+  void test_list_entities() {
+    ONCat oncat(
+      DUMMY_URL,
+      make_mock_token_store_already_logged_in(),
+      OAuthFlow::RESOURCE_OWNER_CREDENTIALS,
+      DUMMY_CLIENT_ID
+    );
+
+    TS_ASSERT(oncat.isUserLoggedIn());
+
+    oncat.setInternetHelper(make_mock_oncat_api({
+      {
+        DUMMY_URL + "/api/instruments?facility=HFIR", std::make_pair(
+          HTTPResponse::HTTP_OK,
+          "["
+          "  {"
+          "    \"facility\": \"HFIR\","
+          "    \"name\": \"HB2C\","
+          "    \"id\": \"HB2C\","
+          "    \"type\": \"instrument\""
+          "  },"
+          "  {"
+          "    \"facility\": \"HFIR\","
+          "    \"name\": \"CG1D\","
+          "    \"id\": \"CG1D\","
+          "    \"type\": \"instrument\""
+          "  }"
+          "]"
+        )
+      }
+    }));
+
+    const auto entities = oncat.list("api", "instruments", {
+      QueryParameter("facility", "HFIR")
+    });
+
+    TS_ASSERT_EQUALS(entities.size(), 2);
+    TS_ASSERT_EQUALS(entities[0].id(), std::string("HB2C"));
+    TS_ASSERT_EQUALS(entities[0].asString("name"), std::string("HB2C"));
+    TS_ASSERT_EQUALS(entities[1].id(), std::string("CG1D"));
+    TS_ASSERT_EQUALS(entities[1].asString("name"), std::string("CG1D"));
+  }
+
+  void test_send_api_request_logs_out_with_invalid_grant() {
+    ONCat oncat(
+      DUMMY_URL,
+      make_mock_token_store_already_logged_in(),
+      OAuthFlow::RESOURCE_OWNER_CREDENTIALS,
+      DUMMY_CLIENT_ID
+    );
+
+    TS_ASSERT(oncat.isUserLoggedIn());
+
+    oncat.setInternetHelper(make_mock_oncat_api({
+      {
+        DUMMY_URL + "/api/instruments?facility=HFIR", std::make_pair(
+          HTTPResponse::HTTP_UNAUTHORIZED, "{}"
+        )
+      }
+    }));
+
+    TS_ASSERT_THROWS(
+      oncat.list("api", "instruments", {QueryParameter("facility", "HFIR")}),
+      TokenRejectedError
+    );
+    TS_ASSERT(!oncat.isUserLoggedIn());
+  }
+
+  void test_client_credentials_flow_with_refresh() {
+    ONCat oncat(
+      DUMMY_URL,
+      make_mock_token_store(),
+      OAuthFlow::CLIENT_CREDENTIALS,
+      DUMMY_CLIENT_ID,
+      boost::make_optional<std::string>(
+        "9a2ad07a-a139-438b-8116-08c5452f96ad"
+      )
+    );
+
+    oncat.setInternetHelper(make_mock_oncat_api({
+      {
+        DUMMY_URL + "/oauth/token", std::make_pair(
+          HTTPResponse::HTTP_OK,
+          "{\"token_type\": \"Bearer\", \"expires_in\": 3600, "
+          "\"access_token\": \"2KSL5aEnLvIudMHIjc7LcBWBCfxOHZ\", "
+          "\"scope\": \"api:read data:read settings:read\"}"
+        )
+      }, {
+        DUMMY_URL + "/api/instruments/HB2C?facility=HFIR", std::make_pair(
+          HTTPResponse::HTTP_OK,
+          "{\"facility\": \"HFIR\","
+          "\"name\": \"HB2C\","
+          "\"id\": \"HB2C\","
+          "\"type\": \"instrument\"}"
+        )
+      }
+    }));
+
+    oncat.retrieve("api", "instruments", "HB2C", {
+      QueryParameter("facility", "HFIR")
+    });
+
+    oncat.setInternetHelper(make_mock_oncat_api({
+      {
+        DUMMY_URL + "/oauth/token", std::make_pair(
+          HTTPResponse::HTTP_OK,
+          "{\"token_type\": \"Bearer\", \"expires_in\": 3600, "
+          "\"access_token\": \"987JHGFiusdvs72fAkjhsKJH32tkjk\", "
+          "\"scope\": \"api:read data:read settings:read\"}"
+        ),
+      }
+    }));
+
+    oncat.refreshTokenIfNeeded(DateAndTime::getCurrentTime() + 3601.0);
+  }
+
+  void test_config_service_token_store_roundtrip() {
+    ConfigServiceTokenStore tokenStore;
+
+    const auto testToken = boost::make_optional(OAuthToken(
+      "Bearer",
+      3600,
+      "2KSL5aEnLvIudMHIjc7LcBWBCfxOHZ",
+      "api:read data:read settings:read",
+      boost::make_optional<std::string>("eZEiz7LbgFrkL5ZHv7R4ck9gOzXexb")
+    ));
+
+    tokenStore.setToken(testToken);
+
+    const auto result = tokenStore.getToken();
+
+    TS_ASSERT_EQUALS(testToken->tokenType(), result->tokenType());
+    TS_ASSERT_EQUALS(testToken->expiresIn(), result->expiresIn());
+    TS_ASSERT_EQUALS(testToken->accessToken(), result->accessToken());
+    TS_ASSERT_EQUALS(testToken->scope(), result->scope());
+    TS_ASSERT_EQUALS(*testToken->refreshToken(), *result->refreshToken());
+  }
+};
+
+#endif /* MANTID_CATALOG_ONCATTEST_H_ */