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 +30/−0
- app/Main.hs +6/−0
- hoauth2-tutorial.cabal +64/−0
- src/HOAuth2Tutorial.hs +293/−0
+ 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