diff options
Diffstat (limited to 'src/Sproxy/Application')
-rw-r--r-- | src/Sproxy/Application/Cookie.hs | 44 | ||||
-rw-r--r-- | src/Sproxy/Application/OAuth2.hs | 18 | ||||
-rw-r--r-- | src/Sproxy/Application/OAuth2/Common.hs | 39 | ||||
-rw-r--r-- | src/Sproxy/Application/OAuth2/Google.hs | 78 | ||||
-rw-r--r-- | src/Sproxy/Application/OAuth2/LinkedIn.hs | 83 | ||||
-rw-r--r-- | src/Sproxy/Application/State.hs | 30 |
6 files changed, 292 insertions, 0 deletions
diff --git a/src/Sproxy/Application/Cookie.hs b/src/Sproxy/Application/Cookie.hs new file mode 100644 index 0000000..07cc162 --- /dev/null +++ b/src/Sproxy/Application/Cookie.hs @@ -0,0 +1,44 @@ +module Sproxy.Application.Cookie ( + AuthCookie(..) +, AuthUser(..) +, cookieDecode +, cookieEncode +) where + +import Data.ByteString (ByteString) +import Foreign.C.Types (CTime(..)) +import qualified Data.Serialize as DS + +import qualified Sproxy.Application.State as State + +data AuthUser = AuthUser { + auEmail :: String +, auGivenName :: String +, auFamilyName :: String +} + +data AuthCookie = AuthCookie { + acUser :: AuthUser +, acExpiry :: CTime +} + +instance DS.Serialize AuthCookie where + put c = DS.put (auEmail u, auGivenName u, auFamilyName u, x) + where u = acUser c + x = (\(CTime i) -> i) $ acExpiry c + get = do + (e, n, f, x) <- DS.get + return AuthCookie { + acUser = AuthUser { auEmail = e, auGivenName = n, auFamilyName = f } + , acExpiry = CTime x + } + + +cookieDecode :: ByteString -> ByteString -> Either String AuthCookie +cookieDecode key d = State.decode key d >>= DS.decode + + +cookieEncode :: ByteString -> AuthCookie -> ByteString +cookieEncode key = State.encode key . DS.encode + + diff --git a/src/Sproxy/Application/OAuth2.hs b/src/Sproxy/Application/OAuth2.hs new file mode 100644 index 0000000..0f7d6e8 --- /dev/null +++ b/src/Sproxy/Application/OAuth2.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE OverloadedStrings #-} +module Sproxy.Application.OAuth2 ( + providers +) where + +import Data.HashMap.Strict (HashMap, fromList) +import Data.Text (Text) + +import Sproxy.Application.OAuth2.Common (OAuth2Provider) +import qualified Sproxy.Application.OAuth2.Google as Google +import qualified Sproxy.Application.OAuth2.LinkedIn as LinkedIn + +providers :: HashMap Text OAuth2Provider +providers = fromList [ + ("google" , Google.provider) + , ("linkedin" , LinkedIn.provider) + ] + diff --git a/src/Sproxy/Application/OAuth2/Common.hs b/src/Sproxy/Application/OAuth2/Common.hs new file mode 100644 index 0000000..07fb759 --- /dev/null +++ b/src/Sproxy/Application/OAuth2/Common.hs @@ -0,0 +1,39 @@ +{-# LANGUAGE OverloadedStrings #-} +module Sproxy.Application.OAuth2.Common ( + AccessTokenBody(..) +, OAuth2Client(..) +, OAuth2Provider +) where + +import Control.Applicative (empty) +import Data.Aeson (FromJSON, parseJSON, Value(Object), (.:)) +import Data.ByteString(ByteString) + +import Sproxy.Application.Cookie (AuthUser) + +data OAuth2Client = OAuth2Client { + oauth2Description :: String +, oauth2AuthorizeURL + :: ByteString -- state + -> ByteString -- redirect url + -> ByteString +, oauth2Authenticate + :: ByteString -- code + -> ByteString -- redirect url + -> IO AuthUser +} + +type OAuth2Provider = (ByteString, ByteString) -> OAuth2Client + +-- | RFC6749. We ignore optional token_type ("Bearer" from Google, omitted by LinkedIn) +-- and expires_in because we don't use them, *and* expires_in creates troubles: +-- it's an integer from Google and string from LinkedIn (sic!) +data AccessTokenBody = AccessTokenBody { + accessToken :: String +} deriving (Eq, Show) + +instance FromJSON AccessTokenBody where + parseJSON (Object v) = AccessTokenBody + <$> v .: "access_token" + parseJSON _ = empty + diff --git a/src/Sproxy/Application/OAuth2/Google.hs b/src/Sproxy/Application/OAuth2/Google.hs new file mode 100644 index 0000000..6b68f44 --- /dev/null +++ b/src/Sproxy/Application/OAuth2/Google.hs @@ -0,0 +1,78 @@ +{-# LANGUAGE DeriveDataTypeable #-} +{-# LANGUAGE OverloadedStrings #-} +module Sproxy.Application.OAuth2.Google ( + provider +) where + +import Control.Applicative (empty) +import Control.Exception (Exception, throwIO) +import Data.Aeson (FromJSON, decode, parseJSON, Value(Object), (.:)) +import Data.ByteString.Lazy (ByteString) +import Data.Monoid ((<>)) +import Data.Typeable (Typeable) +import Network.HTTP.Types (hContentType) +import Network.HTTP.Types.URI (urlEncode) +import qualified Network.HTTP.Conduit as H + +import Sproxy.Application.Cookie (AuthUser(..)) +import Sproxy.Application.OAuth2.Common (AccessTokenBody(accessToken), OAuth2Client(..), OAuth2Provider) + + +provider :: OAuth2Provider +provider (client_id, client_secret) = + OAuth2Client { + oauth2Description = "Google" + , oauth2AuthorizeURL = \state redirect_uri -> + "https://accounts.google.com/o/oauth2/v2/auth" + <> "?scope=" <> urlEncode True "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" + <> "&client_id=" <> urlEncode True client_id + <> "&prompt=select_account" + <> "&redirect_uri=" <> urlEncode True redirect_uri + <> "&response_type=code" + <> "&state=" <> urlEncode True state + + , oauth2Authenticate = \code redirect_uri -> do + let treq = H.setQueryString [ + ("client_id" , Just client_id) + , ("client_secret" , Just client_secret) + , ("code" , Just code) + , ("grant_type" , Just "authorization_code") + , ("redirect_uri" , Just redirect_uri) + ] $ (H.parseRequest_ "POST https://www.googleapis.com/oauth2/v4/token") { + H.requestHeaders = [ + (hContentType, "application/x-www-form-urlencoded") + ] + } + mgr <- H.newManager H.tlsManagerSettings + tresp <- H.httpLbs treq mgr + case decode $ H.responseBody tresp of + Nothing -> throwIO $ GoogleException tresp + Just atResp -> do + ureq <- H.parseRequest $ "https://www.googleapis.com/oauth2/v1/userinfo?access_token=" ++ accessToken atResp + uresp <- H.httpLbs ureq mgr + case decode $ H.responseBody uresp of + Nothing -> throwIO $ GoogleException uresp + Just u -> return AuthUser { auEmail = email u, auGivenName = givenName u, auFamilyName = familyName u } + } + + +data GoogleException = GoogleException (H.Response ByteString) + deriving (Show, Typeable) + + +instance Exception GoogleException + + +data GoogleUserInfo = GoogleUserInfo { + email :: String +, givenName :: String +, familyName :: String +} deriving (Eq, Show) + +instance FromJSON GoogleUserInfo where + parseJSON (Object v) = GoogleUserInfo + <$> v .: "email" + <*> v .: "given_name" + <*> v .: "family_name" + parseJSON _ = empty + diff --git a/src/Sproxy/Application/OAuth2/LinkedIn.hs b/src/Sproxy/Application/OAuth2/LinkedIn.hs new file mode 100644 index 0000000..b60afde --- /dev/null +++ b/src/Sproxy/Application/OAuth2/LinkedIn.hs @@ -0,0 +1,83 @@ +{-# LANGUAGE DeriveDataTypeable #-} +{-# LANGUAGE OverloadedStrings #-} +module Sproxy.Application.OAuth2.LinkedIn ( + provider +) where + +import Control.Applicative (empty) +import Control.Exception (Exception, throwIO) +import Data.Aeson (FromJSON, decode, parseJSON, Value(Object), (.:)) +import Data.ByteString.Char8 (pack) +import Data.ByteString.Lazy (ByteString) +import Data.Monoid ((<>)) +import Data.Typeable (Typeable) +import Network.HTTP.Types (hContentType) +import Network.HTTP.Types.URI (urlEncode) +import qualified Network.HTTP.Conduit as H + +import Sproxy.Application.Cookie (AuthUser(..)) +import Sproxy.Application.OAuth2.Common (AccessTokenBody(accessToken), OAuth2Client(..), OAuth2Provider) + + +provider :: OAuth2Provider +provider (client_id, client_secret) = + OAuth2Client { + oauth2Description = "LinkedIn" + , oauth2AuthorizeURL = \state redirect_uri -> + "https://www.linkedin.com/oauth/v2/authorization" + <> "?scope=r_basicprofile%20r_emailaddress" + <> "&client_id=" <> urlEncode True client_id + <> "&redirect_uri=" <> urlEncode True redirect_uri + <> "&response_type=code" + <> "&state=" <> urlEncode True state + + , oauth2Authenticate = \code redirect_uri -> do + let treq = H.setQueryString [ + ("client_id" , Just client_id) + , ("client_secret" , Just client_secret) + , ("code" , Just code) + , ("grant_type" , Just "authorization_code") + , ("redirect_uri" , Just redirect_uri) + ] $ (H.parseRequest_ "POST https://www.linkedin.com/oauth/v2/accessToken") { + H.requestHeaders = [ + (hContentType, "application/x-www-form-urlencoded") + ] + } + mgr <- H.newManager H.tlsManagerSettings + tresp <- H.httpLbs treq mgr + case decode $ H.responseBody tresp of + Nothing -> throwIO $ LinkedInException tresp + Just atResp -> do + let ureq = (H.parseRequest_ "https://api.linkedin.com/v1/people/\ + \~:(email-address,first-name,last-name)?format=json") { + H.requestHeaders = [ ("Authorization", "Bearer " <> pack (accessToken atResp)) ] + } + uresp <- H.httpLbs ureq mgr + case decode $ H.responseBody uresp of + Nothing -> throwIO $ LinkedInException uresp + Just u -> return AuthUser { auEmail = emailAddress u + , auGivenName = firstName u + , auFamilyName = lastName u } + } + + +data LinkedInException = LinkedInException (H.Response ByteString) + deriving (Show, Typeable) + + +instance Exception LinkedInException + + +data LinkedInUserInfo = LinkedInUserInfo { + emailAddress :: String +, firstName :: String +, lastName :: String +} deriving (Eq, Show) + +instance FromJSON LinkedInUserInfo where + parseJSON (Object v) = LinkedInUserInfo + <$> v .: "emailAddress" + <*> v .: "firstName" + <*> v .: "lastName" + parseJSON _ = empty + diff --git a/src/Sproxy/Application/State.hs b/src/Sproxy/Application/State.hs new file mode 100644 index 0000000..29d9252 --- /dev/null +++ b/src/Sproxy/Application/State.hs @@ -0,0 +1,30 @@ +module Sproxy.Application.State ( + decode +, encode +) where + +import Data.ByteString (ByteString) +import Data.ByteString.Lazy (fromStrict, toStrict) +import Data.Digest.Pure.SHA (hmacSha1, bytestringDigest) +import qualified Data.ByteString.Base64 as Base64 +import qualified Data.Serialize as DS + + +-- FIXME: Compress / decompress ? + + +encode :: ByteString -> ByteString -> ByteString +encode key payload = Base64.encode . DS.encode $ (payload, digest key payload) + + +decode :: ByteString -> ByteString -> Either String ByteString +decode key d = do + (payload, dgst) <- DS.decode =<< Base64.decode d + if dgst /= digest key payload + then Left "junk" + else Right payload + + +digest :: ByteString -> ByteString -> ByteString +digest key payload = toStrict . bytestringDigest $ hmacSha1 (fromStrict key) (fromStrict payload) + |