{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE CPP #-}
module Network.AWS.Auth
(
getAuth
, Credentials (..)
, Auth (..)
, envAccessKey
, envSecretKey
, envSessionToken
, credAccessKey
, credSecretKey
, credSessionToken
, credProfile
, credFile
, fromKeys
, fromSession
, fromTemporarySession
, fromEnv
, fromEnvKeys
, fromFile
, fromFilePath
, fromProfile
, fromProfileName
, fromContainer
, AccessKey (..)
, SecretKey (..)
, SessionToken (..)
, AsAuthError (..)
, AuthError (..)
) where
import Control.Concurrent
import Control.Monad
import Control.Monad.Catch
import Control.Monad.IO.Class
import Control.Monad.Trans.Maybe (MaybeT (..))
import Data.Char (isSpace)
import Data.IORef
import Data.Monoid
import Data.Time (diffUTCTime, getCurrentTime)
import Network.AWS.Data.Log
import Network.AWS.Data.JSON
import Network.AWS.EC2.Metadata
import Network.AWS.Lens (catching, catching_, exception, throwingM,
_IOException)
import Network.AWS.Lens (Prism', prism, (<&>))
import Network.AWS.Prelude
import Network.HTTP.Conduit
import System.Directory (doesFileExist, getHomeDirectory)
import System.Environment
import System.Mem.Weak
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy.Char8 as LBS8
import qualified Data.Ini as INI
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import qualified Network.HTTP.Conduit as HTTP
envAccessKey :: Text
envAccessKey = "AWS_ACCESS_KEY_ID"
envSecretKey :: Text
envSecretKey = "AWS_SECRET_ACCESS_KEY"
envSessionToken :: Text
envSessionToken = "AWS_SESSION_TOKEN"
envProfile :: Text
envProfile = "AWS_PROFILE"
envRegion :: Text
envRegion = "AWS_REGION"
envContainerCredentialsURI :: Text
envContainerCredentialsURI = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"
credAccessKey :: Text
credAccessKey = "aws_access_key_id"
credSecretKey :: Text
credSecretKey = "aws_secret_access_key"
credSessionToken :: Text
credSessionToken = "aws_session_token"
credProfile :: Text
credProfile = "default"
credFile :: (MonadCatch m, MonadIO m) => m FilePath
credFile = catching_ _IOException dir err
where
dir = (++ p) `liftM` liftIO getHomeDirectory
err = throwM $ MissingFileError ("$HOME" ++ p)
p = "/.aws/credentials"
fromKeys :: AccessKey -> SecretKey -> Auth
fromKeys a s = Auth (AuthEnv a (Sensitive s) Nothing Nothing)
fromSession :: AccessKey -> SecretKey -> SessionToken -> Auth
fromSession a s t =
Auth (AuthEnv a (Sensitive s) (Just (Sensitive t)) Nothing)
fromTemporarySession :: AccessKey
-> SecretKey
-> SessionToken
-> UTCTime
-> Auth
fromTemporarySession a s t e =
Auth (AuthEnv a (Sensitive s) (Just (Sensitive t)) (Just (Time e)))
data Credentials
= FromKeys AccessKey SecretKey
| FromSession AccessKey SecretKey SessionToken
| FromEnv Text Text (Maybe Text) (Maybe Text)
| FromProfile Text
| FromFile Text FilePath
| FromContainer
| Discover
deriving (Eq)
instance ToLog Credentials where
build = \case
FromKeys a _ ->
"FromKeys " <> build a <> " ****"
FromSession a _ _ ->
"FromSession " <> build a <> " **** ****"
FromEnv a s t r ->
"FromEnv " <> build a <> " " <> build s <> " " <> m t <> " " <> m r
FromProfile n ->
"FromProfile " <> build n
FromFile n f ->
"FromFile " <> build n <> " " <> build f
FromContainer ->
"FromContainer"
Discover ->
"Discover"
where
m (Just x) = "(Just " <> build x <> ")"
m Nothing = "Nothing"
instance Show Credentials where
show = BS8.unpack . toBS . build
data AuthError
= RetrievalError HttpException
| MissingEnvError Text
| InvalidEnvError Text
| MissingFileError FilePath
| InvalidFileError Text
| InvalidIAMError Text
deriving (Show, Typeable)
instance Exception AuthError
instance ToLog AuthError where
build = \case
RetrievalError e -> build e
MissingEnvError e -> "[MissingEnvError] { message = " <> build e <> "}"
InvalidEnvError e -> "[InvalidEnvError] { message = " <> build e <> "}"
MissingFileError f -> "[MissingFileError] { path = " <> build f <> "}"
InvalidFileError e -> "[InvalidFileError] { message = " <> build e <> "}"
InvalidIAMError e -> "[InvalidIAMError] { message = " <> build e <> "}"
class AsAuthError a where
_AuthError :: Prism' a AuthError
{-# MINIMAL _AuthError #-}
_RetrievalError :: Prism' a HttpException
_MissingEnvError :: Prism' a Text
_InvalidEnvError :: Prism' a Text
_MissingFileError :: Prism' a FilePath
_InvalidFileError :: Prism' a Text
_InvalidIAMError :: Prism' a Text
_RetrievalError = _AuthError . _RetrievalError
_MissingEnvError = _AuthError . _MissingEnvError
_InvalidEnvError = _AuthError . _InvalidEnvError
_MissingFileError = _AuthError . _MissingFileError
_InvalidFileError = _AuthError . _InvalidFileError
_InvalidIAMError = _AuthError . _InvalidIAMError
instance AsAuthError SomeException where
_AuthError = exception
instance AsAuthError AuthError where
_AuthError = id
_RetrievalError = prism RetrievalError $ \case
RetrievalError e -> Right e
x -> Left x
_MissingEnvError = prism MissingEnvError $ \case
MissingEnvError e -> Right e
x -> Left x
_InvalidEnvError = prism InvalidEnvError $ \case
InvalidEnvError e -> Right e
x -> Left x
_MissingFileError = prism MissingFileError $ \case
MissingFileError f -> Right f
x -> Left x
_InvalidFileError = prism InvalidFileError $ \case
InvalidFileError e -> Right e
x -> Left x
_InvalidIAMError = prism InvalidIAMError $ \case
InvalidIAMError e -> Right e
x -> Left x
getAuth :: (Applicative m, MonadIO m, MonadCatch m)
=> Manager
-> Credentials
-> m (Auth, Maybe Region)
getAuth m = \case
FromKeys a s -> return (fromKeys a s, Nothing)
FromSession a s t -> return (fromSession a s t, Nothing)
FromEnv a s t r -> fromEnvKeys a s t r
FromProfile n -> fromProfileName m n
FromFile n f -> fromFilePath n f
FromContainer -> fromContainer m
Discover ->
catching_ _MissingEnvError fromEnv $
catching _MissingFileError fromFile $ \f ->
catching_ _MissingEnvError (fromContainer m) $ do
p <- isEC2 m
unless p $
throwingM _MissingFileError f
fromProfile m
fromEnv :: (Applicative m, MonadIO m, MonadThrow m) => m (Auth, Maybe Region)
fromEnv =
fromEnvKeys
envAccessKey
envSecretKey
(Just envSessionToken)
(Just envRegion)
fromEnvKeys :: (Applicative m, MonadIO m, MonadThrow m)
=> Text
-> Text
-> Maybe Text
-> Maybe Text
-> m (Auth, Maybe Region)
fromEnvKeys access secret session region' =
(,) <$> fmap Auth lookupKeys <*> lookupRegion
where
lookupKeys = AuthEnv
<$> (req access <&> AccessKey . BS8.pack)
<*> (req secret <&> Sensitive . SecretKey . BS8.pack)
<*> (opt session <&> fmap (Sensitive . SessionToken . BS8.pack))
<*> return Nothing
lookupRegion :: (MonadIO m, MonadThrow m) => m (Maybe Region)
lookupRegion = runMaybeT $ do
k <- MaybeT (return region')
r <- MaybeT (opt region')
case fromText (Text.pack r) of
Right x -> return x
Left e -> throwM . InvalidEnvError $
"Unable to parse ENV variable: " <> k <> ", " <> Text.pack e
req k = do
m <- opt (Just k)
maybe (throwM . MissingEnvError $ "Unable to read ENV variable: " <> k)
return
m
opt Nothing = return Nothing
opt (Just k) = liftIO (lookupEnv (Text.unpack k))
fromFile :: (Applicative m, MonadIO m, MonadCatch m) => m (Auth, Maybe Region)
fromFile = do
p <- liftIO (lookupEnv (Text.unpack envProfile))
fromFilePath (maybe credProfile Text.pack p)
=<< credFile
fromFilePath :: (Applicative m, MonadIO m, MonadCatch m)
=> Text
-> FilePath
-> m (Auth, Maybe Region)
fromFilePath n f = do
p <- liftIO (doesFileExist f)
unless p $
throwM (MissingFileError f)
ini <- either (invalidErr Nothing) return =<< liftIO (INI.readIniFile f)
env <- AuthEnv
<$> (req credAccessKey ini <&> AccessKey)
<*> (req credSecretKey ini <&> Sensitive . SecretKey)
<*> (opt credSessionToken ini <&> fmap (Sensitive . SessionToken))
<*> return Nothing
return (Auth env, Nothing)
where
req k i =
case INI.lookupValue n k i of
Left e -> invalidErr (Just k) e
Right x
| blank x -> invalidErr (Just k) "cannot be a blank string."
| otherwise -> return (Text.encodeUtf8 x)
opt k i = return $
case INI.lookupValue n k i of
Left _ -> Nothing
Right x -> Just (Text.encodeUtf8 x)
invalidErr Nothing e = throwM $ InvalidFileError (Text.pack e)
invalidErr (Just k) e = throwM $ InvalidFileError
(Text.pack f <> ", key " <> k <> " " <> Text.pack e)
blank x = Text.null x || Text.all isSpace x
fromProfile :: (MonadIO m, MonadCatch m) => Manager -> m (Auth, Maybe Region)
fromProfile m = do
ls <- try $ metadata m (IAM (SecurityCredentials Nothing))
case BS8.lines `liftM` ls of
Right (x:_) -> fromProfileName m (Text.decodeUtf8 x)
Left e -> throwM (RetrievalError e)
_ -> throwM $
InvalidIAMError "Unable to get default IAM Profile from EC2 metadata"
fromProfileName :: (MonadIO m, MonadCatch m)
=> Manager
-> Text
-> m (Auth, Maybe Region)
fromProfileName m name = do
auth <- liftIO $ fetchAuthInBackground getCredentials
reg <- getRegion
return (auth, Just reg)
where
getCredentials :: IO AuthEnv
getCredentials =
try (metadata m (IAM . SecurityCredentials $ Just name)) >>=
handleErr (eitherDecode' . LBS8.fromStrict) invalidIAMErr
getRegion :: (MonadIO m, MonadCatch m) => m Region
getRegion =
try (identity m) >>=
handleErr (fmap _region) invalidIdentityErr
handleErr _ _ (Left e) = throwM (RetrievalError e)
handleErr f g (Right x) = either (throwM . g) return (f x)
invalidIAMErr = InvalidIAMError
. mappend ("Error parsing IAM profile '" <> name <> "' ")
. Text.pack
invalidIdentityErr = InvalidIAMError
. mappend "Error parsing Instance Identity Document "
. Text.pack
fromContainer :: (MonadIO m, MonadThrow m)
=> Manager
-> m (Auth, Maybe Region)
fromContainer m = do
req <- getCredentialsURI
auth <- liftIO $ fetchAuthInBackground (renew req)
reg <- getRegion
return (auth, reg)
where
getCredentialsURI :: (MonadIO m, MonadThrow m) => m HTTP.Request
getCredentialsURI = do
mp <- liftIO (lookupEnv (Text.unpack envContainerCredentialsURI))
p <- maybe (throwM . MissingEnvError $ "Unable to read ENV variable: " <> envContainerCredentialsURI)
return
mp
#if MIN_VERSION_http_client(0,4,30)
parseUrlThrow $ "http://169.254.170.2" <> p
#else
parseUrl $ "http://169.254.170.2" <> p
#endif
renew :: HTTP.Request -> IO AuthEnv
renew req = do
rs <- httpLbs req m
either (throwM . invalidIdentityErr) return (eitherDecode (responseBody rs))
invalidIdentityErr = InvalidIAMError
. mappend "Error parsing Task Identity Document "
. Text.pack
getRegion :: MonadIO m => m (Maybe Region)
getRegion = runMaybeT $ do
mr <- MaybeT . liftIO $ lookupEnv (Text.unpack envRegion)
either (const . MaybeT $ return Nothing)
return
(fromText (Text.pack mr))
fetchAuthInBackground :: IO AuthEnv -> IO Auth
fetchAuthInBackground menv = menv >>= \(!env) -> liftIO $
case _authExpiry env of
Nothing -> return (Auth env)
Just x -> do
r <- newIORef env
p <- myThreadId
s <- timer menv r p x
return (Ref s r)
where
timer :: IO AuthEnv -> IORef AuthEnv -> ThreadId -> ISO8601 -> IO ThreadId
timer ma !r !p !x = forkIO $ do
s <- myThreadId
w <- mkWeakIORef r (killThread s)
loop ma w p x
loop :: IO AuthEnv -> Weak (IORef AuthEnv) -> ThreadId -> ISO8601 -> IO ()
loop ma w !p !x = do
diff x <$> getCurrentTime >>= threadDelay
env <- try ma
case env of
Left e -> throwTo p (RetrievalError e)
Right !a -> do
mr <- deRefWeak w
case mr of
Nothing -> return ()
Just r -> do
atomicWriteIORef r a
maybe (return ()) (loop ma w p) (_authExpiry a)
diff (Time !x) !y = (* 1000000) $ if n > 0 then n else 1
where
!n = truncate (diffUTCTime x y) - 60