Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Auth.SSO #757

Merged
merged 5 commits into from
Feb 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,7 @@ dist*
/bazel-*

# Direnv
.envrc.local
.envrc.local

# Stack
.stack-work
endgame marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 4 additions & 0 deletions lib/amazonka-core/src/Amazonka/Crypto.hs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module Amazonka.Crypto
hmacSHA256,

-- * Hashing
hashSHA1,
hashSHA256,
hashMD5,
Hash.hash,
Expand Down Expand Up @@ -42,6 +43,9 @@ hmacSHA1 = HMAC.hmac
hmacSHA256 :: ByteArrayAccess a => Key -> a -> HMAC.HMAC Hash.SHA256
hmacSHA256 = HMAC.hmac

hashSHA1 :: ByteArrayAccess a => a -> Hash.Digest Hash.SHA1
hashSHA1 = Hash.hashWith Hash.SHA1

hashSHA256 :: ByteArrayAccess a => a -> Hash.Digest Hash.SHA256
hashSHA256 = Hash.hashWith Hash.SHA256

Expand Down
2 changes: 1 addition & 1 deletion lib/amazonka-core/src/Amazonka/Data/Sensitive.hs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import Amazonka.Prelude

-- | /Note/: read . show /= isomorphic
newtype Sensitive a = Sensitive {fromSensitive :: a}
deriving stock (Eq, Ord, Generic)
deriving stock (Eq, Ord, Generic, Functor)
deriving newtype
( IsString,
Semigroup,
Expand Down
3 changes: 3 additions & 0 deletions lib/amazonka/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Released: **?**, Compare: [2.0.0-rc1](https://github.com/brendanhay/amazonka/com
| `FromFile` | `fromFilePath` | `Text` (profile name), `FilePath` (credentials file), `FilePath` (config file). **Significantly improved** - now respects the `role_arn` setting in config files, alongside either `source_profile`, `credential_source`, or `web_identity_token_file`. |
| | `fromFileEnv` | None - read config files from their default location, and respects the `AWS_PROFILE` environment variable. |
| | `fromAssumedRole` | `Text` (role arn), `Text` (role session name). Assumes a role using `sts:AssumeRole`. |
| | `fromSSO` | `FilePath` (cached token file), `Region` (SSO region), `Text` (account id), `Text` (role name). Assumes a role using `sso:GetRoleCredentials` and the cached JWT created by `aws sso login`. |
| | `fromWebIdentity` | `FilePath` (web identity token file), `Text` (role arn), `Maybe Text` (role session name). Assumes a role using `sts:AssumeRoleWithWebIdentity`. |
| `FromWebIdentity` | `fromWebIdentityEnv` | None - reads `AWS_WEB_IDENTITY_TOKEN_FILE`, `AWS_ROLE_ARN`, and `AWS_ROLE_SESSION_NAME`. |
| | `fromContainer` | `Text` (absolute url to query the ECS Container Agent). |
Expand All @@ -39,6 +40,8 @@ Released: **?**, Compare: [2.0.0-rc1](https://github.com/brendanhay/amazonka/com
[\#724](https://github.com/brendanhay/amazonka/pull/724)
- `amazonka-dynamodb-streams`: Provide a sum type for `AttributeValue`
[\#724](https://github.com/brendanhay/amazonka/pull/724)
- `amazonka`: SSO authentication support (thanks @pbrisbin)
[\#757](https://github.com/brendanhay/amazonka/pull/757)

### Fixed

Expand Down
3 changes: 3 additions & 0 deletions lib/amazonka/amazonka.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ library
Amazonka.Auth.Exception
Amazonka.Auth.InstanceProfile
Amazonka.Auth.Keys
Amazonka.Auth.SSO
Amazonka.Auth.STS
Amazonka.EC2.Metadata
Amazonka.Env
Expand All @@ -95,7 +96,9 @@ library
Amazonka.Data, Amazonka.Types, Amazonka.Bytes, Amazonka.Endpoint, Amazonka.Crypto

build-depends:
, aeson ^>=1.5.0.0 || ^>=2.0.0.0
, amazonka-core ^>=2.0
, amazonka-sso ^>=2.0
, amazonka-sts ^>=2.0
, bytestring >=0.10.8
, conduit >=1.3
Expand Down
2 changes: 2 additions & 0 deletions lib/amazonka/src/Amazonka/Auth.hs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ module Amazonka.Auth
fromWebIdentityEnv,
fromDefaultInstanceProfile,
fromNamedInstanceProfile,
fromSSO,

-- ** Keys
AccessKey (..),
Expand All @@ -60,6 +61,7 @@ import Amazonka.Auth.Container (fromContainer, fromContainerEnv)
import Amazonka.Auth.Exception
import Amazonka.Auth.InstanceProfile (fromDefaultInstanceProfile, fromNamedInstanceProfile)
import Amazonka.Auth.Keys (fromKeys, fromKeysEnv, fromSession, fromTemporarySession)
import Amazonka.Auth.SSO (fromSSO)
import Amazonka.Auth.STS (fromAssumedRole, fromWebIdentity, fromWebIdentityEnv)
import Amazonka.EC2.Metadata
import Amazonka.Env (Env, EnvNoAuth, Env' (..))
Expand Down
36 changes: 27 additions & 9 deletions lib/amazonka/src/Amazonka/Auth/ConfigFile.hs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Amazonka.Auth.Container (fromContainerEnv)
import Amazonka.Auth.Exception
import Amazonka.Auth.InstanceProfile (fromDefaultInstanceProfile)
import Amazonka.Auth.Keys (fromKeysEnv)
import Amazonka.Auth.SSO (fromSSO, relativeCachedTokenFile)
import Amazonka.Auth.STS (fromAssumedRole, fromWebIdentity)
import Amazonka.Data
import Amazonka.Env (Env, Env' (..), lookupRegion)
Expand Down Expand Up @@ -138,6 +139,11 @@ fromFilePath profile credentialsFile configFile env = liftIO $ do
fromAssumedRole roleArn "amazonka-assumed-role" sourceEnv
AssumeRoleWithWebIdentity roleArn mRoleSessionName tokenFile ->
fromWebIdentity tokenFile roleArn mRoleSessionName env
AssumeRoleViaSSO startUrl ssoRegion accountId roleName -> do
cachedTokenFile <-
liftIO $
configPathRelative =<< relativeCachedTokenFile startUrl
fromSSO cachedTokenFile ssoRegion accountId roleName env

-- Once we have the env from the profile, apply the region
-- if we parsed one out.
Expand Down Expand Up @@ -172,7 +178,8 @@ parseConfigProfile profile = parseProfile <&> \p -> (p, parseRegion)
[ explicitKey,
assumeRoleFromProfile,
assumeRoleFromCredentialSource,
assumeRoleWithWebIdentity
assumeRoleWithWebIdentity,
assumeRoleViaSSO
]

parseRegion :: Maybe Region
Expand Down Expand Up @@ -213,6 +220,13 @@ parseConfigProfile profile = parseProfile <&> \p -> (p, parseRegion)
<*> Just (HashMap.lookup "role_session_name" profile)
<*> (Text.unpack <$> HashMap.lookup "web_identity_token_file" profile)

assumeRoleViaSSO =
AssumeRoleViaSSO
<$> HashMap.lookup "sso_start_url" profile
<*> (Region' <$> HashMap.lookup "sso_region" profile)
<*> HashMap.lookup "sso_account_id" profile
<*> HashMap.lookup "sso_role_name" profile

data ConfigProfile
= -- | Recognizes @aws_access_key_id@, @aws_secret_access_key@, and
-- optionally @aws_session_token@.
Expand All @@ -224,6 +238,9 @@ data ConfigProfile
| -- | Recognizes @role_arn@, @role_session_name@, and
-- @web_identity_token_file@.
AssumeRoleWithWebIdentity Text (Maybe Text) FilePath
| -- | Recognizes @sso_start_url@, @sso_region@, @sso_account_id@, and
-- @sso_role_name@.
pbrisbin marked this conversation as resolved.
Show resolved Hide resolved
AssumeRoleViaSSO Text Region Text Text
deriving stock (Eq, Show, Generic)

data CredentialSource = Environment | Ec2InstanceMetadata | EcsContainer
Expand All @@ -249,12 +266,13 @@ fromFileEnv env = liftIO $ do
conf <- configPathRelative "/.aws/config"

fromFilePath (maybe "default" Text.pack mProfile) cred conf env

configPathRelative :: String -> IO String
configPathRelative p = handling_ _IOException err dir
where
configPathRelative p = handling_ _IOException err dir
where
err = Exception.throwIO $ MissingFileError ("$HOME" ++ p)
dir = case os of
"mingw32" ->
Environment.lookupEnv "USERPROFILE"
>>= maybe (Exception.throwIO $ MissingFileError "%USERPROFILE%") pure
_ -> Directory.getHomeDirectory <&> (++ p)
err = Exception.throwIO $ MissingFileError ("$HOME" ++ p)
dir = case os of
"mingw32" ->
Environment.lookupEnv "USERPROFILE"
>>= maybe (Exception.throwIO $ MissingFileError "%USERPROFILE%") pure
_ -> Directory.getHomeDirectory <&> (++ p)
131 changes: 131 additions & 0 deletions lib/amazonka/src/Amazonka/Auth/SSO.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
-- |
-- Module : Amazonka.Auth.SSO
-- Copyright : (c) 2013-2021 Brendan Hay
-- License : Mozilla Public License, v. 2.0.
-- Maintainer : Brendan Hay <[email protected]>
-- Stability : provisional
-- Portability : non-portable (GHC extensions)
module Amazonka.Auth.SSO where

import Amazonka.Auth.Background (fetchAuthInBackground)
import Amazonka.Auth.Exception
import qualified Amazonka.Crypto as Crypto
import Amazonka.Data.Sensitive
import Amazonka.Data.Time (Time (..))
import Amazonka.Env (Env, Env' (..))
import Amazonka.HTTP (retryRequest)
import Amazonka.Lens ((^.))
import Amazonka.Prelude
import Amazonka.SSO.GetRoleCredentials as SSO
import qualified Amazonka.SSO.Types as SSO (RoleCredentials (..))
import Amazonka.Types
import qualified Control.Exception as Exception
import Control.Exception.Lens (handling_, _IOException)
import Control.Monad.Trans.Resource (runResourceT)
import Data.Aeson (FromJSON, decodeFileStrict)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import qualified Network.HTTP.Client as Client

data CachedAccessToken = CachedAccessToken
{ startUrl :: Text,
region :: Region,
accessToken :: Sensitive Text,
expiresAt :: UTCTime
}
deriving stock (Show, Eq, Generic)
deriving anyclass (FromJSON)

-- | Assume a role using an SSO Token.
--
-- The user must have previously called @aws sso login@, and pass in the path to
-- the cached token file, along with SSO region, account ID and role name.
-- ('Amazonka.Auth.ConfigFile.fromFilePath' understands the @sso_@ variables
-- used by the official AWS CLI and will call 'fromSSO' for you.) This function
-- uses 'fetchAuthInBackground' to refresh the credentials as long as the token
-- in the @sso/cache@ file is not expired. When it has, the user will need to
-- @aws sso login@ again.
--
-- <https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html>
fromSSO ::
pbrisbin marked this conversation as resolved.
Show resolved Hide resolved
(MonadIO m, Foldable withAuth) =>
FilePath ->
Region ->
-- | Account ID
Text ->
-- | Role Name
Text ->
Env' withAuth ->
m Env
fromSSO cachedTokenFile ssoRegion accountId roleName env = do
auth <- liftIO $ fetchAuthInBackground getCredentials
pure $ env {envAuth = Identity auth}
pbrisbin marked this conversation as resolved.
Show resolved Hide resolved
where
getCredentials = do
CachedAccessToken {..} <- readCachedAccessToken cachedTokenFile

-- The Region you SSO through may differ from the Region you intend to
-- interact with after. The former is handled here, the latter is taken
-- care of later, in ConfigFile.
let ssoEnv = env {envRegion = ssoRegion}

resp <-
runResourceT $
sendUnsigned ssoEnv $
SSO.newGetRoleCredentials roleName accountId (fromSensitive accessToken)

let mCreds = do
rc <- resp ^. SSO.getRoleCredentialsResponse_roleCredentials
roleCredentialsToAuthEnv rc
endgame marked this conversation as resolved.
Show resolved Hide resolved

case mCreds of
Nothing -> fail "sso:GetRoleWithCredentials returned no credentials."
Just c -> pure c

sendUnsigned ::
( MonadResource m,
AWSRequest a,
Foldable withAuth
) =>
Env' withAuth ->
a ->
m (AWSResponse a)
sendUnsigned env req = do
eResponse <- retryRequest env req
either (liftIO . Exception.throwIO) (pure . Client.responseBody) eResponse

-- | Return the cached token file for a given @sso_start_url@
--
-- Matches
-- [botocore](https://github.com/boto/botocore/blob/c02f3561f56085b8a3f98501d25b9857b916c10e/botocore/utils.py#L2596-L2597),
-- so that we find tokens produced by @aws sso login@.
relativeCachedTokenFile :: MonadIO m => Text -> m FilePath
relativeCachedTokenFile startUrl = do
let sha1 = show . Crypto.hashSHA1 $ Text.encodeUtf8 startUrl
pure $ "/.aws/sso/cache/" <> sha1 <> ".json"

readCachedAccessToken :: MonadIO m => FilePath -> m CachedAccessToken
readCachedAccessToken p = liftIO $
handling_ _IOException err $ do
mCache <- decodeFileStrict p
maybe err pure mCache
where
err =
Exception.throwIO $
InvalidFileError $
mconcat
[ "Unable to read SSO cache. ",
Text.pack p,
" is missing or invalid."
]

roleCredentialsToAuthEnv :: SSO.RoleCredentials -> Maybe AuthEnv
roleCredentialsToAuthEnv rc =
AuthEnv
<$> (AccessKey . Text.encodeUtf8 <$> SSO.accessKeyId rc)
<*> (fmap (SecretKey . Text.encodeUtf8) <$> SSO.secretAccessKey rc)
<*> pure (fmap (SessionToken . Text.encodeUtf8) <$> SSO.sessionToken rc)
<*> pure (expirationToExpires <$> SSO.expiration rc)
where
expirationToExpires = Time . posixSecondsToUTCTime . fromInteger