on develop help sign in
Merge branch 'feature/split-auth-modules' into develop
commitc69f45a99434fe20f15751e47f6bd26672bb3e72 authorBrendan Hay <brendan.g.hay@gmail.com> dateWed 28 Oct 2015, 15:38 (11 years ago) parentdc736d9521f516
10 files changed+835−566
core/src/Network/Google/Types.hs+6−6 gogol/gogol.cabal+6−1 gogol/src/Network/Google/Auth.hs+105−542 gogol/src/Network/Google/Auth/ApplicationDefaultCredentials.hs+133 gogol/src/Network/Google/Auth/Device.hs+56 gogol/src/Network/Google/Auth/InstalledApplication.hs+96 gogol/src/Network/Google/Auth/ServiceAccount.hs+164 gogol/src/Network/Google/Compute/Metadata.hs+8−9 gogol/src/Network/Google/Env.hs+9−8 gogol/src/Network/Google/Internal/Auth.hs+252
core/src/Network/Google/Types.hs+6−6
@@ -61,22 +61,22 @@ data AltMedia = AltMedia deriving (Eq, Ord, Show, Read, Generic, Typeable)
6161instance ToText AltJSON where toText = const "json"
6262instance ToText AltMedia where toText = const "media"
6363
64−newtype OAuthScope = OAuthScope { scopeToText :: Text }
64+newtype OAuthScope = OAuthScope Text
6565 deriving (Eq, Ord, Show, Read, IsString, Generic, Typeable, FromText, ToText, FromJSON, ToJSON)
6666
67−newtype AccessToken = AccessToken { accessToText :: Text }
67+newtype AccessToken = AccessToken Text
6868 deriving (Eq, Ord, Show, Read, IsString, Generic, Typeable, FromText, ToText, FromJSON, ToJSON)
6969
70−newtype RefreshToken = RefreshToken { refreshToText :: Text }
70+newtype RefreshToken = RefreshToken Text
7171 deriving (Eq, Ord, Show, Read, IsString, Generic, Typeable, FromText, ToText, FromJSON, ToJSON)
7272
73−newtype ClientId = ClientId { clientIdToText :: Text }
73+newtype ClientId = ClientId Text
7474 deriving (Eq, Ord, Show, Read, IsString, Generic, Typeable, ToText, FromJSON, ToJSON)
7575
76−newtype Secret = Secret { secretToText :: Text }
76+newtype Secret = Secret Text
7777 deriving (Eq, Ord, Show, Read, IsString, Generic, Typeable, ToText, FromJSON, ToJSON)
7878
79−newtype ServiceId = ServiceId { serviceIdToText :: Text }
79+newtype ServiceId = ServiceId Text
8080 deriving (Eq, Ord, Show, Read, IsString, Generic, Typeable, ToText, FromJSON, ToJSON)
8181
8282newtype MediaDownload a = MediaDownload a
gogol/gogol.cabal+6−1
@@ -41,11 +41,16 @@ library
4141 exposed-modules:
4242 Network.Google
4343 , Network.Google.Auth
44+ , Network.Google.Auth.ApplicationDefaultCredentials
45+ , Network.Google.Auth.Device
46+ , Network.Google.Auth.InstalledApplication
47+ , Network.Google.Auth.ServiceAccount
4448 , Network.Google.Compute.Metadata
4549 , Network.Google.Env
4650
4751 other-modules:
48− Network.Google.Internal.Body
52+ Network.Google.Internal.Auth
53+ , Network.Google.Internal.Body
4954 , Network.Google.Internal.HTTP
5055 , Network.Google.Internal.Logger
5156 , Network.Google.Internal.Multipart
gogol/src/Network/Google/Auth.hs+105−542
@@ -1,9 +1,7 @@
1−{-# LANGUAGE BangPatterns #-}
21{-# LANGUAGE DeriveDataTypeable #-}
32{-# LANGUAGE DeriveGeneric #-}
43{-# LANGUAGE FlexibleContexts #-}
54{-# LANGUAGE FlexibleInstances #-}
6−{-# LANGUAGE GADTs #-}
75{-# LANGUAGE GeneralizedNewtypeDeriving #-}
86{-# LANGUAGE LambdaCase #-}
97{-# LANGUAGE OverloadedStrings #-}
@@ -30,39 +28,28 @@ module Network.Google.Auth
3028 , fromFilePath
3129 , fromJSONCredentials
3230
33− -- ** Thread-safe Storage
34− , Auth (..)
35− , Store
36− , emptyStore
37−
3831 -- ** Authorizing Requests
3932 , authorize
4033
4134 -- ** Constructing Callback URLs
4235 , formURL
43− , redirectURI
44− , accountsURL
36+
37+ -- ** Thread-safe Storage
38+ , Auth (..)
39+ , Store
40+ , initStore
41+ , exchange
42+ , refresh
4543
4644 -- ** Default Constants
47− -- *** Google Compute Engine
4845 , checkGCEVar
49−
50− -- *** Cloud SDK
5146 , cloudSDKConfigDir
52− , cloudSDKConfigPath
53−
54− -- *** Application Default Credentials
5547 , defaultCredentialsFile
56− , defaultCredentialsPath
5748
5849 -- ** Handling Errors
5950 , AsAuthError (..)
6051 , AuthError (..)
6152
62− -- * Credential Types
63− , ServiceAccount (..)
64− , AuthorizedUser (..)
65−
6653 -- * OAuth Types
6754 , OAuthClient (..)
6855 , OAuthToken (..)
@@ -75,278 +62,56 @@ module Network.Google.Auth
7562 , Secret (..)
7663 , ServiceId (..)
7764 , ClientId (..)
78−
79− -- * Exchange and Refresh Internals
80− , getToken
81− , validateToken
82−
83− , exchange
84− , exchangeCode
85−
86− , refresh
87− , refreshMetadata
88− , refreshToken
89− , refreshAssertion
9065 ) where
9166
9267import Control.Applicative
9368import Control.Concurrent
9469import Control.Exception.Lens
95−import Control.Lens hiding (coerce, (.=))
70+import Control.Lens hiding
71+ (coerce,
72+ (.=))
9673import Control.Monad
9774import Control.Monad.Catch
9875import Control.Monad.IO.Class
99−import Crypto.Hash.Algorithms (SHA256 (..))
100−import Crypto.PubKey.RSA.PKCS15 (signSafer)
101−import Crypto.PubKey.RSA.Types (PrivateKey)
76+import Crypto.Hash.Algorithms (SHA256 (..))
77+import Crypto.PubKey.RSA.PKCS15 (signSafer)
78+import Crypto.PubKey.RSA.Types (PrivateKey)
10279import Data.Aeson
10380import Data.Aeson.Types
10481import Data.ByteArray
10582import Data.ByteArray.Encoding
106−import Data.ByteString (ByteString)
107−import Data.ByteString.Builder (Builder)
108−import qualified Data.ByteString.Builder as Build
109−import qualified Data.ByteString.Char8 as BS8
110−import qualified Data.ByteString.Lazy as LBS
83+import Data.ByteString (ByteString)
84+import Data.ByteString.Builder (Builder)
85+import qualified Data.ByteString.Builder as Build
86+import qualified Data.ByteString.Char8 as BS8
87+import qualified Data.ByteString.Lazy as LBS
11188import Data.Coerce
112−import Data.Default.Class (def)
113−import Data.List (intersperse)
89+import Data.Default.Class (def)
90+import Data.List (intersperse)
11491import Data.String
115−import qualified Data.Text as Text
116−import qualified Data.Text.Encoding as Text
117−import qualified Data.Text.Lazy as LText
118−import qualified Data.Text.Lazy.Builder as TBuild
119−import qualified Data.Text.Lazy.Encoding as LText
92+import qualified Data.Text as Text
93+import qualified Data.Text.Encoding as Text
12094import Data.Time
12195import Data.Time.Clock.POSIX
12296import Data.Typeable
12397import Data.X509
12498import Data.X509.Memory
99+import Network.Google.Auth.ApplicationDefaultCredentials
100+import Network.Google.Auth.Device
101+import Network.Google.Auth.InstalledApplication
102+import Network.Google.Auth.ServiceAccount
125103import Network.Google.Compute.Metadata
104+import Network.Google.Internal.Auth
126105import Network.Google.Internal.Logger
127−import Network.Google.Prelude hiding (buildText)
128−import Network.HTTP.Conduit hiding (Request)
129−import qualified Network.HTTP.Conduit as Client
106+import Network.Google.Prelude
107+import Network.HTTP.Conduit hiding
108+ (Request)
109+import qualified Network.HTTP.Conduit as Client
130110import Network.HTTP.Types
131−import System.Directory (doesFileExist,
132− getHomeDirectory)
133−import System.Environment (lookupEnv)
134−import System.FilePath ((</>))
135−import System.Info (os)
136−
137−-- | 1 hour in seconds.
138−maxTokenLifetimeSeconds :: Int
139−maxTokenLifetimeSeconds = 3600
140−
141−-- | The environment variable name which is used to specify the directory
142−-- containing the @application_default_credentials.json@ generated by @gcloud init@.
143−cloudSDKConfigDir :: String
144−cloudSDKConfigDir = "CLOUDSDK_CONFIG"
145−
146−-- | The environment variable pointing the file with local
147−-- Application Default Credentials.
148−defaultCredentialsFile :: String
149−defaultCredentialsFile = "GOOGLE_APPLICATION_CREDENTIALS"
150−
151−-- | An error thrown when attempting to read AuthN/AuthZ information.
152−data AuthError
153− = RetrievalError HttpException
154− | MissingFileError FilePath
155− | InvalidFileError FilePath Text
156− | TokenRefreshError Status Text (Maybe Text)
157− deriving (Show, Typeable)
158−
159−instance Exception AuthError
160−
161−class AsAuthError a where
162− -- | A general authentication error.
163− _AuthError :: Prism' a AuthError
164− {-# MINIMAL _AuthError #-}
165−
166− -- | An error occured while communicating over HTTP with either then
167− -- local metadata or remote accounts.google.com endpoints.
168− _RetrievalError :: Prism' a HttpException
169−
170− -- | The specified default credentials file could not be found.
171− _MissingFileError :: Prism' a FilePath
172−
173− -- | An error occured parsing the default credentials file.
174− _InvalidFileError :: Prism' a (FilePath, Text)
175−
176− -- | An error occured when attempting to refresh a token.
177− _TokenRefreshError :: Prism' a (Status, Text, Maybe Text)
178−
179− _RetrievalError = _AuthError . _RetrievalError
180− _MissingFileError = _AuthError . _MissingFileError
181− _InvalidFileError = _AuthError . _InvalidFileError
182− _TokenRefreshError = _AuthError . _TokenRefreshError
183−
184−instance AsAuthError SomeException where
185− _AuthError = exception
186−
187−instance AsAuthError AuthError where
188− _AuthError = id
189−
190− _RetrievalError = prism RetrievalError $ \case
191− RetrievalError e -> Right e
192− x -> Left x
193−
194− _MissingFileError = prism MissingFileError $ \case
195− MissingFileError f -> Right f
196− x -> Left x
197−
198− _InvalidFileError = prism
199− (uncurry InvalidFileError)
200− (\case
201− InvalidFileError f e -> Right (f, e)
202− x -> Left x)
203−
204− _TokenRefreshError = prism
205− (\(s, e, d) -> TokenRefreshError s e d)
206− (\case
207− TokenRefreshError s e d -> Right (s, e, d)
208− x -> Left x)
209−
210−-- | Lookup the @GOOGLE_APPLICATION_CREDENTIALS@ environment variable for the
211−-- default application credentials filepath.
212−defaultCredentialsPath :: MonadIO m => m (Maybe FilePath)
213−defaultCredentialsPath = liftIO (lookupEnv defaultCredentialsFile)
214−
215−-- | Return the filepath to the Cloud SDK well known file location such as
216−-- @~\/.config\/gcloud\/application_default_credentials.json@.
217−cloudSDKConfigPath :: MonadIO m => m FilePath
218−cloudSDKConfigPath = do
219− m <- liftIO (lookupEnv cloudSDKConfigDir)
220− case m of
221− Just d -> pure $! d </> "application_default_credentials.json"
222− Nothing -> do
223− d <- getConfigDirectory
224− pure $! d </> "gcloud/application_default_credentials.json"
225−
226−getConfigDirectory :: MonadIO m => m FilePath
227−getConfigDirectory = do
228− h <- liftIO getHomeDirectory
229− if os == "windows"
230− then pure h
231− else pure $! h </> ".config"
232−
233−-- | Given a client identifier, client secret, and a list of scopes to authorize,
234−-- construct a URL that can be used to obtain the 'OAuthCode' required to
235−-- instantiate 'FromClient'-style credentials.
236−formURL :: OAuthClient -> [OAuthScope] -> Text
237−formURL OAuthClient{..} ss =
238− LText.toStrict . LText.decodeUtf8 . Build.toLazyByteString $
239− buildText accountsURL
240− <> "?response_type=code"
241− <> "&client_id=" <> buildText _clientId
242− <> "&redirect_uri=" <> buildText redirectURI
243− <> "&scope=" <> queryEncodeScopes ss
244−
245−-- | @urn:ietf:wg:oauth:2.0:oob@.
246−redirectURI :: Text
247−redirectURI = "urn:ietf:wg:oauth:2.0:oob"
248−
249−{-| Service Account credentials which are typically generated/download
250−from the Google Developer console of the following form:
251−
252−@
253−{
254− \"type\": \"service_account\",
255− \"private_key_id\": \"303ad77e5efdf2ce952DFa\",
256− \"private_key\": \"-----BEGIN PRIVATE KEY-----\n...\n\",
257− \"client_email\": \"email@serviceaccount.com\",
258− \"client_id\": \"035-2-310.useraccount.com\"
259−}
260−@
261−
262−The private key is used to sign a JSON Web Token (JWT) of the
263−grant_type @urn:ietf:params:oauth:grant-type:jwt-bearer@, which is sent to
264−'accountsURL' to obtain a valid 'OAuthToken'. This process requires explicitly
265−specifying which 'Scope's the resulting 'OAuthToken' is authorized to access.
266−-}
267−data ServiceAccount = ServiceAccount
268− { _serviceId :: !ClientId
269− , _serviceEmail :: !Text
270− , _serviceKeyId :: !Text
271− , _servicePrivateKey :: !PrivateKey
272− } deriving (Eq, Show)
273−
274−instance FromJSON ServiceAccount where
275− parseJSON = withObject "service_account" $ \o -> do
276− bs <- Text.encodeUtf8 <$> o .: "private_key"
277− k <- case listToMaybe (readKeyFileFromMemory bs) of
278− Just (PrivKeyRSA k) -> pure k
279− _ ->
280− fail "Unable to parse key contents from \"private_key\""
281− ServiceAccount
282− <$> o .: "client_id"
283− <*> o .: "client_email"
284− <*> o .: "private_key_id"
285− <*> pure k
286−
287−{-| Authorized User credentials which are typically generated by the Cloud SDK
288−Tools such as @gcloud init@, of the following form:
289−
290−@
291−{
292− \"type\": \"authorized_user\",
293− \"client_id\": \"32555940559.apps.googleusercontent.com\",
294− \"client_secret\": \"Zms2qjJy2998hD4CTg2ejr2\",
295− \"refresh_token\": \"1/B3gM1x35v.VtqffS1n5w-rSJ\"
296−}
297−@
298−
299−The secret and refresh token are used to obtain a valid 'OAuthToken' from
300−'accountsURL' using grant_type @refresh_token@.
301−-}
302−data AuthorizedUser = AuthorizedUser
303− { _userId :: !ClientId
304− , _userRefresh :: !RefreshToken
305− , _userSecret :: !Secret
306− } deriving (Eq, Show)
307−
308−instance FromJSON AuthorizedUser where
309− parseJSON = withObject "authorized_user" $ \o -> AuthorizedUser
310− <$> o .: "client_id"
311− <*> o .: "refresh_token"
312− <*> o .: "client_secret"
313−
314−-- | A client identifier and accompanying secret used to obtain/refresh a token.
315−data OAuthClient = OAuthClient
316− { _clientId :: !ClientId
317− , _clientSecret :: !Secret
318− } deriving (Eq, Show)
319−
320−{-| An OAuth bearer type token of the following form:
321−
322−@
323−{
324− \"token_type\": \"Bearer\",
325− \"access_token\": \"eyJhbGci...\",
326− \"refresh_token\": \"1/B3gq9K...\",
327− \"expires_in\": 3600,
328− ...
329−}
330−@
331−
332−The '_tokenAccess' field will be inserted verbatim into the
333−@Authorization: Bearer ...@ header for all HTTP requests.
334−-}
335−data OAuthToken = OAuthToken
336− { _tokenAccess :: !AccessToken
337− , _tokenRefresh :: !(Maybe RefreshToken)
338− , _tokenExpiry :: !UTCTime
339− } deriving (Eq, Show)
340−
341−instance FromJSON (UTCTime -> OAuthToken) where
342− parseJSON = withObject "bearer" $ \o -> do
343− t <- o .: "access_token"
344− r <- o .:? "refresh_token"
345− e <- o .: "expires_in" <&> fromInteger
346− pure (OAuthToken t r . addUTCTime e)
347−
348−newtype OAuthCode = OAuthCode { codeToText :: Text }
349− deriving (Eq, Ord, Show, Read, IsString, Generic, Typeable, ToText, FromJSON, ToJSON)
111+import System.Directory (doesFileExist, getHomeDirectory)
112+import System.Environment (lookupEnv)
113+import System.FilePath ((</>))
114+import System.Info (os)
350115
351116-- | The supported credential mechanisms.
352117data Credentials
@@ -375,24 +140,6 @@ data Credentials
375140 -- An 'AuthorizedUser' is typically created by the @gcloud init@ command
376141 -- of the Google CloudSDK Tools.
377142
378−-- | A credentials value in one of two possible states, pre-exchange and
379−-- containing a valid, but possibly expired access token.
380−data Auth
381− = Exchange !Credentials
382− -- ^ Signifies that the initial token refresh has not occured, and the
383− -- 'Credentials' need to be signed and exchanged for a valid 'OAuthToken'.
384−
385− | Refresh !Credentials !OAuthToken
386− -- ^ An 'OAuthToken' that can potentially be expired, with the original
387− -- credentials that can be used to perform a refresh.
388−
389−type Store = MVar Auth
390−
391−-- | Construct storage containing the credentials which have not yet been
392−-- exchanged or refreshed.
393−emptyStore :: MonadIO m => Credentials -> m Store
394−emptyStore !c = liftIO . newMVar $ Exchange c
395−
396143-- | Attempt credentials discovery via the following steps:
397144--
398145-- * Read the default credentials from a file specified by
@@ -459,6 +206,8 @@ fromFilePath ss f = do
459206 either (throwM . InvalidFileError f . Text.pack) pure
460207 (fromJSONCredentials ss bs)
461208
209+-- | Attempt to parse either a @service_account@ or @authorized_user@ formatted
210+-- JSON value to obtain credentials.
462211fromJSONCredentials :: [OAuthScope]
463212 -> LBS.ByteString
464213 -> Either String Credentials
@@ -472,21 +221,31 @@ fromJSONCredentials ss bs = do
472221 ", Failed parsing authorized_user: " ++ ye
473222 _ -> x <|> y
474223
475−authorize :: (MonadIO m, MonadCatch m)
476− => Client.Request
477− -> Store
224+-- | An 'OAuthToken' that can potentially be expired, with the original
225+-- credentials that can be used to perform a refresh.
226+data Auth = Auth
227+ { _credentials :: !Credentials
228+ , _token :: !OAuthToken
229+ }
230+
231+-- | Check if the given token is still valid, ie. younger than the projected
232+-- expiry time.
233+validate :: MonadIO m => Auth -> m Bool
234+validate a = (< _tokenExpiry (_token a)) <$> liftIO getCurrentTime
235+
236+type Store = MVar Auth
237+
238+-- | Construct storage containing the credentials which have not yet been
239+-- exchanged or refreshed.
240+initStore :: (MonadIO m, MonadCatch m)
241+ => Credentials
478242 -> Logger
479243 -> Manager
480− -> m Client.Request
481−authorize rq s l m = bearer <$> getToken s l m
482− where
483− bearer t = rq
484− { Client.requestHeaders =
485− ( hAuthorization
486− , "Bearer " <> Text.encodeUtf8 (accessToText (_tokenAccess t))
487− ) : Client.requestHeaders rq
488− }
244+ -> m Store
245+initStore c l m = exchange c l m >>= liftIO . newMVar
489246
247+-- | Concurrently read the current token, and if expired, then
248+-- safely perform a single serial refresh.
490249getToken :: (MonadIO m, MonadCatch m)
491250 => Store
492251 -> Logger
@@ -494,255 +253,59 @@ getToken :: (MonadIO m, MonadCatch m)
494253 -> m OAuthToken
495254getToken s l m = do
496255 x <- liftIO (readMVar s)
497− mx <- validateToken x
498− case mx of
499− Just t -> pure t
500− Nothing -> liftIO . modifyMVar s $ \y -> do
501− my <- validateToken y
502− case my of
503− Just t -> pure (y, t)
504− Nothing ->
505− case y of
506− Exchange c -> do
507− t <- exchange c l m
508− pure (Refresh c t, t)
509− Refresh c t -> do
510− t' <- refresh c t l m
511− pure (Refresh c t', t')
512−
513−validateToken :: MonadIO m => Auth -> m (Maybe OAuthToken)
514−validateToken Exchange {} = pure Nothing
515−validateToken (Refresh _ t) = do
516− ts <- liftIO getCurrentTime
517− if ts < _tokenExpiry t
518− then pure (Just t)
519− else pure Nothing
520−
256+ mx <- validate x
257+ if mx
258+ then pure (_token x)
259+ else liftIO . modifyMVar s $ \y -> do
260+ my <- validate y
261+ if my
262+ then pure (y, _token y)
263+ else do
264+ z <- refresh y l m
265+ pure (z, _token z)
266+
267+-- | Perform the initial credentials exchange to obtain a valid 'OAuthToken'
268+-- suitable for authorizing requests.
521269exchange :: (MonadIO m, MonadCatch m)
522270 => Credentials
523271 -> Logger
524272 -> Manager
525− -> m OAuthToken
526−exchange c l m =
527− case c of
528− FromMetadata s -> refreshMetadata s l m
529− FromAccount a ss -> refreshAssertion a ss l m
530− FromClient c' n -> exchangeCode c' n l m
531− FromUser AuthorizedUser{..} ->
532− refreshToken _userId _userSecret (Just _userRefresh) l m
533−
534−exchangeCode :: (MonadIO m, MonadCatch m)
535− => OAuthClient
536− -> OAuthCode
537− -> Logger
538− -> Manager
539− -> m OAuthToken
540−exchangeCode OAuthClient{..} n = refreshRequest $
541− accountsRequest
542− { Client.requestBody = buildBody $
543− "grant_type=authorization_code"
544− <> "&client_id=" <> buildText _clientId
545− <> "&client_secret=" <> buildText _clientSecret
546− <> "&code=" <> buildText n
547− <> "&redirect_uri=" <> buildText redirectURI
548− }
273+ -> m Auth
274+exchange c l = fmap (Auth c) . action l
275+ where
276+ action = case c of
277+ FromMetadata s -> metadataToken s
278+ FromAccount a ss -> serviceAccountToken a ss
279+ FromClient c n -> exchangeCode c n
280+ FromUser u -> authorizedUserToken u Nothing
549281
282+-- | Refresh an existing 'OAuthToken' using
550283refresh :: (MonadIO m, MonadCatch m)
551− => Credentials
552− -> OAuthToken
284+ => Auth
553285 -> Logger
554286 -> Manager
555− -> m OAuthToken
556−refresh c OAuthToken{..} l m =
557− case c of
558− FromMetadata s -> refreshMetadata s l m
559− FromAccount a ss -> refreshAssertion a ss l m
560− FromClient OAuthClient{..} _ ->
561− refreshToken _clientId _clientSecret _tokenRefresh l m
562− FromUser AuthorizedUser{..} ->
563− refreshToken _userId _userSecret
564− (_tokenRefresh <|> Just _userRefresh) l m
565−
566−refreshMetadata :: (MonadIO m, MonadCatch m)
567− => ServiceId
568− -> Logger
569− -> Manager
570− -> m OAuthToken
571−refreshMetadata s = refreshRequest $
572− metadataRequest
573− { Client.path = "instance/service-accounts/"
574− <> Text.encodeUtf8 (serviceIdToText s)
575− <> "/token"
576− }
577−
578−refreshToken :: (MonadIO m, MonadCatch m)
579− => ClientId
580− -> Secret
581− -> Maybe RefreshToken
582− -> Logger
583− -> Manager
584− -> m OAuthToken
585−refreshToken c s r = refreshRequest $
586− accountsRequest
587− { Client.requestBody = buildBody $
588− "grant_type=refresh_token"
589− <> "&client_id=" <> buildText c
590− <> "&client_secret=" <> buildText s
591− <> maybe mempty ("&refresh_token=" <>) (buildText <$> r)
592− }
593−
594−refreshAssertion :: (MonadIO m, MonadCatch m)
595− => ServiceAccount
596− -> [OAuthScope]
597− -> Logger
598− -> Manager
599− -> m OAuthToken
600−refreshAssertion s ss l m = do
601− b <- encodeJWTBearer s ss
602− let rq = accountsRequest
603− { Client.requestBody = buildBody $
604− "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer"
605− <> "&assertion="
606− <> Build.byteString b
607− }
608− refreshRequest rq l m
609−
610−encodeJWTBearer :: (MonadIO m, MonadThrow m)
611− => ServiceAccount
612− -> [OAuthScope]
613− -> m ByteString
614−encodeJWTBearer s ss = liftIO $ do
615− i <- input . truncate <$> getPOSIXTime
616− r <- signSafer (Just SHA256) (_servicePrivateKey s) i
617− either failure (pure . concat' i) r
287+ -> m Auth
288+refresh (Auth c t) l = fmap (Auth c) . action l
618289 where
619− concat' i x = i <> "." <> signature (base64 x)
620−
621− failure e = throwM $
622− TokenRefreshError (toEnum 400) (Text.pack (show e)) Nothing
623−
624− signature bs =
625− case BS8.unsnoc bs of
626− Nothing -> mempty
627− Just (bs', x)
628− | x == '=' -> bs'
629− | otherwise -> bs
630−
631− input n = header <> "." <> payload
632− where
633− header = base64Encode
634− [ "alg" .= ("RS256" :: Text)
635− , "typ" .= ("JWT" :: Text)
636− , "kid" .= _serviceKeyId s
637− ]
638−
639− payload = base64Encode
640− [ "aud" .= accountsURL
641− , "scope" .= concatScopes ss
642− , "iat" .= n
643− , "exp" .= (n + maxTokenLifetimeSeconds)
644− , "iss" .= _serviceEmail s
645− ]
646−
647−-- | @https://accounts.google.com/o/oauth2/token@.
648−accountsURL :: Text
649−accountsURL = "https://accounts.google.com/o/oauth2/token"
650−
651−accountsRequest :: Client.Request
652−accountsRequest = def
653− { Client.host = "accounts.google.com"
654− , Client.port = 443
655− , Client.secure = True
656− , Client.checkStatus = \_ _ _ -> Nothing
657− , Client.method = "POST"
658− , Client.path = "/o/oauth2/token"
659− , Client.requestHeaders =
660− [ (hContentType, "application/x-www-form-urlencoded")
661− ]
662− }
663−
664−refreshRequest :: (MonadIO m, MonadCatch m)
665− => Client.Request
666− -> Logger
667− -> Manager
668− -> m OAuthToken
669−refreshRequest rq l m = do
670− logDebug l rq -- debug:ClientRequest
671−
672− rs <- liftIO (httpLbs rq m) `catch` (throwM . RetrievalError)
673−
674− let bs = responseBody rs
675− s = responseStatus rs
676−
677− logDebug l rs -- debug:ClientResponse
678− logTrace l $ "[Response Body]\n" <> bs -- trace:ResponseBody
679−
680− if fromEnum s == 200
681− then success s bs
682− else failure s bs
290+ action = case c of
291+ FromMetadata s -> metadataToken s
292+ FromAccount a ss -> serviceAccountToken a ss
293+ FromClient x _ -> refreshToken x t
294+ FromUser u -> authorizedUserToken u (_tokenRefresh t)
295+
296+-- | Apply the (by way of possible token refresh) a bearer token to the
297+-- authentication header of a request.
298+authorize :: (MonadIO m, MonadCatch m)
299+ => Client.Request
300+ -> Store
301+ -> Logger
302+ -> Manager
303+ -> m Client.Request
304+authorize rq s l m = bearer <$> getToken s l m
683305 where
684− success s bs = do
685− f <- parseErr s bs
686− ts <- liftIO getCurrentTime
687− pure (f ts)
688−
689− failure s bs = do
690− let e = "Failure refreshing token from " <> host <> path
691− logError l $ "[Refresh Error] " <> build e
692− case parseLBS bs of
693− Right x -> refreshErr s (_error x) (_description x)
694− Left _ -> refreshErr s e Nothing
695−
696− parseErr s bs =
697− case parseLBS bs of
698− Right !x -> pure x
699− Left e -> do
700− logError l $
701− "[Parse Error] Failure parsing token refresh " <> build e
702− refreshErr s e Nothing
703−
704− refreshErr :: MonadThrow m => Status -> Text -> Maybe Text -> m a
705− refreshErr s e = throwM . TokenRefreshError s e
706−
707− host = Text.decodeUtf8 (Client.host rq)
708− path = Text.decodeUtf8 (Client.path rq)
709−
710−parseLBS :: FromJSON a => LBS.ByteString -> Either Text a
711−parseLBS = either (Left . Text.pack) Right . eitherDecode'
712−
713−base64Encode :: [Pair] -> ByteString
714−base64Encode = base64 . LBS.toStrict . encode . object
715−
716−base64 :: ByteArray a => a -> ByteString
717−base64 = convertToBase Base64URLUnpadded
718−
719−buildBody :: Builder -> RequestBody
720−buildBody = RequestBodyLBS . Build.toLazyByteString
721−
722−buildText :: ToText a => a -> Builder
723−buildText = build . toText
724−
725−queryEncodeScopes :: [OAuthScope] -> Build.Builder
726−queryEncodeScopes =
727− mconcat
728− . intersperse "+"
729− . map (urlEncodeBuilder True . Text.encodeUtf8)
730− . coerce
731−
732−concatScopes :: [OAuthScope] -> LText.Text
733−concatScopes =
734− TBuild.toLazyText
735− . mconcat
736− . intersperse " "
737− . map TBuild.fromText
738− . coerce
739−
740−data RefreshError = RefreshError
741− { _error :: !Text
742− , _description :: !(Maybe Text)
743− }
744−
745−instance FromJSON RefreshError where
746− parseJSON = withObject "refresh_error" $ \o -> RefreshError
747− <$> o .: "error"
748− <*> o .:? "error_description"
306+ bearer t = rq
307+ { Client.requestHeaders =
308+ ( hAuthorization
309+ , "Bearer " <> Text.encodeUtf8 (toText (_tokenAccess t))
310+ ) : Client.requestHeaders rq
311+ }
gogol/src/Network/Google/Auth/ApplicationDefaultCredentials.hsnew file+133
@@ -0,0 +1,133 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+{-# LANGUAGE RecordWildCards #-}
3+
4+-- |
5+-- Module : Network.Google.Auth.ApplicationDefaultCredentials
6+-- Copyright : (c) 2015 Brendan Hay
7+-- License : Mozilla Public License, v. 2.0.
8+-- Maintainer : Brendan Hay <brendan.g.hay@gmail.com>
9+-- Stability : provisional
10+-- Portability : non-portable (GHC extensions)
11+
12+module Network.Google.Auth.ApplicationDefaultCredentials where
13+
14+import Control.Applicative
15+import Control.Concurrent
16+import Control.Exception.Lens
17+import Control.Lens hiding (coerce, (.=))
18+import Control.Monad
19+import Control.Monad.Catch
20+import Control.Monad.IO.Class
21+import Crypto.Hash.Algorithms (SHA256 (..))
22+import Crypto.PubKey.RSA.PKCS15 (signSafer)
23+import Crypto.PubKey.RSA.Types (PrivateKey)
24+import Data.Aeson
25+import Data.Aeson.Types
26+import Data.ByteArray
27+import Data.ByteArray.Encoding
28+import Data.ByteString (ByteString)
29+import Data.ByteString.Builder (Builder)
30+import qualified Data.ByteString.Builder as Build
31+import qualified Data.ByteString.Char8 as BS8
32+import qualified Data.ByteString.Lazy as LBS
33+import Data.Coerce
34+import Data.Default.Class (def)
35+import Data.List (intersperse)
36+import Data.String
37+import qualified Data.Text as Text
38+import qualified Data.Text.Encoding as Text
39+import qualified Data.Text.Lazy as LText
40+import qualified Data.Text.Lazy.Builder as TBuild
41+import qualified Data.Text.Lazy.Encoding as LText
42+import Data.Time
43+import Data.Time.Clock.POSIX
44+import Data.Typeable
45+import Data.X509
46+import Data.X509.Memory
47+import Network.Google.Compute.Metadata
48+import Network.Google.Internal.Auth
49+import Network.Google.Internal.Logger
50+import Network.Google.Prelude hiding (buildText)
51+import Network.HTTP.Conduit hiding (Request)
52+import qualified Network.HTTP.Conduit as Client
53+import Network.HTTP.Types
54+import System.Directory (doesFileExist,
55+ getHomeDirectory)
56+import System.Environment (lookupEnv)
57+import System.FilePath ((</>))
58+import System.Info (os)
59+
60+-- | The environment variable name which is used to specify the directory
61+-- containing the @application_default_credentials.json@ generated by @gcloud init@.
62+cloudSDKConfigDir :: String
63+cloudSDKConfigDir = "CLOUDSDK_CONFIG"
64+
65+-- | The environment variable pointing the file with local
66+-- Application Default Credentials.
67+defaultCredentialsFile :: String
68+defaultCredentialsFile = "GOOGLE_APPLICATION_CREDENTIALS"
69+
70+{-| Authorized User credentials which are typically generated by the Cloud SDK
71+Tools such as @gcloud init@, of the following form:
72+
73+@
74+{
75+ \"type\": \"authorized_user\",
76+ \"client_id\": \"32555940559.apps.googleusercontent.com\",
77+ \"client_secret\": \"Zms2qjJy2998hD4CTg2ejr2\",
78+ \"refresh_token\": \"1/B3gM1x35v.VtqffS1n5w-rSJ\"
79+}
80+@
81+
82+The secret and refresh token are used to obtain a valid 'OAuthToken' from
83+'accountsURL' using grant_type @refresh_token@.
84+-}
85+data AuthorizedUser = AuthorizedUser
86+ { _userId :: !ClientId
87+ , _userRefresh :: !RefreshToken
88+ , _userSecret :: !Secret
89+ } deriving (Eq, Show)
90+
91+instance FromJSON AuthorizedUser where
92+ parseJSON = withObject "authorized_user" $ \o -> AuthorizedUser
93+ <$> o .: "client_id"
94+ <*> o .: "refresh_token"
95+ <*> o .: "client_secret"
96+
97+authorizedUserToken :: (MonadIO m, MonadCatch m)
98+ => AuthorizedUser
99+ -> Maybe RefreshToken
100+ -> Logger
101+ -> Manager
102+ -> m OAuthToken
103+authorizedUserToken u r = refreshRequest $
104+ accountsRequest
105+ { Client.requestBody = textBody $
106+ "grant_type=refresh_token"
107+ <> "&client_id=" <> toText (_userId u)
108+ <> "&client_secret=" <> toText (_userSecret u)
109+ <> "&refresh_token=" <> toText (fromMaybe (_userRefresh u) r)
110+ }
111+
112+-- | Lookup the @GOOGLE_APPLICATION_CREDENTIALS@ environment variable for the
113+-- default application credentials filepath.
114+defaultCredentialsPath :: MonadIO m => m (Maybe FilePath)
115+defaultCredentialsPath = liftIO (lookupEnv defaultCredentialsFile)
116+
117+-- | Return the filepath to the Cloud SDK well known file location such as
118+-- @~\/.config\/gcloud\/application_default_credentials.json@.
119+cloudSDKConfigPath :: MonadIO m => m FilePath
120+cloudSDKConfigPath = do
121+ m <- liftIO (lookupEnv cloudSDKConfigDir)
122+ case m of
123+ Just d -> pure $! d </> "application_default_credentials.json"
124+ Nothing -> do
125+ d <- getConfigDirectory
126+ pure $! d </> "gcloud/application_default_credentials.json"
127+
128+getConfigDirectory :: MonadIO m => m FilePath
129+getConfigDirectory = do
130+ h <- liftIO getHomeDirectory
131+ if os == "windows"
132+ then pure h
133+ else pure $! h </> ".config"
gogol/src/Network/Google/Auth/Device.hsnew file+56
@@ -0,0 +1,56 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+
3+-- |
4+-- Module : Network.Google.Auth.Device
5+-- Copyright : (c) 2015 Brendan Hay
6+-- License : Mozilla Public License, v. 2.0.
7+-- Maintainer : Brendan Hay <brendan.g.hay@gmail.com>
8+-- Stability : provisional
9+-- Portability : non-portable (GHC extensions)
10+
11+module Network.Google.Auth.Device where
12+
13+import Control.Applicative
14+import Control.Concurrent
15+import Control.Exception.Lens
16+import Control.Lens hiding (coerce, (.=))
17+import Control.Monad
18+import Control.Monad.Catch
19+import Control.Monad.IO.Class
20+import Crypto.Hash.Algorithms (SHA256 (..))
21+import Crypto.PubKey.RSA.PKCS15 (signSafer)
22+import Crypto.PubKey.RSA.Types (PrivateKey)
23+import Data.Aeson
24+import Data.Aeson.Types
25+import Data.ByteArray
26+import Data.ByteArray.Encoding
27+import Data.ByteString (ByteString)
28+import Data.ByteString.Builder (Builder)
29+import qualified Data.ByteString.Builder as Build
30+import qualified Data.ByteString.Char8 as BS8
31+import qualified Data.ByteString.Lazy as LBS
32+import Data.Coerce
33+import Data.Default.Class (def)
34+import Data.List (intersperse)
35+import Data.String
36+import qualified Data.Text as Text
37+import qualified Data.Text.Encoding as Text
38+import qualified Data.Text.Lazy as LText
39+import qualified Data.Text.Lazy.Builder as TBuild
40+import qualified Data.Text.Lazy.Encoding as LText
41+import Data.Time
42+import Data.Time.Clock.POSIX
43+import Data.Typeable
44+import Data.X509
45+import Data.X509.Memory
46+import Network.Google.Compute.Metadata
47+import Network.Google.Internal.Logger
48+import Network.Google.Prelude hiding (buildText)
49+import Network.HTTP.Conduit hiding (Request)
50+import qualified Network.HTTP.Conduit as Client
51+import Network.HTTP.Types
52+import System.Directory (doesFileExist,
53+ getHomeDirectory)
54+import System.Environment (lookupEnv)
55+import System.FilePath ((</>))
56+import System.Info (os)
gogol/src/Network/Google/Auth/InstalledApplication.hsnew file+96
@@ -0,0 +1,96 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+
3+-- |
4+-- Module : Network.Google.Auth.InstalledApplication
5+-- Copyright : (c) 2015 Brendan Hay
6+-- License : Mozilla Public License, v. 2.0.
7+-- Maintainer : Brendan Hay <brendan.g.hay@gmail.com>
8+-- Stability : provisional
9+-- Portability : non-portable (GHC extensions)
10+
11+module Network.Google.Auth.InstalledApplication where
12+
13+import Control.Applicative
14+import Control.Concurrent
15+import Control.Exception.Lens
16+import Control.Lens hiding (coerce, (.=))
17+import Control.Monad
18+import Control.Monad.Catch
19+import Control.Monad.IO.Class
20+import Crypto.Hash.Algorithms (SHA256 (..))
21+import Crypto.PubKey.RSA.PKCS15 (signSafer)
22+import Crypto.PubKey.RSA.Types (PrivateKey)
23+import Data.Aeson
24+import Data.Aeson.Types
25+import Data.ByteArray
26+import Data.ByteArray.Encoding
27+import Data.ByteString (ByteString)
28+import Data.ByteString.Builder (Builder)
29+import qualified Data.ByteString.Builder as Build
30+import qualified Data.ByteString.Char8 as BS8
31+import qualified Data.ByteString.Lazy as LBS
32+import Data.Coerce
33+import Data.Default.Class (def)
34+import Data.List (intersperse)
35+import Data.String
36+import qualified Data.Text as Text
37+import qualified Data.Text.Encoding as Text
38+import qualified Data.Text.Lazy as LText
39+import qualified Data.Text.Lazy.Builder as TBuild
40+import qualified Data.Text.Lazy.Encoding as LText
41+import Data.Time
42+import Data.Time.Clock.POSIX
43+import Data.Typeable
44+import Data.X509
45+import Data.X509.Memory
46+import Network.Google.Internal.Auth
47+import Network.Google.Internal.Logger
48+import Network.Google.Prelude
49+import Network.HTTP.Conduit hiding (Request)
50+import qualified Network.HTTP.Conduit as Client
51+import Network.HTTP.Types
52+
53+-- | Given a client identifier, client secret, and a list of scopes to authorize,
54+-- construct a URL that can be used to obtain the 'OAuthCode' required to
55+-- instantiate 'FromClient'-style credentials.
56+formURL :: OAuthClient -> [OAuthScope] -> Text
57+formURL c ss = accountsURL
58+ <> "?response_type=code"
59+ <> "&client_id=" <> toText (_clientId c)
60+ <> "&redirect_uri=" <> redirectURI
61+ <> "&scope=" <> Text.decodeUtf8 (queryEncodeScopes ss)
62+
63+-- | @urn:ietf:wg:oauth:2.0:oob@.
64+redirectURI :: Text
65+redirectURI = "urn:ietf:wg:oauth:2.0:oob"
66+
67+exchangeCode :: (MonadIO m, MonadCatch m)
68+ => OAuthClient
69+ -> OAuthCode
70+ -> Logger
71+ -> Manager
72+ -> m OAuthToken
73+exchangeCode c n = refreshRequest $
74+ accountsRequest
75+ { Client.requestBody = textBody $
76+ "grant_type=authorization_code"
77+ <> "&client_id=" <> toText (_clientId c)
78+ <> "&client_secret=" <> toText (_clientSecret c)
79+ <> "&code=" <> toText n
80+ <> "&redirect_uri=" <> redirectURI
81+ }
82+
83+refreshToken :: (MonadIO m, MonadCatch m)
84+ => OAuthClient
85+ -> OAuthToken
86+ -> Logger
87+ -> Manager
88+ -> m OAuthToken
89+refreshToken c t = refreshRequest $
90+ accountsRequest
91+ { Client.requestBody = textBody $
92+ "grant_type=refresh_token"
93+ <> "&client_id=" <> toText (_clientId c)
94+ <> "&client_secret=" <> toText (_clientSecret c)
95+ <> maybe mempty ("&refresh_token=" <>) (toText <$> _tokenRefresh t)
96+ }
gogol/src/Network/Google/Auth/ServiceAccount.hsnew file+164
@@ -0,0 +1,164 @@
1+{-# LANGUAGE OverloadedStrings #-}
2+
3+-- |
4+-- Module : Network.Google.Auth.ServiceAccount
5+-- Copyright : (c) 2015 Brendan Hay
6+-- License : Mozilla Public License, v. 2.0.
7+-- Maintainer : Brendan Hay <brendan.g.hay@gmail.com>
8+-- Stability : provisional
9+-- Portability : non-portable (GHC extensions)
10+
11+module Network.Google.Auth.ServiceAccount where
12+
13+import Control.Applicative
14+import Control.Concurrent
15+import Control.Exception.Lens
16+import Control.Lens hiding (coerce, (.=))
17+import Control.Monad
18+import Control.Monad.Catch
19+import Control.Monad.IO.Class
20+import Crypto.Hash.Algorithms (SHA256 (..))
21+import Crypto.PubKey.RSA.PKCS15 (signSafer)
22+import Crypto.PubKey.RSA.Types (PrivateKey)
23+import Data.Aeson
24+import Data.Aeson.Types
25+import Data.ByteArray
26+import Data.ByteArray.Encoding
27+import Data.ByteString (ByteString)
28+import Data.ByteString.Builder (Builder)
29+import qualified Data.ByteString.Builder as Build
30+import qualified Data.ByteString.Char8 as BS8
31+import qualified Data.ByteString.Lazy as LBS
32+import Data.Coerce
33+import Data.Default.Class (def)
34+import Data.List (intersperse)
35+import Data.String
36+import qualified Data.Text as Text
37+import qualified Data.Text.Encoding as Text
38+import qualified Data.Text.Lazy as LText
39+import qualified Data.Text.Lazy.Builder as TBuild
40+import qualified Data.Text.Lazy.Encoding as LText
41+import Data.Time
42+import Data.Time.Clock.POSIX
43+import Data.Typeable
44+import Data.X509
45+import Data.X509.Memory
46+import Network.Google.Compute.Metadata
47+import Network.Google.Internal.Auth
48+import Network.Google.Internal.Logger
49+import Network.Google.Prelude hiding (buildText)
50+import Network.HTTP.Conduit hiding (Request)
51+import qualified Network.HTTP.Conduit as Client
52+import Network.HTTP.Types
53+import System.Directory (doesFileExist,
54+ getHomeDirectory)
55+import System.Environment (lookupEnv)
56+import System.FilePath ((</>))
57+import System.Info (os)
58+
59+-- | 1 hour in seconds.
60+maxTokenLifetimeSeconds :: Int
61+maxTokenLifetimeSeconds = 3600
62+
63+{-| Service Account credentials which are typically generated/download
64+from the Google Developer console of the following form:
65+
66+@
67+{
68+ \"type\": \"service_account\",
69+ \"private_key_id\": \"303ad77e5efdf2ce952DFa\",
70+ \"private_key\": \"-----BEGIN PRIVATE KEY-----\n...\n\",
71+ \"client_email\": \"email@serviceaccount.com\",
72+ \"client_id\": \"035-2-310.useraccount.com\"
73+}
74+@
75+
76+The private key is used to sign a JSON Web Token (JWT) of the
77+grant_type @urn:ietf:params:oauth:grant-type:jwt-bearer@, which is sent to
78+'accountsURL' to obtain a valid 'OAuthToken'. This process requires explicitly
79+specifying which 'Scope's the resulting 'OAuthToken' is authorized to access.
80+-}
81+data ServiceAccount = ServiceAccount
82+ { _serviceId :: !ClientId
83+ , _serviceEmail :: !Text
84+ , _serviceKeyId :: !Text
85+ , _servicePrivateKey :: !PrivateKey
86+ } deriving (Eq, Show)
87+
88+instance FromJSON ServiceAccount where
89+ parseJSON = withObject "service_account" $ \o -> do
90+ bs <- Text.encodeUtf8 <$> o .: "private_key"
91+ k <- case listToMaybe (readKeyFileFromMemory bs) of
92+ Just (PrivKeyRSA k) -> pure k
93+ _ ->
94+ fail "Unable to parse key contents from \"private_key\""
95+ ServiceAccount
96+ <$> o .: "client_id"
97+ <*> o .: "client_email"
98+ <*> o .: "private_key_id"
99+ <*> pure k
100+
101+serviceAccountToken :: (MonadIO m, MonadCatch m)
102+ => ServiceAccount
103+ -> [OAuthScope]
104+ -> Logger
105+ -> Manager
106+ -> m OAuthToken
107+serviceAccountToken s ss l m = do
108+ b <- encodeBearerJWT s ss
109+ let rq = accountsRequest
110+ { Client.requestBody = RequestBodyBS $
111+ "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer"
112+ <> "&assertion="
113+ <> b
114+ }
115+ refreshRequest rq l m
116+
117+metadataToken :: (MonadIO m, MonadCatch m)
118+ => ServiceId
119+ -> Logger
120+ -> Manager
121+ -> m OAuthToken
122+metadataToken s = refreshRequest $
123+ metadataRequest
124+ { Client.path = "instance/service-accounts/"
125+ <> Text.encodeUtf8 (toText s)
126+ <> "/token"
127+ }
128+
129+encodeBearerJWT :: (MonadIO m, MonadThrow m)
130+ => ServiceAccount
131+ -> [OAuthScope]
132+ -> m ByteString
133+encodeBearerJWT s ss = liftIO $ do
134+ i <- input . truncate <$> getPOSIXTime
135+ r <- signSafer (Just SHA256) (_servicePrivateKey s) i
136+ either failure (pure . concat' i) r
137+ where
138+ concat' i x = i <> "." <> signature (base64 x)
139+
140+ failure e = throwM $
141+ TokenRefreshError (toEnum 400) (Text.pack (show e)) Nothing
142+
143+ signature bs =
144+ case BS8.unsnoc bs of
145+ Nothing -> mempty
146+ Just (bs', x)
147+ | x == '=' -> bs'
148+ | otherwise -> bs
149+
150+ input n = header <> "." <> payload
151+ where
152+ header = base64Encode
153+ [ "alg" .= ("RS256" :: Text)
154+ , "typ" .= ("JWT" :: Text)
155+ , "kid" .= _serviceKeyId s
156+ ]
157+
158+ payload = base64Encode
159+ [ "aud" .= accountsURL
160+ , "scope" .= concatScopes ss
161+ , "iat" .= n
162+ , "exp" .= (n + maxTokenLifetimeSeconds)
163+ , "iss" .= _serviceEmail s
164+ ]
gogol/src/Network/Google/Compute/Metadata.hs+8−9
@@ -69,15 +69,6 @@ metadataFlavorHeader = "Metadata-Flavor"
6969metadataFlavorDesired :: ByteString
7070metadataFlavorDesired = "Google"
7171
72−metadataRequest :: Client.Request
73−metadataRequest = def
74− { Client.host = "metadata.google.internal"
75− , Client.port = 80
76− , Client.secure = False
77− , Client.method = "GET"
78− , Client.requestHeaders = [(metadataFlavorHeader, metadataFlavorDesired)]
79− }
80−
8172-- | Detect if the underlying host is running on GCE.
8273--
8374-- The environment variable @NO_GCE_CHECK@ can be set to @1@, @true@, @yes@, or @on@
@@ -243,3 +234,11 @@ metadata path ss m =
243234 else Just . toException $ StatusCodeException s hs cj
244235 }
245236
237+metadataRequest :: Client.Request
238+metadataRequest = def
239+ { Client.host = "metadata.google.internal"
240+ , Client.port = 80
241+ , Client.secure = False
242+ , Client.method = "GET"
243+ , Client.requestHeaders = [(metadataFlavorHeader, metadataFlavorDesired)]
244+ }
gogol/src/Network/Google/Env.hs+9−8
@@ -22,8 +22,8 @@ import Network.HTTP.Conduit
2222
2323-- | The environment containing the parameters required to make Google requests.
2424data Env = Env
25− { _envLogger :: !Logger
26− , _envOverride :: !(Dual (Endo Service))
25+ { _envOverride :: !(Dual (Endo Service))
26+ , _envLogger :: !Logger
2727 , _envManager :: !Manager
2828 , _envStore :: !Store
2929 }
@@ -35,20 +35,20 @@ class HasEnv a where
3535 environment :: Lens' a Env
3636 {-# MINIMAL environment #-}
3737
38− -- | The function used to output log messages.
39− envLogger :: Lens' a Logger
40−
4138 -- | The currently applied overrides to all 'Service' configuration.
4239 envOverride :: Lens' a (Dual (Endo Service))
4340
41+ -- | The function used to output log messages.
42+ envLogger :: Lens' a Logger
43+
4444 -- | The 'Manager' used to create and manage open HTTP connections.
4545 envManager :: Lens' a Manager
4646
4747 -- | The credential store used to sign requests for authentication with Google.
4848 envStore :: Lens' a Store
4949
50− envLogger = environment . lens _envLogger (\s a -> s { _envLogger = a })
5150 envOverride = environment . lens _envOverride (\s a -> s { _envOverride = a })
51+ envLogger = environment . lens _envLogger (\s a -> s { _envLogger = a })
5252 envManager = environment . lens _envManager (\s a -> s { _envManager = a })
5353 envStore = environment . lens _envStore (\s a -> s { _envStore = a })
5454
@@ -117,7 +117,7 @@ newEnv :: (MonadIO m, MonadCatch m) => [OAuthScope] -> m Env
117117newEnv ss = do
118118 m <- liftIO (newManager tlsManagerSettings)
119119 c <- discover ss m
120− newEnvWith c m
120+ newEnvWith c (\_ _ -> pure ()) m
121121
122122-- | Creates a new environment without logging, using the supplied 'Manager' and
123123-- credentials. Use either the 'Credentials' constructor or
@@ -135,6 +135,7 @@ newEnv ss = do
135135-- /See:/ 'newEnv'.
136136newEnvWith :: (MonadIO m, MonadCatch m)
137137 => Credentials
138+ -> Logger
138139 -> Manager
139140 -> m Env
140−newEnvWith c m = Env (\_ _ -> pure ()) mempty m <$> emptyStore c
141+newEnvWith c l m = Env mempty l m <$> initStore c l m
gogol/src/Network/Google/Internal/Auth.hsnew file+252
@@ -0,0 +1,252 @@
1+{-# LANGUAGE BangPatterns #-}
2+{-# LANGUAGE DeriveGeneric #-}
3+{-# LANGUAGE FlexibleContexts #-}
4+{-# LANGUAGE FlexibleInstances #-}
5+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
6+{-# LANGUAGE LambdaCase #-}
7+{-# LANGUAGE OverloadedStrings #-}
8+
9+-- |
10+-- Module : Network.Google.Internal.Auth
11+-- Copyright : (c) 2015 Brendan Hay
12+-- License : Mozilla Public License, v. 2.0.
13+-- Maintainer : Brendan Hay <brendan.g.hay@gmail.com>
14+-- Stability : provisional
15+-- Portability : non-portable (GHC extensions)
16+
17+module Network.Google.Internal.Auth where
18+
19+import Control.Applicative
20+import Control.Concurrent
21+import Control.Exception.Lens
22+import Control.Lens hiding (coerce, (.=))
23+import Control.Monad
24+import Control.Monad.Catch
25+import Control.Monad.IO.Class
26+import Crypto.Hash.Algorithms (SHA256 (..))
27+import Crypto.PubKey.RSA.PKCS15 (signSafer)
28+import Crypto.PubKey.RSA.Types (PrivateKey)
29+import Data.Aeson
30+import Data.Aeson.Types
31+import Data.ByteArray
32+import Data.ByteArray.Encoding
33+import Data.ByteString (ByteString)
34+import Data.ByteString.Builder (Builder)
35+import qualified Data.ByteString.Builder as Build
36+import qualified Data.ByteString.Char8 as BS8
37+import qualified Data.ByteString.Lazy as LBS
38+import Data.Coerce
39+import Data.Default.Class (def)
40+import Data.List (intersperse)
41+import Data.String
42+import qualified Data.Text as Text
43+import qualified Data.Text.Encoding as Text
44+import qualified Data.Text.Lazy as LText
45+import qualified Data.Text.Lazy.Builder as TBuild
46+import qualified Data.Text.Lazy.Encoding as LText
47+import Data.Time
48+import Data.Time.Clock.POSIX
49+import Data.Typeable
50+import Data.X509
51+import Data.X509.Memory
52+import Network.Google.Compute.Metadata
53+import Network.Google.Internal.Logger
54+import Network.Google.Prelude hiding (buildText)
55+import Network.HTTP.Conduit hiding (Request)
56+import qualified Network.HTTP.Conduit as Client
57+import Network.HTTP.Types
58+import System.Directory (doesFileExist,
59+ getHomeDirectory)
60+import System.Environment (lookupEnv)
61+import System.FilePath ((</>))
62+import System.Info (os)
63+
64+-- | A client identifier and accompanying secret used to obtain/refresh a token.
65+data OAuthClient = OAuthClient
66+ { _clientId :: !ClientId
67+ , _clientSecret :: !Secret
68+ } deriving (Eq, Show)
69+
70+{-| An OAuth bearer type token of the following form:
71+
72+@
73+{
74+ \"token_type\": \"Bearer\",
75+ \"access_token\": \"eyJhbGci...\",
76+ \"refresh_token\": \"1/B3gq9K...\",
77+ \"expires_in\": 3600,
78+ ...
79+}
80+@
81+
82+The '_tokenAccess' field will be inserted verbatim into the
83+@Authorization: Bearer ...@ header for all HTTP requests.
84+-}
85+data OAuthToken = OAuthToken
86+ { _tokenAccess :: !AccessToken
87+ , _tokenRefresh :: !(Maybe RefreshToken)
88+ , _tokenExpiry :: !UTCTime
89+ } deriving (Eq, Show)
90+
91+instance FromJSON (UTCTime -> OAuthToken) where
92+ parseJSON = withObject "bearer" $ \o -> do
93+ t <- o .: "access_token"
94+ r <- o .:? "refresh_token"
95+ e <- o .: "expires_in" <&> fromInteger
96+ pure (OAuthToken t r . addUTCTime e)
97+
98+newtype OAuthCode = OAuthCode Text
99+ deriving (Eq, Ord, Show, Read, IsString, Generic, Typeable, ToText, FromJSON, ToJSON)
100+
101+-- | An error thrown when attempting to read AuthN/AuthZ information.
102+data AuthError
103+ = RetrievalError HttpException
104+ | MissingFileError FilePath
105+ | InvalidFileError FilePath Text
106+ | TokenRefreshError Status Text (Maybe Text)
107+ deriving (Show, Typeable)
108+
109+instance Exception AuthError
110+
111+class AsAuthError a where
112+ -- | A general authentication error.
113+ _AuthError :: Prism' a AuthError
114+ {-# MINIMAL _AuthError #-}
115+
116+ -- | An error occured while communicating over HTTP with either then
117+ -- local metadata or remote accounts.google.com endpoints.
118+ _RetrievalError :: Prism' a HttpException
119+
120+ -- | The specified default credentials file could not be found.
121+ _MissingFileError :: Prism' a FilePath
122+
123+ -- | An error occured parsing the default credentials file.
124+ _InvalidFileError :: Prism' a (FilePath, Text)
125+
126+ -- | An error occured when attempting to refresh a token.
127+ _TokenRefreshError :: Prism' a (Status, Text, Maybe Text)
128+
129+ _RetrievalError = _AuthError . _RetrievalError
130+ _MissingFileError = _AuthError . _MissingFileError
131+ _InvalidFileError = _AuthError . _InvalidFileError
132+ _TokenRefreshError = _AuthError . _TokenRefreshError
133+
134+instance AsAuthError SomeException where
135+ _AuthError = exception
136+
137+instance AsAuthError AuthError where
138+ _AuthError = id
139+
140+ _RetrievalError = prism RetrievalError $ \case
141+ RetrievalError e -> Right e
142+ x -> Left x
143+
144+ _MissingFileError = prism MissingFileError $ \case
145+ MissingFileError f -> Right f
146+ x -> Left x
147+
148+ _InvalidFileError = prism
149+ (uncurry InvalidFileError)
150+ (\case
151+ InvalidFileError f e -> Right (f, e)
152+ x -> Left x)
153+
154+ _TokenRefreshError = prism
155+ (\(s, e, d) -> TokenRefreshError s e d)
156+ (\case
157+ TokenRefreshError s e d -> Right (s, e, d)
158+ x -> Left x)
159+
160+data RefreshError = RefreshError
161+ { _error :: !Text
162+ , _description :: !(Maybe Text)
163+ }
164+
165+instance FromJSON RefreshError where
166+ parseJSON = withObject "refresh_error" $ \o -> RefreshError
167+ <$> o .: "error"
168+ <*> o .:? "error_description"
169+
170+-- | @https://accounts.google.com/o/oauth2/token@.
171+accountsURL :: Text
172+accountsURL = "https://accounts.google.com/o/oauth2/token"
173+
174+accountsRequest :: Client.Request
175+accountsRequest = def
176+ { Client.host = "accounts.google.com"
177+ , Client.port = 443
178+ , Client.secure = True
179+ , Client.checkStatus = \_ _ _ -> Nothing
180+ , Client.method = "POST"
181+ , Client.path = "/o/oauth2/token"
182+ , Client.requestHeaders =
183+ [ (hContentType, "application/x-www-form-urlencoded")
184+ ]
185+ }
186+
187+refreshRequest :: (MonadIO m, MonadCatch m)
188+ => Client.Request
189+ -> Logger
190+ -> Manager
191+ -> m OAuthToken
192+refreshRequest rq l m = do
193+ logDebug l rq -- debug:ClientRequest
194+
195+ rs <- liftIO (httpLbs rq m) `catch` (throwM . RetrievalError)
196+
197+ let bs = responseBody rs
198+ s = responseStatus rs
199+
200+ logDebug l rs -- debug:ClientResponse
201+ logTrace l $ "[Response Body]\n" <> bs -- trace:ResponseBody
202+
203+ if fromEnum s == 200
204+ then success s bs
205+ else failure s bs
206+ where
207+ success s bs = do
208+ f <- parseErr s bs
209+ ts <- liftIO getCurrentTime
210+ pure (f ts)
211+
212+ failure s bs = do
213+ let e = "Failure refreshing token from " <> host <> path
214+ logError l $ "[Refresh Error] " <> build e
215+ case parseLBS bs of
216+ Right x -> refreshErr s (_error x) (_description x)
217+ Left _ -> refreshErr s e Nothing
218+
219+ parseErr s bs =
220+ case parseLBS bs of
221+ Right !x -> pure x
222+ Left e -> do
223+ logError l $
224+ "[Parse Error] Failure parsing token refresh " <> build e
225+ refreshErr s e Nothing
226+
227+ refreshErr :: MonadThrow m => Status -> Text -> Maybe Text -> m a
228+ refreshErr s e = throwM . TokenRefreshError s e
229+
230+ host = Text.decodeUtf8 (Client.host rq)
231+ path = Text.decodeUtf8 (Client.path rq)
232+
233+parseLBS :: FromJSON a => LBS.ByteString -> Either Text a
234+parseLBS = either (Left . Text.pack) Right . eitherDecode'
235+
236+base64Encode :: [Pair] -> ByteString
237+base64Encode = base64 . LBS.toStrict . encode . object
238+
239+base64 :: ByteArray a => a -> ByteString
240+base64 = convertToBase Base64URLUnpadded
241+
242+queryEncodeScopes :: [OAuthScope] -> ByteString
243+queryEncodeScopes =
244+ BS8.intercalate "+"
245+ . map (urlEncode True . Text.encodeUtf8)
246+ . coerce
247+
248+concatScopes :: [OAuthScope] -> Text
249+concatScopes = Text.intercalate " " . coerce
250+
251+textBody :: Text -> RequestBody
252+textBody = RequestBodyBS . Text.encodeUtf8