packages feed

hoauth2-tutorial (empty) → 0.1

raw patch · 4 files changed

+393/−0 lines, 4 filesdep +aesondep +basedep +bytestring

Dependencies added: aeson, base, bytestring, hoauth2, hoauth2-tutorial, http-conduit, http-types, scotty, text, transformers, uri-bytestring, wai, warp

Files

+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c)2012-present, Haisheng Wu++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 Haisheng Wu 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.
+ app/Main.hs view
@@ -0,0 +1,6 @@+module Main where++import HOAuth2Tutorial (app)++main :: IO ()+main = app
+ hoauth2-tutorial.cabal view
@@ -0,0 +1,64 @@+cabal-version: 2.4+name:          hoauth2-tutorial+version:       0.1+synopsis:      Tutorial for using hoauth2+description:+  Tutorial to demostrate how to use hoauth2 to implement OAuth2 flow in an web Application.+homepage:      https://github.com/freizl/hoauth2+license:       BSD-3-Clause+license-file:  LICENSE+author:        Haisheng Wu+maintainer:    Haisheng Wu <freizl@gmail.com>+copyright:     Haisheng Wu+category:      Network+build-type:    Simple+stability:     Beta+tested-with:   GHC <=9.2.2++-- A copyright notice.+-- copyright:+-- category:++source-repository head+  type:     git+  location: git://github.com/freizl/hoauth2.git++library+  hs-source-dirs:   src+  exposed-modules:  HOAuth2Tutorial+  build-depends:+    , aeson           >=2.0    && <2.2+    , base            >=4.5    && <5+    , bytestring      >=0.9    && <0.12+    , hoauth2         ^>= 2.5+    , http-conduit    >=2.1    && <2.4+    , http-types      >=0.11   && <0.13+    , scotty          >=0.10.0 && <0.13+    , text            >=0.11   && <1.3+    , transformers    ^>=0.5+    , uri-bytestring  >=0.2.3  && <0.4+    , wai             ^>=3.2+    , warp            >=3.2    && <3.4++  default-language: Haskell2010+  ghc-options:+    -Wall -Wtabs -Wno-unused-do-bind -Wunused-packages -Wpartial-fields+    -Wwarnings-deprecations++executable hoauth2-tutorial+  main-is:          Main.hs++  -- Modules included in this executable, other than Main.+  -- other-modules:++  -- LANGUAGE extensions used by modules in this package.+  -- other-extensions:+  build-depends:+    , base            >=4.5    && <5+    , hoauth2-tutorial++  hs-source-dirs:   app+  default-language: Haskell2010+  ghc-options:+    -Wall -Wtabs -Wno-unused-do-bind -Wunused-packages -Wpartial-fields+    -Wwarnings-deprecations
+ src/HOAuth2Tutorial.hs view
@@ -0,0 +1,293 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- | If you're hurry, go check source code directly.+--+-- = Configure your OAuth2 provider+--+-- Pick which OAuth2 provider you'd to use, e.g. Google, Github, Auth0 etc.+-- Pretty much all standard OAuth2 provider has developer portal to guide developer to use oauth2 flow.+-- So read it through if you're unfamiliar OAuth2 before.+-- Often time, those documents will guide you how to create an Application which has credentials+-- (e.g. @client_id@ and @client_secret@ for a web application), which will be used to authenticate your+-- service (replying party) with server.+--+-- For some OIDC providers, you may even be able to find out those URLs from a well-known endpoint.+--+-- @+-- https:\/\/BASE_DOMAIN\/.well-known\/openid-configuration+-- @+--+-- In this tutorial, I choose Auth0, which is one of existing OAuth2/OIDC Providers in the market.+-- This is the API Docs <https://auth0.com/docs/api>+--+-- = Generate Authorization URL.+--+-- OAuth2 starts with [authorization](https://www.rfc-editor.org/rfc/rfc6749#section-4).+--+-- To generate an authorization URL, call method 'authorizationUrlWithParams' with some query parameters.+-- For instance, @state@, @scope@ etc.+-- That method will also automatically append following query parameter to the authorization url.+--+-- @+-- client_id = 'xxx'        -- client id of your Application credential you got previously+-- response_type = 'code'   -- must be for authorization request+-- redirect_uri = 'xxx'     -- where does the server (provider) send back the authorization code.+--                        -- You have to config this when creating Application at previous step.+-- @+--+-- The generated URL looks like+--+-- @+-- https://DOMAIN/path/to/authorize?client_id=xxx&response_type=code&redirect_uri=xxx&state=xxx&scope=xxx&..+-- @+--+-- /Notes/: As of today, @hoauth2@ only supports @Code Grant@.+--+-- = Redirect user to the Authorization URL+--+-- Now you need to have your user to navigate to that URL to kick off OAuth flow.+--+-- There are different ways to redirect user to the 'authorizeUrl'.+--+-- e.g.+--+--   1. Display as anchor link directly at UI so that user can click it.+--+--   2. Create your own login endpoint, e.g. @/login@, which then 302 to the 'authorizeUrl'.+--+-- In this tutorial, I choose the second option. For instance this is how 'indexH' is implemented.+--+-- >>> setHeader "Location" (uriToText authorizeUrl)+-- >>> status status302+--+-- = Obtain Access Token+--+-- When user navigates to 'authorizeUrl', user will be prompt for login against the OAuth provider.+--+-- After an successful login there, user will be redirect back to your Application's @redirect_uri@+-- with @code@ in the query parameter.+--+-- With this @code@, we could exchange for an Access Token.+--+-- Also you'd better to validate the @state@ is exactly what you pass in the 'authorizeUrl'.+-- OAuth2 provider expects to send the exact @state@ back in the redirect request.+--+-- To obtain an Access Token, you could call 'fetchAccessToken',+-- which essentially takes the authorization @code@, make request to OAuth2 provider's @/token@ endpoint+-- to get an Access Token, plus some other information (see details at 'OAuth2Token').+--+-- 'fetchAccessToken' returns @ExceptT (OAuth2Error Errors) m OAuth2Token@+-- However Scotty, which is web framework I used to build this tutorial,+-- requires error as Text hence the transform with 'oauth2ErrorToText'+--+-- Once we got the 'OAuth2Token' (which actually deserves an better name like @TokenResponse@),+-- we could get the actual 'accessToken' of out it, use which to make API requests to resource server (often time same as the authorization server)+--+-- "Network.OAuth.OAuth2.HttpClient" provides a few handy method to send such API request.+-- For instance,+--+-- @+-- authGetJSON   -- Makes GET request and decode response as JSON, with access token appended in Authorization http header.+-- authPostJSON  -- Similar but does POST request+-- @+--+-- In this tutorial, it makes request to 'auth0UserInfoUri' to fetch Auth0 user information+-- so application knows who did the authorize.+--+-- = The end+--+-- That's it! Congratulations make thus far!+--+-- If you're interested more of OAuth2, keep reading on <https://www.oauth.com/>,+-- which provides a nice guide regarding what is OAuth2 and various use cases.+module HOAuth2Tutorial where++import Control.Monad (void)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Except+import Data.Aeson+  ( FromJSON (parseJSON),+    defaultOptions,+    genericParseJSON,+  )+import Data.ByteString.Char8 qualified as BS+import Data.ByteString.Lazy.Char8 qualified as BSL+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Text.Encoding qualified as T+import Data.Text.Lazy qualified as TL+import GHC.Generics (Generic)+import Network.HTTP.Conduit (newManager, tlsManagerSettings)+import Network.HTTP.Types (status302)+import Network.OAuth.OAuth2.AuthorizationRequest+  ( authorizationUrlWithParams,+  )+import Network.OAuth.OAuth2.HttpClient (authGetJSON)+import Network.OAuth.OAuth2.Internal+  ( ExchangeToken (ExchangeToken),+    OAuth2 (..),+    OAuth2Error,+    OAuth2Token (accessToken),+  )+import Network.OAuth.OAuth2.TokenRequest (fetchAccessToken)+import Network.OAuth.OAuth2.TokenRequest qualified as TR+import URI.ByteString (URI, serializeURIRef')+import URI.ByteString.QQ (uri)+import Web.Scotty (ActionM, scotty)+import Web.Scotty qualified as Scotty++------------------------------++-- * Configuration++------------------------------++auth0 :: OAuth2+auth0 =+  OAuth2+    { oauth2ClientId = "TZlmNRtLY9duT8M4ztgFBLsFA66aEoGs",+      oauth2ClientSecret = "",+      oauth2AuthorizeEndpoint = [uri|https://freizl.auth0.com/authorize|],+      oauth2TokenEndpoint = [uri|https://freizl.auth0.com/oauth/token|],+      oauth2RedirectUri = [uri|http://localhost:9988/oauth2/callback|]+    }++authorizeUrl :: URI+authorizeUrl =+  authorizationUrlWithParams+    [ ("scope", "openid profile email"),+      ("state", randomStateValue)+    ]+    auth0++-- | You'll need to find out an better to create @state@+-- which is recommended in <https://www.rfc-editor.org/rfc/rfc6749#section-10.12>+randomStateValue :: BS.ByteString+randomStateValue = "random-state-to-prevent-csrf"++-- | Endpoint for fetching user profile using access token+auth0UserInfoUri :: URI+auth0UserInfoUri = [uri|https://freizl.auth0.com/userinfo|]++-- | Auth0 user+-- https://auth0.com/docs/api/authentication#get-user-info+data Auth0User = Auth0User+  { name :: TL.Text,+    email :: TL.Text,+    sub :: TL.Text+  }+  deriving (Show, Generic)++instance FromJSON Auth0User where+  parseJSON = genericParseJSON defaultOptions++------------------------------++-- * Web server++------------------------------++-- | The 'scotty' application+app :: IO ()+app = do+  -- Poor man's solution for creating user session.+  refUser <- newIORef Nothing+  scotty 9988 $ do+    Scotty.get "/" $ indexH refUser+    Scotty.get "/login" loginH+    Scotty.get "/logout" (logoutH refUser)+    Scotty.get "/oauth2/callback" $ callbackH refUser++-- | @/@ endpoint handler+indexH :: IORef (Maybe Auth0User) -> ActionM ()+indexH refUser = do+  muser <- liftIO (readIORef refUser)++  let info = case muser of+        Just user ->+          [ "<p>Hello, " `TL.append` name user `TL.append` "</p>",+            "<a href='/logout'>Logout</a>"+          ]+        Nothing -> ["<a href='/login'>Login</a>"]++  Scotty.html . mconcat $ "<h1>hoauth2 Tutorial</h1>" : info++-- | @/login@ endpoint handler+loginH :: ActionM ()+loginH = do+  Scotty.setHeader "Location" (uriToText authorizeUrl)+  Scotty.status status302++-- | @/logout@ endpoint handler+logoutH :: IORef (Maybe Auth0User) -> ActionM ()+logoutH refUser = do+  liftIO (writeIORef refUser Nothing)+  Scotty.redirect "/"++-- | @/oauth2/callback@ endpoint handler+callbackH :: IORef (Maybe Auth0User) -> ActionM ()+callbackH refUser = do+  pas <- Scotty.params++  excepttToActionM $ do+    void $ ExceptT $ pure $ paramValue "state" pas+    codeP <- ExceptT $ pure $ paramValue "code" pas++    mgr <- liftIO $ newManager tlsManagerSettings++    -- Exchange authorization code for Access Token+    -- 'oauth2ErrorToText' turns (OAuth2 error) to Text which is the default way+    -- Scotty represents error message+    let code = ExchangeToken $ TL.toStrict codeP+    tokenResp <- withExceptT oauth2ErrorToText (fetchAccessToken mgr auth0 code)++    -- Call API to resource server with Access Token being authentication code.+    -- 'bslToText' exists for similar reason as 'oauth2ErrorToText'+    let at = accessToken tokenResp+    user <- withExceptT bslToText (authGetJSON mgr at auth0UserInfoUri)++    -- Now we need to find way to set authentication status for this application+    -- that indicates user has been authenticated successfully.+    -- For simplicity in this tutorial, I choose an 'IORef'.+    liftIO $ writeIORef refUser (Just user)++  -- Where to navigate to after login page successfully.+  Scotty.redirect "/"++------------------------------++-- * Utilities++------------------------------++uriToText :: URI -> TL.Text+uriToText = TL.fromStrict . T.decodeUtf8 . serializeURIRef'++bslToText :: BSL.ByteString -> TL.Text+bslToText = TL.pack . BSL.unpack++paramValue ::+  -- | Parameter key+  TL.Text ->+  -- | All parameters+  [Scotty.Param] ->+  Either TL.Text TL.Text+paramValue key params =+  if null val+    then Left ("No value found for param: " <> key)+    else Right (head val)+  where+    val = snd <$> filter (hasParam key) params+    hasParam :: TL.Text -> Scotty.Param -> Bool+    hasParam t = (== t) . fst++-- | Lift ExceptT to ActionM which is basically the handler Monad in Scotty.+excepttToActionM :: Show a => ExceptT TL.Text IO a -> ActionM a+excepttToActionM e = do+  result <- liftIO $ runExceptT e+  either Scotty.raise pure result++oauth2ErrorToText :: OAuth2Error TR.Errors -> TL.Text+oauth2ErrorToText e = TL.pack $ "Unable fetch access token. error detail: " ++ show e