packages feed

hoauth2-demo-1.6.0: src/Idp.hs

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

module Idp where

import Control.Monad.IO.Class
import Control.Monad.Trans.Except
import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Aeson
import Data.Aeson.KeyMap qualified as Aeson
import Data.Bifunctor
import Data.ByteString qualified as BS
import Data.Default
import Data.Map.Strict qualified as Map
import Data.Maybe
import Data.Set qualified as Set
import Data.Text.Lazy (Text)
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Encoding qualified as TL
import Env qualified
import Jose.Jwt
import Lens.Micro
import Network.OAuth.OAuth2
import Network.OAuth2.Experiment
import Network.OAuth2.Provider.Auth0 qualified as IAuth0
import Network.OAuth2.Provider.AzureAD qualified as IAzureAD
import Network.OAuth2.Provider.Dropbox qualified as IDropbox
import Network.OAuth2.Provider.Facebook qualified as IFacebook
import Network.OAuth2.Provider.Fitbit qualified as IFitbit
import Network.OAuth2.Provider.Github qualified as IGithub
import Network.OAuth2.Provider.Google qualified as IGoogle
import Network.OAuth2.Provider.Linkedin qualified as ILinkedin
import Network.OAuth2.Provider.Okta qualified as IOkta
import Network.OAuth2.Provider.Slack qualified as ISlack
import Network.OAuth2.Provider.StackExchange qualified as IStackExchange
import Network.OAuth2.Provider.Twitter qualified as ITwitter
import Network.OAuth2.Provider.Utils
import Network.OAuth2.Provider.Weibo qualified as IWeibo
import Network.OAuth2.Provider.ZOHO qualified as IZOHO
import Session
import System.Directory
import Types
import URI.ByteString
import URI.ByteString.QQ (uri)
import Prelude hiding (id)

defaultOAuth2RedirectUri :: URI
defaultOAuth2RedirectUri = [uri|http://localhost:9988/oauth2/callback|]

createAuthorizationApps :: MonadIO m => (Idp IAuth0.Auth0, Idp IOkta.Okta) -> ExceptT Text m [DemoAuthorizationApp]
createAuthorizationApps (myAuth0Idp, myOktaIdp) = do
  configParams <- readEnvFile
  let initIdpAppConfig :: IdpApplication 'AuthorizationCode i -> IdpApplication 'AuthorizationCode i
      initIdpAppConfig idpAppConfig@AuthorizationCodeIdpApplication {..} =
        case Aeson.lookup (Aeson.fromString $ TL.unpack $ TL.toLower $ getIdpAppName idpAppConfig) configParams of
          Nothing -> idpAppConfig
          Just config ->
            idpAppConfig
              { idpAppClientId = ClientId $ Env.clientId config
              , idpAppClientSecret = ClientSecret $ Env.clientSecret config
              , idpAppRedirectUri = defaultOAuth2RedirectUri
              , idpAppScope = Set.unions [idpAppScope, Set.map Scope (Set.fromList (fromMaybe [] (Env.scopes config)))]
              , idpAppAuthorizeState = AuthorizeState (idpAppName <> ".hoauth2-demo-app-123")
              }
  pure
    [ DemoAuthorizationApp (initIdpAppConfig IAzureAD.defaultAzureADApp)
    , DemoAuthorizationApp (initIdpAppConfig (IAuth0.defaultAuth0App myAuth0Idp))
    , DemoAuthorizationApp (initIdpAppConfig IFacebook.defaultFacebookApp)
    , DemoAuthorizationApp (initIdpAppConfig IFitbit.defaultFitbitApp)
    , DemoAuthorizationApp (initIdpAppConfig IGithub.defaultGithubApp)
    , DemoAuthorizationApp (initIdpAppConfig IDropbox.defaultDropboxApp)
    , DemoAuthorizationApp (initIdpAppConfig IGoogle.defaultGoogleApp)
    , DemoAuthorizationApp (initIdpAppConfig ILinkedin.defaultLinkedinApp)
    , DemoAuthorizationApp (initIdpAppConfig (IOkta.defaultOktaApp myOktaIdp))
    , DemoAuthorizationApp (initIdpAppConfig ITwitter.defaultTwitterApp)
    , DemoAuthorizationApp (initIdpAppConfig ISlack.defaultSlackApp)
    , DemoAuthorizationApp (initIdpAppConfig IWeibo.defaultWeiboApp)
    , DemoAuthorizationApp (initIdpAppConfig IZOHO.defaultZohoApp)
    , DemoAuthorizationApp (initIdpAppConfig IStackExchange.defaultStackExchangeApp)
    ]

googleServiceAccountApp ::
  ExceptT
    Text
    IO
    (IdpApplication 'JwtBearer IGoogle.Google)
googleServiceAccountApp = do
  IGoogle.GoogleServiceAccountKey {..} <- withExceptT TL.pack (ExceptT $ Aeson.eitherDecodeFileStrict ".google-sa.json")
  pkey <- withExceptT TL.pack (ExceptT $ IGoogle.readPemRsaKey privateKey)
  jwt <-
    withExceptT
      TL.pack
      ( ExceptT $
          IGoogle.mkJwt
            pkey
            clientEmail
            Nothing
            ( Set.fromList
                [ "https://www.googleapis.com/auth/userinfo.email"
                , "https://www.googleapis.com/auth/userinfo.profile"
                ]
            )
            IGoogle.defaultGoogleIdp
      )
  pure $ IGoogle.defaultServiceAccountApp jwt

oktaPasswordGrantApp :: Idp IOkta.Okta -> IdpApplication 'ResourceOwnerPassword IOkta.Okta
oktaPasswordGrantApp i =
  ResourceOwnerPasswordIDPApplication
    { idpAppClientId = ""
    , idpAppClientSecret = ""
    , idpAppName = "okta-demo-password-grant-app"
    , idpAppScope = Set.fromList ["openid", "profile"]
    , idpAppUserName = ""
    , idpAppPassword = ""
    , idpAppTokenRequestExtraParams = Map.empty
    , idp = i
    }

-- Base on the document, it works well with custom Athourization Server
-- https://developer.okta.com/docs/guides/implement-grant-type/clientcreds/main/#client-credentials-flow
--
-- With Org AS, got this error
-- Client Credentials requests to the Org Authorization Server must use the private_key_jwt token_endpoint_auth_method
--
oktaClientCredentialsGrantApp :: Idp IOkta.Okta -> IO (IdpApplication 'ClientCredentials IOkta.Okta)
oktaClientCredentialsGrantApp i = do
  let clientId = "0oa9mbklxn2Ac0oJ24x7"
  keyJsonStr <- BS.readFile ".okta-key.json"
  case Aeson.eitherDecodeStrict keyJsonStr of
    Right jwk -> do
      ejwt <- IOkta.mkOktaClientCredentialAppJwt jwk clientId i
      case ejwt of
        Right jwt ->
          pure
            ClientCredentialsIDPApplication
              { idpAppClientId = clientId
              , idpAppClientSecret = ClientSecret (TL.decodeUtf8 $ bsFromStrict $ unJwt jwt)
              , idpAppTokenRequestAuthenticationMethod = ClientAssertionJwt
              , idpAppName = "okta-demo-cc-grant-jwt-app"
              , -- , idpAppScope = Set.fromList ["hw-test"]
                idpAppScope = Set.fromList ["okta.users.read"]
              , idpAppTokenRequestExtraParams = Map.empty
              , idp = i
              }
        Left e -> Prelude.error e
    Left e -> Prelude.error e

-- | https://auth0.com/docs/api/authentication#resource-owner-password
auth0PasswordGrantApp :: Idp IAuth0.Auth0 -> IdpApplication 'ResourceOwnerPassword IAuth0.Auth0
auth0PasswordGrantApp i =
  ResourceOwnerPasswordIDPApplication
    { idpAppClientId = ""
    , idpAppClientSecret = ""
    , idpAppName = "auth0-demo-password-grant-app"
    , idpAppScope = Set.fromList ["openid", "profile", "email"]
    , idpAppUserName = "test"
    , idpAppPassword = ""
    , idpAppTokenRequestExtraParams = Map.empty
    , idp = i
    }

-- | https://auth0.com/docs/api/authentication#client-credentials-flow
auth0ClientCredentialsGrantApp :: Idp IAuth0.Auth0 -> IdpApplication 'ClientCredentials IAuth0.Auth0
auth0ClientCredentialsGrantApp i =
  ClientCredentialsIDPApplication
    { idpAppClientId = ""
    , idpAppClientSecret = ""
    , idpAppTokenRequestAuthenticationMethod = ClientSecretPost
    , idpAppName = "auth0-demo-cc-grant-app"
    , idpAppScope = Set.fromList ["read:users"]
    , idpAppTokenRequestExtraParams = Map.fromList [("audience ", "https://freizl.auth0.com/api/v2/")]
    , idp = i
    }

isSupportPkce :: forall a i. ( 'AuthorizationCode ~ a) => IdpApplication a i -> Bool
isSupportPkce AuthorizationCodeIdpApplication {..} =
  let hostStr = idpAuthorizeEndpoint idp ^. (authorityL . _Just . authorityHostL . hostBSL)
   in any
        (`BS.isInfixOf` hostStr)
        [ "auth0.com"
        , "okta.com"
        , "google.com"
        , "twitter.com"
        ]

-- TODO: use Paths_ module for better to find the file?
envFilePath :: String
envFilePath = ".env.json"

readEnvFile :: MonadIO m => ExceptT Text m Env.EnvConfig
readEnvFile = liftIO $ do
  envFileE <- doesFileExist envFilePath
  if envFileE
    then do
      putStrLn "Found .env.json"
      fileContent <- BS.readFile envFilePath
      case Aeson.eitherDecodeStrict fileContent of
        Left err -> print err >> return Aeson.empty
        Right ec -> return ec
    else return Aeson.empty

initIdps :: MonadIO m => CacheStore -> (Idp IAuth0.Auth0, Idp IOkta.Okta) -> ExceptT Text m ()
initIdps c is = do
  idps <- createAuthorizationApps is
  mapM mkDemoAppEnv idps >>= mapM_ (upsertDemoAppEnv c)

mkDemoAppEnv :: MonadIO m => DemoAuthorizationApp -> ExceptT Text m DemoAppEnv
mkDemoAppEnv ia@(DemoAuthorizationApp idpAppConfig) = do
  re <-
    if isSupportPkce idpAppConfig
      then fmap (second Just) (mkPkceAuthorizeRequest idpAppConfig)
      else pure (mkAuthorizeRequest idpAppConfig, Nothing)
  pure $ DemoAppEnv ia (def {authorizeAbsUri = fst re, authorizePkceCodeVerifier = snd re})