summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorshlevy <>2017-05-19 11:27:00 (GMT)
committerhdiff <hdiff@hdiff.luite.com>2017-05-19 11:27:00 (GMT)
commitae470af63cec4c9822994319cb09d41e98ea353d (patch)
treef24b0633b09a6565e682531b985753f8a1038cbe
version 0.1.0.0HEAD0.1.0.0master
-rw-r--r--AwsMfaCredentials/Effects/AWS.hs19
-rw-r--r--AwsMfaCredentials/Effects/PasswordPrompt.hs17
-rw-r--r--AwsMfaCredentials/Effects/Wait.hs10
-rw-r--r--AwsMfaCredentials/Interpreters/AWS.hs34
-rw-r--r--AwsMfaCredentials/Interpreters/CredentialsWriter.hs58
-rw-r--r--AwsMfaCredentials/Interpreters/PasswordPrompt.hs58
-rw-r--r--AwsMfaCredentials/Interpreters/Wait.hs25
-rw-r--r--AwsMfaCredentials/MainLoop.hs55
-rw-r--r--ChangeLog.md5
-rw-r--r--LICENSE202
-rw-r--r--Main.hs123
-rw-r--r--README.md43
-rw-r--r--Setup.hs2
-rw-r--r--aws-mfa-credentials.cabal50
14 files changed, 701 insertions, 0 deletions
diff --git a/AwsMfaCredentials/Effects/AWS.hs b/AwsMfaCredentials/Effects/AWS.hs
new file mode 100644
index 0000000..1786655
--- /dev/null
+++ b/AwsMfaCredentials/Effects/AWS.hs
@@ -0,0 +1,19 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+module AwsMfaCredentials.Effects.AWS where
+
+import Control.Monad.Freer (Eff, Member, send)
+import Network.AWS.STS.GetSessionToken (GetSessionToken)
+import Network.AWS.STS.Types (Credentials)
+
+-- | AWS-calling effects
+data AWS a where
+ -- | Get a temporary session token
+ GetSessionToken :: GetSessionToken
+ -> AWS Credentials
+
+-- | Get a temporary session token
+getSessionToken :: Member AWS r
+ => GetSessionToken
+ -> Eff r Credentials
+getSessionToken = send . GetSessionToken
diff --git a/AwsMfaCredentials/Effects/PasswordPrompt.hs b/AwsMfaCredentials/Effects/PasswordPrompt.hs
new file mode 100644
index 0000000..4d34316
--- /dev/null
+++ b/AwsMfaCredentials/Effects/PasswordPrompt.hs
@@ -0,0 +1,17 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+module AwsMfaCredentials.Effects.PasswordPrompt where
+
+import Control.Monad.Freer (Eff, Member, send)
+
+-- | Password prompting effects
+data PasswordPrompt prompt ret a where
+ -- | Request a password with the given prompt
+ PasswordPrompt :: prompt
+ -> PasswordPrompt prompt ret ret
+
+-- | Request a password with the given prompt
+passwordPrompt :: Member (PasswordPrompt prompt ret) r
+ => prompt
+ -> Eff r ret
+passwordPrompt = send . PasswordPrompt
diff --git a/AwsMfaCredentials/Effects/Wait.hs b/AwsMfaCredentials/Effects/Wait.hs
new file mode 100644
index 0000000..a259d81
--- /dev/null
+++ b/AwsMfaCredentials/Effects/Wait.hs
@@ -0,0 +1,10 @@
+{-# LANGUAGE FlexibleContexts #-}
+module AwsMfaCredentials.Effects.Wait where
+
+import Control.Monad.Freer (Eff, Member, send)
+import Control.Monad.Freer.Writer (Writer(..))
+import Data.Time.Clock (UTCTime)
+
+-- | Wait until approximately a given time
+waitUntil :: Member (Writer UTCTime) r => UTCTime -> Eff r ()
+waitUntil = send . Writer
diff --git a/AwsMfaCredentials/Interpreters/AWS.hs b/AwsMfaCredentials/Interpreters/AWS.hs
new file mode 100644
index 0000000..01db688
--- /dev/null
+++ b/AwsMfaCredentials/Interpreters/AWS.hs
@@ -0,0 +1,34 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE TypeOperators #-}
+module AwsMfaCredentials.Interpreters.AWS where
+
+import AwsMfaCredentials.Effects.AWS (AWS(..))
+import Control.Lens.Operators ((^.))
+import Control.Monad.Freer (Eff, Member, handleRelay, send)
+import Control.Monad.Freer.Exception (Exc, throwError)
+import qualified Network.AWS as Amazonka
+import Network.AWS.STS.GetSessionToken
+ (gstrsCredentials, gstrsResponseStatus)
+
+newtype AWSResponseFailure = AWSResponseFailure Int
+
+-- | Run an AWS effect in the Amazonka AWS monad.
+runInAWSMonad :: forall r a . ( Member Amazonka.AWS r
+ , Member (Exc AWSResponseFailure) r
+ )
+ => Eff (AWS ': r) a -> Eff r a
+runInAWSMonad = handleRelay pure bind
+ where
+ bind :: AWS x
+ -> (x -> Eff r a)
+ -> Eff r a
+ bind (GetSessionToken req) cont = do
+ res <- send @Amazonka.AWS . Amazonka.send $ req
+ case res ^. gstrsCredentials of
+ Just creds -> cont creds
+ Nothing ->
+ throwError . AWSResponseFailure $ res ^. gstrsResponseStatus
diff --git a/AwsMfaCredentials/Interpreters/CredentialsWriter.hs b/AwsMfaCredentials/Interpreters/CredentialsWriter.hs
new file mode 100644
index 0000000..a4b3b76
--- /dev/null
+++ b/AwsMfaCredentials/Interpreters/CredentialsWriter.hs
@@ -0,0 +1,58 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeOperators #-}
+module AwsMfaCredentials.Interpreters.CredentialsWriter where
+
+import Control.Lens.Operators ((^.))
+import Control.Monad.Freer (Eff, Member, handleRelay, send)
+import Control.Monad.Freer.Exception (Exc, throwError)
+import Control.Monad.Freer.Writer (Writer(..))
+import Data.Ini (Ini(..), readIniFile, writeIniFile)
+import qualified Data.HashMap.Strict as M
+import Data.Text (Text)
+import Network.AWS.Auth (credFile)
+import Network.AWS.STS.Types
+ (Credentials, cAccessKeyId, cSecretAccessKey, cSessionToken)
+import System.FileLock (SharedExclusive(..), withFileLock)
+import System.FilePath (replaceBaseName)
+
+newtype CredentialsFileParseError = CredentialsFileParseError String
+
+-- | Write out credentials to the AWS credentials file.
+--
+-- This is atomic if all accesses use our locking protocol.
+writeCredentials
+ :: forall r a . ( Member IO r
+ , Member (Exc CredentialsFileParseError) r
+ )
+ => Eff (Writer (Text, Credentials) ': r) a
+ -> Eff r a
+writeCredentials = handleRelay pure bind
+ where
+ bind :: Writer (Text, Credentials) x
+ -> (x -> Eff r a)
+ -> Eff r a
+ bind (Writer (profile, creds)) cont =
+ (send $ write profile creds) >>= \case
+ Left e -> throwError e
+ Right _ -> cont ()
+
+ write profile creds = do
+ file <- credFile
+ let lockFile = replaceBaseName ".credentials.lock" file
+ withFileLock lockFile Exclusive $ \_ -> do
+ readIniFile file >>= \case
+ Left s -> return . Left $ CredentialsFileParseError s
+ Right (Ini ini) -> do
+ writeIniFile file . Ini $ M.insert profile (toSection creds) ini
+ return $ Right ()
+
+ toSection creds =
+ M.fromList [ ("aws_access_key_id", creds ^. cAccessKeyId)
+ , ("aws_secret_access_key", creds ^. cSecretAccessKey)
+ , ("aws_session_token", creds ^. cSessionToken)
+ ]
diff --git a/AwsMfaCredentials/Interpreters/PasswordPrompt.hs b/AwsMfaCredentials/Interpreters/PasswordPrompt.hs
new file mode 100644
index 0000000..602e777
--- /dev/null
+++ b/AwsMfaCredentials/Interpreters/PasswordPrompt.hs
@@ -0,0 +1,58 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeOperators #-}
+module AwsMfaCredentials.Interpreters.PasswordPrompt
+ (RunAskPassFailure(..), runWithAskPass) where
+
+import AwsMfaCredentials.Effects.PasswordPrompt (PasswordPrompt(..))
+import Control.Lens.Operators ((<&>))
+import Control.Monad.Freer (Eff, Member, handleRelay, send)
+import Control.Monad.Freer.Exception (Exc, throwError)
+import Data.Text (Text, strip)
+import qualified Data.Text.IO as T
+import System.Exit (ExitCode(..))
+import System.Process
+ ( proc, withCreateProcess, waitForProcess
+ , std_in, std_out, StdStream(..)
+ )
+import System.Timeout (timeout)
+
+data RunAskPassFailure = RunAskPassTimeout
+ | RunAskPassFailure !ExitCode
+
+runAskPassWithTimeout :: String -> IO (Either RunAskPassFailure Text)
+runAskPassWithTimeout prompt =
+ timeout (120 * 10 ^ (6 :: Int)) run <&> \case
+ Just (Left code) -> Left $ RunAskPassFailure code
+ Just (Right pass) -> Right pass
+ Nothing -> Left RunAskPassTimeout
+ where
+ procSpec =
+ (proc "ssh-askpass" [ prompt ]) { std_in = NoStream
+ , std_out = CreatePipe
+ }
+
+ run = withCreateProcess procSpec $ \_ (Just out) _ p -> do
+ pass <- T.hGetContents out
+ waitForProcess p <&> \case
+ ExitSuccess -> Right $ strip pass
+ e -> Left e
+
+-- | Run the PasswordPrompt effect using ssh-askpass.
+runWithAskPass :: forall r a . ( Member IO r
+ , Member (Exc RunAskPassFailure) r
+ )
+ => Eff (PasswordPrompt String Text ': r) a
+ -> Eff r a
+runWithAskPass = handleRelay pure bind
+ where
+ bind :: PasswordPrompt String Text x
+ -> (x -> Eff r a)
+ -> Eff r a
+ bind (PasswordPrompt prompt) cont =
+ (send $ runAskPassWithTimeout prompt) >>= \case
+ Left err -> throwError err
+ Right pass -> cont pass
diff --git a/AwsMfaCredentials/Interpreters/Wait.hs b/AwsMfaCredentials/Interpreters/Wait.hs
new file mode 100644
index 0000000..d75c7d5
--- /dev/null
+++ b/AwsMfaCredentials/Interpreters/Wait.hs
@@ -0,0 +1,25 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeOperators #-}
+module AwsMfaCredentials.Interpreters.Wait where
+
+import Control.Concurrent (threadDelay)
+import Control.Monad.Freer (Eff, Member, runNat)
+import Control.Monad.Freer.Writer (Writer(..))
+import Data.Time
+ (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime)
+
+runWait :: forall r a . (Member IO r)
+ => Eff ((Writer UTCTime) ': r) a
+ -> Eff r a
+runWait = runNat waitUntil
+ where
+ waitUntil :: Writer UTCTime x -> IO x
+ waitUntil (Writer time) =
+ getCurrentTime >>=
+ threadDelay . nominalDiffTimeToMicroseconds . diffUTCTime time
+
+ nominalDiffTimeToMicroseconds :: NominalDiffTime -> Int
+ nominalDiffTimeToMicroseconds = floor . (* 10 ^ (6 :: Integer))
diff --git a/AwsMfaCredentials/MainLoop.hs b/AwsMfaCredentials/MainLoop.hs
new file mode 100644
index 0000000..20d36e0
--- /dev/null
+++ b/AwsMfaCredentials/MainLoop.hs
@@ -0,0 +1,55 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+module AwsMfaCredentials.MainLoop where
+
+import AwsMfaCredentials.Effects.AWS (AWS, getSessionToken)
+import AwsMfaCredentials.Effects.PasswordPrompt
+ (PasswordPrompt, passwordPrompt)
+import AwsMfaCredentials.Effects.Wait (waitUntil)
+import Control.Lens.Operators ((&), (?~), (.~), (^.))
+import Control.Monad.Freer (Eff, Member)
+import Control.Monad.Freer.Writer (Writer, tell)
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Time.Clock (UTCTime, addUTCTime)
+import Network.AWS.STS.Types (Credentials, cExpiration)
+import qualified Network.AWS.STS.GetSessionToken as STS
+import Numeric.Natural (Natural)
+
+-- | Command line options
+data Opts = Opts
+ { -- | The MFA device ID
+ serialNumber :: Text
+ , -- | The duration, in seconds, that the credentials should remain valid
+ duration :: Maybe Natural
+ , -- | The profile whose credentials we're managing
+ profile :: Text
+ , -- | The amount of time, in seconds, before credential expiry to refresh
+ refreshLeadTime :: Natural
+ }
+
+-- | The business logic of the aws-mfa-credentials main loop
+mainLoopBody :: forall r . ( Member (PasswordPrompt String Text) r
+ , Member AWS r
+ , Member (Writer (Text, Credentials)) r
+ , Member (Writer UTCTime) r
+ ) => Opts -> Eff r ()
+mainLoopBody (Opts {..}) = do
+ mfa <- passwordPrompt . T.unpack $ T.concat
+ [ "Enter MFA token for "
+ , profile
+ , " from device "
+ , serialNumber
+ ]
+ let req = STS.getSessionToken & STS.gstTokenCode ?~ mfa
+ & STS.gstDurationSeconds .~ duration
+ & STS.gstSerialNumber ?~ serialNumber
+ creds <- getSessionToken req
+ tell (mfaProfile, creds)
+ waitUntil . addUTCTime refreshLeadTime' $ creds ^. cExpiration
+ where
+ mfaProfile = T.append profile "-mfa"
+
+ refreshLeadTime' = 0 - fromIntegral refreshLeadTime
diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
index 0000000..9cd3491
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,5 @@
+# Revision history for aws-mfa-credentials
+
+## 0.1.0.0 -- 2017-05-19
+
+* Initial release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..9533850
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2017 Takt Inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/Main.hs b/Main.hs
new file mode 100644
index 0000000..774e02c
--- /dev/null
+++ b/Main.hs
@@ -0,0 +1,123 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE TypeOperators #-}
+module Main where
+
+import AwsMfaCredentials.MainLoop (Opts(..), mainLoopBody)
+import qualified AwsMfaCredentials.Effects.AWS as E
+import AwsMfaCredentials.Effects.PasswordPrompt (PasswordPrompt)
+import AwsMfaCredentials.Interpreters.AWS
+ (AWSResponseFailure(..), runInAWSMonad)
+import AwsMfaCredentials.Interpreters.CredentialsWriter
+ (CredentialsFileParseError(..), writeCredentials)
+import AwsMfaCredentials.Interpreters.PasswordPrompt
+ (RunAskPassFailure(..), runWithAskPass)
+import AwsMfaCredentials.Interpreters.Wait (runWait)
+import Control.Monad.Freer (Eff, Member, runM, runNat, send)
+import Control.Monad.Freer.Exception (Exc, runError)
+import Control.Monad.Freer.Writer (Writer)
+import Control.Monad.IO.Class (liftIO)
+import Data.Semigroup ((<>))
+import Data.Text (Text)
+import Data.Time (UTCTime)
+import Network.AWS (AWS, newEnv, runAWS, Credentials(..), runResourceT)
+import Network.AWS.Auth (credFile)
+import qualified Network.AWS.STS.Types as STS
+import Options.Applicative
+import Options.Applicative.Text (textOption)
+import System.IO (hPutStrLn, stderr)
+
+-- | Parser for command line options
+optsParser :: Parser Opts
+optsParser = Opts
+ <$> textOption
+ ( long "mfa-serial-number"
+ <> metavar "SERIAL_NUMBER"
+ <> help "The ID of the MFA device to authenticate with"
+ )
+ <*> (optional $ option auto
+ ( long "duration"
+ <> metavar "DURATION_SECONDS"
+ <> help "The lifetime of the credentials (uses AWS default if unset)"
+ )
+ )
+ <*> textOption
+ ( long "profile"
+ <> metavar "PROFILE"
+ <> help "The name of the profile whose credentials we're managing"
+ <> value "default"
+ <> showDefault
+ )
+ <*> option auto
+ ( long "refresh-lead-time"
+ <> metavar "LEAD_SECONDS"
+ <> help "The amount of time before credential expiry to refresh the credentials"
+ <> value 300
+ <> showDefault
+ )
+
+-- | Full command line parser with usage string.
+optsParserInfo :: ParserInfo Opts
+optsParserInfo = info (optsParser <**> helper)
+ ( fullDesc
+ <> progDesc "Keep your AWS credentials file up to date with MFA-carrying temporary credentials"
+ )
+
+handleError :: forall r a . (Member IO r)
+ => (a -> String)
+ -> Eff (Exc a ': r) Bool
+ -> Eff r Bool
+handleError showE e = runError e >>= \case
+ Left err -> do
+ send . hPutStrLn stderr $ showE err
+ return True
+ Right b -> return b
+
+mainLoop :: Opts -> AWS ()
+mainLoop opts = do
+ (interpret $ mainLoopBody opts) >>= \case
+ False -> mainLoop opts
+ True -> return ()
+ where
+ interpret :: Eff '[ Writer UTCTime
+ , Writer (Text, STS.Credentials)
+ , E.AWS
+ , PasswordPrompt String Text
+ , Exc CredentialsFileParseError
+ , Exc AWSResponseFailure
+ , Exc RunAskPassFailure
+ , IO
+ , AWS
+ ] () -> AWS Bool
+ interpret =
+ runM
+ . runNat @AWS liftIO
+ . handleError runAskPassFailure
+ . handleError awsResponseFailure
+ . handleError credentialsFileParseError
+ . (False <$)
+ . runWithAskPass
+ . runInAWSMonad
+ . writeCredentials
+ . runWait
+
+ runAskPassFailure RunAskPassTimeout =
+ "Timed out waiting for MFA token"
+ runAskPassFailure (RunAskPassFailure _) =
+ "User cancelled token input"
+
+ awsResponseFailure (AWSResponseFailure i) =
+ "Requesting temporary credentials from AWS failed with HTTP error code " ++ show i
+
+ credentialsFileParseError (CredentialsFileParseError msg) =
+ "Error parsing the AWS credentials file: " ++ msg
+
+main :: IO ()
+main = do
+ opts <- execParser optsParserInfo
+ env <- credFile >>= newEnv . FromFile (profile opts)
+ runResourceT . runAWS env $ mainLoop opts
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b950bda
--- /dev/null
+++ b/README.md
@@ -0,0 +1,43 @@
+aws-mfa-credentials
+====================
+
+Keep your AWS credentials file up to date with MFA-carrying temporary credentials
+
+Usage
+------
+
+`aws-mfa-credentials --mfa-serial-number SERIAL_NUMBER`
+
+**See [Locking protcol](#locking-protocol) for important information about avoiding data loss!**
+
+From the [API reference for GetSessionToken][GetSessionToken]:
+
+> SerialNumber: The identification number of the MFA device that is associated with the IAM user who is making the GetSessionToken call. Specify this value if the IAM user has a policy that requires MFA authentication. The value is either the serial number for a hardware device (such as GAHT12345678) or an Amazon Resource Name (ARN) for a virtual device (such as arn:aws:iam::123456789012:mfa/user). You can find the device for an IAM user by going to the AWS Management Console and viewing the user's security credentials.
+
+Optional flags:
+ * `--duration DURATION_SECONDS` The lifetime of the credentials. Defaults to AWS default (43200 at the time of this writing).
+ * `--profile PROFILE` The name of the profile whose credentials we're managing. Defaults to 'default'.
+ * `--refresh-lead-time LEAD_SECONDS` The amount of time before credential expiry to refresh the credentials. Defaults to 300.
+
+Environment variables:
+ * `PATH`: A program named `ssh-askpass`, accepting a prompt as an argument and outputting a password to stdout, must be available on the path.
+ * `HOME`: The home directory containing the `.aws/credentials` file
+
+Operation
+-----------
+
+`aws-mfa-credentials` will request an MFA code from the user using `ssh-askpass`, request temporary credentials from Amazon, and update the credentials file with the temporary credentials.
+
+The new credentials are placed into a profile named `PROFILE-mfa`, e.g. when the `--profile` flag is not given they are placed in `default-mfa`. Use the `AWS_PROFILE` environment variable or other profile-specifying mechanisms to use the credentials.
+
+`aws-mfa-credentials` exits on any error, you may want to run it in a retry loop (with backoff).
+
+Locking protocol
+------------------
+
+To allow safe usage of multiple instances of `aws-mfa-credentials` in parallel, a locking protocol is used for the credentials file: An exclusive fcntl lock is held on the file `$HOME/.aws/.credentials.lock` when writing (and thus data read before the lock is obtained must be rechecked within the lock).
+
+Unfortunately, the AWS cli and other tools do not respect this protocol. Therefore, if you are modifying the credentials file in any way besides `aws-mfa-credentials`, you should either
+stop any existing instances of `aws-mfa-credentials` or obtain the lock yourself. **Credential loss may occur if this is not followed!**
+
+[GetSessionToken]: http://docs.aws.amazon.com/STS/latest/APIReference/API_GetSessionToken.html
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
index 0000000..9a994af
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/aws-mfa-credentials.cabal b/aws-mfa-credentials.cabal
new file mode 100644
index 0000000..b06a37f
--- /dev/null
+++ b/aws-mfa-credentials.cabal
@@ -0,0 +1,50 @@
+name: aws-mfa-credentials
+version: 0.1.0.0
+synopsis: Keep your AWS credentials file up to date with MFA-carrying credentials
+description:
+ As described in http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_configure-api-require.html,
+ some IAM policies may require API calls to be authenticated with MFA for additional security. This service
+ tracks the lifetime of the temporary credentials used to authenticate with MFA, prompting the user for a new
+ MFA code as the old credentials are about to expire and updating the AWS credentials file with the new credentials.
+homepage: https://github.com/TaktInc/aws-mfa-credentials
+license: Apache-2.0
+license-file: LICENSE
+author: Shea Levy
+maintainer: shea.levy@takt.com
+copyright: Copyright 2017 Takt Inc.
+category: Config
+build-type: Simple
+extra-source-files: ChangeLog.md, README.md
+cabal-version: >=1.10
+
+source-repository head
+ type: git
+ location: git://github.com/TaktInc/aws-mfa-credentials.git
+
+executable aws-mfa-credentials
+ main-is: Main.hs
+ other-modules: AwsMfaCredentials.Effects.AWS,
+ AwsMfaCredentials.Effects.PasswordPrompt,
+ AwsMfaCredentials.Effects.Wait,
+ AwsMfaCredentials.Interpreters.AWS,
+ AwsMfaCredentials.Interpreters.CredentialsWriter,
+ AwsMfaCredentials.Interpreters.PasswordPrompt,
+ AwsMfaCredentials.Interpreters.Wait,
+ AwsMfaCredentials.MainLoop
+ build-depends: base >=4.9 && <4.10,
+ optparse-applicative >=0.13.1 && <0.14,
+ optparse-text >=0.1.1 && <0.2,
+ amazonka-core >=1.4.5 && <1.5,
+ amazonka >=1.4.5 && <1.5,
+ amazonka-sts >=1.4.5 && <1.5,
+ freer-effects >=0.3.0 && <0.4,
+ text >=1.2.2 && <1.3,
+ lens >=4.15.1 && <4.16,
+ time >=1.6.0 && <1.7,
+ process >=1.4.3 && <1.5,
+ exceptions >=0.8.3 && <0.9,
+ ini >=0.3.5 && <0.4,
+ unordered-containers >=0.2.8 && <0.3,
+ filelock >=0.1.0 && <0.2,
+ filepath >=1.4.1 && <1.5
+ default-language: Haskell2010