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

Support for SSO credentials #756

Closed
pbrisbin opened this issue Feb 8, 2022 · 5 comments · Fixed by #757
Closed

Support for SSO credentials #756

pbrisbin opened this issue Feb 8, 2022 · 5 comments · Fixed by #757

Comments

@pbrisbin
Copy link
Contributor

pbrisbin commented Feb 8, 2022

👋 We've begun adopting AWS SSO at work, which has made it challenging to continue using amazonka. In an effort to test the 2.0 rc, I've got some local code that adds support for it. If I'm going in the right direction, I'd like to open a PR.

Context

For those that don't know, it works like this:

  1. Add some sso_ bits to ~/.aws/config (account-id and role-name are most important for us here)
  2. Call aws sso login -- authorize in the browser
  3. This writes a JWT (and other metadata) into ~/.aws/sso/cache/{uuid}.json
  4. Use aws or any other client lib normally
  5. Clients discovering Auth should look for the JWT + the sso_ bits of config, and use sso get-role-credentials to get ephemeral Access Keys

Step 5 is further described here, and is basically what my code does.

Code

I was pleasantly surprised that I could build on top of amazonka and get this functional, here is the important bit isolated from the glue required to do it externally. I tried to organize it in the style of the rest of Amazonka.Auth.

-- | Data present in @~/.aws/sso/cache/x.json@
data SsoCache = SsoCache
  { accessToken :: Text
  , region :: Region
  }
  deriving stock Generic
  deriving anyclass FromJSON

-- | Data present in @~/.aws/config@
data SsoConfig = SsoConfig
  { sso_account_id :: Text
  , sso_role_name :: Text
  }

fromSsoCache
  :: (MonadIO m, Foldable withAuth) => Env' withAuth -> m (Auth, Maybe Region)
fromSsoCache env = do
  let
    getCredentials = do
      SsoCache {..} <- readSsoCache
      SsoConfig {..} <- readSsoConfig

      let
        getRoleCredentials =
          SSO.newGetRoleCredentials sso_role_name sso_account_id accessToken

      let env' = env { _envRegion = region }
      eResponse <- runResourceT $ retryRequest env' getRoleCredentials
      clientResponse <- either Exception.throwIO pure eResponse

      let
        mCreds = do
          rc <-
            Client.responseBody clientResponse
              ^. SSO.getRoleCredentialsResponse_roleCredentials
          roleCredentialsToAuthEnv rc

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

  liftIO $ do
    auth <- fetchAuthInBackground getCredentials
    reg <- lookupRegion

    pure (auth, reg)

roleCredentialsToAuthEnv :: SSO.RoleCredentials -> Maybe AuthEnv
roleCredentialsToAuthEnv rc =
  AuthEnv
    <$> (AccessKey . encodeUtf8 <$> SSO.accessKeyId rc)
    <*> (overSensitive (SecretKey . encodeUtf8) <$> SSO.secretAccessKey rc)
    <*> pure (overSensitive (SessionToken . encodeUtf8) <$> SSO.sessionToken rc)
    <*> pure (expirationToExpires <$> SSO.expiration rc)

overSensitive :: (a -> b) -> Sensitive a -> Sensitive b
overSensitive f = Sensitive . f . view _Sensitive

expirationToExpires :: Integer -> ISO8601
expirationToExpires = Time . posixSecondsToUTCTime . fromInteger

readSsoCache :: IO SsoCache
readSsoCache = do
  home <- Directory.getHomeDirectory
  paths <- globDir1 glob home
  caches <- traverse decodeFileStrict paths
  let mCache = listToMaybe $ catMaybes caches
  maybe (throwMissing home) pure mCache
 where
  throwMissing home =
    Exception.throwIO $ MissingFileError $ home <> "/" <> decompile glob
  glob = ".aws/sso/cache/*.json"

readSsoConfig :: IO SsoConfig
readSsoConfig = do
  mConf <- runMaybeT $ do
    conf <- confFile
    ini <- MaybeT $ readIni conf
    MaybeT
      $ liftA2 SsoConfig
      <$> lookupIni conf "sso_account_id" ini
      <*> lookupIni conf "sso_role_name" ini

  case mConf of
    Nothing ->
      Exception.throwIO $ InvalidFileError "No sso_ configuration present"
    Just conf -> pure conf

lookupRegion :: MonadIO m => m (Maybe Region)
lookupRegion = runMaybeT $ asum
  [ readRegionEnv "AWS_REGION"
  , readRegionEnv "AWS_DEFAULT_REGION"
  , readRegionINI
  ]
 where
  readRegionEnv k =
    MaybeT $ liftIO $ fmap (Region' . pack) <$> Environment.lookupEnv k

  readRegionINI = do
    conf <- confFile
    ini <- MaybeT $ readIni conf
    MaybeT $ lookupIni conf confRegion ini

readIni :: MonadIO m => FilePath -> m (Maybe INI.Ini)
readIni path = {- omitted -}

lookupIni
  :: (MonadIO m, FromText a)
  => FilePath -- ^ Path, for error message
  -> Text
  -> INI.Ini
  -> m (Maybe a)
lookupIni path key ini = {- omitted -}

I believe this should drop-in to the catching stanza of Discover after fromEnv and before fromFilePath.

Open Questions / Warts

Having to glob for ~/.aws/sso/cache/*.json makes me uncomfortable. Does anyone know a way to avoid it?

Handling Region is tricky: the Region you SSO with may not be the Region you want to interact with using the SSO credentials. Therefore, it's important that SsoCache{region} is used for the getRoleCredentials call, but we still need to "discover" the actual _envRegion to use. Therefore, I've basically re-implemented a lot of fromFilePath, hopefully in a way that we can DRY it back up in my PR -- is this a good approach or am I missing an easier way?

I'm a little fuzzy on the fetchAuthInBackground. The SSO JWT has an expiry, and the credentials retrieved by get-role-credentials has an expiry. I implemented the refresh loop on the latter, which I imagine would expire first and, if so, be refreshable provided the JWT has not expired. If the JWT has expired, it requires a human aws sso login again, so I don't know that auto-refreshing on that is useful. Is that all reasonable?

fromSsoCache can throw MissingFileError, which we definitely want to ignore since no ~/.aws/sso/cache file is an indication we should not use this method when in Discover mode. However, it also throws InvalidFileError for two different scenarios: no sso_ configuration bits present and a fatal error reading Region from ~/.aws/config. The former could be another reason so skip, the latter though should probably remain fatal on principle. However, I'm inclined to mask both of these since two of them should be masked and the third one, if masked, would fail again the same way later (I imagine). Am I missing any better way to handle that? The current catching stanza reads so nicely, but there is no example of catching two types of errors like I'm introducing.

@endgame
Copy link
Collaborator

endgame commented Feb 8, 2022

I'd love for you to open a PR for this - I don't have the capacity right now to dig through how SSO works to do it myself, but I have enough to review and give advice.

I just (finally) merged #746 , which was a much-needed do-over of the credential chain. The major change is that each auth method is a separate function of type Env' withAuth -> m Env, or Env -> m Env in the case of fromAssumedRole. When you PR, please note any new methods you add in the changelog's table.

You should now be able to stand up a new Amazonka.Auth.SSO module and put most of your code into that. You will probably also want to extend the Amazonka.Auth.ConfigFile module to detect profiles which do SSO. You probably want a function like Amazonka.Auth.SSO.fromSSOToken (or maybe ..fromSSOTokenFile?) to perform the Amazonka version of aws sso get-role-credentials.

Question: Do I understand you correctly, that we aren't expected to handle initial SSO login at all? That will always be done by the aws CLI?

Question: Does sso:GetRoleCredentials ever successfully return without roleCredentials? If not, see #739 for how to fix the generated bindings. If you do this, please do it in a separate PR so that I can regenerate services before you build your SSO fix on top of it.


Responses to your questions:

Having to glob for ~/.aws/sso/cache/*.json makes me uncomfortable. Does anyone know a way to avoid it?

I don't - you might have to rummage around in an SDK or the CLI source to work out exactly what's going on. The AWS examples show very little useful information in the JSON file, sadly. Since AWS docs say that it's possible to sign into multiple accounts at once, there might be multiple such files - I think you just have to try each in turn using options read from the selected profile (region etc) until you find one that works. Note also that we can't rely on looking up the home directory on windows - have a look at how we search for config files on latest main, where we have to test whether os is "mingw32" (yes, even on 64bit).

Handling Region is tricky: the Region you SSO with may not be the Region you want to interact with using the SSO credentials. Therefore, it's important that SsoCache{region} is used for the getRoleCredentials call, but we still need to "discover" the actual _envRegion to use. Therefore, I've basically re-implemented a lot of fromFilePath, hopefully in a way that we can DRY it back up in my PR -- is this a good approach or am I missing an easier way?

I agree with the plan - getRoleCredentials using whatever region is set in config, then overwrite with environment variables. You might find the stuff in Amazonka.Auth.ConfigFile (which just hit main) will do this for you already - IIRC env-var-based region selection is hacked on specifically in there.

I'm a little fuzzy on the fetchAuthInBackground. The SSO JWT has an expiry, and the credentials retrieved by get-role-credentials has an expiry. I implemented the refresh loop on the latter [...] Is that all reasonable?

This sounds right to me, as we can't easily kick the caller back to the browser, and it sounds consistent with documented CLI behvaiour.

Exception handling

I think this might be okay under the new runCredentialChain function, but I actually don't think you need to touch that because most of your changes that affect the credential chain will do so indirectly, by supporting new settings in the config file.

@pbrisbin
Copy link
Contributor Author

pbrisbin commented Feb 9, 2022

Awesome, and thanks for those docs links -- it confirms some things I had figured out by trial & error. I opened #757 as a Draft. I just finished it so I'll take a look over it again with fresh eyes tomorrow. Format, polish, etc.

Do I understand you correctly, that we aren't expected to handle initial SSO login at all? That will always be done by the aws CLI?

That is my understanding, based on all the documentation and how we've always used it. I'm sure boto3 (for example) could have an ssoLogin() that triggers the browser, but I see no reason for amazonka to have such a thing. I also learned that this all only works through named profiles (not default) as aws sso configure forces you to make one. That simplifies a little, but not much, for us.

Does sso:GetRoleCredentials ever successfully return without roleCredentials? If not...

That I don't know; I can't imagine why it would successfully return no credentials. For what it's worth, when my SSO JWT expired before testing my PR, I got an unauthorized _ServiceError.

I think you just have to try each in turn using options read from the selected profile (region etc) until you find one that works

I think it's not quite so bad. As far as I can tell, there is always exactly one JWT in a single {uuid}.json file. I can sso login from profile A and then again for profile B and there is still only one JWT, and I can aws --profile A this and aws --profile B that and they both work.

It makes sense because aws sso list-acccounts and list-account-roles are both using the single authorized JWT to show any Accounts (and Roles) you have access to. SSO seems to somehow be operating at a level above Account. Wild.

You might find the stuff in Amazonka.Auth.ConfigFile (which just hit main) will do this for you already - IIRC env-var-based region selection is hacked on specifically in there.

It was close! I was able to export functions to do 80% of what I needed, which I'm happy with for now.

@endgame endgame linked a pull request Feb 9, 2022 that will close this issue
@endgame
Copy link
Collaborator

endgame commented Feb 9, 2022

I also learned that this all only works through named profiles (not default) as aws sso configure forces you to make one. That simplifies a little, but not much, for us.

That doesn't seem to be completely true; according to these docs (search "if you specify default" and look for the blue note), you can name the profile default.


That documentation also says this:

You can create multiple AWS SSO enabled named profiles that each point to a different AWS account or role. You can also use the aws sso login command on more than one profile at a time. If any of them share the same AWS SSO user account, you must log in to that AWS SSO user account only once and then they all share a single set of AWS SSO cached credentials.

I think you're right that this means AWS SSO user accounts are above normal AWS accounts, but I think you can sign into SSO multiple times. I found this snippet inside botocore, which makes me think it's one SSO signin per start_url?

https://github.com/boto/botocore/blob/c02f3561f56085b8a3f98501d25b9857b916c10e/botocore/utils.py#L2590-L2610

(Re: Maybe in the sso:GetRoleCredentials response)

For what it's worth, when my SSO JWT expired before testing my PR, I got an unauthorized _ServiceError.

Yeah, I think it could be correct to suppress that Maybe in the response. If you PR the generator's config files per #739 I will regenerate service bindings before SSO gets merged.

@pbrisbin
Copy link
Contributor Author

You're right about finding the file,

% grep start_url ~/.aws/config
sso_start_url = https://rensso.awsapps.com/start
% ls ~/.aws/sso/cache
05550f54b45317d3e2f26ef32828d84422f231cd.json
% python
>>> import hashlib
>>> start_url = "https://rensso.awsapps.com/start"
>>> hashlib.sha1(start_url.encode('utf-8')).hexdigest()
'05550f54b45317d3e2f26ef32828d84422f231cd'

By chance is there a preferred way to get a SHA1 in this project?

@endgame
Copy link
Collaborator

endgame commented Feb 10, 2022

I see you've already found the hash functions in Amazonka.Crypto, but I'll mention it here anyway in case someone reads this later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants