on develop help sign in
Using new authentication mechanism to sign requests
commit16d858a330d61a3c59cd9fd63e056d28c34d4bdc authorBrendan Hay <brendan.g.hay@gmail.com> dateThu 22 Oct 2015, 09:52 (11 years ago) parentf37de2e
4 files changed+109−70
core/src/Network/Google/Types.hs+8−2 gogol/gogol.cabal+4−2 gogol/src/Network/Google/Auth.hs+91−49 gogol/src/Network/Google/Internal/HTTP.hs+6−17
core/src/Network/Google/Types.hs+8−2
@@ -72,8 +72,14 @@ newtype AuthKey = AuthKey Text
7272newtype OAuthScope = OAuthScope { scopeToText :: Text }
7373 deriving (Eq, Ord, Show, Read, IsString, Generic, Typeable, ToText, FromJSON, ToJSON)
7474
75−newtype OAuthToken = OAuthToken { tokenToText :: Text }
76− deriving (Eq, Ord, Show, Read, IsString, Generic, Typeable, ToText, FromJSON, ToJSON)
75+newtype OAuthToken = OAuthToken { tokenToBS :: ByteString }
76+ deriving (Eq, Ord, Show, Read, IsString, Generic, Typeable)
77+
78+instance FromJSON OAuthToken where
79+ parseJSON = withText "oauth_token" (pure . OAuthToken . Text.encodeUtf8)
80+
81+instance ToJSON OAuthToken where
82+ toJSON = toJSON . Text.decodeUtf8 . tokenToBS
7783
7884newtype Download a = Download a
7985
gogol/gogol.cabal+4−2
@@ -53,6 +53,7 @@ library
5353 , base >= 4.7 && < 5
5454 , bytestring >= 0.9
5555 , case-insensitive >= 1.2
56+ , cryptonite
5657 , data-default-class >= 0.0.1
5758 , directory >= 1.2
5859 , exceptions >= 0.6
@@ -63,6 +64,7 @@ library
6364 , http-media >= 0.6
6465 , http-types >= 0.8.6
6566 , lens >= 4.4
67+ , memory
6668 , monad-control >= 1
6769 , mtl >= 2.1.3.1
6870 , resourcet >= 1.1
@@ -71,5 +73,5 @@ library
7173 , transformers >= 0.2
7274 , transformers-base >= 0.4
7375 , unordered-containers >= 0.2.5
74− , memory
75− , x509-store
76+ , x509
77+ , x509-store
gogol/src/Network/Google/Auth.hs+91−49
@@ -60,20 +60,30 @@ module Network.Google.Auth where
6060 -- , ClientId (..)
6161 -- ) where
6262
63−import Data.ByteArray.Encoding
6463import Control.Concurrent
6564import Control.Exception.Lens
66−import Control.Lens
65+import Control.Lens hiding ((.=))
6766import Control.Monad
6867import Control.Monad.Catch
6968import Control.Monad.IO.Class
69+import Crypto.Hash.Algorithms (SHA256 (..))
70+import Crypto.PubKey.RSA.PKCS15 (signSafer)
71+import Crypto.PubKey.RSA.Types (PrivateKey)
7072import Data.Aeson
73+import Data.Aeson.Types
74+import Data.ByteArray
75+import Data.ByteArray.Encoding
76+import Data.ByteString (ByteString)
77+import qualified Data.ByteString.Char8 as BS8
7178import qualified Data.ByteString.Lazy as LBS
7279import Data.Default.Class (def)
7380import qualified Data.Text as Text
7481import qualified Data.Text.Encoding as Text
7582import Data.Time
83+import Data.Time.Clock.POSIX
7684import Data.Typeable
85+import Data.X509
86+import Data.X509.Memory
7787import Network.Google.Compute.Metadata
7888import Network.Google.Prelude
7989import Network.HTTP.Conduit hiding (Request)
@@ -188,9 +198,10 @@ data Credentials
188198-- cannot be read, and credentials files are invalid or cannot be found.
189199getAuth :: (MonadIO m, MonadCatch m) => Manager -> Credentials -> m Auth
190200getAuth m = \case
191− FromToken t -> pure (AuthToken t)
192− FromFile f -> fromFilePath m f
193− Discover ->
201+ FromToken t -> pure (AuthToken t)
202+ FromFile f -> fromFilePath m f
203+ FromAccount s -> fromMetadata m s
204+ Discover ->
194205 catching _MissingFileError (fromFile m) $ \f -> do
195206 p <- isGCE m
196207 unless p $
@@ -215,7 +226,7 @@ fromFilePath m f = do
215226 e <- liftIO (LBS.readFile f) >>=
216227 either (throwM . InvalidFileError f) pure . parseLBS
217228 case e of
218− Left a -> pure (AuthSign a)
229+ Left a -> refresh m a >>= fmap AuthSign . liftIO . newMVar
219230 Right u -> refresh m u >>= fmap AuthUser . liftIO . newMVar
220231
221232-- | Lookup the @GOOGLE_APPLICATION_CREDENTIALS@ environment variable for the
@@ -268,24 +279,26 @@ data Auth
268279
269280authorise :: (MonadIO m, MonadCatch m)
270281 => Manager
271− -> Request
282+ -> Client.Request
272283 -> Auth
273− -> m Request
284+ -> m Client.Request
274285authorise m rq = \case
275286 AuthToken t -> pure $! authoriseBearer rq t
276287 AuthSign r -> authoriseToken m r rq
277288 AuthMeta r -> authoriseToken m r rq
278289 AuthUser r -> authoriseToken m r rq
279290
280−authoriseBearer :: Request -> OAuthToken -> Request
281−authoriseBearer rq =
282− appendHeader rq hAuthorization . Just . mappend "Bearer " . tokenToText
291+authoriseBearer :: Client.Request -> OAuthToken -> Client.Request
292+authoriseBearer rq t = rq
293+ { Client.requestHeaders = (hAuthorization, "Bearer " <> tokenToBS t)
294+ : Client.requestHeaders rq
295+ }
283296
284297authoriseToken :: (MonadIO m, MonadCatch m, Refresh a)
285298 => Manager
286299 -> MVar (Expires a)
287− -> Request
288− -> m Request
300+ -> Client.Request
301+ -> m Client.Request
289302authoriseToken m r rq = authoriseBearer rq <$> refreshToken m r
290303
291304refreshToken :: (MonadIO m, MonadCatch m, Refresh a)
@@ -338,15 +351,23 @@ data ServiceAccount = ServiceAccount
338351 { _serviceId :: !ClientId
339352 , _serviceEmail :: !Text
340353 , _serviceKeyId :: !Text
341− , _serviceKey :: !Text
354+ , _serviceKey :: !PrivateKey
342355 }
343356
344357instance FromJSON ServiceAccount where
345− parseJSON = withObject "service_account" $ \o -> ServiceAccount
346− <$> o .: "client_id"
347− <*> o .: "client_email"
348− <*> o .: "private_key_id"
349− <*> o .: "private_key"
358+ parseJSON = withObject "service_account" $ \o -> do
359+ bs <- Text.encodeUtf8 <$> o .: "private_key_id"
360+ ServiceAccount
361+ <$> o .: "client_id"
362+ <*> o .: "client_email"
363+ <*> o .: "private_key"
364+ <*> parseKey bs
365+ where
366+ parseKey bs =
367+ case listToMaybe (readKeyFileFromMemory bs) of
368+ Just (PrivKeyRSA k) -> pure k
369+ _ ->
370+ fail "Unable to parse key contents from 'private_key_id'"
350371
351372-- {
352373-- "client_id": "32555940559.apps.googleusercontent.com",
@@ -371,40 +392,57 @@ class Refresh a where
371392 refresh :: (MonadIO m, MonadCatch m) => Manager -> a -> m (Expires a)
372393
373394instance Refresh ServiceAccount where
374− refresh m s = refreshRequest m s $
375− accountsRequest
376− { Client.requestBody = RequestBodyBS . Text.encodeUtf8 $
395+ refresh m s = do
396+ b <- jwtEncode s
397+ refreshRequest m s $ accountsRequest
398+ { Client.requestBody = RequestBodyBS $
377399 "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer"
378− <> "&assertion=" <> generateAssertion s
400+ <> "&assertion="
401+ <> b
379402 }
380403
381− generateAssertion = do
382− n <- truncate <$> getPOSIXTime
383− readKeyFileFromMemory - do on parse.
384−
385− where
386− assertion = = input <> "." <> signature
404+ -- first_segment = _urlsafe_b64encode(_json_encode(header))
405+ -- second_segment = _urlsafe_b64encode(_json_encode(payload))
406+ -- assertion_input = first_segment + b'.' + second_segment
387407
388− signature = dropWhileEnd (base64 btes . rstrip(b'=')
408+ -- # Sign the assertion.
409+ -- rsa_bytes = rsa.pkcs1.sign(assertion_input, self._private_key,
410+ -- 'SHA-256')
411+ -- signature = base64.urlsafe_b64encode(rsa_bytes).rstrip(b'=')
389412
390− bytes = rsa.pkcs1.sign(input, _serviceKey s, 'SHA256')
391−
392− input n = header <> "." <> payload n
393−
394− header = base64 . encode $ object
395− [ "alg" .= ("RS256" :: Text)
396− , "typ" .= ("JWT" :: Text)
397− , "kid" .= _serviceKeyId s
398− ]
399−
400− payload n = base64 . encode $ object
401− [ "aud" .= Client.host accountsRequest
402− , "scope" .= ([] :: [Text])
403− , "iat" .= n
404− , "exp" .= n + maxTokenLifetimeSeconds
405− , "iss" .= _serviceEmail s
406− ]
413+ -- return assertion_input + b'.' + signature
407414
415+jwtEncode :: (MonadIO m, MonadThrow m) => ServiceAccount -> m ByteString
416+jwtEncode s = liftIO $ do
417+ i <- input . truncate <$> getPOSIXTime
418+ r <- signSafer (Just SHA256) (_serviceKey s) i
419+ either failure (\x -> pure (i <> "." <> signature (base64 x))) r
420+ where
421+ failure e = throwM $
422+ TokenRefreshError (toEnum 400) (Text.pack (show e)) Nothing
423+
424+ signature bs =
425+ case BS8.unsnoc bs of
426+ Nothing -> mempty
427+ Just (bs', x)
428+ | x == '=' -> bs'
429+ | otherwise -> bs
430+
431+ input n = header <> "." <> payload
432+ where
433+ header = base64Encode
434+ [ "alg" .= ("RS256" :: Text)
435+ , "typ" .= ("JWT" :: Text)
436+ , "kid" .= _serviceKeyId s
437+ ]
438+
439+ payload = base64Encode
440+ [ "aud" .= Text.decodeUtf8 (Client.host accountsRequest)
441+ , "scope" .= ([] :: [Text])
442+ , "iat" .= n
443+ , "exp" .= (n + maxTokenLifetimeSeconds)
444+ , "iss" .= _serviceEmail s
445+ ]
408446
409447instance Refresh AuthorisedUser where
410448 refresh m u@AuthorisedUser{..} = refreshRequest m u $
@@ -455,7 +493,7 @@ refreshRequest m r rq = do
455493 pure $! Expires _bearerExpiry _bearerAccess r
456494
457495 failure s = const $
458− refreshErr s ("Failure refreshing token from " <> Text.pack (Client.host rq))
496+ refreshErr s ("Failure refreshing token from " <> Text.decodeUtf8 (Client.host rq))
459497 Nothing
460498
461499 parseErr s bs =
@@ -468,5 +506,9 @@ refreshRequest m r rq = do
468506parseLBS :: FromJSON a => LBS.ByteString -> Either Text a
469507parseLBS = either (Left . Text.pack) Right . eitherDecode'
470508
471−-- base64 :: ToJSON a =>
509+base64Encode :: [Pair] -> ByteString
510+base64Encode = base64 . LBS.toStrict . encode . object
511+
512+-- base64 :: ToJSON a =>
513+base64 :: ByteArray a => a -> ByteString
472514base64 = convertToBase Base64URLUnpadded
gogol/src/Network/Google/Internal/HTTP.hs+6−17
@@ -25,7 +25,7 @@ import qualified Data.Text.Encoding as Text
2525import qualified Data.Text.Lazy as LText
2626import qualified Data.Text.Lazy.Builder as Build
2727import GHC.Exts (toList)
28−import Network.Google.Auth (Auth, refreshToken)
28+import Network.Google.Auth
2929import Network.Google.Env (Env (..))
3030import Network.Google.Internal.Multipart
3131import Network.Google.Types
@@ -38,11 +38,7 @@ import Network.HTTP.Types
3838--
3939-- Resumable endpoints - Not supported to begin with, need to figure
4040-- out how to return session id etc.
41−--
42−-- Assume initially that every service supports multipart upload.
43−--
44−-- When downloading media you must set the alt query parameter
45−-- to media in the request URL.
41+-- - Assume initially that every service supports multipart upload.
4642--
4743-- "resumable" or "multipart" needs to go into the "uploadType" param
4844
@@ -60,8 +56,8 @@ perform Env{..} x = catches go handlers
6056
6157 go = liftResourceT $ do
6258 (ct, b) <- getContent _rqBody
63− auth <- getAuthorisation _envAuth
64− rs <- http (rq auth ct b) _envManager
59+ rq <- authorise _envManager (request ct b) _envAuth
60+ rs <- http rq _envManager
6561 r <- _cliResponse (responseBody rs)
6662 pure $! case r of
6763 Right y -> Right y
@@ -72,7 +68,7 @@ perform Env{..} x = catches go handlers
7268 , _serializeMessage = e
7369 }
7470
75− rq ct b = def
71+ request ct b = def
7672 { Client.host = _svcHost
7773 , Client.port = _svcPort
7874 , Client.secure = _svcSecure
@@ -81,7 +77,7 @@ perform Env{..} x = catches go handlers
8177 , Client.method = _cliMethod
8278 , Client.path = path
8379 , Client.queryString = renderQuery True (toList _rqQuery)
84− , Client.requestHeaders = auth : accept (ct (toList _rqHeaders))
80+ , Client.requestHeaders = accept (ct (toList _rqHeaders))
8581 , Client.requestBody = b
8682 }
8783
@@ -111,13 +107,6 @@ perform Env{..} x = catches go handlers
111107 where
112108 err e = return (Left e)
113109
114−getAuthorisation :: MonadIO m => Auth -> m Header
115−getAuthorisation a = do
116− OAuthToken t <- refreshToken a
117− pure ( hAuthorization
118− , "Bearer " <> t
119− )
120−
121110getContent :: MonadIO m
122111 => Maybe Payload
123112 -> m ([Header] -> [Header], RequestBody)