diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright Moritz Clasmeier (c) 2018
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Moritz Schulte nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,66 @@
+# Access Token Provider
+
+This package provides a convenient retrieval mechanism of access
+tokens. Access Token Provider supporting multiple provider backends,
+including OAuth2 Resource Owner Password Credentials Grant, file-based
+token access (e.g. for Kubernetes) and fetching tokens from the
+environment (e.g. for local testing). The package is configurable via
+environment variables. It uses Katip for logging.
+
+## Examples
+
+```haskell
+import qualified Security.AccessTokenProvider as ATP
+
+retrieveSomeToken :: KatipContextT IO ()
+retrieveSomeToken = do
+  tokenProvider <- ATP.new (AccessTokenName "token-name")
+  token <- ATP.retrieveAccessToken tokenProvider
+  liftIO $ print token
+```
+
+## Configuration
+
+Configuration is done by setting the environment variable `ATP_CONF`.
+
+### OAuth2 based token retrieval
+
+For OAuth2 (Resource Owner Password Credentials Grant) provider, use:
+
+```json
+{
+  "provider": "ropcg",
+  "credentials_directory": "/optional/credentials/directory",
+  "auth_endpoint": "<OAuth2 authentication endpoint>",
+  "tokens": {"token-name": {"scopes": ["first-scope", "second-scope"]}}
+}
+```
+
+The `credentials_directory` setting defaults to the content of the
+environment variable `CREDENTIALS_DIR`. It is expected to contain the
+files `user.json` and `client.json`, containing the user and client
+credentials respectively.
+
+### File based token retrieval (e.g. for Kubernetes)
+
+```json
+{
+  "provider": "file",
+  "tokens": {"token-name": "/some/file/name"}
+}
+```
+
+As a short cut, you can simply save a token path directly in the
+environment variable `TOKEN_FILE`.
+
+### Environment based token retrieval (e.g. for testing)
+
+```json
+{
+  "provider": "fixed",
+  "tokens": {"token-name": "some-fixed-token"}
+}
+```
+
+As a short cut, you can simply save a token directly in the
+environment variable `TOKEN`.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/access-token-provider.cabal b/access-token-provider.cabal
new file mode 100644
--- /dev/null
+++ b/access-token-provider.cabal
@@ -0,0 +1,94 @@
+name:                access-token-provider
+version:             0.1.0.0
+synopsis:            Provides Access Token for Services
+description:         Access Token Provider supporting multiple provider backends,
+                     including OAuth2 Resource Owner Password Credentials Grant,
+                     file-based token access (e.g. for Kubernetes) and fetching
+                     tokens from the environment (e.g. for local testing). The
+                     package is configurable via environment variables.
+homepage:            https://github.com/mtesseract/access-token-provider#readme
+license:             BSD3
+license-file:        LICENSE
+author:              Moritz Clasmeier
+maintainer:          mtesseract@silverratio.net
+copyright:           (c) 2018 Moritz Clasmeier
+category:            Data
+build-type:          Simple
+extra-source-files:  README.md
+cabal-version:       >=1.10
+
+library
+  hs-source-dirs:      src
+  ghc-options:         -Wall
+  exposed-modules:     Security.AccessTokenProvider
+                     , Security.AccessTokenProvider.Internal
+                     , Security.AccessTokenProvider.Internal.Types
+                     , Security.AccessTokenProvider.Internal.Lenses
+                     , Security.AccessTokenProvider.Internal.Util
+                     , Security.AccessTokenProvider.Internal.Providers.Common
+                     , Security.AccessTokenProvider.Internal.Providers.Fixed
+                     , Security.AccessTokenProvider.Internal.Providers.File
+                     , Security.AccessTokenProvider.Internal.Providers.OAuth2.Ropcg
+  build-depends:       base >= 4.7 && < 5
+                     , containers
+                     , text
+                     , bytestring
+                     , exceptions
+                     , aeson
+                     , http-types
+                     , aeson-casing
+                     , http-client
+                     , http-client-tls
+                     , unliftio-core
+                     , unliftio
+                     , katip
+                     , th-format >= 0.1.2.0
+                     , lens
+                     , lens-aeson
+                     , safe-exceptions
+                     , base64-bytestring
+                     , filepath
+                     , stm
+                     , mtl
+                     , transformers
+                     , random
+  default-language:    Haskell2010
+
+
+test-suite access-token-provider-test
+  type:                exitcode-stdio-1.0
+  hs-source-dirs:      tests
+  main-is:             Spec.hs
+  other-modules:       Security.AccessTokenProvider.Test
+                     , Security.AccessTokenProvider.Internal.Test
+                     , Security.AccessTokenProvider.Internal.Util.Test
+                     , Security.AccessTokenProvider.Internal.Providers.Test
+                     , Test
+  build-depends:       base
+                     , access-token-provider
+                     , aeson
+                     , text
+                     , tasty
+                     , tasty-hunit
+                     , katip
+                     , safe-exceptions
+                     , uuid
+                     , random
+                     , lens
+                     , containers
+                     , exceptions
+                     , bytestring
+                     , mtl
+                     , http-client
+                     , http-types
+                     , th-format
+                     , unliftio-core
+                     , unliftio
+                     , safe-exceptions
+  ghc-options:         -threaded -rtsopts -with-rtsopts=-N
+  default-language:    Haskell2010
+  default-extensions:  OverloadedStrings
+
+source-repository head
+  type:     git
+  location: https://github.com/mtesseract/access-token-provider
diff --git a/src/Security/AccessTokenProvider.hs b/src/Security/AccessTokenProvider.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/AccessTokenProvider.hs
@@ -0,0 +1,19 @@
+module Security.AccessTokenProvider
+  ( new
+  , newWithProviders
+  , providerProbeFile
+  , providerProbeFixed
+  , providerProbeRopcg
+  , MonadHttp(..)
+  , MonadFilesystem(..)
+  , MonadEnvironment(..)
+  , AccessTokenName(..)
+  , AccessTokenProvider(..)
+  , AccessToken(..)
+  ) where
+
+import           Security.AccessTokenProvider.Internal
+import           Security.AccessTokenProvider.Internal.Providers.File
+import           Security.AccessTokenProvider.Internal.Providers.Fixed
+import           Security.AccessTokenProvider.Internal.Providers.OAuth2.Ropcg
+import           Security.AccessTokenProvider.Internal.Types
diff --git a/src/Security/AccessTokenProvider/Internal.hs b/src/Security/AccessTokenProvider/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/AccessTokenProvider/Internal.hs
@@ -0,0 +1,55 @@
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE OverloadedStrings     #-}
+{-# LANGUAGE PolyKinds             #-}
+{-# LANGUAGE Rank2Types            #-}
+{-# LANGUAGE RankNTypes            #-}
+{-# LANGUAGE ScopedTypeVariables   #-}
+
+module Security.AccessTokenProvider.Internal where
+
+import           Control.Exception.Safe
+import           Control.Monad.IO.Unlift
+import           Data.List.NonEmpty                                           (NonEmpty (..))
+import qualified Data.List.NonEmpty                                           as NonEmpty
+import           Katip
+
+import           Security.AccessTokenProvider.Internal.Providers.File
+import           Security.AccessTokenProvider.Internal.Providers.Fixed
+import           Security.AccessTokenProvider.Internal.Providers.OAuth2.Ropcg
+import           Security.AccessTokenProvider.Internal.Types
+
+namespace :: Namespace
+namespace = "access-token-provider"
+
+newWithProviders
+  :: (MonadThrow m, KatipContext m)
+  => NonEmpty (AtpProbe m)
+  -> AccessTokenName
+  -> m (AccessTokenProvider m t)
+newWithProviders providers tokenName = katipAddNamespace namespace $
+  probeProviders (NonEmpty.toList providers)
+
+  where probeProviders [] =
+          throwM $ AccessTokenProviderMissing tokenName
+        probeProviders (AtpProbe tryProvider : rest) = do
+          maybeProvider <- tryProvider tokenName
+          case maybeProvider of
+            Nothing ->
+              probeProviders rest
+            Just provider ->
+              pure provider
+
+new
+  :: ( KatipContext m
+     , MonadUnliftIO m
+     , MonadMask m
+     , MonadEnvironment m
+     , MonadFilesystem m
+     , MonadHttp m)
+  => AccessTokenName -> m (AccessTokenProvider m t)
+new = newWithProviders providers
+
+  where providers =
+          AtpProbe providerProbeFixed
+          :| [ AtpProbe providerProbeFile
+             , AtpProbe providerProbeRopcg ]
diff --git a/src/Security/AccessTokenProvider/Internal/Lenses.hs b/src/Security/AccessTokenProvider/Internal/Lenses.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/AccessTokenProvider/Internal/Lenses.hs
@@ -0,0 +1,21 @@
+{-# LANGUAGE FlexibleInstances      #-}
+{-# LANGUAGE FunctionalDependencies #-}
+{-# LANGUAGE MultiParamTypeClasses  #-}
+{-# LANGUAGE TemplateHaskell        #-}
+{-# LANGUAGE TypeSynonymInstances   #-}
+
+module Security.AccessTokenProvider.Internal.Lenses where
+
+import           Control.Lens
+
+import           Security.AccessTokenProvider.Internal.Types
+
+makeFieldsNoPrefix ''AtpConfRopcg
+makeFieldsNoPrefix ''AtpConfFile
+makeFieldsNoPrefix ''AtpConfFixed
+makeFieldsNoPrefix ''AtpPreconfRopcg
+makeFieldsNoPrefix ''AtpRopcgTokenDef
+makeFieldsNoPrefix ''ClientCredentials
+makeFieldsNoPrefix ''UserCredentials
+makeFieldsNoPrefix ''Credentials
+makeFieldsNoPrefix ''AtpRopcgResponse
diff --git a/src/Security/AccessTokenProvider/Internal/Providers/Common.hs b/src/Security/AccessTokenProvider/Internal/Providers/Common.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/AccessTokenProvider/Internal/Providers/Common.hs
@@ -0,0 +1,67 @@
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE QuasiQuotes         #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module Security.AccessTokenProvider.Internal.Providers.Common
+  ( tryNewProvider
+  , tryEnvDeserialization
+  ) where
+
+import           Control.Exception.Safe
+import           Control.Lens
+import           Control.Monad.IO.Class
+import           Control.Monad.Trans.Class
+import           Control.Monad.Trans.Maybe
+import           Data.Aeson
+import           Data.Aeson.Lens
+import           Data.Format
+import           Data.List.NonEmpty                          (NonEmpty)
+import qualified Data.List.NonEmpty                          as NonEmpty
+import           Data.Text                                   (Text)
+import qualified Data.Text.Encoding                          as Text
+import           Katip
+
+import           Security.AccessTokenProvider.Internal.Types
+import           Security.AccessTokenProvider.Internal.Util
+
+tryNewProvider
+  :: (MonadIO m, MonadThrow m, KatipContext m)
+  => AccessTokenName
+  -> m (Maybe envConf)
+  -> (envConf -> m conf)
+  -> (AccessTokenName -> conf -> m (Maybe (AccessTokenProvider m t)))
+  -> m (Maybe (AccessTokenProvider m t))
+tryNewProvider tokenName makeEnvConf makeConf providerBuilder = do
+  maybeEnvConf <- makeEnvConf
+  case maybeEnvConf of
+    Just envConf -> do
+      conf <- makeConf envConf
+      providerBuilder tokenName conf
+    Nothing ->
+      pure Nothing
+
+atpConfVarName :: Text
+atpConfVarName = "ATP_CONF"
+
+tryEnvDeserialization
+  :: ( MonadThrow m
+     , MonadEnvironment m
+     , FromJSON a
+     , KatipContext m )
+  => NonEmpty Text
+  -> m (Maybe a)
+tryEnvDeserialization providers = do
+  maybeConf <- runMaybeT $ do
+    envVal <- MaybeT $ environmentLookup atpConfVarName
+    jsonVal :: Value <- lift $ throwDecode (Text.encodeUtf8 envVal)
+    provider <- MaybeT . pure $ jsonVal ^? key "provider" . _String
+    logFM DebugS (ls [fmt|ATP_CONF requests AccessTokenProvider '${provider}'|])
+    if provider `elem` providers
+      then do logFM InfoS (ls [fmt|Using AccessTokenProvider '${NonEmpty.head providers}'|])
+              pure jsonVal
+      else MaybeT (pure Nothing)
+  case maybeConf of
+    Just conf ->
+      Just <$> throwDecodeValue conf
+    Nothing ->
+      pure Nothing
diff --git a/src/Security/AccessTokenProvider/Internal/Providers/File.hs b/src/Security/AccessTokenProvider/Internal/Providers/File.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/AccessTokenProvider/Internal/Providers/File.hs
@@ -0,0 +1,93 @@
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE LambdaCase            #-}
+{-# LANGUAGE OverloadedStrings     #-}
+{-# LANGUAGE QuasiQuotes           #-}
+{-# LANGUAGE ScopedTypeVariables   #-}
+
+module Security.AccessTokenProvider.Internal.Providers.File
+  ( providerProbeFile
+  ) where
+
+import           Control.Applicative
+import           Control.Exception.Safe
+import           Control.Lens
+import           Control.Monad.IO.Unlift
+import           Data.Format
+import           Data.List.NonEmpty                                     (NonEmpty (..))
+import qualified Data.Map                                               as Map
+import           Data.Maybe
+import qualified Data.Text                                              as Text
+import qualified Data.Text.Encoding                                     as Text
+import           Katip
+import           UnliftIO.STM
+
+import qualified Security.AccessTokenProvider.Internal.Lenses           as L
+import           Security.AccessTokenProvider.Internal.Providers.Common
+import           Security.AccessTokenProvider.Internal.Types
+
+providerProbeFile
+  :: ( KatipContext m
+     , MonadFilesystem m
+     , MonadCatch m
+     , MonadEnvironment m
+     , MonadUnliftIO m )
+  => AccessTokenName -> m (Maybe (AccessTokenProvider m t))
+providerProbeFile tokenName = katipAddNamespace "probe-file" $
+  tryNewProvider tokenName makeConf pure createFilePathTokenProvider
+
+  where makeConf = do
+          maybeTokenFile <- fmap Text.unpack <$> environmentLookup  "TOKEN_FILE"
+          case maybeTokenFile of
+            Just tokenFile ->
+              pure . Just $ AtpConfFile { _tokens = Just Map.empty
+                                        , _token  = Just tokenFile }
+            Nothing ->
+              tryEnvDeserialization ("file" :| [])
+
+createFilePathTokenProvider
+  :: ( KatipContext m
+     , MonadFilesystem m
+     , MonadUnliftIO m
+     , MonadCatch m )
+  => AccessTokenName
+  -> AtpConfFile
+  -> m (Maybe (AccessTokenProvider m t))
+createFilePathTokenProvider (AccessTokenName tokenName) conf = do
+  let tokenFileMap = fromMaybe Map.empty (conf ^. L.tokens)
+      maybeTokenFile = Map.lookup tokenName tokenFileMap <|> (conf^.L.token)
+
+  case maybeTokenFile of
+    Just filename -> do
+      logFM InfoS (ls [fmt|AccessTokenProvider starting|])
+      provider <- newProvider filename
+      pure (Just provider)
+    Nothing ->
+      pure Nothing
+
+  where newProvider filename = do
+          readAction <- newReadAction filename
+          pure AccessTokenProvider { retrieveAccessToken = readAction
+                                   , releaseProvider     = pure () }
+
+newReadAction
+  :: ( MonadFilesystem m
+     , MonadUnliftIO m
+     , MonadCatch m
+     , KatipContext m )
+  => FilePath
+  -> m (m (AccessToken t))
+newReadAction filename = do
+  cache <- atomically $ newTVar (Left (toException AccessTokenProviderTokenMissing))
+  pure $
+    tryAny (fileRead filename) >>= \ case
+      Right bytes -> do
+        liftIO . atomically $ writeTVar cache (Right bytes)
+        pure (AccessToken (Text.decodeUtf8 bytes))
+      Left exn -> do
+        logFM ErrorS (ls [fmt|Failed to read token file '${filename}': $exn|])
+        liftIO (atomically (readTVar cache)) >>= \ case
+          Right bytes -> do
+            logFM WarningS (ls [fmt|Using cached token from '${filename}'|])
+            pure (AccessToken (Text.decodeUtf8 bytes))
+          Left _exn ->
+            throwM exn -- Return newer exception.
diff --git a/src/Security/AccessTokenProvider/Internal/Providers/Fixed.hs b/src/Security/AccessTokenProvider/Internal/Providers/Fixed.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/AccessTokenProvider/Internal/Providers/Fixed.hs
@@ -0,0 +1,54 @@
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE OverloadedStrings     #-}
+{-# LANGUAGE QuasiQuotes           #-}
+{-# LANGUAGE ScopedTypeVariables   #-}
+
+module Security.AccessTokenProvider.Internal.Providers.Fixed
+  ( providerProbeFixed
+  ) where
+
+import           Control.Applicative
+import           Control.Exception.Safe
+import           Control.Lens
+import           Control.Monad.IO.Class
+import           Data.Format
+import           Data.List.NonEmpty                                     (NonEmpty (..))
+import qualified Data.Map                                               as Map
+import           Data.Maybe
+import           Katip
+
+import qualified Security.AccessTokenProvider.Internal.Lenses           as L
+import           Security.AccessTokenProvider.Internal.Providers.Common
+import           Security.AccessTokenProvider.Internal.Types
+
+providerProbeFixed
+  :: (KatipContext m, MonadIO m, MonadThrow m, MonadEnvironment m)
+  => AccessTokenName
+  -> m (Maybe (AccessTokenProvider m t))
+providerProbeFixed tokenName = katipAddNamespace "probe-fixed" $
+  tryNewProvider tokenName makeEnvConf pure createEnvTokenProvider
+
+  where makeEnvConf = do
+          maybeToken <- environmentLookup  "TOKEN"
+          case maybeToken of
+            Just token -> pure . Just $ AtpConfFixed { _tokens = Just Map.empty
+                                                     , _token = Just token }
+            Nothing -> tryEnvDeserialization ("fixed" :| [])
+
+createEnvTokenProvider
+  :: (KatipContext m, MonadEnvironment m)
+  => AccessTokenName
+  -> AtpConfFixed
+  -> m (Maybe (AccessTokenProvider m t))
+createEnvTokenProvider (AccessTokenName tokenName) conf =
+  let tokensMap  = fromMaybe Map.empty (conf^.L.tokens)
+      maybeToken = (conf^.L.token) <|> Map.lookup tokenName tokensMap
+  in case maybeToken of
+       Just token -> do
+         logFM InfoS (ls [fmt|AccessTokenProvider started|])
+         pure . Just $ AccessTokenProvider
+           { retrieveAccessToken = pure (AccessToken token)
+           , releaseProvider = pure ()
+           }
+       Nothing ->
+         pure Nothing
diff --git a/src/Security/AccessTokenProvider/Internal/Providers/OAuth2/Ropcg.hs b/src/Security/AccessTokenProvider/Internal/Providers/OAuth2/Ropcg.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/AccessTokenProvider/Internal/Providers/OAuth2/Ropcg.hs
@@ -0,0 +1,303 @@
+{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE LambdaCase            #-}
+{-# LANGUAGE OverloadedStrings     #-}
+{-# LANGUAGE QuasiQuotes           #-}
+{-# LANGUAGE ScopedTypeVariables   #-}
+
+module Security.AccessTokenProvider.Internal.Providers.OAuth2.Ropcg
+  ( providerProbeRopcg
+  ) where
+
+import           Control.Applicative
+import           Control.Exception.Safe
+import           Control.Lens
+import           Control.Monad
+import           Control.Monad.IO.Class
+import           Control.Monad.IO.Unlift
+import           Data.Aeson
+import qualified Data.ByteString                                        as ByteString
+import qualified Data.ByteString.Base64                                 as B64
+import           Data.Format
+import           Data.List.NonEmpty                                     (NonEmpty (..))
+import qualified Data.Map                                               as Map
+import           Data.Maybe
+import           Data.Monoid
+import qualified Data.Text                                              as Text
+import qualified Data.Text.Encoding                                     as Text
+import           Katip
+import           Network.HTTP.Client
+import           Network.HTTP.Client.TLS
+import           Network.HTTP.Types
+import qualified System.Environment                                     as Env
+import           System.FilePath
+import           System.Random
+import           UnliftIO.Async
+import           UnliftIO.Concurrent
+import           UnliftIO.STM
+
+import qualified Security.AccessTokenProvider.Internal.Lenses           as L
+import           Security.AccessTokenProvider.Internal.Providers.Common
+import           Security.AccessTokenProvider.Internal.Types
+import           Security.AccessTokenProvider.Internal.Util
+
+providerProbeRopcg
+  :: ( MonadMask m
+     , MonadUnliftIO m
+     , KatipContext m
+     , MonadEnvironment m
+     , MonadHttp m
+     , MonadFilesystem m )
+  => AccessTokenName
+  -> m (Maybe (AccessTokenProvider m t))
+providerProbeRopcg tokenName =
+  tryNewProvider tokenName mkConf createRopcgConf
+    createTokenProviderResourceOwnerPasswordCredentials
+
+  where mkConf =
+          tryEnvDeserialization
+          ("resource-owner-password-credentials-grant" :| ["ropcg"])
+
+-- | Derive an authorization header from provided client credentials.
+makeBasicAuthorizationHeader
+  :: ClientCredentials
+  -> Header
+makeBasicAuthorizationHeader credentials =
+  let b64Token = [credentials^.L.clientId, credentials^.L.clientSecret]
+                 & map Text.encodeUtf8
+                 & ByteString.intercalate ":"
+                 & B64.encode
+  in ("Authorization", "Basic " <> b64Token)
+
+retrieveJson
+  :: (MonadFilesystem m, KatipContext m, FromJSON a, MonadCatch m)
+  => FilePath
+  -> m a
+retrieveJson filename = do
+  content <- fileRead filename
+  case eitherDecodeStrict content of
+    Right a      -> return a
+    Left  errMsgStr -> do
+      let errMsg = Text.pack errMsgStr
+      logFM ErrorS (ls [fmt|JSON deserialization error: $errMsg|])
+      throwM . AccessTokenProviderDeserialization $
+        [fmt|Failed to deserialize '${filename}': $errMsg|]
+
+-- | Retrieve credentials from credentials directory.
+retrieveCredentials
+  :: (MonadFilesystem m, KatipContext m, MonadCatch m)
+  => AtpConfRopcg
+  -> m Credentials
+retrieveCredentials conf = do
+  let baseDir = conf^.L.credentialsDirectory
+  userCred   <- retrieveJson
+                (prefixIfRelative baseDir (conf^.L.resourceOwnerPasswordFile))
+  clientCred <- retrieveJson
+                (prefixIfRelative baseDir (conf^.L.clientPasswordFile))
+  return Credentials { _user   = userCred
+                     , _client = clientCred }
+
+  where prefixIfRelative baseDir filename =
+          if isAbsolute filename
+          then filename
+          else baseDir </> filename
+
+maskHttpRequest
+  :: Request
+  -> Request
+maskHttpRequest req =
+  req { requestHeaders = maskHttpHeaders headers }
+  where headers = requestHeaders req
+
+        maskHttpHeaders = map maskHttpHeader
+
+        maskHttpHeader ("Authorization", _) = ("Authorization", "XXXXXXXXXXXXXXXX")
+        maskHttpHeader hdr = hdr
+
+-- | Environment variable expected to contain the path to the mint
+-- credentials.
+envCredentialsDirectory :: String
+envCredentialsDirectory = "CREDENTIALS_DIR"
+
+retrieveCredentialsDir
+  :: (MonadIO m, MonadThrow m)
+  => AtpPreconfRopcg
+  -> m FilePath
+retrieveCredentialsDir envConf =
+  case envConf^.L.credentialsDirectory of
+    Just dir ->
+      pure dir
+    Nothing  ->
+      liftIO (fromMaybe "." <$> Env.lookupEnv envCredentialsDirectory)
+
+createRopcgConf
+  :: (KatipContext m, MonadIO m, MonadCatch m)
+  => AtpPreconfRopcg
+  -> m AtpConfRopcg
+createRopcgConf envConf = do
+  authEndpoint         <- parseEndpoint "authentication" (envConf^.L.authEndpoint)
+  credentialsDirectory <- retrieveCredentialsDir envConf
+  let clientPasswordFile        = fromMaybe defaultClientPasswordFile
+                                  (envConf^.L.clientPasswordFile)
+  let resourceOwnerPasswordFile = fromMaybe defaultResourceOwnerPasswordFile
+                                  (envConf^.L.resourceOwnerPasswordFile)
+  let refreshTimeFactor         = fromMaybe defaultRefreshTimeFactor
+                          (envConf^.L.refreshTimeFactor)
+  manager <- liftIO $ newManager tlsManagerSettings
+  pure AtpConfRopcg
+    { _credentialsDirectory      = credentialsDirectory
+    , _clientPasswordFile        = clientPasswordFile
+    , _resourceOwnerPasswordFile = resourceOwnerPasswordFile
+    , _refreshTimeFactor         = refreshTimeFactor
+    , _authEndpoint              = authEndpoint
+    , _manager                   = manager
+    , _tokens                    = envConf^.L.tokens
+    , _token                     = envConf^.L.token
+    }
+
+  where defaultResourceOwnerPasswordFile = "user.json"
+        defaultClientPasswordFile = "client.json"
+
+-- | Main refreshing function.
+tryRefreshToken
+  :: ( MonadCatch m
+     , KatipContext m
+     , MonadHttp m
+     , MonadFilesystem m )
+  => AtpConfRopcg
+  -> AccessTokenName
+  -> AtpRopcgTokenDef
+  -> m AtpRopcgResponse
+tryRefreshToken conf tokenName tokenDef =
+  katipAddContext (sl "tokenName" tokenName) $
+  katipAddNamespace "refreshActionOne" $ do
+  credentials <- retrieveCredentials conf
+  let manager        = conf^.L.manager
+      bodyParameters =
+        [ ("grant_type", "password")
+        , ("username",   Text.encodeUtf8 (credentials^.L.user.L.applicationUsername))
+        , ("password",   Text.encodeUtf8 (credentials^.L.user.L.applicationPassword))
+        , ("scope",      packScopes (tokenDef^.L.scopes)) ]
+      authorization  = makeBasicAuthorizationHeader (credentials^.L.client)
+      httpRequest    = (conf^.L.authEndpoint) { method = "POST"
+                                              , requestHeaders = [authorization] }
+                       & urlEncodedBody bodyParameters
+      maskedRequest  = Text.pack . show $ maskHttpRequest httpRequest
+  logFM DebugS (ls [fmt|HTTP Request for token refreshing: $maskedRequest|])
+  response <- httpRequestExecute httpRequest manager
+  let status = responseStatus response
+      body   = responseBody response
+  when (status /= ok200) $ do
+    logFM ErrorS (ls [fmt|Failed to refresh token: ${tshow response}|])
+    throwM $ decodeOAuth2Error status body
+  case eitherDecode body :: Either String AtpRopcgResponse of
+    Right tokenResponse -> do
+      logFM DebugS (ls [fmt|Successfully refreshed token '${tokenName}'|])
+      pure tokenResponse
+    Left errMsgS -> do
+      let errMsg = Text.pack errMsgS
+      logFM ErrorS (ls [fmt|Deserialization of token response failed: $errMsg|])
+      throwM $ AccessTokenProviderDeserialization errMsg
+
+  where packScopes = ByteString.intercalate " " . map Text.encodeUtf8
+
+        decodeOAuth2Error status body =
+          case decode body of
+            Just problem ->
+              AccessTokenProviderRefreshFailure problem
+            Nothing ->
+              AccessTokenProviderDeserialization $
+                [fmt|Deserialization of OAuth2 error object failed; response status: ${tshow status}'|]
+
+tokenRefreshLoop
+  :: ( MonadCatch m
+     , KatipContext m
+     , MonadHttp m
+     , MonadFilesystem m )
+  => AtpConfRopcg
+  -> AccessTokenName
+  -> AtpRopcgTokenDef
+  -> TMVar (Either SomeException (AccessToken t))
+  -> m ()
+tokenRefreshLoop conf tokenName tokenDef cache = forever $ do
+  eitherTokenResponse <- tryAny (tryRefreshToken conf tokenName tokenDef)
+  atomically $ do
+    let eitherToken = AccessToken . view L.accessToken <$> eitherTokenResponse
+    isEmptyTMVar cache >>= \ case
+      True -> putTMVar cache eitherToken
+      False -> void $ swapTMVar cache eitherToken
+  secondsToWait <- computeDurationToWait eitherTokenResponse
+  let microsToWait = round $ secondsToWait * 10^(6 :: Int)
+  threadDelay microsToWait
+
+  where -- Returns duration in seconds.
+        computeDurationToWait
+          :: KatipContext m
+          => Either SomeException AtpRopcgResponse
+          -> m Double
+        computeDurationToWait eitherTokenResponse =
+          case eitherTokenResponse of
+              Right tokenResponse ->
+                case tokenResponse^.L.expiresIn of
+                  Just expiresIn ->
+                    pure $ conf^.L.refreshTimeFactor * fromIntegral expiresIn
+                  Nothing ->
+                    pure defaultRefreshInterval
+              Left exn -> do
+                logFM ErrorS (ls [fmt|Failed to refresh token '${tokenName}': $exn|])
+                liftIO $ randomRIO (1, 10) -- Some jitter: wait 1 - 10 seconds.
+
+-- | In seconds.
+defaultRefreshInterval :: Double
+defaultRefreshInterval = 60
+
+-- | By default, we start refreshing tokens after 80% of the
+-- "expires_in" time of a token has been elapsed.
+defaultRefreshTimeFactor :: Double
+defaultRefreshTimeFactor = 0.8
+
+createTokenProviderResourceOwnerPasswordCredentials
+  :: ( MonadUnliftIO m
+     , MonadMask m
+     , KatipContext m
+     , MonadHttp m
+     , MonadFilesystem m )
+  => AccessTokenName
+  -> AtpConfRopcg
+  -> m (Maybe (AccessTokenProvider m t))
+createTokenProviderResourceOwnerPasswordCredentials tokenName conf = do
+  let (AccessTokenName tokenNameText) = tokenName
+      maybeTokenDef = Map.lookup tokenNameText (conf^.L.tokens)
+                      <|> (conf^.L.token)
+
+  case maybeTokenDef of
+    Just tokenDef -> do
+      logFM InfoS (ls [fmt|AccessTokenProvider starting|])
+      provider <- newProvider tokenDef
+      pure (Just provider)
+    Nothing ->
+      pure Nothing
+
+  where newProvider tokenDef = do
+          (retrieveAction, releaseAction) <- newRetrieveAction conf tokenName tokenDef
+          pure AccessTokenProvider { retrieveAccessToken = retrieveAction
+                                   , releaseProvider     = releaseAction }
+newRetrieveAction
+  :: ( KatipContext m
+     , MonadUnliftIO m
+     , MonadCatch m
+     , MonadHttp m
+     , MonadFilesystem m )
+  => AtpConfRopcg
+  -> AccessTokenName
+  -> AtpRopcgTokenDef
+  -> m (m (AccessToken t), m ())
+newRetrieveAction conf tokenName tokenDef = do
+  cache <- atomically newEmptyTMVar
+  asyncHandle <- async $ tokenRefreshLoop conf tokenName tokenDef cache
+  link asyncHandle
+  pure $ do
+    let retrieveAction = atomically (readTMVar cache) >>= \ case
+          Right token -> pure token
+          Left exn    -> throwM exn
+        releaseAction = cancel asyncHandle
+    (retrieveAction, releaseAction)
diff --git a/src/Security/AccessTokenProvider/Internal/Types.hs b/src/Security/AccessTokenProvider/Internal/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/AccessTokenProvider/Internal/Types.hs
@@ -0,0 +1,209 @@
+{-# LANGUAGE DefaultSignatures          #-}
+{-# LANGUAGE DeriveGeneric              #-}
+{-# LANGUAGE DuplicateRecordFields      #-}
+{-# LANGUAGE GADTs                      #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE KindSignatures             #-}
+{-# LANGUAGE OverloadedStrings          #-}
+{-# LANGUAGE RankNTypes                 #-}
+{-# LANGUAGE TemplateHaskell            #-}
+{-# LANGUAGE TypeFamilies               #-}
+
+module Security.AccessTokenProvider.Internal.Types where
+
+import           Control.Arrow
+import           Control.Exception
+import           Control.Monad.IO.Class
+import           Control.Monad.Reader
+import           Control.Monad.Trans.Maybe
+import           Control.Monad.Trans.State
+import           Data.Aeson
+import           Data.Aeson.Casing
+import           Data.Aeson.TH
+import           Data.ByteString           (ByteString)
+import qualified Data.ByteString           as ByteString
+import qualified Data.ByteString.Lazy      as ByteString.Lazy
+import           Data.Format
+import           Data.Map.Strict           (Map)
+import           Data.String
+import           Data.Text                 (Text)
+import qualified Data.Text                 as Text
+import           Data.Typeable
+import           GHC.Generics
+import           Katip
+import           Network.HTTP.Client
+import qualified System.Environment        as Env
+
+type LazyByteString = ByteString.Lazy.ByteString
+
+data AccessTokenProvider (m :: * -> * ) t =
+  AccessTokenProvider { retrieveAccessToken :: m (AccessToken t)
+                      , releaseProvider     :: m () }
+
+newtype AccessToken t =
+  AccessToken { unAccessToken :: Text
+              } deriving (Eq, Ord, Show)
+
+newtype AccessTokenName = AccessTokenName Text
+  deriving (Eq, Ord, Show, IsString)
+
+$(deriveJSON defaultOptions ''AccessTokenName)
+
+instance Format AccessTokenName where
+  formatText (AccessTokenName tokenName) = tokenName
+
+data AtpConfFixed =
+  AtpConfFixed { _tokens :: Maybe (Map Text Text)
+               , _token  :: Maybe Text
+               } deriving (Eq, Show, Generic)
+
+$(deriveJSON (aesonDrop 1 snakeCase) ''AtpConfFixed)
+
+data AtpConfFile =
+  AtpConfFile { _tokens :: Maybe (Map Text FilePath)
+              , _token  :: Maybe FilePath
+              } deriving (Eq, Show, Generic)
+
+$(deriveJSON (aesonDrop 1 snakeCase) ''AtpConfFile)
+
+newtype AtpRopcgTokenDef =
+  AtpRopcgTokenDef { _scopes :: [Text]
+                   } deriving (Eq, Show, Generic)
+
+$(deriveJSON (aesonDrop 1 snakeCase) ''AtpRopcgTokenDef)
+
+data AtpPreconfRopcg =
+  AtpPreconfRopcg
+  { _credentialsDirectory      :: Maybe FilePath
+  , _clientPasswordFile        :: Maybe FilePath
+  , _resourceOwnerPasswordFile :: Maybe FilePath
+  , _refreshTimeFactor         :: Maybe Double
+  , _authEndpoint              :: Text
+  , _tokens                    :: Map Text AtpRopcgTokenDef
+  , _token                     :: Maybe AtpRopcgTokenDef
+  } deriving (Eq, Show, Generic)
+
+$(deriveJSON (aesonDrop 1 snakeCase) ''AtpPreconfRopcg)
+
+data AtpConfRopcg =
+  AtpConfRopcg
+  { _credentialsDirectory      :: FilePath
+  , _clientPasswordFile        :: FilePath
+  , _resourceOwnerPasswordFile :: FilePath
+  , _refreshTimeFactor         :: Double
+  , _authEndpoint              :: Request
+  , _manager                   :: Manager
+  , _tokens                    :: Map Text AtpRopcgTokenDef
+  , _token                     :: Maybe AtpRopcgTokenDef
+  } deriving (Generic)
+
+-- | Type modelling the content of the credentials stored in a
+-- client.json file.
+data ClientCredentials =
+  ClientCredentials { _clientId     :: Text
+                    , _clientSecret :: Text
+                    } deriving (Generic, Show, Eq)
+
+$(deriveJSON (aesonDrop 1 snakeCase) ''ClientCredentials)
+
+
+-- | Type modelling the content of the credentials stored in a
+-- user.json file.
+data UserCredentials =
+  UserCredentials { _applicationUsername :: Text
+                  , _applicationPassword :: Text
+                  } deriving (Generic, Show, Eq)
+
+$(deriveJSON (aesonDrop 1 snakeCase) ''UserCredentials)
+
+-- | Type for RFC7807 @Problem@ objects.
+data OAuth2Error = OAuth2Error
+  { oauth2Error            :: Text
+  , oauth2ErrorDescription :: Maybe Text
+  , oauth2ErrorURI         :: Maybe Text
+  , oauth2ErrorState       :: Maybe Text
+  } deriving (Show, Eq, Generic)
+
+instance ToJSON OAuth2Error where
+   toJSON = genericToJSON $ aesonDrop 6 snakeCase
+instance FromJSON OAuth2Error where
+   parseJSON = genericParseJSON $ aesonDrop 6 snakeCase
+
+
+data AccessTokenProviderException
+  = AccessTokenProviderRefreshFailure OAuth2Error
+  | AccessTokenProviderDeserialization Text
+  | AccessTokenProviderTokenMissing
+  | AccessTokenProviderMissing AccessTokenName
+ deriving (Typeable, Show)
+
+instance Exception AccessTokenProviderException
+
+-- | Type containing all credentials read from a mint credentials
+-- directory.
+data Credentials =
+  Credentials { _user   :: UserCredentials
+              , _client :: ClientCredentials }
+
+data AtpRopcgResponse =
+  AtpRopcgResponse { _scope       :: Maybe Text
+                   , _expiresIn   :: Maybe Int -- Validity in seconds
+                   , _tokenType   :: Text
+                   , _accessToken :: Text
+                   } deriving (Generic, Show, Eq)
+
+$(deriveJSON (aesonDrop 1 snakeCase) ''AtpRopcgResponse)
+
+newtype AtpProbe m =
+  AtpProbe (forall t. AccessTokenName -> m (Maybe (AccessTokenProvider m t)) )
+
+class Monad m => MonadFilesystem m where
+  fileRead :: FilePath -> m ByteString
+  default fileRead :: (m ~ t n, MonadTrans t, MonadFilesystem n) => FilePath -> m ByteString
+  fileRead = lift . fileRead
+
+instance MonadFilesystem IO where
+  fileRead = ByteString.readFile
+
+instance MonadFilesystem m => MonadFilesystem (ReaderT r m)
+instance MonadFilesystem m => MonadFilesystem (MaybeT m)
+instance MonadFilesystem m => MonadFilesystem (KatipContextT m)
+instance MonadFilesystem m => MonadFilesystem (KatipT m)
+instance MonadFilesystem m => MonadFilesystem (StateT s m)
+
+class Monad m => MonadEnvironment m where
+  environmentLookup :: Text -> m (Maybe Text)
+  default environmentLookup :: (m ~ t n, MonadTrans t, MonadEnvironment n)
+                            => Text
+                            -> m (Maybe Text)
+  environmentLookup = lift . environmentLookup
+
+instance MonadEnvironment IO where
+  environmentLookup =
+    Text.unpack
+    >>> Env.lookupEnv
+    >>> liftIO
+    >>> fmap (fmap Text.pack)
+
+instance MonadEnvironment m => MonadEnvironment (ReaderT r m)
+instance MonadEnvironment m => MonadEnvironment (MaybeT m)
+instance MonadEnvironment m => MonadEnvironment (KatipContextT m)
+instance MonadEnvironment m => MonadEnvironment (KatipT m)
+instance MonadEnvironment m => MonadEnvironment (StateT s m)
+
+class Monad m => MonadHttp m where
+  httpRequestExecute :: Request -> Manager -> m (Response  LazyByteString)
+  default httpRequestExecute :: (m ~ t n, MonadTrans t, MonadHttp n)
+                             => Request
+                             -> Manager
+                             -> m (Response  LazyByteString)
+  httpRequestExecute req manager = lift $ httpRequestExecute req manager
+
+instance MonadHttp IO where
+  httpRequestExecute = httpLbs
+
+instance MonadHttp m => MonadHttp (ReaderT r m)
+instance MonadHttp m => MonadHttp (MaybeT m)
+instance MonadHttp m => MonadHttp (KatipContextT m)
+instance MonadHttp m => MonadHttp (KatipT m)
+instance MonadHttp m => MonadHttp (StateT s m)
diff --git a/src/Security/AccessTokenProvider/Internal/Util.hs b/src/Security/AccessTokenProvider/Internal/Util.hs
new file mode 100644
--- /dev/null
+++ b/src/Security/AccessTokenProvider/Internal/Util.hs
@@ -0,0 +1,54 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes       #-}
+
+module Security.AccessTokenProvider.Internal.Util
+  ( throwDecode
+  , tshow
+  , parseEndpoint
+  , throwDecodeValue
+  ) where
+
+import           Control.Exception.Safe
+import           Control.Monad.IO.Class
+import           Data.Aeson
+import           Data.ByteString                             (ByteString)
+import qualified Data.ByteString.Lazy                        as ByteString.Lazy
+import           Data.Format
+import           Data.Text                                   (Text)
+import qualified Data.Text                                   as Text
+import           Katip
+import           Network.HTTP.Client
+
+import           Security.AccessTokenProvider.Internal.Types
+
+throwDecode :: (MonadThrow m, FromJSON a) => ByteString -> m a
+throwDecode bytes =
+  case eitherDecode (ByteString.Lazy.fromStrict bytes) of
+    Right a     ->
+      pure a
+    Left errMsg ->
+      throwM $ AccessTokenProviderDeserialization (Text.pack errMsg)
+
+throwDecodeValue :: (MonadThrow m, FromJSON a) => Value -> m a
+throwDecodeValue val =
+  case fromJSON val of
+    Success a ->
+      pure a
+    Error errMsg ->
+      throwM $ AccessTokenProviderDeserialization (Text.pack errMsg)
+
+parseEndpoint
+  :: (KatipContext m, MonadIO m, MonadCatch m)
+  => Text
+  -> Text
+  -> m Request
+parseEndpoint label endpoint =
+  parseRequest (Text.unpack endpoint) `catchAny` \ exn -> do
+    logFM ErrorS (ls [fmt|Failed to parse $label endpoint '${endpoint}': $exn|])
+    throwM exn
+
+tshow
+  :: Show a
+  => a
+  -> Text
+tshow = Text.pack . show
diff --git a/tests/Security/AccessTokenProvider/Internal/Providers/Test.hs b/tests/Security/AccessTokenProvider/Internal/Providers/Test.hs
new file mode 100644
--- /dev/null
+++ b/tests/Security/AccessTokenProvider/Internal/Providers/Test.hs
@@ -0,0 +1,199 @@
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE QuasiQuotes         #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module Security.AccessTokenProvider.Internal.Providers.Test
+  ( securityAccessTokenProviderInternalProvidersTest
+  ) where
+
+import           Control.Exception.Safe
+import           Control.Lens
+import           Control.Monad.IO.Class
+import qualified Data.ByteString.Lazy                       as ByteString.Lazy
+import           Data.Format
+import qualified Data.Map.Strict                            as Map
+import qualified Data.Text                                  as Text
+import qualified Data.Text.Encoding                         as Text
+import           Data.UUID                                  (UUID)
+import           Katip
+import           Network.HTTP.Client.Internal
+import           Network.HTTP.Types.Status
+import           Network.HTTP.Types.Version
+import           System.Random
+import           Test.Tasty
+import           Test.Tasty.HUnit
+import           UnliftIO.Concurrent
+
+import           Security.AccessTokenProvider
+import qualified Security.AccessTokenProvider               as ATP
+import           Security.AccessTokenProvider.Internal.Util
+import           Test
+
+securityAccessTokenProviderInternalProvidersTest :: [TestTree]
+securityAccessTokenProviderInternalProvidersTest =
+  [ testGroup "Security.AccessTokenProvider.Internal.Providers"
+    [ testCase "Fixed Provider reads from TOKEN"
+        fixedProviderReadsFromToken
+    , testCase "Fixed Provider reads from ATP_CONF"
+        fixedProviderReadsFromConf
+    , testCase "Fixed Provider reads from ATP_CONF and lookup fails"
+        fixedProviderReadsFromConfLookupFails
+    , testCase "File Provider reads from TOKEN_FILE"
+        fileProviderReadsFromTokenFile
+    , testCase "File Provider reads from ATP_CONF"
+        fileProviderReadsFromConf
+    , testCase "File Provider reads from ATP_CONF and lookup fails"
+        fileProviderReadsFromConfLookupFails
+    , testCase "Ropcg Provider reads from ATP_CONF"
+        ropcgProviderReadsFromConf
+    ]
+  ]
+
+fixedProviderReadsFromToken :: Assertion
+fixedProviderReadsFromToken = do
+  token <- tshow <$> (randomIO :: IO UUID)
+  let testState = TestState
+                  { _testStateFilesystem = Map.empty
+                  , _testStateEnvironment = Map.fromList [ ("TOKEN", token) ]
+                  , _testStateHttpResponse = Nothing
+                  , _testStateHttpRequests = []
+                  }
+  evalTestStack testState $ do
+    tokenProvider <- new (AccessTokenName "some-random-token-name")
+    (AccessToken token') <- retrieveAccessToken tokenProvider
+    liftIO $ token @=? token'
+
+fixedProviderReadsFromConf :: Assertion
+fixedProviderReadsFromConf = do
+  token <- tshow <$> (randomIO :: IO UUID)
+  let conf = [fmt|{"provider": "fixed", "tokens": {"label1": "$token"}}|]
+      testState = TestState
+                  { _testStateFilesystem = Map.empty
+                  , _testStateEnvironment = Map.fromList [ ("ATP_CONF", conf) ]
+                  , _testStateHttpResponse = Nothing
+                  , _testStateHttpRequests = []
+                  }
+  evalTestStack testState $ do
+    tokenProvider <- new (AccessTokenName "label1")
+    (AccessToken token') <- retrieveAccessToken tokenProvider
+    liftIO $ token @=? token'
+
+fixedProviderReadsFromConfLookupFails :: Assertion
+fixedProviderReadsFromConfLookupFails = do
+  token <- tshow <$> (randomIO :: IO UUID)
+  let conf = [fmt|{"provider": "fixed", "tokens": {"label1": "$token"}}|]
+      testState = TestState
+                  { _testStateFilesystem = Map.empty
+                  , _testStateEnvironment = Map.fromList [ ("ATP_CONF", conf) ]
+                  , _testStateHttpResponse = Nothing
+                  , _testStateHttpRequests = []
+                  }
+  evalTestStack testState $ do
+    Left _ <- tryAny $ new (AccessTokenName "label2")
+    pure ()
+
+fileProviderReadsFromTokenFile :: Assertion
+fileProviderReadsFromTokenFile = do
+  tokenText <- tshow <$> (randomIO :: IO UUID)
+  let tokenBytes = Text.encodeUtf8 tokenText
+      filename = "/a/b/c"
+      testState = TestState
+                  { _testStateFilesystem =
+                      Map.fromList [ (filename, tokenBytes) ]
+                  , _testStateEnvironment =
+                      Map.fromList [ ("TOKEN_FILE", Text.pack filename) ]
+                  , _testStateHttpResponse = Nothing
+                  , _testStateHttpRequests = []
+                  }
+  evalTestStack testState $ do
+    tokenProvider <- new (AccessTokenName "some-random-token-name")
+    (AccessToken token') <- retrieveAccessToken tokenProvider
+    liftIO $ tokenText @=? token'
+
+fileProviderReadsFromConf :: Assertion
+fileProviderReadsFromConf = do
+  tokenText <- tshow <$> (randomIO :: IO UUID)
+  let tokenBytes = Text.encodeUtf8 tokenText
+      filename = "/a/b/c"
+      conf = [fmt|{"provider": "file", "tokens": {"label1": "$filename"}}|]
+      testState = TestState
+                  { _testStateFilesystem =
+                      Map.fromList [ (filename, tokenBytes) ]
+                  , _testStateEnvironment =
+                      Map.fromList [ ("ATP_CONF", conf) ]
+                  , _testStateHttpResponse = Nothing
+                  , _testStateHttpRequests = []
+                  }
+  evalTestStack testState $ do
+    tokenProvider <- new (AccessTokenName "label1")
+    (AccessToken token') <- retrieveAccessToken tokenProvider
+    liftIO $ tokenText @=? token'
+
+fileProviderReadsFromConfLookupFails :: Assertion
+fileProviderReadsFromConfLookupFails = do
+  tokenText <- tshow <$> (randomIO :: IO UUID)
+  let tokenBytes = Text.encodeUtf8 tokenText
+      filename = "/a/b/c"
+      conf = [fmt|{"provider": "file", "tokens": {"label1": "$filename"}}|]
+      testState = TestState
+                  { _testStateFilesystem =
+                      Map.fromList [ (filename, tokenBytes) ]
+                  , _testStateEnvironment =
+                      Map.fromList [ ("ATP_CONF", conf) ]
+                  , _testStateHttpResponse = Nothing
+                  , _testStateHttpRequests = []
+                  }
+  evalTestStack testState $ do
+    Left _ <- tryAny $ new (AccessTokenName "label2")
+    pure ()
+
+ropcgProviderReadsFromConf :: Assertion
+ropcgProviderReadsFromConf = do
+  tokenText <- tshow <$> (randomIO :: IO UUID)
+  let conf = [fmt|{ "provider": "ropcg",
+                    "credentials_directory": "/credentials",
+                    "auth_endpoint": "https://localhost",
+                    "tokens": {"label1": {"scopes": ["foo"]}}
+                  }|]
+      responseBody = ByteString.Lazy.fromStrict . Text.encodeUtf8 $
+        [fmt|{"scope":        "foo",
+              "expires_in":   60,
+              "token_type":   "test",
+              "access_token": "$tokenText"
+             }|]
+      response = Response { responseStatus    = ok200
+                          , responseVersion   = http20
+                          , responseHeaders   = []
+                          , responseBody      = responseBody
+                          , responseCookieJar = CJ []
+                          , responseClose'    = ResponseClose (pure ())
+                          }
+      userCredentials =
+        "{ \"application_username\": \"some-application-username\", \
+        \  \"application_password\": \"some-application-password\" }"
+      clientCredentials =
+        "{ \"client_id\":     \"some-client-id\", \
+        \  \"client_secret\": \"some-client-secret\" }"
+
+      testState = TestState
+                  { _testStateFilesystem   = Map.fromList
+                    [ ("/credentials/user.json",   userCredentials)
+                    , ("/credentials/client.json", clientCredentials)
+                    ]
+                  , _testStateEnvironment  = Map.fromList [ ("ATP_CONF", conf) ]
+                  , _testStateHttpResponse = Just response
+                  , _testStateHttpRequests = []
+                  }
+  (_, testState') <- runTestStack testState $ do
+    tokenProvider <- new (AccessTokenName "label1")
+    (AccessToken token) <- retrieveAccessToken tokenProvider
+    liftIO $ tokenText @=? token
+    pure ()
+  1 @=? length (testState'^.testStateHttpRequests)
+  pure ()
+
+retrieveSomeToken :: KatipContextT IO ()
+retrieveSomeToken = do
+  tokenProvider <- ATP.new (AccessTokenName "token-name")
+  token <- ATP.retrieveAccessToken tokenProvider
+  liftIO $ print token
diff --git a/tests/Security/AccessTokenProvider/Internal/Test.hs b/tests/Security/AccessTokenProvider/Internal/Test.hs
new file mode 100644
--- /dev/null
+++ b/tests/Security/AccessTokenProvider/Internal/Test.hs
@@ -0,0 +1,16 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Security.AccessTokenProvider.Internal.Test
+  ( securityAccessTokenProviderInternalTest
+  ) where
+
+import           Security.AccessTokenProvider.Internal.Providers.Test
+import           Security.AccessTokenProvider.Internal.Util.Test
+import           Test.Tasty
+
+securityAccessTokenProviderInternalTest :: [TestTree]
+securityAccessTokenProviderInternalTest =
+  [ testGroup "Security.AccessTokenProvider.Internal" $
+    concat [ securityAccessTokenProviderInternalUtilTest
+           , securityAccessTokenProviderInternalProvidersTest ]
+  ]
diff --git a/tests/Security/AccessTokenProvider/Internal/Util/Test.hs b/tests/Security/AccessTokenProvider/Internal/Util/Test.hs
new file mode 100644
--- /dev/null
+++ b/tests/Security/AccessTokenProvider/Internal/Util/Test.hs
@@ -0,0 +1,36 @@
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module Security.AccessTokenProvider.Internal.Util.Test
+  ( securityAccessTokenProviderInternalUtilTest
+  ) where
+
+import           Control.Exception.Safe
+import           Data.Aeson                                  (Value)
+import           Test.Tasty
+import           Test.Tasty.HUnit
+
+import           Security.AccessTokenProvider.Internal.Types
+import           Security.AccessTokenProvider.Internal.Util
+
+securityAccessTokenProviderInternalUtilTest :: [TestTree]
+securityAccessTokenProviderInternalUtilTest =
+  [ testGroup "Security.AccessTokenProvider.Internal.Util"
+    [ testCase "throwDecode throws on deserialization failure"
+        throwDecodeThrowsDeserializationFailure
+    , testCase "throwDecode deserializes JSON"
+        throwDecodeDeserializes
+    ]
+  ]
+
+throwDecodeThrowsDeserializationFailure :: Assertion
+throwDecodeThrowsDeserializationFailure = do
+  let bs = "[1, 2, 3}"
+  Left _res :: Either AccessTokenProviderException [Int] <- try $ throwDecode bs
+  pure ()
+
+throwDecodeDeserializes :: Assertion
+throwDecodeDeserializes = do
+  let bs = "{\"numbers\": [1, 2, 3]}"
+  _res :: Value <- throwDecode bs
+  pure ()
diff --git a/tests/Security/AccessTokenProvider/Test.hs b/tests/Security/AccessTokenProvider/Test.hs
new file mode 100644
--- /dev/null
+++ b/tests/Security/AccessTokenProvider/Test.hs
@@ -0,0 +1,11 @@
+module Security.AccessTokenProvider.Test
+  ( securityAccessTokenProviderTests
+  ) where
+
+import           Security.AccessTokenProvider.Internal.Test
+import           Test.Tasty
+
+securityAccessTokenProviderTests :: TestTree
+securityAccessTokenProviderTests =
+  testGroup "Security.AccessTokenProvider"
+  securityAccessTokenProviderInternalTest
diff --git a/tests/Spec.hs b/tests/Spec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Spec.hs
@@ -0,0 +1,17 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import           Test.Tasty
+
+import           Security.AccessTokenProvider.Test
+
+main :: IO ()
+main = do
+  putStrLn ""
+  defaultMain tests
+
+tests :: TestTree
+tests =
+  testGroup "Access Token Provider Test Suite"
+    [securityAccessTokenProviderTests]
diff --git a/tests/Test.hs b/tests/Test.hs
new file mode 100644
--- /dev/null
+++ b/tests/Test.hs
@@ -0,0 +1,117 @@
+{-# LANGUAGE DeriveFunctor              #-}
+{-# LANGUAGE FlexibleContexts           #-}
+{-# LANGUAGE FlexibleInstances          #-}
+{-# LANGUAGE FunctionalDependencies     #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE MultiParamTypeClasses      #-}
+{-# LANGUAGE OverloadedStrings          #-}
+{-# LANGUAGE TemplateHaskell            #-}
+{-# LANGUAGE TupleSections              #-}
+{-# LANGUAGE TypeSynonymInstances       #-}
+
+
+module Test where
+
+import           Control.Arrow
+import           Control.Lens
+import           Control.Monad.Catch                         hiding (bracket)
+import           Control.Monad.IO.Class
+import           Control.Monad.IO.Unlift
+import           Control.Monad.Reader
+import           Control.Monad.State
+import           Data.ByteString                             (ByteString)
+import qualified Data.ByteString.Lazy                        as ByteString.Lazy
+import           Data.IORef
+import           Data.Map.Strict                             (Map)
+import qualified Data.Map.Strict                             as Map
+import           Data.Text                                   (Text)
+import           Katip
+import           Katip.Monadic
+import           Network.HTTP.Client
+import           System.IO
+import           UnliftIO.Exception
+
+import           Security.AccessTokenProvider.Internal.Types
+
+runTestStack :: TestState -> TestStack a -> IO (a, TestState)
+runTestStack testState m = do
+  s <- newIORef testState
+  a <- m & (_runTestStack
+            >>> runKatip
+            >>> flip runReaderT s)
+  (a,) <$> readIORef s
+
+runKatip :: MonadUnliftIO m => KatipContextT m a -> m a
+runKatip m = do
+  handleScribe <- liftIO $ mkHandleScribe ColorIfTerminal stdout WarningS V2
+  let makeLogEnv = do
+        logEnv <- initLogEnv "test-suite" "test"
+        registerScribe "stdout" handleScribe defaultScribeSettings logEnv
+  bracket (liftIO makeLogEnv) (liftIO . closeScribes) $ \ logEnv -> do
+    let initialContext = ()
+    let initialNamespace = "main"
+    runKatipContextT logEnv initialContext initialNamespace m
+
+evalTestStack :: TestState -> TestStack a -> IO a
+evalTestStack testState m = do
+  s <- newIORef testState
+  m & (_runTestStack
+       >>> runKatip
+       >>> flip runReaderT s)
+
+newtype TestStack a = TestStack
+  { _runTestStack :: KatipContextT (ReaderT (IORef TestState) IO) a
+  } deriving ( Functor
+             , Applicative
+             , Monad
+             , MonadThrow
+             , MonadCatch
+             , MonadMask
+             , MonadReader (IORef TestState)
+             , MonadIO
+             , Katip
+             , KatipContext
+             )
+
+instance MonadUnliftIO TestStack where
+  askUnliftIO = do
+    (UnliftIO u) <- TestStack askUnliftIO
+    pure $ UnliftIO (\ (TestStack m) -> u m)
+
+data TestState =
+  TestState { _testStateFilesystem   :: Map FilePath ByteString
+            , _testStateEnvironment  :: Map Text Text
+            , _testStateHttpRequests :: [Request]
+            , _testStateHttpResponse :: Maybe (Response ByteString.Lazy.ByteString)
+            }
+
+makeFieldsNoPrefix ''TestState
+
+instance MonadState TestState TestStack where
+  get = do
+    envRef <- ask
+    liftIO $ readIORef envRef
+  put s = do
+    envRef <- ask
+    liftIO $ writeIORef envRef s
+
+instance MonadEnvironment TestStack where
+  environmentLookup name =
+    Map.lookup name <$> gets (view testStateEnvironment)
+
+instance MonadFilesystem TestStack where
+  fileRead filename = do
+    fs <- gets (view testStateFilesystem)
+    case Map.lookup filename fs of
+      Just bytes -> pure bytes
+      _          -> error "FIXME: file not found"
+
+instance MonadHttp TestStack where
+  httpRequestExecute request _manager = do
+    testStateHttpRequests %= (request :)
+    maybeResponse <- gets (view testStateHttpResponse)
+    case maybeResponse of
+      Just response ->
+        pure response
+      Nothing ->
+        error "FIXME"
