diff --git a/hoauth2-providers.cabal b/hoauth2-providers.cabal
--- a/hoauth2-providers.cabal
+++ b/hoauth2-providers.cabal
@@ -1,10 +1,8 @@
 cabal-version: 2.4
 name:          hoauth2-providers
-version:       0.2
+version:       0.3.0
 synopsis:      OAuth2 Identity Providers
-description:
-  A list of Identity Providers base on hoauth2 Haskell OAuth2 binding
-
+description:   A few well known Identity Providers
 homepage:      https://github.com/freizl/hoauth2
 license:       MIT
 license-file:  LICENSE
@@ -14,9 +12,7 @@
 category:      Network
 build-type:    Simple
 stability:     Beta
-tested-with:   GHC <=9.2.2
-
--- extra-source-files:
+tested-with:   GHC <=9.6.1
 
 source-repository head
   type:     git
@@ -30,52 +26,77 @@
     DeriveGeneric
     GeneralizedNewtypeDeriving
     ImportQualifiedPost
+    InstanceSigs
     OverloadedStrings
+    PolyKinds
     RecordWildCards
     TypeApplications
     TypeFamilies
 
-  -- other-modules:
-
   exposed-modules:
+    Data.ByteString.Contrib
+    Network.OAuth2.Provider
     Network.OAuth2.Provider.Auth0
     Network.OAuth2.Provider.AzureAD
-    Network.OAuth2.Provider.Dropbox
+    Network.OAuth2.Provider.DropBox
     Network.OAuth2.Provider.Facebook
     Network.OAuth2.Provider.Fitbit
-    Network.OAuth2.Provider.Github
+    Network.OAuth2.Provider.GitHub
     Network.OAuth2.Provider.Google
-    Network.OAuth2.Provider.Linkedin
+    Network.OAuth2.Provider.LinkedIn
     Network.OAuth2.Provider.Okta
     Network.OAuth2.Provider.Slack
     Network.OAuth2.Provider.StackExchange
     Network.OAuth2.Provider.Twitter
-    Network.OAuth2.Provider.Utils
     Network.OAuth2.Provider.Weibo
     Network.OAuth2.Provider.ZOHO
     Network.OIDC.WellKnown
 
   build-depends:
-    , aeson                 >=2.0    && <2.2
-    , base                  >=4.5    && <5
-    , bytestring            >=0.9    && <0.12
+    , aeson                 >=2.0   && <2.2
+    , base                  >=4.5   && <5
+    , bytestring            >=0.9   && <0.12
     , containers            ^>=0.6
-    , cryptonite            >=0.30   && <0.31
-    , data-default          ^>=0.7
-    , directory             ^>=1.3
-    , hoauth2               >=2.7
-    , HsOpenSSL             >=0.11   && <0.12
-    , http-conduit          >=2.1    && <2.4
-    , http-types            >=0.11   && <0.13
-    , jose-jwt              >=0.9.4  && <0.10
-    , mtl
-    , parsec                >=3.1.11 && <3.2.0
-    , text                  >=0.11   && <1.3
-    , time                  >=1.12   && <1.13
-    , transformers          ^>=0.5
-    , unordered-containers  >=0.2.5
-    , uri-bytestring        >=0.2.3  && <0.4
+    , cryptonite            >=0.30  && <0.31
+    , hoauth2               ^>=2.9
+    , HsOpenSSL             >=0.11  && <0.12
+    , http-conduit          >=2.1   && <2.4
+    , http-types            >=0.11  && <0.13
+    , jose-jwt              >=0.9.4 && <0.10
+    , mtl                   >=2.0   && <2.4
+    , text                  >=0.11  && <2.1
+    , time                  >=1.12  && <1.13
+    , transformers          >=0.4   && <0.7
+    , uri-bytestring        >=0.2.3 && <0.4
+    , uri-bytestring-aeson  ^>=0.1
 
   ghc-options:
     -Wall -Wtabs -Wno-unused-do-bind -Wunused-packages -Wpartial-fields
-    -Wwarnings-deprecations
+    -Wwarn -Wwarnings-deprecations
+
+  -- -Wno-missing-signatures
+  if impl(ghc <9.4)
+    ghc-options: -Wno-unticked-promoted-constructors
+
+test-suite hoauth-providers-tests
+  type:               exitcode-stdio-1.0
+  main-is:            Spec.hs
+  hs-source-dirs:     test
+  ghc-options:        -Wall
+  build-depends:
+    , aeson              >=2.0   && <2.2
+    , base               >=4     && <5
+    , hoauth2-providers
+    , hspec              >=2     && <3
+    , uri-bytestring     >=0.2.3 && <0.4
+
+  other-modules:      Network.OIDC.WellKnownSpec
+  default-language:   Haskell2010
+  default-extensions:
+    ImportQualifiedPost
+    OverloadedStrings
+
+  build-tool-depends: hspec-discover:hspec-discover >=2 && <3
+  ghc-options:
+    -Wall -Wtabs -Wno-unused-do-bind -Wunused-packages -Wpartial-fields
+    -Wwarn -Wwarnings-deprecations
diff --git a/src/Data/ByteString/Contrib.hs b/src/Data/ByteString/Contrib.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/ByteString/Contrib.hs
@@ -0,0 +1,20 @@
+{-# LANGUAGE CPP #-}
+
+module Data.ByteString.Contrib where
+
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as BSL
+
+bsToStrict :: BSL.ByteString -> BS.ByteString
+#if MIN_VERSION_bytestring(0,11,0)
+bsToStrict = BS.toStrict
+#else
+bsToStrict = BSL.toStrict
+#endif
+
+bsFromStrict :: BS.ByteString -> BSL.ByteString
+#if MIN_VERSION_bytestring(0,11,0)
+bsFromStrict = BS.fromStrict
+#else
+bsFromStrict = BSL.fromStrict
+#endif
diff --git a/src/Network/OAuth2/Provider.hs b/src/Network/OAuth2/Provider.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/OAuth2/Provider.hs
@@ -0,0 +1,20 @@
+module Network.OAuth2.Provider where
+
+import GHC.Generics (Generic)
+
+data IdpName
+  = Auth0
+  | AzureAD
+  | DropBox
+  | Facebook
+  | Fitbit
+  | GitHub
+  | Google
+  | LinkedIn
+  | Okta
+  | Slack
+  | StackExchange
+  | Twitter
+  | Weibo
+  | ZOHO
+  deriving (Eq, Ord, Show, Enum, Bounded, Generic)
diff --git a/src/Network/OAuth2/Provider/Auth0.hs b/src/Network/OAuth2/Provider/Auth0.hs
--- a/src/Network/OAuth2/Provider/Auth0.hs
+++ b/src/Network/OAuth2/Provider/Auth0.hs
@@ -1,58 +1,47 @@
-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE GeneralizedNewtypeDeriving #-}
-{-# LANGUAGE InstanceSigs #-}
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TypeApplications #-}
-{-# LANGUAGE TypeFamilies #-}
 
--- | https://auth0.com/docs/api/authentication#authorize-application
+-- | [Auth0](https://auth0.com)
+--
+--   * [Auth0 Authorize Application](https://auth0.com/docs/api/authentication#authorize-application)
+--
+--   * [OAuth 2.0 Authorization Framework](https://auth0.com/docs/authenticate/protocols/oauth)
 module Network.OAuth2.Provider.Auth0 where
 
-import Control.Monad.IO.Class
-import Control.Monad.Trans.Except
-import Data.Aeson
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
+import Data.Aeson (FromJSON)
+import Data.ByteString.Lazy.Char8 qualified as BSL
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text.Lazy (Text)
 import GHC.Generics
+import Network.HTTP.Conduit
 import Network.OAuth.OAuth2
 import Network.OAuth2.Experiment
+import Network.OAuth2.Provider
 import Network.OIDC.WellKnown
 import URI.ByteString.QQ
 
-data Auth0 = Auth0
-  deriving (Show, Eq)
-
-type instance IdpUserInfo Auth0 = Auth0User
-
-defaultAuth0App :: Idp Auth0 -> IdpApplication 'AuthorizationCode Auth0
-defaultAuth0App i =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.fromList ["openid", "profile", "email", "offline_access"]
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppName = "default-auth0-App"
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretBasic
-    , idp = i
+sampleAuth0AuthorizationCodeApp :: AuthorizationCodeApplication
+sampleAuth0AuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.fromList ["openid", "profile", "email", "offline_access"]
+    , acAuthorizeState = "CHANGE_ME"
+    , acRedirectUri = [uri|http://localhost|]
+    , acName = "sample-auth0-authorization-code-app"
+    , acAuthorizeRequestExtraParams = Map.empty
+    , acTokenRequestAuthenticationMethod = ClientSecretBasic
     }
 
-defaultAuth0Idp :: Idp Auth0
-defaultAuth0Idp =
-  Idp
-    { idpFetchUserInfo = authGetJSON @(IdpUserInfo Auth0)
-    , --  https://auth0.com/docs/api/authentication#user-profile
-      idpUserInfoEndpoint = [uri|https://foo.auth0.com/userinfo|]
-    , -- https://auth0.com/docs/api/authentication#authorization-code-flow
-      idpAuthorizeEndpoint = [uri|https://foo.auth0.com/authorize|]
-    , -- https://auth0.com/docs/api/authentication#authorization-code-flow44
-      idpTokenEndpoint = [uri|https://foo.auth0.com/oauth/token|]
-    }
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequest
 
 mkAuth0Idp ::
   MonadIO m =>
@@ -60,12 +49,16 @@
   Text ->
   ExceptT Text m (Idp Auth0)
 mkAuth0Idp domain = do
-  OpenIDConfigurationUris {..} <- fetchWellKnownUris domain
+  OpenIDConfiguration {..} <- fetchWellKnown domain
   pure
-    ( defaultAuth0Idp
-        { idpUserInfoEndpoint = userinfoUri
-        , idpAuthorizeEndpoint = authorizationUri
-        , idpTokenEndpoint = tokenUri
+    ( Idp
+        { --  https://auth0.com/docs/api/authentication#user-profile
+          idpUserInfoEndpoint = userinfoEndpoint
+        , -- https://auth0.com/docs/api/authentication#authorization-code-flow
+          idpAuthorizeEndpoint = authorizationEndpoint
+        , -- https://auth0.com/docs/api/authentication#authorization-code-flow44
+          idpTokenEndpoint = tokenEndpoint
+        , idpDeviceAuthorizationEndpoint = Just deviceAuthorizationEndpoint
         }
     )
 
diff --git a/src/Network/OAuth2/Provider/AzureAD.hs b/src/Network/OAuth2/Provider/AzureAD.hs
--- a/src/Network/OAuth2/Provider/AzureAD.hs
+++ b/src/Network/OAuth2/Provider/AzureAD.hs
@@ -1,47 +1,80 @@
 {-# LANGUAGE QuasiQuotes #-}
 
+-- | [AzureAD oauth2 flow](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow)
 module Network.OAuth2.Provider.AzureAD where
 
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
 import Data.Aeson
+import Data.ByteString.Lazy.Char8 qualified as BSL
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text.Lazy (Text)
 import GHC.Generics
+import Network.HTTP.Conduit (Manager)
 import Network.OAuth.OAuth2
 import Network.OAuth2.Experiment
+import Network.OAuth2.Provider
+import Network.OIDC.WellKnown
 import URI.ByteString.QQ
 
-data AzureAD = AzureAD deriving (Eq, Show)
-
-type instance IdpUserInfo AzureAD = AzureADUser
-
--- create app at https://go.microsoft.com/fwlink/?linkid=2083908
+-- Create app at https://go.microsoft.com/fwlink/?linkid=2083908
 --
 -- also be aware to find the right client id.
 -- see https://stackoverflow.com/a/70670961
-defaultAzureADApp :: IdpApplication 'AuthorizationCode AzureAD
-defaultAzureADApp =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.fromList ["openid", "profile", "email"]
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppName = "default-azure-app"
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretBasic
-    , idp = defaultAzureADIdp
+sampleAzureADAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleAzureADAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.fromList ["openid", "profile", "email"]
+    , acAuthorizeState = "CHANGE_ME"
+    , acAuthorizeRequestExtraParams = Map.empty
+    , acRedirectUri = [uri|http://localhost|]
+    , acName = "sample-azure-authorization-code-app"
+    , acTokenRequestAuthenticationMethod = ClientSecretBasic
     }
 
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequest
+
 -- | https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration
+-- It's supporse to resue 'mkAzureIdp'
+--
+-- @
+-- mkAzureIdp "common"
+-- @
+--
+-- But its issuer is "https://login.microsoftonline.com/{tenantid}/v2.0",
+-- which is invalid URI!!
 defaultAzureADIdp :: Idp AzureAD
 defaultAzureADIdp =
   Idp
-    { idpFetchUserInfo = authGetJSON @(IdpUserInfo AzureAD)
-    , idpUserInfoEndpoint = [uri|https://graph.microsoft.com/oidc/userinfo|]
-    , idpAuthorizeEndpoint = [uri|https://login.microsoftonline.com/common/oauth2/v2.0/authorize|]
+    { idpAuthorizeEndpoint = [uri|https://login.microsoftonline.com/common/oauth2/v2.0/authorize|]
     , idpTokenEndpoint = [uri|https://login.microsoftonline.com/common/oauth2/v2.0/token|]
+    , idpUserInfoEndpoint = [uri|https://graph.microsoft.com/oidc/userinfo|]
+    , idpDeviceAuthorizationEndpoint = Just [uri|https://login.microsoftonline.com/common/oauth2/v2.0/devicecode|]
     }
+
+mkAzureIdp ::
+  MonadIO m =>
+  -- | Full domain with no http protocol. e.g. @contoso.onmicrosoft.com@
+  Text ->
+  ExceptT Text m (Idp AzureAD)
+mkAzureIdp domain = do
+  OpenIDConfiguration {..} <- fetchWellKnown ("login.microsoftonline.com/" <> domain <> "/v2.0")
+  pure $
+    Idp
+      { idpUserInfoEndpoint = userinfoEndpoint
+      , idpAuthorizeEndpoint = authorizationEndpoint
+      , idpTokenEndpoint = tokenEndpoint
+      , idpDeviceAuthorizationEndpoint = Just deviceAuthorizationEndpoint
+      }
 
 -- | https://learn.microsoft.com/en-us/azure/active-directory/develop/userinfo
 data AzureADUser = AzureADUser
diff --git a/src/Network/OAuth2/Provider/DropBox.hs b/src/Network/OAuth2/Provider/DropBox.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/OAuth2/Provider/DropBox.hs
@@ -0,0 +1,63 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+-- | [DropBox oauth guide](https://developers.dropbox.com/oauth-guide)
+module Network.OAuth2.Provider.DropBox where
+
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
+import Data.Aeson
+import Data.ByteString.Lazy.Char8 qualified as BSL
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text.Lazy (Text)
+import GHC.Generics
+import Network.HTTP.Conduit (Manager)
+import Network.OAuth.OAuth2
+import Network.OAuth2.Experiment
+import Network.OAuth2.Provider
+import URI.ByteString.QQ
+
+sampleDropBoxAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleDropBoxAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.empty
+    , acAuthorizeState = "CHANGE_ME"
+    , acAuthorizeRequestExtraParams = Map.empty
+    , acRedirectUri = [uri|http://localhost|]
+    , acName = "sample-dropbox-authorization-code-app"
+    , acTokenRequestAuthenticationMethod = ClientSecretBasic
+    }
+
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequestWithCustomMethod (\mgr at url -> authPostJSON mgr at url [])
+
+defaultDropBoxIdp :: Idp DropBox
+defaultDropBoxIdp =
+  Idp
+    { idpAuthorizeEndpoint = [uri|https://www.dropbox.com/1/oauth2/authorize|]
+    , idpTokenEndpoint = [uri|https://api.dropboxapi.com/oauth2/token|]
+    , -- https://www.dropbox.com/developers/documentation/http/documentation#users-get_current_account
+      idpUserInfoEndpoint = [uri|https://api.dropboxapi.com/2/users/get_current_account|]
+    , idpDeviceAuthorizationEndpoint = Nothing
+    }
+
+newtype DropBoxUserName = DropBoxUserName {displayName :: Text}
+  deriving (Show, Generic)
+
+data DropBoxUser = DropBoxUser
+  { email :: Text
+  , name :: DropBoxUserName
+  }
+  deriving (Show, Generic)
+
+instance FromJSON DropBoxUserName where
+  parseJSON = genericParseJSON defaultOptions {fieldLabelModifier = camelTo2 '_'}
+
+instance FromJSON DropBoxUser
diff --git a/src/Network/OAuth2/Provider/Dropbox.hs b/src/Network/OAuth2/Provider/Dropbox.hs
deleted file mode 100644
--- a/src/Network/OAuth2/Provider/Dropbox.hs
+++ /dev/null
@@ -1,57 +0,0 @@
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TypeFamilies #-}
-
-module Network.OAuth2.Provider.Dropbox where
-
-import Data.Aeson
-import Data.Map.Strict qualified as Map
-import Data.Set qualified as Set
-import Data.Text.Lazy (Text)
-import GHC.Generics
-import Network.OAuth.OAuth2
-import Network.OAuth2.Experiment
-import URI.ByteString.QQ
-
-data Dropbox = Dropbox deriving (Eq, Show)
-
-type instance IdpUserInfo Dropbox = DropboxUser
-
-defaultDropboxApp :: IdpApplication 'AuthorizationCode Dropbox
-defaultDropboxApp =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.empty
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppName = "default-dropbox-App"
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretBasic
-    , idp = defaultDropboxIdp
-    }
-
-defaultDropboxIdp :: Idp Dropbox
-defaultDropboxIdp =
-  Idp
-    { idpFetchUserInfo = \mgr at url -> authPostJSON @(IdpUserInfo Dropbox) mgr at url []
-    , idpAuthorizeEndpoint = [uri|https://www.dropbox.com/1/oauth2/authorize|]
-    , idpTokenEndpoint = [uri|https://api.dropboxapi.com/oauth2/token|]
-    , idpUserInfoEndpoint = [uri|https://api.dropboxapi.com/2/users/get_current_account|]
-    }
-
-newtype DropboxUserName = DropboxUserName {displayName :: Text}
-  deriving (Show, Generic)
-
-data DropboxUser = DropboxUser
-  { email :: Text
-  , name :: DropboxUserName
-  }
-  deriving (Show, Generic)
-
-instance FromJSON DropboxUserName where
-  parseJSON = genericParseJSON defaultOptions {fieldLabelModifier = camelTo2 '_'}
-
-instance FromJSON DropboxUser
diff --git a/src/Network/OAuth2/Provider/Facebook.hs b/src/Network/OAuth2/Provider/Facebook.hs
--- a/src/Network/OAuth2/Provider/Facebook.hs
+++ b/src/Network/OAuth2/Provider/Facebook.hs
@@ -1,45 +1,50 @@
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TypeFamilies #-}
 
+-- | [Facebook Login](http://developers.facebook.com/docs/facebook-login/)
 module Network.OAuth2.Provider.Facebook where
 
-import Data.Aeson
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
+import Data.Aeson (FromJSON)
+import Data.ByteString.Lazy.Char8 qualified as BSL
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text.Lazy (Text)
 import GHC.Generics
+import Network.HTTP.Conduit (Manager)
 import Network.OAuth.OAuth2
 import Network.OAuth2.Experiment
+import Network.OAuth2.Provider
 import URI.ByteString.QQ
 
-data Facebook = Facebook deriving (Eq, Show)
-
-type instance IdpUserInfo Facebook = FacebookUser
-
-defaultFacebookApp :: IdpApplication 'AuthorizationCode Facebook
-defaultFacebookApp =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.fromList ["user_about_me", "email"]
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppName = "default-facebook-App"
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretPost
-    , idp = defaultFacebookIdp
+sampleFacebookAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleFacebookAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.fromList ["user_about_me", "email"]
+    , acAuthorizeRequestExtraParams = Map.empty
+    , acAuthorizeState = "CHANGE_ME"
+    , acRedirectUri = [uri|http://localhost|]
+    , acName = "sample-facebook-authorization-code-app"
+    , acTokenRequestAuthenticationMethod = ClientSecretPost
     }
 
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequest
+
 defaultFacebookIdp :: Idp Facebook
 defaultFacebookIdp =
   Idp
-    { idpFetchUserInfo = authGetJSON @(IdpUserInfo Facebook)
-    , idpUserInfoEndpoint = [uri|https://graph.facebook.com/me?fields=id,name,email|]
+    { idpUserInfoEndpoint = [uri|https://graph.facebook.com/me?fields=id,name,email|]
     , idpAuthorizeEndpoint = [uri|https://www.facebook.com/dialog/oauth|]
     , idpTokenEndpoint = [uri|https://graph.facebook.com/v2.3/oauth/access_token|]
+    , idpDeviceAuthorizationEndpoint = Nothing
     }
 
 data FacebookUser = FacebookUser
diff --git a/src/Network/OAuth2/Provider/Fitbit.hs b/src/Network/OAuth2/Provider/Fitbit.hs
--- a/src/Network/OAuth2/Provider/Fitbit.hs
+++ b/src/Network/OAuth2/Provider/Fitbit.hs
@@ -1,45 +1,50 @@
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TypeFamilies #-}
 
+-- | [Fitbit Authorization developer guide](https://dev.fitbit.com/build/reference/web-api/developer-guide/authorization/)
 module Network.OAuth2.Provider.Fitbit where
 
 import Control.Monad (mzero)
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
 import Data.Aeson
+import Data.ByteString.Lazy.Char8 qualified as BSL
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text.Lazy (Text)
+import Network.HTTP.Conduit (Manager)
 import Network.OAuth.OAuth2
 import Network.OAuth2.Experiment
+import Network.OAuth2.Provider
 import URI.ByteString.QQ
 
-data Fitbit = Fitbit deriving (Eq, Show)
-
-type instance IdpUserInfo Fitbit = FitbitUser
-
-defaultFitbitApp :: IdpApplication 'AuthorizationCode Fitbit
-defaultFitbitApp =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.empty
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppName = "default-fitbit-App"
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretBasic
-    , idp = defaultFitbitIdp
+sampleFitbitAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleFitbitAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.empty
+    , acAuthorizeRequestExtraParams = Map.empty
+    , acAuthorizeState = "CHANGE_ME"
+    , acRedirectUri = [uri|http://localhost|]
+    , acName = "sample-fitbit-authorization-code-app"
+    , acTokenRequestAuthenticationMethod = ClientSecretBasic
     }
 
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequest
+
 defaultFitbitIdp :: Idp Fitbit
 defaultFitbitIdp =
   Idp
-    { idpFetchUserInfo = authGetJSON @(IdpUserInfo Fitbit)
-    , idpUserInfoEndpoint = [uri|https://api.fitbit.com/1/user/-/profile.json|]
+    { idpUserInfoEndpoint = [uri|https://api.fitbit.com/1/user/-/profile.json|]
     , idpAuthorizeEndpoint = [uri|https://www.fitbit.com/oauth2/authorize|]
     , idpTokenEndpoint = [uri|https://api.fitbit.com/oauth2/token|]
+    , idpDeviceAuthorizationEndpoint = Nothing
     }
 
 data FitbitUser = FitbitUser
diff --git a/src/Network/OAuth2/Provider/GitHub.hs b/src/Network/OAuth2/Provider/GitHub.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/OAuth2/Provider/GitHub.hs
@@ -0,0 +1,56 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+-- | [Github build oauth applications guide](https://docs.github.com/en/developers/apps/building-oauth-apps)
+module Network.OAuth2.Provider.GitHub where
+
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
+import Data.Aeson (FromJSON)
+import Data.ByteString.Lazy.Char8 qualified as BSL
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text.Lazy (Text)
+import GHC.Generics
+import Network.HTTP.Conduit (Manager)
+import Network.OAuth.OAuth2
+import Network.OAuth2.Experiment
+import Network.OAuth2.Provider
+import URI.ByteString.QQ
+
+sampleGithubAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleGithubAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.empty
+    , acAuthorizeState = "CHANGE_ME"
+    , acAuthorizeRequestExtraParams = Map.empty
+    , acRedirectUri = [uri|http://localhost|]
+    , acName = "sample-github-authorization-code-app"
+    , acTokenRequestAuthenticationMethod = ClientSecretBasic
+    }
+
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequest
+
+defaultGithubIdp :: Idp GitHub
+defaultGithubIdp =
+  Idp
+    { idpUserInfoEndpoint = [uri|https://api.github.com/user|]
+    , idpAuthorizeEndpoint = [uri|https://github.com/login/oauth/authorize|]
+    , idpTokenEndpoint = [uri|https://github.com/login/oauth/access_token|]
+    , idpDeviceAuthorizationEndpoint = Just [uri|https://github.com/login/device/code|]
+    }
+
+data GitHubUser = GitHubUser
+  { name :: Text
+  , id :: Integer
+  }
+  deriving (Show, Generic)
+
+instance FromJSON GitHubUser
diff --git a/src/Network/OAuth2/Provider/Github.hs b/src/Network/OAuth2/Provider/Github.hs
deleted file mode 100644
--- a/src/Network/OAuth2/Provider/Github.hs
+++ /dev/null
@@ -1,52 +0,0 @@
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TypeFamilies #-}
-
--- | https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps
-module Network.OAuth2.Provider.Github where
-
-import Data.Aeson
-import Data.Map.Strict qualified as Map
-import Data.Set qualified as Set
-import Data.Text.Lazy (Text)
-import GHC.Generics
-import Network.OAuth.OAuth2
-import Network.OAuth2.Experiment
-import URI.ByteString.QQ
-
-data Github = Github deriving (Eq, Show)
-
-type instance IdpUserInfo Github = GithubUser
-
-defaultGithubApp :: IdpApplication 'AuthorizationCode Github
-defaultGithubApp =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.empty
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppName = "default-github-App"
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretBasic
-    , idp = defaultGithubIdp
-    }
-
-defaultGithubIdp :: Idp Github
-defaultGithubIdp =
-  Idp
-    { idpFetchUserInfo = authGetJSON @(IdpUserInfo Github)
-    , idpUserInfoEndpoint = [uri|https://api.github.com/user|]
-    , idpAuthorizeEndpoint = [uri|https://github.com/login/oauth/authorize|]
-    , idpTokenEndpoint = [uri|https://github.com/login/oauth/access_token|]
-    }
-
-data GithubUser = GithubUser
-  { name :: Text
-  , id :: Integer
-  }
-  deriving (Show, Generic)
-
-instance FromJSON GithubUser
diff --git a/src/Network/OAuth2/Provider/Google.hs b/src/Network/OAuth2/Provider/Google.hs
--- a/src/Network/OAuth2/Provider/Google.hs
+++ b/src/Network/OAuth2/Provider/Google.hs
@@ -1,18 +1,16 @@
-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE InstanceSigs #-}
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TypeApplications #-}
-{-# LANGUAGE TypeFamilies #-}
 
+-- | [Google build oauth2 web server application](https://developers.google.com/identity/protocols/oauth2/web-server)
 module Network.OAuth2.Provider.Google where
 
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
 import Crypto.PubKey.RSA.Types
 import Data.Aeson
 import Data.Aeson qualified as Aeson
 import Data.Bifunctor
+import Data.ByteString.Contrib
+import Data.ByteString.Lazy.Char8 qualified as BSL
 import Data.Map.Strict qualified as Map
 import Data.Maybe
 import Data.Set qualified as Set
@@ -24,9 +22,10 @@
 import Jose.Jwa
 import Jose.Jws
 import Jose.Jwt
+import Network.HTTP.Conduit (Manager)
 import Network.OAuth.OAuth2
 import Network.OAuth2.Experiment
-import Network.OAuth2.Provider.Utils
+import Network.OAuth2.Provider
 import OpenSSL.EVP.PKey (toKeyPair)
 import OpenSSL.PEM (
   PemPasswordSupply (PwNone),
@@ -39,32 +38,30 @@
 To test at google playground, set redirect uri to "https://developers.google.com/oauthplayground"
 -}
 
-data Google = Google deriving (Eq, Show)
-
-type instance IdpUserInfo Google = GoogleUser
-
 -- * Authorization Code flow
 
-defaultGoogleApp :: IdpApplication 'AuthorizationCode Google
-defaultGoogleApp =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope =
-        Set.fromList
-          [ "https://www.googleapis.com/auth/userinfo.email"
-          , "https://www.googleapis.com/auth/userinfo.profile"
-          ]
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppName = "default-google-App"
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretBasic
-    , idp = defaultGoogleIdp
+sampleGoogleAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleGoogleAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acName = "sample-google-authorization-code-app"
+    , acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.fromList ["https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"]
+    , acAuthorizeState = "CHANGE_ME"
+    , acRedirectUri = [uri|http://localhost|]
+    , acAuthorizeRequestExtraParams = Map.empty
+    , acTokenRequestAuthenticationMethod = ClientSecretBasic
     }
 
 -- * Service Account
 
+sampleServiceAccountApp :: Jwt -> JwtBearerApplication
+sampleServiceAccountApp jwt =
+  JwtBearerApplication
+    { jbName = "sample-google-service-account-app"
+    , jbJwtAssertion = unJwt jwt
+    }
+
 -- | Service account key (in JSON format) that download from google
 data GoogleServiceAccountKey = GoogleServiceAccountKey
   { privateKey :: String
@@ -138,23 +135,23 @@
           }
     Nothing -> Left "unable to parse PEM to RSA key"
 
-defaultServiceAccountApp :: Jwt -> IdpApplication 'JwtBearer Google
-defaultServiceAccountApp jwt =
-  JwtBearerIdpApplication
-    { idpAppName = "google-sa-app"
-    , idpAppJwt = unJwt jwt
-    , idp = defaultGoogleIdp
-    }
-
 -- * IDP
 
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequest
+
 defaultGoogleIdp :: Idp Google
 defaultGoogleIdp =
   Idp
-    { idpFetchUserInfo = authGetJSON @(IdpUserInfo Google)
-    , idpAuthorizeEndpoint = [uri|https://accounts.google.com/o/oauth2/v2/auth|]
+    { idpAuthorizeEndpoint = [uri|https://accounts.google.com/o/oauth2/v2/auth|]
     , idpTokenEndpoint = [uri|https://oauth2.googleapis.com/token|]
     , idpUserInfoEndpoint = [uri|https://www.googleapis.com/oauth2/v2/userinfo|]
+    , idpDeviceAuthorizationEndpoint = Just [uri|https://oauth2.googleapis.com/device/code|]
     }
 
 -- requires scope "https://www.googleapis.com/auth/userinfo.profile" to obtain "name".
diff --git a/src/Network/OAuth2/Provider/LinkedIn.hs b/src/Network/OAuth2/Provider/LinkedIn.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/OAuth2/Provider/LinkedIn.hs
@@ -0,0 +1,56 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+-- | [LinkedIn Authenticating with OAuth 2.0 Overview](https://learn.microsoft.com/en-us/linkedin/shared/authentication/authentication?context=linkedin%2Fcontext)
+module Network.OAuth2.Provider.LinkedIn where
+
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
+import Data.Aeson (FromJSON)
+import Data.ByteString.Lazy.Char8 qualified as BSL
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text.Lazy (Text)
+import GHC.Generics
+import Network.HTTP.Conduit (Manager)
+import Network.OAuth.OAuth2
+import Network.OAuth2.Experiment
+import Network.OAuth2.Provider
+import URI.ByteString.QQ (uri)
+
+sampleLinkedInAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleLinkedInAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.fromList ["r_liteprofile"]
+    , acAuthorizeState = "CHANGE_ME"
+    , acAuthorizeRequestExtraParams = Map.empty
+    , acRedirectUri = [uri|http://localhost|]
+    , acName = "sample-linkedin-authorization-code-app"
+    , acTokenRequestAuthenticationMethod = ClientSecretPost
+    }
+
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequest
+
+defaultLinkedInIdp :: Idp LinkedIn
+defaultLinkedInIdp =
+  Idp
+    { idpUserInfoEndpoint = [uri|https://api.linkedin.com/v2/me|]
+    , idpAuthorizeEndpoint = [uri|https://www.linkedin.com/oauth/v2/authorization|]
+    , idpTokenEndpoint = [uri|https://www.linkedin.com/oauth/v2/accessToken|]
+    , idpDeviceAuthorizationEndpoint = Nothing
+    }
+
+data LinkedInUser = LinkedInUser
+  { localizedFirstName :: Text
+  , localizedLastName :: Text
+  }
+  deriving (Show, Generic, Eq)
+
+instance FromJSON LinkedInUser
diff --git a/src/Network/OAuth2/Provider/Linkedin.hs b/src/Network/OAuth2/Provider/Linkedin.hs
deleted file mode 100644
--- a/src/Network/OAuth2/Provider/Linkedin.hs
+++ /dev/null
@@ -1,51 +0,0 @@
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TypeFamilies #-}
-
-module Network.OAuth2.Provider.Linkedin where
-
-import Data.Aeson
-import Data.Map.Strict qualified as Map
-import Data.Set qualified as Set
-import Data.Text.Lazy (Text)
-import GHC.Generics
-import Network.OAuth.OAuth2
-import Network.OAuth2.Experiment
-import URI.ByteString.QQ
-
-data Linkedin = Linkedin deriving (Eq, Show)
-
-type instance IdpUserInfo Linkedin = LinkedinUser
-
-defaultLinkedinApp :: IdpApplication 'AuthorizationCode Linkedin
-defaultLinkedinApp =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.fromList ["r_liteprofile"]
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppName = "default-linkedin-App"
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretPost
-    , idp = defaultLinkedinIdp
-    }
-
-defaultLinkedinIdp :: Idp Linkedin
-defaultLinkedinIdp =
-  Idp
-    { idpFetchUserInfo = authGetJSON @(IdpUserInfo Linkedin)
-    , idpUserInfoEndpoint = [uri|https://api.linkedin.com/v2/me|]
-    , idpAuthorizeEndpoint = [uri|https://www.linkedin.com/oauth/v2/authorization|]
-    , idpTokenEndpoint = [uri|https://www.linkedin.com/oauth/v2/accessToken|]
-    }
-
-data LinkedinUser = LinkedinUser
-  { localizedFirstName :: Text
-  , localizedLastName :: Text
-  }
-  deriving (Show, Generic, Eq)
-
-instance FromJSON LinkedinUser
diff --git a/src/Network/OAuth2/Provider/Okta.hs b/src/Network/OAuth2/Provider/Okta.hs
--- a/src/Network/OAuth2/Provider/Okta.hs
+++ b/src/Network/OAuth2/Provider/Okta.hs
@@ -1,60 +1,55 @@
-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TypeFamilies #-}
 
+-- https://developer.okta.com/docs/reference/api/oidc/#request-parameters
+-- Okta Org AS doesn't support consent
+-- Okta Custom AS does support consent via config (what scope shall prompt consent)
+
+-- | [Okta OIDC & OAuth2 API](https://developer.okta.com/docs/reference/api/oidc/)
 module Network.OAuth2.Provider.Okta where
 
-import Control.Monad.IO.Class
-import Control.Monad.Trans.Except
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
 import Data.Aeson
 import Data.Aeson qualified as Aeson
 import Data.Bifunctor
+import Data.ByteString.Contrib
+import Data.ByteString.Lazy.Char8 qualified as BSL
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
-import Data.Text.Lazy (Text)
+import Data.Text.Lazy as TL
 import Data.Time
 import GHC.Generics
 import Jose.Jwa
 import Jose.Jwk
 import Jose.Jws
 import Jose.Jwt
+import Network.HTTP.Conduit (Manager)
 import Network.OAuth.OAuth2
 import Network.OAuth2.Experiment
-import Network.OAuth2.Provider.Utils
+import Network.OAuth2.Provider
 import Network.OIDC.WellKnown
 import URI.ByteString.QQ
 
-data Okta = Okta deriving (Eq, Show)
-
-type instance IdpUserInfo Okta = OktaUser
-
-defaultOktaApp :: Idp Okta -> IdpApplication 'AuthorizationCode Okta
-defaultOktaApp i =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.fromList ["openid", "profile", "email"]
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppName = "default-okta-App"
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretBasic
-    , idp = i
+sampleOktaAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleOktaAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.fromList ["openid", "profile", "email"]
+    , acAuthorizeState = "CHANGE_ME"
+    , acAuthorizeRequestExtraParams = Map.empty
+    , acRedirectUri = [uri|http://localhost|]
+    , acName = "sample-okta-authorization-code-app"
+    , acTokenRequestAuthenticationMethod = ClientSecretBasic
     }
 
-defaultOktaIdp :: Idp Okta
-defaultOktaIdp =
-  Idp
-    { idpFetchUserInfo = authGetJSON @(IdpUserInfo Okta)
-    , idpUserInfoEndpoint = [uri|https://foo.okta.com/oauth2/v1/userinfo|]
-    , idpAuthorizeEndpoint =
-        [uri|https://foo.okta.com/oauth2/v1/authorize|]
-    , idpTokenEndpoint =
-        [uri|https://foo.okta.com/oauth2/v1/token|]
-    }
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequest
 
 mkOktaIdp ::
   MonadIO m =>
@@ -62,20 +57,20 @@
   Text ->
   ExceptT Text m (Idp Okta)
 mkOktaIdp domain = do
-  OpenIDConfigurationUris {..} <- fetchWellKnownUris domain
-  pure
-    ( defaultOktaIdp
-        { idpUserInfoEndpoint = userinfoUri
-        , idpAuthorizeEndpoint = authorizationUri
-        , idpTokenEndpoint = tokenUri
-        }
-    )
+  OpenIDConfiguration {..} <- fetchWellKnown domain
+  pure $
+    Idp
+      { idpUserInfoEndpoint = userinfoEndpoint
+      , idpAuthorizeEndpoint = authorizationEndpoint
+      , idpTokenEndpoint = tokenEndpoint
+      , idpDeviceAuthorizationEndpoint = Just deviceAuthorizationEndpoint
+      }
 
 mkOktaClientCredentialAppJwt ::
   Jwk ->
   ClientId ->
-  Idp Okta ->
-  IO (Either String Jwt)
+  Idp i ->
+  IO (Either Text Jwt)
 mkOktaClientCredentialAppJwt jwk cid idp = do
   now <- getCurrentTime
   let cidStr = unClientId cid
@@ -83,19 +78,16 @@
         bsToStrict $
           Aeson.encode $
             Aeson.object
-              [ "iss" .= cidStr
-              , "sub" .= cidStr
-              , "aud" .= idpTokenEndpoint idp
+              [ "aud" .= idpTokenEndpoint idp
               , "exp" .= tToSeconds (addUTCTime (secondsToNominalDiffTime 300) now) -- 5 minutes expiration time
               , "iat" .= tToSeconds now
+              , "iss" .= cidStr
+              , "sub" .= cidStr
               ]
-  first show <$> jwkEncode RS256 jwk (Claims payload)
+  first (TL.pack . show) <$> jwkEncode RS256 jwk (Claims payload)
   where
     tToSeconds = formatTime defaultTimeLocale "%s"
 
--- https://developer.okta.com/docs/reference/api/oidc/#request-parameters
--- Okta Org AS doesn't support consent
--- Okta Custom AS does support consent via config (what scope shall prompt consent)
 data OktaUser = OktaUser
   { name :: Text
   , preferredUsername :: Text
diff --git a/src/Network/OAuth2/Provider/Slack.hs b/src/Network/OAuth2/Provider/Slack.hs
--- a/src/Network/OAuth2/Provider/Slack.hs
+++ b/src/Network/OAuth2/Provider/Slack.hs
@@ -1,47 +1,53 @@
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TypeFamilies #-}
 
+-- | [Sign in with Slack](https://api.slack.com/authentication/sign-in-with-slack)
+--
+--   * [Using OAuth 2.0](https://api.slack.com/legacy/oauth)
 module Network.OAuth2.Provider.Slack where
 
-import Data.Aeson
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
+import Data.Aeson (FromJSON)
+import Data.ByteString.Lazy.Char8 qualified as BSL
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text.Lazy (Text)
 import GHC.Generics
+import Network.HTTP.Conduit (Manager)
 import Network.OAuth.OAuth2
 import Network.OAuth2.Experiment
-import URI.ByteString.QQ
-
-data Slack = Slack deriving (Show, Eq)
-
-type instance IdpUserInfo Slack = SlackUser
+import Network.OAuth2.Provider
+import URI.ByteString.QQ (uri)
 
-defaultSlackApp :: IdpApplication 'AuthorizationCode Slack
-defaultSlackApp =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.fromList ["openid", "profile"]
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretBasic
-    , idpAppName = "default-slack-App"
-    , idp = defaultSlackIdp
+sampleSlackAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleSlackAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.fromList ["openid", "profile"]
+    , acAuthorizeState = "CHANGE_ME"
+    , acAuthorizeRequestExtraParams = Map.empty
+    , acRedirectUri = [uri|http://localhost|]
+    , acTokenRequestAuthenticationMethod = ClientSecretBasic
+    , acName = "sample-slack-authorization-code-app"
     }
 
--- https://api.slack.com/authentication/sign-in-with-slack
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequest
+
 -- https://slack.com/.well-known/openid-configuration
 defaultSlackIdp :: Idp Slack
 defaultSlackIdp =
   Idp
-    { idpFetchUserInfo = authGetJSON @(IdpUserInfo Slack)
-    , idpUserInfoEndpoint = [uri|https://slack.com/api/openid.connect.userInfo|]
+    { idpUserInfoEndpoint = [uri|https://slack.com/api/openid.connect.userInfo|]
     , idpAuthorizeEndpoint = [uri|https://slack.com/openid/connect/authorize|]
     , idpTokenEndpoint = [uri|https://slack.com/api/openid.connect.token|]
+    , idpDeviceAuthorizationEndpoint = Nothing
     }
 
 data SlackUser = SlackUser
diff --git a/src/Network/OAuth2/Provider/StackExchange.hs b/src/Network/OAuth2/Provider/StackExchange.hs
--- a/src/Network/OAuth2/Provider/StackExchange.hs
+++ b/src/Network/OAuth2/Provider/StackExchange.hs
@@ -1,59 +1,71 @@
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TypeFamilies #-}
 
+-- | [StackExchange authentication guide](https://api.stackexchange.com/docs/authentication)
+--
+--    * [StackExchange Apps page](https://stackapps.com/apps/oauth)
 module Network.OAuth2.Provider.StackExchange where
 
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
 import Data.Aeson
 import Data.ByteString (ByteString)
+import Data.ByteString.Lazy.Char8 qualified as BSL
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text.Lazy (Text)
 import GHC.Generics
+import Network.HTTP.Conduit (Manager)
 import Network.OAuth.OAuth2
 import Network.OAuth2.Experiment
-import URI.ByteString
-import URI.ByteString.QQ
+import Network.OAuth2.Provider
+import URI.ByteString (URI)
+import URI.ByteString.QQ (uri)
 
 -- fix key from your application edit page
 -- https://stackapps.com/apps/oauth
 stackexchangeAppKey :: ByteString
 stackexchangeAppKey = ""
 
-data StackExchange = StackExchange deriving (Eq, Show)
-
-type instance IdpUserInfo StackExchange = StackExchangeResp
+userInfoEndpoint :: URI
+userInfoEndpoint =
+  appendQueryParams
+    [ ("key", stackexchangeAppKey)
+    , ("site", "stackoverflow")
+    ]
+    [uri|https://api.stackexchange.com/2.2/me|]
 
-defaultStackExchangeApp :: IdpApplication 'AuthorizationCode StackExchange
-defaultStackExchangeApp =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.empty
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppName = "default-stackexchange-App"
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretPost
-    , idp = defaultStackexchangeIdp
+sampleStackExchangeAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleStackExchangeAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.empty
+    , acAuthorizeState = "CHANGE_ME"
+    , acAuthorizeRequestExtraParams = Map.empty
+    , acRedirectUri = [uri|http://localhost|]
+    , acName = "sample-stackexchange-authorization-code-app"
+    , acTokenRequestAuthenticationMethod = ClientSecretPost
     }
 
-defaultStackexchangeIdp :: Idp StackExchange
-defaultStackexchangeIdp =
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequestWithCustomMethod (authGetJSONWithAuthMethod AuthInRequestQuery)
+
+defaultStackExchangeIdp :: Idp StackExchange
+defaultStackExchangeIdp =
   Idp
-    { idpFetchUserInfo = authGetJSONWithAuthMethod @_ @(IdpUserInfo StackExchange) AuthInRequestQuery
-    , -- Only StackExchange has such specical app key which has to be append in userinfo uri.
+    { -- Only StackExchange has such specical app key which has to be append in userinfo uri.
       -- I feel it's not worth to invent a way to read from config
       -- file which would break the generic of Idp data type.
       -- Until discover a easier way, hard code for now.
-      idpUserInfoEndpoint =
-        appendStackExchangeAppKey
-          [uri|https://api.stackexchange.com/2.2/me?site=stackoverflow|]
-          stackexchangeAppKey
+      idpUserInfoEndpoint = userInfoEndpoint
     , idpAuthorizeEndpoint = [uri|https://stackexchange.com/oauth|]
     , idpTokenEndpoint = [uri|https://stackexchange.com/oauth/access_token|]
+    , idpDeviceAuthorizationEndpoint = Nothing
     }
 
 data StackExchangeResp = StackExchangeResp
diff --git a/src/Network/OAuth2/Provider/Twitter.hs b/src/Network/OAuth2/Provider/Twitter.hs
--- a/src/Network/OAuth2/Provider/Twitter.hs
+++ b/src/Network/OAuth2/Provider/Twitter.hs
@@ -1,47 +1,51 @@
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TypeFamilies #-}
 
--- | https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code
+-- | [Twitter OAuth2 guide](https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code)
 module Network.OAuth2.Provider.Twitter where
 
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
 import Data.Aeson
+import Data.ByteString.Lazy.Char8 qualified as BSL
 import Data.Char (toLower)
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text.Lazy (Text)
 import GHC.Generics
+import Network.HTTP.Conduit (Manager)
 import Network.OAuth.OAuth2
 import Network.OAuth2.Experiment
+import Network.OAuth2.Provider
 import URI.ByteString.QQ
 
-data Twitter = Twitter deriving (Eq, Show)
-
-type instance IdpUserInfo Twitter = TwitterUserResp
-
-defaultTwitterApp :: IdpApplication 'AuthorizationCode Twitter
-defaultTwitterApp =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.fromList ["tweet.read", "users.read"]
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppName = "default-twitter-App"
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretBasic
-    , idp = defaultTwitterIdp
+sampleTwitterAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleTwitterAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.fromList ["tweet.read", "users.read"]
+    , acAuthorizeState = "CHANGE_ME"
+    , acRedirectUri = [uri|http://localhost|]
+    , acName = "sample-twitter-authorization-code-app"
+    , acTokenRequestAuthenticationMethod = ClientSecretBasic
+    , acAuthorizeRequestExtraParams = Map.empty
     }
 
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequest
+
 defaultTwitterIdp :: Idp Twitter
 defaultTwitterIdp =
   Idp
-    { idpFetchUserInfo = authGetJSON @(IdpUserInfo Twitter)
-    , idpUserInfoEndpoint = [uri|https://api.twitter.com/2/users/me|]
+    { idpUserInfoEndpoint = [uri|https://api.twitter.com/2/users/me|]
     , idpAuthorizeEndpoint = [uri|https://twitter.com/i/oauth2/authorize|]
     , idpTokenEndpoint = [uri|https://api.twitter.com/2/oauth2/token|]
+    , idpDeviceAuthorizationEndpoint = Nothing
     }
 
 data TwitterUser = TwitterUser
diff --git a/src/Network/OAuth2/Provider/Utils.hs b/src/Network/OAuth2/Provider/Utils.hs
deleted file mode 100644
--- a/src/Network/OAuth2/Provider/Utils.hs
+++ /dev/null
@@ -1,20 +0,0 @@
-{-# LANGUAGE CPP #-}
-
-module Network.OAuth2.Provider.Utils where
-
-import Data.ByteString qualified as BS
-import Data.ByteString.Lazy qualified as BSL
-
-bsToStrict :: BSL.ByteString -> BS.ByteString
-#if MIN_VERSION_bytestring(0,11,0)
-bsToStrict = BS.toStrict
-#else
-bsToStrict = BSL.toStrict
-#endif
-
-bsFromStrict :: BS.ByteString -> BSL.ByteString
-#if MIN_VERSION_bytestring(0,11,0)
-bsFromStrict = BS.fromStrict
-#else
-bsFromStrict = BSL.fromStrict
-#endif
diff --git a/src/Network/OAuth2/Provider/Weibo.hs b/src/Network/OAuth2/Provider/Weibo.hs
--- a/src/Network/OAuth2/Provider/Weibo.hs
+++ b/src/Network/OAuth2/Provider/Weibo.hs
@@ -1,41 +1,50 @@
 {-# LANGUAGE QuasiQuotes #-}
 
+-- | [微博授权机制](https://open.weibo.com/wiki/%E6%8E%88%E6%9D%83%E6%9C%BA%E5%88%B6)
 module Network.OAuth2.Provider.Weibo where
 
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
 import Data.Aeson
+import Data.ByteString.Lazy.Char8 qualified as BSL
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text.Lazy (Text)
 import GHC.Generics
+import Network.HTTP.Conduit (Manager)
 import Network.OAuth.OAuth2
 import Network.OAuth2.Experiment
+import Network.OAuth2.Provider
 import URI.ByteString.QQ
 
-data Weibo = Weibo deriving (Eq, Show)
-
-type instance IdpUserInfo Weibo = WeiboUID
-
-defaultWeiboApp :: IdpApplication 'AuthorizationCode Weibo
-defaultWeiboApp =
-  AuthorizationCodeIdpApplication
-    { idpAppName = "default-weibo-App"
-    , idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.empty
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppAuthorizeExtraParams = Map.empty
-    , idpAppRedirectUri = [uri|http://localhost|]
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretBasic
-    , idp = defaultWeiboIdp
+sampleWeiboAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleWeiboAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acName = "sample-weibo-authorization-code-app"
+    , acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.empty
+    , acAuthorizeState = "CHANGE_ME"
+    , acAuthorizeRequestExtraParams = Map.empty
+    , acRedirectUri = [uri|http://localhost|]
+    , acTokenRequestAuthenticationMethod = ClientSecretBasic
     }
 
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequestWithCustomMethod (authGetJSONWithAuthMethod AuthInRequestQuery)
+
 defaultWeiboIdp :: Idp Weibo
 defaultWeiboIdp =
   Idp
-    { idpFetchUserInfo = authGetJSONWithAuthMethod @_ @(IdpUserInfo Weibo) AuthInRequestQuery
-    , idpUserInfoEndpoint = [uri|https://api.weibo.com/2/account/get_uid.json|]
+    { idpUserInfoEndpoint = [uri|https://api.weibo.com/2/account/get_uid.json|]
     , idpAuthorizeEndpoint = [uri|https://api.weibo.com/oauth2/authorize|]
     , idpTokenEndpoint = [uri|https://api.weibo.com/oauth2/access_token|]
+    , idpDeviceAuthorizationEndpoint = Nothing
     }
 
 -- | http://open.weibo.com/wiki/2/users/show
diff --git a/src/Network/OAuth2/Provider/ZOHO.hs b/src/Network/OAuth2/Provider/ZOHO.hs
--- a/src/Network/OAuth2/Provider/ZOHO.hs
+++ b/src/Network/OAuth2/Provider/ZOHO.hs
@@ -1,45 +1,50 @@
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TypeFamilies #-}
 
+-- | [ZOHO oauth overview](https://www.zoho.com/crm/developer/docs/api/v2/oauth-overview.html)
 module Network.OAuth2.Provider.ZOHO where
 
+import Control.Monad.IO.Class (MonadIO (..))
+import Control.Monad.Trans.Except (ExceptT (..))
 import Data.Aeson
+import Data.ByteString.Lazy.Char8 qualified as BSL
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Data.Text.Lazy (Text)
 import GHC.Generics
+import Network.HTTP.Conduit (Manager)
 import Network.OAuth.OAuth2
 import Network.OAuth2.Experiment
+import Network.OAuth2.Provider
 import URI.ByteString.QQ
 
-data ZOHO = ZOHO deriving (Eq, Show)
-
-type instance IdpUserInfo ZOHO = ZOHOUserResp
-
-defaultZohoApp :: IdpApplication 'AuthorizationCode ZOHO
-defaultZohoApp =
-  AuthorizationCodeIdpApplication
-    { idpAppClientId = ""
-    , idpAppClientSecret = ""
-    , idpAppScope = Set.fromList ["ZohoCRM.users.READ"]
-    , idpAppAuthorizeExtraParams = Map.fromList [("access_type", "offline"), ("prompt", "consent")]
-    , idpAppAuthorizeState = "CHANGE_ME"
-    , idpAppRedirectUri = [uri|http://localhost/oauth2/callback|]
-    , idpAppName = "default-zoho-App"
-    , idpAppTokenRequestAuthenticationMethod = ClientSecretBasic
-    , idp = defaultZohoIdp
+sampleZohoAuthorizationCodeApp :: AuthorizationCodeApplication
+sampleZohoAuthorizationCodeApp =
+  AuthorizationCodeApplication
+    { acClientId = ""
+    , acClientSecret = ""
+    , acScope = Set.fromList ["ZohoCRM.users.READ"]
+    , acAuthorizeRequestExtraParams = Map.fromList [("access_type", "offline"), ("prompt", "consent")]
+    , acAuthorizeState = "CHANGE_ME"
+    , acRedirectUri = [uri|http://localhost/oauth2/callback|]
+    , acName = "sample-zoho-authorization-code-app"
+    , acTokenRequestAuthenticationMethod = ClientSecretBasic
     }
 
+fetchUserInfo ::
+  (MonadIO m, HasUserInfoRequest a, FromJSON b) =>
+  IdpApplication i a ->
+  Manager ->
+  AccessToken ->
+  ExceptT BSL.ByteString m b
+fetchUserInfo = conduitUserInfoRequest
+
 defaultZohoIdp :: Idp ZOHO
 defaultZohoIdp =
   Idp
-    { idpFetchUserInfo = authGetJSON @(IdpUserInfo ZOHO)
-    , idpUserInfoEndpoint = [uri|https://www.zohoapis.com/crm/v2/users|]
+    { idpUserInfoEndpoint = [uri|https://www.zohoapis.com/crm/v2/users|]
     , idpAuthorizeEndpoint = [uri|https://accounts.zoho.com/oauth/v2/auth|]
     , idpTokenEndpoint = [uri|https://accounts.zoho.com/oauth/v2/token|]
+    , idpDeviceAuthorizationEndpoint = Nothing
     }
 
 -- `oauth/user/info` url does not work and find answer from
diff --git a/src/Network/OIDC/WellKnown.hs b/src/Network/OIDC/WellKnown.hs
--- a/src/Network/OIDC/WellKnown.hs
+++ b/src/Network/OIDC/WellKnown.hs
@@ -1,41 +1,47 @@
-{-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE CPP #-}
 
 module Network.OIDC.WellKnown where
 
 import Control.Monad.Except
+#if MIN_VERSION_base(4,18,0)
+import Control.Monad.IO.Class
+#endif
 import Data.Aeson
+import Data.Aeson.Types
 import Data.Bifunctor
 import Data.ByteString.Lazy (ByteString)
-import Data.ByteString.Lazy qualified as BSL
 import Data.Text.Lazy (Text)
 import Data.Text.Lazy qualified as TL
 import Data.Text.Lazy.Encoding qualified as TL
-import GHC.Generics
 import Network.HTTP.Simple
 import Network.HTTP.Types.Status
 import URI.ByteString
+import URI.ByteString.Aeson ()
 
 -- | Slim OpenID Configuration
 -- TODO: could add more fields to be complete.
+--
+-- See spec <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata>
 data OpenIDConfiguration = OpenIDConfiguration
-  { issuer :: Text
-  , authorizationEndpoint :: Text
-  , tokenEndpoint :: Text
-  , userinfoEndpoint :: Text
-  , jwksUri :: Text
-  }
-  deriving (Generic)
-
-data OpenIDConfigurationUris = OpenIDConfigurationUris
-  { authorizationUri :: URI
-  , tokenUri :: URI
-  , userinfoUri :: URI
+  { issuer :: URI
+  , authorizationEndpoint :: URI
+  , tokenEndpoint :: URI
+  , userinfoEndpoint :: URI
   , jwksUri :: URI
+  , deviceAuthorizationEndpoint :: URI
   }
-  deriving (Generic)
+  deriving (Show, Eq)
 
 instance FromJSON OpenIDConfiguration where
-  parseJSON = genericParseJSON defaultOptions {fieldLabelModifier = camelTo2 '_'}
+  parseJSON :: Value -> Parser OpenIDConfiguration
+  parseJSON = withObject "parseJSON OpenIDConfiguration" $ \t -> do
+    issuer <- t .: "issuer"
+    authorizationEndpoint <- t .: "authorization_endpoint"
+    tokenEndpoint <- t .: "token_endpoint"
+    userinfoEndpoint <- t .: "userinfo_endpoint"
+    jwksUri <- t .: "jwks_uri"
+    deviceAuthorizationEndpoint <- t .: "device_authorization_endpoint"
+    pure OpenIDConfiguration {..}
 
 wellknownUrl :: TL.Text
 wellknownUrl = "/.well-known/openid-configuration"
@@ -49,23 +55,7 @@
   let uri = "https://" <> domain <> wellknownUrl
   req <- liftIO $ parseRequest (TL.unpack uri)
   resp <- httpLbs req
-  return (handleWellKnownResponse resp)
-
-fetchWellKnownUris :: MonadIO m => TL.Text -> ExceptT Text m OpenIDConfigurationUris
-fetchWellKnownUris domain = do
-  OpenIDConfiguration {..} <- fetchWellKnown domain
-  withExceptT (TL.pack . show) $ do
-    ae <- ExceptT $ pure (parseURI strictURIParserOptions $ BSL.toStrict $ TL.encodeUtf8 authorizationEndpoint)
-    te <- ExceptT $ pure (parseURI strictURIParserOptions $ BSL.toStrict $ TL.encodeUtf8 tokenEndpoint)
-    ue <- ExceptT $ pure (parseURI strictURIParserOptions $ BSL.toStrict $ TL.encodeUtf8 userinfoEndpoint)
-    jwks <- ExceptT $ pure (parseURI strictURIParserOptions $ BSL.toStrict $ TL.encodeUtf8 jwksUri)
-    pure
-      OpenIDConfigurationUris
-        { authorizationUri = ae
-        , tokenUri = te
-        , userinfoUri = ue
-        , jwksUri = jwks
-        }
+  pure (handleWellKnownResponse resp)
 
 handleWellKnownResponse :: Response ByteString -> Either Text OpenIDConfiguration
 handleWellKnownResponse resp = do
diff --git a/test/Network/OIDC/WellKnownSpec.hs b/test/Network/OIDC/WellKnownSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Network/OIDC/WellKnownSpec.hs
@@ -0,0 +1,32 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Network.OIDC.WellKnownSpec where
+
+import Data.Aeson qualified as Aeson
+import Network.OIDC.WellKnown (OpenIDConfiguration (..))
+import Test.Hspec
+import URI.ByteString.QQ
+
+spec :: Spec
+spec = do
+  describe "parseJSON OpenIDConfiguration" $ do
+    it "parse openidConfiguration response" $ do
+      let response =
+            "{"
+              <> "\"issuer\": \"https://foo.com\","
+              <> "\"authorization_endpoint\": \"https://foo.com/auth\","
+              <> "\"token_endpoint\": \"https://foo.com/token\","
+              <> "\"userinfo_endpoint\": \"https://foo.com/userinfo\","
+              <> "\"jwks_uri\": \"https://foo.com/jwks\","
+              <> "\"device_authorization_endpoint\": \"https://foo.com/device-code\""
+              <> "}"
+      let expected =
+            OpenIDConfiguration
+              { issuer = [uri|https://foo.com|]
+              , authorizationEndpoint = [uri|https://foo.com/auth|]
+              , tokenEndpoint = [uri|https://foo.com/token|]
+              , userinfoEndpoint = [uri|https://foo.com/userinfo|]
+              , jwksUri = [uri|https://foo.com/jwks|]
+              , deviceAuthorizationEndpoint = [uri|https://foo.com/device-code|]
+              }
+      Aeson.eitherDecode response `shouldBe` Right expected
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,2 @@
+-- file test/Spec.hs
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
