packages feed

hoauth2-demo-1.6.0: src/App.hs

{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}

module App (app) where

import Control.Monad
import Control.Monad.IO.Class
import Control.Monad.Trans.Except
import Data.Aeson
import Data.Maybe
import Data.Text.Lazy (Text)
import Data.Text.Lazy qualified as TL
import Idp
import Network.HTTP.Conduit
import Network.HTTP.Types
import Network.OAuth.OAuth2
import Network.OAuth.OAuth2 qualified as OAuth2
import Network.OAuth2.Experiment
import Network.OAuth2.Provider.Auth0 qualified as IAuth0
import Network.OAuth2.Provider.Okta qualified as IOkta
import Network.Wai qualified as WAI
import Network.Wai.Handler.Warp (run)
import Network.Wai.Middleware.Static
import Session
import Types
import Utils
import Views
import Web.Scotty
import Prelude

------------------------------
-- App
------------------------------

myServerPort :: Int
myServerPort = 9988

app :: IO ()
app =
  putStrLn ("Starting Server. http://localhost:" ++ show myServerPort)
    >> waiApp
    >>= run myServerPort

waiApp :: IO WAI.Application
waiApp = do
  cache <- initCacheStore
  re <- runExceptT $ do
    myAuth0Idp <- IAuth0.mkAuth0Idp "freizl.auth0.com"
    myOktaIdp <- IOkta.mkOktaIdp "dev-494096.okta.com"
    -- For the sake of simplicity for this demo App,
    -- I store user data in MVar in server side.
    -- It means user session shared across browsers.
    -- which simplify my testing cross browsers.
    -- I am sure you don't want to this for your production services.
    initIdps cache (myAuth0Idp, myOktaIdp)
    pure (myAuth0Idp, myOktaIdp)
  case re of
    Left e -> Prelude.error $ TL.unpack $ "unable to init cache: " <> e
    Right r -> do
      putStrLn "global cache has been initialized."
      initApp cache r

initApp :: CacheStore -> (Idp IAuth0.Auth0, Idp IOkta.Okta) -> IO WAI.Application
initApp cache idps = scottyApp $ do
  middleware $ staticPolicy (addBase "public/assets")
  defaultHandler globalErrorHandler
  get "/" $ indexH cache
  get "/oauth2/callback" $ callbackH cache
  get "/logout" $ logoutH cache
  get "/refresh" $ refreshH cache

  get "/login/password-grant" $ testPasswordGrantTypeH idps
  get "/login/cc-grant" (testClientCredentialGrantTypeH idps)

  get "/login/jwt-grant" testJwtBearerGrantTypeH

--------------------------------------------------

-- * Handlers

--------------------------------------------------

redirectToHomeM :: ActionM ()
redirectToHomeM = redirect "/"

globalErrorHandler :: Text -> ActionM ()
globalErrorHandler t = status status500 >> html t

refreshH :: CacheStore -> ActionM ()
refreshH c = do
  idpData@(DemoAppEnv idp sData) <- readIdpParam c
  exceptToActionM $ do
    newToken <- doRefreshToken idpData
    liftIO $ putStrLn ">>>>>> got new token"
    upsertDemoAppEnv c (DemoAppEnv idp (sData {oauth2Token = Just newToken}))
  redirectToHomeM

logoutH :: CacheStore -> ActionM ()
logoutH c = do
  idpData <- readIdpParam c
  liftIO (removeKey c (toLabel idpData))
  redirectToHomeM

indexH :: CacheStore -> ActionM ()
indexH c = liftIO (allValues c) >>= overviewTpl

callbackH :: CacheStore -> ActionM ()
callbackH c = do
  -- https://hackage.haskell.org/package/scotty-0.12/docs/Web-Scotty.html#t:Param
  -- (Text, Text)
  pas <- params
  let stateP = paramValue "state" pas
  when (null stateP) (raise "callbackH: no state from callback request")
  let codeP = paramValue "code" pas
  when (null codeP) (raise "callbackH: no code from callback request")
  exceptToActionM $ do
    idpData <- lookupKey c (TL.takeWhile (/= '.') (head stateP))
    fetchTokenAndUser c (head codeP) idpData
  redirectToHomeM

testPasswordGrantTypeH :: (Idp IAuth0.Auth0, Idp IOkta.Okta) -> ActionM ()
testPasswordGrantTypeH (auth0, okta) = do
  idpP <- paramValue "i" <$> params
  when (null idpP) (raise "testPasswordGrantTypeH: no idp parameter in the password grant type login request")
  let i = head idpP
  case i of
    "auth0" -> testPasswordGrantType (auth0PasswordGrantApp auth0)
    "okta" -> testPasswordGrantType (oktaPasswordGrantApp okta)
    _ -> raise $ "unable to find password grant type flow for idp " <> i
  where
    testPasswordGrantType ::
      ( HasTokenRequest a
      , 'ResourceOwnerPassword ~ a
      , HasDemoLoginUser b
      , HasUserInfoRequest a
      , FromJSON (IdpUserInfo b)
      ) =>
      IdpApplication a b ->
      ActionM ()
    testPasswordGrantType idpApp = do
      exceptToActionM $ do
        mgr <- liftIO $ newManager tlsManagerSettings
        token <- withExceptT oauth2ErrorToText $ conduitTokenRequest idpApp mgr
        user <- tryFetchUser mgr token idpApp
        liftIO $ print user
      redirectToHomeM

testClientCredentialGrantTypeH :: (Idp IAuth0.Auth0, Idp IOkta.Okta) -> ActionM ()
testClientCredentialGrantTypeH (auth0, okta) = do
  idpP <- paramValue "i" <$> params
  when (null idpP) (raise "testClientCredentialsGrantTypeH: no idp parameter in the password grant type login request")
  let i = head idpP
  case i of
    "auth0" -> testClientCredentialsGrantType (auth0ClientCredentialsGrantApp auth0)
    "okta" -> liftIO (oktaClientCredentialsGrantApp okta) >>= testClientCredentialsGrantType
    _ -> raise $ "unable to find password grant type flow for idp " <> i

testClientCredentialsGrantType ::
  forall a b.
  ( 'ClientCredentials ~ b
  ) =>
  HasTokenRequest b =>
  IdpApplication b a ->
  ActionM ()
testClientCredentialsGrantType testApp = do
  exceptToActionM $ do
    mgr <- liftIO $ newManager tlsManagerSettings
    -- client credentials flow is meant for machine to machine
    -- hence wont be able to hit /userinfo endpoint
    tokenResp <- withExceptT oauth2ErrorToText $ conduitTokenRequest testApp mgr
    liftIO $ print tokenResp
  redirectToHomeM

-- Only testing google for now
testJwtBearerGrantTypeH :: ActionM ()
testJwtBearerGrantTypeH = do
  exceptToActionM $ do
    testApp <- googleServiceAccountApp
    mgr <- liftIO $ newManager tlsManagerSettings
    tokenResp <- withExceptT oauth2ErrorToText $ conduitTokenRequest testApp mgr
    user <- tryFetchUser mgr tokenResp testApp
    liftIO $ print user
  redirectToHomeM

--------------------------------------------------

-- * Services

--------------------------------------------------

exceptToActionM :: Show a => ExceptT Text IO a -> ActionM a
exceptToActionM e = do
  result <- liftIO $ runExceptT e
  either raise return result

readIdpParam :: CacheStore -> ActionM DemoAppEnv
readIdpParam c = do
  pas <- params
  let idpP = paramValue "idp" pas
  when (null idpP) redirectToHomeM
  exceptToActionM $ lookupKey c (head idpP)

fetchTokenAndUser ::
  CacheStore ->
  Text ->
  DemoAppEnv ->
  ExceptT Text IO ()
fetchTokenAndUser c exchangeToken idpData@(DemoAppEnv (DemoAuthorizationApp idpAppConfig) DemoAppPerAppSessionData {..}) = do
  mgr <- liftIO $ newManager tlsManagerSettings
  token <-
    if isSupportPkce idpAppConfig
      then do
        when (isNothing authorizePkceCodeVerifier) (throwE "Unable to find code verifier")
        withExceptT oauth2ErrorToText $
          conduitPkceTokenRequest
            idpAppConfig
            mgr
            (ExchangeToken $ TL.toStrict exchangeToken, fromJust authorizePkceCodeVerifier)
      else withExceptT oauth2ErrorToText $ conduitTokenRequest idpAppConfig mgr (ExchangeToken $ TL.toStrict exchangeToken)
  liftIO $ do
    putStrLn "Found access token"
    print token
  luser <- tryFetchUser mgr token idpAppConfig
  liftIO $ do
    print luser
  updateIdp c idpData luser token
  where
    updateIdp :: MonadIO m => CacheStore -> DemoAppEnv -> DemoLoginUser -> OAuth2Token -> ExceptT Text m ()
    updateIdp c1 (DemoAppEnv iApp sData) luser token =
      upsertDemoAppEnv
        c1
        (DemoAppEnv iApp $ sData {loginUser = Just luser, oauth2Token = Just token})

oauth2ErrorToText :: TokenRequestError -> Text
oauth2ErrorToText e = TL.pack $ "conduitTokenRequest - cannot fetch access token. error detail: " ++ show e

tryFetchUser ::
  forall a b.
  (HasDemoLoginUser a, HasUserInfoRequest b, FromJSON (IdpUserInfo a)) =>
  Manager ->
  OAuth2Token ->
  IdpApplication b a ->
  ExceptT Text IO DemoLoginUser
tryFetchUser mgr at idpAppConfig = do
  user <- withExceptT bslToText $ conduitUserInfoRequest idpAppConfig mgr (accessToken at)
  pure $ toLoginUser @a user

doRefreshToken :: DemoAppEnv -> ExceptT Text IO OAuth2Token
doRefreshToken (DemoAppEnv (DemoAuthorizationApp idpAppConfig) (DemoAppPerAppSessionData {..})) = do
  at <- maybe (throwE "no token response found for idp") pure oauth2Token
  rt <- maybe (throwE "no refresh token found for idp") pure (OAuth2.refreshToken at)
  withExceptT (TL.pack . show) $ do
    mgr <- liftIO $ newManager tlsManagerSettings
    conduitRefreshTokenRequest idpAppConfig mgr rt