Search code examples
rubyamazon-web-servicessdksingle-sign-onaws-cli

Can AWS SSO tokens be refreshed (by doing a browser login) automatically?


I have a Ruby application using the AWS SDK Ruby v3, and recent I've added support for using SSO profiles instead of static "key ID + secret" configuration.

The new setup works well for a while, until the token "expires" and I start getting Aws::Errors::InvalidSSOToken exceptions, at which point the user needs to manually run the CLI's aws sso login to get a browser login screen.

I would have liked to skip the manual AWS CLI running step - if the SDK can directly do the aws sso login step (with the correct profile).

I can probably do exec with the correct arguments - but I would like to do it "the SDK way".


Solution

  • Beware: terrible hacks ahead!

    This is my currently complete (unless I find bugs and/or people suggest changes) and working implementation of Ruby code that renews the SSO token without using the AWS CLI, and in a way that is compatible with the AWS Ruby SDK version 3 (as of this writing). It doesn't auto-launches the user's browser, but that is a good thing because the Python browsers extension has a few bugs...

    Kudus to @tsal-troser that, at his own answer, pointed at the Python example that was used to create this sample code, and @2ps that wrote that Python code.

    require 'aws-sdk-core'
    
    class SSOConfig
        class Error < StandardError
        end
        
        def initialize profile = nil
            profile = 'default' if profile.to_s.empty?
            @profile = profile
            raise "No AWS config file found!" unless File.exists? "#{ENV['HOME']}/.aws/config"
            @config = File.readlines("#{ENV['HOME']}/.aws/config").collect(&:chomp)
            @profile_config = @config.select do |l| 
                (l =~ /^\[profile #{@profile}\]$/ .. l =~ /^(?!\[profile #{@profile}\])(\[|$)/) ? true : false
            end[...-1]
            raise Error, "Failed to find session for profile #{profile!}" if session.to_s.empty?
            @session_config = @config.select do |l| 
                (l =~ /^\[sso-session '?#{session}'?\]$/ .. l =~ /^(?!\[sso-session '?#{session}'?\])(\[|$)/) ? true : false
            end[...-1]
            raise Error, "Failed to find SSO session configuration!" if @session_config.empty?
        end
        
        def session
            return @sdk_session unless @sdk_session.nil?
            @sdk_session = @profile_config.select do |l|
                l =~ /^sso_session/
            end.collect do |l|
                (l.split /\s*=\s*/).last
            end.first
        end
        
        def start_url
            return @sdk_start_url unless @sdk_start_url.nil?
            @sdk_start_url = @session_config.select do |l|
                l =~ /^sso_start_url/
            end.collect do |l|
                (l.split /\s*=\s*/).last
            end.first
        end
        
        def account_id
            return @sdk_account_id unless @sdk_account_id.nil?
            @sdk_account_id = @profile_config.select do |l|
                l =~ /^sso_account_id/
            end.collect do |l|
                (l.split /\s*=\s*/).last
            end.first
        end
        
        def region
            'us-east-1' # can we use other regions?
        end
        
        def client_name
            'SSOClientName'
        end
    
        def try_renew_token
            puts "SSO token has expired! Trying to re-authorize SSO session..."
            oidc_client = Aws::SSOOIDC::Client.new(region: self.region)
            creds = oidc_client.register_client(client_name: self.client_name, client_type: 'public')
            auth = oidc_client.start_device_authorization(
                client_id: creds.client_id,
                client_secret: creds.client_secret,
                start_url: self.start_url)
            puts "Verification code: #{auth.user_code}"
            puts "Open your browser with this url: #{auth.verification_uri_complete}"
            puts "When complete, press ENTER here"
            STDIN.getch; puts ""
            token = oidc_client.create_token(
                grant_type: 'urn:ietf:params:oauth:grant-type:device_code', 
                device_code: auth.device_code,
                client_id: creds.client_id,
                client_secret: creds.client_secret)
            sso_client = Aws::SSO::Client.new(region: self.region)
            account_roles = sso_client.list_account_roles(
                access_token: token.access_token,
                account_id: self.account_id)
            role = account_roles.role_list.first
            role_creds = sso_client.get_role_credentials(
                role_name: role.role_name,
                account_id: role.account_id,
                access_token: token.access_token)
            # update the global config credentials, in case someone creates a default client
            # or actually uses the global credentials
            Aws.config[:credentials] = Class.new do
                include Aws::CredentialProvider
                def initialize creds
                    @creds = creds
                end
                def credentials
                    Aws::Credentials.new(@creds.access_key_id, @creds.secret_access_key, @creds.session_token)
                end
            end.new(role_creds.role_credentials)
            # hack SSOTokenProvider to update the token on disk
            # the real SSOTokenProvider will check that file first before trying to refresh
            Class.new(Aws::SSOTokenProvider) do
                def refresh
                # This is called by SSOTokenProvider.initialize and will fail with a stale token file
                # so just ignore it in our hack
                end
            end.new(sso_session: self.session, sso_region: self.region).send :update_token_cache, {
                'startUrl' => self.start_url,
                'region' => self.region,
                'accessToken' => token.access_token,
                'clientId' => creds.client_id,
                'clientSecret' => creds.client_secret,
                'expiresAt' => Time.at(Time.now.tv_sec + token.expires_in).utc,
                'registrationExpiresAt' => Time.at(Time.now.tv_sec + token.expires_in).utc,
                'refreshToken' => token.refresh_token,
            }
            true
        end
    end
    

    Usage: when catching an Aws::Errors::InvalidSSOToken, call SSOConfig.new("your-profile").try_renew_token. After the call returns successfully, Aws.config[:credentials] is loaded with a credential provider that will return the correct credentials, and also the token cache on disk has been updated - so existing clients should continue working by refreshing from the disk cache.

    For example:

    begin
      # try to do some AWS work
    rescue Aws::Errors::InvalidSSOToken => e
      retry if SSOConfig.new("your-profile").try_renew_token
      $stderr.puts "SSO token expired - cannot renew!"
    end
    
    Worries:
    1. Big hack: the SSO token cache logic is private to the SSOTokenProvider and there is no way to manipulate it directly (or get SSOTokenProvider to refresh it itself, once it has expired). So in order to update the disk cache, we both need to instantiate a purposefully broken SSOTokenProvider (otherwise it won't instantiate) and then call its private methods directly. Fun! 😜
    2. Small hack: I can't find a way to use a "real" CredentialProvider for Aws.config[:credentials], so we use an anonymous class.
    3. There is no error checking, except basic sanity checks that we can read the configuration - which throws instead of returning false.
    4. The new token lifetime seems awfully short - 28800 seconds. A login with the AWS CLI lasts for days. I think the problem is that the token we get from create_token doesn't have a refresh token so SSOTokenProvider can't refresh it automatically.