packages feed

oauth2-server-0.3.0.0: test/Web/OAuth2/TestUtils.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}

module Web.OAuth2.TestUtils
  ( -- * Shared fixtures
    TestUser (..)
  , testUser
  , TestAuthSettings (..)
  , mkAuthCode
  , mkRegisteredClient
  , mkPublicClient
  , mkConfidentialClient
  , mkTrackingPersistence
  , mkState
  , mkJWTSettings
  , jwtContext
  , formAuthContext
  , runHandler
  , encodeForm
    -- * Application helpers
  , emptyOAuthState
  , createTestContext
  , createTestApplication
  , addRegisteredClientToState
  , addAuthCodeToState
  , addRefreshTokenToState
  ) where

import Control.Concurrent.MVar
import Control.Monad (void)
import Data.Aeson (FromJSON, ToJSON)
import Data.ByteString.Lazy qualified as LBS
import Data.IORef
import Data.List (find)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Time.Clock
import GHC.Generics (Generic)
import Network.Wai (Application)
import Servant (Context (EmptyContext, (:.)), Handler, Proxy (..), ServerError, serveWithContext)
import Servant.Auth.Server
import Servant.Server.Internal.Handler qualified as Internal
import Web.OAuth2
import Web.OAuth2.Types

data TestUser = TestUser
  { userId :: Text
  , userName :: Text
  , userEmail :: Text
  }
  deriving (Eq, Show, Generic, ToJSON, FromJSON)

instance ToJWT TestUser

instance FromJWT TestUser

instance FormAuth TestUser where
  type FormAuthSettings TestUser = TestAuthSettings
  runFormAuth _ username password =
    pure $
      case (username, password) of
        ("testuser", "testpass") -> Authenticated testUser
        ("admin", "admin") -> Authenticated (TestUser "admin" "Admin User" "admin@example.com")
        _ -> NoSuchUser

data TestAuthSettings = TestAuthSettings

testUser :: TestUser
testUser = TestUser "user1" "Test User" "test@example.com"

mkAuthCode
  :: Text
  -> RegisteredClient
  -> TestUser
  -> UTCTime
  -> Text
  -> Maybe Text
  -> Maybe Text
  -> AuthCode TestUser
mkAuthCode codeValue RegisteredClient{registered_client_id, registered_client_scope} usr expiry redirectUri challenge challengeMethod =
  AuthCode
    { auth_code_value = codeValue
    , auth_code_client_id = registered_client_id
    , auth_code_user = usr
    , auth_code_redirect_uri = redirectUri
    , auth_code_scope = registered_client_scope
    , auth_code_expiry = expiry
    , auth_code_challenge = challenge
    , auth_code_challenge_method = challengeMethod
    }

mkRegisteredClient
  :: Text
  -> [Text]
  -> [Text]
  -> [Text]
  -> Text
  -> Text
  -> Maybe Text
  -> RegisteredClient
mkRegisteredClient clientId redirects grants responses scope tokenMethod secret =
  RegisteredClient
    { registered_client_id = clientId
    , registered_client_name = clientId
    , registered_client_secret = secret
    , registered_client_redirect_uris = redirects
    , registered_client_grant_types = grants
    , registered_client_response_types = responses
    , registered_client_scope = scope
    , registered_client_token_endpoint_auth_method = tokenMethod
    , registered_client_registration_access_token = Nothing
    }

mkPublicClient :: Text -> [Text] -> Text -> RegisteredClient
mkPublicClient clientId redirectUris scope =
  mkRegisteredClient clientId redirectUris ["authorization_code", "refresh_token"] ["code"] scope "none" Nothing

mkConfidentialClient :: Text -> Text -> [Text] -> Text -> RegisteredClient
mkConfidentialClient clientId secret redirectUris scope =
  mkRegisteredClient clientId redirectUris ["authorization_code", "refresh_token"] ["code"] scope "client_secret_post" (Just secret)

mkTrackingPersistence
  :: IO (RefreshTokenPersistence TestUser, IO [RefreshToken TestUser])
mkTrackingPersistence = do
  ref <- newIORef ([] :: [RefreshToken TestUser])
  let persist token =
        atomicModifyIORef' ref $ \tokens -> (token : tokens, ())
      delete tokenValue =
        atomicModifyIORef' ref $ \tokens ->
          (filter ((/= tokenValue) . refresh_token_value) tokens, ())
      lookupToken tokenValue = do
        tokens <- readIORef ref
        pure $ find ((== tokenValue) . refresh_token_value) tokens
  pure
    ( RefreshTokenPersistence
        { persistRefreshToken = persist
        , deleteRefreshToken = delete
        , lookupRefreshToken = lookupToken
        }
    , reverse <$> readIORef ref
    )

mkState
  :: RefreshTokenPersistence TestUser
  -> [RegisteredClient]
  -> [(Text, AuthCode TestUser)]
  -> IO (MVar (OAuthState TestUser))
mkState persistence clients codes =
  newMVar
    OAuthState
      { auth_codes = Map.fromList codes
      , refresh_token_persistence = persistence
      , registered_clients =
          Map.fromList $ fmap (\c -> (registered_client_id c, c)) clients
      , oauth_url = "https://auth.example.com"
      , oauth_port = 443
      , login_form_renderer = defaultLoginFormRenderer
      }

mkJWTSettings :: IO JWTSettings
mkJWTSettings = defaultJWTSettings <$> generateKey

jwtContext :: JWTSettings -> Context '[JWTSettings]
jwtContext settings = settings :. EmptyContext

formAuthContext :: Context '[TestAuthSettings]
formAuthContext = TestAuthSettings :. EmptyContext

runHandler :: Handler a -> IO (Either ServerError a)
runHandler = Internal.runHandler

encodeForm :: [(Text, Text)] -> LBS.ByteString
encodeForm fields =
  let fragment (k, v) = k <> "=" <> v
      body = T.intercalate "&" (map fragment fields)
  in  LBS.fromStrict (TE.encodeUtf8 body)

emptyOAuthState :: IO (MVar (OAuthState TestUser))
emptyOAuthState = do
  persistence <- mkDefaultRefreshTokenPersistence
  newMVar (initOAuthState @TestUser "http://localhost:8080" 8080 persistence defaultLoginFormRenderer)

createTestContext :: IO (Context '[JWTSettings, TestAuthSettings])
createTestContext = do
  jwt <- mkJWTSettings
  pure (jwt :. TestAuthSettings :. EmptyContext)

createTestApplication :: IO (MVar (OAuthState TestUser), Context '[JWTSettings, TestAuthSettings], Application)
createTestApplication = do
  stateVar <- emptyOAuthState
  ctx <- createTestContext
  let app = serveWithContext (Proxy :: Proxy OAuthAPI) ctx (oAuthAPI stateVar ctx)
  pure (stateVar, ctx, app)

addRegisteredClientToState :: MVar (OAuthState TestUser) -> RegisteredClient -> IO ()
addRegisteredClientToState st client =
  void $
    modifyMVar st $ \s -> do
      let updated =
            s
              { registered_clients =
                  Map.insert (registered_client_id client) client (registered_clients s)
              }
      pure (updated, ())

addAuthCodeToState :: MVar (OAuthState TestUser) -> AuthCode TestUser -> IO ()
addAuthCodeToState st ac =
  void $
    modifyMVar st $ \s -> do
      let updated = s{auth_codes = Map.insert (auth_code_value ac) ac (auth_codes s)}
      pure (updated, ())

addRefreshTokenToState :: MVar (OAuthState TestUser) -> RefreshToken TestUser -> IO ()
addRefreshTokenToState st rt =
  void $
    modifyMVar st $ \s -> do
      persistRefreshToken (refresh_token_persistence s) rt
      pure (s, ())