packages feed

hyperbole-oauth2-0.7.0: src/Web/Hyperbole/OAuth2.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE LambdaCase #-}

{- |
Module:      Web.Hyperbole.OAuth2
Copyright:   (c) 2024 Sean Hess
License:     BSD3
Maintainer:  Sean Hess <seanhess@gmail.com>
Stability:   experimental
Portability: portable

OAuth2 support for [Hyperbole](https://hackage.haskell.org/package/hyperbole)

* [hyperbole.live](https://hyperbole.live) - documentation and examples
* [github](https://github.com/seanhess/hyperbole) - issues and source code
-}
module Web.Hyperbole.OAuth2
  ( OAuth2 (..)
  , authUrl
  , validateCode
  , exchangeAuth
  , exchangeRefresh
  , runOAuth2
  , getConfigEnv
  , Scopes (..)
  , AuthFlow (..)
  , Config (..)
  , TokenType (..)
  , Authenticated (..)
  , Token (..)
  , ClientId
  , ClientSecret
  , Code
  , Access
  , Refresh
  , State
  , Auth
  , OAuth2Error (..)
  ) where

import Control.Monad (unless, when)
import Data.Aeson (FromJSON (..), Options (..), ToJSON (..), Value (..), defaultOptions, eitherDecode, genericParseJSON, genericToJSON)
import Data.ByteString.Lazy qualified as BL
import Data.Default
import Data.Maybe (isJust)
import Data.String (IsString (..))
import Data.String.Conversions (cs)
import Data.Text (Text)
import Data.Text qualified as T
import Effectful
import Effectful.Dispatch.Dynamic
import Effectful.Environment
import Effectful.Exception
import GHC.Generics (Generic)
import Network.HTTP.Client (HttpException, Request (..), RequestBody (..))
import Network.HTTP.Client qualified as HTTP
import Network.HTTP.Types (hAccept, hContentType)
import Text.Casing (quietSnake)
import Web.Hyperbole.Data.URI
import Web.Hyperbole.Effect.GenRandom
import Web.Hyperbole.Effect.Hyperbole
import Web.Hyperbole.Effect.Query
import Web.Hyperbole.Effect.Response (respondError)
import Web.Hyperbole.Effect.Session (Session (..), deleteSession, saveSession, session)
import Web.Hyperbole.Types.Response


authUrl :: (OAuth2 :> es) => URI -> Scopes -> Eff es URI
authUrl redirectUrl scopes = send $ AuthUrl redirectUrl scopes


validateCode :: (OAuth2 :> es) => Eff es (Token Code)
validateCode = send ValidateCode


exchangeAuth :: (OAuth2 :> es) => Token Code -> Eff es Authenticated
exchangeAuth authCode = send $ ExchangeAuth authCode


exchangeRefresh :: (OAuth2 :> es) => Token Refresh -> Eff es Authenticated
exchangeRefresh refToken = send $ ExchangeRefresh refToken


data OAuth2 :: Effect where
  AuthUrl :: URI -> Scopes -> OAuth2 m URI
  ValidateCode :: OAuth2 m (Token Code)
  ExchangeAuth :: Token Code -> OAuth2 m Authenticated
  ExchangeRefresh :: Token Refresh -> OAuth2 m Authenticated


type instance DispatchOf OAuth2 = 'Dynamic


runOAuth2
  :: (GenRandom :> es, IOE :> es, Hyperbole :> es)
  => Config
  -> HTTP.Manager
  -> Eff (OAuth2 : es) a
  -> Eff es a
runOAuth2 cfg mgr = interpret $ \_ -> \case
  AuthUrl red scopes -> do
    state <- genRandomToken 6
    let url = authorizationUrl cfg.authorize cfg.clientId red scopes state
    saveSession $ AuthFlow red state
    pure url
  ValidateCode -> do
    flow <- session @AuthFlow
    validateRedirectParams flow
  ExchangeAuth authCode -> do
    flow <- session @AuthFlow
    let params = tokenParams cfg.clientId cfg.clientSecret flow.redirect authCode
    auth <- sendTokenRequest cfg mgr params
    deleteSession @AuthFlow
    pure auth
  ExchangeRefresh refToken -> do
    let params = refreshParams cfg.clientId cfg.clientSecret refToken
    sendTokenRequest cfg mgr params


{- | read oauth config from env. This is not required, you can obtain these secrets another way
and configure the app however you please. Just pass the results into runOAuth2 in your app
-}
getConfigEnv :: (Environment :> es) => Eff es Config
getConfigEnv = do
  clientId <- Token . cs <$> getEnv "OAUTH2_CLIENT_ID"
  clientSecret <- Token . cs <$> getEnv "OAUTH2_CLIENT_SECRET"
  authorize <- Endpoint <$> getEnvURI "OAUTH2_AUTHORIZE_ENDPOINT"
  token <- Endpoint <$> getEnvURI "OAUTH2_TOKEN_ENDPOINT"
  pure $ Config{clientId, clientSecret, authorize, token}
 where
  getEnvURI n = do
    str <- getEnv n
    case parseURI str of
      Nothing -> throwIO $ OAuth2BadEnv n str
      Just u -> pure u


-- Types -------------------------------------------------

newtype Scopes = Scopes [Text]
  deriving (Show, Generic)
instance ToJSON Scopes where
  toJSON (Scopes ss) = String $ T.unwords ss
instance FromJSON Scopes where
  parseJSON v = do
    t <- parseJSON @String v
    pure $ fromString t
instance IsString Scopes where
  fromString s = Scopes $ T.words $ cs s


data ClientId
data ClientSecret
data Code
data Refresh
data Access
data State
data Auth

#if (!MIN_VERSION_aeson(2,2,0))
instance FromJSON URI
instance ToJSON URI
instance FromJSON URIAuth
instance ToJSON URIAuth
#endif


data AuthFlow = AuthFlow
  { redirect :: URI
  , state :: Token State
  }
  deriving (Generic, FromJSON, ToJSON)
instance Session AuthFlow where
  sessionKey = "OAuth2AuthFlow"
  cookiePath = Just "/"
instance Default AuthFlow where
  def = AuthFlow (pathUri "/") (Token mempty)


data Config = Config
  { clientId :: Token ClientId
  , clientSecret :: Token ClientSecret
  , authorize :: Endpoint Auth
  , token :: Endpoint (Token ())
  }


data TokenType
  = Bearer
  deriving (Show, Read, Generic)
instance ToJSON TokenType where
  toJSON s = toJSON $ show s
instance FromJSON TokenType where
  parseJSON (String ttyp) | T.toLower ttyp == "bearer" = pure Bearer
  parseJSON val = fail $ "expected TokenType but got " <> show val


data Authenticated = Authenticated
  { tokenType :: TokenType
  , expiresIn :: Maybe Int
  , scope :: Maybe Scopes
  , accessToken :: Token Access
  , refreshToken :: Maybe (Token Refresh)
  }
  deriving (Generic, Show)
instance FromJSON Authenticated where
  parseJSON = genericParseJSON defaultOptions{fieldLabelModifier = quietSnake}
instance ToJSON Authenticated where
  toJSON = genericToJSON defaultOptions{fieldLabelModifier = quietSnake}
instance Session Authenticated where
  sessionKey = "OAuth2Authenticated"
  cookiePath = Just "/"


data OAuth2Error
  = OAuth2BadResponse String BL.ByteString
  | OAuth2TokenRequest HttpException
  | OAuth2BadEnv String String
  deriving (Show, Exception)


-- Lower level --------------------------------------------------

authorizationUrl :: Endpoint Auth -> Token ClientId -> URI -> Scopes -> Token State -> URI
authorizationUrl (Endpoint auth) (Token cid) redUrl (Scopes scopes) (Token state) =
  auth{uriQuery = cs $ renderQuery True authParams}
 where
  authParams =
    [ ("response_type", Just "code")
    , ("client_id", Just $ cs cid)
    , ("redirect_uri", Just $ cs $ uriToText redUrl)
    , ("scope", Just $ cs $ T.intercalate " " scopes)
    , ("state", Just $ cs state)
    ]


tokenParams :: Token ClientId -> Token ClientSecret -> URI -> Token Code -> Query
tokenParams (Token cid) (Token sec) redUrl (Token ac) =
  [ ("grant_type", Just "authorization_code")
  , ("client_id", Just $ cs cid)
  , ("client_secret", Just $ cs sec)
  , ("redirect_uri", Just $ cs $ uriToText redUrl)
  , ("code", Just $ cs ac)
  ]


refreshParams :: Token ClientId -> Token ClientSecret -> Token Refresh -> Query
refreshParams (Token cid) (Token sec) (Token ref) =
  [ ("grant_type", Just "refresh_token")
  , ("client_id", Just $ cs cid)
  , ("client_secret", Just $ cs sec)
  , ("refresh_token", Just $ cs ref)
  ]


validateRedirectParams :: (Hyperbole :> es) => AuthFlow -> Eff es (Token Code)
validateRedirectParams flow = do
  err <- lookupParam @Text "error"

  when (isJust err) $ do
    desc <- param "error_description"
    respondError $ ErrAuth desc

  authState <- param @(Token State) "state"
  authCode <- param @(Token Code) "code"

  unless (flow.state == authState) $ do
    respondError $ ErrAuth "Oauth2 State mismatch"

  pure authCode


sendTokenRequest :: (IOE :> es) => Config -> HTTP.Manager -> Query -> Eff es Authenticated
sendTokenRequest cfg mgr params = do
  baseReq <- HTTP.requestFromURI cfg.token.uri

  let req =
        baseReq
          { method = "POST"
          , requestBody = RequestBodyBS $ renderQuery False params
          , requestHeaders =
              [ (hContentType, "application/x-www-form-urlencoded")
              , (hAccept, "application/json")
              ]
          }

  res <- liftIO (HTTP.httpLbs req mgr) `catch` (throwIO . OAuth2TokenRequest)

  let body = HTTP.responseBody res
  case eitherDecode @Authenticated body of
    Left e -> throwIO $ OAuth2BadResponse e body
    Right tr -> pure tr