packages feed

hoauth2-demo-1.7.0: src/Idp.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RankNTypes #-}

module Idp where

import Control.Monad.IO.Class
import Control.Monad.Trans.Except
import Data.Aeson (FromJSON)
import Data.Aeson qualified as Aeson
import Data.Bifunctor
import Data.ByteString qualified as BS
import Data.ByteString.Contrib
import Data.ByteString.Lazy.Char8 qualified as BSL
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 Network.HTTP.Conduit
import Network.OAuth.OAuth2
import Network.OAuth2.Experiment
import Network.OAuth2.Provider
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.Weibo qualified as IWeibo
import Network.OAuth2.Provider.ZOHO qualified as IZOHO
import Types
import URI.ByteString (URI)
import URI.ByteString.QQ (uri)
import User
import Prelude hiding (id)

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

createAuthorizationCodeApp ::
  Idp i ->
  IdpName ->
  ExceptT Text IO (IdpApplication i AuthorizationCodeApplication)
createAuthorizationCodeApp idp idpName = do
  let newAppName = "sample-" <> toText idpName <> "-authorization-code-app"
  let sampleApp = findAuthorizationCodeSampleApp idpName
  Env.OAuthAppSetting {..} <- Env.lookup newAppName
  let newApp' =
        sampleApp
          { acClientId = clientId
          , acClientSecret = clientSecret
          , acScope = if Set.null scopes then acScope sampleApp else scopes
          , acRedirectUri = defaultOAuth2RedirectUri
          , acAuthorizeState = AuthorizeState (toText idpName <> ".hoauth2-demo-app-123")
          }
  pure IdpApplication {idp = idp, application = newApp'}

-- | https://auth0.com/docs/api/authentication#resource-owner-password
createResourceOwnerPasswordApp ::
  Idp i ->
  IdpName ->
  ExceptT Text IO (IdpApplication i ResourceOwnerPasswordApplication)
createResourceOwnerPasswordApp i idpName = do
  let newAppName = "sample-" <> toText idpName <> "-resource-owner-app"
  let defaultApp =
        ResourceOwnerPasswordApplication
          { ropClientId = ""
          , ropClientSecret = ""
          , ropName = newAppName
          , ropScope = Set.fromList ["openid", "profile", "email"]
          , ropUserName = ""
          , ropPassword = ""
          , ropTokenRequestExtraParams = Map.empty
          }
  Env.OAuthAppSetting {..} <- Env.lookup newAppName
  newApp' <- case user of
    Nothing -> throwE ("[createResourceOwnerPasswordApp] unable to load user config for " <> toText idpName)
    Just userConfig ->
      pure
        defaultApp
          { ropClientId = clientId
          , ropClientSecret = clientSecret
          , ropUserName = Username (Env.username userConfig)
          , ropPassword = Password (Env.password userConfig)
          }
  pure $
    IdpApplication
      { idp = i
      , application = newApp'
      }

-- | https://auth0.com/docs/api/authentication#client-credentials-flow
createClientCredentialsApp ::
  Idp i ->
  IdpName ->
  ExceptT Text IO (IdpApplication i ClientCredentialsApplication)
createClientCredentialsApp i idpName = do
  let newAppName = "sample-" <> toText idpName <> "-client-credentials-app"
  let defaultApp =
        ClientCredentialsApplication
          { ccClientId = ""
          , ccClientSecret = ""
          , ccTokenRequestAuthenticationMethod = ClientSecretPost
          , ccName = ""
          , ccScope = Set.empty
          , ccTokenRequestExtraParams = Map.empty
          }

  Env.OAuthAppSetting {..} <- Env.lookup newAppName
  newApp <- case idpName of
    Auth0 ->
      pure
        defaultApp
          { ccTokenRequestExtraParams = Map.fromList [("audience ", "https://freizl.auth0.com/api/v2/")]
          }
    -- Okta -> createOktaClientCredentialsGrantAppJwt i appSetting
    _ -> pure defaultApp
  let newApp' =
        newApp
          { ccClientId = clientId
          , ccClientSecret = clientSecret
          , ccScope = scopes
          , ccName = newAppName
          }
  pure $
    IdpApplication
      { idp = i
      , application = newApp'
      }

-- Base on the document, it works well with both custom Athourization Server and Org As.
-- https://developer.okta.com/docs/guides/implement-grant-type/clientcreds/main/#client-credentials-flow
--
-- But with Org AS, has to use jwt athentication method otherwise got error
-- Client Credentials requests to the Org Authorization Server must use the private_key_jwt token_endpoint_auth_method
--
-- FIXME: Get error from Okta about parsing assertion error
createOktaClientCredentialsGrantAppJwt ::
  Idp i ->
  Env.OAuthAppSetting ->
  ExceptT Text IO ClientCredentialsApplication
createOktaClientCredentialsGrantAppJwt i Env.OAuthAppSetting {..} = do
  keyJsonStr <- liftIO $ BS.readFile ".okta-key.json"
  jwk <- except (first TL.pack $ Aeson.eitherDecodeStrict keyJsonStr)
  jwt <- ExceptT $ IOkta.mkOktaClientCredentialAppJwt jwk clientId i
  pure
    ClientCredentialsApplication
      { ccClientId = clientId
      , ccClientSecret = ClientSecret (TL.decodeUtf8 $ bsFromStrict $ unJwt jwt)
      , ccTokenRequestAuthenticationMethod = ClientAssertionJwt
      , ccName = ""
      , ccScope = Set.empty
      , ccTokenRequestExtraParams = Map.empty
      }

createDeviceAuthApp ::
  Idp i ->
  IdpName ->
  ExceptT Text IO (IdpApplication i DeviceAuthorizationApplication)
createDeviceAuthApp i idpName = do
  let authMethod =
        if Okta == idpName
          then Just ClientSecretBasic
          else Nothing
      extraParams =
        if AzureAD == idpName
          then Map.singleton "tenant" "/common"
          else Map.empty
  let newAppName = "sample-" <> toText idpName <> "-device-authorization-app"
      newApp =
        DeviceAuthorizationApplication
          { daClientId = ""
          , daClientSecret = ""
          , daName = newAppName
          , daScope = Set.empty
          , daAuthorizationRequestExtraParam = extraParams
          , daAuthorizationRequestAuthenticationMethod = authMethod
          }
  Env.OAuthAppSetting {..} <- Env.lookup newAppName
  let newApp' =
        newApp
          { daClientId = clientId
          , daClientSecret = clientSecret
          , daScope = scopes
          }
  pure $
    IdpApplication
      { idp = i
      , application = newApp'
      }

googleServiceAccountApp :: ExceptT Text IO (IdpApplication Google JwtBearerApplication)
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 $
    IdpApplication
      { idp = IGoogle.defaultGoogleIdp
      , application = IGoogle.sampleServiceAccountApp jwt
      }

findIdp ::
  TenantBasedIdps ->
  IdpName ->
  DemoIdp
findIdp (myAuth0Idp, myOktaIdp) = \case
  AzureAD -> DemoIdp IAzureAD.defaultAzureADIdp
  Auth0 -> DemoIdp myAuth0Idp
  Okta -> DemoIdp myOktaIdp
  Facebook -> DemoIdp IFacebook.defaultFacebookIdp
  Fitbit -> DemoIdp IFitbit.defaultFitbitIdp
  GitHub -> DemoIdp IGitHub.defaultGithubIdp
  DropBox -> DemoIdp IDropBox.defaultDropBoxIdp
  Google -> DemoIdp IGoogle.defaultGoogleIdp
  LinkedIn -> DemoIdp ILinkedIn.defaultLinkedInIdp
  Twitter -> DemoIdp ITwitter.defaultTwitterIdp
  Slack -> DemoIdp ISlack.defaultSlackIdp
  Weibo -> DemoIdp IWeibo.defaultWeiboIdp
  ZOHO -> DemoIdp IZOHO.defaultZohoIdp
  StackExchange -> DemoIdp IStackExchange.defaultStackExchangeIdp

findAuthorizationCodeSampleApp :: IdpName -> AuthorizationCodeApplication
findAuthorizationCodeSampleApp = \case
  Auth0 -> IAuth0.sampleAuth0AuthorizationCodeApp
  Okta -> IOkta.sampleOktaAuthorizationCodeApp
  AzureAD -> IAzureAD.sampleAzureADAuthorizationCodeApp
  Facebook -> IFacebook.sampleFacebookAuthorizationCodeApp
  Fitbit -> IFitbit.sampleFitbitAuthorizationCodeApp
  GitHub -> IGitHub.sampleGithubAuthorizationCodeApp
  DropBox -> IDropBox.sampleDropBoxAuthorizationCodeApp
  Google -> IGoogle.sampleGoogleAuthorizationCodeApp
  LinkedIn -> ILinkedIn.sampleLinkedInAuthorizationCodeApp
  Twitter -> ITwitter.sampleTwitterAuthorizationCodeApp
  Slack -> ISlack.sampleSlackAuthorizationCodeApp
  Weibo -> IWeibo.sampleWeiboAuthorizationCodeApp
  ZOHO -> IZOHO.sampleZohoAuthorizationCodeApp
  StackExchange -> IStackExchange.sampleStackExchangeAuthorizationCodeApp

findFetchUserInfoMethod ::
  (MonadIO m, HasDemoLoginUser i, HasUserInfoRequest a, FromJSON (IdpUser i)) =>
  IdpName ->
  ( IdpApplication i a ->
    Manager ->
    AccessToken ->
    ExceptT BSL.ByteString m (IdpUser i)
  )
findFetchUserInfoMethod = \case
  Auth0 -> IAuth0.fetchUserInfo
  Okta -> IOkta.fetchUserInfo
  AzureAD -> IAzureAD.fetchUserInfo
  Facebook -> IFacebook.fetchUserInfo
  Fitbit -> IFitbit.fetchUserInfo
  GitHub -> IGitHub.fetchUserInfo
  DropBox -> IDropBox.fetchUserInfo
  Google -> IGoogle.fetchUserInfo
  LinkedIn -> ILinkedIn.fetchUserInfo
  Twitter -> ITwitter.fetchUserInfo
  Slack -> ISlack.fetchUserInfo
  Weibo -> IWeibo.fetchUserInfo
  ZOHO -> IZOHO.fetchUserInfo
  StackExchange -> IStackExchange.fetchUserInfo

isSupportPkce :: IdpName -> Bool
isSupportPkce idpName =
  idpName
    `elem` [ Auth0
           , Okta
           , Google
           , Twitter
           ]