oauth2-mock-server/src/Server.hs
2024-01-10 03:00:12 +01:00

341 lines
11 KiB
Haskell

{-# LANGUAGE DataKinds, TypeOperators, OverloadedStrings, ScopedTypeVariables, TypeApplications, RecordWildCards, AllowAmbiguousTypes #-}
module Server
{-( insecureOAuthMock'
, runMockServer
-- , runMockServer'
)-} where
import AuthCode
import User
import Control.Applicative ((<|>))
import Control.Concurrent
import Control.Concurrent.STM (atomically)
import Control.Concurrent.STM.TVar (newTVarIO, readTVar)
import Control.Exception (bracket)
import Control.Monad (unless)
import Control.Monad.IO.Class
import Control.Monad.Trans.Reader
import Data.Aeson
import Data.ByteString (ByteString (..), fromStrict, toStrict)
import Data.List (find, elemIndex)
import Data.Maybe (fromMaybe, isJust)
import Data.String (IsString (..))
import Data.Text hiding (elem, find, head, length, map, null, splitAt, tail, words)
import Data.Text.Encoding (decodeUtf8)
import Data.Time.Clock (NominalDiffTime (..), nominalDay, UTCTime(..), getCurrentTime, addUTCTime)
import qualified Data.ByteString.Char8 as BS
import qualified Data.Map.Strict as Map
import GHC.Read (readPrec, lexP)
import Jose.Jwa
import Jose.Jwe
import Jose.Jwk (generateRsaKeyPair, generateSymmetricKey, KeyUse(Enc), KeyId)
import Jose.Jwt hiding (decode, encode)
import Network.HTTP.Client (newManager, defaultManagerSettings)
import Network.Wai.Handler.Warp
import Servant
import Servant.Client
import Servant.API
import Text.ParserCombinators.ReadPrec (look, pfail)
import qualified Text.Read.Lex as Lex
testUsers :: [User] -- TODO move to db
testUsers =
[ User {name = "Fallback User", email = "foo@bar.com", password = "0000", uID = "0"}
, User {name = "Tina Tester", email = "t@t.tt", password = "1111", uID = "1"}
, User {name = "Max Muster", email = "m@m.mm", password = "2222", uID = "2"}]
data AuthClient = Client
{ ident :: Text
, secret :: Text
} deriving (Show, Eq)
trustedClients :: [AuthClient] -- TODO move to db
trustedClients = [Client "42" "shhh"]
data ResponseType = Code -- ^ authorisation code grant
| Token -- ^ implicit grant via access token
| IDToken -- ^ implicit grant via access token & ID token
deriving (Eq, Show)
instance Read ResponseType where
readPrec = do
Lex.Ident str <- lexP
Lex.EOF <- lexP
case str of
"code" -> return Code
"token" -> return Token
"id_token" -> return IDToken
_ -> pfail
------------------------------
---- Authorisation endpoint ----
------------------------------
type QScope = String
type QClient = String
type QResType = String
type QRedirect = String
type QState = Text
type QParam = QueryParam' [Required, Strict]
type Auth user userData = BasicAuth "login" user
:> "auth"
:> QParam "scope" QScope
:> QParam "client_id" QClient
:> QParam "response_type" QResType
:> QParam "redirect_uri" QRedirect
:> QueryParam "state" QState
:> Get '[JSON] userData
-- type Insert = "insert" :> Post '[JSON] User
type AuthHandler = ReaderT AuthState Handler
type AuthServer a = ServerT a AuthHandler
toHandler :: AuthState -> AuthHandler a -> Handler a
toHandler s h = runReaderT h s
authServer :: forall user userData . UserData user userData => AuthServer (Auth user userData)
authServer = handleAuth
where
handleAuth :: user
-> QScope
-> QClient
-> QResType
-> QRedirect
-> Maybe QState
-> AuthHandler userData
handleAuth u scopes client responseType url mState = do
unless (isJust $ find (\c -> ident c == pack client) trustedClients) . -- TODO fetch trusted clients from db | TODO also check if the redirect url really belongs to the client
throwError $ err404 { errBody = "Not a trusted client."}
let responseType' = read @ResponseType responseType
liftIO $ print responseType'
unless (responseType' == Code) $ throwError err500 { errBody = "Unsupported response type" }
mAuthCode <- asks (genUnencryptedCode client url 600) >>= liftIO
liftIO $ print mAuthCode
-- liftIO . putStrLn $ "user: " ++ show u ++ " | scopes: " ++ show (map (showScope @user @userData) scopes')
redirect $ addParams url mAuthCode mState
redirect :: Maybe ByteString -> AuthHandler userData
redirect (Just url) = liftIO (print url) >> throwError err303 { errHeaders = [("Location", url)]}
redirect Nothing = throwError err500 { errBody = "Could not generate authorisation code."}
addParams :: String -> Maybe Text -> Maybe Text -> Maybe ByteString
addParams url Nothing _ = Nothing
addParams url (Just code) mState =
let qPos = fromMaybe (length url) $ elemIndex '?' url
(pre, post) = splitAt qPos url
rState = case mState of {Just s -> "&state=" ++ (unpack . replace "/" "%2F" $ replace "=" "%3D" s); Nothing -> ""}
post' = if not (null post) then '&' : tail post else post
in Just . fromString $ pre ++ "?code=" ++ (unpack code) ++ post' ++ rState
----------------------
---- Token Endpoint ----
----------------------
data ClientData = ClientData
{ grantType :: GrantType
, grant :: String
, userName :: Maybe String
, clientID :: String
, clientSecret :: String
, redirect :: String
} deriving Show
instance FromJSON ClientData where
parseJSON (Object o) = ClientData
<$> o .: "grant_type"
<*> (o .: ps PassCreds --TODO add alternatives
<|> o .: ps AuthCode
<|> o .: ps Implicit
<|> o .: ps ClientCreds)
<*> o .:? "username"
<*> o .: "client_id"
<*> o .: "client_secret"
<*> o .: "redirect_url"
where ps = fromString @Key . show
data GrantType = PassCreds | AuthCode | Implicit | ClientCreds deriving (Eq)
instance Show GrantType where
show PassCreds = "password"
show AuthCode = "authorization_code"
show _ = undefined --TODO support other flows
instance FromJSON GrantType where
parseJSON (String s)
| s == pack (show AuthCode) = pure AuthCode
| otherwise = error $ show s ++ " grant type not supported yet"
data JWT = JWT
{ issuer :: Text
, expiration :: UTCTime
} deriving (Show, Eq)
instance ToJSON JWT where
toJSON (JWT i e) = object ["iss" .= i, "exp" .= e]
data JWTWrapper = JWTW
{ token :: String
, expiresIn :: NominalDiffTime
} deriving (Show)
instance ToJSON JWTWrapper where
toJSON (JWTW t e) = object ["access_token" .= t, "token_type" .= ("JWT" :: Text), "expires_in" .= e]
instance FromJSON JWTWrapper where
parseJSON (Object o) = JWTW
<$> o .: "access_token"
<*> o .: "expires_in"
instance FromHttpApiData JWTWrapper where
parseHeader bs = case decode (fromStrict bs) of
Just x -> Right x
Nothing -> Left "Invalid JWT wrapper"
type Token = "token"
:> ReqBody '[JSON] ClientData
:> Post '[JSON] JWTWrapper
tokenEndpoint :: AuthServer Token
tokenEndpoint = provideToken
where
provideToken :: ClientData -> AuthHandler JWTWrapper
provideToken client = case (grantType client) of
AuthCode -> do
unless (Client (pack $ clientID client) (pack $ clientSecret client) `elem` trustedClients) .
throwError $ err500 { errBody = "Invalid client" }
valid <- asks (verify (pack $ grant client) (clientID client)) >>= liftIO
unless valid . throwError $ err500 { errBody = "Invalid authorisation code" }
-- return JWT {token = "", tokenType = "JWT", expiration = 0.25 * nominalDay}
token <- asks mkToken >>= liftIO
liftIO . putStrLn $ "token: " ++ show token
return token
x -> error $ show x ++ " not supported yet"
mkToken :: AuthState -> IO JWTWrapper
mkToken state = do
privateKey <- atomically $ readTVar state >>= return . privateKey
now <- getCurrentTime
let
lifetime = nominalDay / 4 -- TODO make configurable
jwt = JWT "Oauth2MockServer" (lifetime `addUTCTime` now)
encoded <- jwkEncode RSA_OAEP_256 A128GCM privateKey (Nested . Jwt . toStrict $ encode jwt)
case encoded of
Right (Jwt token) -> return $ JWTW (BS.unpack token) lifetime
Left e -> error $ show e
----------------------
---- Users Endpoint ----
----------------------
type Users = "users"
type Me userData = Users
:> "me"
:> Header "Authorization" JWTWrapper
:> Get '[JSON] userData
type UserList userData = Users
:> "query"
:> Header "Authorization" JWTWrapper
:> Get '[JSON] [userData] -- TODO support query params
userEndpoint :: forall user userData . UserData user userData => AuthServer (Me userData)
userEndpoint = handleUserData
where
handleUserData :: Maybe JWTWrapper -> AuthHandler userData
handleUserData jwtw = do
undefined
-- let
-- scopes' = map (readScope @user @userData) $ words scopes
-- uData = mconcat $ map (userScope @user @userData u) scopes'
-- liftIO . putStrLn $ "user: " ++ show u ++ " | scopes: " ++ show (map (showScope @user @userData) scopes')
-- return uData
userListEndpoint :: forall user userData . UserData user userData => AuthServer (UserList userData)
userListEndpoint = handleUserData
where
handleUserData :: Maybe JWTWrapper -> AuthHandler [userData]
handleUserData jwtw = do
undefined
-------------------
---- Server Main ----
-------------------
type Routing user userData = Auth user userData
:<|> Token
:<|> Me userData
:<|> UserList userData
routing :: forall user userData . UserData user userData => AuthServer (Routing user userData)
routing = authServer @user @userData
:<|> tokenEndpoint
:<|> userEndpoint @user @userData
:<|> userListEndpoint @user @userData
exampleAuthServer :: AuthServer (Routing User (Map.Map Text Text))
exampleAuthServer = routing
authAPI :: Proxy (Routing User (Map.Map Text Text))
authAPI = Proxy
-- insecureOAuthMock :: Application
-- insecureOAuthMock = authAPI `serve` exampleAuthServer
insecureOAuthMock' :: [User] -> AuthState -> Application
insecureOAuthMock' testUsers s = serveWithContext authAPI c $ hoistServerWithContext authAPI p (toHandler s) exampleAuthServer
where
c = authenticate testUsers :. EmptyContext
p = Proxy :: Proxy '[BasicAuthCheck User]
authenticate :: [User] -> BasicAuthCheck User
authenticate users = BasicAuthCheck $ \authData -> do
let
(uEmail, uPass) = (,) <$> (decodeUtf8 . basicAuthUsername) <*> (decodeUtf8 . basicAuthPassword) $ authData
case (find (\u -> email u == uEmail) users) of
Nothing -> return NoSuchUser
Just u -> return $ if uPass == password u then Authorized u else BadPassword
-- frontend :: BasicAuthData -> ClientM (Map.Map Text Text)
-- frontend ba = client authAPI ba "[ID]" "42" "code" ""
runMockServer :: Int -> IO ()
runMockServer port = do
state <- mkState
run port $ insecureOAuthMock' testUsers state
-- runMockServer' :: Int -> IO ()
-- runMockServer' port = do
-- mgr <- newManager defaultManagerSettings
-- state <- mkState
-- bracket (forkIO . run port $ insecureOAuthMock' testUsers state) killThread $ \_ ->
-- runClientM (frontend $ BasicAuthData "foo@bar.com" "0000") (mkClientEnv mgr (BaseUrl Http "localhost" port ""))
-- >>= print
mkState :: IO AuthState
mkState = do
(publicKey, privateKey) <- generateRsaKeyPair 256 (KeyId "Oauth2MockKey") Enc Nothing
let activeCodes = Map.empty
newTVarIO State{..}