google-oauth2 0.2.2 → 0.3.0.0
raw patch · 4 files changed
+185/−286 lines, 4 filesdep +hoauth2dep +http-client-tlsdep +safe-exceptionsdep −HTTPdep −aesondep −http-typesdep ~basesetup-changed
Dependencies added: hoauth2, http-client-tls, safe-exceptions, text, transformers, uri-bytestring
Dependencies removed: HTTP, aeson, http-types
Dependency ranges changed: base
Files
- Setup.hs +0/−2
- google-oauth2.cabal +57/−78
- src/Network/Google/OAuth2.hs +78/−206
- test/Network/Google/OAuth2Spec.hs +50/−0
− Setup.hs
@@ -1,2 +0,0 @@-import Distribution.Simple-main = defaultMain
google-oauth2.cabal view
@@ -1,82 +1,61 @@-Name: google-oauth2-Version: 0.2.2-Author: Pat Brisbin <pbrisbin@gmail.com>-Maintainer: Pat Brisbin <pbrisbin@gmail.com>-License: MIT-License-File: LICENSE-Synopsis: Google OAuth2 token negotiation-Description:- Interacting with the Google OAuth2 authorization API- .- 1. Prompt the user for a verification code- 2. POST that code to the Google API for a set of tokens (access and refresh)- 3. Use the access token until it expires- 4. Use the refresh token to get a new access token- 5. Repeat from 3- .- Example usage:- .- > import Data.Monoid- > import Network.Google.OAuth2- > import Network.HTTP.Conduit- > import Network.HTTP.Types (hAuthorization)- >- > import qualified Data.ByteString.Char8 as B8- > import qualified Data.ByteString.Lazy.Char8 as L8- >- > main :: IO ()- > main = do- > let client = OAuth2Client clientId clientSecret- > scopes = ["https://www.googleapis.com/auth/drive"]- >- > token <- getAccessToken client scopes Nothing- >- > request <- parseUrl "https://www.googleapis.com/drive/v2/files"- > response <- withManager $ httpLbs $ authorize token request- >- > L8.putStrLn $ responseBody response- >- > where- > authorize token request = request- > -- Note: haddock chokes on curly braces for some reason, so I'm using- > -- parens here instead.- > ( requestHeaders = [(hAuthorization, B8.pack $ "Bearer " <> token)] )- >- > -- Setup in Google Developers Console- > clientId = "..."- > clientSecret = "..."- .+-- This file has been generated from package.yaml by hpack version 0.20.0.+--+-- see: https://github.com/sol/hpack+--+-- hash: a8210f3cdc16ee63542ebfbc6b0d085387f5a259853791163818eec24fe4224f -Cabal-Version: >= 1.10-Build-Type: Simple+name: google-oauth2+version: 0.3.0.0+synopsis: Google OAuth2 token negotiation+description: See https://github.com/pbrisbin/google-oauth2#readme+homepage: https://github.com/pbrisbin/google-oauth2#readme+bug-reports: https://github.com/pbrisbin/google-oauth2/issues+author: Pat Brisbin <pbrisbin@gmail.com>+maintainer: Pat Brisbin <pbrisbin@gmail.com>+license: MIT+license-file: LICENSE+build-type: Simple+cabal-version: >= 1.10 -Library- Default-Language: Haskell2010- HS-Source-Dirs: src- GHC-Options: -Wall- Exposed-Modules: Network.Google.OAuth2- Build-Depends: base >= 4 && < 5- , HTTP >= 4000.2 && < 4000.4- , aeson >= 0.8 && < 0.12- , bytestring- , http-conduit- Default-Extensions: OverloadedStrings- RecordWildCards+source-repository head+ type: git+ location: https://github.com/pbrisbin/google-oauth2 -Test-Suite spec- Type: exitcode-stdio-1.0- Default-Language: Haskell2010- Hs-Source-Dirs: test- Ghc-Options: -Wall- Main-Is: Spec.hs- Build-Depends: base- , google-oauth2- , hspec- , bytestring- , http-conduit- , http-types- , load-env+library+ hs-source-dirs:+ src+ ghc-options: -Wall -Wall+ build-depends:+ base >=4 && <5+ , bytestring+ , hoauth2+ , http-client-tls+ , safe-exceptions+ , text+ , transformers+ , uri-bytestring+ exposed-modules:+ Network.Google.OAuth2+ other-modules:+ Paths_google_oauth2+ default-language: Haskell2010 -Source-Repository head- Type: git- Location: https://github.com/pbrisbin/google-oauth2+test-suite spec+ type: exitcode-stdio-1.0+ main-is: Spec.hs+ hs-source-dirs:+ test+ ghc-options: -Wall -Wall+ build-depends:+ base >=4 && <5+ , bytestring+ , google-oauth2+ , hoauth2+ , hspec+ , http-conduit+ , load-env+ , text+ other-modules:+ Network.Google.OAuth2Spec+ Paths_google_oauth2+ default-language: Haskell2010
src/Network/Google/OAuth2.hs view
@@ -1,223 +1,95 @@-{-# LANGUAGE CPP #-}-module Network.Google.OAuth2- (- -- * Types- Credentials(..)- , OAuth2Client(..)- , OAuth2Code- , OAuth2Scope- , OAuth2Token- , OAuth2Tokens(..)-- -- * Getting an access token- , getAccessToken+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE StandaloneDeriving #-}+{-# OPTIONS_GHC -fno-warn-orphans #-} - -- * Lower-level steps- , newCreds- , refreshCreds- , promptForCode- , exchangeCode+module Network.Google.OAuth2+ ( getAccessToken ) where -#if __GLASGOW_HASKELL__ < 710-import Control.Applicative ((<$>),(<*>))-#endif--import Control.Arrow (second)-import Control.Monad (mzero, void)-import Data.Aeson-import Data.ByteString (ByteString)-import Data.List (intercalate)-import Data.Maybe (fromJust)-import Data.Monoid ((<>))-import Network.HTTP.Conduit- ( Response(..)- , httpLbs- , newManager- , parseUrlThrow- , tlsManagerSettings- , urlEncodedBody- )-import Network.HTTP.Base (urlEncode)-import System.IO (hFlush, stdout)--import qualified Control.Exception as E+import Control.Exception.Safe (handleIO, throwString)+import Control.Monad ((<=<))+import Control.Monad.Trans.Maybe import qualified Data.ByteString.Char8 as C8-import qualified Data.ByteString.Lazy as BL--type OAuth2Code = String-type OAuth2Scope = String-type OAuth2Token = String---- | OAuth2 client definition------ https://developers.google.com/console/help/new/#generatingoauth2----data OAuth2Client = OAuth2Client- { clientId :: !String- , clientSecret :: !String- }- deriving (Read, Show)--data OAuth2Tokens = OAuth2Tokens- { accessToken :: !OAuth2Token- , refreshToken :: !OAuth2Token- , expiresIn :: !Int- , tokenType :: !String- }- deriving (Read, Show)--instance FromJSON OAuth2Tokens where- parseJSON (Object o) = OAuth2Tokens- <$> o .: "access_token"- <*> o .: "refresh_token"- <*> o .: "expires_in"- <*> o .: "token_type"-- parseJSON _ = mzero---- Used only when refreshing a token, where the response lacks the originally--- supplied refresh_token-data RefreshResponse = RefreshResponse- { rAccessToken :: OAuth2Token- , rExpiresIn :: Int- , rTokenType :: String- }+import Data.Text (Text)+import qualified Data.Text as T+import Data.Text.Encoding (encodeUtf8)+import Network.HTTP.Client.TLS (getGlobalManager)+import Network.OAuth.OAuth2+import System.IO (hFlush, stdout)+import Text.Read (readMaybe)+import URI.ByteString (serializeURIRef')+import URI.ByteString.QQ (uri) -instance FromJSON RefreshResponse where- parseJSON (Object o) = RefreshResponse- <$> o .: "access_token"- <*> o .: "expires_in"- <*> o .: "token_type"+deriving instance Read IdToken+deriving instance Read AccessToken+deriving instance Read RefreshToken+deriving instance Read OAuth2Token - parseJSON _ = mzero+getAccessToken+ :: Text -- ^ Client Id+ -> Text -- ^ Client Secret+ -> [Text] -- ^ Scopes+ -> Maybe FilePath -- ^ File in which to cache the token+ -> IO OAuth2Token -- ^ Refreshed token+getAccessToken clientId clientSecret scopes mPath = do+ mgr <- getGlobalManager+ token <- cached mPath $ do+ code <- prompt $ unlines+ [ ""+ , "Visit the following URL to retrieve a verification code:"+ , ""+ , C8.unpack $ serializeURIRef' $ authorizationUrl oauth2+ , ""+ , "Verification code: "+ ] -toOAuth2Tokens :: OAuth2Token -> RefreshResponse -> OAuth2Tokens-toOAuth2Tokens token RefreshResponse{..} =- OAuth2Tokens- { accessToken = rAccessToken- , refreshToken = token- , expiresIn = rExpiresIn- , tokenType = rTokenType+ fetchAccessToken' mgr $ ExchangeToken $ T.pack code+ maybe (pure token) (refreshAccessToken' mgr) $ refreshToken token+ where+ oauth2 = OAuth2+ { oauthClientId = clientId+ , oauthClientSecret = clientSecret+ , oauthOAuthorizeEndpoint =+ appendQueryParams+ [ ("redirect_uri", "urn:ietf:wg:oauth:2.0:oob")+ , ("scope", C8.intercalate " " $ map encodeUtf8 scopes)+ ]+ [uri|https://accounts.google.com/o/oauth2/auth|]+ , oauthAccessTokenEndpoint =+ appendQueryParams+ [ ("redirect_uri", "urn:ietf:wg:oauth:2.0:oob")+ ]+ [uri|https://www.googleapis.com/oauth2/v3/token|]+ , oauthCallback = Nothing } --- | Pairs a client and its tokens------ This type is primarily so they can be cached together and not require access--- the client id when using cached tokens----data Credentials = Credentials- { credsClient :: OAuth2Client- , credsTokens :: OAuth2Tokens- }- deriving (Read, Show)---- | Get a valid access token with the given scopes------ If given, credentials are cached in a file, thus preventing the need for any--- prompting on subsequent reuse. N.B. this function always refreshes the access--- token before returning it.----getAccessToken :: OAuth2Client- -> [OAuth2Scope]- -> Maybe FilePath -- ^ File in which to cache the token- -> IO OAuth2Token -- ^ Refreshed token-getAccessToken client scopes (Just tokenFile) = do- cached <- cachedValue tokenFile- creds <- case cached of- Just c -> return c- Nothing -> newCreds client scopes-- refreshed <- refreshCreds creds- void $ cacheValue tokenFile refreshed-- return $ accessToken $ credsTokens refreshed--getAccessToken client scopes Nothing = do- creds <- newCreds client scopes- refreshed <- refreshCreds creds-- return $ accessToken $ credsTokens refreshed---- | Prompt the user for a verification code and exchange it for tokens-newCreds :: OAuth2Client -> [OAuth2Scope] -> IO Credentials-newCreds client scopes = do- code <- promptForCode client scopes- tokens <- exchangeCode client code+ fetchAccessToken' m = fromEither <=< fetchAccessToken m oauth2+ refreshAccessToken' m = fromEither <=< refreshAccessToken m oauth2 - return $ Credentials client tokens+ fromEither = either (throwString . show) pure --- | Prompt the user for a verification code-promptForCode :: OAuth2Client -> [OAuth2Scope] -> IO OAuth2Code-promptForCode client scopes = do- putStrLn ""- putStrLn "Visit the following URL to retrieve a verification code:"- putStrLn ""- putStrLn $ permissionUrl client scopes- putStrLn ""- putStr "Verification code: "+prompt :: String -> IO String+prompt msg = do+ putStr msg hFlush stdout- getLine --- | Exchange a verification code for tokens-exchangeCode :: OAuth2Client -> OAuth2Code -> IO OAuth2Tokens-exchangeCode client code = postTokens- [ ("client_id", clientId client)- , ("client_secret", clientSecret client)- , ("grant_type", "authorization_code")- , ("redirect_uri", redirectUri)- , ("code", code)- ]---- | Use the refresh token to get a new access token-refreshCreds :: Credentials -> IO Credentials-refreshCreds (Credentials client tokens) = do- refreshed <- postTokens- [ ("client_id", clientId client)- , ("client_secret", clientSecret client)- , ("grant_type", "refresh_token")- , ("refresh_token", refreshToken tokens)- ]-- return $ Credentials client- $ toOAuth2Tokens (refreshToken tokens) refreshed--postTokens :: FromJSON a => [(ByteString, String)] -> IO a-postTokens params = do- request <- parseUrlThrow "https://accounts.google.com/o/oauth2/token"-- let params' = map (second C8.pack) params-- mngr <- newManager tlsManagerSettings- unsafeDecode <$> httpLbs (urlEncodedBody params' request) mngr--cachedValue :: Read a => FilePath -> IO (Maybe a)-cachedValue tokenFile = do- result <- fmap (fmap reads) $ try $ readFile tokenFile-- return $ case result of- Right ((t,_):_) -> Just t- _ -> Nothing--cacheValue :: Show a => FilePath -> a -> IO a-cacheValue tokenFile x = fmap (const x) $ try $ writeFile tokenFile (show x)--permissionUrl :: OAuth2Client -> [OAuth2Scope] -> String-permissionUrl client scopes =- "https://accounts.google.com/o/oauth2/auth"- <> "?response_type=code"- <> "&client_id=" <> clientId client- <> "&redirect_uri=urn:ietf:wg:oauth:2.0:oob"- <> "&scope=" <> intercalate "+" (map urlEncode scopes)+cached :: (Read a, Show a) => Maybe FilePath -> IO a -> IO a+cached Nothing act = act+cached (Just fp) act = do+ mResult <- runMaybeT $ do+ c <- MaybeT $ readFileSafe fp+ MaybeT $ pure $ readMaybe c -redirectUri :: String-redirectUri = "urn:ietf:wg:oauth:2.0:oob"+ case mResult of+ Just x -> pure x+ _ -> do+ x <- act+ x <$ writeFileSafe fp (show x) --- With token responses, we assume that if we don't get an HTTP exception, then--- the response body will parse correctly.-unsafeDecode :: FromJSON a => Response BL.ByteString -> a-unsafeDecode = fromJust . decode . responseBody+readFileSafe :: FilePath -> IO (Maybe String)+readFileSafe = handleIO (const $ pure Nothing) . (Just <$>) . readFile -try :: IO a -> IO (Either E.IOException a)-try = E.try+writeFileSafe :: FilePath -> String -> IO ()+writeFileSafe fp = handleIO (const $ pure ()) . writeFile fp
+ test/Network/Google/OAuth2Spec.hs view
@@ -0,0 +1,50 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++module Network.Google.OAuth2Spec+ ( main+ , spec+ ) where++import Test.Hspec++import qualified Data.ByteString.Lazy.Char8 as L8+import Data.Monoid+import Data.Text (Text)+import qualified Data.Text as T+import Data.Text.Encoding (encodeUtf8)+import LoadEnv+import Network.Google.OAuth2+import Network.HTTP.Simple+import Network.OAuth.OAuth2 (AccessToken(..), OAuth2Token(..))+import System.Environment++-- | To run in an interactive way+--+-- So you can perform the OAuth2 flow+--+-- > stack exec runghc test/Network/Google/OAuth2Spec.hs+--+main :: IO ()+main = hspec spec++spec :: Spec+spec = describe "Token exchange" $ do+ it "grants access to an API" $ do+ loadEnv+ clientId <- T.pack <$> getEnv "CLIENT_ID"+ clientSecret <- T.pack <$> getEnv "CLIENT_SECRET"+ scope <- T.pack <$> getEnv "EXAMPLE_SCOPE"+ exampleUrl <- getEnv "EXAMPLE_URL"++ OAuth2Token{..} <- getAccessToken clientId clientSecret [scope]+ $ Just "test/oauth.token"++ request <- parseRequest exampleUrl+ response <- httpLBS $ authorize (atoken accessToken) request+ getResponseBody response `shouldSatisfy` (not . L8.null)++authorize :: Text -> Request -> Request+authorize token = setRequestHeaders+ [ ("Authorization", encodeUtf8 $ "Bearer " <> token)+ ]