module Facebook.Auth ( getAppAccessToken , getUserAccessTokenStep1 , getUserAccessTokenStep2 , getUserLogoutUrl , extendUserAccessToken , RedirectUrl , Permission , hasExpired , isValid ) where import Control.Applicative import Control.Monad (join) import Control.Monad.IO.Class (MonadIO(liftIO)) import Data.Aeson ((.:)) import Data.Aeson.Types (parseEither) import Data.Maybe (fromMaybe) import Data.Text (Text) import Data.Time (getCurrentTime, addUTCTime, UTCTime) import Data.String (IsString(..)) import qualified Control.Exception.Lifted as E import qualified Data.Attoparsec.Char8 as A import qualified Data.ByteString as B import qualified Data.ByteString.Char8 as B8 import qualified Data.Conduit as C import qualified Data.Conduit.Attoparsec as C import qualified Data.List as L import qualified Data.Text as T import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding.Error as TE import qualified Network.HTTP.Conduit as H import qualified Network.HTTP.Types as HT import Facebook.Types import Facebook.Base import Facebook.Monad -- | Get an app access token from Facebook using your -- credentials. getAppAccessToken :: C.ResourceIO m => FacebookT Auth m AppAccessToken getAppAccessToken = runResourceInFb $ do creds <- getCreds req <- fbreq "/oauth/access_token" Nothing $ tsq creds [("grant_type", "client_credentials")] response <- fbhttp req lift $ H.responseBody response C.$$ C.sinkParser (AppAccessToken <$ A.string "access_token=" <*> A.takeByteString) -- | The first step to get an user access token. Returns the -- Facebook URL you should redirect you user to. Facebook will -- authenticate the user, authorize your app and then redirect -- the user back into the provider 'RedirectUrl'. getUserAccessTokenStep1 :: Monad m => RedirectUrl -> [Permission] -> FacebookT Auth m Text getUserAccessTokenStep1 redirectUrl perms = do creds <- getCreds withTier $ \tier -> let urlBase = case tier of Production -> "https://www.facebook.com/dialog/oauth?client_id=" Beta -> "https://www.beta.facebook.com/dialog/oauth?client_id=" in T.concat $ urlBase : TE.decodeUtf8 (appId creds) : "&redirect_uri=" : redirectUrl : (case perms of [] -> [] _ -> "&scope=" : L.intersperse "," (map unPermission perms) ) -- | The second step to get an user access token. If the user is -- successfully authenticate and they authorize your application, -- then they'll be redirected back to the 'RedirectUrl' you've -- passed to 'getUserAccessTokenStep1'. You should take the -- request query parameters passed to your 'RedirectUrl' and give -- to this function that will complete the user authentication -- flow and give you an @'UserAccessToken'@. getUserAccessTokenStep2 :: C.ResourceIO m => RedirectUrl -- ^ Should be exactly the same -- as in 'getUserAccessTokenStep1'. -> [Argument] -- ^ Query parameters. -> FacebookT Auth m UserAccessToken getUserAccessTokenStep2 redirectUrl query = case query of [code@("code", _)] -> runResourceInFb $ do -- Get the access token data through Facebook's OAuth. now <- liftIO getCurrentTime creds <- getCreds req <- fbreq "/oauth/access_token" Nothing $ tsq creds [code, ("redirect_uri", TE.encodeUtf8 redirectUrl)] preToken <- fmap (userAccessTokenParser now) . asBS =<< fbhttp req -- Get user's ID throught Facebook's graph. userInfo <- asJson =<< fbhttp =<< fbreq "/me" (Just preToken) [("fields", "id")] case (parseEither (.: "id") userInfo, preToken) of (Left str, _) -> E.throw $ FbLibraryException $ T.concat [ "getUserAccessTokenStep2: failed to get the UserId (" , T.pack str, ")" ] (Right (userId :: UserId), UserAccessToken _ d e) -> return (UserAccessToken userId d e) _ -> let [error_, errorReason, errorDescr] = map (fromMaybe "" . flip lookup query) ["error", "error_reason", "error_description"] errorType = T.concat [t error_, " (", t errorReason, ")"] t = TE.decodeUtf8With TE.lenientDecode in E.throw $ FacebookException errorType (t errorDescr) -- | Attoparsec parser for user access tokens returned by -- Facebook as a query string. Returns an user access token with -- a broken 'UserId'. userAccessTokenParser :: UTCTime -- ^ 'getCurrentTime' -> B.ByteString -> UserAccessToken userAccessTokenParser now bs = let q = HT.parseQuery bs; lookup' a = join (lookup a q) in case (,) <$> lookup' "access_token" <*> lookup' "expires" of (Just (tok, expt)) -> UserAccessToken userId tok (toExpire expt) _ -> error $ "userAccessTokenParser: failed to parse " ++ show bs where toExpire expt = let i = read (B8.unpack expt) :: Int in addUTCTime (fromIntegral i) now userId = error "userAccessTokenParser: never here" -- | The URL an user should be redirected to in order to log them -- out of their Facebook session. Facebook will then redirect -- the user to the provided URL after logging them out. Note -- that, at the time of this writing, Facebook's policies require -- you to log the user out of Facebook when they ask to log out -- of your site. -- -- Note also that Facebook may refuse to redirect the user to the -- provided URL if their user access token is invalid. In order -- to prevent this bug, we suggest that you use 'isValid' before -- redirecting the user to the URL provided by 'getUserLogoutUrl' -- since this function doesn't do any validity checks. getUserLogoutUrl :: Monad m => UserAccessToken -- ^ The user's access token. -> RedirectUrl -- ^ URL the user should be directed to in -- your site domain. -> FacebookT Auth m Text -- ^ Logout URL in -- @https:\/\/www.facebook.com\/@ (or on -- @https:\/\/www.beta.facebook.com\/@ when -- using the beta tier). getUserLogoutUrl (UserAccessToken _ data_ _) next = do withTier $ \tier -> let urlBase = case tier of Production -> "https://www.facebook.com/logout.php?" Beta -> "https://www.beta.facebook.com/logout.php?" in TE.decodeUtf8 $ urlBase <> HT.renderQuery False [ ("next", Just (TE.encodeUtf8 next)) , ("access_token", Just data_) ] -- | URL where the user is redirected to after Facebook -- authenticates the user authorizes your application. This URL -- should be inside the domain registered for your Facebook -- application. type RedirectUrl = Text -- | A permission that is asked for the user when he authorizes -- your app. Please refer to Facebook's documentation at -- -- to see which permissions are available. -- -- This is a @newtype@ of 'Text' that supports only 'IsString'. -- This means that to create a 'Permission' you should use the -- @OverloadedStrings@ language extension. For example, -- -- > {-# LANGUAGE OverloadedStrings #-} -- > -- > perms :: [Permission] -- > perms = ["user_about_me", "email", "offline_access"] newtype Permission = Permission { unPermission :: Text } instance Show Permission where show = show . unPermission instance IsString Permission where fromString = Permission . fromString -- | @True@ if the access token has expired, otherwise @False@. hasExpired :: (Functor m, MonadIO m) => AccessToken anyKind -> m Bool hasExpired token = case accessTokenExpires token of Nothing -> return False Just expTime -> (>= expTime) <$> liftIO getCurrentTime -- | @True@ if the access token is valid. An expired access -- token is not valid (see 'hasExpired'). However, a non-expired -- access token may not be valid as well. For example, in the -- case of an user access token, they may have changed their -- password, logged out from Facebook or blocked your app. isValid :: C.ResourceIO m => AccessToken anyKind -> FacebookT anyAuth m Bool isValid token = do expired <- hasExpired token if expired then return False else let page = case token of UserAccessToken _ _ _ -> "/me" -- Documented way of checking if the token is valid, -- see . AppAccessToken _ -> "/19292868552" -- This is Facebook's page on Facebook. While -- this behaviour is undocumented, it will -- return a "400 Bad Request" status code -- whenever the access token is invalid. It -- will actually work with user access tokens, -- too, but they have another, better way of -- being checked. in httpCheck =<< fbreq page (Just token) [] -- | Extend the expiration time of an user access token (see -- ). -- Returns @Left exc@ if there is an error while extending, or -- @Right token@ with the new user access token (which could have -- the same data and expiration time as before, but you can't -- assume this). Note that expired access tokens can't be -- extended, only valid tokens. extendUserAccessToken :: C.ResourceIO m => UserAccessToken -> FacebookT Auth m (Either FacebookException UserAccessToken) extendUserAccessToken token@(UserAccessToken _ data_ _) = do expired <- hasExpired token if expired then return (Left hasExpiredExc) else tryToExtend where tryToExtend = runResourceInFb $ do creds <- getCreds req <- fbreq "/oauth/access_token" Nothing $ tsq creds [ ("grant_type", "fb_exchange_token") , ("fb_exchange_token", data_) ] eresponse <- E.try (asBS =<< fbhttp req) case eresponse of Right response -> do now <- liftIO getCurrentTime return (Right $ userAccessTokenParser now response) Left exc -> return (Left exc) hasExpiredExc = mkExc [ "the user access token has already expired, " , "so I'll not try to extend it." ] mkExc = FbLibraryException . T.concat . ("extendUserAccessToken: ":)