packages feed

hoauth2-tutorial 0.2 → 0.3.0

raw patch · 4 files changed

+329/−48 lines, 4 filesdep +containersdep −hoauth2-tutorialdep −waidep −warpdep ~hoauth2dep ~scottydep ~textnew-component:exe:hoauth2-experiment-tutorial

Dependencies added: containers

Dependencies removed: hoauth2-tutorial, wai, warp

Dependency ranges changed: hoauth2, scotty, text, transformers

Files

− app/Main.hs
@@ -1,6 +0,0 @@-module Main where--import HOAuth2Tutorial (app)--main :: IO ()-main = app
hoauth2-tutorial.cabal view
@@ -1,6 +1,6 @@ cabal-version: 2.4 name:          hoauth2-tutorial-version:       0.2+version:       0.3.0 synopsis:      Tutorial for using hoauth2 description:   Tutorial to demostrate how to use hoauth2 to implement OAuth2 flow in an web Application.@@ -14,32 +14,25 @@ category:      Network build-type:    Simple stability:     Beta-tested-with:   GHC <=9.2.2---- A copyright notice.--- copyright:--- category:+tested-with:   GHC <=9.6.1  source-repository head   type:     git   location: git://github.com/freizl/hoauth2.git -library+common common   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.7-    , 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+    , aeson           >=2.0   && <2.2+    , base            >=4.5   && <5+    , bytestring      >=0.9   && <0.12+    , hoauth2         ^>=2.9+    , http-conduit    >=2.1   && <2.4+    , http-types      >=0.11  && <0.13+    , scotty          >=0.10  && <0.13+    , text            >=0.11  && <2.1+    , transformers    >=0.4   && <0.7+    , uri-bytestring  >=0.2.3 && <0.4    default-language: Haskell2010   ghc-options:@@ -47,19 +40,11 @@     -Wwarnings-deprecations  executable hoauth2-tutorial-  main-is:          Main.hs--  -- Modules included in this executable, other than Main.-  -- other-modules:+  import: common+  main-is:          HOAuth2Tutorial.hs -  -- LANGUAGE extensions used by modules in this package.-  -- other-extensions:+executable hoauth2-experiment-tutorial+  import: common+  main-is:          HOAuth2ExperimentTutorial.hs   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+    containers ^>= 0.6
+ src/HOAuth2ExperimentTutorial.hs view
@@ -0,0 +1,300 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE ImportQualifiedPost #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- | If you're hurry, go check source code directly.+--+-- = Configure your Identity Provider and Application+--+-- 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. This is the API Docs <https://auth0.com/docs/api>.+--+-- Define a @Idp@ and an Application+--+-- @+-- auth0 :: Idp "auth0"+-- auth0 = Idp {...}+--+-- auth0AuthCodeApp :: AuthorizationCodeApplication+-- auth0AuthCodeApp = AuthorizationCodeApplication {...}+-- @+--+-- = Generate Authorization URL.+--+-- OAuth2 starts with [authorization](https://www.rfc-editor.org/rfc/rfc6749#section-4).+--+-- To generate an authorization URL, call method `mkAuthorizationRequest`.+--+-- 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.+-- @+--+-- The generated URL looks like+--+-- @+-- https://IDP_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 this authorization URL.+--+-- 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 authorization URL.+--+-- 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 authorization URL, 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 authorization URL.+-- OAuth2 provider expects to send the exact @state@ back in the redirect request.+--+-- To obtain an Access Token, you could call `conduitTokenRequest`,+-- which essentially takes the authorization @code@, make request to OAuth2 provider's @/token@ endpoint+-- to get an Access Token.+--+-- `conduitTokenRequest` returns @ExceptT (OAuth2Error Errors) m OAuth2Token@+-- However Scotty, which is web framework I used to build this tutorial,+-- requires error as Text hence it is transformed 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 @/userinfo@ endpoint 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 Main where++import Control.Monad+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Trans.Except+import Data.Aeson (FromJSON)+import Data.ByteString.Lazy.Char8 qualified as BSL+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+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 (+  ExchangeToken (ExchangeToken),+  OAuth2Token (accessToken),+  TokenResponseError,+ )+import Network.OAuth2.Experiment+import URI.ByteString.QQ (uri)+import Web.Scotty (ActionM, scotty)+import Web.Scotty qualified as Scotty++------------------------------++-- * Configuration++------------------------------++auth0 :: Idp "auth0"+auth0 =+  Idp+    { idpAuthorizeEndpoint = [uri|https://freizl.auth0.com/authorize|]+    , idpTokenEndpoint = [uri|https://freizl.auth0.com/oauth/token|]+    , idpUserInfoEndpoint = [uri|http://freizl.auth0.com/userinfo|]+    , idpDeviceAuthorizationEndpoint = Just [uri|http://freizl.auth0.com/oauth/device/code|]+    }++authCodeApp :: AuthorizationCodeApplication+authCodeApp =+  AuthorizationCodeApplication+    { acClientId = ""+    , acClientSecret = ""+    , acScope = Set.fromList ["openid", "profile", "email", "offline_access"]+    , acAuthorizeState = randomStateValue+    , acRedirectUri = [uri|http://localhost:9988/oauth2/callback|]+    , acName = "sample-auth0-authorization-code-app"+    , acAuthorizeRequestExtraParams = Map.empty+    , acTokenRequestAuthenticationMethod = ClientSecretBasic+    }++auth0DemoApp :: IdpApplication "auth0" AuthorizationCodeApplication+auth0DemoApp = IdpApplication {idp = auth0, application = authCodeApp}++-- | You'll need to find out an better way to create @state@+-- which is recommended in <https://www.rfc-editor.org/rfc/rfc6749#section-10.12>+randomStateValue :: AuthorizeState+randomStateValue = "random-state-to-prevent-csrf"++isSameState :: AuthorizeState -> TL.Text -> Bool+isSameState state1 = (== state1) . AuthorizeState++-- | 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++------------------------------++-- * Web server++------------------------------++main :: IO ()+main = app++-- | 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" (TL.fromStrict $ uriToText $ mkAuthorizationRequest auth0DemoApp)+  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+    stateV <- ExceptT $ pure $ paramValue "state" pas+    unless (isSameState randomStateValue stateV) $+      throwE "Unable to validate state"+    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 (conduitTokenRequest auth0DemoApp mgr 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 (conduitUserInfoRequest auth0DemoApp mgr at)++    -- 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++------------------------------++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 :: TokenResponseError -> TL.Text+oauth2ErrorToText e = TL.pack $ "Unable fetch access token. error detail: " ++ show e
src/HOAuth2Tutorial.hs view
@@ -27,7 +27,7 @@ -- -- OAuth2 starts with [authorization](https://www.rfc-editor.org/rfc/rfc6749#section-4). ----- To generate an authorization URL, call method 'authorizationUrl', then call 'appendQueryParams' to+-- To generate an authorization URL, call method `authorizationUrl`, then call `appendQueryParams` to -- append additional query parameters, e.g. @state@, @scope@ etc. -- -- That method will also automatically append following query parameter to the authorization url.@@ -36,7 +36,6 @@ -- 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@@ -51,15 +50,15 @@ -- -- 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'.+-- There are different ways to redirect user to the authorization URL. -- -- 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'.+--   2. Create your own login endpoint, e.g. @/login@, which then 302 to the authorization URL. ----- In this tutorial, I choose the second option. For instance this is how 'indexH' is implemented.+-- In this tutorial, I choose the second option. For instance this is how @indexH@ is implemented. -- -- >>> setHeader "Location" (uriToText authorizeUrl) -- >>> status status302@@ -104,7 +103,7 @@ -- -- 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+module Main where  import Control.Monad (void) import Control.Monad.IO.Class (liftIO)@@ -122,7 +121,7 @@   ExchangeToken (ExchangeToken),   OAuth2 (..),   OAuth2Token (accessToken),-  TokenRequestError,+  TokenResponseError,   appendQueryParams,   authGetJSON,   authorizationUrl,@@ -183,6 +182,9 @@  ------------------------------ +main :: IO ()+main = app+ -- | The 'scotty' application app :: IO () app = do@@ -283,5 +285,5 @@   result <- liftIO $ runExceptT e   either Scotty.raise pure result -oauth2ErrorToText :: TokenRequestError -> TL.Text+oauth2ErrorToText :: TokenResponseError -> TL.Text oauth2ErrorToText e = TL.pack $ "Unable fetch access token. error detail: " ++ show e